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
6 changes: 6 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@
Honest small zip/tar of plugin.json and LICENSE, ``../`` path
traversal, and oversized file-count or byte-count trees stay their
own classes.
Materialized files and zip/tar members whose path exceeds 32
components fail as `claude-plugin-excessive-path-depth`. Zip-slip
stays `claude-plugin-archive-path-traversal`. Nested archives stay
`claude-plugin-decompression-bomb`. Oversized file-count and byte
budgets stay `claude-plugin-oversized-package`. Snippets are the
label ``nested-path``. Members are not extracted.
Receipt ``sbom_sha256`` is SHA-256 of a deterministic CycloneDX 1.5
document from the existing SBOM parsers. It is not a second policy
digest. Verify fails closed when the digest disagrees. Malformed
Expand Down
132 changes: 126 additions & 6 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
and hook files fail admission. Zip or tar members whose uncompressed size
divided by compressed size exceeds the bounded ratio, or nested archives
beyond a small depth, fail admission without extracting the payload.
Materialized files and archive members nested beyond a bounded path
component depth fail admission as excessive path depth.
A lockfile-backed package.json without a
lifecycle download stays inventory. Vendored trees are one scope finding,
not hook scans. A first-party ``SHA256SUMS``, ``SHA256SUMS.txt``,
Expand Down Expand Up @@ -192,6 +194,12 @@
"budget. Hostile oversized trees fail admission. "
"[CWE-400 - Uncontrolled Resource Consumption]"
)
CLAUDE_PLUGIN_EXCESSIVE_PATH_DEPTH_MESSAGE: Final = (
"Claude plugin tree or archive member nests directories beyond the "
"bounded path depth. Deep recursion fails admission and is not "
"extracted. "
"[CWE-400 - Uncontrolled Resource Consumption]"
)
CLAUDE_PLUGIN_DECOMPRESSION_BOMB_MESSAGE: Final = (
"Claude plugin archive member expands far beyond its compressed size, "
"nests archives beyond the bounded depth, or the archive's total "
Expand Down Expand Up @@ -359,6 +367,7 @@
_MAX_PACKAGE_BYTES: Final = 10 * 1024 * 1024
_MAX_ARCHIVE_COMPRESSION_RATIO: Final = 100
_MAX_ARCHIVE_NESTING_DEPTH: Final = 1
_MAX_PATH_DEPTH: Final = 32
_CONCEALED_CHAR = re.compile(
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u200b-\u200d\u202a-\u202e\u2066-\u2069]"
)
Expand Down Expand Up @@ -1019,6 +1028,8 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]:
class. Vendored trees are one scope finding. A matching checksum
file, comments-only checksum file, or missing checksum file is
not a finding. Cosign or GPG signatures are not required.
Files or archive members nested beyond ``_MAX_PATH_DEPTH``
components fail as excessive path depth.
"""
plugin_dir = root / ".claude-plugin"
if not plugin_dir.is_dir() or plugin_dir.is_symlink():
Expand Down Expand Up @@ -1051,6 +1062,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]:
hits.extend(_license_mismatch_hits(root, {}))
hits.extend(_checksum_mismatch_hits(root))
hits.extend(_unsigned_checksum_hits(root))
hits.extend(_excessive_path_depth_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 @@ -1127,19 +1139,24 @@ def inspect_claude_plugin_archive(
extract_root: Bounded destination root.

Returns:
Path-traversal and decompression-bomb hits. Empty when every
member stays inside the root, stays within the ratio/depth/byte
bounds, or ``archive_path`` is not a readable archive. Secret
literals and raw archive bytes never appear in snippets.
Path-traversal, decompression-bomb, and excessive-path-depth
hits. Empty when every member stays inside the root, stays
within the ratio/nesting/byte/path-depth bounds, or
``archive_path`` is not a readable archive. Secret literals and
raw archive bytes never appear in snippets. Deep members are
never extracted.
"""
hits, safe_members = _classify_archive_members(archive_path, extract_root)
bomb_hits = _inspect_archive_decompression_bombs(archive_path, extract_root)
budget_hits = _archive_aggregate_budget_hits(archive_path, extract_root)
depth_hits = _archive_member_path_depth_hits(archive_path, extract_root)
if bomb_hits or budget_hits:
return (*hits, *bomb_hits, *budget_hits)
return (*hits, *bomb_hits, *budget_hits, *depth_hits)
for name in safe_members:
if _path_exceeds_max_depth(name):
continue
_extract_archive_member(archive_path, name, extract_root)
return hits
return (*hits, *depth_hits)


