diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21e96137..c2597ddb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: | diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 0956e590..e1d7dccd 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -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. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 13d31d1f..c3139231 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -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 @@ -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|" @@ -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: @@ -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) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2f048ded..dcac70eb 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-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` | diff --git a/docs/doctoring/cwl-security-issue-detectors.md b/docs/doctoring/cwl-security-issue-detectors.md index add79193..727cf466 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, 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 | diff --git a/tests/test_claude_plugin_dynamic_eval.py b/tests/test_claude_plugin_dynamic_eval.py new file mode 100644 index 00000000..36557447 --- /dev/null +++ b/tests/test_claude_plugin_dynamic_eval.py @@ -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, '', '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