Motivation
In the ErpForFactoryGames CD bring-up, the deploy target's address (a private-range LXC IP) was committed in deploy/erp-deploy.json as a string literal — "Host": "10.10.107.175". Not a secret (RFC1918, not externally routable), but it leaked the homelab's internal topology to a public repo. We fixed it in ErpForFactoryGames#266 by swapping the IP for an erp-lxc SSH-config alias resolved at runtime.
Catch was: nothing in the framework warned us. The field is plumbed through [Parameter] and RemoteOptions.Host and SshConnectionResolver cleanly — but the literal value sitting in a JSON config file passed every other check. Gitleaks didn't flag it (it's not credential-shaped). GitHub Secret Scanning didn't flag it. Code review didn't catch it because the IP looked like config, not a secret.
This is a generic CD smell: fields that should resolve from environment / secret store / operator config sometimes get pinned as literals in committed files, and the framework silently allows it. ADR-0002's [Secret] story handles credential-shaped fields well; the gap is the non-credential fields where the literal is the leak.
Proposal
Two attributes that share a validator:
// "This field must resolve from environment / prompt / explicit override at run-time.
// Refuse to start if it resolves to a literal in a committed config file."
[NeverLiteral]
// Stronger form: this field must come from a [SecretSource]-shaped lookup
// (env-var named after the field, secret store, prompt). Refuses parameter
// flags AND config-file values. Implied for [Secret].
[FromEnvironment]
Behaviour:
- At parameter-resolution time, the validator looks at how the value was resolved.
- If
[NeverLiteral] and the resolution source is "committed config file", fail with "Field {Name} must not be a literal in a committed file — set ${ENV_VAR_NAME} instead."
- Local override files (e.g.
*.local.json that are gitignored) pass — committed status is the discriminator, checkable via git check-ignore.
The validator runs on every ./build.sh invocation, not just Doctor. Lifts the "we forgot to use the env-var" failure to the very first frame instead of letting it ship.
Why this fits Fallout (not gitleaks / secret-scanning)
- Gitleaks detects patterns in the file. It can't tell whether a string is "supposed to be env-sourced" — that's a schema concern, only knowable from the C# parameter declaration.
- GitHub Secret Scanning is partner-pattern-driven. Non-credential leaks (IPs, internal hostnames, identifiers) aren't covered.
- Fallout already owns the parameter schema. The cost is
O(LOC for one validator) rather than maintaining an external regex set.
Open questions
- Granularity. Field-level (this string) vs object-level (this whole
RemoteOptions block must be env-resolved). Probably both, with object-level implemented as "every field marked [NeverLiteral]".
- CI vs local enforcement. Should the validator fail the build hard, or just warn locally and fail in CI?
[Secret] fails hard already; [NeverLiteral] should probably match.
- Backwards compat for migrations. A repo retrofitting this on existing fields needs a transition mode (warn but don't fail) — otherwise the first
[NeverLiteral] lands and every old PR breaks.
- Detection of "committed file" without shelling to git. Caller might pass a
--config <path> not yet under source control. Cheapest probe: git check-ignore --quiet <path> exit code; fallback to "is the file inside the repo tree" + "is it tracked at HEAD" via libgit2.
Related
Motivation
In the ErpForFactoryGames CD bring-up, the deploy target's address (a private-range LXC IP) was committed in
deploy/erp-deploy.jsonas a string literal —"Host": "10.10.107.175". Not a secret (RFC1918, not externally routable), but it leaked the homelab's internal topology to a public repo. We fixed it in ErpForFactoryGames#266 by swapping the IP for anerp-lxcSSH-config alias resolved at runtime.Catch was: nothing in the framework warned us. The field is plumbed through
[Parameter]andRemoteOptions.HostandSshConnectionResolvercleanly — but the literal value sitting in a JSON config file passed every other check. Gitleaks didn't flag it (it's not credential-shaped). GitHub Secret Scanning didn't flag it. Code review didn't catch it because the IP looked like config, not a secret.This is a generic CD smell: fields that should resolve from environment / secret store / operator config sometimes get pinned as literals in committed files, and the framework silently allows it. ADR-0002's
[Secret]story handles credential-shaped fields well; the gap is the non-credential fields where the literal is the leak.Proposal
Two attributes that share a validator:
Behaviour:
[NeverLiteral]and the resolution source is "committed config file", fail with"Field {Name} must not be a literal in a committed file — set ${ENV_VAR_NAME} instead."*.local.jsonthat are gitignored) pass — committed status is the discriminator, checkable viagit check-ignore.The validator runs on every
./build.shinvocation, not justDoctor. Lifts the "we forgot to use the env-var" failure to the very first frame instead of letting it ship.Why this fits Fallout (not gitleaks / secret-scanning)
O(LOC for one validator)rather than maintaining an external regex set.Open questions
RemoteOptionsblock must be env-resolved). Probably both, with object-level implemented as "every field marked[NeverLiteral]".[Secret]fails hard already;[NeverLiteral]should probably match.[NeverLiteral]lands and every old PR breaks.--config <path>not yet under source control. Cheapest probe:git check-ignore --quiet <path>exit code; fallback to "is the file inside the repo tree" + "is it tracked at HEAD" via libgit2.Related
[Secret]; this is its non-credential sibling.LiteralLeakProberather than a parameter-attribute, if Doctor lands first.