def build_claude_plugin_scan_receipt(
Expand Down Expand Up @@ -3629,6 +3646,109 @@ def _plugin_sbom_sha256(root: Path) -> str:
)


def _path_component_count(relative: str) -> int:
"""Return the number of non-empty path components in ``relative``.

Args:
relative: A POSIX or mixed-separator path.

Returns:
Component count after dropping empty parts and ``.``. ``/`` and
``./`` are zero.
"""
cleaned = relative.replace("\\", "/").strip("/")
if not cleaned:
return 0
return len([part for part in cleaned.split("/") if part and part != "."])


def _path_exceeds_max_depth(relative: str) -> bool:
"""Return whether ``relative`` nests past ``_MAX_PATH_DEPTH``.

Args:
relative: A POSIX or mixed-separator path.

Returns:
``True`` when the component count is greater than the bound.
"""
return _path_component_count(relative) > _MAX_PATH_DEPTH


def _excessive_path_depth_hit(relative: str, display_file: str | None = None) -> PluginHit:
"""Return one excessive-path-depth finding with a sanitized label.

Args:
relative: Offending relative path.
display_file: Optional archive path recorded on the hit.

Returns:
One finding. Snippets never include secrets or bidi.
"""
return PluginHit(
rule_id="claude-plugin-excessive-path-depth",
line=1,
snippet="nested-path",
message=CLAUDE_PLUGIN_EXCESSIVE_PATH_DEPTH_MESSAGE,
file=display_file,
)


def _archive_member_path_depth_hits(
archive_path: Path, extract_root: Path
) -> tuple[PluginHit, ...]:
"""Return one depth finding for the first too-deep in-root member.

Args:
archive_path: Candidate zip or tar file.
extract_root: Bounded destination used to skip zip-slip names.

Returns:
At most one hit. Traversal names stay the traversal class.
Members are not extracted.
"""
names, opened = _archive_member_names(archive_path)
if not opened:
return ()
display = _archive_display_path(archive_path, extract_root)
for name in names:
if _archive_member_escapes(name, extract_root):
continue
if _path_exceeds_max_depth(name):
return (_excessive_path_depth_hit(name, display),)
return ()


def _excessive_path_depth_hits(root: Path) -> tuple[PluginHit, ...]:
"""Return bounded depth findings for a materialized plugin tree.

Args:
root: Plugin scan root.

Returns:
One tree finding for the first too-deep regular file or symlink,
plus at most one finding per archive. Zip-slip members stay
traversal. Nested zip/tar members stay decompression-bomb.
"""
hits: list[PluginHit] = []
tree_hit = False
for path in _walk_entries(root):
try:
relative = path.relative_to(root).as_posix()
except (OSError, ValueError):
continue
if not tree_hit and _path_exceeds_max_depth(relative):
hits.append(_excessive_path_depth_hit(relative, relative))
tree_hit = True
continue
try:
is_archive = path.is_file() and not path.is_symlink() and _is_archive_path(path)
except OSError:
continue
if is_archive:
hits.extend(_archive_member_path_depth_hits(path, root))
return tuple(hits)


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, 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 |
| 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-excessive-path-depth` when a materialized file or archive member nests past 32 path components, `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 @@ -95,7 +95,9 @@ files being scanned, then applies the union of relevant checks. Examples:
`claude-plugin-checksum-mismatch` when a first-party checksum file
disagrees with artifact bytes on disk,
`claude-plugin-unsigned-checksum` when digest rows have no sibling
signature file, `claude-plugin-github-merge-command`
signature file, `claude-plugin-excessive-path-depth` when a
materialized file or archive member nests past 32 path components,
`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
Loading