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 @@ -56,7 +56,8 @@ jobs:
--test tests/test_claude_plugin_supply_chain.py \
--test tests/test_claude_plugin_scan_cli.py \
--test tests/test_claude_plugin_license_mismatch.py \
--test tests/test_claude_plugin_postinstall_download.py
--test tests/test_claude_plugin_postinstall_download.py \
--test tests/test_claude_plugin_dynamic_eval.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@
LICENSE/NOTICE absence still fails closed; conflicting SPDX identifiers
across the declared license field, LICENSE, and NOTICE fail as
`claude-plugin-license-mismatch` without inventing legal approval.
`.claude-plugin/` is included in the scan walk.
Hook `eval`/`exec`/`compile`/`Function` and shell `eval` fail as
`claude-plugin-dynamic-eval`. `.claude-plugin/` is included in the
scan walk.
40 changes: 36 additions & 4 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
Findings come from parsed manifests and executable surfaces, not from issue
titles. A floating Git ref, provider secret, pipe-to-shell installer,
unsigned executable download, package.json lifecycle download, unpinned
package URL install, undeclared hook, archive path escape, unadmitted nested
submodule, hardcoded GitHub write token, Docker socket bind, secret copied
into a network request, or a released skill-supply-chain finding on a plugin
skill/agent surface is a policy finding. Capability inventory is evidence,
package URL install, dynamic eval/exec, undeclared hook, archive path
escape, unadmitted nested submodule, hardcoded GitHub write token, Docker
socket bind, secret copied into a network request, or a released
skill-supply-chain finding on a plugin skill/agent surface is a policy
finding. Capability inventory is evidence,
not permission: presence of a capability is not a finding by itself. Skill
homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule
identities. A lockfile-backed package.json without a lifecycle download
Expand Down Expand Up @@ -74,6 +75,16 @@
"Record the conflict without inventing legal approval. "
"[CWE-1104 - Use of Unmaintained Third Party Components]"
)
CLAUDE_PLUGIN_DYNAMIC_EVAL_MESSAGE: Final = (
"Claude plugin hook evaluates a string as code. Dynamic eval, exec, "
"compile, or Function constructors fail admission. "
"[CWE-95 - Improper Neutralization of Directives in Dynamically Evaluated Code]"
)
_DYNAMIC_EVAL = re.compile(
r"\b(?:eval|exec|compile)\s*\(|\bnew\s+Function\s*\(|\bFunction\s*\(|"
r"(?:^|[\s;&|])eval\s+[\"'$]",
re.IGNORECASE | re.MULTILINE,
)
_SPDX_TOKEN = re.compile(
r"\b(Apache-2\.0|MIT|BSD-2-Clause|BSD-3-Clause|GPL-3\.0-only|"
r"GPL-3\.0-or-later|LGPL-3\.0-only|AGPL-3\.0-only|MPL-2\.0|ISC|"
Expand Down Expand Up @@ -461,6 +472,7 @@ def inspect_claude_plugin_file(
if hook_surface:
hits.extend(_unsigned_executable_download_hits(content))
hits.extend(_unpinned_package_install_hits(content))
hits.extend(_dynamic_eval_hits(content))
if lifecycle_surface:
hits.extend(_package_lifecycle_hits(content))
if manifest or hook_surface:
Expand Down Expand Up @@ -973,6 +985,26 @@ def _github_write_token_hits(content: str) -> tuple[PluginHit, ...]:
)


def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]:
"""Return findings for eval/exec/compile/Function on hook surfaces."""
match = _DYNAMIC_EVAL.search(content)
if match is None:
return ()
token = match.group(0).strip()
if "(" in token:
label = token.split("(", 1)[0].strip()[:40]
else:
label = token.split()[0][:40]
return (
PluginHit(
rule_id="claude-plugin-dynamic-eval",
line=content[: match.start()].count("\n") + 1,
snippet=label,
message=CLAUDE_PLUGIN_DYNAMIC_EVAL_MESSAGE,
),
)


def _docker_socket_hits(content: str) -> tuple[PluginHit, ...]:
"""Return host Docker-socket findings from hook or manifest text."""
match = _DOCKER_SOCKET.search(content)
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-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-secret-to-network`, 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-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-secret-to-network`, 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
2 changes: 1 addition & 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, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, 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, and secret-to-network flows, 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, 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 Down
97 changes: 97 additions & 0 deletions tests/test_claude_plugin_dynamic_eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Dynamic eval/exec on plugin hook surfaces 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"
_EVAL_RULE = "claude-plugin-dynamic-eval"


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 session\n", encoding="utf-8")
(root / "LICENSE").write_text("MIT\n", encoding="utf-8")
return root


def test_python_eval_hook_is_reported() -> None:
"""eval() on a plugin hook is dynamic code evaluation."""
hits = inspect_claude_plugin_file(
"run.py",
"hooks/run.py",
"payload = input()\neval(payload)\n",
)
assert any(hit.rule_id == _EVAL_RULE for hit in hits)
assert all("payload" not in hit.snippet or "eval" in hit.snippet for hit in hits)


def test_python_exec_and_js_function_are_reported() -> None:
"""exec() and new Function() are the same dynamic-evaluation class."""
exec_hits = inspect_claude_plugin_file(
"run.py", "scripts/run.py", "exec(compiled)\n"
)
fn_hits = inspect_claude_plugin_file(
"run.js", "hooks/run.js", "const fn = new Function(code);\n"
)
compile_hits = inspect_claude_plugin_file(
"run.py", "commands/run.py", "compile(src, '<hook>', 'exec')\n"
)
assert any(hit.rule_id == _EVAL_RULE for hit in exec_hits)
assert any(hit.rule_id == _EVAL_RULE for hit in fn_hits)
assert any(hit.rule_id == _EVAL_RULE for hit in compile_hits)


def test_print_and_evaluate_are_not_dynamic_eval() -> None:
"""evaluate() and print() are not eval()."""
hits = inspect_claude_plugin_file(
"run.py",
"hooks/run.py",
"print(1)\nresult = evaluate(config)\n",
)
assert all(hit.rule_id != _EVAL_RULE for hit in hits)


def test_repo_root_eval_is_not_a_plugin_finding() -> None:
"""Ordinary Python files are not Claude plugin hook surfaces."""
hits = inspect_claude_plugin_file("app.py", "app.py", "eval(user_input)\n")
assert hits == ()


def test_eval_hook_fails_plugin_receipt(tmp_path: Path) -> None:
"""A licensed plugin with eval() in a declared hook fails admission."""
root = _licensed_plugin(tmp_path)
(root / "hooks" / "session.sh").write_text(
"#!/bin/sh\neval \"$PAYLOAD\"\n", encoding="utf-8"
)
receipt = build_claude_plugin_scan_receipt(root)
assert receipt.scan_result == "fail"
assert _EVAL_RULE in receipt.finding_summary