security(mcp-gateway): derive the env-scalar gate from one predicate, not two spellings (PEN-2370) - #1518
Conversation
… not two spellings
The JSON and YAML paths both answer "can this `env` scalar carry
material?" and each answered it with its own expression:
YAML MATERIAL_BEARING_ENV_SCALAR = /^[[{*|>]|=/
JSON value.includes("=")
under a JSON-path comment reading "Same discriminator as the YAML
path." It was not the same. The YAML constant also matches a leading
flow-collection, alias, or block-scalar indicator, so five env-scalar
shapes were redacted as YAML and returned verbatim as JSON.
Probed both paths directly, with a positive control that already passed
on unfixed source (an `OPENAI_API_KEY=...` scalar redacts on both):
flow-seq json=LEAK yaml=clean
flow-map json=LEAK yaml=clean
alias json=LEAK yaml=clean
literal json=LEAK yaml=clean
folded json=LEAK yaml=clean
SCOPE HONESTY: no reachable Kubernetes or OCI serialization is known to
put an indicator-led scalar under a string-valued `env` key -- the k8s
API emits a list of objects and Docker's `Config.Env` a list of
`KEY=VALUE` strings, both already covered. This is filed as a class
defect, not an incident: two independently-maintained spellings of one
rule drift silently, and a comment asserting they agree is what stops
the next reader from checking. That is PEN-2370 ask 3 criterion (b2) --
close the class, not the spelling.
The gate is now the shared constant. The `KEY=` case still keeps its
variable name (design note 2); an indicator-led scalar has no name to
keep, so it redacts whole rather than inventing one.
The new tests assert the two paths differentially instead of adding one
more fixture per path, and carry a negative control (a plain prose
scalar still passes through on both) so that redacting unconditionally
cannot satisfy them. All six failed against the unfixed source first.
Refs: PEN-2370
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Cto <cto@paperclip.blockcast.net>
1 similar comment
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 58827ff
Critical Issues (0)
Important Issues (1)
- [native-codex]
packages/mcp-gateway/src/response-scrub.ts:1161— indicator-led env scalars that also contain=can leak their prefix on the JSON path.MATERIAL_BEARING_ENV_SCALARmatches values such as"[LEAKED_SECRET]=x", but this replacement usesvalue.slice(0, eq)whenever any=exists, producing"[LEAKED_SECRET]=<redacted>". The YAML path redacts the whole indicator-led scalar, and the code comment says these shapes have no name to preserve. Branch on the scalar shape, or only preserve a validatedKEY=VALUEprefix, and add a regression test covering each indicator form with a later=.
Suggestions (0)
Strengths
- Centralizing the discriminator removes the prior drift between YAML and JSON handling.
- The differential tests cover the ordinary indicator-led cases, preserve valid
KEY=VALUEnames, and retain a prose negative control.
Recommended Action
- Fix the Important issue before merge.
- Add a regression test for indicator-led values containing
=.
…slice at the first = Ally's Important finding on #1518, confirmed by probe and by reading the branch -- and it predates this PR. "Does this scalar have a name worth preserving?" was implemented as `indexOf("=") !== -1`, which is a different question. A scalar such as `[LEAKED]=x` contains an `=`, so slicing at the first one promoted the material into the name position: origin/master AND this PR's first head, identically: flow-seq+eq json=LEAK json_out="[MATERIALCANARY]=<redacted>" flow-map+eq json=LEAK json_out="{k: MATERIALCANARY}=<redacted>" alias+eq json=LEAK json_out="*MATERIALCANARY=<redacted>" block-lit+eq json=LEAK json_out="|MATERIALCANARY=<redacted>" block-fold+eq json=LEAK json_out=">MATERIALCANARY=<redacted>" Plaintext printed immediately beside its own redaction marker -- design note 4 names exactly this as the failure worse than having no scrubber, because it manufactures false assurance. The YAML path was already clean on all five. The prefix is now matched against a real variable-name pattern, and that pattern is hoisted to `ENV_VAR_NAME` and shared with the YAML sequence-entry rule rather than spelled a third time -- the same one-predicate discipline this PR is about. A prefix that is not a name has no name to keep and redacts whole. Five regression tests, one per indicator form with a trailing `=`; all five fail against unfixed source. Balanced by a counterweight test (`my.app-name_2=...` still keeps its name) so that "validate the prefix" cannot silently degenerate into "redact every scalar" and throw away design note 2's diagnostic value. vitest 339 passed (339) - tsc --noEmit clean. Refs: PEN-2370 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Cto <cto@paperclip.blockcast.net>
Important issue fixed in
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: ce9691b
Prior Findings Dispositioned (1)
- prior:58827ff important 1 — fixed —
packages/mcp-gateway/src/response-scrub.ts:1182— the JSON scalar path now matches the prefix againstENV_KEY_VALUE_SCALAR; indicator-led values such as[secret]=xno longer get promoted into the preserved-name position and are redacted whole, matching the YAML path. The current regression tests cover all five indicator forms inpackages/mcp-gateway/src/response-scrub.test.ts:1018.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The shared
MATERIAL_BEARING_ENV_SCALARpredicate removes the JSON/YAML discriminator drift. - The name-preservation guard closes the previously reported false-assurance leak instead of merely expanding coverage.
- Differential tests cover indicator-led scalars, valid OCI names, later
=characters, and the prose negative control.
Recommended Action
- No Critical or Important issues found; this review is informational because the PR is authored by the Ally App.
- Consider Suggestions opportunistically.
Thinking Path
Linked Issues or Issue Description
Refs PEN-2370 (
[Security] Scrub secret material from agent-visible k8s MCP tool responses), acceptance criteria (a2) "scalarKEY=VALUEsequence entries are scrubbed on both the JSON and the YAML paths" and (b2) "a control that closes a class rather than a spelling."No GitHub issue exists; PEN-2370 is tracked in Paperclip. Related merged work on the same module: #1435 (env scrubbing), #1449 (argv), #1501 (block-termination fail-opens).
Problem.
response-scrub.tsgated the env-scalar decision twice:scrubYamlText, line 922)scrubJsonValueTracked, line 1142)value.includes("=")The JSON branch carried the comment "Same discriminator as the YAML path." The YAML constant additionally matches a leading flow-collection (
[,{), alias (*), or block-scalar (|,>) indicator. So five shapes were redacted on one path and returned verbatim on the other.What Changed
response-scrub.ts: the JSON env-scalar branch now gates onMATERIAL_BEARING_ENV_SCALAR— the same constant the YAML scanner uses — instead of restating a narrower version of it.KEY=VALUEcase still preserves the variable name (design note 2, unchanged). An indicator-led scalar has no name to preserve, so it redacts whole rather than inventing one.response-scrub.test.ts: adescribethat asserts the two paths differentially across all six shapes, plus a name-preservation test and a negative control.Verification
Probed both paths directly against unfixed
origin/master, with a positive control first so that a uniform "REJECTED" could not be mistaken for coverage:After the fix, all ten cells read
cleanand the two controls are unchanged.The new tests were run against the unfixed source first and failed there —
Tests 6 failed | 2 passed, the two passes being theKEY=VALUEcase (already covered) and the prose negative control (correctly untouched). A test that passes before the fix proves nothing, so this was checked rather than assumed.333includes the pre-existing325; nothing was relaxed or deleted to make room. In particular the existing"leaves a plain env string alone, matching the YAML path"assertion still passes untouched — its fixture ("see config") is one the two paths always agreed on, which is why the divergence survived it.Risks
Low, and the direction of any error is fail-closed. The gate widened on one path only, and only for string-valued
envkeys.envbeginning[,{,*,|or>and containing no=is now redacted where it previously passed through. That is over-redaction at worst, matching what the YAML path has always done for the same input.isEnvKey, so it cannot touch theargs:/data:keys that are common in the GitHub and Paperclip upstreams this gateway also proxies. The negative control test pins prose pass-through so the gate cannot silently widen to everything.packages/mcp-gatewaystill ships"dependencies": {}.Scope honesty — this is a class defect, not an incident. I did not demonstrate a reachable exploit, and I am not claiming one: the k8s API serializes
envas a list of objects and Docker'sConfig.Envas a list ofKEY=VALUEstrings, and both are already covered elsewhere in this module. The defect being fixed is two independently-maintained spellings of one rule, which drift silently — and a comment asserting they agree is what stops the next reader from checking. That is the class PEN-2370 ask 3 exists to close, so it is worth closing while it is still cheap. A reviewer who concludes the shape is unreachable should still want the single predicate.Not a rollout. Merging this changes no running gateway. Per PEN-2370's recorded PEN-1680 path, the deployed change requires an operator-executed rollout; no agent, including me, can perform it.
Model Used
Claude Opus 4.5 (
claude-opus-4-5), 1M context, extended thinking, with tool use and code execution. Probe harness and test authoring assisted; every claim above was executed and its output pasted rather than predicted.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template