From 6d1afd3c2640b9886c4de0e98d03149577441eec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 15:16:40 +0900 Subject: [PATCH 1/8] test(scanner): fail closed on plugin gem push and nuget push Lock executable gem push, nuget push, and dotnet nuget push. Keep gem list, nuget list, comments, echo lookalikes, and pnpm publish on their existing classes. Relates to #1099. --- tests/test_claude_plugin_gem_nuget.py | 197 ++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/test_claude_plugin_gem_nuget.py diff --git a/tests/test_claude_plugin_gem_nuget.py b/tests/test_claude_plugin_gem_nuget.py new file mode 100644 index 00000000..5f12f625 --- /dev/null +++ b/tests/test_claude_plugin_gem_nuget.py @@ -0,0 +1,197 @@ +"""Hook gem push and nuget push 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" +_GEM_RULE = "claude-plugin-gem-push-command" +_NUGET_RULE = "claude-plugin-nuget-push-command" +_PNPM_RULE = "claude-plugin-pnpm-publish-command" +_SECRET = "sk-gem-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_GEM_RULE, _NUGET_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_gem_push_fails_admission(tmp_path: Path) -> None: + """``gem push`` on a hook is RubyGems write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ngem push pkg/app-1.0.0.gem\n") + hits = _hits(root, _GEM_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "gem push" for hit in hits) + assert receipt.scan_result == "fail" + assert _GEM_RULE in receipt.finding_summary + assert _NUGET_RULE not in receipt.finding_summary + assert _PNPM_RULE not in receipt.finding_summary + assert inventory["package_install"] is True + + +def test_hook_nuget_push_fails_admission(tmp_path: Path) -> None: + """``nuget push`` on a hook is NuGet write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nnuget push app.nupkg -Source nuget.org\n") + hits = _hits(root, _NUGET_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "nuget push" for hit in hits) + assert receipt.scan_result == "fail" + assert _NUGET_RULE in receipt.finding_summary + assert _GEM_RULE not in receipt.finding_summary + + +def test_dotnet_nuget_push_is_the_same_class() -> None: + """``dotnet nuget push`` canonicalizes to the nuget-push command label.""" + body = "#!/bin/sh\ndotnet nuget push app.nupkg --source nuget.org\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any( + hit.rule_id == _NUGET_RULE and hit.snippet == "nuget push" for hit in hits + ) + + +def test_gem_list_and_nuget_list_stay_inventory(tmp_path: Path) -> None: + """Read-only gem/nuget list commands stay inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ngem list\nnuget 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_push_are_not_this_class(tmp_path: Path) -> None: + """Unquoted comments and echo lookalikes are not executable pushes.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\n# gem push app.gem\necho "nuget push app.nupkg"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_readme_gem_push_is_not_this_class(tmp_path: Path) -> None: + """README gem-push wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("gem push pkg/app-1.0.0.gem\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _GEM_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["package_install"] is True + + +def test_gem_and_nuget_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: + """One hook can fail closed on both gem push and nuget push.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\ngem push app.gem\nnuget push app.nupkg\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _GEM_RULE) + assert _hits(root, _NUGET_RULE) + assert receipt.scan_result == "fail" + assert _PNPM_RULE not in receipt.finding_summary + + +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\ngem push app.gem --key {_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + gem_hits = [hit for hit in hits if hit.rule_id == _GEM_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert gem_hits + for hit in gem_hits: + assert hit.snippet == "gem push" + 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_nuget_push_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that pushes nupkg is the nuget class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PostToolUse": [{"command": "nuget push app.nupkg -Source nuget.org"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _NUGET_RULE) + assert receipt.scan_result == "fail" + + +def test_pnpm_publish_without_gem_stays_the_pnpm_class() -> None: + """pnpm publish without gem/nuget stays the pnpm class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\npnpm publish --access public\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _PNPM_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) + + +def test_manifest_description_gem_prose_is_not_this_class(tmp_path: Path) -> None: + """Marketplace description prose about gem push 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 gem push against rubygems.org." + _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" From 798da4703bb81f91eed0380fe9603ad41ae620e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 15:16:40 +0900 Subject: [PATCH 2/8] feat(scanner): reject plugin gem push and nuget push Fail closed on executable gem push as claude-plugin-gem-push-command and nuget push as claude-plugin-nuget-push-command. Reads, comments, and echo lookalikes stay inventory. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 7 +- appguardrail_core/claude_plugin_detector.py | 82 ++++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 5 +- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index a4bbcbdc..ad949bdc 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -159,12 +159,15 @@ `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`. + `claude-plugin-npm-publish-command`. ``gem push`` fails as + `claude-plugin-gem-push-command`. ``nuget push`` and + ``dotnet nuget push`` fail as `claude-plugin-nuget-push-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``, and ``cargo check`` + ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, + and ``nuget 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 069e3741..d39956ea 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -33,11 +33,13 @@ ``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. +Hook or manifest ``gem push`` and ``nuget push`` fail closed as +RubyGems and NuGet registry writes. Unquoted ``#`` comments and ``echo``/``printf``/``print`` lookalikes are not that class. ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, ``az account show``, -``npm pack``, and ``cargo check`` +``npm pack``, ``cargo check``, ``gem list``, and ``nuget list`` stay inventory. Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth ``config.json``, cookie jars, and @@ -47,7 +49,7 @@ ``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, ``az account show``, -``npm pack``, and ``cargo check`` +``npm pack``, ``cargo check``, ``gem list``, and ``nuget list`` stay inventory. Skill homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule identities. Skill, command, or agent text that hides tool use, rewrites @@ -344,6 +346,16 @@ "distribution is write authority on PyPI. Remove the command. " "[CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_GEM_PUSH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs gem push. Publishing a gem is " + "write authority on RubyGems. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_NUGET_PUSH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs nuget push. Publishing a package " + "is write authority on NuGet. 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 " @@ -490,6 +502,11 @@ _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) +_GEM_PUSH_COMMAND = re.compile(r"\bgem\s+push\b", re.IGNORECASE) +_NUGET_PUSH_COMMAND = re.compile( + r"\b(?:dotnet\s+)?nuget\s+push\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( @@ -772,7 +789,8 @@ re.compile( r"\b(?:pip|npm|pnpm|yarn|uv|cargo|apt-get)\s+install\b|" r"\b(?:npm\s+publish|pnpm\s+publish|twine\s+upload|cargo\s+publish|" - r"uv\s+publish|poetry\s+publish)\b", + r"uv\s+publish|poetry\s+publish|gem\s+push|" + r"(?:dotnet\s+)?nuget\s+push)\b", re.IGNORECASE, ), ), @@ -998,6 +1016,8 @@ def inspect_claude_plugin_file( 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(_gem_push_command_hits(content, manifest=manifest)) + hits.extend(_nuget_push_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -2312,6 +2332,62 @@ def _poetry_publish_command_hits( return () +def _gem_push_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``gem push`` findings with a command label, not gem names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``gem push``. ``gem list``, comments, and + echo lookalikes are not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _GEM_PUSH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-gem-push-command", + line=first_line + source[: match.start()].count("\n"), + snippet="gem push", + message=CLAUDE_PLUGIN_GEM_PUSH_COMMAND_MESSAGE, + ), + ) + return () + + +def _nuget_push_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``nuget push`` 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 ``nuget push`` or ``dotnet nuget push``. + ``nuget list`` is not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _NUGET_PUSH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-nuget-push-command", + line=first_line + source[: match.start()].count("\n"), + snippet="nuget push", + message=CLAUDE_PLUGIN_NUGET_PUSH_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 8c552812..31d12e6c 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-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-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 5450f475..d7acc262 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -118,6 +118,8 @@ files being scanned, then applies the union of relevant checks. Examples: `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 ``gem push``, + `claude-plugin-nuget-push-command` for ``nuget push``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and @@ -126,7 +128,8 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-github-write-token`. ``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, - ``az account show``, ``npm pack``, and ``cargo check`` stay inventory. + ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, + and ``nuget 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 0ddb58fa2e95e7ddbaa4f6b4c5b2296e481296dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:12:27 +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 d39956ea..f41f9a4a 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1930,14 +1930,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. @@ -1955,17 +2014,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 ef1ebf33119704713c37b370f4e0cb397a50b8c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:12:31 +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 d5b3be15e3cfcd4169e5b26377cb3e6e59c1a3aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:38 +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 adde73bfd7ad4a6f02249f2c3bc4a7562d518a95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:39 +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 f41f9a4a..a0479196 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1989,12 +1989,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. @@ -2024,8 +2040,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 14286b72792238b51b5f4c9ca0e8c4d58ec94d03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:57 +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 a0479196..e48a4a0c 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -509,6 +509,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, @@ -1989,6 +1994,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. @@ -2010,8 +2059,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: @@ -2023,7 +2073,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 511b1d29dad4ef2f9e0d30c082623be6131245a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:58 +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 "<