From 1d8438b635ea4996b54f5a43a447ae6aedf08571 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 14:58:52 +0000 Subject: [PATCH] Protect paired history snapshots from disclosure --- docs/roadmap/implementation.md | 2 + internal/engine/resolution_compare_test.go | 87 ++++++++++++++++++++++ internal/engine/secret.go | 3 + 3 files changed, 92 insertions(+) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1a3a2e2..73d9d97 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -368,6 +368,8 @@ Stage 11 verifies the resulting disclosure through the safe rendering boundary. ## Stage 11 — Mask paired snapshots and preserve safe disclosure +**Status:** Complete. Focused moved, removed, dynamic-tag, dependency, secret, and diff checks passed. + **Scope:** snapshot rendering and inspection integration. 1. Mask a moved explanation's before and after snapshots using their respective diff --git a/internal/engine/resolution_compare_test.go b/internal/engine/resolution_compare_test.go index ca2b36d..112584b 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -588,3 +588,90 @@ func TestRegisteredResolutionKeepsRemovedSensitivity(t *testing.T) { t.Fatal("removed historical sensitivity lost") } } + +func TestMovedAndRemovedSensitiveValuesStayHidden(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"` + } + for _, remove := range []bool{false, true} { + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{ + Secret: func(path Path) bool { return path.String() == "/items/1/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").Index(1).Key("value"), "retain value", Keys("derived")) + if remove { + value.Items = value.Items[:1] + } + r.RecordNormalization(Keys("items"), "update records") + 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: PRIVATE_CANARY}, {id: b, value: other}]\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + for _, path := range []Path{Keys("items"), Keys("items").Index(0), Keys("items").Index(1), Keys("derived")} { + explanation, ok := result.Inspection.Explain(path) + if !ok { + continue + } + if strings.Contains(explanation.Effective.YAML, "PRIVATE_CANARY") { + t.Fatalf("effective leaked at %s", path.String()) + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, "PRIVATE_CANARY") || strings.Contains(change.After.YAML, "PRIVATE_CANARY") { + t.Fatalf("history leaked at %s: %#v", path.String(), change) + } + } + } + var output strings.Builder + if err := result.Inspection.WriteEffective(&output); err != nil || strings.Contains(output.String(), "PRIVATE_CANARY") { + t.Fatalf("effective output leaked: %q, %v", output.String(), err) + } + } +} + +func TestMovedDynamicTaggedValueStaysHidden(t *testing.T) { + type sensitive struct { + ID string `yaml:"id"` + Token string `yaml:"token" configkit:"secret"` + } + type config struct { + Items []any `yaml:"items"` + } + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{ + Defaults: func() config { + return config{Items: []any{sensitive{ID: "a", Token: "DYNAMIC_PRIVATE_CANARY"}, sensitive{ID: "b", Token: "other"}}} + }, + Resolve: func(value *config, r *Resolution) error { + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + r.RecordNormalization(Keys("items"), "reorder") + return nil + }, + }, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(NoSource(), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + for _, path := range []Path{Keys("items"), Keys("items").Index(1).Key("token")} { + explanation, ok := result.Inspection.Explain(path) + if !ok { + t.Fatalf("missing explanation %s", path.String()) + } + if strings.Contains(explanation.Effective.YAML, "DYNAMIC_PRIVATE_CANARY") { + t.Fatal("effective value leaked") + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, "DYNAMIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "DYNAMIC_PRIVATE_CANARY") { + t.Fatal("history leaked") + } + } + } +} diff --git a/internal/engine/secret.go b/internal/engine/secret.go index 0f803d8..38a3510 100644 --- a/internal/engine/secret.go +++ b/internal/engine/secret.go @@ -92,6 +92,9 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa 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