Skip to content
Merged
73 changes: 72 additions & 1 deletion cli/cmd/score_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,62 @@ func resetKali(ctx context.Context, cmd *cobra.Command, cfg *config.Config, appl
return nil
}

// hostsBaselineAwk defines is_baseline(), shared by the find and clean
// commands so the two cannot drift apart.
//
// 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
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]/
}
`

const (
hostsFindProgram = hostsBaselineAwk + `!is_baseline() {c++} END{print c+0}`
hostsKeepProgram = hostsBaselineAwk + `is_baseline()`
)

// 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.
//
// 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)"
elif ! awk "$dg_hosts_keep" /etc/hosts > "$dg_t" 2>/dev/null; then
Comment thread
mkultraWasHere marked this conversation as resolved.
echo " WARN: /etc/hosts rewrite skipped (could not read /etc/hosts)"
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)"
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 {
Expand Down Expand Up @@ -255,11 +311,24 @@ 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`,
},
{
// 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,
},
}

var sb strings.Builder
sb.WriteString("#!/bin/sh\ntotal_found=0\ntotal_removed=0\n")

// 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 {
fmt.Fprintf(&sb, "\n# %s\ncount_%d=$(%s)\ntotal_found=$((total_found + count_%d))\n", t.label, i, t.find, i)
if apply {
Expand All @@ -271,7 +340,9 @@ 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" 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)
}

fmt.Fprintf(&sb, "\necho '%s'\n", resetResultMarker)
Expand Down
263 changes: 263 additions & 0 deletions cli/cmd/score_reset_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package cmd

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// 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()

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, hostsFindProgram, path).Output()
if err != nil {
t.Fatalf("count filter: %v", err)
}
keptOut, err := exec.Command(awkBin, hostsKeepProgram, 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"},
},
{
// 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" +
"#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"},
},
{
// 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" +
"# 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"},
},
{
// 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",
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"},
},
{
// `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",
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)
}
})
}
}

// 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 {
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.
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)
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 -qE '` + hostsLoopbackGuard + `'`,
} {
if !strings.Contains(applyScript, want) {
t.Errorf("apply mode missing %q", want)
}
}
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.
if strings.Contains(buildKaliCleanupScript(false), "sudo -n cp") {
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)
}
}
}
Loading