Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 3 additions & 9 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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"
Expand Down
98 changes: 98 additions & 0 deletions appguardrail_core/claude_plugin_sarif.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion docs/TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
2 changes: 1 addition & 1 deletion docs/doctoring/cwl-security-issue-detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
206 changes: 206 additions & 0 deletions tests/test_claude_plugin_sarif_receipt.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tests/test_module_coverage_gate_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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