Reject validator changes to secret classification

This commit is contained in:
2026-09-06 19:20:04 -05:00
parent fb2e2c5386
commit 9b147bfd96
5 changed files with 90 additions and 7 deletions

View File

@@ -243,7 +243,9 @@ public-name representation, JSON marshals only the name, and projection, metadat
histories, diagnostics, effective output, histories, diagnostics, effective output,
and resolution snapshots never retain the credential. Validation remains read-only: and resolution snapshots never retain the credential. Validation remains read-only:
in addition to ordinary projected-value comparison, ConfigKit checks the private in addition to ordinary projected-value comparison, ConfigKit checks the private
resolved state against this load's lookup result. Applications must keep their resolved state against this load's lookup result. Validator mutation checks also
reject changes to projected field sensitivity, even when a replacement dynamic
concrete type preserves the same textual values. Applications must keep their
validation messages value-free and must not include `Value()` in diagnostics. validation messages value-free and must not include `Value()` in diagnostics.
For example, a typed pipeline field is declared as: For example, a typed pipeline field is declared as:

View File

@@ -2,18 +2,18 @@
## Status and scope ## Status and scope
Status: in progress. Review baseline: `f42ecc70c330a399aa6dada87658694402a515ec`. Status: complete. All nine findings have been addressed. Review baseline: `f42ecc70c330a399aa6dada87658694402a515ec`.
This review covers the initial implementation against the This review covers the initial implementation against the
[architecture policy](../policy/architecture.md), [configuration contract](../config.md), [architecture policy](../policy/architecture.md), [configuration contract](../config.md),
and [implementation plan](implementation.md). The package boundaries and principal and [implementation plan](implementation.md). The package boundaries and principal
lifecycle are in place, but the findings below must be addressed before claiming lifecycle were in place at review time; the original findings below are retained
that the initial target is fully satisfied. No production code was changed during with completion evidence. No production code was changed during the original
this review. All findings below were reproduced through the public API or local review; subsequent remediation is recorded here. All findings below were reproduced through the public API or local
validation tools, with the additional affected call sites identified by inspection. validation tools, with the additional affected call sites identified by inspection.
Priorities: P1 means high-impact confidentiality, isolation, or availability; Priorities: P1 means high-impact confidentiality, isolation, or availability;
P2 means a correctness or validation gap that also requires remediation. P2 means a correctness or validation gap that also requires remediation.
Each item remains open until its regression checks and the relevant baseline pass. Each item below records its completed correction and validation evidence.
Keep current contracts and implementation evidence accurate when fixing these issues; Keep current contracts and implementation evidence accurate when fixing these issues;
do not change the agreed semantics simply to accommodate the implementation. do not change the agreed semantics simply to accommodate the implementation.
@@ -21,7 +21,10 @@ do not change the agreed semantics simply to accommodate the implementation.
Status: complete. Typed projections now retain concrete field sensitivity; final, Status: complete. Typed projections now retain concrete field sensitivity; final,
baseline and pre-resolution projections feed historical/dependency classification. baseline and pre-resolution projections feed historical/dependency classification.
Dynamic nested, replaced, deleted and introduced-value regressions pass. Full tests Dynamic nested, replaced, deleted and introduced-value regressions pass. A final
audit additionally verified that validators cannot change dynamic field sensitivity
through equal-text type replacement; the added regression failed before the fix
and now passes. Full tests
pass with the temporary canonical-TMPDIR workaround for issue 8; vet, build and pass with the temporary canonical-TMPDIR workaround for issue 8; vet, build and
whitespace checks pass. whitespace checks pass.
@@ -285,3 +288,24 @@ precedence, separate explicit bootstrap, and application-owned domain lifecycle
remain appropriate. Repair these boundary and consistency defects within that remain appropriate. Repair these boundary and consistency defects within that
architecture. Re-run focused regressions, the normal baseline and race suites, architecture. Re-run focused regressions, the normal baseline and race suites,
local documentation checks, and applicable release gates before closing this roadmap. local documentation checks, and applicable release gates before closing this roadmap.
## Remediation completion
All nine findings are corrected and committed in individual units of work, with an
additional issue-1 audit correction for validator sensitivity mutation. The normal
macOS baseline no longer requires a TMPDIR workaround. The historical review and
intermediate validation notes above retain the conditions that applied at the time.
The complete source checker is validated locally with:
```sh
CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5 CONFIGKIT_NEWER_TOOLCHAIN=go1.26.0 /bin/bash scripts/release-check.sh
```
This uses the available cached toolchains, runs both native baselines and the race
suite, checks module hygiene, formatting, documentation and example boundaries,
and cross-builds Linux/macOS on amd64/arm64. Native execution during remediation
was macOS/arm64; new native Linux evidence and selection of current supported patch
releases remain maintainer release gates. No release, tag, push or downstream
application migration was performed. Retire this completed roadmap during release
preparation according to the documentation policy.

View File

@@ -226,3 +226,25 @@ func TestDynamicConcreteTagsRemainSecretThroughHistory(t *testing.T) {
}) })
} }
} }
func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T) {
type sensitive struct {
Token string `yaml:"token" configkit:"secret"`
}
type cfg struct {
Payload any `yaml:"payload"`
}
_, err := Load(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)}
}}})
var failure *LoadError
if !errors.As(err, &failure) {
t.Fatal("validator changed inspection sensitivity without a mutation error")
}
for _, d := range failure.Diagnostics() {
if d.Code == CodeValidateMutation {
return
}
}
t.Fatal("missing validation mutation diagnostic")
}

View File

@@ -139,3 +139,26 @@ func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
} }
return Path{}, false return Path{}, false
} }
// Dynamic type replacement can change classification even when projected text
// is equal. Validators must preserve this inspection metadata as well as values.
func projectedSensitivity(root *document.Node) map[string]Path {
result := make(map[string]Path)
var visit func(*document.Node, Path)
visit = func(node *document.Node, path Path) {
if node == nil {
return
}
if node.Sensitive {
result[path.String()] = path.clone()
}
for _, entry := range node.Mapping {
visit(entry.Value, path.Key(entry.Key))
}
for i, child := range node.Sequence {
visit(child, path.Index(i))
}
}
visit(root, Path{})
return result
}

View File

@@ -112,6 +112,18 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch
for _, delta := range compareDocumentTrees(before, after) { for _, delta := range compareDocumentTrees(before, after) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path)) state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path))
} }
beforeSensitivity := projectedSensitivity(before)
afterSensitivity := projectedSensitivity(after)
for pointer, path := range beforeSensitivity {
if _, exists := afterSensitivity[pointer]; !exists {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
}
}
for pointer, path := range afterSensitivity {
if _, exists := beforeSensitivity[pointer]; !exists {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
}
}
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) { for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path)) state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
} }