Files
felhom-agent/internal/capability/manifest_test.go
T

297 lines
10 KiB
Go

package capability
import (
"os"
"reflect"
"regexp"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
)
// sudoersPath is the in-repo allowlist, relative to this test file (internal/capability/).
const sudoersPath = "../../configs/felhom-agent.sudoers"
// parseSudoersEntries returns every command pattern from the Cmnd_Alias blocks, with the sudoers
// escapes (`\,` `\:`) unescaped. It joins continuation lines and splits the alias RHS on commas
// that are NOT backslash-escaped (escaped commas are literal arg chars, e.g. the lvs `-o` list).
func parseSudoersEntries(t *testing.T, text string) []string {
t.Helper()
// 1. Collapse line continuations, keeping only Cmnd_Alias RHS text.
var rhs strings.Builder
lines := strings.Split(text, "\n")
inAlias := false
for _, ln := range lines {
trimmed := strings.TrimSpace(ln)
if strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "Cmnd_Alias ") {
inAlias = true
if eq := strings.IndexByte(trimmed, '='); eq >= 0 {
trimmed = trimmed[eq+1:]
}
} else if !inAlias {
continue
}
// The final NOPASSWD line ("felhom-agent ALL=...") ends the alias region.
if strings.Contains(trimmed, "ALL=(") {
inAlias = false
continue
}
cont := strings.HasSuffix(trimmed, "\\")
rhs.WriteString(strings.TrimSuffix(trimmed, "\\"))
rhs.WriteString(" ")
if !cont {
// A non-continued line is the last entry of this alias. Emit a comma so it does not
// merge with the next alias's first entry when all RHS text is concatenated.
rhs.WriteString(", ")
inAlias = false
}
}
// 2. Split on unescaped commas → individual command entries.
raw := rhs.String()
var entries []string
var cur strings.Builder
for i := 0; i < len(raw); i++ {
c := raw[i]
if c == '\\' && i+1 < len(raw) {
cur.WriteByte(raw[i+1]) // unescape: keep the next char literally (\, → , ; \: → :)
i++
continue
}
if c == ',' {
entries = appendTrimmed(entries, cur.String())
cur.Reset()
continue
}
cur.WriteByte(c)
}
entries = appendTrimmed(entries, cur.String())
return entries
}
func appendTrimmed(entries []string, s string) []string {
if t := strings.Join(strings.Fields(s), " "); t != "" {
return append(entries, t)
}
return entries
}
// globToRegex translates a sudoers fnmatch pattern to an anchored regex. It is NOT a perfect sudo
// emulator — it only needs to catch a removed/renamed grant (the real failure mode). `*` → `.*`,
// `[...]` char classes pass through (valid regex), regex metachars are escaped.
func globToRegex(pat string) *regexp.Regexp {
var b strings.Builder
b.WriteString("^")
for i := 0; i < len(pat); i++ {
c := pat[i]
switch {
case c == '*':
b.WriteString(".*")
case c == '[': // copy the char class verbatim (valid in regex too)
if j := strings.IndexByte(pat[i:], ']'); j > 0 {
b.WriteString(pat[i : i+j+1])
i += j
continue
}
b.WriteString("\\[")
case strings.IndexByte(`.+()|{}^$\?`, c) >= 0:
b.WriteByte('\\')
b.WriteByte(c)
default:
b.WriteByte(c)
}
}
b.WriteString("$")
return regexp.MustCompile(b.String())
}
// matchesAny reports whether cmdline matches at least one sudoers entry pattern.
func matchesAny(cmdline string, entries []string) bool {
for _, e := range entries {
if globToRegex(e).MatchString(cmdline) {
return true
}
}
return false
}
// TestManifestCoveredBySudoers is the headline build-time gate: EVERY manifest capability's
// representative command line must be permitted by at least one sudoers pattern. This is exactly
// the check that would have caught the lxc-info / make-private grants being dropped at the
// 2026-06-28 cutover — in CI, before shipping.
func TestManifestCoveredBySudoers(t *testing.T) {
data, err := os.ReadFile(sudoersPath)
if err != nil {
t.Fatalf("read sudoers %s: %v", sudoersPath, err)
}
entries := parseSudoersEntries(t, string(data))
if len(entries) < 20 {
t.Fatalf("parsed only %d sudoers entries — parser likely broke", len(entries))
}
for _, c := range Manifest() {
cmdline := strings.TrimSpace(c.Binary + " " + strings.Join(c.ReprArgs, " "))
if !matchesAny(cmdline, entries) {
t.Errorf("capability %q (%s) NOT covered by any sudoers grant:\n %s",
c.Name, c.Feature, cmdline)
}
}
}
// TestRedProof_DroppedGrantFailsCheck is the companion red-proof: with the lxc-info line removed
// from an in-memory copy of the sudoers, the coverage check for guest-init-pid MUST fail. Proves
// the build gate actually catches the regression (a green test that can never go red is hollow).
func TestRedProof_DroppedGrantFailsCheck(t *testing.T) {
data, err := os.ReadFile(sudoersPath)
if err != nil {
t.Fatalf("read sudoers: %v", err)
}
// Drop the lxc-info grant line.
var kept []string
for _, ln := range strings.Split(string(data), "\n") {
if strings.Contains(ln, "lxc-info") {
continue
}
kept = append(kept, ln)
}
mutated := strings.Join(kept, "\n")
if strings.Contains(mutated, "lxc-info") {
t.Fatal("setup: lxc-info line not removed")
}
entries := parseSudoersEntries(t, mutated)
var guestInit Capability
for _, c := range Manifest() {
if c.Name == "guest-init-pid" {
guestInit = c
}
}
if guestInit.Name == "" {
t.Fatal("manifest missing guest-init-pid")
}
cmdline := guestInit.Binary + " " + strings.Join(guestInit.ReprArgs, " ")
if matchesAny(cmdline, entries) {
t.Errorf("red-proof FAILED: guest-init-pid still matches after dropping the lxc-info grant — the build gate would NOT catch the regression")
}
// Sanity: the UNMUTATED file MUST cover it (so the failure above is specific to the drop).
full := parseSudoersEntries(t, string(data))
if !matchesAny(cmdline, full) {
t.Errorf("guest-init-pid should be covered by the real sudoers")
}
}
// TestRedProof_DroppedControllerSwapTeeFailsCheck is the companion red-proof for the v0.45.0
// FELHOM_CONTROLLERSWAP grants: with the `tee /etc/felhom-controller-image` line removed, the
// controllerswap-write capability MUST be reported uncovered. Proves the build gate watches the new
// swap write grant (so dropping it can't ship a non-root agent that silently can't auto-update).
func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
data, err := os.ReadFile(sudoersPath)
if err != nil {
t.Fatalf("read sudoers: %v", err)
}
var kept []string
for _, ln := range strings.Split(string(data), "\n") {
if strings.Contains(ln, "tee /etc/felhom-controller-image") {
continue
}
kept = append(kept, ln)
}
mutated := strings.Join(kept, "\n")
entries := parseSudoersEntries(t, mutated)
var write Capability
for _, c := range Manifest() {
if c.Name == "controllerswap-write" {
write = c
}
}
if write.Name == "" {
t.Fatal("manifest missing controllerswap-write")
}
cmdline := write.Binary + " " + strings.Join(write.ReprArgs, " ")
if matchesAny(cmdline, entries) {
t.Errorf("red-proof FAILED: controllerswap-write still matches after dropping the tee grant")
}
if full := parseSudoersEntries(t, string(data)); !matchesAny(cmdline, full) {
t.Errorf("controllerswap-write should be covered by the real sudoers")
}
}
// TestEscrowCeremonyArgvPinned locks the ceremony argv copies together (Scenario G, v0.88.0).
// The exec runner and the manifest entry both consume escrow.CeremonyArgs() (one shared source),
// and TestManifestCoveredBySudoers proves manifest ⊆ sudoers — so pinning the shared source to
// the EXPECTED literal here transitively locks all three: runner == manifest == sudoers.
// Red-proof: mutate one element of the argv in internal/escrow/ceremony.go and THIS test fails
// (and so does the sudoers coverage); a sudoers-side mutation is caught by the existing
// TestRedProof_* machinery.
func TestEscrowCeremonyArgvPinned(t *testing.T) {
wantBinary := "/usr/local/bin/felhom-agent"
wantArgs := []string{"--config", "/etc/felhom-agent/agent.json", "--selftest=escrow-create", "--upload", "--output=json"}
if escrow.CeremonyBinary != wantBinary {
t.Errorf("escrow.CeremonyBinary = %q, want %q", escrow.CeremonyBinary, wantBinary)
}
if got := escrow.CeremonyArgs(); !reflect.DeepEqual(got, wantArgs) {
t.Errorf("escrow.CeremonyArgs() = %q, want %q (the sudoers line + manifest entry must stay byte-identical)", got, wantArgs)
}
var entry Capability
for _, c := range Manifest() {
if c.Name == "escrow-ceremony" {
entry = c
}
}
if entry.Name == "" {
t.Fatal("manifest missing escrow-ceremony")
}
if entry.Binary != escrow.CeremonyBinary || !reflect.DeepEqual(entry.ReprArgs, escrow.CeremonyArgs()) {
t.Errorf("manifest escrow-ceremony argv diverged from the shared constant: %s %q", entry.Binary, entry.ReprArgs)
}
if !entry.Critical {
t.Error("escrow-ceremony must be Critical (the wizard's whole run path is this one grant)")
}
if entry.GatedBy != GatePBSDR {
t.Errorf("escrow-ceremony GatedBy = %q, want %q (no PBS key → no ceremony; inactive, never red, on a DR-off box)", entry.GatedBy, GatePBSDR)
}
// CeremonyArgs must return a COPY — a caller mutating its slice must not poison the source.
mutated := escrow.CeremonyArgs()
mutated[0] = "--poisoned"
if got := escrow.CeremonyArgs(); !reflect.DeepEqual(got, wantArgs) {
t.Error("escrow.CeremonyArgs() shares its backing array — callers can mutate the source")
}
}
// TestWGCapabilityCriticality pins the exact S4 (v0.66.0) Critical set for the FELHOM_WG entries:
// the backup path (conf install, unit enable/restart, handshake read) is operator-alert-worthy now
// that offsite backups ride the tunnel; the one-time apt install and the deliberate disable
// (revocation) are NOT. Red-proof: flip any one entry's Critical in manifest.go and this fails.
func TestWGCapabilityCriticality(t *testing.T) {
wantCritical := map[string]bool{
"wg-tools-install": false,
"wg-conf-install": true,
"wg-enable": true,
"wg-restart": true,
"wg-disable": false,
"wg-handshake-read": true,
}
seen := map[string]bool{}
for _, c := range Manifest() {
want, ok := wantCritical[c.Name]
if !ok {
continue
}
seen[c.Name] = true
if c.Critical != want {
t.Errorf("%s: Critical = %v, want %v", c.Name, c.Critical, want)
}
}
for name := range wantCritical {
if !seen[name] {
t.Errorf("manifest missing wg capability %q", name)
}
}
}