From 4633a1b8a2d6afaa4e074c350ffaa34392ab2961 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 17 Oct 2026 04:42:23 +0000 Subject: [PATCH] Project consumed version evidence through sequence appends --- docs/roadmap/remediation.md | 8 ++++- facade_source_version_mode_test.go | 52 ++++++++++++++++++++++++++++++ internal/engine/imports.go | 29 ++++++++++++++--- internal/engine/secret.go | 11 ++----- internal/engine/source_version.go | 41 ++++++++++++++++------- 5 files changed, 116 insertions(+), 25 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 0d8458e..db99c61 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -13,7 +13,13 @@ stricter final identity validation, broader version policy, or new release gates ## 1. Project consumed version evidence through authored sequence appends — P1 -- [ ] Addressed and regression-tested. +- [x] Addressed and regression-tested. + +Completion: retained sensitivity paths now project declarations and ancestors +through assigned authored-list ranges without changing source-local evidence. +Public regressions cover imports and grouped underlays, leaf/container masking, +and unclassified declarations. Focused source-version tests, the everyday check, +and `go test -race ./internal/engine` passed. **Owners:** `projectAcquiredDestinations` / `projectSourceVersionEvidence` in [imports.go](../../internal/engine/imports.go), and evidence classification in diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index 1e8c9a0..90a730d 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -3,14 +3,66 @@ package configkit_test import ( "bytes" "errors" + "fmt" "os" "path/filepath" "strings" "testing" + "testing/fstest" configkit "gitea.maximumdirect.net/eric/ConfigKit" ) +func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) { + t.Parallel() + for _, grouped := range []bool{false, true} { + for _, sensitivePath := range []string{"", "/records/1/version", "/records/1"} { + t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) { + definition := configkit.Definition[map[string]any]{ + SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}, + Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}, + Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath }, + } + first := "records: [{id: first, version: first-version}]\n" + child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n" + var result *configkit.Result[map[string]any] + var err error + if grouped { + result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{ + {Source: configkit.Bytes("first", []byte(first))}, {Source: configkit.Bytes("child", []byte(child))}, + }}}, + }) + } else { + filesystem := configkit.NewFilesystem("versions", fstest.MapFS{ + "main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)}, + "child.yml": {Data: []byte(child)}, + }) + result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{ + Definition: definition, Imports: true, SourceVersionMode: configkit.SourceVersionModeRequired, + }) + } + if err != nil { + t.Fatal(err) + } + sources := result.Sources() + check := sources[len(sources)-1].VersionCheck + if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID { + t.Fatalf("source-local evidence changed: %#v", check) + } + if sensitivePath != "" { + if !check.Redacted || check.Version.Text != "" { + t.Fatal("shifted version evidence was disclosed") + } + } else if check.Redacted || check.Version.Text != "PRIVATE-VERSION-CANARY" { + t.Fatal("unclassified version evidence was lost") + } + }) + } + } +} + type sourceVersionModeReader struct { *strings.Reader reads int diff --git a/internal/engine/imports.go b/internal/engine/imports.go index 8202a48..75f6942 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -662,7 +662,7 @@ func projectAcquiredDestinations(composed *composedDocument, scope acquisitionSc } contributionPrefix = appendDestination(container, Path{}.Index(start)) contributionInsideItem = true - projectSourceVersionEvidence(contribution.processing, contributionPrefix) + projectSourceVersionEvidence(contribution.processing, contributionPrefix, nil, sourceIndex) } else if contributionInsideItem { exactDestination := appendDestination(contributionPrefix, contribution.destination) contribution.source.Destination = exactDestination.clone() @@ -672,7 +672,9 @@ func projectAcquiredDestinations(composed *composedDocument, scope acquisitionSc sources[position].Destination = exactDestination sources[position].destinationUnresolved = false } - projectSourceVersionEvidence(contribution.processing, exactDestination) + projectSourceVersionEvidence(contribution.processing, exactDestination, nil, sourceIndex) + } else { + projectSourceVersionEvidence(contribution.processing, contribution.destination, currentComposition.sequenceRanges, sourceIndex) } if contribution.nested != nil && contribution.nested.acquisition != nil { for _, nestedScope := range contribution.nested.acquisition { @@ -684,9 +686,28 @@ func projectAcquiredDestinations(composed *composedDocument, scope acquisitionSc projectScope(composed, scope, Path{}, false, "") } -func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, destination Path) { +func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, destination Path, ranges []sequenceRange, sourceIndex int) { if processing != nil && processing.evidence != nil { - processing.evidence.destination = destination.clone() + evidence := processing.evidence + evidence.destination = destination.clone() + // Rebuild from source-local paths on each handoff: an input can be + // composed first on its own and then at a different offset in its tier. + evidence.projectedPaths = nil + paths := evidence.sensitivityPaths() + for index, path := range paths { + for _, assigned := range ranges { + if assigned.sourceIndex != sourceIndex || !pathContains(assigned.path, path) || len(path.segments) <= len(assigned.path.segments) { + continue + } + position := len(assigned.path.segments) + item, ok := schemaIndex(path.segments[position]) + if ok && path.segments[position].kind != pathSegmentKey { + path.segments[position] = pathSegment{kind: pathSegmentIndex, index: assigned.start + item} + } + } + paths[index] = path + } + evidence.projectedPaths = paths } } diff --git a/internal/engine/secret.go b/internal/engine/secret.go index 18f609d..d7aafc7 100644 --- a/internal/engine/secret.go +++ b/internal/engine/secret.go @@ -125,15 +125,8 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa if item == nil { continue } - candidate := item.resolvedPath - if !candidate.valid() { - candidate = item.path - } - resolved := addSecretEvidencePath(paths, item.destination, candidate) - addCandidate(resolved) - classifyPath(resolved) - for _, ancestor := range item.ancestors { - resolved := addSecretEvidencePath(paths, item.destination, ancestor.path) + for _, resolved := range item.sensitivityPaths() { + addSecretPath(paths, resolved.String()) addCandidate(resolved) classifyPath(resolved) } diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index a4d2e15..f7ce2bc 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -30,16 +30,17 @@ type sourceVersionAncestor struct { } type sourceVersionEvidence struct { - sourceID string - path Path - resolvedPath Path - destination Path - version Version - location *Location - ancestors []sourceVersionAncestor - absence sourceVersionPathAbsence - checked bool - redacted bool + sourceID string + path Path + resolvedPath Path + destination Path + projectedPaths []Path + version Version + location *Location + ancestors []sourceVersionAncestor + absence sourceVersionPathAbsence + checked bool + redacted bool } type sourceVersionState struct { @@ -80,7 +81,7 @@ func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity redacted := evidence.redacted if conservative { redacted = redacted || version.Text != "" - } else if sensitivity.isSensitiveAtOrAbove(appendDestination(evidence.destination, path).String()) { + } else if sensitivity.isSensitiveAtOrAbove(evidence.sensitivityPaths()[0].String()) { redacted = true } if redacted { @@ -91,6 +92,24 @@ func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity } } +// sensitivityPaths preserves source-local evidence while describing its assigned +// application coordinates. Before assembly, the original mount is the fallback. +func (evidence *sourceVersionEvidence) sensitivityPaths() []Path { + if evidence.projectedPaths != nil { + return evidence.projectedPaths + } + local := evidence.resolvedPath + if !local.valid() { + local = evidence.path + } + paths := make([]Path, 0, len(evidence.ancestors)+1) + paths = append(paths, appendDestination(evidence.destination, local)) + for _, ancestor := range evidence.ancestors { + paths = append(paths, appendDestination(evidence.destination, ancestor.path)) + } + return paths +} + func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement placementMode) (sourceVersionProcessingResult, []Diagnostic) { if state == nil || state.mode == sourceVersionModeDefault { return sourceVersionProcessingResult{}, nil