From b212f1dc6b69af94c0df7978cc00e28912651bcb Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 4 Aug 2026 20:04:30 -0400 Subject: [PATCH 1/6] fix(cli): scrub attacker-seeded /etc/hosts on the Kali box `score reset` left the Kali /etc/hosts untouched, so AD host->IP mappings an agent wrote (e.g. `nxc --generate-hosts-file`) persisted across runs and handed the next agent the domain topology it is meant to enumerate. Add an /etc/hosts target to the Kali cleanup that strips non-loopback lines while preserving the pristine baseline (blank, comment, 127.*, ::1, and fe/ff IPv6-reserved rows). The rewrite is root-guarded with `sudo -n` so it fails closed rather than prompting. Co-Authored-By: Claude Opus 4.8 --- cli/cmd/score_reset.go | 13 +++++++++++++ cli/cmd/score_reset_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 cli/cmd/score_reset_test.go diff --git a/cli/cmd/score_reset.go b/cli/cmd/score_reset.go index b2e85838..7a2b2be1 100644 --- a/cli/cmd/score_reset.go +++ b/cli/cmd/score_reset.go @@ -255,6 +255,19 @@ func buildKaliCleanupScript(apply bool) string { find: `find $HOME -maxdepth 3 \( -name "*.ccache" -o -name "*.kirbi" -o -name "*.keytab" -o -name "*.pfx" \) ! -path "*/.local/*" 2>/dev/null | wc -l`, clean: `find $HOME -maxdepth 3 \( -name "*.ccache" -o -name "*.kirbi" -o -name "*.keytab" -o -name "*.pfx" \) ! -path "*/.local/*" -delete 2>/dev/null`, }, + { + // The pristine Kali image ships /etc/hosts with only loopback and + // IPv6-reserved lines. Anything mapping a routable address to a + // hostname is agent-seeded recon (e.g. `nxc --generate-hosts-file`), + // and handing that to the next agent leaks the domain topology it is + // meant to discover. Strip those lines while preserving the baseline + // block (blank, comment, 127.*, ::1, and fe/ff IPv6-reserved rows). + // Rewrite needs root; `sudo -n` fails closed rather than prompting, so + // a box without passwordless sudo reports found-but-not-removed. + label: "attacker /etc/hosts entries (non-loopback)", + find: `awk 'NF && $1 !~ /^#/ && $1 !~ /^127\./ && $1 !~ /^::1$/ && $1 !~ /^f[ef]/ {c++} END{print c+0}' /etc/hosts 2>/dev/null || echo 0`, + clean: `awk 'NF==0 || $1 ~ /^#/ || $1 ~ /^127\./ || $1 ~ /^::1$/ || $1 ~ /^f[ef]/' /etc/hosts > /tmp/.dg_hosts 2>/dev/null && sudo -n cp /tmp/.dg_hosts /etc/hosts 2>/dev/null; rm -f /tmp/.dg_hosts 2>/dev/null`, + }, } var sb strings.Builder diff --git a/cli/cmd/score_reset_test.go b/cli/cmd/score_reset_test.go new file mode 100644 index 00000000..f73afa45 --- /dev/null +++ b/cli/cmd/score_reset_test.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "strings" + "testing" +) + +// TestKaliCleanupScriptStripsAttackerHosts pins the /etc/hosts scrub behaviour: +// both modes inspect the file, apply mode rewrites it (root-guarded), and +// dry-run never mutates it. The strip filter must preserve the loopback block. +func TestKaliCleanupScriptStripsAttackerHosts(t *testing.T) { + for _, apply := range []bool{false, true} { + if !strings.Contains(buildKaliCleanupScript(apply), "/etc/hosts") { + t.Fatalf("apply=%v: cleanup script does not reference /etc/hosts", apply) + } + } + + applyScript := buildKaliCleanupScript(true) + if !strings.Contains(applyScript, "sudo -n cp") { + t.Error("apply mode should rewrite /etc/hosts via `sudo -n cp` (fail-closed, no prompt)") + } + if !strings.Contains(applyScript, `$1 ~ /^127\./`) { + t.Error("strip filter must preserve loopback (127.*) lines") + } + + // Dry-run must be side-effect free: it may count, never rewrite. + if strings.Contains(buildKaliCleanupScript(false), "sudo -n cp") { + t.Error("dry-run must not contain the /etc/hosts rewrite command") + } +} From f78607680e4553ee9b393b05e4e14fc5c37aae03 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 4 Aug 2026 20:42:36 -0400 Subject: [PATCH 2/6] fix(cli): guard the /etc/hosts scrub against truncation and /tmp races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hardening on b212f1d. The rewrite installed whatever the keep-filter produced, with no floor. An agent that clobbered /etc/hosts outright (`nxc --generate-hosts-file > /etc/hosts` rather than `>>`) leaves no baseline line to keep, so the scrub wrote an empty /etc/hosts and broke hostname resolution for the next run — the exact scenario it exists for. The copy is now refused unless a loopback line survived the filter. It also staged the filtered copy at a fixed /tmp/.dg_hosts and handed that path to root. /tmp is world-writable, so a predictable name lets a local user swap the contents between the unprivileged write and the `sudo -n cp` and choose the system's /etc/hosts. Use mktemp instead. The IPv6 prefix test was case-sensitive, so FE80::/FF02:: rows were counted as attacker entries and stripped out of the baseline. Match with tolower(). Every skip path now reports why. Previously a box without passwordless sudo showed found-but-not-removed with stderr discarded and no reason. The find and clean commands were hand-written complements of each other; both now derive from hostsBaselineFilter so they cannot drift. Tests run the real awk against fixture hosts files (pristine, nxc-seeded, uppercase v6, ULA and global v6, clobbered) rather than string-matching the generated script, and `sh -n` validates the emitted script in both modes. The uppercase and empty-rewrite cases both fail against the previous filter. Co-Authored-By: Claude Opus 5 (1M context) --- cli/cmd/score_reset.go | 48 ++++++++--- cli/cmd/score_reset_test.go | 155 ++++++++++++++++++++++++++++++++++-- 2 files changed, 186 insertions(+), 17 deletions(-) diff --git a/cli/cmd/score_reset.go b/cli/cmd/score_reset.go index 7a2b2be1..e29ec7cf 100644 --- a/cli/cmd/score_reset.go +++ b/cli/cmd/score_reset.go @@ -175,6 +175,38 @@ func resetKali(ctx context.Context, cmd *cobra.Command, cfg *config.Config, appl return nil } +// hostsBaselineFilter is an awk condition matching the /etc/hosts lines a +// pristine Kali image ships: blank, comment, IPv4 loopback, ::1, and the +// fe/ff IPv6-reserved rows. Everything else maps a routable address to a +// hostname and is therefore agent-seeded recon. The v6 prefix test is +// case-insensitive because tools emit FE80::/FF02:: as readily as lowercase. +// +// Both the find and clean commands are derived from this single condition +// (clean keeps it, find counts its negation) so the two can never drift. +const hostsBaselineFilter = `NF==0 || $1 ~ /^#/ || $1 ~ /^127\./ || $1 ~ /^::1$/ || tolower($1) ~ /^f[ef]/` + +// hostsCleanScript rewrites /etc/hosts down to the baseline block. +// +// The filtered copy goes to a mktemp path rather than a fixed one: /tmp is +// world-writable, and root copies this file, so a predictable name lets any +// local user swap the contents between the write and the copy. The rewrite is +// refused unless a loopback line survived — an agent that clobbered +// /etc/hosts outright (`nxc --generate-hosts-file > /etc/hosts`) leaves +// nothing to keep, and an empty /etc/hosts breaks hostname resolution for the +// next run. Every skip path says why; `sudo -n` fails closed instead of +// prompting, so a box without passwordless sudo reports found-but-not-removed. +const hostsCleanScript = `dg_t=$(mktemp 2>/dev/null) + if [ -z "$dg_t" ]; then + echo " WARN: /etc/hosts rewrite skipped (mktemp unavailable)" + elif ! awk '` + hostsBaselineFilter + `' /etc/hosts > "$dg_t" 2>/dev/null; then + echo " WARN: /etc/hosts rewrite skipped (could not read /etc/hosts)" + elif ! grep -q '^127\.' "$dg_t"; then + echo " WARN: /etc/hosts rewrite skipped (no loopback line survived the filter)" + elif ! sudo -n cp "$dg_t" /etc/hosts 2>/dev/null; then + echo " WARN: /etc/hosts rewrite skipped (needs passwordless sudo)" + fi + rm -f "$dg_t" 2>/dev/null` + // buildKaliCleanupScript generates the shell script for Kali artifact cleanup. // Uses $HOME so it works for both ssm-user (AWS) and kali (Azure). func buildKaliCleanupScript(apply bool) string { @@ -256,17 +288,13 @@ func buildKaliCleanupScript(apply bool) string { clean: `find $HOME -maxdepth 3 \( -name "*.ccache" -o -name "*.kirbi" -o -name "*.keytab" -o -name "*.pfx" \) ! -path "*/.local/*" -delete 2>/dev/null`, }, { - // The pristine Kali image ships /etc/hosts with only loopback and - // IPv6-reserved lines. Anything mapping a routable address to a - // hostname is agent-seeded recon (e.g. `nxc --generate-hosts-file`), - // and handing that to the next agent leaks the domain topology it is - // meant to discover. Strip those lines while preserving the baseline - // block (blank, comment, 127.*, ::1, and fe/ff IPv6-reserved rows). - // Rewrite needs root; `sudo -n` fails closed rather than prompting, so - // a box without passwordless sudo reports found-but-not-removed. + // Anything mapping a routable address to a hostname is agent-seeded + // recon (e.g. `nxc --generate-hosts-file`), and handing that to the + // next agent leaks the domain topology it is meant to discover. + // See hostsBaselineFilter for what counts as baseline. label: "attacker /etc/hosts entries (non-loopback)", - find: `awk 'NF && $1 !~ /^#/ && $1 !~ /^127\./ && $1 !~ /^::1$/ && $1 !~ /^f[ef]/ {c++} END{print c+0}' /etc/hosts 2>/dev/null || echo 0`, - clean: `awk 'NF==0 || $1 ~ /^#/ || $1 ~ /^127\./ || $1 ~ /^::1$/ || $1 ~ /^f[ef]/' /etc/hosts > /tmp/.dg_hosts 2>/dev/null && sudo -n cp /tmp/.dg_hosts /etc/hosts 2>/dev/null; rm -f /tmp/.dg_hosts 2>/dev/null`, + find: `awk '!(` + hostsBaselineFilter + `) {c++} END{print c+0}' /etc/hosts 2>/dev/null || echo 0`, + clean: hostsCleanScript, }, } diff --git a/cli/cmd/score_reset_test.go b/cli/cmd/score_reset_test.go index f73afa45..71aa9325 100644 --- a/cli/cmd/score_reset_test.go +++ b/cli/cmd/score_reset_test.go @@ -1,13 +1,132 @@ package cmd import ( + "os" + "os/exec" + "path/filepath" "strings" "testing" ) -// TestKaliCleanupScriptStripsAttackerHosts pins the /etc/hosts scrub behaviour: -// both modes inspect the file, apply mode rewrites it (root-guarded), and -// dry-run never mutates it. The strip filter must preserve the loopback block. +// runHostsFilter executes the generated awk filters against a fixture +// /etc/hosts and returns (lines counted as attacker-seeded, lines kept by the +// rewrite). Exercising the real awk is the point: the regexes are where this +// feature can actually break, and a string match on the script would not +// notice a wrong one. +func runHostsFilter(t *testing.T, content string) (found string, kept string) { + t.Helper() + + awkBin, err := exec.LookPath("awk") + if err != nil { + t.Skip("awk not available") + } + + path := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + countOut, err := exec.Command(awkBin, `!(`+hostsBaselineFilter+`) {c++} END{print c+0}`, path).Output() + if err != nil { + t.Fatalf("count filter: %v", err) + } + keptOut, err := exec.Command(awkBin, hostsBaselineFilter, path).Output() + if err != nil { + t.Fatalf("keep filter: %v", err) + } + return strings.TrimSpace(string(countOut)), string(keptOut) +} + +func TestHostsBaselineFilter(t *testing.T) { + tests := []struct { + name string + hosts string + wantFound string + wantKept []string // substrings that must survive the rewrite + wantGone []string // substrings that must not + wantKeptEmpty bool // nothing survives, so the rewrite must be refused + }{ + { + name: "pristine kali plus azure cloud-init", + hosts: "127.0.0.1\tlocalhost\n" + + "::1\t\tlocalhost ip6-localhost ip6-loopback\n" + + "fe00::0\t\tip6-localnet\n" + + "ff00::0\t\tip6-mcastprefix\n" + + "ff02::1\t\tip6-allnodes\n" + + "ff02::2\t\tip6-allrouters\n" + + "\n" + + "127.0.1.1\tkali\n" + + "127.0.0.1 kali-attack-box\n", + wantFound: "0", + wantKept: []string{"127.0.0.1\tlocalhost", "ff02::2", "127.0.1.1\tkali", "kali-attack-box"}, + }, + { + name: "nxc --generate-hosts-file output appended", + hosts: "127.0.0.1\tlocalhost\n" + + "::1\t\tlocalhost ip6-localhost ip6-loopback\n" + + "127.0.1.1\tkali\n" + + "10.10.10.10 dc01.sevenkingdoms.local dc01\n" + + "10.10.10.11 castelblack.north.sevenkingdoms.local castelblack\n", + wantFound: "2", + wantKept: []string{"127.0.0.1", "::1", "127.0.1.1"}, + wantGone: []string{"dc01.sevenkingdoms.local", "castelblack"}, + }, + { + name: "uppercase IPv6 reserved rows are baseline, not artifacts", + hosts: "127.0.0.1 localhost\n" + + "FE80::1 link-local\n" + + "FF02::1 ip6-allnodes\n", + wantFound: "0", + wantKept: []string{"FE80::1", "FF02::1"}, + }, + { + name: "unique-local and other routable v6 are artifacts", + hosts: "127.0.0.1 localhost\n" + + "fd00::5 evil.corp.local\n" + + "2001:db8::1 srv02.corp.local\n", + wantFound: "2", + wantKept: []string{"127.0.0.1 localhost"}, + wantGone: []string{"evil.corp.local", "srv02.corp.local"}, + }, + { + // An agent that clobbered /etc/hosts outright + // (`nxc --generate-hosts-file > /etc/hosts`) leaves nothing for the + // filter to keep. Installing that empty result would break hostname + // resolution, so the clean script's loopback guard must refuse it. + name: "clobbered hosts file leaves nothing to keep", + hosts: "10.10.10.10 dc01.local dc01\n10.10.10.11 srv02.local\n", + wantFound: "2", + wantGone: []string{"dc01", "srv02"}, + wantKeptEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + found, kept := runHostsFilter(t, tt.hosts) + if found != tt.wantFound { + t.Errorf("found = %s, want %s (kept:\n%s)", found, tt.wantFound, kept) + } + for _, want := range tt.wantKept { + if !strings.Contains(kept, want) { + t.Errorf("rewrite dropped baseline line %q (kept:\n%s)", want, kept) + } + } + for _, gone := range tt.wantGone { + if strings.Contains(kept, gone) { + t.Errorf("rewrite preserved attacker entry %q (kept:\n%s)", gone, kept) + } + } + if tt.wantKeptEmpty && strings.TrimSpace(kept) != "" { + t.Errorf("expected nothing to survive the filter, got:\n%s", kept) + } + }) + } +} + +// TestKaliCleanupScriptStripsAttackerHosts pins the script shape: both modes +// inspect /etc/hosts, apply mode rewrites it through a mktemp file under +// non-prompting sudo, and dry-run never mutates it. func TestKaliCleanupScriptStripsAttackerHosts(t *testing.T) { for _, apply := range []bool{false, true} { if !strings.Contains(buildKaliCleanupScript(apply), "/etc/hosts") { @@ -16,11 +135,17 @@ func TestKaliCleanupScriptStripsAttackerHosts(t *testing.T) { } applyScript := buildKaliCleanupScript(true) - if !strings.Contains(applyScript, "sudo -n cp") { - t.Error("apply mode should rewrite /etc/hosts via `sudo -n cp` (fail-closed, no prompt)") + for _, want := range []string{ + `dg_t=$(mktemp 2>/dev/null)`, // not a fixed /tmp path root copies from + `sudo -n cp "$dg_t" /etc/hosts`, + `grep -q '^127\.'`, + } { + if !strings.Contains(applyScript, want) { + t.Errorf("apply mode missing %q", want) + } } - if !strings.Contains(applyScript, `$1 ~ /^127\./`) { - t.Error("strip filter must preserve loopback (127.*) lines") + if strings.Contains(applyScript, "/tmp/.dg_hosts") { + t.Error("apply mode must not stage the rewrite at a predictable /tmp path") } // Dry-run must be side-effect free: it may count, never rewrite. @@ -28,3 +153,19 @@ func TestKaliCleanupScriptStripsAttackerHosts(t *testing.T) { t.Error("dry-run must not contain the /etc/hosts rewrite command") } } + +// TestKaliCleanupScriptIsValidShell catches quoting or syntax damage in the +// generated script without executing any of it. +func TestKaliCleanupScriptIsValidShell(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh not available") + } + for _, apply := range []bool{false, true} { + cmd := exec.Command(sh, "-n") + cmd.Stdin = strings.NewReader(buildKaliCleanupScript(apply)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("apply=%v: generated script is not valid sh: %v\n%s", apply, err, out) + } + } +} From 32ceaef56f84111976431616f71a4896e64ddb3a Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 4 Aug 2026 21:08:15 -0400 Subject: [PATCH 3/6] fix(cli): strip commented-out /etc/hosts entries too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrub keyed off "the line starts with #", so an entry an agent commented out rather than deleted survived it. `# 10.0.0.5 dc01.corp.local` hands the next agent the same topology as the live line. Match on the first token past any leading `#` instead, and count the line as an artifact only when that token parses as a routable address. Prose comments stay baseline, which is the reason not to simply drop every comment: that would also take the cloud-init manage_etc_hosts block and Ubuntu's IPv6 header, both of which a pristine image ships. The token scan makes the leading `#` optional so it strips indentation as well. Splitting an indented line without that yields an empty first field, which would read as prose and let the entry through — a hazard specific to this parse, since awk's own $1 ignores leading blanks. A comment that *starts* with an IP-like token (`# 1.2.3.4 release notes`) is now dropped as an artifact. That is undecidable from the line alone, and the failure only ever costs a comment, never a mapping. The awk program is multi-line as a result, so both variants are emitted once into shell variables instead of inlined at each of the three call sites. The generated script is what an operator reads back off a failing box, so it stays legible. Verified against fixture hosts files driving the real awk: commented, double-hash, indented, prose, commented loopback, CRLF, empty, and missing files, plus the full generated block against a cloud-init image. Exercised under BWK awk only; Kali defaults to mawk and every construct used is POSIX awk. Co-Authored-By: Claude Opus 5 (1M context) --- cli/cmd/score_reset.go | 56 +++++++++++++++++++++++++++++-------- cli/cmd/score_reset_test.go | 47 +++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/cli/cmd/score_reset.go b/cli/cmd/score_reset.go index e29ec7cf..5ae53d29 100644 --- a/cli/cmd/score_reset.go +++ b/cli/cmd/score_reset.go @@ -175,15 +175,44 @@ func resetKali(ctx context.Context, cmd *cobra.Command, cfg *config.Config, appl return nil } -// hostsBaselineFilter is an awk condition matching the /etc/hosts lines a -// pristine Kali image ships: blank, comment, IPv4 loopback, ::1, and the -// fe/ff IPv6-reserved rows. Everything else maps a routable address to a -// hostname and is therefore agent-seeded recon. The v6 prefix test is -// case-insensitive because tools emit FE80::/FF02:: as readily as lowercase. +// hostsBaselineAwk defines is_baseline(), the single predicate behind both the +// find and clean commands (clean keeps its matches, find counts its negation) +// so the two can never drift apart. // -// Both the find and clean commands are derived from this single condition -// (clean keeps it, find counts its negation) so the two can never drift. -const hostsBaselineFilter = `NF==0 || $1 ~ /^#/ || $1 ~ /^127\./ || $1 ~ /^::1$/ || tolower($1) ~ /^f[ef]/` +// A line is baseline if it is blank, or if its first token — after any leading +// `#` — is not a routable address. That keeps everything a pristine image +// ships: loopback, ::1, the fe/ff IPv6-reserved rows, and prose comments such +// as the cloud-init manage_etc_hosts block or Ubuntu's IPv6 header. Reading +// past the `#` is what catches an entry an agent commented out rather than +// deleted — `# 10.0.0.5 dc01.corp.local` leaks the same topology as the live +// line. The v6 prefix test is case-insensitive because tools emit +// FE80::/FF02:: as readily as lowercase. +// +// host_addr makes the `#` optional so it also strips leading whitespace from +// indented entries; splitting those without stripping yields an empty first +// field, which would read as prose and let the entry through. +const hostsBaselineAwk = ` +function host_addr( s, f) { + s = $0 + sub(/^[ \t]*#*[ \t]*/, "", s) + split(s, f, /[ \t]+/) + return f[1] +} +function is_baseline( a) { + if (NF == 0) return 1 + a = host_addr() + if (a !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && !(a ~ /:/ && a ~ /^[0-9A-Fa-f:]+$/)) return 1 + return a ~ /^127\./ || a == "::1" || tolower(a) ~ /^f[ef]/ +} +` + +// The two awk programs the cleanup script runs. Emitted once into shell +// variables so the generated script stays readable when an operator reads it +// back off a failing box. +const ( + hostsFindProgram = hostsBaselineAwk + `!is_baseline() {c++} END{print c+0}` + hostsKeepProgram = hostsBaselineAwk + `is_baseline()` +) // hostsCleanScript rewrites /etc/hosts down to the baseline block. // @@ -198,7 +227,7 @@ const hostsBaselineFilter = `NF==0 || $1 ~ /^#/ || $1 ~ /^127\./ || $1 ~ /^::1$/ const hostsCleanScript = `dg_t=$(mktemp 2>/dev/null) if [ -z "$dg_t" ]; then echo " WARN: /etc/hosts rewrite skipped (mktemp unavailable)" - elif ! awk '` + hostsBaselineFilter + `' /etc/hosts > "$dg_t" 2>/dev/null; then + elif ! awk "$dg_hosts_keep" /etc/hosts > "$dg_t" 2>/dev/null; then echo " WARN: /etc/hosts rewrite skipped (could not read /etc/hosts)" elif ! grep -q '^127\.' "$dg_t"; then echo " WARN: /etc/hosts rewrite skipped (no loopback line survived the filter)" @@ -291,9 +320,9 @@ func buildKaliCleanupScript(apply bool) string { // Anything mapping a routable address to a hostname is agent-seeded // recon (e.g. `nxc --generate-hosts-file`), and handing that to the // next agent leaks the domain topology it is meant to discover. - // See hostsBaselineFilter for what counts as baseline. + // See hostsBaselineAwk for what counts as baseline. label: "attacker /etc/hosts entries (non-loopback)", - find: `awk '!(` + hostsBaselineFilter + `) {c++} END{print c+0}' /etc/hosts 2>/dev/null || echo 0`, + find: `awk "$dg_hosts_find" /etc/hosts 2>/dev/null || echo 0`, clean: hostsCleanScript, }, } @@ -301,6 +330,11 @@ func buildKaliCleanupScript(apply bool) string { var sb strings.Builder sb.WriteString("#!/bin/sh\ntotal_found=0\ntotal_removed=0\n") + // The hosts programs are multi-line; hoisting them out of the per-target + // commands keeps the emitted script readable. Neither program touches + // anything on its own, so both are safe to define in dry-run mode. + fmt.Fprintf(&sb, "\ndg_hosts_find='%s'\ndg_hosts_keep='%s'\n", hostsFindProgram, hostsKeepProgram) + for i, t := range targets { fmt.Fprintf(&sb, "\n# %s\ncount_%d=$(%s)\ntotal_found=$((total_found + count_%d))\n", t.label, i, t.find, i) if apply { diff --git a/cli/cmd/score_reset_test.go b/cli/cmd/score_reset_test.go index 71aa9325..8c4ff779 100644 --- a/cli/cmd/score_reset_test.go +++ b/cli/cmd/score_reset_test.go @@ -26,11 +26,11 @@ func runHostsFilter(t *testing.T, content string) (found string, kept string) { t.Fatalf("write fixture: %v", err) } - countOut, err := exec.Command(awkBin, `!(`+hostsBaselineFilter+`) {c++} END{print c+0}`, path).Output() + countOut, err := exec.Command(awkBin, hostsFindProgram, path).Output() if err != nil { t.Fatalf("count filter: %v", err) } - keptOut, err := exec.Command(awkBin, hostsBaselineFilter, path).Output() + keptOut, err := exec.Command(awkBin, hostsKeepProgram, path).Output() if err != nil { t.Fatalf("keep filter: %v", err) } @@ -88,6 +88,48 @@ func TestHostsBaselineFilter(t *testing.T) { wantKept: []string{"127.0.0.1 localhost"}, wantGone: []string{"evil.corp.local", "srv02.corp.local"}, }, + { + // Commenting an entry out hides it from resolution but not from + // the next agent reading the file. + name: "commented-out entries are artifacts", + hosts: "127.0.0.1 localhost\n" + + "# 10.10.10.12 dc02.sevenkingdoms.local dc02\n" + + "#10.10.10.13 srv03.sevenkingdoms.local\n" + + "## 10.10.10.14 srv04.sevenkingdoms.local\n", + wantFound: "3", + wantKept: []string{"127.0.0.1 localhost"}, + wantGone: []string{"dc02", "srv03", "srv04"}, + }, + { + // Prose comments are baseline: cloud-init writes this block on + // Azure images and stripping it would not restore pristine. + name: "prose comments survive", + hosts: "# Your system has configured 'manage_etc_hosts' as True.\n" + + "# As a result, if you wish for changes to this file to persist\n" + + "# then you will need to either:\n" + + "# a.) make changes to the master file in /etc/cloud/templates/\n" + + "# The following lines are desirable for IPv6 capable hosts\n" + + "127.0.0.1 localhost\n", + wantFound: "0", + wantKept: []string{"manage_etc_hosts", "IPv6 capable hosts", "a.) make changes"}, + }, + { + // Splitting an indented line without stripping the leading + // whitespace yields an empty first field, which would read as + // prose and let the entry through. + name: "indented entries are still artifacts", + hosts: "127.0.0.1 localhost\n \t10.10.10.15 srv05.sevenkingdoms.local\n", + wantFound: "1", + wantKept: []string{"127.0.0.1 localhost"}, + wantGone: []string{"srv05"}, + }, + { + // A commented loopback line leaks nothing. + name: "commented loopback stays baseline", + hosts: "127.0.0.1 localhost\n# 127.0.1.1 oldkali\n# ::1 localhost\n", + wantFound: "0", + wantKept: []string{"oldkali", "# ::1 localhost"}, + }, { // An agent that clobbered /etc/hosts outright // (`nxc --generate-hosts-file > /etc/hosts`) leaves nothing for the @@ -137,6 +179,7 @@ func TestKaliCleanupScriptStripsAttackerHosts(t *testing.T) { applyScript := buildKaliCleanupScript(true) for _, want := range []string{ `dg_t=$(mktemp 2>/dev/null)`, // not a fixed /tmp path root copies from + `awk "$dg_hosts_keep" /etc/hosts > "$dg_t"`, `sudo -n cp "$dg_t" /etc/hosts`, `grep -q '^127\.'`, } { From ca3262e77356ffbb33e703c9327bb0a2718f7f70 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 4 Aug 2026 21:16:25 -0400 Subject: [PATCH 4/6] fix(cli): accept either loopback family in the /etc/hosts rewrite guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #416. The guard that refuses to install a filtered /etc/hosts with no surviving baseline tested for `^127\.` alone. It asks whether baseline survived, and `::1` is baseline — a file whose only loopback is the v6 one still resolves localhost, so refusing there would leave the artifacts in place for the next run with nothing gained. Accept either family. Not reachable on the images this targets, which all ship `127.0.0.1 localhost`, but the guard should match its own stated intent. The `::1` arm is anchored to a delimiter so it cannot match a routable address that merely starts with those characters. The pattern moves to hostsLoopbackGuard so the script and the test cannot drift, and TestHostsLoopbackGuard now runs the real grep against real filter output instead of pinning the literal — v4-only and v6-only baselines are accepted, a clobbered file and a comments-only file are refused. Co-Authored-By: Claude Opus 5 (1M context) --- cli/cmd/score_reset.go | 16 +++++++++-- cli/cmd/score_reset_test.go | 56 ++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/cli/cmd/score_reset.go b/cli/cmd/score_reset.go index 5ae53d29..09309307 100644 --- a/cli/cmd/score_reset.go +++ b/cli/cmd/score_reset.go @@ -214,6 +214,11 @@ const ( hostsKeepProgram = hostsBaselineAwk + `is_baseline()` ) +// hostsLoopbackGuard matches a surviving loopback line in the filtered output. +// The `::1` arm is anchored to a delimiter so it cannot match a routable +// address that merely starts with those characters. +const hostsLoopbackGuard = `^127\.|^::1([[:space:]]|$)` + // hostsCleanScript rewrites /etc/hosts down to the baseline block. // // The filtered copy goes to a mktemp path rather than a fixed one: /tmp is @@ -222,14 +227,19 @@ const ( // refused unless a loopback line survived — an agent that clobbered // /etc/hosts outright (`nxc --generate-hosts-file > /etc/hosts`) leaves // nothing to keep, and an empty /etc/hosts breaks hostname resolution for the -// next run. Every skip path says why; `sudo -n` fails closed instead of -// prompting, so a box without passwordless sudo reports found-but-not-removed. +// next run. Either loopback family counts: the guard asks whether baseline +// survived, and a file whose baseline is `::1` alone still resolves localhost. +// Every skip path says why; `sudo -n` fails closed instead of prompting, so a +// box without passwordless sudo reports found-but-not-removed. +// +// cp is deliberate: writing into the existing /etc/hosts keeps that inode's +// mode and owner, so the 0600 mktemp file does not make /etc/hosts root-only. const hostsCleanScript = `dg_t=$(mktemp 2>/dev/null) if [ -z "$dg_t" ]; then echo " WARN: /etc/hosts rewrite skipped (mktemp unavailable)" elif ! awk "$dg_hosts_keep" /etc/hosts > "$dg_t" 2>/dev/null; then echo " WARN: /etc/hosts rewrite skipped (could not read /etc/hosts)" - elif ! grep -q '^127\.' "$dg_t"; then + elif ! grep -qE '` + hostsLoopbackGuard + `' "$dg_t"; then echo " WARN: /etc/hosts rewrite skipped (no loopback line survived the filter)" elif ! sudo -n cp "$dg_t" /etc/hosts 2>/dev/null; then echo " WARN: /etc/hosts rewrite skipped (needs passwordless sudo)" diff --git a/cli/cmd/score_reset_test.go b/cli/cmd/score_reset_test.go index 8c4ff779..0bd9a288 100644 --- a/cli/cmd/score_reset_test.go +++ b/cli/cmd/score_reset_test.go @@ -166,6 +166,60 @@ func TestHostsBaselineFilter(t *testing.T) { } } +// TestHostsLoopbackGuard runs the real guard against real filter output. The +// rewrite is refused unless baseline survived, and either loopback family +// counts as baseline — a file whose only loopback is `::1` still resolves +// localhost, so skipping the scrub there would leave artifacts behind. +func TestHostsLoopbackGuard(t *testing.T) { + grepBin, err := exec.LookPath("grep") + if err != nil { + t.Skip("grep not available") + } + + tests := []struct { + name string + hosts string + wantAccept bool + }{ + { + name: "IPv4 loopback survives", + hosts: "127.0.0.1 localhost\n10.10.10.10 dc01.local\n", + wantAccept: true, + }, + { + name: "IPv6-only baseline survives", + hosts: "::1 localhost ip6-localhost\nff02::1 ip6-allnodes\n10.10.10.10 dc01.local\n", + wantAccept: true, + }, + { + name: "clobbered file leaves no loopback", + hosts: "10.10.10.10 dc01.local\n10.10.10.11 srv02.local\n", + wantAccept: false, + }, + { + name: "comments alone are not a loopback line", + hosts: "# 127.0.1.1 oldkali\n# prose\n10.10.10.10 dc01.local\n", + wantAccept: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, kept := runHostsFilter(t, tt.hosts) + + path := filepath.Join(t.TempDir(), "filtered") + if err := os.WriteFile(path, []byte(kept), 0o644); err != nil { + t.Fatalf("write filtered: %v", err) + } + + err := exec.Command(grepBin, "-qE", hostsLoopbackGuard, path).Run() + if accepted := err == nil; accepted != tt.wantAccept { + t.Errorf("guard accepted = %v, want %v (filtered:\n%s)", accepted, tt.wantAccept, kept) + } + }) + } +} + // TestKaliCleanupScriptStripsAttackerHosts pins the script shape: both modes // inspect /etc/hosts, apply mode rewrites it through a mktemp file under // non-prompting sudo, and dry-run never mutates it. @@ -181,7 +235,7 @@ func TestKaliCleanupScriptStripsAttackerHosts(t *testing.T) { `dg_t=$(mktemp 2>/dev/null)`, // not a fixed /tmp path root copies from `awk "$dg_hosts_keep" /etc/hosts > "$dg_t"`, `sudo -n cp "$dg_t" /etc/hosts`, - `grep -q '^127\.'`, + `grep -qE '` + hostsLoopbackGuard + `'`, } { if !strings.Contains(applyScript, want) { t.Errorf("apply mode missing %q", want) From 6e50ac0174f29bb9fccc820ed42afebea8671a11 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 4 Aug 2026 21:36:25 -0400 Subject: [PATCH 5/6] fix(cli): report Kali cleanup counts as items, not files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #416. The per-target detail line hardcoded "files", which the /etc/hosts target made visibly wrong: "attacker /etc/hosts entries (non-loopback): 2 files" counts lines, not files. Two older targets are already off in the same way — the agent report and hashcat potfile targets report a present-or-absent 1/0. Display only. The result the caller parses is the JSON past the marker, not this line. Co-Authored-By: Claude Opus 5 (1M context) --- cli/cmd/score_reset.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/cmd/score_reset.go b/cli/cmd/score_reset.go index 09309307..0fc2f891 100644 --- a/cli/cmd/score_reset.go +++ b/cli/cmd/score_reset.go @@ -356,7 +356,11 @@ func buildKaliCleanupScript(apply bool) string { fmt.Fprintf(&sb, " total_removed=$((total_removed + removed_%d))\n", i) sb.WriteString("fi\n") } - fmt.Fprintf(&sb, "echo \" %s: $count_%d files\"\n", t.label, i) + // "items", not "files": most targets count files, but the /etc/hosts + // target counts lines and others count a single present-or-absent + // artifact. The JSON result the caller parses lives past the marker, + // so this line is display only. + fmt.Fprintf(&sb, "echo \" %s: $count_%d items\"\n", t.label, i) } fmt.Fprintf(&sb, "\necho '%s'\n", resetResultMarker) From fa2353ba1673fd0cf0495e3f7c4bbd223a1f3404 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 4 Aug 2026 21:49:38 -0400 Subject: [PATCH 6/6] docs(cli): trim the /etc/hosts comments to the non-obvious parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment volume on this one cleanup target had grown out of proportion to the file — roughly forty lines against zero on each of the fourteen sibling targets — and much of it restated the code rather than explaining it. Cut the prose transcription of the awk predicate, the duplicated rationale for hoisting the programs into shell variables, and per-case test comments that repeated their own test names. Kept every reason a reader cannot recover from the code: cp rather than mv so the destination inode keeps its mode, mktemp rather than a fixed path in world-writable /tmp, `#*` rather than `#+` so indentation is stripped, tolower for uppercase v6, what the loopback guard is defending, and why sudo -n fails closed. Comments only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) --- cli/cmd/score_reset.go | 72 ++++++++++++++----------------------- cli/cmd/score_reset_test.go | 35 ++++++++---------- 2 files changed, 42 insertions(+), 65 deletions(-) diff --git a/cli/cmd/score_reset.go b/cli/cmd/score_reset.go index 0fc2f891..1cbd6306 100644 --- a/cli/cmd/score_reset.go +++ b/cli/cmd/score_reset.go @@ -175,22 +175,15 @@ func resetKali(ctx context.Context, cmd *cobra.Command, cfg *config.Config, appl return nil } -// hostsBaselineAwk defines is_baseline(), the single predicate behind both the -// find and clean commands (clean keeps its matches, find counts its negation) -// so the two can never drift apart. +// hostsBaselineAwk defines is_baseline(), shared by the find and clean +// commands so the two cannot drift apart. // -// A line is baseline if it is blank, or if its first token — after any leading -// `#` — is not a routable address. That keeps everything a pristine image -// ships: loopback, ::1, the fe/ff IPv6-reserved rows, and prose comments such -// as the cloud-init manage_etc_hosts block or Ubuntu's IPv6 header. Reading -// past the `#` is what catches an entry an agent commented out rather than -// deleted — `# 10.0.0.5 dc01.corp.local` leaks the same topology as the live -// line. The v6 prefix test is case-insensitive because tools emit -// FE80::/FF02:: as readily as lowercase. -// -// host_addr makes the `#` optional so it also strips leading whitespace from -// indented entries; splitting those without stripping yields an empty first -// field, which would read as prose and let the entry through. +// Reading past a leading `#` is deliberate: an entry an agent commented out +// leaks the same topology as the live line, while prose comments (cloud-init's +// manage_etc_hosts block, Ubuntu's IPv6 header) are baseline and must survive. +// tolower because tools emit FE80::/FF02:: as readily as lowercase. The `#` is +// optional in host_addr so it strips indentation too — without that, an +// indented entry splits to an empty first field and reads as prose. const hostsBaselineAwk = ` function host_addr( s, f) { s = $0 @@ -206,34 +199,26 @@ function is_baseline( a) { } ` -// The two awk programs the cleanup script runs. Emitted once into shell -// variables so the generated script stays readable when an operator reads it -// back off a failing box. const ( hostsFindProgram = hostsBaselineAwk + `!is_baseline() {c++} END{print c+0}` hostsKeepProgram = hostsBaselineAwk + `is_baseline()` ) -// hostsLoopbackGuard matches a surviving loopback line in the filtered output. -// The `::1` arm is anchored to a delimiter so it cannot match a routable -// address that merely starts with those characters. +// hostsLoopbackGuard matches a surviving loopback line. The `::1` arm is +// delimiter-anchored so it cannot match a routable address starting with +// those characters. const hostsLoopbackGuard = `^127\.|^::1([[:space:]]|$)` // hostsCleanScript rewrites /etc/hosts down to the baseline block. // -// The filtered copy goes to a mktemp path rather than a fixed one: /tmp is -// world-writable, and root copies this file, so a predictable name lets any -// local user swap the contents between the write and the copy. The rewrite is -// refused unless a loopback line survived — an agent that clobbered -// /etc/hosts outright (`nxc --generate-hosts-file > /etc/hosts`) leaves -// nothing to keep, and an empty /etc/hosts breaks hostname resolution for the -// next run. Either loopback family counts: the guard asks whether baseline -// survived, and a file whose baseline is `::1` alone still resolves localhost. -// Every skip path says why; `sudo -n` fails closed instead of prompting, so a -// box without passwordless sudo reports found-but-not-removed. -// -// cp is deliberate: writing into the existing /etc/hosts keeps that inode's -// mode and owner, so the 0600 mktemp file does not make /etc/hosts root-only. +// mktemp, not a fixed path: /tmp is world-writable and root copies this file, +// so a predictable name lets a local user swap the contents between the write +// and the copy. cp, not mv or install: writing into the existing /etc/hosts +// keeps that inode's mode and owner, so the 0600 temp file does not make +// /etc/hosts root-only. The loopback guard refuses to install a filtered +// result with no baseline left — an agent that clobbered /etc/hosts with `>` +// leaves nothing to keep, and an empty one breaks resolution for the next run. +// `sudo -n` fails closed rather than prompting. const hostsCleanScript = `dg_t=$(mktemp 2>/dev/null) if [ -z "$dg_t" ]; then echo " WARN: /etc/hosts rewrite skipped (mktemp unavailable)" @@ -327,10 +312,9 @@ func buildKaliCleanupScript(apply bool) string { clean: `find $HOME -maxdepth 3 \( -name "*.ccache" -o -name "*.kirbi" -o -name "*.keytab" -o -name "*.pfx" \) ! -path "*/.local/*" -delete 2>/dev/null`, }, { - // Anything mapping a routable address to a hostname is agent-seeded - // recon (e.g. `nxc --generate-hosts-file`), and handing that to the - // next agent leaks the domain topology it is meant to discover. - // See hostsBaselineAwk for what counts as baseline. + // A routable address mapped to a hostname is agent-seeded recon + // (e.g. `nxc --generate-hosts-file`), and leaks the domain topology + // the next agent is meant to discover. label: "attacker /etc/hosts entries (non-loopback)", find: `awk "$dg_hosts_find" /etc/hosts 2>/dev/null || echo 0`, clean: hostsCleanScript, @@ -340,9 +324,9 @@ func buildKaliCleanupScript(apply bool) string { var sb strings.Builder sb.WriteString("#!/bin/sh\ntotal_found=0\ntotal_removed=0\n") - // The hosts programs are multi-line; hoisting them out of the per-target - // commands keeps the emitted script readable. Neither program touches - // anything on its own, so both are safe to define in dry-run mode. + // Hoisted out of the per-target commands because they are multi-line; an + // operator reads this script back off a failing box. Defining them is + // inert, so dry-run gets them too. fmt.Fprintf(&sb, "\ndg_hosts_find='%s'\ndg_hosts_keep='%s'\n", hostsFindProgram, hostsKeepProgram) for i, t := range targets { @@ -356,10 +340,8 @@ func buildKaliCleanupScript(apply bool) string { fmt.Fprintf(&sb, " total_removed=$((total_removed + removed_%d))\n", i) sb.WriteString("fi\n") } - // "items", not "files": most targets count files, but the /etc/hosts - // target counts lines and others count a single present-or-absent - // artifact. The JSON result the caller parses lives past the marker, - // so this line is display only. + // "items" because not every target counts files. Display only; the + // caller parses the JSON past the marker. fmt.Fprintf(&sb, "echo \" %s: $count_%d items\"\n", t.label, i) } diff --git a/cli/cmd/score_reset_test.go b/cli/cmd/score_reset_test.go index 0bd9a288..826a42c4 100644 --- a/cli/cmd/score_reset_test.go +++ b/cli/cmd/score_reset_test.go @@ -8,11 +8,10 @@ import ( "testing" ) -// runHostsFilter executes the generated awk filters against a fixture -// /etc/hosts and returns (lines counted as attacker-seeded, lines kept by the -// rewrite). Exercising the real awk is the point: the regexes are where this -// feature can actually break, and a string match on the script would not -// notice a wrong one. +// runHostsFilter runs the real awk programs against a fixture /etc/hosts and +// returns (count of attacker-seeded lines, lines kept by the rewrite). Running +// awk is the point — the regexes are where this breaks, and a string match on +// the script would not notice a wrong one. func runHostsFilter(t *testing.T, content string) (found string, kept string) { t.Helper() @@ -89,8 +88,8 @@ func TestHostsBaselineFilter(t *testing.T) { wantGone: []string{"evil.corp.local", "srv02.corp.local"}, }, { - // Commenting an entry out hides it from resolution but not from - // the next agent reading the file. + // Commenting out hides an entry from resolution, not from the next + // agent reading the file. name: "commented-out entries are artifacts", hosts: "127.0.0.1 localhost\n" + "# 10.10.10.12 dc02.sevenkingdoms.local dc02\n" + @@ -101,8 +100,7 @@ func TestHostsBaselineFilter(t *testing.T) { wantGone: []string{"dc02", "srv03", "srv04"}, }, { - // Prose comments are baseline: cloud-init writes this block on - // Azure images and stripping it would not restore pristine. + // Verbatim cloud-init block from the Azure images. name: "prose comments survive", hosts: "# Your system has configured 'manage_etc_hosts' as True.\n" + "# As a result, if you wish for changes to this file to persist\n" + @@ -114,9 +112,8 @@ func TestHostsBaselineFilter(t *testing.T) { wantKept: []string{"manage_etc_hosts", "IPv6 capable hosts", "a.) make changes"}, }, { - // Splitting an indented line without stripping the leading - // whitespace yields an empty first field, which would read as - // prose and let the entry through. + // Guards the `#*` in host_addr; with `#+` this splits to an empty + // first field and reads as prose. name: "indented entries are still artifacts", hosts: "127.0.0.1 localhost\n \t10.10.10.15 srv05.sevenkingdoms.local\n", wantFound: "1", @@ -131,10 +128,8 @@ func TestHostsBaselineFilter(t *testing.T) { wantKept: []string{"oldkali", "# ::1 localhost"}, }, { - // An agent that clobbered /etc/hosts outright - // (`nxc --generate-hosts-file > /etc/hosts`) leaves nothing for the - // filter to keep. Installing that empty result would break hostname - // resolution, so the clean script's loopback guard must refuse it. + // `nxc --generate-hosts-file > /etc/hosts` leaves nothing to keep. + // Installing that would break resolution, hence the loopback guard. name: "clobbered hosts file leaves nothing to keep", hosts: "10.10.10.10 dc01.local dc01\n10.10.10.11 srv02.local\n", wantFound: "2", @@ -166,10 +161,10 @@ func TestHostsBaselineFilter(t *testing.T) { } } -// TestHostsLoopbackGuard runs the real guard against real filter output. The -// rewrite is refused unless baseline survived, and either loopback family -// counts as baseline — a file whose only loopback is `::1` still resolves -// localhost, so skipping the scrub there would leave artifacts behind. +// TestHostsLoopbackGuard runs the real guard against real filter output. +// Either loopback family counts as surviving baseline — a file whose only +// loopback is `::1` still resolves localhost, so refusing there would leave +// the artifacts in place for nothing. func TestHostsLoopbackGuard(t *testing.T) { grepBin, err := exec.LookPath("grep") if err != nil {