update.nix (8981B)
1 # modules/features/pentest/update.nix — `pentest-update`: move the pins in both _pkgs files (default.nix + assets.nix) 2 # forward, deliberately. 3 # 4 # pentest-update report what is newer upstream (changes nothing) 5 # pentest-update --apply rewrite the revs and hashes in place 6 # 7 # It never commits, and it never runs during a rebuild: pinning is a decision, 8 # not a side effect. After --apply, read `jj diff` and rebuild — if an upstream 9 # renamed an asset, payloads.nix's `pick` fails the build with the name it 10 # could not find, which is the point of pinning in the first place. 11 { ... }: 12 { 13 flake.nixosModules.pentest-update = 14 { config, pkgs, lib, ... }: 15 let 16 on = config.daemon.pentest.enable; 17 18 script = pkgs.writeText "pentest-update.py" '' 19 """Re-pin a pentest pin file (_pkgs/default.nix or _pkgs/assets.nix).""" 20 import json, os, re, subprocess, sys, urllib.error, urllib.request 21 22 APPLY = "--apply" in sys.argv 23 ROOT = os.environ.get("NIXDAEMON", os.path.expanduser("~/NixDaemon")) 24 # The file to re-pin, chosen by the wrapper so one script serves both 25 # _pkgs/default.nix (derivations) and _pkgs/assets.nix (the release 26 # table). Both use the same `url =`/`hash =` and fetchFromGitHub shapes. 27 PKGS = os.environ.get("PENTEST_PKGS_FILE", 28 os.path.join(ROOT, "modules/features/pentest/_pkgs/default.nix")) 29 print("── " + os.path.relpath(PKGS, ROOT)) 30 31 def api(path): 32 req = urllib.request.Request( 33 "https://api.github.com" + path, 34 headers={"Accept": "application/vnd.github+json", 35 "User-Agent": "pentest-update"}, 36 ) 37 tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") 38 if tok: 39 req.add_header("Authorization", "Bearer " + tok) 40 with urllib.request.urlopen(req, timeout=30) as r: 41 return json.load(r) 42 43 def prefetch_unpacked(url): 44 h = subprocess.run(["nix-prefetch-url", "--unpack", "--type", "sha256", url], 45 capture_output=True, text=True).stdout.strip().splitlines() 46 if not h: 47 return None 48 return subprocess.run(["nix", "hash", "convert", "--hash-algo", "sha256", 49 "--to", "sri", h[-1]], 50 capture_output=True, text=True).stdout.strip() 51 52 def prefetch_file(url): 53 out = subprocess.run(["nix", "store", "prefetch-file", "--json", url], 54 capture_output=True, text=True).stdout 55 try: 56 return json.loads(out)["hash"] 57 except Exception: 58 return None 59 60 if not os.path.exists(PKGS): 61 sys.exit("pentest-update: cannot find " + PKGS + " (set $NIXDAEMON)") 62 63 text = open(PKGS).read() 64 original = text 65 changes, problems = [], [] 66 67 # fetchFromGitHub pins: owner / repo / rev / hash, in that order. 68 gh = re.compile( 69 r'owner\s*=\s*"(?P<owner>[^"]+)";\s*\n\s*' 70 r'repo\s*=\s*"(?P<repo>[^"]+)";\s*\n\s*' 71 r'rev\s*=\s*"(?P<rev>[0-9a-f]{40})";\s*\n\s*' 72 r'hash\s*=\s*"(?P<hash>sha256-[^"]+)";') 73 74 for m in list(gh.finditer(text)): 75 slug = m.group("owner") + "/" + m.group("repo") 76 try: 77 head = api("/repos/" + slug + "/commits/HEAD")["sha"] 78 except (urllib.error.URLError, urllib.error.HTTPError, KeyError) as e: 79 problems.append(slug + ": " + str(e)) 80 continue 81 if head == m.group("rev"): 82 print(" up to date " + slug + " @ " + head[:12]) 83 continue 84 print(" NEWER " + slug + ": " + m.group("rev")[:12] + " -> " + head[:12]) 85 if not APPLY: 86 continue 87 new_hash = prefetch_unpacked( 88 "https://github.com/" + slug + "/archive/" + head + ".tar.gz") 89 if not new_hash: 90 problems.append(slug + ": prefetch failed, left alone") 91 continue 92 block = m.group(0) 93 text = text.replace( 94 block, 95 block.replace(m.group("rev"), head).replace(m.group("hash"), new_hash), 96 1) 97 changes.append(slug) 98 99 # Release-asset pins: .../releases/download/<tag>/<asset> 100 rel = re.compile( 101 r'https://github\.com/(?P<slug>[^/]+/[^/]+)/releases/download/' 102 r'(?P<tag>[^/"]+)/(?P<asset>[^"/]+)') 103 seen = set() 104 # A derivation that builds its URL with Nix interpolation leaves the 105 # literal text of that interpolation where the tag should be, e.g. 106 # a dollar-brace version reference. Comparing THAT against the real 107 # latest tag reported three pins as newer when all three were current — 108 # a tool that cries "newer" about nothing is worse than useless. So 109 # resolve it from the nearest preceding `version = "…";`. 110 def resolve_tag(text, tag, upto): 111 if "''${" not in tag: 112 return tag 113 vers = re.findall(r'version\s*=\s*"([^"]+)"', text[:upto]) 114 if not vers: 115 return None 116 return re.sub(r'\$\{version\}', vers[-1], tag) 117 118 for m in rel.finditer(original): 119 slug = m.group("slug") 120 tag = resolve_tag(original, m.group("tag"), m.start()) 121 if tag is None: 122 problems.append(slug + ": pinned tag is interpolated and could " 123 "not be resolved; check it by hand") 124 continue 125 if (slug, tag) in seen: 126 continue 127 seen.add((slug, tag)) 128 try: 129 latest = api("/repos/" + slug + "/releases/latest")["tag_name"] 130 except (urllib.error.URLError, urllib.error.HTTPError, KeyError) as e: 131 problems.append(slug + ": " + str(e)) 132 continue 133 if latest == tag: 134 print(" up to date " + slug + " release " + tag) 135 continue 136 print(" NEWER " + slug + " release: " + tag + " -> " + latest) 137 if not APPLY: 138 continue 139 # Re-point every asset of this slug/tag, hashing each new file. 140 ok = True 141 for a in {mm.group("asset") for mm in rel.finditer(original) 142 if mm.group("slug") == slug and mm.group("tag") == tag}: 143 url = ("https://github.com/" + slug + "/releases/download/" 144 + latest + "/" + a) 145 h = prefetch_file(url) 146 if not h: 147 problems.append(slug + "/" + a + ": not in " + latest + ", left alone") 148 ok = False 149 continue 150 old_url = ("https://github.com/" + slug + "/releases/download/" 151 + tag + "/" + a) 152 old_block = re.search( 153 re.escape(old_url) + r'";\s*\n\s*hash\s*=\s*"(sha256-[^"]+)"', text) 154 if old_block: 155 text = text.replace(old_block.group(1), h, 1) 156 text = text.replace(old_url, url) 157 if ok: 158 changes.append(slug + " (release " + latest + ")") 159 # The version string often appears separately; flag it. 160 problems.append(slug + ": check the `version =` string still says " 161 + latest) 162 163 if problems: 164 print("\nneeds your attention:") 165 for p in problems: 166 print(" " + p) 167 168 if not APPLY: 169 print("\nnothing written. Re-run with --apply to re-pin.") 170 sys.exit(0) 171 172 if text == original: 173 print("\nno changes to write.") 174 sys.exit(0) 175 176 open(PKGS, "w").write(text) 177 print("\nrewrote " + PKGS + " (" + ", ".join(changes) + ")") 178 print("Review it, rebuild, then commit yourself — this never commits.") 179 ''; 180 181 pentest-update = pkgs.writeShellScriptBin "pentest-update" '' 182 set -uo pipefail 183 PATH=${lib.makeBinPath [ pkgs.nix pkgs.nix-prefetch-scripts pkgs.coreutils ]}:$PATH 184 ROOT="''${NIXDAEMON:-$HOME/NixDaemon}" 185 rc=0 186 # Both pin files, one script. assets.nix is the big release table; 187 # default.nix is the derivations (SharpCollection, mimikatz, scripts). 188 for f in \ 189 "$ROOT/modules/features/pentest/_pkgs/default.nix" \ 190 "$ROOT/modules/features/pentest/_pkgs/assets.nix"; do 191 PENTEST_PKGS_FILE="$f" ${pkgs.python3}/bin/python3 ${script} "$@" || rc=$? 192 done 193 exit $rc 194 ''; 195 in 196 { 197 config = lib.mkIf on { 198 environment.systemPackages = [ pentest-update ]; 199 }; 200 }; 201 }