From 501d59f21b2879ea541e964b0f68de516593a669 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 29 Sep 2026 22:56:10 +0000 Subject: [PATCH] Preserve secret sensitivity across sibling inheritance --- docs/roadmap/implementation.md | 7 ++ internal/engine/inheritance_secret_test.go | 136 +++++++++++++++++++++ internal/engine/secret.go | 39 ++++++ 3 files changed, 182 insertions(+) create mode 100644 internal/engine/inheritance_secret_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 073d427..dd5dfd8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -244,6 +244,13 @@ history budgets. Run the everyday checker. ## Stage 6 — Preserve sensitivity through inheritance +**Completed 2026-09-29.** Sensitivity classification now follows corresponding +source/destination paths through structural transfers and controls to a fixed +point, while leaving neighboring fields unclassified. Historical replacement +evidence remains redacted, and inherited secret references remain unresolved +until the existing typed lookup point. Focused secret/history tests and +`./scripts/check.sh` passed. Public loading remains unchanged. + Connect transfer dependencies to existing secret classification and historical redaction. Classify both source and destination paths under schema and `Definition.Secret` rules; propagate sensitivity through chains/nested copies. diff --git a/internal/engine/inheritance_secret_test.go b/internal/engine/inheritance_secret_test.go new file mode 100644 index 0000000..ac70be1 --- /dev/null +++ b/internal/engine/inheritance_secret_test.go @@ -0,0 +1,136 @@ +package engine + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +func TestInheritanceSensitivityFollowsCopiesWithoutMaskingNeighbors(t *testing.T) { + const canary = "INHERITED-CANARY-CREDENTIAL" + input := parseCompositionDocument(t, "source-1", "parent: {token: "+canary+", plain: visible}\nchild: {extends: parent}\nnext: {extends: child}\nother: {extends: parent}\n") + for _, classified := range []Path{Keys("parent", "token"), Keys("next", "token")} { + t.Run(classified.String(), func(t *testing.T) { + inspection, _ := inheritanceInspectionForTest(t, input) + inspection.secrets = classifySecretPaths(nil, func(path Path) bool { + return path.String() == classified.String() + }, inspection.metadata, inspection.final, inspection.applications, inspection.input) + for _, path := range []Path{Keys("parent", "token"), Keys("child", "token"), Keys("next", "token"), Keys("other", "token")} { + explanation, found := inspection.Explain(path) + if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) { + t.Fatalf("sensitive copy %s = %#v, %v", path, explanation, found) + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) { + t.Fatalf("historical canary at %s: %#v", path, change) + } + } + } + plain, found := inspection.Explain(Keys("other", "plain")) + if !found || plain.Effective.Redacted || plain.Effective.YAML != "visible\n" { + t.Fatalf("unrelated field = %#v, %v", plain, found) + } + var output bytes.Buffer + if err := inspection.WriteEffective(&output); err != nil || strings.Contains(output.String(), canary) || !strings.Contains(output.String(), "visible") { + t.Fatalf("effective rendering = %q, %v", output.String(), err) + } + }) + } +} + +func TestInheritanceSensitivityPreservesDisplacedHistoryAndControl(t *testing.T) { + const canary = "DISPLACED-CANARY-CREDENTIAL" + inspection, _ := inheritanceInspectionForTest(t, parseCompositionDocument(t, "source-1", "parent: {branch: {token: "+canary+"}}\nchild: {extends: parent, branch: public}\n")) + inspection.secrets = classifySecretPaths(nil, func(path Path) bool { + return path.String() == Keys("parent", "branch", "token").String() || path.String() == Keys("child", inheritanceKey).String() + }, inspection.metadata, inspection.final, inspection.applications, inspection.input) + path := Keys("child", "branch", "token") + explanation, found := inspection.Explain(path) + if !found || explanation.Effective.Present { + t.Fatalf("displaced sensitive path = %#v, %v", explanation, found) + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) || + (change.After.Present && !change.After.Redacted) { + t.Fatalf("displaced secret leaked: %#v", explanation.History) + } + } + var rendered bytes.Buffer + if err := inspection.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), canary) { + t.Fatalf("displaced explanation rendering = %q, %v", rendered.String(), err) + } + control, found := inspection.Explain(Keys("child", inheritanceKey)) + if !found || control.Effective.Present || len(control.History) == 0 || !control.History[0].After.Redacted { + t.Fatalf("sensitive control history = %#v, %v", control, found) + } + info, found := inspection.Metadata().Lookup(Keys("child", "branch")) + if !found || len(info.Origin.Inheritance) != 0 { + t.Fatalf("independent replacement provenance = %#v, %v", info, found) + } +} + +func TestInheritanceSensitivityIncludesSchemaTaggedSource(t *testing.T) { + type entry struct { + Token string `yaml:"token"` + } + type configuration struct { + Parent struct { + Token string `yaml:"token" configkit:"secret"` + } `yaml:"parent"` + Child entry `yaml:"child"` + } + schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding()) + if issue != nil { + t.Fatal(issue) + } + inspection, _ := inheritanceInspectionForTest(t, parseCompositionDocument(t, "source-1", "parent: {token: TAGGED-CANARY}\nchild: {extends: parent}\n")) + inspection.secrets = classifySecretPaths(schema, nil, inspection.metadata, inspection.final, inspection.applications, inspection.input) + child, found := inspection.Explain(Keys("child", "token")) + if !found || !child.Effective.Redacted || strings.Contains(child.Effective.YAML, "TAGGED-CANARY") { + t.Fatalf("tagged source copy = %#v, %v", child, found) + } +} + +func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) { + type entry struct { + Credential Secret `yaml:"credential"` + } + type configuration struct { + Entries map[string]entry `yaml:"entries"` + } + schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding()) + if issue != nil { + t.Fatal(issue) + } + composed, diagnostics := composeDocuments(parseCompositionDocument(t, "source-1", "entries:\n parent: {credential: INHERITED_SECRET_NAME}\n child: {extends: parent}\n")) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + expanded, diagnostics := expandInheritanceWithOrigins(composed.root, composed.origins, defaultProjectionLimits()) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + lookupCount := 0 + decoded, issues := decodeConfiguration(schema, reflect.Zero(reflect.TypeFor[configuration]()), expanded.root) + if len(issues) != 0 || lookupCount != 0 { + t.Fatalf("decoding and lookup order = %#v, %d", issues, lookupCount) + } + resolved := resolveSecretReferences(decoded.value, schema, expanded.root, func(name string) (string, bool) { + lookupCount++ + if name != "INHERITED_SECRET_NAME" { + t.Fatalf("inherited reference name = %q", name) + } + return "RESOLVED-CREDENTIAL-CANARY", true + }) + if len(resolved.diagnostics) != 0 || lookupCount != 1 || + decoded.value.Interface().(configuration).Entries["child"].Credential.Value() != "RESOLVED-CREDENTIAL-CANARY" { + t.Fatalf("inherited reference resolution = %#v, %d", resolved.diagnostics, lookupCount) + } + inspection, _ := inheritanceInspectionFromComposition(t, composed) + inspection.secrets = classifySecretPaths(schema, nil, inspection.metadata, inspection.final, inspection.applications, inspection.input) + var rendered bytes.Buffer + if err := inspection.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), "RESOLVED-CREDENTIAL-CANARY") { + t.Fatalf("reference rendering = %q, %v", rendered.String(), err) + } +} diff --git a/internal/engine/secret.go b/internal/engine/secret.go index 51ac30b..b91795f 100644 --- a/internal/engine/secret.go +++ b/internal/engine/secret.go @@ -34,6 +34,35 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me } } } + var inherited []secretDependency + for _, operation := range metadata.inheritance { + if operation.control != nil { + control := operation.control.path + paths[control.String()] = control.clone() + for _, contributor := range operation.controlOrigin.contributors { + paths[contributor.source.String()] = contributor.source.clone() + inherited = append(inherited, secretDependency{target: control.clone(), dependency: contributor.source.clone()}) + } + continue + } + if operation.transfer == nil { + continue + } + for pointer := range operation.parentOrigins { + source, err := ParsePath(pointer) + if err != nil { + continue + } + relative, ok := relativeHistoryPath(operation.transfer.from, source) + if !ok { + continue + } + destination := appendInheritancePath(operation.transfer.to, relative) + paths[pointer] = source + paths[destination.String()] = destination.clone() + inherited = append(inherited, secretDependency{target: destination, dependency: source}) + } + } secret := make(map[string]bool) for _, tree := range append(projections, final) { @@ -62,6 +91,16 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me changed = true } } + for _, link := range inherited { + if pathIsSecret(secret, link.dependency) && !secret[link.target.String()] { + secret[link.target.String()] = true + changed = true + } + if pathIsSecret(secret, link.target) && !secret[link.dependency.String()] { + secret[link.dependency.String()] = true + changed = true + } + } if !changed { return secret }