diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index d8a7cf2b..24ceb5f4 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -124,7 +124,10 @@ or ``*.sha256`` next to ``plugin.json`` that names the plugin artifact or enumerated files fails as `claude-plugin-checksum-mismatch` when the digest disagrees with bytes on disk. Matching checksums, comment-only - rows, and missing checksum files are not that class. Cosign or GPG + rows, and missing checksum files are not that class. A checksum file + with digest rows and no non-empty sibling ``.sig``, ``.asc``, ``.gpg``, + ``.bundle``, or ``cosign.bundle`` fails as + `claude-plugin-unsigned-checksum`. Cosign or GPG network verification is not required. Snippets are path labels, not hashes or secrets. ``sbom_sha256`` stays the CycloneDX receipt digest. Hook or manifest ``gh pr merge`` fails as diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 9951ba80..d3072327 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -50,7 +50,10 @@ ``checksums.sha256``, or ``*.sha256`` next to ``plugin.json`` that names the plugin artifact or enumerated files fails closed when the digest disagrees with bytes on disk. Comments are ignored. Absence of a -checksum or Cosign signature is not that class. Receipts bind +checksum file is not that class. A checksum file with digest rows and +no non-empty sibling ``.sig``, ``.asc``, ``.gpg``, ``.bundle``, or +``cosign.bundle`` fails closed as an unsigned checksum. Network Cosign +or GPG verification is not performed. Receipts bind ``policy_provenance`` to the running AppGuardrail release and the exact scan-policy bytes, and ``sbom_sha256`` to a deterministic CycloneDX document of declared dependencies; verification fails closed when that @@ -154,9 +157,15 @@ CLAUDE_PLUGIN_CHECKSUM_MISMATCH_MESSAGE: Final = ( "Claude plugin checksum file lists a SHA-256 digest that does not match " "the bytes on disk. Bind admission to the exact artifact. Absence of a " - "checksum or Cosign signature is not this class. " + "checksum file is not this class. " "[CWE-494 - Download of Code Without Integrity Check]" ) +CLAUDE_PLUGIN_UNSIGNED_CHECKSUM_MESSAGE: Final = ( + "Claude plugin checksum file lists digests but has no sibling signature " + "file. Place a non-empty Cosign bundle or detached GPG signature next " + "to the checksum. Network verification is not performed. " + "[CWE-347 - Improper Verification of Cryptographic Signature]" +) CLAUDE_PLUGIN_DYNAMIC_EVAL_MESSAGE: Final = ( "Claude plugin hook evaluates a string as code. Dynamic eval, exec, " "compile, or Function constructors fail admission. " @@ -1041,6 +1050,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: else: hits.extend(_license_mismatch_hits(root, {})) hits.extend(_checksum_mismatch_hits(root)) + hits.extend(_unsigned_checksum_hits(root)) _, file_count, scanned_byte_count = _artifact_digest(root) if file_count > _MAX_PACKAGE_FILES or scanned_byte_count > _MAX_PACKAGE_BYTES: hits.append( @@ -3906,6 +3916,65 @@ def _checksum_mismatch_hits(root: Path) -> tuple[PluginHit, ...]: return tuple(hits) +_CHECKSUM_SIGNATURE_SUFFIXES: Final = (".sig", ".asc", ".gpg", ".bundle") + + +def _checksum_has_signature_file(checksum_path: Path) -> bool: + """Return whether a non-empty sibling signature file exists. + + Args: + checksum_path: First-party checksum file. + + Returns: + True when a regular, non-symlink sibling ``.sig``, ``.asc``, + ``.gpg``, ``.bundle``, or ``cosign.bundle`` has a non-zero size. + Empty files, missing files, and unreadable paths are False. + Bytes are not cryptographically verified. + """ + names = [checksum_path.name + suffix for suffix in _CHECKSUM_SIGNATURE_SUFFIXES] + names.append("cosign.bundle") + for name in names: + candidate = checksum_path.parent / name + try: + if candidate.is_symlink() or not candidate.is_file(): + continue + if candidate.stat().st_size > 0: + return True + except OSError: + continue + return False + + +def _unsigned_checksum_hits(root: Path) -> tuple[PluginHit, ...]: + """Return findings when checksum digest rows have no sibling signature. + + Missing checksum files and comment-only checksum files are not this + class. Network Cosign or GPG verification is not performed. Snippets + are checksum filenames, never digests or secret literals. + """ + hits: list[PluginHit] = [] + for path in _checksum_file_paths(root): + relative = path.relative_to(root).as_posix() + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if not _parse_checksum_entries(text, path): + continue + if _checksum_has_signature_file(path): + continue + hits.append( + PluginHit( + rule_id="claude-plugin-unsigned-checksum", + line=1, + snippet=_sanitize_path_snippet(path.name), + message=CLAUDE_PLUGIN_UNSIGNED_CHECKSUM_MESSAGE, + file=relative, + ) + ) + return tuple(hits) + + def _empty_identity() -> dict[str, str]: """Return blank plugin identity fields.""" return { diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0a4274b0..018a60c1 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`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, 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`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, 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/sast-dast-rule-research.md b/docs/sast-dast-rule-research.md index 54e3ff10..73f796d1 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -93,7 +93,9 @@ files being scanned, then applies the union of relevant checks. Examples: for the exact AppGuardrail release and scan-policy bytes, plus `sbom_sha256` of a deterministic CycloneDX 1.5 dependency document, and `claude-plugin-checksum-mismatch` when a first-party checksum file - disagrees with artifact bytes on disk, `claude-plugin-github-merge-command` + disagrees with artifact bytes on disk, + `claude-plugin-unsigned-checksum` when digest rows have no sibling + signature file, `claude-plugin-github-merge-command` for hook or manifest ``gh pr merge``, `claude-plugin-github-release-command` for ``gh release`` create/upload/delete/edit, diff --git a/tests/test_claude_plugin_checksum_mismatch.py b/tests/test_claude_plugin_checksum_mismatch.py index 919e93a0..02026048 100644 --- a/tests/test_claude_plugin_checksum_mismatch.py +++ b/tests/test_claude_plugin_checksum_mismatch.py @@ -91,7 +91,6 @@ def test_sha256sums_matching_plugin_json_is_not_a_finding(tmp_path: Path) -> Non receipt = build_claude_plugin_scan_receipt(root) assert _checksum_hits(root) == [] assert _CHECKSUM_RULE not in receipt.finding_summary - assert receipt.scan_result == "pass" def test_no_checksum_file_is_not_a_finding(tmp_path: Path) -> None: @@ -116,7 +115,6 @@ def test_sha256sums_comment_lines_are_ignored(tmp_path: Path) -> None: receipt = build_claude_plugin_scan_receipt(root) assert _checksum_hits(root) == [] assert _CHECKSUM_RULE not in receipt.finding_summary - assert receipt.scan_result == "pass" def test_sbom_sha256_still_binds_and_verifies_with_checksum_file( @@ -210,7 +208,6 @@ def test_plugin_json_sha256_sibling_match_is_not_a_finding(tmp_path: Path) -> No ) receipt = build_claude_plugin_scan_receipt(root) assert _checksum_hits(root) == [] - assert receipt.scan_result == "pass" def test_binary_mode_star_prefix_matching_digest_is_not_a_finding( @@ -221,7 +218,6 @@ def test_binary_mode_star_prefix_matching_digest_is_not_a_finding( digest = _sha256(_plugin_json(root)) (root / "SHA256SUMS").write_text(f"{digest} *plugin.json\n", encoding="utf-8") assert _checksum_hits(root) == [] - assert build_claude_plugin_scan_receipt(root).scan_result == "pass" def test_comments_only_checksum_file_is_not_a_finding(tmp_path: Path) -> None: @@ -287,7 +283,6 @@ def test_tab_separator_matching_digest_is_not_a_finding(tmp_path: Path) -> None: digest = _sha256(_plugin_json(root)) (root / "SHA256SUMS").write_text(f"{digest}\tplugin.json\n", encoding="utf-8") assert _checksum_hits(root) == [] - assert build_claude_plugin_scan_receipt(root).scan_result == "pass" def test_absolute_and_windows_listed_paths_fail_closed(tmp_path: Path) -> None: diff --git a/tests/test_claude_plugin_unsigned_checksum.py b/tests/test_claude_plugin_unsigned_checksum.py new file mode 100644 index 00000000..83509fdc --- /dev/null +++ b/tests/test_claude_plugin_unsigned_checksum.py @@ -0,0 +1,237 @@ +"""First-party checksum files with digest rows need a sibling signature file.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from appguardrail_core import claude_plugin_detector as detector +from appguardrail_core.claude_plugin_detector import ( + build_claude_plugin_scan_receipt, + scan_claude_plugin_package, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_UNSIGNED_RULE = "claude-plugin-unsigned-checksum" +_MISMATCH_RULE = "claude-plugin-checksum-mismatch" +_SECRET = "sk-sig-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 that satisfies current admission policy.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + }, + ) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _sha256(path: Path) -> str: + """Return the hex SHA-256 digest of a regular file.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _plugin_json(root: Path) -> Path: + """Return the materialized plugin.json path.""" + return root / ".claude-plugin" / "plugin.json" + + +def _unsigned_hits(root: Path): + """Return unsigned-checksum hits from the package scan.""" + return [hit for hit in scan_claude_plugin_package(root) if hit.rule_id == _UNSIGNED_RULE] + + +def test_matching_checksum_without_signature_fails_admission(tmp_path: Path) -> None: + """A matching SHA256SUMS with no sibling signature is unsigned.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (root / "SHA256SUMS").write_text(f"{digest} plugin.json\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + hits = _unsigned_hits(root) + + assert hits + assert all(hit.snippet == "SHA256SUMS" for hit in hits) + assert receipt.scan_result == "fail" + assert _UNSIGNED_RULE in receipt.finding_summary + assert _MISMATCH_RULE not in receipt.finding_summary + + +def test_matching_checksum_with_sig_is_not_this_class(tmp_path: Path) -> None: + """A non-empty ``SHA256SUMS.sig`` sibling is not the unsigned class.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (root / "SHA256SUMS").write_text(f"{digest} plugin.json\n", encoding="utf-8") + (root / "SHA256SUMS.sig").write_text("untrusted-detached-signature\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + + assert _unsigned_hits(root) == [] + assert _UNSIGNED_RULE not in receipt.finding_summary + assert receipt.scan_result == "pass" + + +def test_cosign_bundle_sibling_is_not_this_class(tmp_path: Path) -> None: + """A non-empty ``cosign.bundle`` next to the checksum is a signature file.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (root / "SHA256SUMS").write_text(f"{digest} plugin.json\n", encoding="utf-8") + (root / "cosign.bundle").write_text('{"payload":"offline"}\n', encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + + assert _unsigned_hits(root) == [] + assert receipt.scan_result == "pass" + + +def test_empty_sig_file_still_fails_admission(tmp_path: Path) -> None: + """A zero-byte ``.sig`` is not a signature.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (root / "SHA256SUMS").write_text(f"{digest} plugin.json\n", encoding="utf-8") + (root / "SHA256SUMS.sig").write_bytes(b"") + receipt = build_claude_plugin_scan_receipt(root) + + assert _unsigned_hits(root) + assert receipt.scan_result == "fail" + assert _UNSIGNED_RULE in receipt.finding_summary + + +def test_no_checksum_file_is_not_this_class(tmp_path: Path) -> None: + """Absence of a checksum file is not the unsigned class.""" + root = _licensed_plugin(tmp_path) + receipt = build_claude_plugin_scan_receipt(root) + + assert _unsigned_hits(root) == [] + assert receipt.scan_result == "pass" + assert _UNSIGNED_RULE not in receipt.finding_summary + + +def test_comments_only_checksum_is_not_this_class(tmp_path: Path) -> None: + """Comment-only SHA256SUMS enumerates no digests, so it is not unsigned.""" + root = _licensed_plugin(tmp_path) + (root / "SHA256SUMS").write_text("# nothing listed\n\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + + assert _unsigned_hits(root) == [] + assert receipt.scan_result == "pass" + + +def test_mismatch_without_signature_is_both_classes(tmp_path: Path) -> None: + """Wrong digest and missing signature are independent findings.""" + root = _licensed_plugin(tmp_path) + (root / "SHA256SUMS").write_text(f"{'0' * 64} plugin.json\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + hits = scan_claude_plugin_package(root) + rule_ids = {hit.rule_id for hit in hits} + + assert _MISMATCH_RULE in rule_ids + assert _UNSIGNED_RULE in rule_ids + assert receipt.scan_result == "fail" + + +def test_gpg_asc_sibling_is_not_this_class(tmp_path: Path) -> None: + """A non-empty ``SHA256SUMS.asc`` sibling is a GPG signature file.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (root / "SHA256SUMS").write_text(f"{digest} plugin.json\n", encoding="utf-8") + (root / "SHA256SUMS.asc").write_text("-----BEGIN PGP SIGNATURE-----\n", encoding="utf-8") + assert _unsigned_hits(root) == [] + assert build_claude_plugin_scan_receipt(root).scan_result == "pass" + + +def test_snippets_are_checksum_labels_not_secrets(tmp_path: Path) -> None: + """Snippets name the checksum file and omit secrets, bidi, and digests.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (root / "SHA256SUMS").write_text( + f"# {_SECRET}{_BIDI}\n{digest} plugin.json\n", + encoding="utf-8", + ) + hits = _unsigned_hits(root) + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert hits + for hit in hits: + assert hit.snippet == "SHA256SUMS" + assert _SECRET not in hit.snippet + assert _BIDI not in hit.snippet + assert digest not in hit.snippet + assert _SECRET not in hit.message + assert _SECRET not in payload + assert _BIDI not in payload + + +def test_plugin_json_sha256_without_sig_fails_admission(tmp_path: Path) -> None: + """A matching ``plugin.json.sha256`` sibling still needs a signature file.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (_plugin_json(root).parent / "plugin.json.sha256").write_text( + f"{digest}\n", + encoding="utf-8", + ) + receipt = build_claude_plugin_scan_receipt(root) + hits = _unsigned_hits(root) + + assert hits + assert all(hit.snippet == "plugin.json.sha256" for hit in hits) + assert receipt.scan_result == "fail" + assert _UNSIGNED_RULE in receipt.finding_summary + assert _MISMATCH_RULE not in receipt.finding_summary + + +def test_unreadable_checksum_is_not_the_unsigned_class(tmp_path: Path) -> None: + """Undecodable checksum bytes stay the mismatch class, not unsigned.""" + root = _licensed_plugin(tmp_path) + (root / "SHA256SUMS").write_bytes(b"\xff\xfe") + assert _unsigned_hits(root) == [] + assert _UNSIGNED_RULE not in { + hit.rule_id for hit in scan_claude_plugin_package(root) + } + + +def test_symlink_sig_is_not_a_signature(tmp_path: Path) -> None: + """A symlink ``.sig`` is not a regular signature file.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + (root / "SHA256SUMS").write_text(f"{digest} plugin.json\n", encoding="utf-8") + target = tmp_path / "outside.sig" + target.write_text("detached\n", encoding="utf-8") + (root / "SHA256SUMS.sig").symlink_to(target) + receipt = build_claude_plugin_scan_receipt(root) + assert _unsigned_hits(root) + assert receipt.scan_result == "fail" + + +def test_signature_stat_oserror_is_unsigned(tmp_path: Path, monkeypatch) -> None: + """An unreadable sibling signature file does not count as signed.""" + root = _licensed_plugin(tmp_path) + digest = _sha256(_plugin_json(root)) + checksum = root / "SHA256SUMS" + checksum.write_text(f"{digest} plugin.json\n", encoding="utf-8") + (root / "SHA256SUMS.sig").write_text("detached\n", encoding="utf-8") + original_stat = Path.stat + + def boom_stat(self: Path, *args, **kwargs): + """Raise when the sibling signature is stat'd.""" + if self.name.endswith(".sig"): + raise OSError("stat") + return original_stat(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", boom_stat) + assert detector._checksum_has_signature_file(checksum) is False