commit a1878b43da92116c29feb24802e01397c8a07282
parent 957a875a8227e402bba871eae1931886c168f112
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Thu, 8 Oct 2026 18:39:35 +0100
feat(pentest): category harness, options, core and wordlists
mkCategory derives the gated nixosModule, devShell and a binary-resolution
smoke check from one package list per category, plus a checkScript hook for
categories whose deliverable is a tree rather than a binary. Tools install to
environment.systemPackages, not home.packages, so they work under sudo.
default.nix aggregates the categories so the host imports one name.
$WORDLISTS points at nixpkgs' own wordlists tree (seclists + rockyou, already
decompressed) rather than rebuilding it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
6 files changed, 275 insertions(+), 0 deletions(-)
diff --git a/modules/features/pentest/_sets.nix b/modules/features/pentest/_sets.nix
@@ -0,0 +1,91 @@
+# modules/features/pentest/_sets.nix — the category factory.
+#
+# Not a flake-parts module: the path contains `/_`, so import-tree skips it
+# (flake.nix). Every category file imports it and calls mkCategory once.
+#
+# One category is declared in one place — its package list — and this derives
+# the four things that list feeds:
+#
+# flake.pentestPackages.<name> the list itself, as pkgs -> [package].
+# devshells.nix unions these (Task 16).
+# flake.nixosModules.pentest-<name>
+# environment.systemPackages when the category
+# is on. System, not home: half this toolkit
+# needs root (`sudo nmap -sS`), and
+# home-manager packages are not on root's PATH.
+# perSystem.checks.pentest-<name>
+# the smoke test: every name in expectedBins
+# resolves on PATH. Catches the common failure
+# where an attribute exists but its binary is
+# called something else (netexec -> nxc,
+# snmpcheck -> snmp-check, bloodyad -> bloodyAD).
+# perSystem.devShells.pentest-<name>
+# the same list, portable: `nix develop`.
+#
+# The category declares its own `daemon.pentest.<name>.enable`; options.nix
+# holds only the master switch and the options no category owns.
+{ lib }:
+{
+ name,
+ description,
+ packages,
+ expectedBins ? [ ],
+ # Extra assertions for a category whose deliverable is not a binary (a
+ # wordlist tree, a payload tree). Takes { pkgs, lib }, returns shell appended
+ # to the check; fail with a non-zero exit and a message naming what is wrong.
+ checkScript ? (_: ""),
+ # Merged into the gated config, so a category that is off cannot turn on a
+ # NixOS service. Takes the module args it needs; returns a config attrset.
+ extraConfig ? (_: { }),
+ default ? true,
+}:
+{
+ flake.pentestPackages.${name} = packages;
+
+ flake.nixosModules."pentest-${name}" =
+ { config, pkgs, lib, ... }:
+ let
+ cfg = config.daemon.pentest;
+ on = cfg.enable && cfg.${name}.enable;
+ in
+ {
+ options.daemon.pentest.${name}.enable = lib.mkEnableOption description // {
+ inherit default;
+ };
+
+ config = lib.mkIf on (
+ lib.mkMerge [
+ { environment.systemPackages = packages pkgs; }
+ (extraConfig { inherit config pkgs lib; })
+ ]
+ );
+ };
+
+ perSystem =
+ { pkgs, ... }:
+ {
+ checks."pentest-${name}" = pkgs.runCommand "pentest-${name}-check"
+ {
+ nativeBuildInputs = packages pkgs;
+ passthru.expectedBins = expectedBins;
+ }
+ ''
+ missing=""
+ for b in ${lib.escapeShellArgs expectedBins}; do
+ command -v "$b" >/dev/null 2>&1 || missing="$missing $b"
+ done
+ if [ -n "$missing" ]; then
+ echo "pentest-${name}: expected binaries not on PATH:$missing" >&2
+ echo " (the attribute built, but its binary is named something else)" >&2
+ exit 1
+ fi
+ ${checkScript { inherit pkgs lib; }}
+ echo "pentest-${name}: ${toString (builtins.length expectedBins)} binaries ok" > $out
+ '';
+
+ devShells."pentest-${name}" = pkgs.mkShell {
+ name = "pentest-${name}";
+ packages = packages pkgs;
+ };
+ };
+}
diff --git a/modules/features/pentest/core.nix b/modules/features/pentest/core.nix
@@ -0,0 +1,49 @@
+# modules/features/pentest/core.nix — what every category assumes: the
+# primitives for talking to a target and the two paths the rest of the toolkit
+# hangs off.
+#
+# On whenever any category is on, so $PAYLOADS and $WORDLISTS always resolve.
+# Their values here are placeholders; wordlists.nix and payloads.nix override
+# them with lib.mkForce.
+#
+# `ncat` is not a top-level attribute — it ships inside nmap, which is why nmap
+# appears here as well as in recon.nix (Nix dedupes the closure).
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "core";
+ description = "pentest primitives: ncat, socat, smbclient, kerberos, ldap";
+
+ packages = pkgs: [
+ pkgs.nmap # ncat: the reverse-shell workhorse (--ssl, -e)
+ pkgs.socat
+ pkgs.samba # smbclient, net
+ pkgs.krb5 # kinit, klist — CPTS AD boxes live on these
+ pkgs.openldap # ldapsearch
+ pkgs.sshpass
+ pkgs.rlwrap # line editing in a dumb reverse shell
+ pkgs.jq
+ pkgs.dnsutils # dig, nslookup
+ pkgs.iputils
+ pkgs.util-linux
+ ];
+
+ expectedBins = [
+ "ncat"
+ "socat"
+ "smbclient"
+ "kinit"
+ "ldapsearch"
+ "sshpass"
+ "rlwrap"
+ "dig"
+ ];
+
+ extraConfig = { lib, ... }: {
+ # Placeholders: wordlists.nix (Task 2) and payloads.nix (Task 12) take these
+ # over with lib.mkForce.
+ environment.sessionVariables = {
+ PAYLOADS = lib.mkDefault "/run/current-system/sw/share/pentest/payloads";
+ WORDLISTS = lib.mkDefault "/run/current-system/sw/share/pentest/wordlists";
+ };
+ };
+}
diff --git a/modules/features/pentest/default.nix b/modules/features/pentest/default.nix
@@ -0,0 +1,24 @@
+# modules/features/pentest/default.nix — the toolkit as one NixOS module
+# (self.nixosModules.pentest), assembled from the named modules it imports.
+#
+# The host imports this one name, so adding a category means editing this file
+# and nothing else — the same arrangement as modules/home/default.nix.
+#
+# daemon.pentest.enable the master switch
+# daemon.pentest.<category>.enable per category, declared by the category
+# itself (_sets.nix)
+#
+# Default-on categories are the ones CPTS exercises; DFIR, reversing, wireless,
+# cloud, OSINT and mobile ship switched off (see each file's `default`).
+{ self, ... }:
+{
+ flake.nixosModules.pentest =
+ { ... }:
+ {
+ imports = with self.nixosModules; [
+ pentest-options # the master switch and the options no category owns
+ pentest-core # ncat, socat, smbclient, kerberos, ldap; $PAYLOADS/$WORDLISTS
+ pentest-wordlists # seclists, rockyou, searchsploit → $WORDLISTS
+ ];
+ };
+}
diff --git a/modules/features/pentest/options.nix b/modules/features/pentest/options.nix
@@ -0,0 +1,61 @@
+# modules/features/pentest/options.nix — the toolkit's master switch and the
+# options no single category owns.
+#
+# daemon.pentest.enable the whole toolkit (default false)
+# daemon.pentest.<category>.enable declared by each category itself
+# (_sets.nix), so adding a category
+# touches one file, not two
+# daemon.pentest.payloads.windowsArches which .exe variants to cross-build
+# daemon.pentest.htb.vpnDir where .ovpn profiles are looked for
+# daemon.pentest.htb.promptTarget show $TARGET in the prompt
+#
+# Scope: authorised lab use (HackTheBox CPTS prep). Nothing here points at any
+# host; the operator supplies targets at runtime.
+{ ... }:
+{
+ flake.nixosModules.pentest-options =
+ { lib, config, user, ... }:
+ let
+ cfg = config.daemon.pentest;
+ # Every category option declared by _sets.nix: an attrset carrying a
+ # boolean `enable`. `htb` and `payloads.windowsArches` are not categories
+ # and drop out of this filter on their own.
+ categories = lib.filterAttrs (_: v: lib.isAttrs v && v ? enable && lib.isBool v.enable) cfg;
+ enabledWhileOff = lib.attrNames (lib.filterAttrs (_: v: v.enable) categories);
+ in
+ {
+ options.daemon.pentest = {
+ enable = lib.mkEnableOption "the offensive security toolkit";
+
+ payloads.windowsArches = lib.mkOption {
+ type = lib.types.listOf (lib.types.enum [ "amd64" "x86" "arm64" ]);
+ default = [ "amd64" "x86" ];
+ description = "Windows architectures to cross-build payload binaries for.";
+ };
+
+ htb.vpnDir = lib.mkOption {
+ type = lib.types.str;
+ default = "/home/${user}/.config/htb/vpn";
+ description = ''
+ Directory scanned for OpenVPN profiles by `htbvpn list`. Deliberately
+ not Nix-managed: HTB profiles are per-account and rotate.
+ '';
+ };
+
+ htb.promptTarget = lib.mkOption {
+ type = lib.types.bool;
+ default = true;
+ description = "Show the current $TARGET in the shell prompt.";
+ };
+ };
+
+ config.assertions = [
+ {
+ assertion = cfg.enable || enabledWhileOff == [ ];
+ message =
+ "daemon.pentest: ${lib.concatStringsSep ", " enabledWhileOff} "
+ + "enabled while daemon.pentest.enable is false. Set daemon.pentest.enable = true.";
+ }
+ ];
+ };
+}
diff --git a/modules/features/pentest/wordlists.nix b/modules/features/pentest/wordlists.nix
@@ -0,0 +1,45 @@
+# modules/features/pentest/wordlists.nix — $WORDLISTS, the tree every fuzzer
+# and cracker is pointed at.
+#
+# $WORDLISTS/rockyou.txt 14 M passwords, plain text
+# $WORDLISTS/seclists/ the SecLists checkout (1.9 G)
+# $WORDLISTS/nmap.lst wfuzz/ the smaller sets
+#
+# nixpkgs' `wordlists` already aggregates seclists, rockyou, nmap.lst and wfuzz
+# under one share/wordlists, and already ships rockyou DECOMPRESSED — so this
+# module points at that tree instead of rebuilding it. The check still asserts
+# rockyou is plain text: it has shipped gzipped before, and a gzip magic number
+# would make every `hashcat ... rockyou.txt` silently crack nothing.
+#
+# `wordlists` also provides `wordlists_path`, so the shell can find the tree
+# without $WORDLISTS.
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "wordlists";
+ description = "seclists, rockyou and the exploit-db archive";
+
+ packages = pkgs: [
+ pkgs.wordlists # seclists + rockyou + nmap.lst + wfuzz, and `wordlists_path`
+ pkgs.exploitdb # `searchsploit`
+ ];
+
+ expectedBins = [ "wordlists_path" "searchsploit" ];
+
+ checkScript = { pkgs, lib }: ''
+ dir=${pkgs.wordlists}/share/wordlists
+ for p in rockyou.txt seclists nmap.lst; do
+ if [ ! -e "$dir/$p" ]; then
+ echo "pentest-wordlists: $dir/$p is missing" >&2
+ exit 1
+ fi
+ done
+ if [ "$(head -c2 "$dir/rockyou.txt" | od -An -tx1 | tr -d ' ')" = "1f8b" ]; then
+ echo "pentest-wordlists: rockyou.txt is gzipped, not plain text" >&2
+ exit 1
+ fi
+ '';
+
+ extraConfig = { pkgs, lib, ... }: {
+ environment.sessionVariables.WORDLISTS = lib.mkForce "${pkgs.wordlists}/share/wordlists";
+ };
+}
diff --git a/modules/hosts/laptop/configuration.nix b/modules/hosts/laptop/configuration.nix
@@ -27,6 +27,7 @@
desktop-hyprland # Hyprland + Caelestia (NixOS side)
desktop-niri # Niri + Noctalia: the wrapped package as the login session
theme # Stylix: Rosé Pine Dawn GRUB and console (tuigreet)
+ pentest # the offensive toolkit (modules/features/pentest/default.nix)
];
# The desktops. Both are installed and chosen at login; set one to false to
@@ -36,6 +37,10 @@
niri.enable = true;
};
+ # The offensive toolkit (modules/features/pentest/). Each category carries
+ # its own daemon.pentest.<category>.enable; see pentest-cheat.
+ daemon.pentest.enable = true;
+
# GRUB (themed Rosé Pine Dawn by modules/features/theme.nix) on the EFI
# partition at /boot; kernels are copied there since / is a separate btrfs.
boot.loader.grub = {