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
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ jobs:
--test tests/test_claude_plugin_hide_actions.py \
--test tests/test_claude_plugin_insecure_file_mode.py \
--test tests/test_claude_plugin_decompression_bomb.py \
--test tests/test_claude_plugin_archive_aggregate_admission.py
--test tests/test_claude_plugin_archive_aggregate_admission.py \
--test tests/test_claude_plugin_policy_provenance.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
13 changes: 9 additions & 4 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@
receipt binds `capability_inventory_sha256` over canonical JSON without
secret literals. Undeclared executable surfaces that appear after manifest
inventory fail admission. `pass` means the exact tree satisfied the exact
AppGuardrail policy, not activation. Retained receipts fail closed on a
wrong artifact digest, wrong scanner policy digest, stale catalog, source,
or marketplace identity, or replay against mutated bytes; verification is
not Noema admission. Hardcoded GitHub PAT or app tokens, host Docker
AppGuardrail policy, not activation. Receipts bind `policy_provenance`
(`schema_version`, `source_repository` identity
`ContextualWisdomLab/appguardrail`, `scanner_release_version` from the
existing scanner version, and the same `scanner_policy_sha256` digest)
to the exact AppGuardrail release and scan-policy bytes. Retained
receipts fail closed on a wrong artifact digest, wrong scanner policy
digest, disagreeing scanner version, swapped provenance, stale catalog,
source, or marketplace identity, or replay against mutated bytes;
verification is not Noema admission. Hardcoded GitHub PAT or app tokens, host Docker
socket binds, and named secrets copied into curl/wget/fetch fail
admission as policy findings; `gh issue create` and `docker push` stay
inventory evidence. Secret references require a complete environment-variable
Expand Down
73 changes: 66 additions & 7 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
beyond a small depth, fail admission without extracting the payload.
A lockfile-backed package.json without a
lifecycle download stays inventory. Vendored trees are one scope finding,
not hook scans.
not hook scans. Receipts bind ``policy_provenance`` to the running
AppGuardrail release and the exact scan-policy bytes; verification fails
closed when that digest or scanner version disagrees. ``scan_result=pass``
is not Noema admission.
"""

from __future__ import annotations
Expand Down Expand Up @@ -270,6 +273,8 @@
)
_SCANNER_NAME: Final = "appguardrail"
_SCANNER_VERSION: Final = "0.1.1"
_POLICY_PROVENANCE_SCHEMA_VERSION: Final = "1"
_SCANNER_SOURCE_REPOSITORY: Final = "ContextualWisdomLab/appguardrail"

_FULL_SHA = re.compile(r"^[0-9a-fA-F]{40}$")
_WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:")
Expand Down Expand Up @@ -557,6 +562,29 @@ class PluginHit:
file: str | None = None


@dataclass(frozen=True, slots=True)
class PolicyProvenance:
"""Bounded binding of one scan to the AppGuardrail release and policy bytes.

