0b09a799cb
Pairs with agent v0.77.0. StageEscrowSecret pushes the repo password to the agent (POST /escrow/stage-secret) at offsite-enable → EscrowState="pending". Atomicity gate: RunOffboxBackup (scheduler + handler) refuses until EscrowState="escrowed" (operator POST /backup/offbox/confirm-escrow after the escrow ceremony) — no un-recoverable offsite ciphertext can exist. DR: POST /backup/offbox/inject-password pre-places a recovered 64-hex password 0600 (honored by WriteOffboxSecrets' IsNotExist guard; refuses clobber without force). DR recipe gains non-secret offsite_restic coords (DRResticCoord); SFTP key regenerated at DR, not escrowed. New settings.OffboxTarget.EscrowState. Tests + atomicity & inject companion red-proofs green; UI gates pass. NOT yet live-validated (supervised ceremony). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
232 lines
8.6 KiB
Go
232 lines
8.6 KiB
Go
package report
|
|
|
|
import (
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// secretNameRe matches any JSON key that smells like a credential — mirrors the agent half.
|
|
var secretNameRe = regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
|
|
|
|
// rommCompose is a realistic catalog compose: user-data binds via ${USERDATA_PATH}/${HDD_PATH}, plus a
|
|
// secret-laden environment section (which must NEVER reach the recipe — bindings come from volumes only).
|
|
const rommCompose = `services:
|
|
romm:
|
|
image: romm:latest
|
|
environment:
|
|
- DB_PASSWORD=${DB_PASSWORD}
|
|
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET}
|
|
volumes:
|
|
- ${USERDATA_PATH}/roms:/roms
|
|
- ${HDD_PATH}/appdata/romm/resources:/romm/resources
|
|
- romm_redis_data:/data
|
|
volumes:
|
|
romm_redis_data:
|
|
`
|
|
|
|
// secretLadenStack builds a romm Stack whose persisted Env carries synthetic secrets (an ENC: value and
|
|
// a token-shaped value) — exactly what the recipe must keep out.
|
|
func secretLadenStack() stacks.Stack {
|
|
return stacks.Stack{
|
|
Name: "romm",
|
|
Meta: stacks.Metadata{Slug: "romm", DisplayName: "RomM"},
|
|
ComposePath: "/stacks/romm/docker-compose.yml",
|
|
Deployed: true,
|
|
AppConfig: &stacks.AppConfig{
|
|
Deployed: true,
|
|
Env: map[string]string{
|
|
"HDD_PATH": "/mnt/felhom-drives/felhom-flash",
|
|
"DB_PASSWORD": "ENC:U2FsdGVkX1+DEADBEEFsecret==",
|
|
"IGDB_CLIENT_SECRET": "tok_live_SUPERSECRET_must_not_leak",
|
|
"SECRET_KEY": "ENC:another_encrypted_blob",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestBuildAppRecipe_NoSecrets is THE load-bearing boundary test: the emitted AppRecipe for an app whose
|
|
// deploy config contains secrets (ENC: + token-shaped) carries NONE of those values and NO
|
|
// credential-shaped key — only the {catalog_ref, enabled, storage_bindings} allowlist.
|
|
func TestBuildAppRecipe_NoSecrets(t *testing.T) {
|
|
s := secretLadenStack()
|
|
rec := buildAppRecipe(s, rommCompose)
|
|
|
|
b, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := string(b)
|
|
|
|
// (1) none of the secret VALUES survived.
|
|
for _, leak := range []string{"ENC:", "U2FsdGVkX1", "DEADBEEF", "tok_live_SUPERSECRET_must_not_leak", "another_encrypted_blob"} {
|
|
if strings.Contains(out, leak) {
|
|
t.Errorf("SECRET LEAK: emitted recipe contains %q\n recipe: %s", leak, out)
|
|
}
|
|
}
|
|
// (2) no credential-shaped KEY survived (DB_PASSWORD / SECRET_KEY / IGDB_CLIENT_SECRET as keys).
|
|
assertNoSecretKeys(t, b)
|
|
|
|
// (3) positive: we DID emit the allowlisted facts (not a vacuous pass).
|
|
if rec.CatalogRef != "romm" || !rec.Enabled {
|
|
t.Errorf("expected catalog_ref=romm enabled=true, got %+v", rec)
|
|
}
|
|
if len(rec.StorageBindings) != 2 {
|
|
t.Fatalf("expected 2 storage bindings (roms + resources), got %+v", rec.StorageBindings)
|
|
}
|
|
}
|
|
|
|
// TestBuildAppRecipe_AllowlistIsLoadBearing is the companion (red-proof of the boundary test): a NAIVE
|
|
// emitter that dumps AppConfig.Env (i.e. the allowlist guard removed) WOULD leak the token — proving the
|
|
// no-secrets assertion above is real, not vacuous. The production emitter must NOT leak it.
|
|
func TestBuildAppRecipe_AllowlistIsLoadBearing(t *testing.T) {
|
|
s := secretLadenStack()
|
|
const token = "tok_live_SUPERSECRET_must_not_leak"
|
|
|
|
// The "guard removed" shape — dumping Env alongside the app. This is what the boundary forbids.
|
|
unsafe, _ := json.Marshal(map[string]any{"catalog_ref": s.Meta.Slug, "env": s.AppConfig.Env})
|
|
if !strings.Contains(string(unsafe), token) {
|
|
t.Fatal("companion is broken: the unsafe (guard-removed) shape should contain the secret token")
|
|
}
|
|
|
|
// The REAL emitter must keep it out — same fixture, allowlist intact.
|
|
real, _ := json.Marshal(buildAppRecipe(s, rommCompose))
|
|
if strings.Contains(string(real), token) {
|
|
t.Fatalf("BOUNDARY VIOLATION: production emitter leaked the token: %s", real)
|
|
}
|
|
}
|
|
|
|
// Scenario E (fork-4) — the OffsiteRestic DR coord carries coordinates ONLY (no password/key), clears the
|
|
// secret-name regex, and its fields are emitted. Extends the _NoSecrets boundary to the new field.
|
|
func TestDRResticCoord_NoSecrets(t *testing.T) {
|
|
half := &DRRecipeAppHalf{
|
|
RecipeVersion: DRRecipeVersion,
|
|
Customer: DRCustomer{ID: "cust", Display: "Cust", Domain: "demo-felhom.eu"},
|
|
Apps: []AppRecipe{},
|
|
OffsiteRestic: &DRResticCoord{
|
|
Host: "u629193-sub1.your-storagebox.de", User: "u629193-sub1", Port: 23, RepoPath: "/home/felhom-demo-repo",
|
|
},
|
|
}
|
|
b, err := json.Marshal(half)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// (1) no credential-shaped KEY name survived (the DRResticCoord field names must clear the regex).
|
|
assertNoSecretKeys(t, b)
|
|
out := string(b)
|
|
// (2) positive: the coordinate fields ARE emitted (not a vacuous pass).
|
|
for _, want := range []string{`"offsite_restic"`, `"host":"u629193-sub1.your-storagebox.de"`, `"user":"u629193-sub1"`, `"port":23`, `"repo_path":"/home/felhom-demo-repo"`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("DR coord missing %s in %s", want, out)
|
|
}
|
|
}
|
|
// (3) the password/key field names must never appear.
|
|
if strings.Contains(out, "repo_password") || strings.Contains(out, "ssh_key") || strings.Contains(out, "password") {
|
|
t.Fatalf("DR coord must carry NO password/key: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestAppStorageBindings(t *testing.T) {
|
|
got := appStorageBindings(rommCompose, "/mnt/felhom-drives/felhom-flash")
|
|
want := map[string]StorageBinding{
|
|
"/roms": {ContainerPath: "/roms", Drive: "felhom-flash", Subpath: "userdata/roms"},
|
|
"/romm/resources": {ContainerPath: "/romm/resources", Drive: "felhom-flash", Subpath: "appdata/romm/resources"},
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("got %d bindings, want %d: %+v", len(got), len(want), got)
|
|
}
|
|
for _, b := range got {
|
|
w, ok := want[b.ContainerPath]
|
|
if !ok || b != w {
|
|
t.Errorf("binding %+v unexpected (want %+v)", b, w)
|
|
}
|
|
}
|
|
// The named volume (romm_redis_data) is NOT a drive bind → excluded.
|
|
for _, b := range got {
|
|
if strings.Contains(b.Subpath, "redis") {
|
|
t.Errorf("named volume leaked into bindings: %+v", b)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAppStorageBindings_NoHDD: an app with no HDD_PATH (rootfs-only) yields no bindings, non-nil slice.
|
|
func TestAppStorageBindings_NoHDD(t *testing.T) {
|
|
got := appStorageBindings(rommCompose, "")
|
|
if got == nil || len(got) != 0 {
|
|
t.Errorf("no HDD_PATH should yield empty (non-nil) bindings, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildDRRecipeAppHalf(t *testing.T) {
|
|
reader := func(path string) string {
|
|
if path == "/stacks/romm/docker-compose.yml" {
|
|
return rommCompose
|
|
}
|
|
return ""
|
|
}
|
|
all := []stacks.Stack{
|
|
secretLadenStack(),
|
|
{Name: "traefik", Protected: true, Deployed: true}, // protected → excluded
|
|
{Name: "vikunja", Meta: stacks.Metadata{Slug: "vikunja"}, Deployed: false}, // not deployed → excluded
|
|
{Name: "actualbudget", Meta: stacks.Metadata{Slug: "actualbudget"}, Deployed: true}, // rootfs app, no compose path
|
|
}
|
|
half := BuildDRRecipeAppHalf("cust-demo", "Demo Customer", "demo-felhom.eu", all, reader)
|
|
|
|
if half.RecipeVersion != 1 {
|
|
t.Errorf("recipe_version=%d want 1", half.RecipeVersion)
|
|
}
|
|
if half.Customer.ID != "cust-demo" || half.Customer.Display != "Demo Customer" || half.Customer.Domain != "demo-felhom.eu" {
|
|
t.Errorf("customer = %+v", half.Customer)
|
|
}
|
|
// Only romm + actualbudget (deployed, non-protected). traefik (protected) + vikunja (not deployed) out.
|
|
if len(half.Apps) != 2 {
|
|
t.Fatalf("expected 2 apps, got %d: %+v", len(half.Apps), half.Apps)
|
|
}
|
|
byRef := map[string]AppRecipe{}
|
|
for _, a := range half.Apps {
|
|
byRef[a.CatalogRef] = a
|
|
}
|
|
if r, ok := byRef["romm"]; !ok || len(r.StorageBindings) != 2 {
|
|
t.Errorf("romm recipe wrong: %+v", r)
|
|
}
|
|
if r, ok := byRef["actualbudget"]; !ok || len(r.StorageBindings) != 0 {
|
|
t.Errorf("actualbudget (rootfs) should have 0 bindings: %+v", r)
|
|
}
|
|
|
|
// Whole-half no-secrets sweep.
|
|
b, _ := json.Marshal(half)
|
|
assertNoSecretKeys(t, b)
|
|
if strings.Contains(string(b), "tok_live_SUPERSECRET_must_not_leak") {
|
|
t.Errorf("SECRET LEAK in assembled app-half: %s", b)
|
|
}
|
|
}
|
|
|
|
// assertNoSecretKeys walks decoded JSON and fails on any object key matching secretNameRe.
|
|
func assertNoSecretKeys(t *testing.T, jsonBytes []byte) {
|
|
t.Helper()
|
|
var v any
|
|
if err := json.Unmarshal(jsonBytes, &v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var walk func(prefix string, node any)
|
|
walk = func(prefix string, node any) {
|
|
switch n := node.(type) {
|
|
case map[string]any:
|
|
for k, child := range n {
|
|
if secretNameRe.MatchString(k) {
|
|
t.Errorf("secret-shaped key %q at %s — the recipe must carry no credential field", k, prefix)
|
|
}
|
|
walk(prefix+"."+k, child)
|
|
}
|
|
case []any:
|
|
for _, child := range n {
|
|
walk(prefix, child)
|
|
}
|
|
}
|
|
}
|
|
walk("<root>", v)
|
|
}
|