From f73451250e4577ec8ad94c895ee08d630ed4c5ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 15:02:23 +0900 Subject: [PATCH 1/8] test(scanner): fail closed on plugin pnpm uv and poetry publish Hook and manifest pnpm publish, uv publish, and poetry publish must fail closed. yarn npm publish stays the npm class. Comments, echo lookalikes, and README wording stay inventory. Relates to #1099. --- tests/test_claude_plugin_alt_publish.py | 203 ++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/test_claude_plugin_alt_publish.py diff --git a/tests/test_claude_plugin_alt_publish.py b/tests/test_claude_plugin_alt_publish.py new file mode 100644 index 00000000..4cd8d2ed --- /dev/null +++ b/tests/test_claude_plugin_alt_publish.py @@ -0,0 +1,203 @@ +"""Hook pnpm, uv, and poetry publish fail closed; yarn npm stays npm.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + _collect_plugin_hits, + build_claude_plugin_scan_receipt, + inspect_claude_plugin_file, + inventory_claude_plugin_capabilities, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_PNPM_RULE = "claude-plugin-pnpm-publish-command" +_UV_RULE = "claude-plugin-uv-publish-command" +_POETRY_RULE = "claude-plugin-poetry-publish-command" +_NPM_RULE = "claude-plugin-npm-publish-command" +_PYPI_RULE = "claude-plugin-pypi-upload-command" +_SECRET = "sk-alt-publish-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_PNPM_RULE, _UV_RULE, _POETRY_RULE}) + + +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, hook_body: str = "#!/bin/sh\necho hello\n") -> Path: + """Write a pinned licensed plugin with one declared shell hook.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + "hooks": {"PreToolUse": [{"command": "hooks/session.sh"}]}, + }, + ) + hook = root / "hooks" / "session.sh" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text(hook_body, encoding="utf-8") + hook.chmod(0o755) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _hits(root: Path, rule_id: str): + """Return receipt-path hits for one rule identity.""" + return [hit for hit in _collect_plugin_hits(root) if hit.rule_id == rule_id] + + +def test_hook_pnpm_publish_fails_admission(tmp_path: Path) -> None: + """``pnpm publish`` on a hook is registry write authority, not npm.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\npnpm publish --access public\n") + hits = _hits(root, _PNPM_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "pnpm publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _PNPM_RULE in receipt.finding_summary + assert _NPM_RULE not in receipt.finding_summary + assert inventory["package_install"] is True + + +def test_hook_uv_publish_fails_admission() -> None: + """``uv publish`` is a PyPI write, not twine upload.""" + body = "#!/bin/sh\nuv publish --token dummy\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _UV_RULE and hit.snippet == "uv publish" for hit in hits) + assert all(hit.rule_id != _PYPI_RULE for hit in hits) + + +def test_hook_poetry_publish_fails_admission(tmp_path: Path) -> None: + """``poetry publish`` on a hook is registry write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\npoetry publish --build\n") + hits = _hits(root, _POETRY_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "poetry publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _POETRY_RULE in receipt.finding_summary + assert _PYPI_RULE not in receipt.finding_summary + + +def test_yarn_npm_publish_stays_the_npm_class() -> None: + """``yarn npm publish`` remains the npm-publish class, not pnpm.""" + body = "#!/bin/sh\nyarn npm publish\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _NPM_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) + + +def test_pnpm_list_stays_inventory(tmp_path: Path) -> None: + """``pnpm list`` stays inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\npnpm list\nuv pip list\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_comment_and_echo_alt_publish_are_not_this_class(tmp_path: Path) -> None: + """Unquoted comments and echo lookalikes are not executable publishes.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\n# pnpm publish\necho "poetry publish"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_readme_pnpm_publish_is_not_this_class(tmp_path: Path) -> None: + """README publish wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("pnpm publish --access public\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _PNPM_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["package_install"] is True + + +def test_echo_then_real_uv_publish_still_fails() -> None: + """``echo done && uv publish`` still runs the registry write.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "done" && uv publish\n', + ) + assert any(hit.rule_id == _UV_RULE and hit.snippet == "uv publish" for hit in hits) + + +def test_snippets_are_command_labels_not_secrets(tmp_path: Path) -> None: + """Snippets name the CLI command and omit secrets and bidi.""" + body = f"#!/bin/sh\npnpm publish --otp {_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + pnpm_hits = [hit for hit in hits if hit.rule_id == _PNPM_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert pnpm_hits + for hit in pnpm_hits: + assert hit.snippet == "pnpm publish" + 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_plugin_manifest_poetry_publish_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that publishes with poetry is that class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], + "PostToolUse": [{"command": "poetry publish --build"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _POETRY_RULE) + assert receipt.scan_result == "fail" + + +def test_manifest_prose_is_not_this_class(tmp_path: Path) -> None: + """Marketplace description prose about pnpm publish is not a command.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["description"] = "Never runs pnpm publish against the public registry." + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_npm_publish_stays_the_npm_class() -> None: + """Bare ``npm publish`` stays the npm class, not pnpm.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\nnpm publish --access public\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _NPM_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) From e933579f2b248ed43e5165824a7fe642025a1c5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 15:07:01 +0900 Subject: [PATCH 2/8] feat(scanner): reject plugin pnpm uv and poetry publish Fail closed on executable pnpm publish, uv publish, and poetry publish. yarn npm publish stays the npm class. npm pack and comments stay inventory. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 6 +- appguardrail_core/claude_plugin_detector.py | 111 +++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 3 + tests/test_claude_plugin_terraform_helm.py | 10 +- 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index e95dbab8..a4bbcbdc 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -155,7 +155,11 @@ `claude-plugin-az-containerapp-up-command`. ``npm publish`` fails as `claude-plugin-npm-publish-command`. ``twine upload`` fails as `claude-plugin-pypi-upload-command`. ``cargo publish`` fails as - `claude-plugin-cargo-publish-command`. + `claude-plugin-cargo-publish-command`. ``pnpm publish`` fails as + `claude-plugin-pnpm-publish-command`. ``uv publish`` fails as + `claude-plugin-uv-publish-command`. ``poetry publish`` fails as + `claude-plugin-poetry-publish-command`. ``yarn npm publish`` stays + `claude-plugin-npm-publish-command`. Hook comments and ``echo``/``printf`` lookalikes are not those classes. ``terraform plan``, ``helm list``, diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 0d3ee5e1..069e3741 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -31,6 +31,8 @@ ``az containerapp up`` fail closed as object-store and Container Apps writes. Hook or manifest ``npm publish``, ``twine upload``, and ``cargo publish`` fail closed as registry-publish command findings. +Hook or manifest ``pnpm publish``, ``uv publish``, and +``poetry publish`` fail closed as alternate-manager registry writes. Unquoted ``#`` comments and ``echo``/``printf``/``print`` lookalikes are not that class. ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, @@ -327,6 +329,21 @@ "is write authority on crates.io. Remove the command. " "[CWE-269 - Improper Privilege Management]" ) +CLAUDE_PLUGIN_PNPM_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs pnpm publish. Publishing a " + "package is write authority on the npm registry. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_UV_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs uv publish. Publishing a " + "distribution is write authority on PyPI. Remove the command. " + "[CWE-250 - Execution with Unnecessary Privileges]" +) +CLAUDE_PLUGIN_POETRY_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs poetry publish. Publishing a " + "distribution is write authority on PyPI. Remove the command. " + "[CWE-250 - Execution with Unnecessary Privileges]" +) CLAUDE_PLUGIN_DOCKER_SOCKET_MESSAGE: Final = ( "Claude plugin hook reaches the host Docker socket. Socket access is host " "control, not an image push. Remove the socket bind and keep builds " @@ -470,6 +487,9 @@ _NPM_PUBLISH_COMMAND = re.compile(r"\bnpm\s+publish\b", re.IGNORECASE) _PYPI_UPLOAD_COMMAND = re.compile(r"\btwine\s+upload\b", re.IGNORECASE) _CARGO_PUBLISH_COMMAND = re.compile(r"\bcargo\s+publish\b", re.IGNORECASE) +_PNPM_PUBLISH_COMMAND = re.compile(r"\bpnpm\s+publish\b", re.IGNORECASE) +_UV_PUBLISH_COMMAND = re.compile(r"\buv\s+publish\b", re.IGNORECASE) +_POETRY_PUBLISH_COMMAND = re.compile(r"\bpoetry\s+publish\b", re.IGNORECASE) _REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) _FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") _DOCKER_SOCKET = re.compile( @@ -751,7 +771,8 @@ "package_install", re.compile( r"\b(?:pip|npm|pnpm|yarn|uv|cargo|apt-get)\s+install\b|" - r"\b(?:npm\s+publish|twine\s+upload|cargo\s+publish)\b", + r"\b(?:npm\s+publish|pnpm\s+publish|twine\s+upload|cargo\s+publish|" + r"uv\s+publish|poetry\s+publish)\b", re.IGNORECASE, ), ), @@ -974,6 +995,9 @@ def inspect_claude_plugin_file( hits.extend(_npm_publish_command_hits(content, manifest=manifest)) hits.extend(_pypi_upload_command_hits(content, manifest=manifest)) hits.extend(_cargo_publish_command_hits(content, manifest=manifest)) + hits.extend(_pnpm_publish_command_hits(content, manifest=manifest)) + hits.extend(_uv_publish_command_hits(content, manifest=manifest)) + hits.extend(_poetry_publish_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -2203,6 +2227,91 @@ def _cargo_publish_command_hits( return () +def _pnpm_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``pnpm publish`` findings with a command label, not tokens. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``pnpm publish``. ``npm publish`` and + ``yarn npm publish`` stay the npm class. ``pnpm list`` is not + this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _PNPM_PUBLISH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-pnpm-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet="pnpm publish", + message=CLAUDE_PLUGIN_PNPM_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + +def _uv_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``uv publish`` findings with a command label, not tokens. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``uv publish``. ``twine upload`` stays + the PyPI class. ``uv pip list`` is not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _UV_PUBLISH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-uv-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet="uv publish", + message=CLAUDE_PLUGIN_UV_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + +def _poetry_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``poetry publish`` findings with a command label, not names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``poetry publish``. ``twine upload`` stays + the PyPI class. Comments and echo lookalikes are not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _POETRY_PUBLISH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-poetry-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet="poetry publish", + message=CLAUDE_PLUGIN_POETRY_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: """Return findings for eval/exec/compile/Function on hook surfaces.""" match = _DYNAMIC_EVAL.search(content) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4b704f6a..8c552812 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-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-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-aws-s3-write-command` for hook or manifest `aws s3 sync`/`cp`, `claude-plugin-az-containerapp-up-command` for `az containerapp up`, `claude-plugin-npm-publish-command` for hook or manifest `npm publish`, `claude-plugin-pypi-upload-command` for `twine upload`, `claude-plugin-cargo-publish-command` for `cargo publish`, `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-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-aws-s3-write-command` for hook or manifest `aws s3 sync`/`cp`, `claude-plugin-az-containerapp-up-command` for `az containerapp up`, `claude-plugin-npm-publish-command` for hook or manifest `npm publish`, `claude-plugin-pypi-upload-command` for `twine upload`, `claude-plugin-cargo-publish-command` for `cargo publish`, `claude-plugin-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `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 e6dba772..5450f475 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -115,6 +115,9 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-npm-publish-command` for ``npm publish``, `claude-plugin-pypi-upload-command` for ``twine upload``, `claude-plugin-cargo-publish-command` for ``cargo publish``, + `claude-plugin-pnpm-publish-command` for ``pnpm publish``, + `claude-plugin-uv-publish-command` for ``uv publish``, + `claude-plugin-poetry-publish-command` for ``poetry publish``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 4a95efdf..cb572786 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -18,6 +18,8 @@ _HELM_RULE = "claude-plugin-helm-install-command" _KUBECTL_RULE = "claude-plugin-kubectl-apply-command" _DOCKER_PUSH_RULE = "claude-plugin-docker-push-command" +_VERCEL_RULE = "claude-plugin-vercel-deploy-command" +_FLY_RULE = "claude-plugin-fly-deploy-command" _SECRET = "sk-tf-must-not-leak" _BIDI = "\u202e" _THIS_CLASS = frozenset({_TERRAFORM_RULE, _HELM_RULE}) @@ -100,8 +102,8 @@ def test_terraform_plan_and_helm_list_stay_inventory(tmp_path: Path) -> None: assert receipt.scan_result == "pass" -def test_vercel_deploy_and_fly_deploy_stay_inventory(tmp_path: Path) -> None: - """Hosted deploy CLIs stay inventory; this slice does not own them.""" +def test_vercel_deploy_and_fly_deploy_are_not_this_class(tmp_path: Path) -> None: + """Hosted deploy CLIs stay later successor classes, not terraform/helm.""" root = _licensed_plugin( tmp_path, "#!/bin/sh\nvercel deploy\nfly deploy\n", @@ -110,7 +112,9 @@ def test_vercel_deploy_and_fly_deploy_stay_inventory(tmp_path: Path) -> None: inventory = inventory_claude_plugin_capabilities(root) assert _THIS_CLASS.isdisjoint(receipt.finding_summary) - assert receipt.scan_result == "pass" + assert _VERCEL_RULE in receipt.finding_summary + assert _FLY_RULE in receipt.finding_summary + assert receipt.scan_result == "fail" assert inventory["deployment_write"] is True From 49c96bc14dfd7a9d4d42b485df1b24c875a17d42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:12:21 +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 069e3741..5c9e68a0 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1910,14 +1910,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. @@ -1935,17 +1994,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 0d3aa12b2c6d70a030b815ea6fc513ddf7fb71e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:12:22 +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 cb572786..e4062cf6 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -258,3 +258,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 45ae5b54323f99f94323d39ce5149c2b2abe1a00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:32 +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 e4062cf6..22ec52d0 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -280,3 +280,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 c8b6da22fa9390a34cd0729f2389490d9a6237be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:33 +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 5c9e68a0..7a9629ef 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1969,12 +1969,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. @@ -2004,8 +2020,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 df8aa5eb8c611597475be3e150d27407f28d6c32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:51 +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 7a9629ef..e2f1a017 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -492,6 +492,11 @@ _POETRY_PUBLISH_COMMAND = re.compile(r"\bpoetry\s+publish\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, @@ -1969,6 +1974,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. @@ -1990,8 +2039,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: @@ -2003,7 +2053,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 d784672aed5ea97ba77ee77d446ada2aa3794b98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:53 +0900 Subject: [PATCH 8/8] test(scanner): carry heredoc command regressions --- tests/test_claude_plugin_terraform_helm.py | 47 ++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 22ec52d0..fe3bc41e 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -18,8 +18,6 @@ _HELM_RULE = "claude-plugin-helm-install-command" _KUBECTL_RULE = "claude-plugin-kubectl-apply-command" _DOCKER_PUSH_RULE = "claude-plugin-docker-push-command" -_VERCEL_RULE = "claude-plugin-vercel-deploy-command" -_FLY_RULE = "claude-plugin-fly-deploy-command" _SECRET = "sk-tf-must-not-leak" _BIDI = "\u202e" _THIS_CLASS = frozenset({_TERRAFORM_RULE, _HELM_RULE}) @@ -102,8 +100,8 @@ def test_terraform_plan_and_helm_list_stay_inventory(tmp_path: Path) -> None: assert receipt.scan_result == "pass" -def test_vercel_deploy_and_fly_deploy_are_not_this_class(tmp_path: Path) -> None: - """Hosted deploy CLIs stay later successor classes, not terraform/helm.""" +def test_vercel_deploy_and_fly_deploy_stay_inventory(tmp_path: Path) -> None: + """Hosted deploy CLIs stay inventory; this slice does not own them.""" root = _licensed_plugin( tmp_path, "#!/bin/sh\nvercel deploy\nfly deploy\n", @@ -112,9 +110,7 @@ def test_vercel_deploy_and_fly_deploy_are_not_this_class(tmp_path: Path) -> None inventory = inventory_claude_plugin_capabilities(root) assert _THIS_CLASS.isdisjoint(receipt.finding_summary) - assert _VERCEL_RULE in receipt.finding_summary - assert _FLY_RULE in receipt.finding_summary - assert receipt.scan_result == "fail" + assert receipt.scan_result == "pass" assert inventory["deployment_write"] is True @@ -216,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 = ( @@ -282,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 = ( @@ -302,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 "<