diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7a95c380..4a0c9e02 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,10 +53,17 @@ jobs: run: | python -m scripts.ci.verify_module_coverage \ --module appguardrail_core/claude_plugin_detector.py \ - --test tests/test_claude_plugin_supply_chain.py + --test tests/test_claude_plugin_supply_chain.py \ + --test tests/test_claude_plugin_scan_cli.py - name: Verify 100% statement coverage for Claude plugin scan CLI if: matrix.python-version == '3.13' run: | python -m scripts.ci.verify_module_coverage \ --module appguardrail_core/claude_plugin_scan_cli.py \ --test tests/test_claude_plugin_scan_cli.py + - name: Verify 100% statement coverage for Claude plugin SARIF receipt bind + if: matrix.python-version == '3.13' + run: | + python -m scripts.ci.verify_module_coverage \ + --module appguardrail_core/claude_plugin_sarif.py \ + --test tests/test_claude_plugin_sarif_receipt.py diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 47957c9f..c47bd8e4 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -35,4 +35,6 @@ 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. + Receipt `sarif_sha256` is the SHA-256 of a deterministic SARIF 2.1.0 + document covering the same finding rule_ids as `finding_summary`. `.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 73852a32..5f7b63e4 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -24,6 +24,7 @@ from typing import Final, Iterable import zipfile +from .claude_plugin_sarif import finding_summary_to_sarif, sarif_document_sha256 CLAUDE_PLUGIN_FLOATING_REF_MESSAGE: Final = ( "Claude plugin source uses a floating branch or tag instead of an immutable " @@ -603,15 +604,8 @@ def build_claude_plugin_scan_receipt( policy_sha256 = _sha256(Path(__file__).read_bytes()) inventory = inventory_claude_plugin_capabilities(root) capability_inventory_sha256 = _capability_inventory_digest(inventory) - sarif_sha256 = _sha256( - json.dumps( - [ - {"rule_id": hit.rule_id, "line": hit.line, "file": hit.file or ""} - for hit in hits - ], - sort_keys=True, - separators=(",", ":"), - ).encode() + sarif_sha256 = sarif_document_sha256( + finding_summary_to_sarif(finding_summary, tool_version=scanner_version) ) is_package = (root / ".claude-plugin").is_dir() and not ( root / ".claude-plugin" diff --git a/appguardrail_core/claude_plugin_sarif.py b/appguardrail_core/claude_plugin_sarif.py new file mode 100644 index 00000000..13cf8167 --- /dev/null +++ b/appguardrail_core/claude_plugin_sarif.py @@ -0,0 +1,98 @@ +"""Bind Claude plugin receipt findings to a deterministic SARIF 2.1.0 document. + +This adapter reuses ``findings_to_sarif``. It does not invent a second SARIF +dialect. Receipt ``sarif_sha256`` is the SHA-256 of that document. Secret +literals and raw bidi never appear in SARIF text. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Iterable, Mapping, Sequence + +from .sarif import SARIF_VERSION, findings_to_sarif + + +def finding_summary_to_sarif( + finding_summary: Sequence[str] | Iterable[str], + *, + tool_version: str = "0.0.0", +) -> dict[str, Any]: + """Return a deterministic SARIF 2.1.0 log covering receipt finding rule_ids. + + Args: + finding_summary: Unique rule_ids recorded on the scan receipt. + tool_version: Scanner version recorded on the SARIF driver. + + Returns: + SARIF 2.1.0 document with one result per rule_id, in sorted order. + Snippets are empty so secret literals and raw bidi cannot appear. + """ + findings = [ + { + "rule_id": rule_id, + "severity": "HIGH", + "message": rule_id, + "file": "n/a", + "line": 1, + "category": "supply-chain", + "context": "app-code", + "snippet": "", + } + for rule_id in sorted(finding_summary) + ] + return findings_to_sarif(findings, tool_version=tool_version) + + +def sarif_document_sha256(sarif: Mapping[str, Any]) -> str: + """Return the SHA-256 digest of canonical SARIF JSON. + + Args: + sarif: SARIF 2.1.0 log. + + Returns: + Hex digest of compact, key-sorted JSON bytes. + """ + payload = json.dumps(sarif, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def receipt_sarif_is_consistent( + finding_summary: Sequence[str] | Iterable[str], + sarif: object, +) -> bool: + """Return whether receipt rule_ids and SARIF result ruleIds match. + + Every receipt finding rule_id appears in SARIF results, every SARIF + result ruleId appears in the receipt summary, and the counts match. + + Args: + finding_summary: Receipt ``finding_summary`` rule_ids. + sarif: Candidate SARIF 2.1.0 log. + + Returns: + True only when rule_ids and counts match. Malformed logs fail closed. + """ + if type(sarif) is not dict: + return False + if sarif.get("version") != SARIF_VERSION: + return False + runs = sarif.get("runs") + if type(runs) is not list or not runs or type(runs[0]) is not dict: + return False + results = runs[0].get("results") + if type(results) is not list: + return False + result_ids: list[str] = [] + for item in results: + if type(item) is not dict: + return False + rule_id = item.get("ruleId") + if type(rule_id) is not str or not rule_id: + return False + result_ids.append(rule_id) + summary_ids = [str(rule_id) for rule_id in finding_summary] + if len(result_ids) != len(summary_ids): + return False + return sorted(result_ids) == sorted(summary_ids) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d5885417..30bea557 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 with catalog repository/SHA bind, 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 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 f0c393a5..d01af07a 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 with catalog repository/SHA bind, 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 SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification | | Orphaned Actions workflows | DAST | #929 | PR #966 / issue #929 | maps only | | Org CI failure without evidence | non-detectable | 353 tickets | inventory snapshot | maps only | | UX / control-plane product gaps | non-detectable | #871, #928 | out of SAST/DAST scope | maps only | diff --git a/tests/test_claude_plugin_sarif_receipt.py b/tests/test_claude_plugin_sarif_receipt.py new file mode 100644 index 00000000..c9b2a454 --- /dev/null +++ b/tests/test_claude_plugin_sarif_receipt.py @@ -0,0 +1,206 @@ +"""SARIF 2.1.0 documents must stay semantically bound to plugin receipts.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from appguardrail_core.claude_plugin_detector import build_claude_plugin_scan_receipt +from appguardrail_core.claude_plugin_sarif import ( + finding_summary_to_sarif, + receipt_sarif_is_consistent, + sarif_document_sha256, +) +from scanner.cli.appguardrail import main + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_SECRET = "sk-sarif-receipt-must-not-leak" +_UNDECLARED_RULE = "claude-plugin-undeclared-executable" + + +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 _pass_plugin(root: Path) -> Path: + """Write a pinned licensed plugin that satisfies current admission policy.""" + manifest = { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + _write_json(root / ".claude-plugin" / "marketplace.json", manifest) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _undeclared_plugin(root: Path) -> Path: + """Write a pinned licensed plugin with an undeclared executable hook.""" + _pass_plugin(root) + hook = root / "hooks" / "hidden.sh" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\necho hidden\n", encoding="utf-8") + return root + + +def _leaky_plugin(root: Path) -> Path: + """Write a plugin whose manifest contains a provider secret and bidi mark.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": "leaky\u202e", + "version": "0.0.1", + "env": {"OPENAI_API_KEY": _SECRET}, + "source": {"ref": "main"}, + }, + ) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _run_cli( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], +) -> tuple[int, str, str]: + """Invoke the public AppGuardrail entrypoint and return exit code plus streams.""" + monkeypatch.setattr(sys, "argv", ["appguardrail", *argv]) + with pytest.raises(SystemExit) as excinfo: + main() + captured = capsys.readouterr() + code = excinfo.value.code + return (0 if code is None else int(code)), captured.out, captured.err + + +def _bound_sarif(receipt: object) -> dict: + """Rebuild the SARIF document that the receipt sarif_sha256 must hash.""" + return finding_summary_to_sarif( + receipt.finding_summary, + tool_version=receipt.scanner_version, + ) + + +def test_pinned_plugin_sarif_sha256_is_stable_and_consistent(tmp_path: Path) -> None: + """Two scans of a pinned licensed plugin bind the same empty-or-consistent SARIF.""" + first = build_claude_plugin_scan_receipt(_pass_plugin(tmp_path / "a")) + second = build_claude_plugin_scan_receipt(_pass_plugin(tmp_path / "b")) + sarif = _bound_sarif(first) + + assert first.sarif_sha256 == second.sarif_sha256 + assert first.finding_summary == second.finding_summary + assert sarif["version"] == "2.1.0" + assert sarif["$schema"].endswith("sarif-2.1.0.json") + assert first.sarif_sha256 == sarif_document_sha256(sarif) + assert receipt_sarif_is_consistent(first.finding_summary, sarif) + assert len(sarif["runs"][0]["results"]) == len(first.finding_summary) + + +def test_undeclared_executable_rule_id_is_in_receipt_and_sarif(tmp_path: Path) -> None: + """An undeclared hook changes both finding_summary and the bound SARIF digest.""" + clean = build_claude_plugin_scan_receipt(_pass_plugin(tmp_path / "clean")) + dirty_root = _undeclared_plugin(tmp_path / "dirty") + dirty = build_claude_plugin_scan_receipt(dirty_root) + sarif = _bound_sarif(dirty) + rule_ids = [result["ruleId"] for result in sarif["runs"][0]["results"]] + + assert _UNDECLARED_RULE in dirty.finding_summary + assert _UNDECLARED_RULE in rule_ids + assert dirty.finding_summary != clean.finding_summary + assert dirty.sarif_sha256 != clean.sarif_sha256 + assert dirty.sarif_sha256 == sarif_document_sha256(sarif) + assert receipt_sarif_is_consistent(dirty.finding_summary, sarif) + assert len(rule_ids) == len(dirty.finding_summary) + serialized = json.dumps(sarif) + assert "echo hidden" not in serialized + + +def test_swapped_sarif_rule_id_fails_consistency_check(tmp_path: Path) -> None: + """Swapping one SARIF result ruleId against the receipt summary must fail.""" + receipt = build_claude_plugin_scan_receipt(_undeclared_plugin(tmp_path / "plugin")) + sarif = _bound_sarif(receipt) + swapped = json.loads(json.dumps(sarif)) + swapped["runs"][0]["results"][0]["ruleId"] = "not-the-receipt-rule" + + assert receipt_sarif_is_consistent(receipt.finding_summary, sarif) + assert not receipt_sarif_is_consistent(receipt.finding_summary, swapped) + extra = json.loads(json.dumps(sarif)) + extra["runs"][0]["results"].append({"ruleId": "extra-rule"}) + missing = json.loads(json.dumps(sarif)) + missing["runs"][0]["results"] = [] + assert not receipt_sarif_is_consistent(receipt.finding_summary, extra) + assert not receipt_sarif_is_consistent(receipt.finding_summary, missing) + + +def test_scan_plugin_cli_receipt_includes_bound_sarif_sha256( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """scan-plugin stdout receipt sarif_sha256 hashes the bound SARIF document.""" + root = _pass_plugin(tmp_path / "plugin") + + code, stdout, stderr = _run_cli( + monkeypatch, + capsys, + ["scan-plugin", "--plugin-root", str(root)], + ) + + payload = json.loads(stdout) + sarif = finding_summary_to_sarif( + payload["finding_summary"], + tool_version=payload["scanner_version"], + ) + assert code == 0 + assert payload["scan_result"] == "pass" + assert payload["sarif_sha256"] == sarif_document_sha256(sarif) + assert receipt_sarif_is_consistent(payload["finding_summary"], sarif) + assert _SECRET not in stdout + assert _SECRET not in stderr + + +def test_sarif_and_receipt_omit_raw_secrets_and_bidi(tmp_path: Path) -> None: + """Bound SARIF and the receipt must not echo secret literals or raw bidi.""" + receipt = build_claude_plugin_scan_receipt(_leaky_plugin(tmp_path / "leaky")) + sarif = _bound_sarif(receipt) + serialized = json.dumps(receipt.as_dict()) + json.dumps(sarif) + + assert "claude-plugin-provider-secret" in receipt.finding_summary + assert receipt_sarif_is_consistent(receipt.finding_summary, sarif) + assert _SECRET not in serialized + assert "OPENAI_API_KEY" not in serialized + assert "\u202e" not in serialized + + +def test_receipt_sarif_consistency_rejects_malformed_logs() -> None: + """Malformed SARIF documents are not semantically consistent with a receipt.""" + summary = (_UNDECLARED_RULE,) + valid = finding_summary_to_sarif(summary, tool_version="0.1.1") + + assert receipt_sarif_is_consistent(summary, valid) + assert not receipt_sarif_is_consistent(summary, "not-a-log") + assert not receipt_sarif_is_consistent(summary, {"version": "2.0.0", "runs": []}) + assert not receipt_sarif_is_consistent(summary, {"version": "2.1.0", "runs": []}) + assert not receipt_sarif_is_consistent( + summary, {"version": "2.1.0", "runs": ["not-a-run"]} + ) + assert not receipt_sarif_is_consistent( + summary, {"version": "2.1.0", "runs": [{"results": "nope"}]} + ) + assert not receipt_sarif_is_consistent( + summary, {"version": "2.1.0", "runs": [{"results": ["not-a-result"]}]} + ) + assert not receipt_sarif_is_consistent( + summary, {"version": "2.1.0", "runs": [{"results": [{"ruleId": ""}]}]} + ) + assert not receipt_sarif_is_consistent((), valid) diff --git a/tests/test_module_coverage_gate_contract.py b/tests/test_module_coverage_gate_contract.py index 69e8afec..f9bc8fe7 100644 --- a/tests/test_module_coverage_gate_contract.py +++ b/tests/test_module_coverage_gate_contract.py @@ -137,6 +137,7 @@ def test_tests_workflow_enforces_exact_100_percent_for_new_modules() -> None: assert "scripts/ci/collect_code_scanning_drift.py" in workflow assert "appguardrail_core/claude_plugin_detector.py" in workflow assert "appguardrail_core/claude_plugin_scan_cli.py" in workflow + assert "appguardrail_core/claude_plugin_sarif.py" in workflow assert "appguardrail_core/github_actions_poll_loop.py" not in workflow assert "appguardrail_core/github_workflow_orphans.py" not in workflow assert "100% statement coverage" in workflow