From 4351d47a8b0cd6fbc6de67d198ec1c3aa1f6a94f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:01:30 +0900 Subject: [PATCH 1/6] test(scanner): fail closed on plugin dart and flutter pub publish Hook and manifest dart pub publish, flutter pub publish, and legacy pub publish must fail closed. pub get, comments, echo lookalikes, and README wording stay inventory. Relates to #1099. --- tests/test_claude_plugin_pub_publish.py | 186 ++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/test_claude_plugin_pub_publish.py diff --git a/tests/test_claude_plugin_pub_publish.py b/tests/test_claude_plugin_pub_publish.py new file mode 100644 index 00000000..a3c12cd2 --- /dev/null +++ b/tests/test_claude_plugin_pub_publish.py @@ -0,0 +1,186 @@ +"""Hook dart/flutter pub publish fail closed; pub get 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" +_PUB_RULE = "claude-plugin-pub-publish-command" +_GEM_RULE = "claude-plugin-gem-push-command" +_PNPM_RULE = "claude-plugin-pnpm-publish-command" +_SECRET = "sk-pub-must-not-leak" +_BIDI = "\u202e" + + +def _write_json(path: Path, payload: dict) -> None: + """Write one JSON document under ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _licensed_plugin(root: Path, 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_dart_pub_publish_fails_admission(tmp_path: Path) -> None: + """``dart pub publish`` on a hook is pub.dev write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndart pub publish --force\n") + hits = _hits(root, _PUB_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "dart pub publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _PUB_RULE in receipt.finding_summary + assert _GEM_RULE not in receipt.finding_summary + assert _PNPM_RULE not in receipt.finding_summary + assert inventory["package_install"] is True + + +def test_hook_flutter_pub_publish_fails_admission() -> None: + """``flutter pub publish`` is the same pub.dev class.""" + body = "#!/bin/sh\nflutter pub publish\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any( + hit.rule_id == _PUB_RULE and hit.snippet == "flutter pub publish" for hit in hits + ) + + +def test_legacy_pub_publish_fails_admission() -> None: + """Bare ``pub publish`` is the Dart SDK spelling of the same class.""" + body = "#!/bin/sh\npub publish --force\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _PUB_RULE and hit.snippet == "pub publish" for hit in hits) + + +def test_pub_get_stays_inventory(tmp_path: Path) -> None: + """``dart pub get`` stays inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndart pub get\nflutter pub get\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _PUB_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_pnpm_publish_stays_the_pnpm_class() -> None: + """``pnpm publish`` remains the pnpm class, not pub.dev.""" + body = "#!/bin/sh\npnpm publish --access public\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _PNPM_RULE in rule_ids + assert _PUB_RULE not in rule_ids + + +def test_comment_and_echo_pub_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# dart pub publish\necho "flutter pub publish"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _PUB_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_readme_pub_publish_is_not_this_class(tmp_path: Path) -> None: + """README pub wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("dart pub publish --force\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _PUB_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["package_install"] is True + + +def test_echo_then_real_pub_publish_still_fails() -> None: + """``echo done && dart pub publish`` still runs the registry write.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "done" && dart pub publish\n', + ) + assert any( + hit.rule_id == _PUB_RULE and hit.snippet == "dart pub 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\ndart pub publish --token {_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + pub_hits = [hit for hit in hits if hit.rule_id == _PUB_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert pub_hits + for hit in pub_hits: + assert hit.snippet == "dart pub 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_flutter_pub_publish_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that publishes to pub.dev 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": "flutter pub publish"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _PUB_RULE) + assert receipt.scan_result == "fail" + + +def test_manifest_prose_is_not_this_class(tmp_path: Path) -> None: + """Marketplace description prose about pub 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 dart pub publish against pub.dev." + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _PUB_RULE) == [] + assert receipt.scan_result == "pass" From 09b6dcec649870228deec8ca306fcace78944371 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:17:42 +0900 Subject: [PATCH 2/6] feat(scanner): reject plugin dart and flutter pub publish Fail closed on executable dart pub publish, flutter pub publish, and legacy pub publish. dart pub get and comments stay inventory. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 2 + appguardrail_core/claude_plugin_detector.py | 51 ++++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 1 + 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index ad949bdc..996f5744 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -162,6 +162,8 @@ `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`. + ``dart pub publish``, ``flutter pub publish``, and ``pub publish`` + fail as `claude-plugin-pub-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 d39956ea..7182aa4a 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -34,7 +34,9 @@ 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. +RubyGems and NuGet registry writes. Hook or manifest ``dart pub +publish``, ``flutter pub publish``, and ``pub publish`` fail closed +as pub.dev registry writes. Unquoted ``#`` comments and ``echo``/``printf``/``print`` lookalikes are not that class. ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, @@ -356,6 +358,11 @@ "is write authority on NuGet. Remove the command. " "[CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_PUB_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs dart pub publish. Publishing a " + "package is write authority on pub.dev. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) 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 " @@ -507,6 +514,10 @@ r"\b(?:dotnet\s+)?nuget\s+push\b", re.IGNORECASE, ) +_PUB_PUBLISH_COMMAND = re.compile( + r"\b(?:(?Pdart|flutter)\s+)?pub\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( @@ -790,7 +801,8 @@ 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|gem\s+push|" - r"(?:dotnet\s+)?nuget\s+push)\b", + r"(?:dotnet\s+)?nuget\s+push|" + r"(?:dart\s+|flutter\s+)?pub\s+publish)\b", re.IGNORECASE, ), ), @@ -1018,6 +1030,7 @@ def inspect_claude_plugin_file( 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(_pub_publish_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -2388,6 +2401,40 @@ def _nuget_push_command_hits( return () +def _pub_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``pub 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 ``dart pub publish``, ``flutter pub + publish``, or legacy ``pub publish``. ``dart pub get`` is not + this class. ``pnpm publish`` stays the pnpm class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _PUB_PUBLISH_COMMAND) + if match is None: + continue + sdk = match.group("sdk") + if sdk: + snippet = sdk.lower() + " pub publish" + else: + snippet = "pub publish" + return ( + PluginHit( + rule_id="claude-plugin-pub-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet=snippet, + message=CLAUDE_PLUGIN_PUB_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 31d12e6c..30eab260 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-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-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 d7acc262..2f616386 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -120,6 +120,7 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-poetry-publish-command` for ``poetry publish``, `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``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and From c1313fb90bd7bd874b765587ba829cc0288ab704 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:43 +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 0f4c1f3e6000ca370ec298a8ba7564e3083fb4d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:45 +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 783fca57..e7410ffa 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -2002,12 +2002,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. @@ -2037,8 +2053,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 f938158d07646c5e6f486148c767b62fe000deba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:15:03 +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 e7410ffa..6f410667 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -520,6 +520,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, @@ -2002,6 +2007,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. @@ -2023,8 +2072,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: @@ -2036,7 +2086,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 d42618e7dc1c94467ba2bf3bb23bb67f8935177a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:15:04 +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 "<