Preserve secret sensitivity across sibling inheritance
This commit is contained in:
@@ -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.
|
||||
|
||||
136
internal/engine/inheritance_secret_test.go
Normal file
136
internal/engine/inheritance_secret_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user