Carry sensitivity across matched records
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user