NixDaemon

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

2026-10-08-pentest-toolkit.md (30567B)


      1 # Pentest Toolkit Implementation Plan
      2 
      3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
      4 
      5 **Goal:** Build a Kali-equivalent, modular, toggleable offensive toolkit into the NixDaemon flake, with an HTB workflow layer (`htbtarget` / `htbvpn` / `htbtime`) and a multi-arch payload staging tree.
      6 
      7 **Architecture:** Dendritic flake-parts. Each tool category declares one `flake.pentestPackages.<cat>` function (`pkgs -> [package]`); a `mkCategory` helper in `modules/features/pentest/_sets.nix` derives the gated `flake.nixosModules.pentest-<cat>`, a `perSystem.devShells.pentest-<cat>`, and a `perSystem.checks.pentest-<cat>` smoke test from that single list. Tools install to `environment.systemPackages` so they work under `sudo`. Privileged user commands follow the repo's existing `fan-ec` / `fan` split: a fixed store script plus a scoped NOPASSWD sudo rule.
      8 
      9 **Tech Stack:** Nix (flakes, flake-parts, import-tree), NixOS 26.11, home-manager, `pkgsCross.mingwW64` for Windows binaries, zsh `precmd` for cross-terminal state, systemd template units for VPN.
     10 
     11 **Spec:** `docs/superpowers/specs/2026-10-08-pentest-toolkit-design.md`
     12 
     13 ## Global Constraints
     14 
     15 - Every `modules/**/*.nix` is a flake-parts module; any path containing `/_` is **not** auto-imported (use for plain helper/derivation files).
     16 - Modules reference each other **by name** through `self.nixosModules.*` / `self.homeModules.*`, never by path.
     17 - Tool packages go to `environment.systemPackages`, never `home.packages` (spec D1 — `sudo nmap -sS` must work).
     18 - Use native NixOS options where they exist: `programs.wireshark.enable`, `programs.proxychains.enable` (spec D5).
     19 - Master switch `daemon.pentest.enable`, default `false`. Default-on categories: `recon ad bloodhound web pivot crack shells wordlists payloads python gui`. Default-off: `dfir reversing wireless cloud osint mobile`.
     20 - System is `x86_64-linux`; the only host is `nixosConfigurations.nixos`.
     21 - Every file opens with a `# modules/path/file.nix — <purpose>` comment block in the existing house style (see `modules/home/fan.nix`).
     22 - Commit messages end with `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
     23 - Verification command for any module task: `nix build .#checks.x86_64-linux.pentest-<cat>` then `nix flake check`.
     24 
     25 ## Review Focus
     26 
     27 Five failure modes the spec implies but does not pin to a test. Each has a test added to the owning task.
     28 
     29 1. **impacket suffix-free aliases shadowing real commands** (Task 3). `impacket` ships `split.py`, `ping.py`, `net.py`, `reg.py`, `services.py`, `attrib.py`, `smbclient.py`. Stripping `.py` would make `split -l 1000 hosts` invoke an SMB tool, and `net`/`smbclient` collide with samba once Task 5 lands. Collisions must be detected against the assembled closure at build time and exposed as `impacket-<name>` instead.
     30 2. **`payload-serve` binding every interface when `tun0` is absent** (Task 12). Must fail closed rather than silently publishing the payload tree to the local network.
     31 3. **`htbtime off` with a missing or corrupt state file** (Task 14). Must restore the NixOS default (NTP enabled) rather than leaving the clock unmanaged — including when `off` is run without a preceding `htbtime`.
     32 4. **`htbtarget` writing unvalidated input to `/etc/hosts`** (Task 15). Anything that is not an IPv4/IPv6 address or a DNS label must be rejected before the privileged helper runs, so no arbitrary line can land in `/etc/hosts`.
     33 5. **`htbvpn up` while another profile is active** (Task 13). Must stop the running unit first; two concurrent `tun` devices must be impossible.
     34 
     35 ---
     36 
     37 ### Task 1: Category harness, options tree, and `core`
     38 
     39 **Files:**
     40 - Create: `modules/features/pentest/_sets.nix` (helper, not auto-imported)
     41 - Create: `modules/features/pentest/options.nix`
     42 - Create: `modules/features/pentest/core.nix`
     43 - Modify: `modules/hosts/laptop/configuration.nix` (import `pentest-options` + `pentest-core`, set `daemon.pentest.enable = true`)
     44 
     45 **Interfaces:**
     46 - Consumes: nothing.
     47 - Produces:
     48   - `mkCategory :: { name : String, description : String, packages : pkgs -> [package], expectedBins : [String], extraModule ? : module, default ? : Bool } -> flakePartsModule` — returns a module declaring `flake.pentestPackages.<name>`, `flake.nixosModules.pentest-<name>`, `perSystem.checks.pentest-<name>`, `perSystem.devShells.pentest-<name>`.
     49   - `daemon.pentest.enable`, `daemon.pentest.<name>.enable`, `daemon.pentest.payloads.windowsArches`, `daemon.pentest.htb.vpnDir`, `daemon.pentest.htb.promptTarget`.
     50   - Env: `$PAYLOADS`, `$WORDLISTS` (set by `core.nix`, values filled in by Tasks 2 and 12).
     51 
     52 - [ ] **Step 1: Write the failing check**
     53 
     54 In `_sets.nix`, `mkCategory` must produce a check derivation that fails when any expected binary is absent. Write `core.nix` declaring the category with its binaries, so the check exists before the module works:
     55 
     56 ```nix
     57 # core.nix declares, via mkCategory:
     58 #   name = "core";
     59 #   packages = pkgs: with pkgs; [ ncat socat samba krb5 openldap sshpass rlwrap ];
     60 #   expectedBins = [ "ncat" "socat" "smbclient" "kinit" "ldapsearch" "sshpass" ];
     61 ```
     62 
     63 The check runs, for each name in `expectedBins`, `command -v <name>` inside a derivation whose `buildInputs` are the category's packages, and fails naming every binary it could not find.
     64 
     65 - [ ] **Step 2: Run the check to verify it fails**
     66 
     67 Run: `nix build .#checks.x86_64-linux.pentest-core -L`
     68 Expected: FAIL — `error: flake output attribute 'checks.x86_64-linux.pentest-core' does not exist` (before `_sets.nix` is written), then a missing-binary failure naming `smbclient` (samba's binary is not in `pkgs.samba`'s default output path until the correct attribute is used).
     69 
     70 - [ ] **Step 3: Implement `mkCategory` in `modules/features/pentest/_sets.nix`**
     71 
     72 A function taking the attrset above and returning a flake-parts module. The gated nixosModule body is `config = lib.mkIf (cfg.enable && cfg.${name}.enable) { environment.systemPackages = packages pkgs; }` merged with `extraModule`. Resolve `smbclient` from `pkgs.samba` (it is not a top-level attribute — confirmed absent).
     73 
     74 - [ ] **Step 4: Implement `options.nix` and `core.nix`**
     75 
     76 `options.nix` declares `flake.nixosModules.pentest-options` with the option tree from Global Constraints, plus an assertion that no `daemon.pentest.<cat>.enable` is true while `daemon.pentest.enable` is false. `core.nix` uses `mkCategory` and additionally sets `environment.sessionVariables.PAYLOADS` and `WORDLISTS` to `lib.mkDefault` placeholders that Tasks 2 and 12 override.
     77 
     78 - [ ] **Step 5: Wire into the host**
     79 
     80 Add `pentest-options` and `pentest-core` to the `imports` list in `modules/hosts/laptop/configuration.nix`, and set `daemon.pentest.enable = true;` beside the existing `daemon.desktop` block.
     81 
     82 - [ ] **Step 6: Verify the check passes and the system builds**
     83 
     84 Run: `nix build .#checks.x86_64-linux.pentest-core -L && nix build .#nixosConfigurations.nixos.config.system.build.toplevel`
     85 Expected: both succeed.
     86 
     87 - [ ] **Step 7: Commit**
     88 
     89 ```bash
     90 git add modules/features/pentest/ modules/hosts/laptop/configuration.nix
     91 git commit -m "feat(pentest): category harness, option tree, and core module"
     92 ```
     93 
     94 ---
     95 
     96 ### Task 2: Wordlists
     97 
     98 **Files:**
     99 - Create: `modules/features/pentest/wordlists.nix`
    100 
    101 **Interfaces:**
    102 - Consumes: `mkCategory` (Task 1).
    103 - Produces: `$WORDLISTS` pointing at a directory containing `rockyou.txt` **decompressed**; `flake.pentestPackages.wordlists`.
    104 
    105 - [ ] **Step 1: Write the failing check**
    106 
    107 The check asserts the decompressed file exists and is plain text:
    108 
    109 ```nix
    110 expectedPaths = [ "$WORDLISTS/rockyou.txt" "$WORDLISTS/seclists" ];
    111 # plus: test "$(head -c2 $WORDLISTS/rockyou.txt)" != "$(printf '\037\213')"  # not gzip magic
    112 ```
    113 
    114 - [ ] **Step 2: Run the check to verify it fails**
    115 
    116 Run: `nix build .#checks.x86_64-linux.pentest-wordlists -L`
    117 Expected: FAIL — attribute does not exist.
    118 
    119 - [ ] **Step 3: Implement `wordlists.nix`**
    120 
    121 `packages = pkgs: [ seclists wordlists exploitdb ]`. Add a derivation that `gunzip`s `${pkgs.rockyou}`'s gzipped payload into `$out/rockyou.txt` and symlinks `${pkgs.seclists}/share/seclists` to `$out/seclists`. Set `environment.sessionVariables.WORDLISTS` to that derivation with `lib.mkForce`.
    122 
    123 - [ ] **Step 4: Verify**
    124 
    125 Run: `nix build .#checks.x86_64-linux.pentest-wordlists -L`
    126 Expected: PASS.
    127 
    128 - [ ] **Step 5: Commit**
    129 
    130 ```bash
    131 git add modules/features/pentest/wordlists.nix
    132 git commit -m "feat(pentest): wordlists with decompressed rockyou and \$WORDLISTS"
    133 ```
    134 
    135 ---
    136 
    137 ### Task 3: Offensive Python environment and impacket aliases
    138 
    139 **Files:**
    140 - Create: `modules/features/pentest/python.nix`
    141 - Create: `modules/features/pentest/_aliases.nix` (helper)
    142 
    143 **Interfaces:**
    144 - Consumes: `mkCategory` (Task 1).
    145 - Produces:
    146   - `pentest-python` — a `python3.withPackages` interpreter with every offensive library importable.
    147   - `mkSuffixFreeAliases :: { package, reserved : [String], prefix : String } -> derivation` — symlinks `bin/foo.py` to `bin/foo`, **except** names in `reserved`, which become `bin/<prefix>-foo`.
    148   - `impacket` — a discovery command: no args lists all scripts via fzf with `--help` preview; `impacket <name>` execs that script.
    149 
    150 - [ ] **Step 1: Write the failing collision test**
    151 
    152 This is Review Focus #1. The check must prove that no alias shadows a command provided by any other enabled category, computed from the closure rather than hardcoded:
    153 
    154 ```nix
    155 # checks.pentest-python asserts:
    156 #  1. `pentest-python -c "import impacket, certipy, pypykatz, bloodyad, lsassy, scapy, pwn"` exits 0
    157 #  2. `secretsdump --help` and `GetUserSPNs --help` exit 0        (aliased)
    158 #  3. `command -v split` resolves into coreutils, NOT impacket    (collision avoided)
    159 #  4. `command -v ping`  resolves into iputils,  NOT impacket
    160 #  5. `impacket-split --help` and `impacket-net --help` exit 0    (prefixed instead)
    161 #  6. the alias derivation's build FAILS if a new upstream script
    162 #     collides with a reserved name that is not in the prefix list
    163 ```
    164 
    165 - [ ] **Step 2: Run the check to verify it fails**
    166 
    167 Run: `nix build .#checks.x86_64-linux.pentest-python -L`
    168 Expected: FAIL — attribute does not exist.
    169 
    170 - [ ] **Step 3: Implement `mkSuffixFreeAliases` in `_aliases.nix`**
    171 
    172 Glob `${package}/bin/*.py`. For each, strip `.py`; if the stripped name appears in `reserved`, emit `${prefix}-${name}` instead, otherwise emit the bare name. Build `reserved` at eval time from the binaries of `coreutils`, `iputils`, `samba`, `util-linux` and `systemd` so it tracks the real closure instead of a hand-maintained list. Fail the build listing any name that is reserved **and** absent from the emitted prefixed set.
    173 
    174 - [ ] **Step 4: Implement `python.nix`**
    175 
    176 `pentestPython = pkgs.python3.withPackages (ps: with ps; [ impacket certipy pywerview dploot masky bloodhound ldapdomaindump pypykatz bloodyad lsassy minikerberos aiowinreg ldap3 dnspython scapy pwntools pycryptodomex requests rich ])`, exposed as `pentest-python`. Per spec C2, if `withPackages` fails to resolve, fall back to listing the conflicting tools as standalone packages and keep the shared env for the libraries only — record which path was taken in the file's header comment.
    177 
    178 - [ ] **Step 5: Verify**
    179 
    180 Run: `nix build .#checks.x86_64-linux.pentest-python -L`
    181 Expected: PASS — including assertions 3 and 4, which prove `split` and `ping` still resolve to coreutils and iputils.
    182 
    183 - [ ] **Step 6: Commit**
    184 
    185 ```bash
    186 git add modules/features/pentest/python.nix modules/features/pentest/_aliases.nix
    187 git commit -m "feat(pentest): offensive python env, impacket aliases with collision guard"
    188 ```
    189 
    190 ---
    191 
    192 ### Task 4: `recon`
    193 
    194 **Files:** Create `modules/features/pentest/recon.nix`
    195 
    196 **Interfaces:** Consumes `mkCategory`. Produces `flake.pentestPackages.recon`.
    197 
    198 - [ ] **Step 1: Write the failing check** — `expectedBins = [ "nmap" "masscan" "rustscan" "naabu" "fscan" "nbtscan" "enum4linux-ng" "snmp-check" "onesixtyone" "dnsrecon" "subfinder" "amass" "httpx" "dnsx" "katana" "gowitness" "whatweb" ]`.
    199 - [ ] **Step 2: Run to verify it fails** — `nix build .#checks.x86_64-linux.pentest-recon -L`, expect attribute-missing.
    200 - [ ] **Step 3: Implement** — `packages` per spec C1 recon list, plus `thc-ipv6` and `eyewitness`. Note `snmpcheck`'s binary is `snmp-check`; resolve the real binary names rather than assuming they match attribute names.
    201 - [ ] **Step 4: Verify** — same command, expect PASS.
    202 - [ ] **Step 5: Commit** — `feat(pentest): recon and scanning module`
    203 
    204 ---
    205 
    206 ### Task 5: `ad`
    207 
    208 **Files:** Create `modules/features/pentest/ad.nix`
    209 
    210 **Interfaces:** Consumes `mkCategory`, Task 3's `pentest-python`. Produces `flake.pentestPackages.ad`.
    211 
    212 - [ ] **Step 1: Write the failing check** — `expectedBins = [ "nxc" "certipy" "bloodhound-python" "kerbrute" "responder" "mitm6" "coercer" "donpapi" "adidnsdump" "ldapdomaindump" "smbmap" "evil-winrm" "pypykatz" "bloodyAD" "lsassy" "dploot" "masky" "pywerview" "secretsdump" "ntlmrelayx" "GetUserSPNs" ]`. The last three come from Task 3's aliases and prove the two tasks compose.
    213 - [ ] **Step 2: Run to verify it fails.**
    214 - [ ] **Step 3: Implement** — spec C1 `ad` list. `netexec`'s binary is `nxc`; `bloodyad`'s is `bloodyAD`. Custom `krbrelayx` and `sprayhound` are deferred to Task 11 and must **not** appear in `expectedBins` yet.
    215 - [ ] **Step 4: Verify** — expect PASS, and additionally run `sudo -n true 2>/dev/null; sudo nmap -sS -p22 127.0.0.1` on the built system to prove spec D1 (root PATH).
    216 - [ ] **Step 5: Commit** — `feat(pentest): active directory module`
    217 
    218 ---
    219 
    220 ### Task 6: `web`
    221 
    222 **Files:** Create `modules/features/pentest/web.nix`
    223 
    224 - [ ] **Step 1: Write the failing check** — `expectedBins = [ "ffuf" "gobuster" "feroxbuster" "dirb" "nikto" "sqlmap" "commix" "wfuzz" "nuclei" "wpscan" "joomscan" "dalfox" "arjun" "jwt-cli" "jwt-hack" "mitmproxy" ]`. GUI tools (`burpsuite`, `zap`) belong to Task 10, not here.
    225 - [ ] **Step 2: Run to verify it fails.**
    226 - [ ] **Step 3: Implement** — spec C1 `web` list minus the GUI entries. Set `NUCLEI_TEMPLATES_DIR` to `${pkgs.nuclei-templates}` so `nuclei` does not try to write to `$HOME` on first run.
    227 - [ ] **Step 4: Verify** — expect PASS.
    228 - [ ] **Step 5: Commit** — `feat(pentest): web application module`
    229 
    230 ---
    231 
    232 ### Task 7: `pivot`, `crack`, `shells`
    233 
    234 Grouped: three identical-shape package lists over the Task 1 helper. The only non-trivial part is `programs.proxychains.enable`.
    235 
    236 **Files:** Create `modules/features/pentest/{pivot,crack,shells}.nix`
    237 
    238 - [ ] **Step 1: Write the three failing checks**
    239   - pivot: `[ "ligolo-proxy" "ligolo-agent" "chisel" "socat" "proxychains4" "sshuttle" "gost" "frpc" "iodine" "stunnel" "wg" "openvpn" ]`
    240   - crack: `[ "hashcat" "john" "hydra" "medusa" "crowbar" "crunch" "cewl" "hashid" "nth" ]`
    241   - shells: `[ "msfconsole" "msfvenom" "pwsh" "pwncat" "rlwrap" "updog" "miniserve" "xfreerdp" "upx" ]`
    242 - [ ] **Step 2: Run all three to verify they fail.**
    243 - [ ] **Step 3: Implement the three modules** — spec C1 lists. In `pivot.nix`'s `extraModule`, set `programs.proxychains.enable = true` with `proxyDNS = true` and a `socks5 127.0.0.1 1080` default, because `/etc/proxychains.conf` is unwritable on NixOS (spec D5). Verify `ligolo-ng`'s actual binary names and `name-that-hash`'s (`nth`) before asserting them.
    244 - [ ] **Step 4: Verify** — all three checks PASS.
    245 - [ ] **Step 5: Commit** — `feat(pentest): pivoting, cracking, and shell modules`
    246 
    247 ---
    248 
    249 ### Task 8: Optional categories (`dfir`, `reversing`, `wireless`, `cloud`, `osint`, `mobile`)
    250 
    251 Grouped: six package lists, all `default = false`.
    252 
    253 **Files:** Create `modules/features/pentest/{dfir,reversing,wireless,cloud,osint,mobile}.nix`
    254 
    255 - [ ] **Step 1: Write the six failing checks** — binaries per spec C1. Checks must build even when the category is disabled, since they test the package list, not the system closure.
    256 - [ ] **Step 2: Run to verify they fail.**
    257 - [ ] **Step 3: Implement the six modules** — `default = false` for each. `wireless.nix`'s `extraModule` sets `programs.wireshark.enable = true` with `package = pkgs.wireshark` and adds `daemonsec` to the `wireshark` group, because the package alone cannot capture without root (spec D5). `velociraptor` is deferred to Task 11.
    258 - [ ] **Step 4: Verify** — `nix flake check` passes with all six disabled.
    259 - [ ] **Step 5: Commit** — `feat(pentest): dfir, reversing, wireless, cloud, osint, mobile modules`
    260 
    261 ---
    262 
    263 ### Task 9: BloodHound CE with neo4j and postgresql
    264 
    265 Separate task: the only category needing stateful services, so a reviewer could reject this while accepting Task 5.
    266 
    267 **Files:** Create `modules/features/pentest/bloodhound.nix`
    268 
    269 **Interfaces:** Produces `flake.nixosModules.pentest-bloodhound`; a `bloodhound-up` / `bloodhound-down` pair so the databases are not running on every boot.
    270 
    271 - [ ] **Step 1: Write the failing test**
    272 
    273 A NixOS VM test (`pkgs.nixosTest`) asserting that with `daemon.pentest.bloodhound.enable = true` the `neo4j` and `postgresql` units reach active, and port 7474 answers. A VM test rather than a binary check, because the deliverable is running services.
    274 
    275 - [ ] **Step 2: Run to verify it fails** — `nix build .#checks.x86_64-linux.pentest-bloodhound-vm -L`, expect attribute-missing.
    276 - [ ] **Step 3: Implement** — `services.neo4j.enable` and `services.postgresql.enable` (with a `bloodhound` database and user) gated on the category, plus `bloodhound-ce`. Both services set `wantedBy = []` so they start only via `bloodhound-up`, which is a `writeShellScriptBin` running `systemctl start` through a scoped NOPASSWD rule as in `fan-cli.nix`.
    277 - [ ] **Step 4: Verify** — VM test PASSES.
    278 - [ ] **Step 5: Commit** — `feat(pentest): bloodhound-ce with neo4j and postgresql`
    279 
    280 ---
    281 
    282 ### Task 10: GUI tools and desktop entries
    283 
    284 **Files:** Create `modules/features/pentest/gui.nix`
    285 
    286 - [ ] **Step 1: Write the failing check** — `expectedBins = [ "burpsuite" "zap" "ghidra" "wireshark" "autopsy" "cutter" "sqlitebrowser" ]`, plus an assertion that each ships a `share/applications/*.desktop` file so they appear in the launcher like Kali's menu.
    287 - [ ] **Step 2: Run to verify it fails.**
    288 - [ ] **Step 3: Implement** — the GUI set; `allowUnfree` already holds for `burpsuite`. Gate on `daemon.pentest.gui.enable` (default true per D4). Do not duplicate `wireshark`'s native option here — depend on `wireless.nix`'s and assert in the check that enabling `gui` without `wireless` still yields a usable `wireshark` by setting `programs.wireshark.enable` in whichever module is enabled, using `lib.mkDefault` to avoid a conflict.
    289 - [ ] **Step 4: Verify** — check PASSES; `nix flake check` passes with both `gui` and `wireless` enabled (proving no option conflict).
    290 - [ ] **Step 5: Commit** — `feat(pentest): gui tools with desktop entries`
    291 
    292 ---
    293 
    294 ### Task 11: Missing-tool derivations
    295 
    296 **Files:**
    297 - Create: `modules/features/pentest/_pkgs/default.nix` (an attrset the categories consume)
    298 - Create: `modules/features/pentest/_pkgs/{krbrelayx,sprayhound,velociraptor,ropper,lse,peass,sharpcollection,potato,printer}.nix`
    299 - Modify: `ad.nix`, `dfir.nix`, `reversing.nix` to pull from `_pkgs` and extend their `expectedBins`
    300 
    301 **Interfaces:**
    302 - Produces: `pentestPkgs :: attrset` of derivations, consumed by Task 12's payload tree and by the category modules.
    303 
    304 - [ ] **Step 1: Write the failing check**
    305 
    306 `checks.pentest-pkgs` asserts each derivation builds and, for script collections, that a named file exists in the output (e.g. `${peass}/linpeas.sh`, `${sharpcollection}/NetFramework_4.7_Any/Rubeus.exe`). Prebuilt Windows artefacts must additionally report as PE: `file` output contains `PE32`.
    307 
    308 - [ ] **Step 2: Run to verify it fails.**
    309 - [ ] **Step 3: Obtain real hashes, then implement**
    310 
    311 Hashes cannot be guessed. For each source run `nix-prefetch-url --unpack <url>` (or `nurl <repo>`) and paste the result. Linux/Python tools (`krbrelayx`, `sprayhound`, `ropper`, `lse`, `velociraptor`) are `fetchFromGitHub` + `buildPythonApplication` or `writeShellApplication` wrappers. Prebuilt sets (`peass`, `sharpcollection`, the potato family, the printer family) are `fetchFromGitHub` / `fetchurl` of release assets installed verbatim — per spec D3, building .NET offline is out of scope.
    312 
    313 - [ ] **Step 4: Extend the consuming categories** — add `krbrelayx`/`sprayhound` to `ad.nix`, `velociraptor` to `dfir.nix`, `ropper` to `reversing.nix`, with their binaries appended to those `expectedBins`.
    314 - [ ] **Step 5: Verify** — `nix build .#checks.x86_64-linux.pentest-pkgs -L` PASSES, and the three amended category checks still pass.
    315 - [ ] **Step 6: Commit** — `feat(pentest): derivations for tools absent from nixpkgs`
    316 
    317 ---
    318 
    319 ### Task 12: Payload tree, cross-compiled binaries, and `payload-serve`
    320 
    321 **Files:**
    322 - Create: `modules/features/pentest/payloads.nix`
    323 - Create: `modules/features/pentest/_payloads.nix` (the tree-assembly function)
    324 
    325 **Interfaces:**
    326 - Consumes: `pentestPkgs` (Task 11).
    327 - Produces: `$PAYLOADS` (overriding Task 1's placeholder with `lib.mkForce`); `payload-serve` with flags `[port]`, `--smb`, `--list`.
    328 
    329 - [ ] **Step 1: Write the failing checks**
    330 
    331 Two assertions, the second being Review Focus #2:
    332 
    333 ```nix
    334 # 1. structure: every path in the spec C3 tree exists, and
    335 #      file $PAYLOADS/windows/amd64/creds/mimikatz-2.2.0.exe  contains "PE32+"
    336 #      file $PAYLOADS/linux/amd64/agents/ligolo-agent          contains "ELF 64-bit"
    337 #      file $PAYLOADS/linux/arm64/agents/ligolo-agent          contains "ARM aarch64"
    338 # 2. payload-serve refuses to start when no tun interface exists:
    339 #      ip link show tun0 absent  =>  exit 2, message naming htbvpn,
    340 #      and NOTHING listening on the requested port afterwards
    341 ```
    342 
    343 - [ ] **Step 2: Run to verify it fails.**
    344 - [ ] **Step 3: Implement the cross-builds**
    345 
    346 In `_payloads.nix`, build Windows artefacts as `pkgs.pkgsCross.mingwW64.<tool>` (verified available for `mimikatz`, `ligolo-ng`, `netexec`) and Linux arm64 as `pkgs.pkgsCross.aarch64-multiplatform.<tool>`. Assemble the spec C3 tree with `runCommand` + `lib.fileset`, naming versioned duplicates by version (`mimikatz-2.2.0.exe`) rather than letting them contend for one name.
    347 
    348 - [ ] **Step 4: Implement `payload-serve`**
    349 
    350 Resolve the serve address from the first `tun*` interface. If none exists, exit 2 naming `htbvpn up` — never fall back to `0.0.0.0`, which would publish the tree to the LAN. `--smb` runs impacket's `smbserver` bound to the same address; `--list` prints `tree $PAYLOADS`.
    351 
    352 - [ ] **Step 5: Verify** — both checks PASS. Confirm assertion 2 by running `payload-serve 8000` with no VPN up and checking `ss -tlnp` shows nothing new.
    353 - [ ] **Step 6: Commit** — `feat(pentest): multi-arch payload tree and payload-serve`
    354 
    355 ---
    356 
    357 ### Task 13: `htbvpn`
    358 
    359 **Files:**
    360 - Create: `modules/features/pentest/vpn.nix`
    361 
    362 **Interfaces:**
    363 - Produces: `htbvpn@.service` (systemd template); `htbvpn` with subcommands `list|up|down|status`; `htbip`.
    364 
    365 - [ ] **Step 1: Write the failing test**
    366 
    367 A `nixosTest` with a dummy OpenVPN profile, asserting the Review Focus #5 behaviour:
    368 
    369 ```
    370 1. htbvpn up profileA        => htbvpn@profileA active, tun0 has an address
    371 2. htbvpn up profileB        => profileA STOPPED first; exactly one tun device exists
    372 3. htbvpn down               => no htbvpn@* unit active, tun0 gone
    373 4. htbvpn up missingProfile  => exit 2, lists available profiles, no unit started
    374 ```
    375 
    376 - [ ] **Step 2: Run to verify it fails.**
    377 - [ ] **Step 3: Implement**
    378 
    379 `systemd.services."htbvpn@"` runs `openvpn --config ${vpnDir}/%i.ovpn` with `Restart=no`. `htbvpn up` resolves the profile, stops any active `htbvpn@*` instance, then starts the new one through a NOPASSWD sudo rule scoped to `systemctl {start,stop,restart} htbvpn@*` only — mirroring `fan-cli.nix`. Create `${vpnDir}` via `systemd.tmpfiles` and leave profiles un-managed (spec C5).
    380 
    381 - [ ] **Step 4: Verify** — the VM test PASSES, all four assertions.
    382 - [ ] **Step 5: Commit** — `feat(pentest): htbvpn via systemd template unit`
    383 
    384 ---
    385 
    386 ### Task 14: `htbtime`
    387 
    388 **Files:**
    389 - Create: `modules/features/pentest/time.nix`
    390 
    391 **Interfaces:**
    392 - Produces: `htb-time` (root helper, `environment.systemPackages` + scoped NOPASSWD rule); subcommands `<target>|off|status`.
    393 
    394 - [ ] **Step 1: Write the failing test**
    395 
    396 A `nixosTest` covering the round trip and Review Focus #3:
    397 
    398 ```
    399 1. record timedatectl output
    400 2. htb-time 10.0.0.1  => NTP disabled, timesyncd inactive, state file written
    401 3. htb-time off       => timedatectl output matches step 1 exactly
    402 4. rm the state file; htb-time off
    403      => NTP ENABLED (the NixOS default), exit 0, warning printed
    404         -- must never leave the clock unmanaged
    405 5. htb-time <unreachable>
    406      => exit nonzero, warning naming `htbtime off`, NTP still disabled
    407         (deliberate: spec C6 says do not silently half-apply)
    408 ```
    409 
    410 - [ ] **Step 2: Run to verify it fails.**
    411 - [ ] **Step 3: Implement**
    412 
    413 `ntpdate` comes from `${pkgs.ntp}/bin/ntpdate` — it is **not** a top-level package (confirmed). Record prior state to `/var/lib/htb-time/state`: whether `systemd-timesyncd` was active, `timedatectl show -p NTP`, and whether `chrony`/`ntpd` were running. Sync with `ntpdate -u <target>`, falling back to `${pkgs.chrony}/bin/chronyd -q` when the DC refuses. On `off` with no or unparseable state, default to `timedatectl set-ntp true` and say so. Print the resulting offset and warn that a large skew breaks TLS, so `nix` and `git` may fail.
    414 
    415 - [ ] **Step 4: Verify** — the VM test PASSES, all five assertions, especially 4.
    416 - [ ] **Step 5: Commit** — `feat(pentest): htbtime, conflict-aware clock skew control`
    417 
    418 ---
    419 
    420 ### Task 15: `htbtarget`, cross-terminal state, and engagement scaffolding
    421 
    422 **Files:**
    423 - Create: `modules/home/htb.nix`
    424 - Create: `modules/home/htb-shell.nix`
    425 - Create: `modules/features/pentest/hosts.nix` (the `/etc/hosts` root helper)
    426 - Modify: `modules/home/default.nix` (import `htb` and `htb-shell` by name)
    427 
    428 **Interfaces:**
    429 - Consumes: `htbvpn`/`htbip` (Task 13), `htbtime` (Task 14).
    430 - Produces: `htbtarget [IP [FQDN]] | clear`; exported `$TARGET`, `$RHOST`, `$IP`, `$BOX`; `htb new <box>`; `htb ls`; `htb-hosts` root helper.
    431 
    432 - [ ] **Step 1: Write the failing tests**
    433 
    434 Two parts. First the cross-terminal guarantee from spec C4:
    435 
    436 ```
    437 1. shell A: htbtarget 10.10.11.5
    438 2. shell B (already running, new prompt): $TARGET == 10.10.11.5
    439 ```
    440 
    441 Second, Review Focus #4 — validation before the privileged helper runs:
    442 
    443 ```
    444 3. htbtarget "10.10.11.5"                  => accepted
    445 4. htbtarget "not an ip"                   => exit 2, /etc/hosts unchanged
    446 5. htbtarget "10.10.11.5" $'x\n1.2.3.4 evil' => exit 2, /etc/hosts unchanged
    447 6. htbtarget 10.10.11.5 dc01.vintage.htb   => exactly one marked block in
    448                                               /etc/hosts; running twice does
    449                                               not duplicate it
    450 7. htbtarget clear; htbtime                => exit 2, message naming `htbtarget`
    451                                               as the command that sets a target
    452 ```
    453 
    454 - [ ] **Step 2: Run to verify they fail.**
    455 - [ ] **Step 3: Implement the state file and shell hook**
    456 
    457 State in `$XDG_STATE_HOME/htb/{target,name,vpn}`. In `htb-shell.nix`, add a zsh `precmd` function that re-reads those files and exports `$TARGET`, `$RHOST`, `$IP`, `$BOX`. Wire it through `programs.zsh.initContent` so it coexists with the dotfiles' ZDOTDIR tree (`modules/home/shell.nix`) rather than overwriting it. When `daemon.pentest.htb.promptTarget`, add a starship custom module showing `$TARGET`.
    458 
    459 - [ ] **Step 4: Implement validation and the `/etc/hosts` helper**
    460 
    461 Validate the IP with a strict IPv4/IPv6 match and the FQDN against `^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$` **before** invoking `htb-hosts`. `htb-hosts` rewrites only the region between `# BEGIN htb` and `# END htb` markers, so repeated calls replace rather than append. Scoped NOPASSWD rule as in `fan-cli.nix`.
    462 
    463 - [ ] **Step 5: Implement `htb new` and `htb ls`**
    464 
    465 `htb new <box>` creates `~/htb/<box>/{nmap,loot,creds,www,exploit}` plus `notes.md` from a template, sets the target when given one, and prints the path. `htb ls` lists `~/htb/*` newest first.
    466 
    467 - [ ] **Step 6: Verify** — all six assertions pass; confirm assertion 2 by hand in two live terminals.
    468 - [ ] **Step 7: Commit** — `feat(pentest): htbtarget cross-terminal state and engagement scaffolding`
    469 
    470 ---
    471 
    472 ### Task 16: Cheat card, `pentest-update`, devshells, and docs
    473 
    474 **Files:**
    475 - Create: `modules/home/cheats/pentest.md`
    476 - Modify: `modules/home/cheats.nix` (add `"pentest"` to `cards`)
    477 - Create: `modules/features/pentest/devshells.nix`
    478 - Create: `modules/features/pentest/update.nix`
    479 - Modify: `README.md` (a `Pentest` row in the "What runs" table and a tree entry)
    480 
    481 - [ ] **Step 1: Write the failing checks**
    482 
    483 `pentest-cheat --list` prints exactly `recon ad pivot transfer crack web dfir htb`; `nix develop .#pentest --command nxc --version` exits 0; `pentest-update --dry-run` exits 0 and changes no file.
    484 
    485 - [ ] **Step 2: Run to verify they fail.**
    486 - [ ] **Step 3: Implement the cheat card** — sections per the check, in the existing `modules/home/cheats/*.md` format. Document the two spec limitations explicitly: `$TARGET` refreshes at the next prompt, and `htbtime` skew breaks TLS.
    487 - [ ] **Step 4: Implement `devshells.nix`** — `devShells.pentest` as the union of every `flake.pentestPackages.*`, plus the per-category shells already produced by `mkCategory`.
    488 - [ ] **Step 5: Implement `pentest-update`** — re-pins every `_pkgs/` derivation via `nix-prefetch-url`/`nurl`, rewrites hashes in place, prints a diff, never commits. `--dry-run` only reports.
    489 - [ ] **Step 6: Verify** — all three checks PASS; `nix flake check` passes whole-flake.
    490 - [ ] **Step 7: Commit** — `feat(pentest): cheat card, devshells, pentest-update, README`
    491 
    492 ---
    493 
    494 ## Done when
    495 
    496 - `nix flake check` passes with every default-on category enabled.
    497 - `sudo nmap -sS` works (spec D1).
    498 - `split` and `ping` still resolve to coreutils and iputils (Review Focus #1).
    499 - `payload-serve` refuses to bind without a tun device (Review Focus #2).
    500 - `htbtime off` restores the clock from a missing state file (Review Focus #3).
    501 - `htbtarget` rejects malformed input before touching `/etc/hosts` (Review Focus #4).
    502 - `htbvpn up` never leaves two tun devices (Review Focus #5).
    503 - `$TARGET` set in one terminal appears in another at its next prompt.