Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,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``, ``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.
106 changes: 104 additions & 2 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
Expand Down Expand Up @@ -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, "
"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 = (
"Claude plugin hook or package lifecycle script downloads an unsigned "
"executable and makes it runnable. Pin and verify binaries; do not "
Expand Down Expand Up @@ -257,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*\]|"
Expand Down Expand Up @@ -1473,6 +1482,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)
Expand Down Expand Up @@ -1574,6 +1584,98 @@ 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 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.

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():
token = (
key
if isinstance(key, str)
and _NAMED_SECRET_TOKEN.fullmatch(key) is not None
else None
)
if token is None:
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,
),
)
args = server.get("args")
if isinstance(args, list):
for arg in args:
token = _mcp_secret_reference_token(arg)
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,
),
)
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",
line=_line_of(content, token),
snippet=token,
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 ()


def _normalized_name_hits(payload: object, content: str) -> tuple[PluginHit, ...]:
"""Return hits when a plugin identity name is not Unicode NFC.

Expand Down
2 changes: 1 addition & 1 deletion docs/TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
3 changes: 2 additions & 1 deletion docs/doctoring/cwl-security-issue-detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/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 |
Expand All @@ -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`
Expand Down
Loading