From 503ff23a845824e59f864f6baad35f4847b029cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:30:10 +0900 Subject: [PATCH 1/6] test(scanner): fail closed on plugin hex publish and conda upload Hook and manifest hex publish, mix hex.publish, conda upload, and anaconda upload must fail closed. hex info, conda list, comments, echo lookalikes, and README wording stay inventory. Relates to #1099. --- tests/test_claude_plugin_hex_conda.py | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/test_claude_plugin_hex_conda.py diff --git a/tests/test_claude_plugin_hex_conda.py b/tests/test_claude_plugin_hex_conda.py new file mode 100644 index 00000000..dd437a91 --- /dev/null +++ b/tests/test_claude_plugin_hex_conda.py @@ -0,0 +1,220 @@ +"""Hook hex publish and conda upload fail closed; list stays inventory.""" + +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" +_HEX_RULE = "claude-plugin-hex-publish-command" +_CONDA_RULE = "claude-plugin-conda-upload-command" +_PUB_RULE = "claude-plugin-pub-publish-command" +_SECRET = "sk-hex-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_HEX_RULE, _CONDA_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_hex_publish_fails_admission(tmp_path: Path) -> None: + """``hex publish`` on a hook is Hex.pm write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nhex publish --yes\n") + hits = _hits(root, _HEX_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "hex publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _HEX_RULE in receipt.finding_summary + assert _CONDA_RULE not in receipt.finding_summary + assert _PUB_RULE not in receipt.finding_summary + assert inventory["package_install"] is True + + +def test_mix_hex_publish_is_the_same_class() -> None: + """``mix hex.publish`` is the Mix spelling of the Hex.pm class.""" + body = "#!/bin/sh\nmix hex.publish --yes\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any( + hit.rule_id == _HEX_RULE and hit.snippet == "mix hex.publish" for hit in hits + ) + + +def test_hook_conda_upload_fails_admission(tmp_path: Path) -> None: + """``conda upload`` on a hook is Anaconda.org write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nconda upload dist/app-1.0.0.tar.bz2\n") + hits = _hits(root, _CONDA_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "conda upload" for hit in hits) + assert receipt.scan_result == "fail" + assert _CONDA_RULE in receipt.finding_summary + assert _HEX_RULE not in receipt.finding_summary + + +def test_anaconda_upload_is_the_same_class() -> None: + """``anaconda upload`` canonicalizes to the conda-upload command class.""" + body = "#!/bin/sh\nanaconda upload dist/app-1.0.0.tar.bz2\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any( + hit.rule_id == _CONDA_RULE and hit.snippet == "anaconda upload" for hit in hits + ) + + +def test_hex_info_and_conda_list_stay_inventory(tmp_path: Path) -> None: + """Read-only hex/conda listing stays inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nhex info phoenix\nconda list\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _HEX_RULE) == [] + assert _hits(root, _CONDA_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_hex_and_conda_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: + """One hook can fail closed on both hex publish and conda upload.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nhex publish --yes\nconda upload dist/app-1.0.0.tar.bz2\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _HEX_RULE) + assert _hits(root, _CONDA_RULE) + assert receipt.scan_result == "fail" + assert _PUB_RULE not in receipt.finding_summary + + +def test_pub_publish_stays_the_pub_class() -> None: + """``dart pub publish`` remains the pub.dev class, not Hex.pm.""" + body = "#!/bin/sh\ndart pub publish --force\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _PUB_RULE in rule_ids + assert _HEX_RULE not in rule_ids + assert _CONDA_RULE not in rule_ids + + +def test_comment_and_echo_hex_conda_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# hex publish\necho "conda upload dist/app.tar.bz2"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _HEX_RULE) == [] + assert _hits(root, _CONDA_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_readme_hex_conda_is_not_this_class(tmp_path: Path) -> None: + """README hex/conda wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("hex publish --yes\nconda upload app.tar.bz2\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _HEX_RULE) == [] + assert _hits(root, _CONDA_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["package_install"] is True + + +def test_echo_then_real_hex_publish_still_fails() -> None: + """``echo done && hex publish`` still runs the registry write.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "done" && hex publish --yes\n', + ) + assert any( + hit.rule_id == _HEX_RULE and hit.snippet == "hex 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\nhex publish --key {_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + hex_hits = [hit for hit in hits if hit.rule_id == _HEX_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert hex_hits + for hit in hex_hits: + assert hit.snippet == "hex 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_conda_upload_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that uploads to Anaconda.org 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": "conda upload dist/app-1.0.0.tar.bz2"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _CONDA_RULE) + assert receipt.scan_result == "fail" + + +def test_manifest_prose_is_not_this_class(tmp_path: Path) -> None: + """Marketplace description prose about hex 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 hex publish or conda upload." + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _HEX_RULE) == [] + assert _hits(root, _CONDA_RULE) == [] + assert receipt.scan_result == "pass" From 512710e793884e7aa17456e29df63c6eb02462ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:33:33 +0900 Subject: [PATCH 2/6] feat(scanner): reject plugin hex publish and conda upload Fail closed on executable hex publish, mix hex.publish, conda upload, and anaconda upload. hex info and conda list stay inventory. dart pub publish stays the pub.dev class. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 7 +- appguardrail_core/claude_plugin_detector.py | 84 ++++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 4 +- 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 996f5744..38811263 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -163,13 +163,16 @@ `claude-plugin-gem-push-command`. ``nuget push`` and ``dotnet nuget push`` fail as `claude-plugin-nuget-push-command`. ``dart pub publish``, ``flutter pub publish``, and ``pub publish`` - fail as `claude-plugin-pub-publish-command`. + fail as `claude-plugin-pub-publish-command`. ``hex publish`` and + ``mix hex.publish`` fail as `claude-plugin-hex-publish-command`. + ``conda upload`` and ``anaconda upload`` fail as + `claude-plugin-conda-upload-command`. Hook comments and ``echo``/``printf`` lookalikes are not those classes. ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, - and ``nuget list`` + ``nuget list``, ``hex info``, and ``conda list`` stay inventory. Hardcoded PATs stay `claude-plugin-github-write-token`. Snippets are command labels, not tokens. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 783fca57..447c31ed 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -363,6 +363,16 @@ "package is write authority on pub.dev. Remove the command. " "[CWE-269 - Improper Privilege Management]" ) +CLAUDE_PLUGIN_HEX_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs hex publish. Publishing a " + "package is write authority on Hex.pm. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_CONDA_UPLOAD_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs conda upload. Publishing a " + "package is write authority on Anaconda.org. 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 " @@ -518,6 +528,14 @@ r"\b(?:(?Pdart|flutter)\s+)?pub\s+publish\b", re.IGNORECASE, ) +_HEX_PUBLISH_COMMAND = re.compile( + r"\b(?:mix\s+hex\.publish|hex\s+publish)\b", + re.IGNORECASE, +) +_CONDA_UPLOAD_COMMAND = re.compile( + r"\b(?Pconda|anaconda)\s+upload\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( @@ -802,7 +820,9 @@ r"\b(?:npm\s+publish|pnpm\s+publish|twine\s+upload|cargo\s+publish|" r"uv\s+publish|poetry\s+publish|gem\s+push|" r"(?:dotnet\s+)?nuget\s+push|" - r"(?:dart\s+|flutter\s+)?pub\s+publish)\b", + r"(?:dart\s+|flutter\s+)?pub\s+publish|" + r"mix\s+hex\.publish|hex\s+publish|" + r"(?:conda|anaconda)\s+upload)\b", re.IGNORECASE, ), ), @@ -1031,6 +1051,8 @@ def inspect_claude_plugin_file( hits.extend(_gem_push_command_hits(content, manifest=manifest)) hits.extend(_nuget_push_command_hits(content, manifest=manifest)) hits.extend(_pub_publish_command_hits(content, manifest=manifest)) + hits.extend(_hex_publish_command_hits(content, manifest=manifest)) + hits.extend(_conda_upload_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -2499,6 +2521,66 @@ def _pub_publish_command_hits( return () +def _hex_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``hex publish`` findings with a command label, not package names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``hex publish`` or ``mix hex.publish``. + ``hex info`` is not this class. ``dart pub publish`` stays the + pub.dev class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _HEX_PUBLISH_COMMAND) + if match is None: + continue + token = match.group(0).lower() + snippet = "mix hex.publish" if token.startswith("mix ") else "hex publish" + return ( + PluginHit( + rule_id="claude-plugin-hex-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet=snippet, + message=CLAUDE_PLUGIN_HEX_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + +def _conda_upload_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``conda upload`` findings with a command label, not package names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``conda upload`` or ``anaconda upload``. + ``conda list`` is not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _CONDA_UPLOAD_COMMAND) + if match is None: + continue + snippet = match.group("cli").lower() + " upload" + return ( + PluginHit( + rule_id="claude-plugin-conda-upload-command", + line=first_line + source[: match.start()].count("\n"), + snippet=snippet, + message=CLAUDE_PLUGIN_CONDA_UPLOAD_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 30eab260..2ab55c2c 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-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `claude-plugin-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub 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-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub publish`, `claude-plugin-hex-publish-command` for `hex publish`/`mix hex.publish`, `claude-plugin-conda-upload-command` for `conda upload`/`anaconda upload`, `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 2f616386..a36a3729 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -121,6 +121,8 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-gem-push-command` for ``gem push``, `claude-plugin-nuget-push-command` for ``nuget push``, `claude-plugin-pub-publish-command` for ``dart pub publish``, + `claude-plugin-hex-publish-command` for ``hex publish``, + `claude-plugin-conda-upload-command` for ``conda upload``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and @@ -130,7 +132,7 @@ files being scanned, then applies the union of relevant checks. Examples: ``kubectl get``, ``docker ps``, ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, - and ``nuget list`` stay inventory. + ``nuget list``, ``hex info``, and ``conda list`` stay inventory. - Mapped, not owned here: GitHub Actions transport-only poll loops (#1087, PR #1088) and orphaned workflow registry DAST (#929, PR #966). - `tool-execute-parameters-passthrough`: Strix-observed dynamic tool execution From e90a1c23baffbb41bb281270b38c311e36c0a6fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:42:03 +0900 Subject: [PATCH 3/6] 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 321d8541f7183617a465150fa6aeb63436c42647 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:42:05 +0900 Subject: [PATCH 4/6] 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 447c31ed..677a5231 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -2024,12 +2024,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. @@ -2059,8 +2075,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 d544735ae2408e9c89496e33e8ba1d89839ee7b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:15:09 +0900 Subject: [PATCH 5/6] 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 677a5231..7c1ba18e 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -538,6 +538,11 @@ ) _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, @@ -2024,6 +2029,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. @@ -2045,8 +2094,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: @@ -2058,7 +2108,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 546668cf5f017e545405126d9e11f78b2aa5f254 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:15:10 +0900 Subject: [PATCH 6/6] 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 "<