Repository navigation
fix(security): redact URI credentials, curl user flags, AWS keys, and PEM keys in McpArgumentSanitizer (#886) - #898
Conversation
|
All CI checks are green ✅. This is the broad fix for Issue #886 — redacts URI userinfo, curl -u/--user credentials, AWS access keys, and PEM private keys in McpArgumentSanitizer. Ready for review! |
85f77dc to
3a0b768
Compare
bdcf620 to
bc9aa3b
Compare
3a0b768 to
4e64ca4
Compare
kshivang
left a comment
There was a problem hiding this comment.
Thanks @KhushiTrivediii — good catch that URI userinfo, curl -u, AWS key IDs and PEM headers were reaching the approval dialog and the operation ledger unmasked (#886).
A note on the conflict: this branch was cut from dev @ 8d35f7721 (v9.5.21), which has since been rebased forward (the #935/#695 integrations, module reorg, and the Windows persistence fix). The "conflicts" are all in files this PR does not actually change, so rather than merging the stale snapshot I rebased the branch onto current dev (bc9aa3b2f) — the contribution now sits on current dev and the PR is mergeable again. Your original commit is preserved in the ancestry.
One substantive repair (agent work, recorded separately, not credited):
- URI userinfo now reuses
LogSanitizer.redactUrlUserInfo(the #640 helper) instead of a local regex. The local regex stopped at the first@in the authority, sopostgres://user:p@ss@hostredacted touser:[REDACTED]@ss@host— leaving the password tail in the dialog and the ledger. The helper ends the authority at the first/,?,#and treats the last@as the delimiter, so the whole userinfo is removed. This is exactly the reuse issue #886's suggested-fix calls for. - PEM private keys are now masked as the whole
BEGIN..ENDblock (header-only masking left the key body on the line); truncated fragments still mask to end of line. curl -u/--userrule hardened: username part can't contain/,:,@and the flag can't be mid-token, so a bare URL argument (-u http://host) is left alone while a realuser:passis masked whole.
Added regression tests pinning the last-@ case, a path @/colon, colon-in-password, and full-block PEM masking. McpGovernanceReviewTest + McpArgumentSanitizerAuthSchemeTest pass locally (5+10 tests), ktlint/detekt clean on the touched source sets. (The full ai.rever.boss.mcp.* package SIGABRTs the local test JVM in my environment and on pristine dev too, so I validated the touched classes directly; your CI's full suite is the authoritative run.)
@claude review
|
Thanks @KhushiTrivediii — good catch that URI userinfo, Conflict note: this branch was cut from dev @ Substantive repair (agent work, recorded separately, not credited to you):
Added regression tests pinning the last- @claude review |
|
Claude finished @kshivang's task in 5m 22s —— View job Review:
|
| input | result |
|---|---|
curl --user=admin:hunter2 https://x |
unchanged — password in the dialog and the ledger |
curl -uadmin:hunter2 https://x |
unchanged |
Both are valid curl (long options take --opt=value; short options take an attached argument), and -uadmin:… is the form that shows up in copy-pasted one-liners. Tracing the --user= case: at index 0 the -u alternative needs -u and sees --; at index 1 the lookbehind (?<![A-Za-z0-9_-]) sees the leading - and blocks. So there is no match at all, not a partial one.
Suggest (-u|--user)\s*[= \t]\s* style separation, or (?:\s+|=) for the long form plus an attached-argument branch for -u. At minimum the KDoc must stop claiming the = form.
2. An email username defeats the whole rule
[^:/\s@]+ forbids @ in the username, so curl -u alice@example.com:hunter2 matches nothing and the password survives intact. Email-as-username is the ordinary shape for Jira, Confluence, Bitbucket app passwords and most SaaS basic auth.
The @ exclusion isn't free to drop, though — I checked what it buys, and it's rsync -u user@host:/srv/data /tmp (-u is rsync's --update), which would otherwise be mangled. So this needs a narrower rule rather than a straight relaxation: allowing @ in the username while requiring the password not to begin with / would catch the email case and still leave user@host:/srv/… alone. Worth doing at least for the unambiguous --user long form, where there is no --update collision.
3. -----BEGIN PGP PRIVATE KEY BLOCK----- is not matched
McpArgumentSanitizer.kt:44 requires PRIVATE KEY----- immediately after the optional label:
-----BEGIN (?:[A-Z0-9-]+ )?PRIVATE KEY-----
GPG's armor header is -----BEGIN PGP PRIVATE KEY BLOCK----- — the trailing BLOCK means neither the BEGIN nor the END marker matches, so an exported GPG secret key passes through whole. RSA / EC / DSA / OPENSSH / ENCRYPTED are all fine. Allowing an optional BLOCK before the closing dashes covers it.
4. rsync -u host:/srv/data is over-masked into host:[REDACTED]
(?!//) blocks a double slash only, so a single-slash remote path is read as a password:
rsync -u host:/srv/data /tmp -> rsync -u host:[REDACTED] /tmp
This one matters more than an ordinary false positive because it contradicts this class's stated reason for existing (McpArgumentSanitizer.kt:13-19): a path is "the thing an operator most needs to read before approving a mutating tool call". Masking the destination of a file-copying command is the failure mode the whole class is narrower-than-LogSanitizer to avoid.
5. The \-in-authority caveat now reaches a consent surface, not just a log
LogSanitizer.redactUserInfo's own KDoc records that \ does not end an authority, so https://evil.example\@good.example/x is rendered as https://[REDACTED]@good.example/x — "hiding the host that was actually visited." That trade-off was reasoned about for logs. Here the output is what an operator reads before granting a mutating call, and under WHATWG parsing (which Chromium/JxBrowser uses) the host actually reached is evil.example. A tool argument is attacker-influenceable in exactly the way this file's governance docs assume, so an argument can now render a trusted-looking host in the approval dialog.
I don't think this blocks the PR — it's inherited from #640, not introduced here, and it's strictly better than the unredacted status quo. But it should be written down at the call site (:137-139 is the natural place), because "this helper's documented limitations were accepted for logs" is not the same acceptance as "accepted for a consent dialog." Same class of note this repo already keeps for the telemetry bus.
6. \s+ crosses newlines, and the replacement rewrites it to a space
$1 $2:[REDACTED] normalises the separator, so -u\nadmin:pass comes out as -u admin:[REDACTED] — one line where there were two. The sibling rule right above deliberately uses [ \t] for precisely this reason, and there is an existing test pinning it: McpArgumentSanitizerAuthSchemeTest.kt:61, "authorization label does not consume command text on the next line". [ \t]+ here would match the established convention, and echoing the matched separator instead of a literal space would keep the sanitized text a faithful rendering of the command being approved.
7. AWS coverage notes (minor, fail-open)
- The trailing
\bmeansAKIA+ more than 16 following uppercase/digit characters matches nothing — a malformed or concatenated token is passed through rather than masked. Real IDs are exactly 20, so this is low risk, but it fails open where the rest of the file fails closed. - Only
AKIA/ASIAof the ~10 AWS id prefixes (AROA,AIDA,AIPA,ANPA,APKA, …). Defensible scope — worth one KDoc line saying so. - The secret access key is still exposed in the positional form:
aws configure set aws_secret_access_key wJalr…has no:/=, sosensitiveAssignmentnever fires andsanitizeMap's key rules don't see inside a singlecommandstring. A 40-char base64 shape is too false-positive-prone to match blindly, so I'd record it as a known limitation rather than chase it.
8. Smaller items
McpArgumentSanitizer.kt:45— with(?s), a truncatedBEGINblock will lazily scan forward to any laterENDanywhere in the string, swallowing unrelated text between two keys. Safe direction, worth a KDoc sentence.:56— capture group 3 is never referenced by the replacement; make it\S*non-capturing. ([^\s]*→\S*while you're there.)- Username visibility is inconsistent between the two new rules: the URI path drops the username along with the password (
postgres://[REDACTED]@host) while the curl path keeps it (-u admin:[REDACTED]). Both are defensible, but an operator judging which account a call authenticates as gets an answer in one case and not the other. Fine to keep — just call it out in the KDoc so the next reader doesn't "fix" one to match the other.
9. Test structure
The 73 added lines are one test method, URI userinfo credentials, curl user flags, AWS keys, and PEM keys are redacted, carrying 13 assertions across four independent rules. This repo's convention (AGENTS.md, repeatedly) is that a mutation should fail a named test — here, breaking PEM masking fails a test whose name lists four things, and the first failing assertion masks the remaining twelve. I'd split it into four tests and put them beside McpArgumentSanitizerAuthSchemeTest, which is already the sanitizer-specific home; McpGovernanceReviewTest is otherwise about policy/approval lifecycle.
Missing fixtures, each corresponding to a finding above: --user=, -uadmin:, an email username, BEGIN PGP PRIVATE KEY BLOCK, and rsync -u host:/path. The negative cases that are present (redisPlain, pathAt, curlUrl) are the good part of this test — they're what pins the (?!//) guard.
Verdict
Approve-with-changes in spirit: 1, 2 and 3 are the same class of leak the PR exists to close and should be fixed here rather than followed up, since a partial credential filter is worse than a known-absent one — it invites the assumption that the dialog is clean. 4 is a genuine readability regression against this class's stated design. 5 and 6 want comments/consistency, not redesign. Everything else is minor.
One caveat on my own verification: I reviewed by reading and hand-tracing each pattern against the existing rules and the LogSanitizer implementation — I was not able to execute a scratch harness or the Gradle test task in this environment, so treat the table in finding 1 and the traces in 2–4 as careful manual analysis rather than executed output. They're mechanical enough to confirm quickly with a one-line test each, which is also the argument for adding those fixtures.
· branch fix/mcp-sanitizer-uri-credentials-and-key-shapes
… PEM keys in McpArgumentSanitizer (risa-labs-inc#886)
…PEM blocks in the argument sanitizer Review repair for risa-labs-inc#886 (agent work, not credited to the contributor): - URI userinfo: call LogSanitizer.redactUrlUserInfo (merged in risa-labs-inc#640) instead of a local regex. The local regex stopped at the first @ in the authority, so a password containing an @ left its tail (ss@host) in the approval dialog and the operation ledger. Issue risa-labs-inc#886's suggested fix calls for exactly this reuse. - PEM private keys: mask the whole BEGIN..END block (a header-only mask left the key material on the line) and still mask truncated fragments to end of line. - curl -u/--user: username part may not contain /, :, or @, and the flag must not be mid-token, so a bare URL argument (-u http://host) is left untouched while a real user:pass credential is masked whole (passwords may contain colons). - Tests: pin the last-@ delimiter case, path-@ case, colon-in-password case, and full-block PEM masking.
…nt sanitizer Claude review findings on the rebased head, same class of leak the PR exists to close: - -u/--user now also match the '=' form (--user=admin:pass) and the attached short form (-uadmin:pass); the KDoc no longer claims a form the regex does not cover - email usernames (alice@example.com) are matched; a password no longer needs to be the first '@' segment, while the password may itself contain ':' or '@' - the password may not start with '/' or '\\', so a bare URL (-u http://host) and an rsync remote spec (-u host:/srv/data) stay readable for the operator - PEM rule now covers GPG armor (-----BEGIN PGP PRIVATE KEY BLOCK-----) - the separator is [ \t] (never \s) and is echoed back, so the rule cannot cross a newline and the sanitized text stays a faithful rendering - the redactUrlUserInfo call site records the inherited backslash-in-authority limitation, now that the output is a consent surface, not only a log - split the 13-assertion mega-test into four named tests beside the existing sanitizer tests, each with the fixtures for its rule
4e64ca4 to
7008642
Compare
…the two redaction rules
|
@claude — disposition of the findings, addressed at head Fixed here (same class of leak the PR exists to close):
Accepted with a note (not a redesign): Verification: |
|
Claude finished @kshivang's task in 7m 29s —— View job Re-review of the disposition at
|
| # | Claim | Verified |
|---|---|---|
| 1 | --user= / -uadmin: |
✅ --user(?:[ \t]+|=) and the attached -u branch both match; two fixtures |
| 2 | Email usernames | ✅ [^\s:=/]+ admits @; --user=alice@example.com:… covered too |
| 3 | GPG armor | ✅ PRIVATE KEY(?: BLOCK)? in both markers, and the PGP PUBLIC KEY BLOCK negative test pins that the label group can't absorb PUBLIC |
| 4 | rsync over-mask | ✅ (?![/\\]) keeps -u host:/srv/data and -u http://host readable |
| 5 | KDoc = lie |
✅ gone; the KDoc now describes what the regex does |
| 5b | \-in-authority note at the consent surface |
✅ :165-171, the right place |
| 6 | \s+ newline |
✅ [ \t], and the separator is echoed via $1 |
| 7 | AWS scope | ✅ :36-41 states prefixes, the 40-char secret omission and its reason; AKIA+17 fixture pins the fail-open |
| 8 | Username visibility | ✅ :73-77, with the "do not 'fix' one to match the other" instruction |
| 9 | Test split | ✅ four named tests, all the fixtures I listed, plus good negatives (redisPlain, pathAt, curlUrl, public-key block) |
Group 3 is non-capturing now too. The KDoc on basicAuthFlagPattern is the best kind — it explains the (?![/\\]) guard by naming the command it protects.
Residual: three gaps the tightened rule opens or leaves, all in basicAuthFlagPattern
These are new or newly-reachable, not restatements. The first is the one I'd fix before merge, because it leaves a password tail on the consent surface and in the ledger, and this file already carries a test documenting that exact failure mode for a sibling rule.
A. A quoted password containing a space leaks its tail. The password branch is [^\s]*, which stops at the first space:
curl -u 'admin:hun ter' https://x
-> curl -u 'admin:[REDACTED] ter' https://x
ter' is readable. This is precisely the bug the existing test a quoted credential is masked for both quoting styles (:86-98) pins for authorizationHeader, whose comment spells it out: "the bare class used to take 'abc and leave def'". That rule solved it with (?:"[^"]*"|'[^']*'|[^\s&,;}]+); the new rule doesn't reuse the solution, and a space is legal in a basic-auth password, so shell-quoted -u 'user:pass phrase' is the ordinary way to pass one. Same three-branch alternation on the password would close it.
B. --proxy-user leaks while its own short form -U is masked. (?i) makes the -u branch match -U, which is curl's --proxy-user and carries the same user:password — good, and probably unintentional. But the long spelling isn't in the alternation, and the lookbehind (?<![A-Za-z0-9_-]) blocks a match on the -user inside it (the preceding y is in the class), so:
curl -U admin:hunter2 x -> curl -U admin:[REDACTED] x
curl --proxy-user admin:hunter2 -> unchanged
One flag, two spellings, opposite outcomes. Adding --proxy-user to the alternation is a one-token change, and the KDoc's "and the equivalent spellings" then becomes true of the whole flag family.
C. A \-continuation between the flag and its value now leaks. Narrowing to [ \t] was right, but it makes the most common multi-line curl shape invisible:
curl -u \
admin:hunter2 https://x
Group 2 has to match the trailing \ and then find a :, which is the newline, so there's no match at all and admin:hunter2 reaches the dialog whole. A separator alternative of [ \t]*\\\r?\n[ \t]* covers it without reopening what \s+ would: a trailing backslash is unambiguous evidence of continuation, so the rule still can't wander into an unrelated next line the way the authorization: rule's pinned convention guards against.
Relatedly, the newline fixture at :182-184 asserts the absence of masking, and its comment reads as if the only thing at stake were faithful rendering. What it actually pins is that admin:hunter2 is written to the ledger in plaintext. Keeping the fixture is fine; the comment should say that outright, the way the rest of this file's comments do.
Minor, no action needed beyond a KDoc line
- Over-masks that hide a fact the dialog exists to show, all in the fail-closed direction and none a leak:
docker run -u 1000:1000 img->-u 1000:[REDACTED](hides which uid a container runs as — the most common non-curl-u X:Ythere is), a relative rsync remote spec-u host:srv/data(the(?![/\\])guard only covers absolute paths), and a single-dash-user:valueflag. Requiring the username not to be all digits would take the docker case out cheaply; otherwise one KDoc sentence saying-u X:Yon a non-curl tool is masked deliberately would stop the next reader treating it as a bug. mysql -uroot -pSecretstill passes the password through. Out of this PR's scope and genuinely hard (-pis a port flag on half the tools that have one), so worth recording beside the AWS-secret note as a known limitation rather than chased.
Verdict
The nine findings are genuinely addressed rather than papered over, and the KDoc now carries the reasoning at the density this repo's AGENTS.md asks for — the (?![/\\]) explanation and the "do not 'fix' one to match the other" note are both the kind of comment that survives a year. A is the one I'd close in this PR: it is a password tail on a consent surface, and the fix is to reuse an alternation already sitting 30 lines away in the same file. B and C are one-line pattern additions each. D/E are comments.
Caveat on my own verification, same as last round: python3 and ./gradlew are both outside this environment's allowed commands, so findings A-C are careful hand-traces against the Java regex semantics, not executed output. Each is a one-line fixture to confirm, which is also the argument for adding them.
· branch fix/mcp-sanitizer-uri-credentials-and-key-shapes
|
@KhushiTrivediii thanks, this sanitizer work is useful. One thing from Claude's latest pass needs to land before merge: the new URI-credential rule can still leave a password tail visible on the consent surface and in the ledger. There's already a test in that file documenting the same failure for a sibling rule, so a matching test here should be quick. I've set this to draft. Mark it ready once that's fixed and I'll merge it. |
08da448 to
4077231
Compare
Windows lets the user move Downloads (Explorer, Properties, Location), for instance to D:\Downloads, but BOSS kept answering %USERPROFILE%\Downloads. - WindowsDownloadsFolder reads the shell's value for FOLDERID_Downloads with reg.exe, parses REG_SZ and REG_EXPAND_SZ, expands %NAME% variables and keeps only an absolute path. It needs no native binding, so the host and plugin processes read it the same way. Read once per JVM; a missing value, a missing reg.exe or a run past 5 s fall back to the old answer. It is only consulted when user.home is the Windows profile, so test tasks that redirect user.home never read the registry. - DownloadsDirectory.current() passes it as the known folder, which still wins only when it names a real directory. - FileSystemDataProviderImpl.writeFile/readFile admit the Downloads folder the provider hands out, and paths under it, in addition to home. Compared on real paths, so .., symlinks and junctions cannot lead into a sibling; a Downloads folder at a filesystem root admits nothing; delete stays home-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reader tests test what they say Review follow-up for this PR. - The desktop getDefaultDownloadsDirectory logs once per JVM when the Windows known folder could not be read, so a failed registry read is no longer indistinguishable from a folder that was never moved. plugin-path-utils stays logger-free. - The commonMain expect KDoc no longer says Windows is always %USERPROFILE%\Downloads. - WindowsDownloadsFolder.current checks os.name itself instead of relying on its only caller to. - "a successful run returns its standard output" ran java -version, which prints to stderr, so it passed on empty output; it now runs --version and checks the version is in what came back (a planted stdout-dropping mutation fails it). The timeout test uses a program that sleeps 60 s against a 1 s timeout instead of a 0 ms race. - RelocatedDownloadsAccessTest's outside-home premise is a @BeforeTest assumption, so it gates every case instead of only itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipc needs plugin-api-ipc calls DownloadsDirectory from plugin-path-utils at runtime (FileSystemDataProviderProxy), but assembleUpstreamJars shipped only four jars, none of which contains it. The standalone microkernel runtime would compile against plugin-api-ipc and then fail with NoClassDefFoundError on the first getDownloadsDirectory() call. - assembleUpstreamJars now also ships plugin-path-utils-<ver>.jar, with the same dependsOn and rename trick as the other plugin-platform jars, and the floor on produced jars is 5, so a missing one fails the task. - plugin-path-utils gets version 1.0.0, like plugin-api-ipc and plugin-api-core, so its jar has a versioned name the runtime can pin. - The task KDoc and the release.yml comment list the fifth jar. Before: 4 jars, none containing ai/rever/boss/plugin/pathutils. After: 5 jars, plugin-path-utils-1.0.0.jar contains DownloadsDirectory.class and WindowsDownloadsFolder.class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ke the timeout test time itself Review follow-up for this PR. - isInsideDownloads fails closed: an IOException from canonicalFile or toRealPath now makes the check return false, so the plugin gets the SecurityException refusal instead of a generic I/O failure. - realPath climbs with Files.exists(NOFOLLOW_LINKS), so a dangling symlink or junction inside Downloads is found as the link it is; toRealPath on it fails, and the write is refused instead of being checked as a plain name and then followed. The KDoc says so. - The timeout test asserts the elapsed wall time is at least the timeout, so a child that fails at once (which also gives null) cannot pass it. - A comment on the home branch of isReadableAndWritable says it compares canonicalFile paths and is knowingly weaker than the Downloads branch. Behaviour unchanged. Seen red first: on the previous code both new RelocatedDownloadsAccessTest cases fail (IOException: Invalid file path; FileNotFoundException through the dangling junction), and the timeout test with a child that fails at once fails after 124 ms, where the old version of the test passed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…les, and lock zoom updates (risa-labs-inc#938) Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>
…ds-follow-relocated-windows-folder fix(plugins): follow a Downloads folder the user moved on Windows
|
@KhushiTrivediii thanks for the credential-redaction work. I checked the author patch and the current PR still includes old dev history and a workflow-file edit; its merge base is |
072a2bd to
2ae2565
Compare
Fixes #886
Changes