From 80f56b0fabde73ec66d38b2cd260d4a9a2ed2e00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:03:24 +0900 Subject: [PATCH 01/11] feat(scanner): bind marketplace catalog identity onto plugin receipts External --marketplace-entry documents set catalog_repository, catalog_commit_sha, and marketplace_blob_sha. A floating catalog commit or a catalog plugin identity that disagrees with the retrieved artifact fails closed. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 7 +- appguardrail_core/claude_plugin_detector.py | 85 +++++++- appguardrail_core/claude_plugin_scan_cli.py | 37 ++-- docs/TRACEABILITY.md | 2 +- .../doctoring/cwl-security-issue-detectors.md | 2 +- tests/test_claude_plugin_scan_cli.py | 183 ++++++++++++++++++ 6 files changed, 296 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index b82d5027..0b4dc32e 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -29,5 +29,8 @@ copying those regular expressions. `appguardrail scan-plugin --plugin-root [--marketplace-entry ] [--receipt-json ]` scans a materialized plugin tree, emits that same receipt JSON, and exits - nonzero unless `scan_result` is pass. `.claude-plugin/` is included in the - scan walk. + nonzero unless `scan_result` is pass. An external marketplace catalog + binds `catalog_repository`, `catalog_commit_sha`, and + `marketplace_blob_sha`; a floating catalog commit or a catalog plugin + identity that disagrees with the retrieved artifact fails closed. + `.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 90d28430..15d339d4 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -562,6 +562,8 @@ def build_claude_plugin_scan_receipt( scanner_version: str = _SCANNER_VERSION, scan_started_at: str = "", scan_completed_at: str = "", + catalog_payload: object | None = None, + catalog_bytes: bytes | None = None, ) -> PluginScanReceipt: """Return a deterministic admission receipt for one plugin artifact. @@ -570,18 +572,24 @@ def build_claude_plugin_scan_receipt( scanner_version: Scanner release identity recorded on the receipt. scan_started_at: Optional caller-supplied start timestamp. scan_completed_at: Optional caller-supplied completion timestamp. + catalog_payload: Optional parsed marketplace catalog document. + catalog_bytes: Optional exact catalog file bytes. Returns: Receipt whose identity excludes wall-clock fields. ``scan_result`` is ``pass`` only when ``.claude-plugin/`` exists and no policy findings remain. Secret literals never appear on the receipt. """ - hits = _collect_plugin_hits(root) + hits = list(_collect_plugin_hits(root)) + catalog = _catalog_identity(catalog_payload) + hits.extend(_catalog_bind_hits(root, catalog)) finding_summary = tuple(sorted({hit.rule_id for hit in hits})) identity = _plugin_identity(root) artifact_sha256, file_count, scanned_byte_count = _artifact_digest(root) marketplace_path = root / ".claude-plugin" / "marketplace.json" - marketplace_bytes = _regular_file_bytes(marketplace_path) + marketplace_bytes = ( + catalog_bytes if catalog_bytes is not None else _regular_file_bytes(marketplace_path) + ) marketplace_blob_sha = _sha256(marketplace_bytes) if marketplace_bytes else "" marketplace_entry_sha256 = _sha256( json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() @@ -607,8 +615,8 @@ def build_claude_plugin_scan_receipt( "scanner_name": _SCANNER_NAME, "scanner_version": scanner_version, "scanner_policy_sha256": policy_sha256, - "catalog_repository": "", - "catalog_commit_sha": "", + "catalog_repository": catalog["catalog_repository"], + "catalog_commit_sha": catalog["catalog_commit_sha"], "marketplace_blob_sha": marketplace_blob_sha, "marketplace_entry_sha256": marketplace_entry_sha256, "plugin_name": identity["plugin_name"], @@ -683,6 +691,8 @@ def verify_plugin_scan_receipt( root: Path, *, expected_policy_sha256: str | None = None, + catalog_payload: object | None = None, + catalog_bytes: bytes | None = None, ) -> PluginReceiptVerification: """Fail closed unless the receipt still binds the current artifact and policy. @@ -691,6 +701,8 @@ def verify_plugin_scan_receipt( root: Materialized tree being admitted. expected_policy_sha256: Caller-pinned policy digest. When omitted, the current scanner policy bytes are required. + catalog_payload: Catalog document used when the receipt was issued. + catalog_bytes: Exact catalog bytes used when the receipt was issued. Returns: Structured mismatch field names. Empty mismatches mean the receipt @@ -698,7 +710,11 @@ def verify_plugin_scan_receipt( ``scan_result=pass`` is not Noema admission. Reasons never include secret literals or raw bidi characters. """ - live = build_claude_plugin_scan_receipt(root) + live = build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog_payload, + catalog_bytes=catalog_bytes, + ) current_policy_sha256 = _sha256(Path(__file__).read_bytes()) expected = ( current_policy_sha256 @@ -1181,6 +1197,65 @@ def _empty_identity() -> dict[str, str]: } +def _empty_catalog_identity() -> dict[str, str]: + """Return blank catalog and plugin identity fields.""" + return { + "catalog_repository": "", + "catalog_commit_sha": "", + **_empty_identity(), + } + + +def _catalog_identity(payload: object | None) -> dict[str, str]: + """Return catalog repository/SHA plus first plugin identity from a catalog.""" + identity = _empty_catalog_identity() + if not isinstance(payload, dict): + return identity + repo = payload.get("repository") or payload.get("catalog_repository") + sha = ( + payload.get("commit") + or payload.get("catalog_commit_sha") + or payload.get("sha") + ) + identity.update(_identity_from_payload(payload)) + if isinstance(repo, str): + identity["catalog_repository"] = repo + if isinstance(sha, str): + identity["catalog_commit_sha"] = sha + return identity + + +def _catalog_bind_hits(root: Path, catalog: dict[str, str]) -> tuple[PluginHit, ...]: + """Return findings when an external catalog disagrees with the artifact.""" + hits: list[PluginHit] = [] + catalog_sha = catalog.get("catalog_commit_sha") or "" + if catalog_sha and not _FULL_SHA.fullmatch(catalog_sha): + hits.append( + PluginHit( + rule_id="claude-plugin-floating-git-ref", + line=1, + snippet=catalog_sha[:120], + message=CLAUDE_PLUGIN_FLOATING_REF_MESSAGE, + file="marketplace.json", + ) + ) + plugin = _plugin_identity(root) + for field in ("plugin_name", "source_repository", "source_commit_sha"): + left, right = catalog.get(field) or "", plugin.get(field) or "" + if left and right and left != right: + hits.append( + PluginHit( + rule_id="claude-plugin-source-mismatch", + line=1, + snippet=field, + message=CLAUDE_PLUGIN_SOURCE_MISMATCH_MESSAGE, + file="marketplace.json", + ) + ) + break + return tuple(hits) + + def _identity_from_payload(payload: object) -> dict[str, str]: """Return bounded identity from one parsed marketplace or plugin document.""" identity = _empty_identity() diff --git a/appguardrail_core/claude_plugin_scan_cli.py b/appguardrail_core/claude_plugin_scan_cli.py index b2b680fe..d797a2c5 100644 --- a/appguardrail_core/claude_plugin_scan_cli.py +++ b/appguardrail_core/claude_plugin_scan_cli.py @@ -75,12 +75,25 @@ def scan_plugin_artifact( if not str(plugin_root) or plugin_root.is_symlink() or not plugin_root.is_dir(): print(_ERROR_PLUGIN_ROOT, file=err) return 1 + catalog_payload: object | None = None + catalog_bytes: bytes | None = None if marketplace_entry is not None: - status = _validate_marketplace_entry(marketplace_entry, err) + status, catalog_payload, catalog_bytes = _load_marketplace_catalog( + marketplace_entry, err + ) if status != 0: return status - receipt = build_claude_plugin_scan_receipt(plugin_root) - verification = verify_plugin_scan_receipt(receipt, plugin_root) + receipt = build_claude_plugin_scan_receipt( + plugin_root, + catalog_payload=catalog_payload, + catalog_bytes=catalog_bytes, + ) + verification = verify_plugin_scan_receipt( + receipt, + plugin_root, + catalog_payload=catalog_payload, + catalog_bytes=catalog_bytes, + ) if not verification.matches: print(_ERROR_RECEIPT_STALE, file=err) return 1 @@ -93,28 +106,30 @@ def scan_plugin_artifact( return 0 if receipt.scan_result == "pass" else 1 -def _validate_marketplace_entry(path: Path, err: TextIO) -> int: - """Fail closed unless ``path`` is a bounded regular JSON catalog file.""" +def _load_marketplace_catalog( + path: Path, err: TextIO +) -> tuple[int, object | None, bytes | None]: + """Return parsed catalog bytes or a fail-closed status.""" if path.is_symlink() or not path.is_file(): print(_ERROR_MARKETPLACE, file=err) - return 1 + return 1, None, None try: data = path.read_bytes() except OSError: print(_ERROR_MARKETPLACE, file=err) - return 1 + return 1, None, None if len(data) > MAX_MARKETPLACE_BYTES: print(_ERROR_MARKETPLACE_SIZE, file=err) - return 1 + return 1, None, None try: payload = json.loads(data.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): print(_ERROR_MARKETPLACE_JSON, file=err) - return 1 + return 1, None, None if not isinstance(payload, (dict, list)): print(_ERROR_MARKETPLACE_JSON, file=err) - return 1 - return 0 + return 1, None, None + return 0, payload, data def _write_receipt(path: Path, payload: str, err: TextIO) -> int: diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 1b7889e8..d5885417 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-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, 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`, `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-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, 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 dbc94c06..f0c393a5 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, a secret-free scan receipt, and fail-closed stale/mismatched receipt verification | +| 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, a secret-free scan receipt with catalog repository/SHA bind, 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_scan_cli.py b/tests/test_claude_plugin_scan_cli.py index db3f2fb1..a432414f 100644 --- a/tests/test_claude_plugin_scan_cli.py +++ b/tests/test_claude_plugin_scan_cli.py @@ -365,3 +365,186 @@ def test_scan_plugin_receipt_write_and_verify_edges( captured = capsys.readouterr() assert "does not match" in captured.err assert "scan_result" not in captured.out + + +_CATALOG_REPOSITORY = "anthropics/claude-plugins-community" + + +def _catalog_document( + *, + repository: str = _CATALOG_REPOSITORY, + commit: str = _PINNED_COMMIT, + plugin_name: str = "safe-plugin", + plugin_repo: str = "example/safe-plugin", + plugin_ref: str = _PINNED_COMMIT, +) -> dict: + """Return a bounded external marketplace catalog document.""" + return { + "repository": repository, + "commit": commit, + "plugins": [ + { + "name": plugin_name, + "version": "1.0.0", + "source": {"source": "github", "repo": plugin_repo, "ref": plugin_ref}, + } + ], + } + + +def test_scan_plugin_binds_matching_catalog_identity( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A matching catalog SHA and repository bind onto the receipt.""" + root = _pass_plugin(tmp_path / "plugin") + catalog = tmp_path / "catalog" / "marketplace.json" + _write_json(catalog, _catalog_document()) + catalog_digest = __import__("hashlib").sha256(catalog.read_bytes()).hexdigest() + + code, stdout, stderr = _run_cli( + monkeypatch, + capsys, + [ + "scan-plugin", + "--plugin-root", + str(root), + "--marketplace-entry", + str(catalog), + ], + ) + + payload = json.loads(stdout) + assert code == 0 + assert payload["scan_result"] == "pass" + assert payload["catalog_repository"] == _CATALOG_REPOSITORY + assert payload["catalog_commit_sha"] == _PINNED_COMMIT + assert payload["marketplace_blob_sha"] == catalog_digest + assert payload["source_repository"] == "example/safe-plugin" + assert payload["source_commit_sha"] == _PINNED_COMMIT + assert _SECRET not in stdout + assert _SECRET not in stderr + + +def test_scan_plugin_rejects_floating_catalog_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A branch name is not an immutable catalog commit SHA.""" + root = _pass_plugin(tmp_path / "plugin") + catalog = tmp_path / "catalog" / "marketplace.json" + _write_json(catalog, _catalog_document(commit="main")) + + code, stdout, stderr = _run_cli( + monkeypatch, + capsys, + [ + "scan-plugin", + "--plugin-root", + str(root), + "--marketplace-entry", + str(catalog), + ], + ) + + payload = json.loads(stdout) + assert code != 0 + assert payload["scan_result"] == "fail" + assert "claude-plugin-floating-git-ref" in payload["finding_summary"] + assert payload["catalog_commit_sha"] == "main" + + +def test_scan_plugin_rejects_catalog_source_mismatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Catalog plugin identity must match the retrieved artifact.""" + root = _pass_plugin(tmp_path / "plugin") + catalog = tmp_path / "catalog" / "marketplace.json" + _write_json( + catalog, + _catalog_document(plugin_repo="example/other-plugin"), + ) + + code, stdout, stderr = _run_cli( + monkeypatch, + capsys, + [ + "scan-plugin", + "--plugin-root", + str(root), + "--marketplace-entry", + str(catalog), + ], + ) + + payload = json.loads(stdout) + assert code != 0 + assert payload["scan_result"] == "fail" + assert "claude-plugin-source-mismatch" in payload["finding_summary"] + assert payload["catalog_repository"] == _CATALOG_REPOSITORY + + +def test_catalog_identity_aliases_and_non_object_payloads(tmp_path: Path) -> None: + """Catalog bind reads alias keys and ignores non-object catalogs.""" + from appguardrail_core.claude_plugin_detector import ( + _catalog_bind_hits, + _catalog_identity, + build_claude_plugin_scan_receipt, + ) + + root = _pass_plugin(tmp_path / "plugin") + aliased = _catalog_identity( + { + "catalog_repository": _CATALOG_REPOSITORY, + "catalog_commit_sha": _PINNED_COMMIT, + "plugins": [ + { + "name": "safe-plugin", + "source": {"repo": "example/safe-plugin", "ref": _PINNED_COMMIT}, + } + ], + } + ) + sha_alias = _catalog_identity({"sha": _PINNED_COMMIT}) + empty = _catalog_identity(["not-an-object"]) + missing = _catalog_identity(None) + name_mismatch = _catalog_bind_hits( + root, + { + "catalog_repository": "", + "catalog_commit_sha": "", + "plugin_name": "other-plugin", + "source_repository": "", + "source_commit_sha": "", + }, + ) + sha_mismatch = _catalog_bind_hits( + root, + { + "catalog_repository": "", + "catalog_commit_sha": "", + "plugin_name": "", + "source_repository": "", + "source_commit_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + ) + receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=_catalog_document(), + catalog_bytes=b'{"repository":"anthropics/claude-plugins-community"}', + ) + + assert aliased["catalog_repository"] == _CATALOG_REPOSITORY + assert aliased["catalog_commit_sha"] == _PINNED_COMMIT + assert sha_alias["catalog_commit_sha"] == _PINNED_COMMIT + assert empty["catalog_repository"] == "" + assert missing["catalog_commit_sha"] == "" + assert any(hit.rule_id == "claude-plugin-source-mismatch" for hit in name_mismatch) + assert any(hit.rule_id == "claude-plugin-source-mismatch" for hit in sha_mismatch) + assert receipt.marketplace_blob_sha == __import__("hashlib").sha256( + b'{"repository":"anthropics/claude-plugins-community"}' + ).hexdigest() From 1c4aa3e38175fec4ac0691154db1a23aa7754acb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:12:51 +0900 Subject: [PATCH 02/11] test(scanner): cover canonical marketplace entry selection --- .../test_claude_plugin_marketplace_catalog.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/test_claude_plugin_marketplace_catalog.py diff --git a/tests/test_claude_plugin_marketplace_catalog.py b/tests/test_claude_plugin_marketplace_catalog.py new file mode 100644 index 00000000..e6cc0d7a --- /dev/null +++ b/tests/test_claude_plugin_marketplace_catalog.py @@ -0,0 +1,116 @@ +"""Canonical marketplace catalog contracts for the Claude plugin scan CLI.""" + +from __future__ import annotations + +from io import StringIO +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_scan_cli import scan_plugin_artifact + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_CATALOG_REPOSITORY = "anthropics/claude-plugins-official" +_PLUGIN_REPOSITORY = "https://github.com/example/safe-plugin.git" + + +def _write_json(path: Path, payload: object) -> None: + """Write deterministic JSON fixture bytes.""" + 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 one pinned local plugin identity that satisfies package policy.""" + identity = { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": _PLUGIN_REPOSITORY, + "ref": _PINNED_COMMIT, + }, + } + _write_json(root / ".claude-plugin" / "plugin.json", identity) + _write_json(root / ".claude-plugin" / "marketplace.json", identity) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _catalog(*plugins: dict[str, object]) -> dict[str, object]: + """Return catalog provenance plus canonical ``plugins`` entries.""" + return { + "repository": _CATALOG_REPOSITORY, + "commit": _PINNED_COMMIT, + "plugins": list(plugins), + } + + +def _canonical_entry(name: str = "safe-plugin") -> dict[str, object]: + """Return the current upstream URL/SHA source shape.""" + return { + "name": name, + "version": "1.0.0", + "source": { + "source": "git-subdir", + "url": _PLUGIN_REPOSITORY, + "path": "plugins/safe-plugin", + "ref": "main", + "sha": _PINNED_COMMIT, + }, + } + + +def _scan(root: Path, catalog: Path) -> tuple[int, str, str]: + """Run the public adapter with captured streams.""" + stdout = StringIO() + stderr = StringIO() + code = scan_plugin_artifact( + root, + marketplace_entry=catalog, + stdout=stdout, + stderr=stderr, + ) + return code, stdout.getvalue(), stderr.getvalue() + + +def test_scan_selects_named_plugin_from_canonical_multi_plugin_catalog( + tmp_path: Path, +) -> None: + """Catalog order and a human ref must not override the matching pinned SHA.""" + root = _plugin(tmp_path / "plugin") + catalog = tmp_path / "marketplace.json" + _write_json( + catalog, + _catalog( + _canonical_entry("unrelated-plugin"), + _canonical_entry(), + ), + ) + + code, stdout, stderr = _scan(root, catalog) + + receipt = json.loads(stdout) + assert code == 0 + assert stderr == "" + assert receipt["scan_result"] == "pass" + assert receipt["catalog_repository"] == _CATALOG_REPOSITORY + assert receipt["catalog_commit_sha"] == _PINNED_COMMIT + assert receipt["plugin_name"] == "safe-plugin" + assert receipt["source_repository"] == _PLUGIN_REPOSITORY + assert receipt["source_commit_sha"] == _PINNED_COMMIT + assert "claude-plugin-source-mismatch" not in receipt["finding_summary"] + assert "claude-plugin-floating-git-ref" not in receipt["finding_summary"] + + +def test_scan_rejects_duplicate_named_catalog_entries(tmp_path: Path) -> None: + """Two catalog entries cannot both claim the one materialized plugin identity.""" + root = _plugin(tmp_path / "plugin") + catalog = tmp_path / "marketplace.json" + _write_json(catalog, _catalog(_canonical_entry(), _canonical_entry())) + + code, stdout, stderr = _scan(root, catalog) + + assert code != 0 + assert stdout == "" + assert "matching plugin entry" in stderr From e9852bdb9025f3873dbafed9e0381a1798a41b5a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:13:55 +0900 Subject: [PATCH 03/11] fix(scanner): bind canonical marketplace entry by plugin name --- appguardrail_core/claude_plugin_scan_cli.py | 72 ++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/appguardrail_core/claude_plugin_scan_cli.py b/appguardrail_core/claude_plugin_scan_cli.py index d797a2c5..4c86c4d0 100644 --- a/appguardrail_core/claude_plugin_scan_cli.py +++ b/appguardrail_core/claude_plugin_scan_cli.py @@ -23,6 +23,7 @@ _ERROR_MARKETPLACE = "marketplace entry is missing or not a file" _ERROR_MARKETPLACE_SIZE = "marketplace entry exceeds the bounded size" _ERROR_MARKETPLACE_JSON = "marketplace entry is not valid JSON" +_ERROR_MARKETPLACE_IDENTITY = "marketplace catalog does not contain one matching plugin entry" _ERROR_RECEIPT_WRITE = "cannot write receipt" _ERROR_RECEIPT_STALE = "receipt does not match the scanned artifact" @@ -83,6 +84,11 @@ def scan_plugin_artifact( ) if status != 0: return status + status, catalog_payload = _select_marketplace_entry( + catalog_payload, plugin_root, err + ) + if status != 0: + return status receipt = build_claude_plugin_scan_receipt( plugin_root, catalog_payload=catalog_payload, @@ -126,12 +132,76 @@ def _load_marketplace_catalog( except (UnicodeDecodeError, json.JSONDecodeError): print(_ERROR_MARKETPLACE_JSON, file=err) return 1, None, None - if not isinstance(payload, (dict, list)): + if not isinstance(payload, dict): print(_ERROR_MARKETPLACE_JSON, file=err) return 1, None, None return 0, payload, data +def _materialized_plugin_name(root: Path) -> str: + """Return the local plugin name used to select one catalog entry.""" + path = root / ".claude-plugin" / "plugin.json" + try: + if path.is_symlink() or not path.is_file(): + return "" + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return "" + if not isinstance(payload, dict): + return "" + name = payload.get("name") + return name if isinstance(name, str) else "" + + +def _normalize_marketplace_entry(entry: dict[str, object]) -> dict[str, object]: + """Normalize current URL/SHA catalog sources for the shared identity parser.""" + normalized = dict(entry) + source = entry.get("source") + if isinstance(source, str): + normalized["source"] = {"path": source} + return normalized + if not isinstance(source, dict): + return normalized + source_identity = dict(source) + url = source.get("url") + if "repo" not in source_identity and isinstance(url, str): + source_identity["repo"] = url + sha = source.get("sha") + if isinstance(sha, str): + source_identity["ref"] = sha + normalized["source"] = source_identity + return normalized + + +def _select_marketplace_entry( + payload: object | None, + plugin_root: Path, + err: TextIO, +) -> tuple[int, object | None]: + """Select exactly one canonical catalog entry for the materialized plugin.""" + if not isinstance(payload, dict): + print(_ERROR_MARKETPLACE_JSON, file=err) + return 1, None + plugins = payload.get("plugins") + if plugins is None: + return 0, payload + if not isinstance(plugins, list): + print(_ERROR_MARKETPLACE_JSON, file=err) + return 1, None + plugin_name = _materialized_plugin_name(plugin_root) + matches = [ + item + for item in plugins + if isinstance(item, dict) and item.get("name") == plugin_name + ] + if not plugin_name or len(matches) != 1: + print(_ERROR_MARKETPLACE_IDENTITY, file=err) + return 1, None + selected = dict(payload) + selected["plugins"] = [_normalize_marketplace_entry(matches[0])] + return 0, selected + + def _write_receipt(path: Path, payload: str, err: TextIO) -> int: """Write receipt JSON to a bounded regular file without following symlinks.""" if path.is_symlink() or path.is_dir(): From 434fd1001b02b6da3b0e27ac3709520841208303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:06:31 +0900 Subject: [PATCH 04/11] test(scanner): require receipt API catalog selection --- .../test_claude_plugin_marketplace_catalog.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/test_claude_plugin_marketplace_catalog.py b/tests/test_claude_plugin_marketplace_catalog.py index e6cc0d7a..a79a320b 100644 --- a/tests/test_claude_plugin_marketplace_catalog.py +++ b/tests/test_claude_plugin_marketplace_catalog.py @@ -1,4 +1,4 @@ -"""Canonical marketplace catalog contracts for the Claude plugin scan CLI.""" +"""Canonical marketplace catalog contracts for Claude plugin scan admission.""" from __future__ import annotations @@ -6,6 +6,7 @@ import json from pathlib import Path +from appguardrail_core.claude_plugin_detector import build_claude_plugin_scan_receipt from appguardrail_core.claude_plugin_scan_cli import scan_plugin_artifact @@ -61,6 +62,11 @@ def _canonical_entry(name: str = "safe-plugin") -> dict[str, object]: } +def _catalog_bytes(payload: object) -> bytes: + """Return the exact deterministic bytes supplied to receipt hashing.""" + return (json.dumps(payload, indent=2) + "\n").encode() + + def _scan(root: Path, catalog: Path) -> tuple[int, str, str]: """Run the public adapter with captured streams.""" stdout = StringIO() @@ -114,3 +120,43 @@ def test_scan_rejects_duplicate_named_catalog_entries(tmp_path: Path) -> None: assert code != 0 assert stdout == "" assert "matching plugin entry" in stderr + + +def test_receipt_api_selects_named_plugin_from_multi_plugin_catalog( + tmp_path: Path, +) -> None: + """Direct receipt callers must bind the materialized plugin, not entry zero.""" + root = _plugin(tmp_path / "plugin") + catalog = _catalog(_canonical_entry("unrelated-plugin"), _canonical_entry()) + + receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog, + catalog_bytes=_catalog_bytes(catalog), + ) + + assert receipt.scan_result == "pass" + assert receipt.catalog_repository == _CATALOG_REPOSITORY + assert receipt.catalog_commit_sha == _PINNED_COMMIT + assert receipt.plugin_name == "safe-plugin" + assert receipt.source_repository == _PLUGIN_REPOSITORY + assert receipt.source_commit_sha == _PINNED_COMMIT + assert "claude-plugin-source-mismatch" not in receipt.finding_summary + assert "claude-plugin-floating-git-ref" not in receipt.finding_summary + + +def test_receipt_api_fails_closed_on_duplicate_named_catalog_entries( + tmp_path: Path, +) -> None: + """Direct receipt admission cannot silently choose one duplicate identity.""" + root = _plugin(tmp_path / "plugin") + catalog = _catalog(_canonical_entry(), _canonical_entry()) + + receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog, + catalog_bytes=_catalog_bytes(catalog), + ) + + assert receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in receipt.finding_summary From 76387a90ee92478c07fbdc9385c79e25d16b6752 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:13:09 +0000 Subject: [PATCH 05/11] Harden Claude plugin marketplace validation and receipt binding --- appguardrail_core/claude_plugin_detector.py | 130 +++++++++++++++++- appguardrail_core/claude_plugin_scan_cli.py | 69 ++-------- .../test_claude_plugin_marketplace_catalog.py | 80 ++++++++++- tests/test_claude_plugin_scan_cli.py | 51 ++++++- tests/test_claude_plugin_supply_chain.py | 106 ++++++++++++++ 5 files changed, 373 insertions(+), 63 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 15d339d4..82e09a02 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -581,7 +581,13 @@ def build_claude_plugin_scan_receipt( remain. Secret literals never appear on the receipt. """ hits = list(_collect_plugin_hits(root)) - catalog = _catalog_identity(catalog_payload) + catalog, catalog_is_valid = _receipt_catalog_identity( + root, + catalog_payload, + catalog_bytes, + ) + if not catalog_is_valid: + hits.append(_invalid_catalog_hit()) hits.extend(_catalog_bind_hits(root, catalog)) finding_summary = tuple(sorted({hit.rule_id for hit in hits})) identity = _plugin_identity(root) @@ -1206,6 +1212,128 @@ def _empty_catalog_identity() -> dict[str, str]: } +class _MarketplaceCatalogError(ValueError): + """Raised when a marketplace catalog cannot bind one plugin identity.""" + + +def _normalize_marketplace_entry(entry: object) -> dict[str, object]: + """Return one validated entry with URL/SHA source aliases normalized.""" + if not isinstance(entry, dict): + raise _MarketplaceCatalogError("invalid plugin entry") + name = entry.get("name") + if not isinstance(name, str) or not name: + raise _MarketplaceCatalogError("invalid plugin name") + version = entry.get("version") + if version is not None and not isinstance(version, str): + raise _MarketplaceCatalogError("invalid plugin version") + source = entry.get("source") + if not isinstance(source, dict): + raise _MarketplaceCatalogError("invalid plugin source") + normalized_source = dict(source) + repository = source.get("repo") + url = source.get("url") + if repository is None: + if not isinstance(url, str) or not url: + raise _MarketplaceCatalogError("invalid source repository") + normalized_source["repo"] = url + elif not isinstance(repository, str) or not repository: + raise _MarketplaceCatalogError("invalid source repository") + elif url is not None and (not isinstance(url, str) or not url): + raise _MarketplaceCatalogError("invalid source URL") + sha = source.get("sha") + ref = source.get("ref") + if sha is not None: + if not isinstance(sha, str) or not sha: + raise _MarketplaceCatalogError("invalid source SHA") + normalized_source["ref"] = sha + elif not isinstance(ref, str) or not ref: + raise _MarketplaceCatalogError("invalid source ref") + path_value = source.get("path") + if path_value is not None and not isinstance(path_value, str): + raise _MarketplaceCatalogError("invalid source path") + normalized = dict(entry) + normalized["source"] = normalized_source + return normalized + + +def _select_marketplace_entry( + payload: object | None, + root: Path, +) -> dict[str, object]: + """Select exactly one valid catalog entry for the materialized plugin.""" + if not isinstance(payload, dict): + raise _MarketplaceCatalogError("invalid catalog document") + plugins = payload.get("plugins") + if plugins is None: + return _normalize_marketplace_entry(payload) + if not isinstance(plugins, list): + raise _MarketplaceCatalogError("invalid plugins collection") + normalized_entries = [_normalize_marketplace_entry(entry) for entry in plugins] + plugin_name = _plugin_identity(root)["plugin_name"] + matches = [entry for entry in normalized_entries if entry["name"] == plugin_name] + if not plugin_name or len(matches) != 1: + raise _MarketplaceCatalogError("catalog entry selection is ambiguous") + selected = dict(payload) + selected["plugins"] = matches + return selected + + +def _json_documents_match(left: object, right: object) -> bool: + """Return whether two parsed JSON values have the same canonical value.""" + try: + return json.dumps( + left, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) == json.dumps( + right, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + except (TypeError, ValueError): + return False + + +def _receipt_catalog_identity( + root: Path, + catalog_payload: object | None, + catalog_bytes: bytes | None, +) -> tuple[dict[str, str], bool]: + """Derive catalog identity from authoritative bytes and report validity.""" + payload = catalog_payload + valid = True + if catalog_bytes is not None: + try: + parsed = _load_manifest_json(catalog_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, _DuplicateJsonMember): + return _empty_catalog_identity(), False + if catalog_payload is not None and not _json_documents_match( + catalog_payload, parsed + ): + valid = False + payload = parsed + if payload is None: + return _empty_catalog_identity(), valid + try: + selected = _select_marketplace_entry(payload, root) + except _MarketplaceCatalogError: + return _empty_catalog_identity(), False + return _catalog_identity(selected), valid + + +def _invalid_catalog_hit() -> PluginHit: + """Return a bounded fail-closed finding for an invalid catalog binding.""" + return PluginHit( + rule_id="claude-plugin-source-mismatch", + line=1, + snippet="catalog_identity", + message=CLAUDE_PLUGIN_SOURCE_MISMATCH_MESSAGE, + file="marketplace.json", + ) + + def _catalog_identity(payload: object | None) -> dict[str, str]: """Return catalog repository/SHA plus first plugin identity from a catalog.""" identity = _empty_catalog_identity() diff --git a/appguardrail_core/claude_plugin_scan_cli.py b/appguardrail_core/claude_plugin_scan_cli.py index 4c86c4d0..5d47de23 100644 --- a/appguardrail_core/claude_plugin_scan_cli.py +++ b/appguardrail_core/claude_plugin_scan_cli.py @@ -14,6 +14,10 @@ from typing import TextIO from appguardrail_core.claude_plugin_detector import ( + _DuplicateJsonMember, + _MarketplaceCatalogError, + _load_manifest_json, + _select_marketplace_entry as _select_catalog_entry, build_claude_plugin_scan_receipt, verify_plugin_scan_receipt, ) @@ -84,7 +88,7 @@ def scan_plugin_artifact( ) if status != 0: return status - status, catalog_payload = _select_marketplace_entry( + status, _ = _select_marketplace_entry( catalog_payload, plugin_root, err ) if status != 0: @@ -120,7 +124,8 @@ def _load_marketplace_catalog( print(_ERROR_MARKETPLACE, file=err) return 1, None, None try: - data = path.read_bytes() + with path.open("rb") as stream: + data = stream.read(MAX_MARKETPLACE_BYTES + 1) except OSError: print(_ERROR_MARKETPLACE, file=err) return 1, None, None @@ -128,8 +133,8 @@ def _load_marketplace_catalog( print(_ERROR_MARKETPLACE_SIZE, file=err) return 1, None, None try: - payload = json.loads(data.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): + payload = _load_manifest_json(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, _DuplicateJsonMember): print(_ERROR_MARKETPLACE_JSON, file=err) return 1, None, None if not isinstance(payload, dict): @@ -138,67 +143,17 @@ def _load_marketplace_catalog( return 0, payload, data -def _materialized_plugin_name(root: Path) -> str: - """Return the local plugin name used to select one catalog entry.""" - path = root / ".claude-plugin" / "plugin.json" - try: - if path.is_symlink() or not path.is_file(): - return "" - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError): - return "" - if not isinstance(payload, dict): - return "" - name = payload.get("name") - return name if isinstance(name, str) else "" - - -def _normalize_marketplace_entry(entry: dict[str, object]) -> dict[str, object]: - """Normalize current URL/SHA catalog sources for the shared identity parser.""" - normalized = dict(entry) - source = entry.get("source") - if isinstance(source, str): - normalized["source"] = {"path": source} - return normalized - if not isinstance(source, dict): - return normalized - source_identity = dict(source) - url = source.get("url") - if "repo" not in source_identity and isinstance(url, str): - source_identity["repo"] = url - sha = source.get("sha") - if isinstance(sha, str): - source_identity["ref"] = sha - normalized["source"] = source_identity - return normalized - - def _select_marketplace_entry( payload: object | None, plugin_root: Path, err: TextIO, ) -> tuple[int, object | None]: """Select exactly one canonical catalog entry for the materialized plugin.""" - if not isinstance(payload, dict): - print(_ERROR_MARKETPLACE_JSON, file=err) - return 1, None - plugins = payload.get("plugins") - if plugins is None: - return 0, payload - if not isinstance(plugins, list): - print(_ERROR_MARKETPLACE_JSON, file=err) - return 1, None - plugin_name = _materialized_plugin_name(plugin_root) - matches = [ - item - for item in plugins - if isinstance(item, dict) and item.get("name") == plugin_name - ] - if not plugin_name or len(matches) != 1: + try: + selected = _select_catalog_entry(payload, plugin_root) + except _MarketplaceCatalogError: print(_ERROR_MARKETPLACE_IDENTITY, file=err) return 1, None - selected = dict(payload) - selected["plugins"] = [_normalize_marketplace_entry(matches[0])] return 0, selected diff --git a/tests/test_claude_plugin_marketplace_catalog.py b/tests/test_claude_plugin_marketplace_catalog.py index a79a320b..7b1cc5bc 100644 --- a/tests/test_claude_plugin_marketplace_catalog.py +++ b/tests/test_claude_plugin_marketplace_catalog.py @@ -7,6 +7,7 @@ from pathlib import Path from appguardrail_core.claude_plugin_detector import build_claude_plugin_scan_receipt +from appguardrail_core.claude_plugin_detector import verify_plugin_scan_receipt from appguardrail_core.claude_plugin_scan_cli import scan_plugin_artifact @@ -150,7 +151,11 @@ def test_receipt_api_fails_closed_on_duplicate_named_catalog_entries( ) -> None: """Direct receipt admission cannot silently choose one duplicate identity.""" root = _plugin(tmp_path / "plugin") - catalog = _catalog(_canonical_entry(), _canonical_entry()) + first = _canonical_entry() + second = _canonical_entry() + first["source"]["ref"] = _PINNED_COMMIT + second["source"]["ref"] = _PINNED_COMMIT + catalog = _catalog(first, second) receipt = build_claude_plugin_scan_receipt( root, @@ -160,3 +165,76 @@ def test_receipt_api_fails_closed_on_duplicate_named_catalog_entries( assert receipt.scan_result == "fail" assert "claude-plugin-source-mismatch" in receipt.finding_summary + + +def test_receipt_api_rejects_catalog_payload_that_differs_from_bytes( + tmp_path: Path, +) -> None: + """The identity payload cannot be paired with bytes from another catalog.""" + root = _plugin(tmp_path / "plugin") + catalog = _catalog(_canonical_entry()) + different = _catalog(_canonical_entry("different-plugin")) + + receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=different, + catalog_bytes=_catalog_bytes(catalog), + ) + + assert receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in receipt.finding_summary + + +def test_receipt_api_rejects_duplicate_catalog_json_members(tmp_path: Path) -> None: + """Catalog bytes with ambiguous duplicate members fail closed.""" + root = _plugin(tmp_path / "plugin") + catalog = _catalog(_canonical_entry()) + valid_receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog, + catalog_bytes=_catalog_bytes(catalog), + ) + duplicate_bytes = _catalog_bytes(catalog).replace( + b' "plugins": [', + b' "plugins": [],\n "plugins": [', + 1, + ) + + receipt = build_claude_plugin_scan_receipt( + root, + catalog_bytes=duplicate_bytes, + ) + verification = verify_plugin_scan_receipt( + valid_receipt, + root, + catalog_bytes=duplicate_bytes, + ) + + assert receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in receipt.finding_summary + assert verification.matches is False + + +def test_receipt_verification_uses_catalog_bytes_as_identity_source( + tmp_path: Path, +) -> None: + """Verification rejects a payload that does not match the retained blob.""" + root = _plugin(tmp_path / "plugin") + catalog = _catalog(_canonical_entry()) + catalog_bytes = _catalog_bytes(catalog) + receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog, + catalog_bytes=catalog_bytes, + ) + + verification = verify_plugin_scan_receipt( + receipt, + root, + catalog_payload=_catalog(_canonical_entry("different-plugin")), + catalog_bytes=catalog_bytes, + ) + + assert receipt.scan_result == "pass" + assert verification.matches is False + assert "scan_receipt_id" in verification.mismatches diff --git a/tests/test_claude_plugin_scan_cli.py b/tests/test_claude_plugin_scan_cli.py index a432414f..cc6b00d1 100644 --- a/tests/test_claude_plugin_scan_cli.py +++ b/tests/test_claude_plugin_scan_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from io import BytesIO import sys from pathlib import Path @@ -314,21 +315,63 @@ def test_scan_plugin_rejects_symlink_root_and_non_json_marketplace( oversized.write_bytes(b"[" + (b" " * (MAX_MARKETPLACE_BYTES + 1)) + b"]") assert scan_plugin_artifact(root, marketplace_entry=oversized) == 1 - original_read = Path.read_bytes + no_matches = tmp_path / "no-matches.json" + no_matches.write_text('{"plugins": []}\n', encoding="utf-8") + assert scan_plugin_artifact(root, marketplace_entry=no_matches) == 1 - def boom_read(self: Path) -> bytes: + original_open = Path.open + + def boom_open(self: Path, *args: object, **kwargs: object) -> object: """Raise on the marketplace file only.""" if self == binary: raise OSError("denied") - return original_read(self) + return original_open(self, *args, **kwargs) - monkeypatch.setattr(Path, "read_bytes", boom_read) + monkeypatch.setattr(Path, "open", boom_open) assert scan_plugin_artifact(root, marketplace_entry=binary) == 1 captured = capsys.readouterr() assert _SECRET not in captured.out assert _SECRET not in captured.err +def test_marketplace_reader_stops_after_size_limit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The catalog loader never reads an oversized marketplace in full.""" + from appguardrail_core import claude_plugin_scan_cli as cli + + marketplace = tmp_path / "oversized.json" + marketplace.write_bytes(b"{}") + read_sizes: list[int] = [] + original_open = Path.open + + class TrackingReader(BytesIO): + """Record the one bounded read requested by the catalog loader.""" + + def read(self, size: int = -1) -> bytes: + read_sizes.append(size) + return b"x" * size + + def tracking_open(self: Path, *args: object, **kwargs: object) -> object: + """Return a controlled marketplace stream and real streams otherwise.""" + if self == marketplace: + return TrackingReader() + return original_open(self, *args, **kwargs) + + monkeypatch.setattr(Path, "open", tracking_open) + + status, payload, data = cli._load_marketplace_catalog( + marketplace, + __import__("io").StringIO(), + ) + + assert status == 1 + assert payload is None + assert data is None + assert read_sizes == [cli.MAX_MARKETPLACE_BYTES + 1] + + def test_scan_plugin_receipt_write_and_verify_edges( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_claude_plugin_supply_chain.py b/tests/test_claude_plugin_supply_chain.py index 10576c1c..297cb5e2 100644 --- a/tests/test_claude_plugin_supply_chain.py +++ b/tests/test_claude_plugin_supply_chain.py @@ -2162,6 +2162,112 @@ def test_receipt_verification_coverage_edges(tmp_path: Path) -> None: assert "scan_result" in pass_on_fail.mismatches +def test_marketplace_catalog_binding_validation_edges(tmp_path: Path) -> None: + """Catalog selection rejects malformed identities and binds URL/SHA aliases.""" + from appguardrail_core import claude_plugin_detector as detector + + root = _bound_identity_plugin(tmp_path) + entry = { + "name": "hook-plugin", + "version": "1.0.0", + "source": { + "url": "example/hook-plugin", + "ref": "main", + "sha": _PINNED_COMMIT, + "path": "plugin", + }, + } + catalog = { + "repository": "example/catalog", + "commit": _PINNED_COMMIT, + "plugins": [entry], + } + + selected = detector._select_marketplace_entry(catalog, root) + selected_source = selected["plugins"][0]["source"] + assert selected_source["repo"] == "example/hook-plugin" + assert selected_source["ref"] == _PINNED_COMMIT + assert detector._select_marketplace_entry(entry, root)["name"] == "hook-plugin" + + invalid_entries: list[object] = [ + "entry", + {"name": "", "source": {"repo": "r", "ref": _PINNED_COMMIT}}, + {"name": "hook-plugin", "version": 1, "source": {}}, + {"name": "hook-plugin", "source": "plugin"}, + {"name": "hook-plugin", "source": {}}, + {"name": "hook-plugin", "source": {"repo": 1, "ref": _PINNED_COMMIT}}, + { + "name": "hook-plugin", + "source": {"repo": "r", "url": 1, "ref": _PINNED_COMMIT}, + }, + {"name": "hook-plugin", "source": {"repo": "r", "sha": 1}}, + {"name": "hook-plugin", "source": {"repo": "r"}}, + { + "name": "hook-plugin", + "source": {"repo": "r", "ref": _PINNED_COMMIT, "path": 1}, + }, + ] + for invalid in invalid_entries: + with pytest.raises(detector._MarketplaceCatalogError): + detector._normalize_marketplace_entry(invalid) + for invalid_catalog in ( + None, + {"plugins": {}}, + {"plugins": []}, + {"plugins": [entry, entry]}, + ): + with pytest.raises(detector._MarketplaceCatalogError): + detector._select_marketplace_entry(invalid_catalog, root) + + assert detector._json_documents_match(catalog, catalog) is True + assert detector._json_documents_match({"invalid": {1}}, {}) is False + assert detector._receipt_catalog_identity(root, None, None)[1] is True + assert detector._receipt_catalog_identity(root, None, b"{")[1] is False + assert detector._receipt_catalog_identity( + root, + {"plugins": []}, + None, + )[1] is False + assert detector._receipt_catalog_identity( + root, + {"plugins": []}, + json.dumps(catalog).encode(), + )[1] is False + assert detector._catalog_identity(None)["catalog_repository"] == "" + assert ( + detector._catalog_identity({"catalog_commit_sha": _PINNED_COMMIT})[ + "catalog_commit_sha" + ] + == _PINNED_COMMIT + ) + assert ( + detector._catalog_identity({"sha": _PINNED_COMMIT})["catalog_commit_sha"] + == _PINNED_COMMIT + ) + + invalid_receipt = detector.build_claude_plugin_scan_receipt( + root, + catalog_payload={"plugins": []}, + ) + assert invalid_receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in invalid_receipt.finding_summary + + hits = detector._catalog_bind_hits( + root, + { + "catalog_repository": "example/catalog", + "catalog_commit_sha": "main", + "plugin_name": "different", + "source_repository": "", + "source_commit_sha": "", + }, + ) + assert {hit.rule_id for hit in hits} == { + "claude-plugin-floating-git-ref", + "claude-plugin-source-mismatch", + } + + _GITHUB_WRITE_TOKEN_RULE = "claude-plugin-github-write-token" _DOCKER_SOCKET_RULE = "claude-plugin-docker-socket" _SECRET_TO_NETWORK_RULE = "claude-plugin-secret-to-network" From f690b974640397f7e9dcc71ea2e6df12c2bc05c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 06:16:36 +0900 Subject: [PATCH 06/11] test(scanner): preserve relative marketplace source contract --- ...aude_plugin_marketplace_relative_source.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/test_claude_plugin_marketplace_relative_source.py diff --git a/tests/test_claude_plugin_marketplace_relative_source.py b/tests/test_claude_plugin_marketplace_relative_source.py new file mode 100644 index 00000000..54425b1a --- /dev/null +++ b/tests/test_claude_plugin_marketplace_relative_source.py @@ -0,0 +1,91 @@ +"""Official relative-path marketplace source contracts for Claude plugin receipts.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import build_claude_plugin_scan_receipt + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_CATALOG_REPOSITORY = "anthropics/claude-plugins-official" +_PLUGIN_REPOSITORY = "https://github.com/example/safe-plugin.git" + + +def _write_json(path: Path, payload: object) -> None: + """Write deterministic JSON fixture bytes.""" + 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 one pinned local plugin identity that satisfies package policy.""" + identity = { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": _PLUGIN_REPOSITORY, + "ref": _PINNED_COMMIT, + }, + } + _write_json(root / ".claude-plugin" / "plugin.json", identity) + _write_json(root / ".claude-plugin" / "marketplace.json", identity) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _catalog(source: str) -> dict[str, object]: + """Return one catalog entry using Claude Code's documented string source form.""" + return { + "repository": _CATALOG_REPOSITORY, + "commit": _PINNED_COMMIT, + "plugins": [ + { + "name": "safe-plugin", + "version": "1.0.0", + "source": source, + } + ], + } + + +def _catalog_bytes(payload: object) -> bytes: + """Return exact deterministic bytes supplied to receipt hashing.""" + return (json.dumps(payload, indent=2) + "\n").encode() + + +def test_receipt_api_accepts_documented_relative_marketplace_source( + tmp_path: Path, +) -> None: + """A documented ``./...`` source remains valid after selector centralization.""" + root = _plugin(tmp_path / "plugin") + catalog = _catalog("./plugins/safe-plugin") + + receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog, + catalog_bytes=_catalog_bytes(catalog), + ) + + assert receipt.scan_result == "pass" + assert receipt.plugin_name == "safe-plugin" + assert receipt.catalog_repository == _CATALOG_REPOSITORY + assert receipt.catalog_commit_sha == _PINNED_COMMIT + assert "claude-plugin-source-mismatch" not in receipt.finding_summary + + +def test_receipt_api_rejects_relative_marketplace_source_escape(tmp_path: Path) -> None: + """String sources cannot escape the marketplace root through parent traversal.""" + root = _plugin(tmp_path / "plugin") + catalog = _catalog("../outside/safe-plugin") + + receipt = build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog, + catalog_bytes=_catalog_bytes(catalog), + ) + + assert receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in receipt.finding_summary From d727e575ba8e7f04fb2b091f6aa93caf0ba66e28 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:23:04 +0000 Subject: [PATCH 07/11] Validate relative plugin sources and enforce marketplace identity matching --- appguardrail_core/claude_plugin_detector.py | 24 ++++++++++++++++++--- tests/test_claude_plugin_supply_chain.py | 5 +++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 82e09a02..cb28740b 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1227,6 +1227,19 @@ def _normalize_marketplace_entry(entry: object) -> dict[str, object]: if version is not None and not isinstance(version, str): raise _MarketplaceCatalogError("invalid plugin version") source = entry.get("source") + if isinstance(source, str): + normalized_path = source.replace("\\", "/") + path_parts = normalized_path[2:].split("/") + if ( + not normalized_path.startswith("./") + or not path_parts + or any(part in {"", ".", ".."} for part in path_parts) + or _CONCEALED_CHAR.search(source) + ): + raise _MarketplaceCatalogError("invalid relative plugin source") + normalized = dict(entry) + normalized["source"] = {"path": normalized_path} + return normalized if not isinstance(source, dict): raise _MarketplaceCatalogError("invalid plugin source") normalized_source = dict(source) @@ -1263,15 +1276,20 @@ def _select_marketplace_entry( """Select exactly one valid catalog entry for the materialized plugin.""" if not isinstance(payload, dict): raise _MarketplaceCatalogError("invalid catalog document") + plugin_name = _plugin_identity(root)["plugin_name"] + if not plugin_name: + raise _MarketplaceCatalogError("materialized plugin identity is missing") plugins = payload.get("plugins") if plugins is None: - return _normalize_marketplace_entry(payload) + selected = _normalize_marketplace_entry(payload) + if selected["name"] != plugin_name: + raise _MarketplaceCatalogError("catalog entry does not match plugin") + return selected if not isinstance(plugins, list): raise _MarketplaceCatalogError("invalid plugins collection") normalized_entries = [_normalize_marketplace_entry(entry) for entry in plugins] - plugin_name = _plugin_identity(root)["plugin_name"] matches = [entry for entry in normalized_entries if entry["name"] == plugin_name] - if not plugin_name or len(matches) != 1: + if len(matches) != 1: raise _MarketplaceCatalogError("catalog entry selection is ambiguous") selected = dict(payload) selected["plugins"] = matches diff --git a/tests/test_claude_plugin_supply_chain.py b/tests/test_claude_plugin_supply_chain.py index 297cb5e2..f491c847 100644 --- a/tests/test_claude_plugin_supply_chain.py +++ b/tests/test_claude_plugin_supply_chain.py @@ -2188,6 +2188,11 @@ def test_marketplace_catalog_binding_validation_edges(tmp_path: Path) -> None: assert selected_source["repo"] == "example/hook-plugin" assert selected_source["ref"] == _PINNED_COMMIT assert detector._select_marketplace_entry(entry, root)["name"] == "hook-plugin" + with pytest.raises(detector._MarketplaceCatalogError): + detector._select_marketplace_entry( + {**entry, "name": "different-plugin"}, + root, + ) invalid_entries: list[object] = [ "entry", From 687937974b0b978fb6d9934d0c981e12e82fc558 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 06:32:26 +0900 Subject: [PATCH 08/11] test(scanner): pin marketplace pluginRoot contract --- ...t_claude_plugin_marketplace_plugin_root.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/test_claude_plugin_marketplace_plugin_root.py diff --git a/tests/test_claude_plugin_marketplace_plugin_root.py b/tests/test_claude_plugin_marketplace_plugin_root.py new file mode 100644 index 00000000..3cbbeddd --- /dev/null +++ b/tests/test_claude_plugin_marketplace_plugin_root.py @@ -0,0 +1,103 @@ +"""Official ``metadata.pluginRoot`` marketplace source contracts.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import build_claude_plugin_scan_receipt + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_CATALOG_REPOSITORY = "anthropics/claude-plugins-official" +_PLUGIN_REPOSITORY = "https://github.com/example/safe-plugin.git" + + +def _write_json(path: Path, payload: object) -> None: + 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: + identity = { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": _PLUGIN_REPOSITORY, + "ref": _PINNED_COMMIT, + }, + } + _write_json(root / ".claude-plugin" / "plugin.json", identity) + _write_json(root / ".claude-plugin" / "marketplace.json", identity) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _catalog(*, plugin_root: str | None, source: str) -> dict[str, object]: + payload: dict[str, object] = { + "repository": _CATALOG_REPOSITORY, + "commit": _PINNED_COMMIT, + "plugins": [ + { + "name": "safe-plugin", + "version": "1.0.0", + "source": source, + } + ], + } + if plugin_root is not None: + payload["metadata"] = {"pluginRoot": plugin_root} + return payload + + +def _receipt(root: Path, catalog: dict[str, object]): + return build_claude_plugin_scan_receipt( + root, + catalog_payload=catalog, + catalog_bytes=(json.dumps(catalog, indent=2) + "\n").encode(), + ) + + +def test_receipt_accepts_bare_source_under_safe_plugin_root(tmp_path: Path) -> None: + """Claude Code v2.1.239+ bare names resolve below ``metadata.pluginRoot``.""" + receipt = _receipt( + _plugin(tmp_path / "plugin"), + _catalog(plugin_root="./plugins", source="safe-plugin"), + ) + + assert receipt.scan_result == "pass" + assert "claude-plugin-source-mismatch" not in receipt.finding_summary + + +def test_receipt_rejects_bare_source_without_plugin_root(tmp_path: Path) -> None: + """A bare source has no relative-path authority without ``pluginRoot``.""" + receipt = _receipt( + _plugin(tmp_path / "plugin"), + _catalog(plugin_root=None, source="safe-plugin"), + ) + + assert receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in receipt.finding_summary + + +def test_receipt_rejects_plugin_root_parent_traversal(tmp_path: Path) -> None: + """The marketplace root cannot be escaped through ``pluginRoot``.""" + receipt = _receipt( + _plugin(tmp_path / "plugin"), + _catalog(plugin_root="../plugins", source="safe-plugin"), + ) + + assert receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in receipt.finding_summary + + +def test_receipt_rejects_non_bare_source_under_plugin_root(tmp_path: Path) -> None: + """A slash-containing source still requires the explicit ``./`` form.""" + receipt = _receipt( + _plugin(tmp_path / "plugin"), + _catalog(plugin_root="./plugins", source="team/safe-plugin"), + ) + + assert receipt.scan_result == "fail" + assert "claude-plugin-source-mismatch" in receipt.finding_summary From 0484372b8e2df6b690c25b132510e947269d91b7 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:38:43 +0000 Subject: [PATCH 09/11] Resolve bare marketplace plugin sources against validated pluginRoot metadata --- appguardrail_core/claude_plugin_detector.py | 44 +++++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index cb28740b..8af5e693 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1216,7 +1216,11 @@ class _MarketplaceCatalogError(ValueError): """Raised when a marketplace catalog cannot bind one plugin identity.""" -def _normalize_marketplace_entry(entry: object) -> dict[str, object]: +def _normalize_marketplace_entry( + entry: object, + *, + plugin_root: object | None = None, +) -> dict[str, object]: """Return one validated entry with URL/SHA source aliases normalized.""" if not isinstance(entry, dict): raise _MarketplaceCatalogError("invalid plugin entry") @@ -1229,12 +1233,39 @@ def _normalize_marketplace_entry(entry: object) -> dict[str, object]: source = entry.get("source") if isinstance(source, str): normalized_path = source.replace("\\", "/") + if not normalized_path.startswith("./"): + normalized_root = ( + plugin_root.replace("\\", "/") + if isinstance(plugin_root, str) + else "" + ) + root_parts = normalized_root[2:].split("/") + if ( + not normalized_path + or normalized_path in {".", ".."} + or "/" in normalized_path + or _CONCEALED_CHAR.search(normalized_path) + or ( + normalized_root != "." + and ( + not normalized_root.startswith("./") + or not root_parts + or any(part in {"", ".", ".."} for part in root_parts) + ) + ) + ): + raise _MarketplaceCatalogError("invalid relative plugin source") + normalized_path = ( + f"./{normalized_path}" + if normalized_root == "." + else f"{normalized_root}/{normalized_path}" + ) path_parts = normalized_path[2:].split("/") if ( not normalized_path.startswith("./") or not path_parts or any(part in {"", ".", ".."} for part in path_parts) - or _CONCEALED_CHAR.search(source) + or _CONCEALED_CHAR.search(normalized_path) ): raise _MarketplaceCatalogError("invalid relative plugin source") normalized = dict(entry) @@ -1279,15 +1310,20 @@ def _select_marketplace_entry( plugin_name = _plugin_identity(root)["plugin_name"] if not plugin_name: raise _MarketplaceCatalogError("materialized plugin identity is missing") + metadata = payload.get("metadata") + plugin_root = metadata.get("pluginRoot") if isinstance(metadata, dict) else None plugins = payload.get("plugins") if plugins is None: - selected = _normalize_marketplace_entry(payload) + selected = _normalize_marketplace_entry(payload, plugin_root=plugin_root) if selected["name"] != plugin_name: raise _MarketplaceCatalogError("catalog entry does not match plugin") return selected if not isinstance(plugins, list): raise _MarketplaceCatalogError("invalid plugins collection") - normalized_entries = [_normalize_marketplace_entry(entry) for entry in plugins] + normalized_entries = [ + _normalize_marketplace_entry(entry, plugin_root=plugin_root) + for entry in plugins + ] matches = [entry for entry in normalized_entries if entry["name"] == plugin_name] if len(matches) != 1: raise _MarketplaceCatalogError("catalog entry selection is ambiguous") From 76bf0fc0785060351f4ba5446a8e5a2c2b6fa407 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 06:45:03 +0900 Subject: [PATCH 10/11] test(scanner): isolate target entry validation --- ...t_claude_plugin_marketplace_plugin_root.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_claude_plugin_marketplace_plugin_root.py b/tests/test_claude_plugin_marketplace_plugin_root.py index 3cbbeddd..02880866 100644 --- a/tests/test_claude_plugin_marketplace_plugin_root.py +++ b/tests/test_claude_plugin_marketplace_plugin_root.py @@ -101,3 +101,26 @@ def test_receipt_rejects_non_bare_source_under_plugin_root(tmp_path: Path) -> No assert receipt.scan_result == "fail" assert "claude-plugin-source-mismatch" in receipt.finding_summary + + +def test_receipt_ignores_unrelated_official_source_types(tmp_path: Path) -> None: + """Unsupported unrelated entries cannot poison exact-name target selection.""" + catalog = _catalog(plugin_root="./plugins", source="safe-plugin") + plugins = catalog["plugins"] + assert isinstance(plugins, list) + plugins.insert( + 0, + { + "name": "unrelated-npm-plugin", + "source": { + "source": "npm", + "package": "@example/unrelated-plugin", + "version": "2.1.0", + }, + }, + ) + + receipt = _receipt(_plugin(tmp_path / "plugin"), catalog) + + assert receipt.scan_result == "pass" + assert "claude-plugin-source-mismatch" not in receipt.finding_summary From b5373d5138fa4a23d67a478d3b64a7aea0406ee2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:50:45 +0000 Subject: [PATCH 11/11] Normalize only the selected Claude marketplace plugin entry --- appguardrail_core/claude_plugin_detector.py | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 8af5e693..5a70508e 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1320,15 +1320,25 @@ def _select_marketplace_entry( return selected if not isinstance(plugins, list): raise _MarketplaceCatalogError("invalid plugins collection") - normalized_entries = [ - _normalize_marketplace_entry(entry, plugin_root=plugin_root) - for entry in plugins - ] - matches = [entry for entry in normalized_entries if entry["name"] == plugin_name] + matches: list[dict[str, object]] = [] + for entry in plugins: + if not isinstance(entry, dict): + raise _MarketplaceCatalogError("invalid plugin entry") + name = entry.get("name") + if not isinstance(name, str) or not name: + raise _MarketplaceCatalogError("invalid plugin name") + version = entry.get("version") + if version is not None and not isinstance(version, str): + raise _MarketplaceCatalogError("invalid plugin version") + if name == plugin_name: + matches.append(entry) if len(matches) != 1: raise _MarketplaceCatalogError("catalog entry selection is ambiguous") + normalized_match = _normalize_marketplace_entry( + matches[0], plugin_root=plugin_root + ) selected = dict(payload) - selected["plugins"] = matches + selected["plugins"] = [normalized_match] return selected