Scrub a short secret, and both spellings of a proxy password - #107
Merged
Conversation
Two security findings from the PR #106 review, dismissed there with reasons and taken now. Both are about the LIFETIME of a credential's forms rather than about the scrubber's arithmetic, which is why the round that built it did not see them. A SHORT SECRET WAS SILENTLY NOT SCRUBBED. The shared scrubber skipped anything under eight characters, and I had documented that as a deliberate trade: redacting a short string turns every innocent occurrence of it into the marker and leaves an operator a failure detail they cannot read. The reasoning about readability is right. The trade is wrong at the point of USE, because at that instant the value IS a live credential and the cost of leaking it is unbounded, while noise in a message is recoverable. It was not hypothetical. Gitea and Forgejo accept an ACCOUNT PASSWORD for git-over-HTTP with a default minimum of six characters, nothing validates the length of a factory token an operator pastes into the registry, and such a password reached factory_run.failure_detail verbatim -- written by the container that holds the git WRITE token. So the floor no longer gates the scrub. It survives as MIN_PLAUSIBLE_SECRET_LENGTH, and a secret below it is scrubbed AND logged once, naming the consequence. A warning an operator can read beats a silence they cannot. The log line carries the LENGTH and never the value. Two things deliberately NOT done. There is no refusal at registration: "a credential must be eight characters" is a new rule about what an operator may configure, it would block a working Gitea deployment, and inventing it under cover of a logging fix is how a product decision gets made by accident. And the proxy password's startup REFUSAL is removed rather than kept, because its entire stated premise was "SecretScrub ignores it" -- a refusal whose reason has become false is worse than none. THE PROXY PASSWORD WAS SCRUBBED IN A SPELLING THE CONTAINER DOES NOT CARRY. Measured rather than argued: an operator writes p%2fss%20x, credentialIn decoded it to "p/ss x", and SecretScrub derives its URL-encoded form with URLEncoder, which emits UPPERCASE hex and + for a space -- p%2Fss+x. The value actually set in every container is the raw URL, which contains neither. `env` and `printenv` are routine agent actions and their output lands in run_event, which a viewer can read. Both spellings are now collected, raw first because that is the environment variable, decoded second because that is what a Proxy-Authorization header and a verbose curl print. distinct() collapses them when the password carries no escapes, so the common case costs nothing. Four existing tests pinned the behaviour being reversed and are rewritten rather than deleted, each saying what changed and why -- two of them written by the round that introduced the floor. The publisher's floor test is the interesting one: with the floor gone, NOTHING in that file separates the shared class from a hypothetical local copy, and that is the correct state rather than a gap. The two agree on every path that class can reach, since it holds exactly one credential; what guarantees it reaches the shared class is structural -- its own copy is deleted -- not an assertion. Three mutations, each killed by exactly the intended test: restoring the length floor, dropping the second spelling, and returning only the decoded form. 301 suites, 2597 tests, 0 failures.
Review findings on the credential-scrub change. The HIGH is a defect this branch introduced, and it is not in what was added -- it is in what was deleted. requireScrubbableProxyPasswords was, unnoticed, the ONLY startup caller of proxyCredentials(). Its stated premise was a length rule; that premise had become false and it was deleted on that basis. The call had a second job nobody had named: URLDecoder throws on a bare % -- 100%secure, pa%ss -- and a bare % is a legal password character an operator writes. That throw used to happen at boot. Afterwards the first caller is RunFailures.scrubFor, on the run-launch path. RunLauncher builds the transcript scrub AFTER runtime.create(unit) has put the model key and the git write token into three containers. RunDispatcher's catch then calls failures.of(...), which re-enters the same code and throws a SECOND time, escaping the handler before the finally that calls registry.forget. That comment already says what follows: isExecuting() is the watchdog's one absolute exemption, so the sandbox is permanently unreclaimable. One mistyped character, every run, deterministically. decode never throws now. Returning the value unchanged is the truthful answer as well as the safe one: a % the operator did not mean as an escape means the value already IS its own decoded form, and bothSpellings collapses to the single entry that is true. The exception is not logged, because its message quotes two characters of the password. The lesson is worth more than the fix. Deleting a control because its STATED reason has become false is only safe once you know what else the control was doing. The reason was documentation; the call was behaviour. One grep for callers would have shown it. Four more, each from the same round: scrubFor's proxy call was unguarded while the two decrypt calls above it were, so one bad proxy value disarmed the SCM and harness scrub too -- the opposite of the per-credential degradation that method documents. The write token was paired with the READ username. Credentials.Scm carries a writeUsername precisely "so the call sites are already correct when a deployment issues two", and the comment ten lines below in the same method states the rule the code broke: base64(user:secret) built with the wrong username appears on no wire. The identical defect this branch fixes for the proxy credential, two credentials apart, in one method. A review record from M1 task 1 had already named it, together with the floor and the encoding, as one Open entry -- and it sat there while two later rounds found them one at a time. An Open entry naming several defects is not a single item. URLDecoder is a FORM decoder and turns + into a space, which no URI userinfo means by it. A password a+b yielded a decoded form "a b" that appears on no wire while the real header form went uncovered. + is escaped before decoding now. The registry credential reached no scrub. Defence in depth -- nothing places it on a container -- closed for one entry. Then the tests, where two were weaker than they looked: The WARN is the entire stated compensation for removing the floor, and it was asserted by nothing. In a class whose purpose is keeping credentials out of logs, adding the secret to its arguments would have passed every test in the repository. It is now asserted to fire, to carry the length, and to carry neither the value nor the username, with a negative control. bothSpellingsOfOneProxyPasswordAreScrubbed could not see a decode-only regression: URLEncoder re-encodes p@ss back to the operator's own p%40ss -- no hex letter, so no case to differ on -- and the fixture cancelled out the very mechanism it was written to prove. %2f removes the coincidence. Both new proxy tests would also have passed a scrub that redacted EVERYTHING; the host must survive or an operator gets a failure naming nothing. SecretScrubHasOneHomeTest did not exist, while SecretScrub's javadoc cited the guard as precedent and OutcomeWriterTest rested on "structural, not an assertion". Worse: the moment the two implementations stopped differing was the moment a re-added local copy stopped failing any test. Written, allowlist empty, mutation-verified against a re-added copy. And "logged once" was never true -- a scrub is built per launch and again per failure. Said plainly rather than fixed by deduplicating, because a reader who believes it is deduplicated will build on that. Three documents still described the deleted rule, two of them operator-facing: .env.example and CORPORATE-ENVIRONMENT.md both promised a startup refusal, and MODULES.md documented the floor in full and named a constant that no longer exists. 302 suites, 2603 tests, 0 failures.
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the two security findings PR #106's review raised and dismissed with reasons. Both were held
back deliberately: they change what a credential's forms are, on paths #106 did not otherwise
touch, and bolting that onto a debt round would have made a product decision by accident.
Both are about the lifetime of a credential's forms rather than the scrubber's arithmetic — which
is why the round that built the shared scrubber did not see either.
1 — A short secret was silently not scrubbed
The shared scrubber skipped anything under eight characters, and I documented that as a deliberate
trade: redacting a short string turns every innocent occurrence of it into the marker and leaves an
operator a failure detail they cannot read.
The reasoning about readability is right. The trade is wrong at the point of use. At that instant
the value is a live credential and the cost of leaking it is unbounded, while noise in a message is
recoverable.
It was not hypothetical. Gitea and Forgejo accept an account password for git-over-HTTP with a
default minimum of six characters, nothing validates the length of a factory token an operator
pastes into the registry, and such a password reached
factory_run.failure_detailverbatim — writtenby the container that holds the git write token.
The floor no longer gates the scrub. It survives as
MIN_PLAUSIBLE_SECRET_LENGTH, and a secret belowit is scrubbed and logged once, naming the consequence. A warning an operator can read beats a
silence they cannot. The log line carries the length and never the value.
Two things deliberately not done
No refusal at registration. "A credential must be eight characters" is a new rule about what an
operator may configure. It would block a working Gitea deployment, and inventing it under cover of a
logging fix is how a product decision gets made by accident.
The proxy password's startup refusal is removed, not kept. Its entire stated premise was
"
SecretScrubignores it" — a refusal whose reason has become false is worse than no refusal,because the next reader trusts the reason.
2 — The proxy password was scrubbed in a spelling the container does not carry
Measured rather than argued. An operator writes
p%2fss%20x;credentialIndecoded that top/ss x; andSecretScrubderives its URL-encoded form withURLEncoder, which emits uppercasehex and
+for a space —p%2Fss+x. The value actually set in every container is the raw URL, whichcontains neither.
That matters because
envandprintenvare routine agent actions, and their output lands inrun_event, which a viewer can read.Both spellings are collected now: raw first, because that is the environment variable; decoded
second, because that is what a
Proxy-Authorization: Basicheader and a verbosecurlprint.distinct()collapses them when the password carries no escapes, so the common case costs nothing.Four tests pinned the behaviour being reversed
Rewritten rather than deleted, each saying what changed and why — two of them written by the round
that introduced the floor.
The publisher's floor test is the interesting one. With the floor gone, nothing in that file
separates the shared class from a hypothetical local copy — and that is the correct state rather
than a gap. The two now agree on every path that class can reach, since it holds exactly one
credential, so ordering is unreachable. What guarantees the publisher reaches the shared class is
structural: its own copy is deleted and it has no other scrub.
Verification
Three mutations, each killed by exactly the intended test: restoring the length floor, dropping the
second spelling, and returning only the decoded form.
302 suites, 2603 tests, 0 failures (
testFast+testServices).Review round
Four lenses. One HIGH, found by three of them independently — and it is a defect this branch
introduced. Not in what was added, in what was deleted.
The HIGH
requireScrubbableProxyPasswordswas, unnoticed, the only startup caller ofproxyCredentials(). Its stated premise was a length rule; that premise had become false, soit was deleted on that basis. The call had a second job nobody had named:
URLDecoderthrows ona bare
%—100%secure,pa%ss— and a bare%is a legal password character anoperator writes. That throw used to happen at boot.
Afterwards the first caller is on the run-launch path:
RunLauncherbuilds the transcript scrub afterruntime.create(unit)has put the modelkey and the git write token into three containers. It throws.
RunDispatcher's catch callsfailures.of(...), which re-enters the same code and throws asecond time.
finallythat callsregistry.forget. The dispatcher's own commentstates the consequence:
isExecuting()is the watchdog's one absolute exemption, so thesandbox is permanently unreclaimable.
One mistyped character in a proxy URL, every run, deterministically.
decodenever throws now. Returning the value unchanged is the truthful answer as well as thesafe one — a
%the operator did not mean as an escape means the value already is its owndecoded form. The exception is deliberately not logged: its message quotes two characters of the
password.
The lesson is worth more than the fix. Deleting a control because its stated reason has
become false is only safe once you know what else the control was doing. The reason was
documentation; the call was behaviour. One grep for callers would have shown it.
Four more in the production path
scrubFor's proxy call was unguarded while the two decrypt calls above it were, so one badproxy value disarmed the SCM and harness scrub too — the opposite of the per-credential
degradation that method documents.
Credentials.Scmcarries awriteUsernameprecisely "so the call sites are already correct when a deployment issuestwo", and the comment ten lines below in the same method states the rule the code broke. The
identical defect this branch fixes for the proxy credential, two credentials apart, in one method.
URLDecoderis a FORM decoder and turns+into a space, which no URI userinfo means byit. A password
a+byielded a "decoded" forma bthat appears on no wire while the realheader form went uncovered.
Two tests were weaker than they looked
floor, and in a class whose purpose is keeping credentials out of logs, adding the secret to its
arguments would have passed every test in the repository. Now asserted to fire, to carry the
length, and to carry neither the value nor the username — with a negative control.
bothSpellingsOfOneProxyPasswordAreScrubbedcould not see a decode-only regression.URLEncoderre-encodesp@ssback to the operator's ownp%40ss— no hex letter, so nocase to differ on — so the fixture cancelled out the very mechanism it was written to prove.
Measured: it survived that mutation.
%2fremoves the coincidence.Both new proxy tests would also have passed a scrub that redacted everything; the host must
survive, or an operator gets a failure that names nothing.
One guard that was cited but never written
SecretScrubHasOneHomeTestdid not exist, whileSecretScrub's javadoc cited the guard asprecedent and
OutcomeWriterTestrested on "structural, not an assertion". Worse: the momentthe two implementations stopped differing was the moment a re-added local copy stopped failing any
test. Written, allowlist empty, mutation-verified against a re-added copy.
An old record that already knew
The M1 task-1 review record's
[sec-L2]had named all three of the read-username pairing, thelength floor and the form-encoding — as one Open entry, correctly noting they were "latent
while a deployment carries one alphanumeric token". It sat there while two later rounds found them
one at a time. An Open entry naming several defects is not a single item, and the half that
goes latent last is the one nobody re-reads.
Documentation
Three documents still described the deleted rule, two of them operator-facing:
.env.exampleandCORPORATE-ENVIRONMENT.mdboth promised a startup refusal, andMODULES.mddocumented the floorin full and named a constant that no longer exists. Also "logged once", which was never true — a
scrub is built per launch and again per failure.
Four findings are dismissed with reasons rather than fixed. Full dispositions:
.claude/reviews/global/credential-scrub-forms.md.