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
5 changes: 4 additions & 1 deletion CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@
or ``*.sha256`` next to ``plugin.json`` that names the plugin artifact
or enumerated files fails as `claude-plugin-checksum-mismatch` when the
digest disagrees with bytes on disk. Matching checksums, comment-only
rows, and missing checksum files are not that class. Cosign or GPG
rows, and missing checksum files are not that class. A checksum file
with digest rows and no non-empty sibling ``.sig``, ``.asc``, ``.gpg``,
``.bundle``, or ``cosign.bundle`` fails as
`claude-plugin-unsigned-checksum`. Cosign or GPG
network verification is not required. Snippets are path labels, not
hashes or secrets. ``sbom_sha256`` stays the CycloneDX receipt digest.
Hook or manifest ``gh pr merge`` fails as
Expand Down
73 changes: 71 additions & 2 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
``checksums.sha256``, or ``*.sha256`` next to ``plugin.json`` that names
the plugin artifact or enumerated files fails closed when the digest
disagrees with bytes on disk. Comments are ignored. Absence of a
checksum or Cosign signature is not that class. Receipts bind
checksum file is not that class. A checksum file with digest rows and
no non-empty sibling ``.sig``, ``.asc``, ``.gpg``, ``.bundle``, or
``cosign.bundle`` fails closed as an unsigned checksum. Network Cosign
or GPG verification is not performed. Receipts bind
``policy_provenance`` to the running AppGuardrail release and the exact
scan-policy bytes, and ``sbom_sha256`` to a deterministic CycloneDX
document of declared dependencies; verification fails closed when that
Expand Down Expand Up @@ -154,9 +157,15 @@
CLAUDE_PLUGIN_CHECKSUM_MISMATCH_MESSAGE: Final = (
"Claude plugin checksum file lists a SHA-256 digest that does not match "
"the bytes on disk. Bind admission to the exact artifact. Absence of a "
"checksum or Cosign signature is not this class. "
"checksum file is not this class. "
"[CWE-494 - Download of Code Without Integrity Check]"
)
CLAUDE_PLUGIN_UNSIGNED_CHECKSUM_MESSAGE: Final = (
"Claude plugin checksum file lists digests but has no sibling signature "
"file. Place a non-empty Cosign bundle or detached GPG signature next "
"to the checksum. Network verification is not performed. "
"[CWE-347 - Improper Verification of Cryptographic Signature]"
)
CLAUDE_PLUGIN_DYNAMIC_EVAL_MESSAGE: Final = (
"Claude plugin hook evaluates a string as code. Dynamic eval, exec, "
"compile, or Function constructors fail admission. "
Expand Down Expand Up @@ -1041,6 +1050,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]:
else:
hits.extend(_license_mismatch_hits(root, {}))
hits.extend(_checksum_mismatch_hits(root))
hits.extend(_unsigned_checksum_hits(root))
_, file_count, scanned_byte_count = _artifact_digest(root)
if file_count > _MAX_PACKAGE_FILES or scanned_byte_count > _MAX_PACKAGE_BYTES:
hits.append(
Expand Down Expand Up @@ -3906,6 +3916,65 @@ def _checksum_mismatch_hits(root: Path) -> tuple[PluginHit, ...]:
return tuple(hits)


_CHECKSUM_SIGNATURE_SUFFIXES: Final = (".sig", ".asc", ".gpg", ".bundle")


def _checksum_has_signature_file(checksum_path: Path) -> bool:
"""Return whether a non-empty sibling signature file exists.

Args:
checksum_path: First-party checksum file.

Returns:
True when a regular, non-symlink sibling ``.sig``, ``.asc``,
``.gpg``, ``.bundle``, or ``cosign.bundle`` has a non-zero size.
Empty files, missing files, and unreadable paths are False.
Bytes are not cryptographically verified.
"""
names = [checksum_path.name + suffix for suffix in _CHECKSUM_SIGNATURE_SUFFIXES]
names.append("cosign.bundle")
for name in names:
candidate = checksum_path.parent / name
try:
if candidate.is_symlink() or not candidate.is_file():
continue
if candidate.stat().st_size > 0:
return True
except OSError:
continue
return False


def _unsigned_checksum_hits(root: Path) -> tuple[PluginHit, ...]:
"""Return findings when checksum digest rows have no sibling signature.

Missing checksum files and comment-only checksum files are not this
class. Network Cosign or GPG verification is not performed. Snippets
are checksum filenames, never digests or secret literals.
"""
hits: list[PluginHit] = []
for path in _checksum_file_paths(root):
relative = path.relative_to(root).as_posix()
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if not _parse_checksum_entries(text, path):
continue
if _checksum_has_signature_file(path):
continue
hits.append(
PluginHit(
rule_id="claude-plugin-unsigned-checksum",
line=1,
snippet=_sanitize_path_snippet(path.name),
message=CLAUDE_PLUGIN_UNSIGNED_CHECKSUM_MESSAGE,
file=relative,
)
)
return tuple(hits)


def _empty_identity() -> dict[str, str]:
"""Return blank plugin identity fields."""
return {
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, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, fail-closed receipt verification | implemented-branch |
| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, fail-closed receipt verification | implemented-branch |
| Orphaned GitHub Actions registry identities (#929) | owned by PR #966 / issue #929; live registry DAST | mapped-family only; this successor does not ship or close the detector |
| Org security-failure CI tickets without copied vuln evidence | documented non-detectable family | snapshot in `tests/fixtures/cwl-security-issue-inventory.json` |

Expand Down
4 changes: 3 additions & 1 deletion docs/sast-dast-rule-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ files being scanned, then applies the union of relevant checks. Examples:
for the exact AppGuardrail release and scan-policy bytes, plus
`sbom_sha256` of a deterministic CycloneDX 1.5 dependency document, and
`claude-plugin-checksum-mismatch` when a first-party checksum file
disagrees with artifact bytes on disk, `claude-plugin-github-merge-command`
disagrees with artifact bytes on disk,
`claude-plugin-unsigned-checksum` when digest rows have no sibling
signature file, `claude-plugin-github-merge-command`
for hook or manifest ``gh pr merge``,
`claude-plugin-github-release-command` for ``gh release``
create/upload/delete/edit,
Expand Down
5 changes: 0 additions & 5 deletions tests/test_claude_plugin_checksum_mismatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ def test_sha256sums_matching_plugin_json_is_not_a_finding(tmp_path: Path) -> Non
receipt = build_claude_plugin_scan_receipt(root)
assert _checksum_hits(root) == []
assert _CHECKSUM_RULE not in receipt.finding_summary
assert receipt.scan_result == "pass"


def test_no_checksum_file_is_not_a_finding(tmp_path: Path) -> None:
Expand All @@ -116,7 +115,6 @@ def test_sha256sums_comment_lines_are_ignored(tmp_path: Path) -> None:
receipt = build_claude_plugin_scan_receipt(root)
assert _checksum_hits(root) == []
assert _CHECKSUM_RULE not in receipt.finding_summary
assert receipt.scan_result == "pass"


def test_sbom_sha256_still_binds_and_verifies_with_checksum_file(
Expand Down Expand Up @@ -210,7 +208,6 @@ def test_plugin_json_sha256_sibling_match_is_not_a_finding(tmp_path: Path) -> No
)
receipt = build_claude_plugin_scan_receipt(root)
assert _checksum_hits(root) == []
assert receipt.scan_result == "pass"


def test_binary_mode_star_prefix_matching_digest_is_not_a_finding(
Expand All @@ -221,7 +218,6 @@ def test_binary_mode_star_prefix_matching_digest_is_not_a_finding(
digest = _sha256(_plugin_json(root))
(root / "SHA256SUMS").write_text(f"{digest} *plugin.json\n", encoding="utf-8")
assert _checksum_hits(root) == []
assert build_claude_plugin_scan_receipt(root).scan_result == "pass"


def test_comments_only_checksum_file_is_not_a_finding(tmp_path: Path) -> None:
Expand Down Expand Up @@ -287,7 +283,6 @@ def test_tab_separator_matching_digest_is_not_a_finding(tmp_path: Path) -> None:
digest = _sha256(_plugin_json(root))
(root / "SHA256SUMS").write_text(f"{digest}\tplugin.json\n", encoding="utf-8")
assert _checksum_hits(root) == []
assert build_claude_plugin_scan_receipt(root).scan_result == "pass"


def test_absolute_and_windows_listed_paths_fail_closed(tmp_path: Path) -> None:
Expand Down
Loading