From ebf98ffc62ebe6cb486c0c7dee32f0f4ca09a2ff Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 17:33:44 +0000 Subject: [PATCH] Propagate matched sensitivity with one work queue --- docs/roadmap/remediation.md | 11 +++- internal/engine/resolution_compare_test.go | 34 ++++++++++++ .../engine/resolution_remediation_test.go | 51 +++++++++++++++++- internal/engine/secret.go | 29 ++-------- internal/engine/secret_propagation.go | 53 ++++++++++++++++++- 5 files changed, 149 insertions(+), 29 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 4fee4b8..b19b6b2 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -125,7 +125,16 @@ operation path solely to satisfy an ancestor check. ## Finding 3 — Sensitivity propagation rescans the whole collection quadratically -**Priority:** P2. **Status:** Open. +**Priority:** P2. **Status:** Complete. + +Completion: matching edges and real dependencies now share one indexed work +queue. Candidate coordinates are registered once, newly reached historical +coordinates are registered on demand, and each trigger fires at most once; +classification no longer rebuilds closure in full-map passes. Conservative +historical masking is retained. The existing earlier-matched-value regression +is retained in this fix, and a cyclic rotation regression covers removed fields +and dependency-derived values. Focused sensitivity tests and +`./scripts/check.sh` passed. **Code:** [secret classification](../../internal/engine/secret.go), `classifySecretPathsWithEvidence`, working-tree lines 83–122; associated diff --git a/internal/engine/resolution_compare_test.go b/internal/engine/resolution_compare_test.go index 112584b..ce2feb4 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -675,3 +675,37 @@ func TestMovedDynamicTaggedValueStaysHidden(t *testing.T) { } } } + +func TestMovedDependencyProtectsEarlierMatchedValue(t *testing.T) { + type item struct { + ID string `yaml:"id"` + Secret string `yaml:"secret"` + 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/secret" }, + Resolve: func(value *config, r *Resolution) error { + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + value.Items[0].Value = value.Items[1].Secret + r.RecordNormalization(Keys("items"), "reorder") + r.RecordNormalization(Keys("items").Index(0).Key("value"), "derive", Keys("items").Index(1).Key("secret")) + return nil + }, + }, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: a, secret: HIDDEN_A, value: public}, {id: b, value: EARLIER_PRIVATE}]\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value")) + if !ok { + t.Fatal("missing explanation") + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "HIDDEN_A") { + t.Fatalf("sensitive lineage leaked: %#v", change) + } + } +} diff --git a/internal/engine/resolution_remediation_test.go b/internal/engine/resolution_remediation_test.go index 7e739bc..2fcec16 100644 --- a/internal/engine/resolution_remediation_test.go +++ b/internal/engine/resolution_remediation_test.go @@ -1,6 +1,9 @@ package engine -import "testing" +import ( + "strings" + "testing" +) func TestMovedRemovedFieldHasConsistentHistory(t *testing.T) { type config struct { @@ -94,3 +97,49 @@ func TestMovedInheritedHistoryUsesFinalRecordPrefix(t *testing.T) { } } } + +func TestCyclicMatchingProtectsRemovedAndDerivedValues(t *testing.T) { + type config struct { + Items []map[string]any `yaml:"items"` + Derived string `yaml:"derived"` + } + removed := Keys("items").Index(1).Key("extra") + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{ + Secret: func(path Path) bool { return path.String() == "/items/0/extra" }, + Resolve: func(value *config, r *Resolution) error { + value.Derived = value.Items[0]["extra"].(string) + delete(value.Items[0], "extra") + last := value.Items[len(value.Items)-1] + copy(value.Items[1:], value.Items[:len(value.Items)-1]) + value.Items[0] = last + r.RecordNormalization(Keys("items"), "remove field and rotate") + r.RecordDerivedDefault(Keys("derived"), "derive from removed value", removed) + return nil + }, + }, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: CYCLIC_PRIVATE_CANARY}, {id: b}, {id: c}, {id: d}]\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + for _, path := range []Path{Keys("items"), removed, Keys("derived")} { + explanation, found := result.Inspection.Explain(path) + if !found { + t.Fatalf("missing explanation for %s", path.String()) + } + if strings.Contains(explanation.Effective.YAML, "CYCLIC_PRIVATE_CANARY") { + t.Fatalf("effective value leaked at %s", path.String()) + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, "CYCLIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "CYCLIC_PRIVATE_CANARY") { + t.Fatalf("history leaked at %s", path.String()) + } + } + } + var output strings.Builder + if err := result.Inspection.WriteEffective(&output); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "CYCLIC_PRIVATE_CANARY") { + t.Fatal("effective rendering leaked") + } +} diff --git a/internal/engine/secret.go b/internal/engine/secret.go index 38a3510..c451fe0 100644 --- a/internal/engine/secret.go +++ b/internal/engine/secret.go @@ -80,34 +80,11 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa classifyPath(resolved) } } - classified := closeSecretDependencies(paths, secret, dependencies, inherited) + var correspondence resolutionCorrespondence 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 - } - if prior, matched := model.resolutionPayload.correspondence.entryPath(path); matched { - projected[prior.String()] = prior - } - } - 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) + correspondence = model.resolutionPayload.correspondence } - return classified + return closeSecretDependenciesWithCorrespondence(paths, secret, dependencies, inherited, correspondence) } func addSecretPath(paths map[string]Path, pointer string) { diff --git a/internal/engine/secret_propagation.go b/internal/engine/secret_propagation.go index b45a450..21f99e8 100644 --- a/internal/engine/secret_propagation.go +++ b/internal/engine/secret_propagation.go @@ -6,6 +6,13 @@ type secretTrigger struct { } func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool { + return closeSecretDependenciesWithCorrespondence(paths, sensitive, dependencies, inherited, resolutionCorrespondence{}) +} + +// Matching adds endpoint edges to the same work queue as recorded dependencies. +// Newly reached coordinates are indexed once, including historical fields that +// have no physical descendant at the corresponding final position. +func closeSecretDependenciesWithCorrespondence(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency, correspondence resolutionCorrespondence) map[string]bool { result := make(map[string]bool, len(sensitive)) queue := make([]string, 0, len(sensitive)) for pointer, classified := range sensitive { @@ -17,12 +24,14 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d var triggers pathPrefixIndex triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited)) + fired := make([]bool, 0, cap(triggerValues)) insert := func(dependency Path, target string, ordinary bool) { if ordinary && !dependency.valid() { return } id := len(triggerValues) triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary}) + fired = append(fired, false) triggers.insert(dependency.String(), id) } for _, dependency := range dependencies { @@ -33,7 +42,46 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d insert(link.target, link.dependency.String(), false) } - fired := make([]bool, len(triggerValues)) + var projected map[string]bool + if len(correspondence.collections) != 0 { + projected = make(map[string]bool, len(paths)) + } + project := func(path Path) { + if projected == nil || !path.valid() { + return + } + pointer := path.String() + if projected[pointer] { + return + } + projected[pointer] = true + add := func(target Path, matched bool) { + if !matched { + return + } + targetPointer := target.String() + if targetPointer == pointer { + return + } + if _, exists := paths[targetPointer]; !exists { + paths[targetPointer] = target + } + insert(path, targetPointer, false) + } + add(correspondence.finalPath(path)) + add(correspondence.entryPath(path)) + } + if projected != nil { + // Projection may add missing target coordinates to paths. Snapshot the + // original candidates so map insertion cannot affect this traversal. + candidates := make([]Path, 0, len(paths)) + for _, path := range paths { + candidates = append(candidates, path) + } + for _, path := range candidates { + project(path) + } + } activate := func(id int) { if fired[id] { return @@ -48,6 +96,9 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d for head := 0; head < len(queue); head++ { pointer := queue[head] if classified, ok := paths[pointer]; ok && classified.valid() { + // Register a synthesized coordinate before visiting triggers, never + // while a trigger-index traversal is in progress. + project(classified) triggers.visitAncestors(classified.String(), func(id int) { if triggerValues[id].ordinary { activate(id)