diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 23b5bf7..f207977 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -692,3 +692,18 @@ unsafe evidence query, or release gate. Reviewed behavioral coverage in No runtime code changed in this documentation unit, so the race suite was not rerun. No environmental limitation affected validation; checks ran on Linux/amd64 only. Publication and release preparation remain outside this work. + +## Post-implementation remediation evidence + +The subsequent review found coverage gaps beyond the original stage validation; +the [remediation record](remediation.md#shared-source-version-review-2026-10-15) +owns their status. Stage 9–12 evidence now also includes +`TestSourceVersionEvidenceClassificationKeepsCollidingShapes` and +`TestFacadeSourceVersionEvidenceRedactsCollidingShapes`. Both failed before the +shape-collision fix and pass afterward. Classification preserves original +mapping-key and sequence-index candidates independently without erasing payload +classification. Focused source-version/sensitivity/history tests, +`GOTOOLCHAIN=local ./scripts/check.sh`, and +`GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed on +`go1.26.9 linux/amd64`. Existing historical/dependency propagation and neighboring +public-value coverage remain green; no new sensitivity policy was introduced. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 19bcd8b..1e23476 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -162,7 +162,14 @@ issues remain. Findings 1 and 2 above remain completed historical findings. ### 3. Preserve distinct semantic shapes during evidence classification (P1) -Status: open. +Status: complete. Original evidence shapes are classified independently before +their results enter the existing pointer-based sensitivity/dependency closure. +Evidence no longer overwrites payload candidates. New engine and public +regressions failed before the fix and pass afterward, covering both acquisition +orders, schema tags, predicate observation of both shapes, and unchanged final +payload. Focused source-version/sensitivity/history tests, the everyday check, +and the full race suite passed on `go1.26.9 linux/amd64`. The implementation +plan records the additional Stage 9–12 regression evidence. Affected: `classifySecretPathsWithEvidence` and `addSecretEvidencePath` in [secret.go](../../internal/engine/secret.go), lines 78–104 at `020d720`. diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index 3ed042f..c801788 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -16,6 +16,68 @@ type sourceVersionModeReader struct { reads int } +func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) { + t.Parallel() + type header struct { + Version string `yaml:"version"` + } + type secretHeader struct { + Version string `yaml:"version" configkit:"secret"` + } + type configuration struct { + Meta map[string]header `yaml:"meta"` + Data struct { + Meta map[string]secretHeader `yaml:"meta"` + } `yaml:"data"` + } + path, err := configkit.ParsePath("/meta/0/version") + if err != nil { + t.Fatal(err) + } + for _, reverse := range []bool{false, true} { + groups := []configkit.Underlay{ + {Inputs: []configkit.Input{{ + Source: configkit.Bytes("mapping", []byte("meta: {\"0\": {version: PRIVATE-HEADER-CANARY}}\n")), + Destination: configkit.Keys("data"), + }}}, + {Inputs: []configkit.Input{{ + Source: configkit.Bytes("sequence", []byte("meta: [{version: another-header}]\n")), + Destination: configkit.Keys("data"), + }}}, + } + if reverse { + groups[0], groups[1] = groups[1], groups[0] + } + loaded, err := configkit.LoadLayered(configkit.Input{ + Source: configkit.Bytes("main", []byte("meta: {\"0\": {version: main}}\ndata: {meta: null}\n")), + }, configkit.LayeredOptions[configuration]{ + Definition: configkit.Definition[configuration]{Version: &configkit.VersionRule{ + Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }, + }}, + SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: groups, + }) + if err != nil { + t.Fatal(err) + } + if loaded.Config.Meta["0"].Version != "main" || loaded.Config.Data.Meta != nil { + t.Fatalf("reverse=%v: final payload changed: %#v", reverse, loaded.Config) + } + foundMapping := false + for _, source := range loaded.Sources() { + if source.Label != "mapping" { + continue + } + foundMapping = true + if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" { + t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse) + } + } + if !foundMapping { + t.Fatal("missing mapping source record") + } + } +} + func (reader *sourceVersionModeReader) Read(data []byte) (int, error) { reader.reads++ return reader.Reader.Read(data) diff --git a/internal/engine/secret.go b/internal/engine/secret.go index b2db37f..ff15fbb 100644 --- a/internal/engine/secret.go +++ b/internal/engine/secret.go @@ -75,6 +75,19 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa classifyProjection(tree) } classifyProjection(final) + classifyPath := func(path Path) { + if root, ok := schemaSecretRoot(schema, path); ok { + secret[root.String()] = true + } + if predicate != nil && predicate(path.clone()) { + secret[path.String()] = true + } + } + for _, path := range paths { + classifyPath(path) + } + // Classify each original evidence shape before folding results into pointer + // sensitivity. Numeric mapping keys and sequence indices can share a pointer. for _, item := range evidence { if item == nil { continue @@ -83,25 +96,18 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa if !candidate.valid() { candidate = item.path } - addSecretEvidencePath(paths, item.destination, candidate) + classifyPath(addSecretEvidencePath(paths, item.destination, candidate)) for _, ancestor := range item.ancestors { - addSecretEvidencePath(paths, item.destination, ancestor.path) - } - } - for pointer, path := range paths { - if root, ok := schemaSecretRoot(schema, path); ok { - secret[root.String()] = true - } - if predicate != nil && predicate(path.clone()) { - secret[pointer] = true + classifyPath(addSecretEvidencePath(paths, item.destination, ancestor.path)) } } return closeSecretDependencies(paths, secret, dependencies, inherited) } -func addSecretEvidencePath(paths map[string]Path, destination, local Path) { +func addSecretEvidencePath(paths map[string]Path, destination, local Path) Path { path := appendDestination(destination, local) - paths[path.String()] = path.clone() + addSecretPath(paths, path.String()) + return path } func addSecretPath(paths map[string]Path, pointer string) { diff --git a/internal/engine/secret_classification_test.go b/internal/engine/secret_classification_test.go index ef0aaf9..1fed508 100644 --- a/internal/engine/secret_classification_test.go +++ b/internal/engine/secret_classification_test.go @@ -169,3 +169,34 @@ func TestSourceVersionEvidenceSensitivityUsesOriginalMountedPaths(t *testing.T) t.Fatalf("unclassified or semantic evidence candidates = %#v; original path seen %v, ancestor predicate seen %v", classified, sawOriginalSequencePath, sawAncestorPredicate) } } + +func TestSourceVersionEvidenceClassificationKeepsCollidingShapes(t *testing.T) { + t.Parallel() + type header struct { + Version string `yaml:"version" configkit:"secret"` + } + type configuration struct { + Meta map[string]header `yaml:"meta"` + } + schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding()) + if issue != nil { + t.Fatal(issue) + } + keyPath := Keys("meta", "0", "version") + indexPath := Keys("meta").Index(0).Key("version") + for _, reverse := range []bool{false, true} { + evidence := []*sourceVersionEvidence{{resolvedPath: keyPath}, {resolvedPath: indexPath}} + if reverse { + evidence[0], evidence[1] = evidence[1], evidence[0] + } + var sawKey, sawIndex bool + classified := classifySecretPathsWithEvidence(schema, func(path Path) bool { + sawKey = sawKey || reflect.DeepEqual(path, keyPath) + sawIndex = sawIndex || reflect.DeepEqual(path, indexPath) + return false + }, Metadata{}, nil, nil, evidence) + if !classified[keyPath.String()] || !sawKey || !sawIndex { + t.Errorf("reverse=%v: classified=%v, key seen=%v, index seen=%v", reverse, classified, sawKey, sawIndex) + } + } +}