cliErrorMessage() at src/cli.ts:2898 strips API keys, bearer tokens and URL passwords out of every error the CLI prints. It is used at 22 call sites, all of them on the way to stderr.
The same string gets written to the scan database without it.
src/api.ts:733:
"fail-scan",
"--scan-id",
activeScan.id,
"--message",
(failure instanceof Error
? failure.message
: String(failure)
).slice(0, 2400),
That value comes off the model event stream (api.ts:1191, api.ts:1196). It is stored in scans.failure_message (workbench_db.py:1777) and printed back out by scan-history show (scan-history-renderer.ts:213). Nothing on that path redacts it.
So a credential the CLI hides from the terminal still lands on disk.
What it looks like
I copied cliErrorMessage verbatim and ran four realistic upstream errors through it. First line is what you see, second is what gets stored.
stderr : error sending request for url (https://[redacted]@proxy.internal/v1/responses)
stored : error sending request for url (https://svc-account:s3cr3tP4ss@proxy.internal/v1/responses)
stderr : Incorrect API key provided: [redacted]
stored : Incorrect API key provided: sk-proj-ABCDEF1234567890
stderr : upstream rejected header Authorization: Bearer [redacted]
stored : upstream rejected header Authorization: Bearer eyJhbGciOiJIUzI1NiJ9padding
stderr : fetch to https://api.internal/v1/chat?api_key=[redacted] failed
stored : fetch to https://api.internal/v1/chat?api_key=SUPERSECRETVALUE123 failed
The first one is the easy path to hit. Point the tool at a proxied model provider whose base_url carries userinfo and let the provider be unreachable. classifyConnectionFailure (api.ts:1543) reads that as a network error, so you get the canned "The model service could not be reached" from cli.ts:2499 and never see the URL. The raw string with the password is already in workbench.sqlite3.
Why it leaves the machine
Dockerfile:52 sets CODEX_SECURITY_STATE_DIR=/output/.codex-security-state and compose.yaml:28 mounts /output to the host results directory. The database sits inside the bundle people archive and pass around.
The db file is 0600 (workbench_db.py:237), so I am not claiming local disclosure. The problem is that the value is stored unredacted, printed again by scan-history show, and carried along in the results directory.
Fix
Apply the redactor at api.ts:737. That means moving cliErrorMessage somewhere api.ts can import it.
Worth folding in while you are there. redactError at src/multiscan.ts:525 is a third copy, used for bulk-scan receipts, and it is missing the URL and query parameter rules. Same input, nothing removed:
in : error sending request for url (https://svc-account:s3cr3tP4ss@proxy.internal/v1/responses)
out : error sending request for url (https://svc-account:s3cr3tP4ss@proxy.internal/v1/responses)
One shared helper for all three would close both gaps and stop them drifting apart again.
On the tests
tests-ts/cli-fixtures.ts:20 has a 50 entry list of secret shapes, including https://USER:PASSWORD@example.test/private. It is asserted in five test files, all on the stderr path. tests-ts/api.test.ts:1890 asserts the raw message reaching fail-scan, so the suite as it stands would not catch this.
Found on 0.1.1 by reading the source, not from a failed scan in the wild.
cliErrorMessage()atsrc/cli.ts:2898strips API keys, bearer tokens and URL passwords out of every error the CLI prints. It is used at 22 call sites, all of them on the way to stderr.The same string gets written to the scan database without it.
src/api.ts:733:That value comes off the model event stream (
api.ts:1191,api.ts:1196). It is stored inscans.failure_message(workbench_db.py:1777) and printed back out byscan-history show(scan-history-renderer.ts:213). Nothing on that path redacts it.So a credential the CLI hides from the terminal still lands on disk.
What it looks like
I copied
cliErrorMessageverbatim and ran four realistic upstream errors through it. First line is what you see, second is what gets stored.The first one is the easy path to hit. Point the tool at a proxied model provider whose base_url carries userinfo and let the provider be unreachable.
classifyConnectionFailure(api.ts:1543) reads that as a network error, so you get the canned "The model service could not be reached" fromcli.ts:2499and never see the URL. The raw string with the password is already inworkbench.sqlite3.Why it leaves the machine
Dockerfile:52setsCODEX_SECURITY_STATE_DIR=/output/.codex-security-stateandcompose.yaml:28mounts/outputto the host results directory. The database sits inside the bundle people archive and pass around.The db file is
0600(workbench_db.py:237), so I am not claiming local disclosure. The problem is that the value is stored unredacted, printed again byscan-history show, and carried along in the results directory.Fix
Apply the redactor at
api.ts:737. That means movingcliErrorMessagesomewhereapi.tscan import it.Worth folding in while you are there.
redactErroratsrc/multiscan.ts:525is a third copy, used for bulk-scan receipts, and it is missing the URL and query parameter rules. Same input, nothing removed:One shared helper for all three would close both gaps and stop them drifting apart again.
On the tests
tests-ts/cli-fixtures.ts:20has a 50 entry list of secret shapes, includinghttps://USER:PASSWORD@example.test/private. It is asserted in five test files, all on the stderr path.tests-ts/api.test.ts:1890asserts the raw message reachingfail-scan, so the suite as it stands would not catch this.Found on 0.1.1 by reading the source, not from a failed scan in the wild.