diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 128c169..1a3a2e2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -339,6 +339,8 @@ unknown. Use both whole-list-only and overlapping list/field records. ## Stage 10 — Classify matched and removed sensitivity evidence +**Status:** Complete. Focused sensitivity, secret, and dependency checks passed. + **Scope:** existing classification and dependency closure. 1. Extend history sensitivity candidates to include actual entry/final shapes diff --git a/internal/engine/resolution_compare_test.go b/internal/engine/resolution_compare_test.go index c5ab486..ca2b36d 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -533,3 +533,58 @@ func TestRegisteredResolutionExplainsRemovedFieldOnMovedRecord(t *testing.T) { } t.Fatalf("removed field history = %#v", explanation.History) } + +func TestRegisteredResolutionTransfersSensitivityFromEntry(t *testing.T) { + type item struct { + ID string `yaml:"id"` + Value string `yaml:"value"` + } + type config struct { + Items []item `yaml:"items"` + Derived string `yaml:"derived"` + } + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{ + Secret: func(path Path) bool { return path.String() == "/items/0/value" }, + Resolve: func(value *config, r *Resolution) error { + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + value.Derived = value.Items[1].Value + r.RecordNormalization(Keys("items"), "reorder") + r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value")) + return nil + }, + }, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + for _, path := range []Path{Keys("items").Index(0).Key("value"), Keys("items").Index(1).Key("value"), Keys("derived")} { + if !pathIsSecret(result.Inspection.secrets, path) { + t.Errorf("sensitivity lost at %s", path.String()) + } + } +} + +func TestRegisteredResolutionKeepsRemovedSensitivity(t *testing.T) { + type item struct { + ID string `yaml:"id"` + Value string `yaml:"value"` + } + type config struct { + Items []item `yaml:"items"` + } + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{ + Secret: func(path Path) bool { return path.String() == "/items/0/value" }, + Resolve: func(value *config, r *Resolution) error { + value.Items = value.Items[1:] + r.RecordNormalization(Keys("items"), "remove record") + return nil + }, + }, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + if !pathIsSecret(result.Inspection.secrets, Keys("items").Index(0).Key("value")) { + t.Fatal("removed historical sensitivity lost") + } +} diff --git a/internal/engine/resolution_correspondence.go b/internal/engine/resolution_correspondence.go index 0678c8c..fd2dc79 100644 --- a/internal/engine/resolution_correspondence.go +++ b/internal/engine/resolution_correspondence.go @@ -124,3 +124,34 @@ func (c resolutionCorrespondence) entryPath(path Path) (Path, bool) { } return path.clone(), true } + +// finalPath translates historical record descendants without matching removals. +func (c resolutionCorrespondence) finalPath(path Path) (Path, bool) { + if !path.valid() { + return Path{}, false + } + for depth := 0; depth < len(path.segments); depth++ { + prefix := Path{segments: path.segments[:depth]} + collection, found := c.collections[prefix.String()] + if !found || !collection.eligible { + continue + } + for i, segment := range collection.path.segments { + if segment.kind == pathSegmentKey && path.segments[i].kind == pathSegmentIndex { + return path.clone(), true + } + } + index, ok := schemaIndex(path.segments[depth]) + if !ok || index >= len(collection.entryToFinal) { + continue + } + current := collection.entryToFinal[index] + if current < 0 { + return Path{}, false + } + translated := path.clone() + translated.segments[depth] = pathSegment{kind: pathSegmentIndex, index: current} + return translated, true + } + return path.clone(), true +} diff --git a/internal/engine/secret.go b/internal/engine/secret.go index 9bd896f..0f803d8 100644 --- a/internal/engine/secret.go +++ b/internal/engine/secret.go @@ -80,7 +80,31 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa classifyPath(resolved) } } - return closeSecretDependencies(paths, secret, dependencies, inherited) + classified := closeSecretDependencies(paths, secret, dependencies, inherited) + if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil { + // Project from the completed entry classification into a separate map so + // one side of a swap cannot influence the other while it is being read. + projected := make(map[string]Path) + for _, path := range paths { + if !pathIsSecret(classified, path) { + continue + } + if target, matched := model.resolutionPayload.correspondence.finalPath(path); matched { + projected[target.String()] = target + } + } + for pointer, path := range projected { + paths[pointer] = path + secret[pointer] = true + } + for pointer, sensitive := range classified { + if sensitive { + secret[pointer] = true + } + } + return closeSecretDependencies(paths, secret, dependencies, inherited) + } + return classified } func addSecretPath(paths map[string]Path, pointer string) {