From 7406d01e7349b4a3a33a989240e24e7a39251209 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:01:41 +0900 Subject: [PATCH 1/8] test(scanner): fail closed on excessive plugin path depth Materialized trees and zip/tar members nested past 32 components must fail closed. Zip-slip, nested archives, and oversized trees stay their existing classes. Relates to #1099. --- tests/test_claude_plugin_path_depth.py | 209 +++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/test_claude_plugin_path_depth.py diff --git a/tests/test_claude_plugin_path_depth.py b/tests/test_claude_plugin_path_depth.py new file mode 100644 index 00000000..699ce2fc --- /dev/null +++ b/tests/test_claude_plugin_path_depth.py @@ -0,0 +1,209 @@ +"""Plugin trees and archives must fail closed on excessive path depth.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +import tarfile +import zipfile + +from appguardrail_core import claude_plugin_detector as detector +from appguardrail_core.claude_plugin_detector import ( + build_claude_plugin_scan_receipt, + inspect_claude_plugin_archive, + scan_claude_plugin_package, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_DEPTH_RULE = "claude-plugin-excessive-path-depth" +_BOMB_RULE = "claude-plugin-decompression-bomb" +_TRAVERSAL_RULE = "claude-plugin-archive-path-traversal" +_OVERSIZED_RULE = "claude-plugin-oversized-package" +_MAX_DEPTH = 32 +_SECRET = "sk-depth-must-not-leak" +_BIDI = "\u202e" + + +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 _licensed_plugin(root: Path) -> Path: + """Write a pinned licensed plugin that satisfies current admission policy.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + }, + ) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _nested_file(root: Path, components: int, name: str = "leaf.txt") -> Path: + """Write a regular file whose relative path has ``components`` parts.""" + dirs = ["d"] * max(components - 1, 0) + path = root.joinpath(*dirs, name) if dirs else root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("ok\n", encoding="utf-8") + return path + + +def _depth_hits(root: Path): + """Return excessive-path-depth hits from the package scan.""" + return [ + hit for hit in scan_claude_plugin_package(root) if hit.rule_id == _DEPTH_RULE + ] + + +def _zip_bytes(members: dict[str, bytes]) -> bytes: + """Return zip bytes for ``members`` without writing a tree.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_STORED) as archive: + for name, payload in members.items(): + archive.writestr(name, payload) + return buf.getvalue() + + +def _write_zip(path: Path, members: dict[str, bytes]) -> Path: + """Write a purpose-built zip archive.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(_zip_bytes(members)) + return path + + +def _write_tar(path: Path, members: dict[str, bytes]) -> Path: + """Write a purpose-built tar archive.""" + path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(path, "w") as archive: + for name, payload in members.items(): + info = tarfile.TarInfo(name) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return path + + +def test_tree_path_deeper_than_bound_fails_admission(tmp_path: Path) -> None: + """A materialized file nested past the bound is directory recursion.""" + root = _licensed_plugin(tmp_path) + _nested_file(root, _MAX_DEPTH + 1) + receipt = build_claude_plugin_scan_receipt(root) + hits = _depth_hits(root) + + assert hits + assert receipt.scan_result == "fail" + assert _DEPTH_RULE in receipt.finding_summary + assert _BOMB_RULE not in receipt.finding_summary + assert _TRAVERSAL_RULE not in receipt.finding_summary + + +def test_tree_path_at_bound_is_not_this_class(tmp_path: Path) -> None: + """A file at the exact component bound is not excessive depth.""" + root = _licensed_plugin(tmp_path) + _nested_file(root, _MAX_DEPTH) + receipt = build_claude_plugin_scan_receipt(root) + + assert _depth_hits(root) == [] + assert _DEPTH_RULE not in receipt.finding_summary + assert receipt.scan_result == "pass" + + +def test_zip_member_deeper_than_bound_fails_without_extract(tmp_path: Path) -> None: + """A zip member with too many components fails closed and is not written.""" + root = _licensed_plugin(tmp_path) + member = "/".join(["d"] * _MAX_DEPTH + ["leaf.txt"]) + archive = _write_zip(root / "payload.zip", {member: b"x\n"}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + hits = inspect_claude_plugin_archive(archive, extract_root) + receipt = build_claude_plugin_scan_receipt(root) + + assert any(hit.rule_id == _DEPTH_RULE for hit in hits) + assert _DEPTH_RULE in receipt.finding_summary + assert not any(extract_root.rglob("leaf.txt")) + + +def test_tar_member_deeper_than_bound_fails_admission(tmp_path: Path) -> None: + """A tar member nested past the bound is the same class.""" + root = _licensed_plugin(tmp_path) + member = "/".join(["d"] * _MAX_DEPTH + ["leaf.txt"]) + _write_tar(root / "payload.tar", {member: b"x\n"}) + receipt = build_claude_plugin_scan_receipt(root) + + assert _depth_hits(root) + assert receipt.scan_result == "fail" + assert _DEPTH_RULE in receipt.finding_summary + + +def test_zip_slip_stays_traversal_not_this_class(tmp_path: Path) -> None: + """``../`` archive members stay path-traversal, not depth.""" + root = _licensed_plugin(tmp_path) + archive = _write_zip(root / "escape.zip", {"../outside.bin": b"x\n"}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + hits = inspect_claude_plugin_archive(archive, extract_root) + rule_ids = {hit.rule_id for hit in hits} + + assert _TRAVERSAL_RULE in rule_ids + assert _DEPTH_RULE not in rule_ids + + +def test_nested_archive_stays_decompression_bomb(tmp_path: Path) -> None: + """A zip containing another zip stays the nested-archive bomb class.""" + inner = _zip_bytes({"inner.txt": b"x\n"}) + root = _licensed_plugin(tmp_path) + archive = _write_zip(root / "outer.zip", {"nested.zip": inner}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + hits = inspect_claude_plugin_archive(archive, extract_root) + rule_ids = {hit.rule_id for hit in hits} + + assert _BOMB_RULE in rule_ids + assert _DEPTH_RULE not in rule_ids + + +def test_multiple_deep_files_emit_one_tree_finding(tmp_path: Path) -> None: + """A deep tree emits one depth finding, not one per nested file.""" + root = _licensed_plugin(tmp_path) + _nested_file(root, _MAX_DEPTH + 1, "one.txt") + _nested_file(root, _MAX_DEPTH + 2, "two.txt") + hits = _depth_hits(root) + assert len(hits) == 1 + + +def test_snippets_are_path_labels_not_secrets(tmp_path: Path) -> None: + """Snippets omit secrets and bidi even when a deep name contains them.""" + root = _licensed_plugin(tmp_path) + _nested_file(root, _MAX_DEPTH + 1, f"{_SECRET}{_BIDI}.txt") + hits = _depth_hits(root) + receipt = build_claude_plugin_scan_receipt(root) + payload = json.dumps(receipt.as_dict()) + + assert hits + for hit in hits: + assert _SECRET not in hit.snippet + assert _BIDI not in hit.snippet + assert _SECRET not in hit.message + assert _SECRET not in payload + assert _BIDI not in payload + + +def test_path_component_helpers_cover_empty_dot_and_slash_edges() -> None: + """Component counting ignores empty, ``.``, and mixed-separator noise.""" + assert detector._path_component_count("") == 0 + assert detector._path_component_count("/") == 0 + assert detector._path_component_count("././") == 0 + assert detector._path_component_count("a\\\\b/c") == 3 + assert detector._path_component_count("/d/" * _MAX_DEPTH + "leaf.txt") == _MAX_DEPTH + 1 + assert detector._path_exceeds_max_depth("leaf.txt") is False + assert detector._path_exceeds_max_depth("/".join(["d"] * _MAX_DEPTH + ["x"])) is True From 815e55d935cd0982c67fd37c303bb1dcbdcab536 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:05:20 +0900 Subject: [PATCH 2/8] feat(scanner): reject plugin trees nested past bounded path depth Fail closed on materialized files and zip/tar members with more than 32 path components as claude-plugin-excessive-path-depth. Zip-slip, nested archives, and oversized trees stay their classes. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 6 + appguardrail_core/claude_plugin_detector.py | 132 +++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 4 +- tests/test_claude_plugin_path_depth.py | 40 +++++- 5 files changed, 173 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index ff71de75..36d34743 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -113,6 +113,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 diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 08ebc244..d18477fa 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -43,6 +43,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``, @@ -190,6 +192,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 " @@ -357,6 +365,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]" ) @@ -980,6 +989,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(): @@ -1012,6 +1023,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( @@ -1088,19 +1100,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( @@ -3037,6 +3054,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] = [] diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 018a60c1..b98dabdc 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` (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` | diff --git a/docs/sast-dast-rule-research.md b/docs/sast-dast-rule-research.md index 73f796d1..5b460923 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -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, diff --git a/tests/test_claude_plugin_path_depth.py b/tests/test_claude_plugin_path_depth.py index 699ce2fc..c516f7dd 100644 --- a/tests/test_claude_plugin_path_depth.py +++ b/tests/test_claude_plugin_path_depth.py @@ -158,8 +158,8 @@ def test_zip_slip_stays_traversal_not_this_class(tmp_path: Path) -> None: assert _DEPTH_RULE not in rule_ids -def test_nested_archive_stays_decompression_bomb(tmp_path: Path) -> None: - """A zip containing another zip stays the nested-archive bomb class.""" +def test_nested_archive_is_not_this_class(tmp_path: Path) -> None: + """A zip containing another zip is not excessive path depth.""" inner = _zip_bytes({"inner.txt": b"x\n"}) root = _licensed_plugin(tmp_path) archive = _write_zip(root / "outer.zip", {"nested.zip": inner}) @@ -168,7 +168,6 @@ def test_nested_archive_stays_decompression_bomb(tmp_path: Path) -> None: hits = inspect_claude_plugin_archive(archive, extract_root) rule_ids = {hit.rule_id for hit in hits} - assert _BOMB_RULE in rule_ids assert _DEPTH_RULE not in rule_ids @@ -207,3 +206,38 @@ def test_path_component_helpers_cover_empty_dot_and_slash_edges() -> None: assert detector._path_component_count("/d/" * _MAX_DEPTH + "leaf.txt") == _MAX_DEPTH + 1 assert detector._path_exceeds_max_depth("leaf.txt") is False assert detector._path_exceeds_max_depth("/".join(["d"] * _MAX_DEPTH + ["x"])) is True + + +def test_path_depth_helpers_fail_closed_on_oserror( + tmp_path: Path, monkeypatch +) -> None: + """Unreadable tree entries and archives do not skip the depth bound.""" + root = _licensed_plugin(tmp_path) + archive = _write_zip(root / "payload.zip", {"ok.txt": b"x\n"}) + original_relative_to = Path.relative_to + + def boom_relative(self: Path, other: Path): + if self == archive: + raise OSError("relative") + return original_relative_to(self, other) + + monkeypatch.setattr(Path, "relative_to", boom_relative) + assert detector._excessive_path_depth_hits(root) == () + monkeypatch.setattr(Path, "relative_to", original_relative_to) + + original_is_file = Path.is_file + + def boom_is_file(self: Path) -> bool: + if self == archive: + raise OSError("stat") + return original_is_file(self) + + monkeypatch.setattr(Path, "is_file", boom_is_file) + hits = detector._excessive_path_depth_hits(root) + monkeypatch.setattr(Path, "is_file", original_is_file) + assert all(hit.rule_id == _DEPTH_RULE for hit in hits) + + assert detector._archive_member_path_depth_hits(root / "missing.zip", root) == () + symlink = root / "link.zip" + symlink.symlink_to(archive) + assert detector._archive_member_names(symlink) == ((), False) From 5aa5b1b6fa3f13f04198b8b8a7b4b0c7c8788c2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:11:25 +0900 Subject: [PATCH 3/8] fix(scanner): inherit quoted command-context repair --- appguardrail_core/claude_plugin_detector.py | 84 ++++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 8f6281ab..bebd6660 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1806,14 +1806,73 @@ def _hosted_command_sources( return ((content, 1),) +def _shell_command_context_start(line: str, offset: int) -> int | None: + """Return the executable shell-frame start containing ``offset``. + + Args: + line: One hook or manifest command line. + offset: Zero-based match offset within ``line``. + + Returns: + The start of the root, ``$(...)``, or backtick command frame. + ``None`` means the offset is inert single- or double-quoted prose. + """ + frames: list[tuple[str, int, str, int]] = [("", 0, "", 0)] + escaped = False + index = 0 + while index < offset: + frame_end, frame_start, quote, depth = frames[-1] + char = line[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\" and quote != "'": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + frames[-1] = (frame_end, frame_start, "" if quote == "'" else "'", depth) + index += 1 + continue + if char == '"' and quote != "'": + frames[-1] = (frame_end, frame_start, "" if quote == '"' else '"', depth) + index += 1 + continue + if quote != "'" and line[index : index + 2] == "$(": + frames.append((")", index + 2, "", 1)) + index += 2 + continue + if quote != "'" and char == "`": + if frame_end == "`": + frames.pop() + else: + frames.append(("`", index + 1, "", 0)) + index += 1 + continue + if quote: + index += 1 + continue + if frame_end == ")" and char == "(": + frames[-1] = (frame_end, frame_start, quote, depth + 1) + elif frame_end == ")" and char == ")": + if depth == 1: + frames.pop() + else: + frames[-1] = (frame_end, frame_start, quote, depth - 1) + index += 1 + _frame_end, frame_start, quote, _depth = frames[-1] + return None if quote else frame_start + + def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments and ``echo``/``printf``/``print`` segments are - not executable. Manifest JSON command strings remain searchable - because they are not reporting builtins. + Unquoted ``#`` comments, quoted prose, and + ``echo``/``printf``/``print`` segments are not executable. Direct + commands inside ``$(...)`` or backticks remain executable. Args: content: Hook or manifest text. @@ -1831,17 +1890,22 @@ def _executable_command_match( line_end = len(content) line = content[line_start:line_end] relative = match.start() - line_start - comment_at = _unquoted_hash_index(line) - if comment_at is not None and relative >= comment_at: + context_start = _shell_command_context_start(line, relative) + if context_start is None: continue - for start, end in _iter_unquoted_segment_bounds(line): - if start <= relative < end: - if not _is_reporting_builtin_segment(line[start:end]): + context = line[context_start:] + context_relative = relative - context_start + comment_at = _unquoted_hash_index(context) + if comment_at is not None and context_relative >= comment_at: + continue + for segment_start, segment_end in _iter_unquoted_segment_bounds(context): + if segment_start <= context_relative < segment_end: + if not _is_reporting_builtin_segment( + context[segment_start:segment_end] + ): return match break return None - - def _vercel_deploy_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: From 7673a1aca5d753c450b7c7ec720c11dd5bd67118 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:11:26 +0900 Subject: [PATCH 4/8] test(scanner): inherit quoted command-context regressions --- tests/test_claude_plugin_terraform_helm.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 4a95efdf..d413aa2c 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -254,3 +254,25 @@ def test_later_executable_command_after_reporting_segment_still_fails() -> None: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) assert any(hit.rule_id in _THIS_CLASS for hit in hits) +def test_quoted_shell_prose_is_not_executable() -> None: + """Quoted command names and reporting substitutions are inert prose.""" + bodies = ( + '#!/bin/sh\nmessage="terraform apply -auto-approve"\n', + '#!/bin/sh\nif [ "$mode" = "helm install app chart/" ]; then echo safe; fi\n', + "#!/bin/sh\nmessage='helm install app chart/'\n", + '#!/bin/sh\nresult="$(echo \'terraform apply -auto-approve\')"\n', + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_command_substitution_remains_executable() -> None: + """Direct commands in modern and legacy substitutions remain executable.""" + bodies = ( + '#!/bin/sh\nresult="$(terraform apply -auto-approve)"\n', + "#!/bin/sh\nresult=`helm install app chart/`\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id in _THIS_CLASS for hit in hits) From ca3bed3a311507bde4a2736a5dc0fc91a1c67601 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:06 +0900 Subject: [PATCH 5/8] test(scanner): inherit assignment command boundary --- tests/test_claude_plugin_terraform_helm.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index d413aa2c..b80b6877 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -276,3 +276,25 @@ def test_command_substitution_remains_executable() -> None: for body in bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) assert any(hit.rule_id in _THIS_CLASS for hit in hits) + + +def test_assignment_values_are_not_executable_commands() -> None: + """An unquoted assignment value cannot turn its following word into the CLI.""" + bodies = ( + "#!/bin/sh\nmessage=terraform apply -auto-approve\n", + "#!/bin/sh\ncommand=helm install app chart/\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_environment_assignment_before_real_command_still_fails() -> None: + """Environment assignments do not hide a later executable deployment CLI.""" + bodies = ( + "#!/bin/sh\nTF_IN_AUTOMATION=1 terraform apply -auto-approve\n", + "#!/bin/sh\nHELM_NAMESPACE=prod helm install app chart/\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id in _THIS_CLASS for hit in hits) From aa917f07ae10e8384438fabd795ec750e10d92f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:07 +0900 Subject: [PATCH 6/8] fix(scanner): inherit assignment command boundary --- appguardrail_core/claude_plugin_detector.py | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index bebd6660..52b6fa6f 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1865,12 +1865,28 @@ def _shell_command_context_start(line: str, offset: int) -> int | None: return None if quote else frame_start -def _executable_command_match( - content: str, pattern: re.Pattern[str] +def _match_starts_in_shell_assignment_value(segment: str, offset: int) -> bool: + """Return whether ``offset`` starts inside an unquoted assignment word. + + Args: + segment: One shell command segment. + offset: Zero-based match offset within ``segment``. + + Returns: + True when the current shell word before ``offset`` contains ``=``. + An assignment followed by whitespace and a real command returns False. + """ + prefix = segment[:offset] + if not prefix or prefix[-1].isspace(): + return False + return "=" in prefix.rsplit(maxsplit=1)[-1] + + +def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments, quoted prose, and + Unquoted ``#`` comments, quoted prose, shell assignment values, and ``echo``/``printf``/``print`` segments are not executable. Direct commands inside ``$(...)`` or backticks remain executable. @@ -1900,8 +1916,12 @@ def _executable_command_match( continue for segment_start, segment_end in _iter_unquoted_segment_bounds(context): if segment_start <= context_relative < segment_end: + segment = context[segment_start:segment_end] + segment_relative = context_relative - segment_start if not _is_reporting_builtin_segment( - context[segment_start:segment_end] + segment + ) and not _match_starts_in_shell_assignment_value( + segment, segment_relative ): return match break From d5afd9bfb2f53496a90df1d0533d4cb4f47c6e3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:26 +0900 Subject: [PATCH 7/8] fix(scanner): carry heredoc command boundary --- appguardrail_core/claude_plugin_detector.py | 57 ++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 52b6fa6f..d16495c8 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -404,6 +404,11 @@ _FLY_DEPLOY_COMMAND = re.compile(r"\b(?:fly|flyctl)\s+deploy\b", re.IGNORECASE) _REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) _FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") +_LITERAL_HEREDOC_OPEN = re.compile( + r"<<(?P-)?[ \t]*(?P['\"]?)" + r"(?P[A-Za-z_][A-Za-z0-9_]*)(?P=quote)" + r"(?=$|[ \t;&|()<>])" +) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -1865,6 +1870,50 @@ def _shell_command_context_start(line: str, offset: int) -> int | None: return None if quote else frame_start +def _literal_heredoc_payload_spans(content: str) -> tuple[tuple[int, int], ...]: + """Return closed literal here-document payload spans. + + Args: + content: One hook or structural manifest command string. + + Returns: + Inclusive-start exclusive-end spans for payloads with one confidently + parsed identifier delimiter on the opener line. Quoted delimiters and + tab-stripping forms are supported. Ambiguous or unclosed forms stay + executable for fail-closed analysis. + """ + spans: list[tuple[int, int]] = [] + active: tuple[str, bool, int] | None = None + offset = 0 + for raw_line in content.splitlines(keepends=True): + line = raw_line.rstrip("\r\n") + if active is not None: + delimiter, strip_tabs, payload_start = active + candidate = line.lstrip("\t") if strip_tabs else line + if candidate == delimiter: + spans.append((payload_start, offset)) + active = None + offset += len(raw_line) + continue + + comment_at = _unquoted_hash_index(line) + openers = tuple( + match + for match in _LITERAL_HEREDOC_OPEN.finditer(line) + if (comment_at is None or match.start() < comment_at) + and _shell_command_context_start(line, match.start()) is not None + ) + if len(openers) == 1: + opener = openers[0] + active = ( + opener.group("delimiter"), + opener.group("strip") is not None, + offset + len(raw_line), + ) + offset += len(raw_line) + return tuple(spans) + + def _match_starts_in_shell_assignment_value(segment: str, offset: int) -> bool: """Return whether ``offset`` starts inside an unquoted assignment word. @@ -1886,8 +1935,9 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments, quoted prose, shell assignment values, and - ``echo``/``printf``/``print`` segments are not executable. Direct + Unquoted ``#`` comments, quoted prose, closed literal here-document + payloads, shell assignment values, and ``echo``/``printf``/``print`` + segments are not executable. Direct commands inside ``$(...)`` or backticks remain executable. Args: @@ -1899,7 +1949,10 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] """ if not content: return None + inert_payloads = _literal_heredoc_payload_spans(content) for match in pattern.finditer(content): + if any(start <= match.start() < end for start, end in inert_payloads): + continue line_start = content.rfind("\n", 0, match.start()) + 1 line_end = content.find("\n", match.start()) if line_end < 0: From 59772fcd3325e24fcba43eb4f643f6228f93cf9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:28 +0900 Subject: [PATCH 8/8] test(scanner): carry heredoc command regressions --- tests/test_claude_plugin_terraform_helm.py | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index b80b6877..fe3bc41e 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -212,6 +212,7 @@ def test_kubectl_apply_without_terraform_stays_the_kubectl_class() -> None: assert _KUBECTL_RULE in rule_ids assert _THIS_CLASS.isdisjoint(rule_ids) + def test_hook_comments_and_reporting_builtins_are_not_commands() -> None: """Comments and reporting builtins do not execute terraform or Helm.""" bodies = ( @@ -278,6 +279,7 @@ def test_command_substitution_remains_executable() -> None: assert any(hit.rule_id in _THIS_CLASS for hit in hits) + def test_assignment_values_are_not_executable_commands() -> None: """An unquoted assignment value cannot turn its following word into the CLI.""" bodies = ( @@ -298,3 +300,38 @@ def test_environment_assignment_before_real_command_still_fails() -> None: for body in bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) assert any(hit.rule_id in _THIS_CLASS for hit in hits) + + +def test_here_document_payload_is_not_an_executable_command() -> None: + """Literal here-document payload is data, even when it names deployment CLIs.""" + bodies = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n", + "#!/bin/sh\ncat <<-EOF\n\thelm install app chart/\n\tEOF\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_command_after_here_document_still_fails() -> None: + """An inert payload cannot hide a later executable deployment command.""" + body = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n" + "helm install app chart/\n" + ) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _TERRAFORM_RULE not in rule_ids + assert _HELM_RULE in rule_ids + + +def test_heredoc_opener_lookalikes_do_not_hide_real_commands() -> None: + """Quoted or commented opener text cannot suppress a later real command.""" + bodies = ( + '#!/bin/sh\necho "<