This is not an SPDX SBOM and is not Noema admission. Fields never contain
secrets, tokens, or unbounded plugin text.
"""

schema_version: str
source_repository: str
scanner_release_version: str
scanner_policy_sha256: str

def as_dict(self) -> dict[str, str]:
"""Return JSON-safe provenance without secret literals."""
return {
"schema_version": self.schema_version,
"source_repository": self.source_repository,
"scanner_release_version": self.scanner_release_version,
"scanner_policy_sha256": self.scanner_policy_sha256,
}


@dataclass(frozen=True, slots=True)
class PluginScanReceipt:
"""Bounded deterministic receipt for one Claude plugin artifact scan."""
Expand All @@ -565,6 +593,7 @@ class PluginScanReceipt:
scanner_name: str
scanner_version: str
scanner_policy_sha256: str
policy_provenance: PolicyProvenance
catalog_repository: str
catalog_commit_sha: str
marketplace_blob_sha: str
Expand Down Expand Up @@ -592,6 +621,7 @@ def as_dict(self) -> dict[str, object]:
"scanner_name": self.scanner_name,
"scanner_version": self.scanner_version,
"scanner_policy_sha256": self.scanner_policy_sha256,
"policy_provenance": self.policy_provenance.as_dict(),
"catalog_repository": self.catalog_repository,
"catalog_commit_sha": self.catalog_commit_sha,
"marketplace_blob_sha": self.marketplace_blob_sha,
Expand Down Expand Up @@ -943,7 +973,9 @@ def build_claude_plugin_scan_receipt(
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.
remain. Secret literals never appear on the receipt. ``policy_provenance``
binds the running release and the exact policy digest; it is not a
second policy hash and is not Noema admission.
"""
hits = list(_collect_plugin_hits(root))
catalog, catalog_is_valid = _receipt_catalog_identity(
Expand All @@ -965,7 +997,8 @@ def build_claude_plugin_scan_receipt(
marketplace_entry_sha256 = _sha256(
json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()
)
policy_sha256 = _sha256(Path(__file__).read_bytes())
policy_sha256 = _scanner_policy_sha256()
provenance = _policy_provenance(policy_sha256)
inventory = inventory_claude_plugin_capabilities(root)
capability_inventory_sha256 = _capability_inventory_digest(inventory)
sarif_sha256 = sarif_document_sha256(
Expand All @@ -979,6 +1012,7 @@ def build_claude_plugin_scan_receipt(
"scanner_name": _SCANNER_NAME,
"scanner_version": scanner_version,
"scanner_policy_sha256": policy_sha256,
"policy_provenance": provenance.as_dict(),
"catalog_repository": catalog["catalog_repository"],
"catalog_commit_sha": catalog["catalog_commit_sha"],
"marketplace_blob_sha": marketplace_blob_sha,
Expand All @@ -1005,7 +1039,12 @@ def build_claude_plugin_scan_receipt(
scan_started_at=scan_started_at,
scan_completed_at=scan_completed_at,
finding_summary=finding_summary,
**{key: value for key, value in body.items() if key != "finding_summary"},
policy_provenance=provenance,
**{
key: value
for key, value in body.items()
if key not in {"finding_summary", "policy_provenance"}
},
)


Expand All @@ -1021,7 +1060,7 @@ def receipt_matches_artifact(receipt: PluginScanReceipt, root: Path) -> bool:
"""
artifact_sha256, _, _ = _artifact_digest(root)
return artifact_sha256 == receipt.artifact_sha256 and (
receipt.scanner_policy_sha256 == _sha256(Path(__file__).read_bytes())
receipt.scanner_policy_sha256 == _scanner_policy_sha256()
)


Expand Down Expand Up @@ -1072,14 +1111,15 @@ def verify_plugin_scan_receipt(
Structured mismatch field names. Empty mismatches mean the receipt
still describes this tree and policy. ``admitted`` is always false:
``scan_result=pass`` is not Noema admission. Reasons never include
secret literals or raw bidi characters.
secret literals or raw bidi characters. A disagreeing policy digest
or scanner version fails closed against the running scanner.
"""
live = build_claude_plugin_scan_receipt(
root,
catalog_payload=catalog_payload,
catalog_bytes=catalog_bytes,
)
current_policy_sha256 = _sha256(Path(__file__).read_bytes())
current_policy_sha256 = _scanner_policy_sha256()
expected = (
current_policy_sha256
if expected_policy_sha256 is None
Expand All @@ -1093,6 +1133,10 @@ def verify_plugin_scan_receipt(
or receipt.scanner_policy_sha256 != expected
):
mismatches.append("scanner_policy_sha256")
if receipt.scanner_version != _SCANNER_VERSION:
mismatches.append("scanner_version")
if receipt.policy_provenance != live.policy_provenance:
mismatches.append("policy_provenance")
if receipt.catalog_commit_sha != live.catalog_commit_sha:
mismatches.append("catalog_commit_sha")
if receipt.source_commit_sha != live.source_commit_sha:
Expand Down Expand Up @@ -2422,6 +2466,21 @@ def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()


def _scanner_policy_sha256() -> str:
"""Return SHA-256 of the exact detector policy bytes used for this scan."""
return _sha256(Path(__file__).read_bytes())


def _policy_provenance(policy_sha256: str) -> PolicyProvenance:
"""Return provenance bound to the running scanner release and policy digest."""
return PolicyProvenance(
schema_version=_POLICY_PROVENANCE_SCHEMA_VERSION,
source_repository=_SCANNER_SOURCE_REPOSITORY,
scanner_release_version=_SCANNER_VERSION,
scanner_policy_sha256=policy_sha256,
)


def _walk_entries(root: Path) -> tuple[Path, ...]:
"""Yield regular files and symlinks without following linked directories."""
found: list[Path] = []
Expand Down
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` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, fail-closed receipt verification | implemented-branch |
| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, 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
3 changes: 2 additions & 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 from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, secrets copied into MCP env/args/command/URL/headers, hide-actions / self-modify / goal-escalation wording on skill/command/agent surfaces, setuid/setgid or world-writable executable and hook modes, zip/tar decompression bombs, nested-archive depth, and pre-extraction aggregate byte budget, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent/command surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification |
| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, secrets copied into MCP env/args/command/URL/headers, hide-actions / self-modify / goal-escalation wording on skill/command/agent surfaces, setuid/setgid or world-writable executable and hook modes, zip/tar decompression bombs, nested-archive depth, and pre-extraction aggregate byte budget, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent/command surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release and exact scan-policy digest, 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 Expand Up @@ -50,6 +50,7 @@ owned by PRs #1088 and #966.
- `tests/test_claude_plugin_insecure_file_mode.py`
- `tests/test_claude_plugin_decompression_bomb.py`
- `tests/test_claude_plugin_archive_aggregate_admission.py`
- `tests/test_claude_plugin_policy_provenance.py`
- `tests/test_password_indirection_precision.py`
- `tests/test_cwl_security_issue_inventory.py`
- `tests/fixtures/cwl-security-issue-inventory.json`
Expand Down
3 changes: 2 additions & 1 deletion docs/sast-dast-rule-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ files being scanned, then applies the union of relevant checks. Examples:
goal-escalation instruction wording on those surfaces, setuid/setgid or
world-writable executable and hook modes, zip/tar decompression bombs,
nested-archive depth, and pre-extraction aggregate byte budget, and fail-closed
replay of a stale or mismatched scan receipt.
replay of a stale or mismatched scan receipt bound to `policy_provenance`
for the exact AppGuardrail release and scan-policy bytes.
- Mapped, not owned here: GitHub Actions transport-only poll loops (#1087,
PR #1088) and orphaned workflow registry DAST (#929, PR #966).
- `tool-execute-parameters-passthrough`: Strix-observed dynamic tool execution
Expand Down
Loading