NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

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 }