package hostsecurity import ( "os" "path/filepath" "strings" "testing" ) func TestRenderFirewallSafeManagedTable(t *testing.T) { p := FirewallPolicy{ Enabled: true, DefaultInbound: "drop", AllowICMP: true, TrustedCIDRs: []string{"10.0.0.0/8", "2001:db8::/32"}, Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "22", Source: "192.0.2.0/24", Comment: "ssh"}, {Action: "drop", Protocol: "udp", Port: "10000-10100"}}, } out, err := renderFirewall(p) if err != nil { t.Fatal(err) } for _, want := range []string{"table inet dockwatch", "policy drop", "ct state established,related accept", "ip saddr 192.0.2.0/24 tcp dport 22 accept", "udp dport 10000-10100 drop"} { if !strings.Contains(out, want) { t.Fatalf("rendered firewall missing %q:\n%s", want, out) } } if strings.Contains(out, "flush ruleset") { t.Fatal("managed firewall must never flush the global ruleset") } } func TestRenderFirewallRejectsUnsafeInput(t *testing.T) { cases := []FirewallPolicy{ {DefaultInbound: "drop", Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "0"}}}, {DefaultInbound: "drop", Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "22", Source: "not-a-cidr"}}}, {DefaultInbound: "drop", Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "22", Comment: "x\nadd rule"}}}, } for i, p := range cases { if _, err := renderFirewall(p); err == nil { t.Fatalf("case %d should fail", i) } } } func TestRenderFail2Ban(t *testing.T) { p := Fail2BanPolicy{Bantime: "1h", Findtime: "10m", MaxRetry: 5, Backend: "auto", IgnoreIP: []string{"127.0.0.1/8", "::1"}, Jails: []Fail2BanJail{{Name: "sshd", Enabled: true, Port: "ssh", Filter: "sshd", Backend: "systemd"}}} out, err := renderFail2Ban(p) if err != nil { t.Fatal(err) } for _, want := range []string{"[DEFAULT]", "bantime = 1h", "[sshd]", "enabled = true", "backend = systemd"} { if !strings.Contains(out, want) { t.Fatalf("missing %q", want) } } } func TestRenderAuditdUsesFileWatchesOnly(t *testing.T) { p := AuditdPolicy{IdentityFiles: true, Docker: true, Custom: []AuditWatch{{Path: "/srv/app", Permissions: "wa", Key: "app-config"}}} out, err := renderAuditd(p) if err != nil { t.Fatal(err) } if !strings.Contains(out, "-w /etc/passwd -p wa -k identity") || !strings.Contains(out, "-w /srv/app -p wa -k app-config") { t.Fatalf("unexpected rules:\n%s", out) } if strings.Contains(out, "-a always") { t.Fatal("Dockwatch preset should not inject broad syscall rules") } } func TestAuditWatchValidation(t *testing.T) { bad := []AuditdPolicy{{Custom: []AuditWatch{{Path: "relative", Permissions: "wa", Key: "x"}}}, {Custom: []AuditWatch{{Path: "/srv/x", Permissions: "777", Key: "x"}}}, {Custom: []AuditWatch{{Path: "/srv/x", Permissions: "wa", Key: "x;bad"}}}} for i, p := range bad { if _, err := renderAuditd(p); err == nil { t.Fatalf("case %d should fail", i) } } } func TestFirewallProviderPlansPreserveForeignRules(t *testing.T) { p := FirewallPolicy{ Provider: FirewallProviderUFW, Enabled: true, ManageDefault: true, DefaultInbound: "drop", TrustedCIDRs: []string{"192.0.2.0/24"}, Rules: []FirewallRule{ {Action: "accept", Protocol: "tcp", Port: "22", Comment: "ssh"}, {Action: "limit", Protocol: "tcp", Port: "443", Source: "198.51.100.0/24", Comment: "https"}, }, } backend := FirewallBackendInfo{Selected: FirewallProviderUFW} out, warnings, err := renderFirewallPlan(p, backend, FirewallRuntimeView{Provider: FirewallProviderUFW}) if err != nil { t.Fatal(err) } for _, want := range []string{"ufw default deny incoming", "ufw allow from 192.0.2.0/24", "ufw allow proto tcp", "ufw limit proto tcp"} { if !strings.Contains(out, want) { t.Fatalf("UFW plan missing %q:\n%s", want, out) } } if strings.Contains(strings.ToLower(out), "ufw reset") { t.Fatal("UFW provider must never reset foreign rules") } if len(warnings) == 0 { t.Fatal("expected provider safety warnings") } } func TestFirewalldPlanUsesNativeRichRules(t *testing.T) { p := FirewallPolicy{ Provider: FirewallProviderFirewalld, Enabled: true, ManageDefault: true, DefaultInbound: "drop", Zone: "public", Rules: []FirewallRule{{Action: "reject", Protocol: "tcp", Port: "23", Source: "203.0.113.0/24"}}, } backend := FirewallBackendInfo{Selected: FirewallProviderFirewalld, DefaultZone: "public"} out, _, err := renderFirewallPlan(p, backend, FirewallRuntimeView{Provider: FirewallProviderFirewalld, Zone: "public"}) if err != nil { t.Fatal(err) } for _, want := range []string{"firewall-cmd --zone=public --set-target=DROP", "--add-rich-rule", `source address=\"203.0.113.0/24\"`, "reject"} { if !strings.Contains(out, want) { t.Fatalf("firewalld plan missing %q:\n%s", want, out) } } if strings.Contains(out, "--remove-service") || strings.Contains(out, "--remove-port") { t.Fatal("preview must not suggest deleting foreign zone primitives") } } func TestFirewallProviderNormalization(t *testing.T) { for in, want := range map[string]string{"": "auto", "AUTO": "auto", "ufw": "ufw", "Firewalld": "firewalld", "nftables": "nftables"} { if got := normalizeFirewallProvider(in); got != want { t.Fatalf("normalize %q = %q want %q", in, got, want) } } if got := normalizeFirewallProvider("iptables"); got != "" { t.Fatalf("unsupported provider should normalize to empty, got %q", got) } } func TestRenderNftablesSupportsRejectAndLimit(t *testing.T) { p := FirewallPolicy{DefaultInbound: "accept", Rules: []FirewallRule{ {Action: "reject", Protocol: "tcp", Port: "23"}, {Action: "limit", Protocol: "tcp", Port: "22"}, }} out, err := renderFirewall(p) if err != nil { t.Fatal(err) } if !strings.Contains(out, "tcp dport 23 reject") { t.Fatalf("reject rule missing:\n%s", out) } if !strings.Contains(out, "tcp dport 22 ct state new limit rate 6/minute accept") { t.Fatalf("limit rule missing:\n%s", out) } bad := FirewallPolicy{Rules: []FirewallRule{{Action: "limit", Protocol: "udp", Port: "53"}}} if _, err := renderFirewall(bad); err == nil { t.Fatal("UDP limit rule should be rejected") } } func TestUFWDefaultFromConfigWhenInactive(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "etc", "default"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "etc", "default", "ufw"), []byte("DEFAULT_INPUT_POLICY=\"DROP\"\n"), 0o644); err != nil { t.Fatal(err) } s := New(Config{Enabled: true, HostRoot: root, DataDir: t.TempDir()}) if got := s.ufwDefaultFromConfig(); got != "drop" { t.Fatalf("ufw default = %q want drop", got) } } func TestUFWRulesHaveDeterministicDockwatchOwnershipTags(t *testing.T) { r := FirewallRule{Action: "accept", Protocol: "tcp", Port: "22", Source: "192.0.2.0/24", Comment: "ssh admin"} a := ufwRuleArgs(r) b := ufwRuleArgs(r) if strings.Join(a, "|") != strings.Join(b, "|") { t.Fatal("UFW rule tag must be deterministic") } joined := strings.Join(a, " ") if !strings.Contains(joined, "dockwatch:rule:") { t.Fatalf("missing Dockwatch ownership tag: %s", joined) } if strings.Contains(strings.ToLower(joined), "reset") { t.Fatal("UFW rule command must not reset firewall") } }