From 0e1f36590d421bb017a0cda88bf9337c6fe93ba5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 09:02:10 +0900 Subject: [PATCH 01/39] test(scanner): fail closed on plugin terraform apply and helm install Lock terraform apply and helm install as fail-closed findings. Keep terraform plan, helm list, vercel deploy, and fly deploy as inventory. Relates to #1099. --- tests/test_claude_plugin_deployment_write.py | 5 +- tests/test_claude_plugin_terraform_helm.py | 213 +++++++++++++++++++ 2 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 tests/test_claude_plugin_terraform_helm.py diff --git a/tests/test_claude_plugin_deployment_write.py b/tests/test_claude_plugin_deployment_write.py index 7f256c50..5a1a7c83 100644 --- a/tests/test_claude_plugin_deployment_write.py +++ b/tests/test_claude_plugin_deployment_write.py @@ -115,8 +115,8 @@ def test_kubectl_get_and_docker_ps_stay_inventory(tmp_path: Path) -> None: assert inventory["deployment_write"] is False -def test_terraform_and_helm_stay_inventory(tmp_path: Path) -> None: - """Terraform apply and Helm install stay inventory; this slice does not own them.""" +def test_terraform_and_helm_are_not_kubectl_or_docker_push(tmp_path: Path) -> None: + """Terraform apply and Helm install are not the kubectl/docker-push class.""" root = _licensed_plugin( tmp_path, "#!/bin/sh\nterraform apply -auto-approve\nhelm install app chart/\n", @@ -125,7 +125,6 @@ def test_terraform_and_helm_stay_inventory(tmp_path: Path) -> None: inventory = inventory_claude_plugin_capabilities(root) assert _THIS_CLASS.isdisjoint(receipt.finding_summary) - assert receipt.scan_result == "pass" assert inventory["deployment_write"] is True diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py new file mode 100644 index 00000000..f50b67be --- /dev/null +++ b/tests/test_claude_plugin_terraform_helm.py @@ -0,0 +1,213 @@ +"""Hook terraform apply and helm install fail closed; plan/list stay 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" +_TERRAFORM_RULE = "claude-plugin-terraform-apply-command" +_HELM_RULE = "claude-plugin-helm-install-command" +_KUBECTL_RULE = "claude-plugin-kubectl-apply-command" +_DOCKER_PUSH_RULE = "claude-plugin-docker-push-command" +_SECRET = "sk-tf-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_TERRAFORM_RULE, _HELM_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_terraform_apply_fails_admission(tmp_path: Path) -> None: + """``terraform apply`` on a hook is infra write authority, not inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nterraform apply -auto-approve\n") + hits = _hits(root, _TERRAFORM_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "terraform apply" for hit in hits) + assert receipt.scan_result == "fail" + assert _TERRAFORM_RULE in receipt.finding_summary + assert _HELM_RULE not in receipt.finding_summary + assert _KUBECTL_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_hook_helm_install_fails_admission(tmp_path: Path) -> None: + """``helm install`` on a hook is cluster write authority, not inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nhelm install app chart/\n") + hits = _hits(root, _HELM_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "helm install" for hit in hits) + assert receipt.scan_result == "fail" + assert _HELM_RULE in receipt.finding_summary + assert _TERRAFORM_RULE not in receipt.finding_summary + assert _DOCKER_PUSH_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_terraform_plan_and_helm_list_stay_inventory(tmp_path: Path) -> None: + """``terraform plan`` and ``helm list`` stay inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nterraform plan\nhelm list\n") + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _TERRAFORM_RULE) == [] + assert _hits(root, _HELM_RULE) == [] + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_vercel_deploy_and_fly_deploy_stay_inventory(tmp_path: Path) -> None: + """Hosted deploy CLIs stay inventory; this slice does not own them.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nvercel deploy\nfly deploy\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + assert inventory["deployment_write"] is True + + +def test_readme_terraform_apply_is_not_this_class(tmp_path: Path) -> None: + """README terraform wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("terraform apply -auto-approve\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _TERRAFORM_RULE) == [] + assert receipt.scan_result == "pass" + assert _TERRAFORM_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_terraform_and_helm_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: + """One hook can fail closed on both terraform apply and helm install.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nterraform apply -auto-approve\nhelm install app chart/\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _TERRAFORM_RULE) + assert _hits(root, _HELM_RULE) + assert receipt.scan_result == "fail" + assert _TERRAFORM_RULE in receipt.finding_summary + assert _HELM_RULE in receipt.finding_summary + assert _KUBECTL_RULE not in receipt.finding_summary + + +def test_case_insensitive_terraform_apply_fails_admission(tmp_path: Path) -> None: + """``TERRAFORM APPLY`` is the same infra-write class.""" + body = "#!/bin/sh\nTERRAFORM APPLY -auto-approve\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + root = _licensed_plugin(tmp_path, body) + assert _hits(root, _TERRAFORM_RULE) + assert any( + hit.rule_id == _TERRAFORM_RULE and hit.snippet == "terraform apply" for hit in hits + ) + + +def test_case_insensitive_helm_install_fails_admission() -> None: + """``HELM INSTALL`` canonicalizes the snippet to ``helm install``.""" + body = "#!/bin/sh\nHELM INSTALL app chart/\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _HELM_RULE and hit.snippet == "helm install" 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\nterraform apply -var 'token={_SECRET}{_BIDI}'\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + terraform_hits = [hit for hit in hits if hit.rule_id == _TERRAFORM_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert terraform_hits + for hit in terraform_hits: + assert hit.snippet == "terraform apply" + 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_helm_install_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that installs a chart is the helm class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PostToolUse": [{"command": "helm install app chart/"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _HELM_RULE) + assert receipt.scan_result == "fail" + assert _HELM_RULE in receipt.finding_summary + + +def test_empty_hook_is_not_this_class() -> None: + """Empty hook text is not terraform or helm write authority.""" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", "") + assert [hit.rule_id for hit in hits if hit.rule_id in _THIS_CLASS] == [] + + +def test_kubectl_apply_without_terraform_stays_the_kubectl_class() -> None: + """Cluster apply without terraform/helm stays the kubectl class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\nkubectl apply -f deploy.yml\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _KUBECTL_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) From e465cd71b495215e6ab7bc1ce298b588c0edfd75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 09:02:10 +0900 Subject: [PATCH 02/39] feat(scanner): fail closed on plugin terraform apply and helm install Hook and manifest terraform apply fail as claude-plugin-terraform-apply-command. helm install fails as claude-plugin-helm-install-command. Snippets are command labels. Relates to #1099. --- .github/workflows/tests.yml | 4 +- .../1099-claude-plugin-supply-chain.md | 5 +- appguardrail_core/claude_plugin_detector.py | 67 ++++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 8 ++- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 319ef210..b0e1a6f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -77,7 +77,9 @@ jobs: --test tests/test_claude_plugin_sbom_receipt.py \ --test tests/test_claude_plugin_checksum_mismatch.py \ --test tests/test_claude_plugin_github_merge_release.py \ - --test tests/test_claude_plugin_credential_store.py + --test tests/test_claude_plugin_credential_store.py \ + --test tests/test_claude_plugin_deployment_write.py \ + --test tests/test_claude_plugin_terraform_helm.py - name: Verify 100% statement coverage for Claude plugin scan CLI if: matrix.python-version == '3.13' run: | diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 14aa6bc9..0d64a8ce 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -132,7 +132,10 @@ ``docker push`` and ``docker image push`` fail as `claude-plugin-docker-push-command`. ``gh issue create``, ``gh pr review``, ``gh release list``, ``kubectl get``, ``docker ps``, - ``terraform apply``, and ``helm install`` stay inventory. Hardcoded + ``terraform apply`` fails as `claude-plugin-terraform-apply-command`. + ``helm install`` fails as `claude-plugin-helm-install-command`. + ``terraform plan``, ``helm list``, ``vercel deploy``, and ``fly deploy`` + stay inventory. Hardcoded PATs stay `claude-plugin-github-write-token`. Snippets are command labels, not tokens. Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``, diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 5d5f7b92..855f66fa 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -21,14 +21,17 @@ not permission, except that hook or manifest ``gh pr merge`` and ``gh release create|upload|delete|edit`` fail closed as command findings. Hook or manifest ``kubectl apply`` and ``docker push`` fail closed as -deployment-write command findings. Hook or manifest paths into +deployment-write command findings. Hook or manifest ``terraform apply`` +and ``helm install`` fail closed as infra-write command findings. +``terraform plan``, ``helm list``, ``vercel deploy``, and ``fly deploy`` +stay inventory. Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth ``config.json``, cookie jars, and ``~/.ssh/id_*`` private keys fail closed as credential-store findings. Chrome and Firefox profile stores stay browser-profile findings. Hardcoded PATs stay write-token findings. ``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, -``terraform apply``, and ``helm install`` stay inventory. Skill +``terraform plan``, and ``helm list`` stay inventory. Skill homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule identities. Skill, command, or agent text that hides tool use, rewrites the system prompt, or escalates the declared goal is a separate @@ -232,6 +235,16 @@ "write authority on a registry. Remove the command. " "[CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs terraform apply. Applying " + "infrastructure is write authority. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs helm install. Installing a chart " + "is write authority on a cluster. 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 " @@ -353,6 +366,8 @@ r"\bdocker(?:\s+image)?\s+push\b", re.IGNORECASE, ) +_TERRAFORM_APPLY_COMMAND = re.compile(r"\bterraform\s+apply\b", re.IGNORECASE) +_HELM_INSTALL_COMMAND = re.compile(r"\bhelm\s+install\b", re.IGNORECASE) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -839,6 +854,8 @@ def inspect_claude_plugin_file( hits.extend(_github_release_command_hits(content)) hits.extend(_kubectl_apply_command_hits(content)) hits.extend(_docker_push_command_hits(content)) + hits.extend(_terraform_apply_command_hits(content)) + hits.extend(_helm_install_command_hits(content)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -1555,6 +1572,52 @@ def _docker_push_command_hits(content: str) -> tuple[PluginHit, ...]: ) +def _terraform_apply_command_hits(content: str) -> tuple[PluginHit, ...]: + """Return ``terraform apply`` findings with a command label, not vars. + + Args: + content: Hook or manifest text. + + Returns: + One hit when ``terraform apply`` is present. ``terraform plan`` + and README wording are not this class. + """ + match = _TERRAFORM_APPLY_COMMAND.search(content) + if match is None: + return () + return ( + PluginHit( + rule_id="claude-plugin-terraform-apply-command", + line=content[: match.start()].count("\n") + 1, + snippet="terraform apply", + message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, + ), + ) + + +def _helm_install_command_hits(content: str) -> tuple[PluginHit, ...]: + """Return ``helm install`` findings with a command label, not chart names. + + Args: + content: Hook or manifest text. + + Returns: + One hit when ``helm install`` is present. ``helm list`` and + ``helm status`` are not this class. + """ + match = _HELM_INSTALL_COMMAND.search(content) + if match is None: + return () + return ( + PluginHit( + rule_id="claude-plugin-helm-install-command", + line=content[: match.start()].count("\n") + 1, + snippet="helm install", + message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE, + ), + ) + + 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 7d1289dd..9f050311 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-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-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-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-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 b6c24b52..1f66df23 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -98,14 +98,16 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-github-release-command` for ``gh release`` create/upload/delete/edit, `claude-plugin-kubectl-apply-command` for ``kubectl apply``, - `claude-plugin-docker-push-command` for ``docker push``, and + `claude-plugin-docker-push-command` for ``docker push``, + `claude-plugin-terraform-apply-command` for ``terraform apply``, + `claude-plugin-helm-install-command` for ``helm install``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and SSH private keys. Chrome/Firefox profile stores stay `claude-plugin-browser-profile-access`. Hardcoded PATs stay `claude-plugin-github-write-token`. ``gh issue create``, ``gh pr review``, - ``kubectl get``, ``docker ps``, ``terraform apply``, and ``helm install`` - stay inventory. + ``kubectl get``, ``docker ps``, ``terraform plan``, ``helm list``, + ``vercel deploy``, and ``fly deploy`` 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 33aae04fec0b3813c1504f4f5a3aede6f272e8a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:59:56 +0900 Subject: [PATCH 03/39] test(scanner): reproduce terraform and helm command-context false positives --- tests/test_claude_plugin_terraform_helm.py | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index f50b67be..4a95efdf 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -211,3 +211,46 @@ def test_kubectl_apply_without_terraform_stays_the_kubectl_class() -> None: rule_ids = {hit.rule_id for hit in hits} 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 = ( + "#!/bin/sh\n# terraform apply -auto-approve\n", + "#!/bin/sh\necho 'helm install app chart/'\n", + "#!/bin/sh\nprintf 'terraform apply -auto-approve\\n'\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_manifest_prose_and_reporting_commands_are_not_commands() -> None: + """Only structural executable command values carry deployment authority.""" + content = json.dumps( + { + "name": "safe-plugin", + "description": "Run terraform apply or helm install only after review.", + "hooks": { + "PostToolUse": [ + {"command": "echo 'terraform apply -auto-approve'"}, + {"command": "printf 'helm install app chart/\\n'"}, + ] + }, + } + ) + hits = inspect_claude_plugin_file( + "plugin.json", ".claude-plugin/plugin.json", content + ) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_later_executable_command_after_reporting_segment_still_fails() -> None: + """A reporting segment cannot hide a later real terraform or Helm write.""" + bodies = ( + "#!/bin/sh\necho checked && terraform apply -auto-approve\n", + "#!/bin/sh\nprintf 'checked\\n'; 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 1f901a358f1b4314641b0908ecb3cabb99ad6847 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:02:32 +0900 Subject: [PATCH 04/39] fix(scanner): parse executable terraform and helm commands --- appguardrail_core/claude_plugin_detector.py | 203 +++++++++++++++++--- 1 file changed, 171 insertions(+), 32 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 855f66fa..9504bca1 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -854,8 +854,8 @@ def inspect_claude_plugin_file( hits.extend(_github_release_command_hits(content)) hits.extend(_kubectl_apply_command_hits(content)) hits.extend(_docker_push_command_hits(content)) - hits.extend(_terraform_apply_command_hits(content)) - hits.extend(_helm_install_command_hits(content)) + hits.extend(_terraform_apply_command_hits(content, manifest=manifest)) + hits.extend(_helm_install_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -1572,53 +1572,192 @@ def _docker_push_command_hits(content: str) -> tuple[PluginHit, ...]: ) -def _terraform_apply_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return ``terraform apply`` findings with a command label, not vars. +def _unquoted_hash_index(line: str) -> int | None: + """Return the index of an unquoted ``#`` shell comment, if any. Args: - content: Hook or manifest text. + line: One hook or manifest line without a trailing newline. + + Returns: + The comment index, or ``None`` when every ``#`` is quoted or escaped. + """ + in_single = False + in_double = False + escaped = False + for index, char in enumerate(line): + if escaped: + escaped = False + continue + if char == "\\" and not in_single: + escaped = True + continue + if char == "'" and not in_double: + in_single = not in_single + continue + if char == '"' and not in_single: + in_double = not in_double + continue + if char == "#" and not in_single and not in_double: + return index + return None + + +def _iter_unquoted_segment_bounds(line: str) -> tuple[tuple[int, int], ...]: + """Return start/end offsets of unquoted shell command segments. + + Args: + line: One hook or manifest line without a trailing newline. + + Returns: + Inclusive-start exclusive-end spans split on unquoted ``&&``, + ``||``, ``;``, ``|``, and ``&``. Quoted lookalikes stay one span. + """ + bounds: list[tuple[int, int]] = [] + start = 0 + in_single = False + in_double = False + escaped = False + length = len(line) + index = 0 + while index < length: + char = line[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\" and not in_single: + escaped = True + index += 1 + continue + if char == "'" and not in_double: + in_single = not in_single + index += 1 + continue + if char == '"' and not in_single: + in_double = not in_double + index += 1 + continue + if in_single or in_double: + index += 1 + continue + two = line[index : index + 2] + if two in {"&&", "||"}: + bounds.append((start, index)) + start = index + 2 + index += 2 + continue + if char in {";", "|", "&"}: + bounds.append((start, index)) + start = index + 1 + index += 1 + continue + index += 1 + bounds.append((start, length)) + return tuple(bounds) + + +def _first_shell_token(segment: str) -> str: + """Return the first command basename of a shell segment. + + Args: + segment: One unquoted command fragment. Returns: - One hit when ``terraform apply`` is present. ``terraform plan`` - and README wording are not this class. + A lowercase basename such as ``echo``. Empty when the fragment + has no command token. """ - match = _TERRAFORM_APPLY_COMMAND.search(content) + match = _FIRST_SHELL_TOKEN.match(segment) if match is None: + return "" + name = match.group(1).rsplit("/", 1)[-1] + if name.lower().endswith(".exe"): + name = name[:-4] + return name.lower() + + +def _is_reporting_builtin_segment(segment: str) -> bool: + """Return whether the segment only prints text instead of running a CLI. + + Args: + segment: One unquoted command fragment. + + Returns: + ``True`` for ``echo``, ``printf``, and ``print``, including path + and ``.exe`` spellings. + """ + return _first_shell_token(segment) in _REPORTING_BUILTINS + + +def _manifest_command_sources(content: str) -> tuple[tuple[str, int], ...]: + """Return structural manifest command strings with source line numbers.""" + try: + payload = _load_manifest_json(content) + except (_DuplicateJsonMember, _NonstandardJsonConstant, json.JSONDecodeError): return () - return ( - PluginHit( - rule_id="claude-plugin-terraform-apply-command", - line=content[: match.start()].count("\n") + 1, - snippet="terraform apply", - message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, - ), - ) + found: list[tuple[str, int]] = [] -def _helm_install_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return ``helm install`` findings with a command label, not chart names. + def collect(value: object) -> None: + if isinstance(value, dict): + for key, nested in value.items(): + if key == "command" and isinstance(nested, str) and nested.strip(): + found.append((nested, _script_line(content, nested))) + else: + collect(nested) + elif isinstance(value, list): + for nested in value: + collect(nested) + + collect(payload) + return tuple(found) + + +def _hosted_command_sources( + content: str, *, manifest: bool +) -> tuple[tuple[str, int], ...]: + """Return shell text sources for one hook or structural manifest.""" + if manifest: + return _manifest_command_sources(content) + return ((content, 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 and ``echo``/``printf``/``print`` segments are + not executable. Manifest JSON command strings remain searchable + because they are not reporting builtins. Args: content: Hook or manifest text. + pattern: Compiled command regex. Returns: - One hit when ``helm install`` is present. ``helm list`` and - ``helm status`` are not this class. + The first executable match, or ``None``. """ - match = _HELM_INSTALL_COMMAND.search(content) - if match is None: - return () - return ( - PluginHit( - rule_id="claude-plugin-helm-install-command", - line=content[: match.start()].count("\n") + 1, - snippet="helm install", - message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE, - ), - ) + if not content: + return None + for match in pattern.finditer(content): + line_start = content.rfind("\n", 0, match.start()) + 1 + line_end = content.find("\n", match.start()) + if line_end < 0: + 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: + continue + for start, end in _iter_unquoted_segment_bounds(line): + if start <= relative < end: + if not _is_reporting_builtin_segment(line[start:end]): + return match + break + return None -def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: +def _terraform_apply_command_hits(\n content: str, *, manifest: bool = False\n) -> tuple[PluginHit, ...]:\n """Return executable terraform apply findings without vars."""\n for source, first_line in _hosted_command_sources(content, manifest=manifest):\n match = _executable_command_match(source, _TERRAFORM_APPLY_COMMAND)\n if match is None:\n continue\n return (\n PluginHit(\n rule_id="claude-plugin-terraform-apply-command",\n line=first_line + source[: match.start()].count("\\n"),\n snippet="terraform apply",\n message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE,\n ),\n )\n return ()\n\n\ndef _helm_install_command_hits(\n content: str, *, manifest: bool = False\n) -> tuple[PluginHit, ...]:\n """Return executable helm install findings without chart names."""\n for source, first_line in _hosted_command_sources(content, manifest=manifest):\n match = _executable_command_match(source, _HELM_INSTALL_COMMAND)\n if match is None:\n continue\n return (\n PluginHit(\n rule_id="claude-plugin-helm-install-command",\n line=first_line + source[: match.start()].count("\\n"),\n snippet="helm install",\n message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE,\n ),\n )\n return ()\n\n\ndef _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: """Return findings for eval/exec/compile/Function on hook surfaces.""" match = _DYNAMIC_EVAL.search(content) if match is None: From 705174862a2c4e0fb35d2b5990cbfbd84f285403 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:05:42 +0900 Subject: [PATCH 05/39] fix(scanner): restore executable command parser syntax --- appguardrail_core/claude_plugin_detector.py | 40 ++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 9504bca1..747eb783 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1757,7 +1757,45 @@ def _executable_command_match( return None -def _terraform_apply_command_hits(\n content: str, *, manifest: bool = False\n) -> tuple[PluginHit, ...]:\n """Return executable terraform apply findings without vars."""\n for source, first_line in _hosted_command_sources(content, manifest=manifest):\n match = _executable_command_match(source, _TERRAFORM_APPLY_COMMAND)\n if match is None:\n continue\n return (\n PluginHit(\n rule_id="claude-plugin-terraform-apply-command",\n line=first_line + source[: match.start()].count("\\n"),\n snippet="terraform apply",\n message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE,\n ),\n )\n return ()\n\n\ndef _helm_install_command_hits(\n content: str, *, manifest: bool = False\n) -> tuple[PluginHit, ...]:\n """Return executable helm install findings without chart names."""\n for source, first_line in _hosted_command_sources(content, manifest=manifest):\n match = _executable_command_match(source, _HELM_INSTALL_COMMAND)\n if match is None:\n continue\n return (\n PluginHit(\n rule_id="claude-plugin-helm-install-command",\n line=first_line + source[: match.start()].count("\\n"),\n snippet="helm install",\n message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE,\n ),\n )\n return ()\n\n\ndef _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: +def _terraform_apply_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable terraform apply findings without vars.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _TERRAFORM_APPLY_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-terraform-apply-command", + line=first_line + source[: match.start()].count("\n"), + snippet="terraform apply", + message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, + ), + ) + return () + + +def _helm_install_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable helm install findings without chart names.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _HELM_INSTALL_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-helm-install-command", + line=first_line + source[: match.start()].count("\n"), + snippet="helm install", + message=CLAUDE_PLUGIN_HELM_INSTALL_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) if match is None: From 41669d695c60635d38c2f2ef5174141f20f9a24b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:07:41 +0900 Subject: [PATCH 06/39] fix(scanner): define command-context parser tokens --- appguardrail_core/claude_plugin_detector.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 747eb783..8f3953f8 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -368,6 +368,8 @@ ) _TERRAFORM_APPLY_COMMAND = re.compile(r"\bterraform\s+apply\b", re.IGNORECASE) _HELM_INSTALL_COMMAND = re.compile(r"\bhelm\s+install\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( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, From d7384e53d44a426356a73e24b2b9cc3fe153de27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:00:31 +0900 Subject: [PATCH 07/39] test(scanner): reject quoted deployment command prose --- tests/test_claude_plugin_terraform_helm.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 4a95efdf..82d26972 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -212,6 +212,7 @@ def test_kubectl_apply_without_terraform_stays_the_kubectl_class() -> None: assert _KUBECTL_RULE in rule_ids assert _THIS_CLASS.isdisjoint(rule_ids) + def test_hook_comments_and_reporting_builtins_are_not_commands() -> None: """Comments and reporting builtins do not execute terraform or Helm.""" bodies = ( @@ -254,3 +255,26 @@ 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 cb208db431789b35b11064740f3067036ee2e2fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:02:57 +0900 Subject: [PATCH 08/39] fix(scanner): distinguish quoted prose from shell commands --- appguardrail_core/claude_plugin_detector.py | 83 ++++++++++++++++++--- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 8f3953f8..bc3f9ff2 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1723,14 +1723,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. @@ -1748,17 +1807,23 @@ 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 + 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 start, end in _iter_unquoted_segment_bounds(line): - if start <= relative < end: - if not _is_reporting_builtin_segment(line[start:end]): + 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 _terraform_apply_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: From 3c5fdc447fd563fc909c92c1afe15dfd350264a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:04:26 +0900 Subject: [PATCH 09/39] fix(scanner): remove invalid backtick escapes --- appguardrail_core/claude_plugin_detector.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index bc3f9ff2..9c5843f9 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1760,11 +1760,11 @@ def _shell_command_context_start(line: str, offset: int) -> int | None: frames.append((")", index + 2, "", 1)) index += 2 continue - if quote != "'" and char == "\`": - if frame_end == "\`": + if quote != "'" and char == "`": + if frame_end == "`": frames.pop() else: - frames.append(("\`", index + 1, "", 0)) + frames.append(("`", index + 1, "", 0)) index += 1 continue if quote: From 3a5b14771cc7abb6553fa1ec9bcd8b62acc947ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:06:11 +0900 Subject: [PATCH 10/39] test(scanner): use literal legacy substitution syntax --- tests/test_claude_plugin_terraform_helm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 82d26972..bc35c08c 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -272,7 +272,7 @@ 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", + "#!/bin/sh\nresult=`helm install app chart/`\n", ) for body in bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) From 90ae232e852b3d01463d978f9bccd9157c341fe7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:29:26 +0900 Subject: [PATCH 11/39] test(scanner): expose assignment-value command false positive --- 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 bc35c08c..5eb276a2 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -278,3 +278,25 @@ def test_command_substitution_remains_executable() -> 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_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 1e6a3eb43a5bc2d146656af13e7fa6cad2a77315 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:31:37 +0900 Subject: [PATCH 12/39] fix(scanner): ignore command names inside shell assignments --- appguardrail_core/claude_plugin_detector.py | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 9c5843f9..605ac065 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1782,12 +1782,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. @@ -1817,13 +1833,18 @@ 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 return None + def _terraform_apply_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: From 6f6be77d6b1bbef327de001ffb3fcaef2623b998 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:03:06 +0900 Subject: [PATCH 13/39] test(scanner): reproduce heredoc command prose false positive --- tests/test_claude_plugin_terraform_helm.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 5eb276a2..1e837ae3 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -300,3 +300,27 @@ 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 + From 12faf2f46847ba05448ad4885d75559d8f26e301 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:07:42 +0900 Subject: [PATCH 14/39] fix(scanner): ignore closed literal heredoc payloads --- 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 605ac065..bb965dea 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -370,6 +370,11 @@ _HELM_INSTALL_COMMAND = re.compile(r"\bhelm\s+install\b", re.IGNORECASE) _REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) _FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") +_LITERAL_HEREDOC_OPEN = re.compile( + r"<<(?P-)?[ \t]*(?P['\"]?)" + r"(?P[A-Za-z_][A-Za-z0-9_]*)(?P=quote)" + r"(?=$|[ \t;&|()<>])" +) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -1782,6 +1787,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. @@ -1803,8 +1852,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: @@ -1816,7 +1866,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 fa9df24c67890bf57a7c98bb6f7b65f7d1a19548 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:08:10 +0900 Subject: [PATCH 15/39] test(scanner): preserve commands after heredoc lookalikes --- tests/test_claude_plugin_terraform_helm.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 1e837ae3..fe3bc41e 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -324,3 +324,14 @@ def test_command_after_here_document_still_fails() -> None: 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 "< Date: Tue, 8 Sep 2026 17:08:23 +0900 Subject: [PATCH 16/39] docs(scanner): trace heredoc command boundary --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 9f050311..7a833f09 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-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-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-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 executable `terraform apply`, `claude-plugin-helm-install-command` for executable `helm install` (closed literal here-document payloads, quoted/commented prose, reporting builtins, and assignment values are negative boundaries; commands after those boundaries remain positive), `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` | From 5bdcde76bde14742c96dbdd9a6919438e2ae6c17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 20:09:27 +0900 Subject: [PATCH 17/39] test(scanner): reproduce no-op argument command false positives --- tests/test_claude_plugin_terraform_helm.py | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index fe3bc41e..f35266db 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -335,3 +335,26 @@ def test_heredoc_opener_lookalikes_do_not_hide_real_commands() -> 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_nonexecuting_builtins_do_not_execute_argument_text() -> None: + """No-op and status builtins do not execute command-like arguments.""" + bodies = ( + "#!/bin/sh\n: terraform apply -auto-approve\n", + "#!/bin/sh\ntrue helm install app chart/\n", + "#!/bin/sh\nfalse 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_after_nonexecuting_builtin_still_fails() -> None: + """A no-op argument cannot hide a later real deployment command.""" + bodies = ( + "#!/bin/sh\n: terraform apply; helm install app chart/\n", + "#!/bin/sh\nfalse helm install app chart/ || terraform apply\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 873370bfceba0f161c6d1682459741f84d0ef92a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 20:10:22 +0900 Subject: [PATCH 18/39] fix(scanner): ignore no-op command argument prose --- appguardrail_core/claude_plugin_detector.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index bb965dea..2962278e 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -368,8 +368,10 @@ ) _TERRAFORM_APPLY_COMMAND = re.compile(r"\bterraform\s+apply\b", re.IGNORECASE) _HELM_INSTALL_COMMAND = re.compile(r"\bhelm\s+install\b", re.IGNORECASE) -_REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) -_FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") +_REPORTING_BUILTINS: Final = frozenset( + {":", "echo", "false", "print", "printf", "true"} +) +_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)" @@ -1683,13 +1685,13 @@ def _first_shell_token(segment: str) -> str: def _is_reporting_builtin_segment(segment: str) -> bool: - """Return whether the segment only prints text instead of running a CLI. + """Return whether the command does not execute its argument text. Args: segment: One unquoted command fragment. Returns: - ``True`` for ``echo``, ``printf``, and ``print``, including path + ``True`` for no-op, status, and reporting commands, including path and ``.exe`` spellings. """ return _first_shell_token(segment) in _REPORTING_BUILTINS From 82a0cc86d2662d5953aa7c088f22ac9435a12c26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 20:57:30 +0900 Subject: [PATCH 19/39] test(scanner): reproduce manifest argv write bypass --- tests/test_claude_plugin_terraform_helm.py | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index f35266db..d122b96e 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -195,6 +195,43 @@ def test_plugin_manifest_helm_install_fails_admission(tmp_path: Path) -> None: assert _HELM_RULE in receipt.finding_summary +def test_manifest_command_args_preserve_executable_argv() -> None: + """Structured command and args are one executable argv surface.""" + content = json.dumps( + { + "mcpServers": { + "terraform-writer": { + "command": "terraform", + "args": ["apply", "-auto-approve"], + }, + "helm-writer": { + "command": "helm", + "args": ["install", "app", "chart/"], + }, + } + } + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", content) + rule_ids = {hit.rule_id for hit in hits} + + assert _TERRAFORM_RULE in rule_ids + assert _HELM_RULE in rule_ids + + +def test_manifest_command_args_preserve_nonwrite_token_boundaries() -> None: + """Non-write, non-token, and non-array args do not invent write commands.""" + manifests = ( + {"command": "terraform", "args": ["plan"]}, + {"command": "helm", "args": ["list"]}, + {"command": "terraform", "args": ["apply later"]}, + {"command": "helm", "args": "install"}, + ) + for manifest in manifests: + content = json.dumps({"mcpServers": {"reader": manifest}}) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", content) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + def test_empty_hook_is_not_this_class() -> None: """Empty hook text is not terraform or helm write authority.""" hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", "") From 969842df15739d570eba7ac50112969e0e574406 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 20:58:18 +0900 Subject: [PATCH 20/39] fix(scanner): preserve structured manifest argv --- appguardrail_core/claude_plugin_detector.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 2962278e..2f20afbe 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -63,6 +63,7 @@ import os from pathlib import Path import re +import shlex import stat import tarfile from typing import Final, Iterable @@ -1710,7 +1711,15 @@ def collect(value: object) -> None: if isinstance(value, dict): for key, nested in value.items(): if key == "command" and isinstance(nested, str) and nested.strip(): - found.append((nested, _script_line(content, nested))) + args = value.get("args") + command = nested + if isinstance(args, list) and all( + isinstance(argument, str) for argument in args + ): + command = " ".join( + (nested, *(shlex.quote(argument) for argument in args)) + ) + found.append((command, _script_line(content, nested))) else: collect(nested) elif isinstance(value, list): From c76b1bd304b5c3c062a8538c04121e652c69dda4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:01:15 +0900 Subject: [PATCH 21/39] test(scanner): bound manifest argv to direct executable --- tests/test_claude_plugin_terraform_helm.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index d122b96e..8059c141 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -208,6 +208,10 @@ def test_manifest_command_args_preserve_executable_argv() -> None: "command": "helm", "args": ["install", "app", "chart/"], }, + "path-writer": { + "command": "/usr/bin/terraform", + "args": ["apply"], + }, } } ) @@ -224,7 +228,13 @@ def test_manifest_command_args_preserve_nonwrite_token_boundaries() -> None: {"command": "terraform", "args": ["plan"]}, {"command": "helm", "args": ["list"]}, {"command": "terraform", "args": ["apply later"]}, + {"command": "terraform", "args": ["applyLocal"]}, + {"command": "terraform", "args": ["apply-now"]}, + {"command": "helm", "args": ["install-chart"]}, + {"command": "wrapper", "args": ["terraform", "apply"]}, + {"command": "echo", "args": ["helm", "install", "app", "chart/"]}, {"command": "helm", "args": "install"}, + {"command": "terraform", "args": ["apply", 1]}, ) for manifest in manifests: content = json.dumps({"mcpServers": {"reader": manifest}}) From 18ca616c2ba4c4322f64e39336c5dc809bbac81a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:02:20 +0900 Subject: [PATCH 22/39] fix(scanner): distinguish direct manifest argv --- appguardrail_core/claude_plugin_detector.py | 78 ++++++++++++++++++--- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 2f20afbe..07919414 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -63,7 +63,6 @@ import os from pathlib import Path import re -import shlex import stat import tarfile from typing import Final, Iterable @@ -1711,15 +1710,7 @@ def collect(value: object) -> None: if isinstance(value, dict): for key, nested in value.items(): if key == "command" and isinstance(nested, str) and nested.strip(): - args = value.get("args") - command = nested - if isinstance(args, list) and all( - isinstance(argument, str) for argument in args - ): - command = " ".join( - (nested, *(shlex.quote(argument) for argument in args)) - ) - found.append((command, _script_line(content, nested))) + found.append((nested, _script_line(content, nested))) else: collect(nested) elif isinstance(value, list): @@ -1730,6 +1721,49 @@ def collect(value: object) -> None: return tuple(found) +def _manifest_argv_command_line( + content: str, *, executable: str, verb: str +) -> int | None: + """Return the source line for one direct structural manifest argv command.""" + try: + payload = _load_manifest_json(content) + except (_DuplicateJsonMember, _NonstandardJsonConstant, json.JSONDecodeError): + return None + + found_line: int | None = None + + def collect(value: object) -> None: + nonlocal found_line + if found_line is not None: + return + if isinstance(value, dict): + command = value.get("command") + args = value.get("args") + if ( + isinstance(command, str) + and command.strip() + and isinstance(args, list) + and args + and all(isinstance(argument, str) for argument in args) + ): + command_name = ( + command.strip().replace("\\", "/").rsplit("/", 1)[-1].casefold() + ) + if command_name.endswith(".exe"): + command_name = command_name[:-4] + if command_name == executable and args[0].casefold() == verb: + found_line = _script_line(content, command) + return + for nested in value.values(): + collect(nested) + elif isinstance(value, list): + for nested in value: + collect(nested) + + collect(payload) + return found_line + + def _hosted_command_sources( content: str, *, manifest: bool ) -> tuple[tuple[str, int], ...]: @@ -1925,6 +1959,19 @@ def _terraform_apply_command_hits( message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, ), ) + if manifest: + line = _manifest_argv_command_line( + content, executable="terraform", verb="apply" + ) + if line is not None: + return ( + PluginHit( + rule_id="claude-plugin-terraform-apply-command", + line=line, + snippet="terraform apply", + message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, + ), + ) return () @@ -1944,6 +1991,17 @@ def _helm_install_command_hits( message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE, ), ) + if manifest: + line = _manifest_argv_command_line(content, executable="helm", verb="install") + if line is not None: + return ( + PluginHit( + rule_id="claude-plugin-helm-install-command", + line=line, + snippet="helm install", + message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE, + ), + ) return () From 52a6911215deec58537f4d6f3f1828e1fe62292e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:12:00 +0900 Subject: [PATCH 23/39] test(scanner): preserve argv identity and terraform options --- tests/test_claude_plugin_terraform_helm.py | 43 +++++++++++----------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 8059c141..d930b775 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -196,30 +196,26 @@ def test_plugin_manifest_helm_install_fails_admission(tmp_path: Path) -> None: def test_manifest_command_args_preserve_executable_argv() -> None: - """Structured command and args are one executable argv surface.""" - content = json.dumps( - { - "mcpServers": { - "terraform-writer": { - "command": "terraform", - "args": ["apply", "-auto-approve"], - }, - "helm-writer": { - "command": "helm", - "args": ["install", "app", "chart/"], - }, - "path-writer": { - "command": "/usr/bin/terraform", - "args": ["apply"], - }, - } - } + """Each direct manifest argv surface independently preserves its identity.""" + cases = ( + ({"command": "terraform", "args": ["apply", "-auto-approve"]}, _TERRAFORM_RULE), + ({"command": "/usr/bin/terraform", "args": ["apply"]}, _TERRAFORM_RULE), + ( + {"command": "terraform", "args": ["-chdir=infra", "apply"]}, + _TERRAFORM_RULE, + ), + ( + {"command": "helm", "args": ["install", "app", "chart/"]}, + _HELM_RULE, + ), ) - hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", content) - rule_ids = {hit.rule_id for hit in hits} + for manifest, expected_rule in cases: + content = json.dumps({"mcpServers": {"writer": manifest}}) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", content) + rule_ids = {hit.rule_id for hit in hits} - assert _TERRAFORM_RULE in rule_ids - assert _HELM_RULE in rule_ids + assert expected_rule in rule_ids + assert len(rule_ids & _THIS_CLASS) == 1 def test_manifest_command_args_preserve_nonwrite_token_boundaries() -> None: @@ -230,6 +226,9 @@ def test_manifest_command_args_preserve_nonwrite_token_boundaries() -> None: {"command": "terraform", "args": ["apply later"]}, {"command": "terraform", "args": ["applyLocal"]}, {"command": "terraform", "args": ["apply-now"]}, + {"command": "terraform", "args": ["-chdir=infra", "plan"]}, + {"command": "terraform", "args": ["-plugin-dir", "apply"]}, + {"command": " terraform ", "args": ["apply"]}, {"command": "helm", "args": ["install-chart"]}, {"command": "wrapper", "args": ["terraform", "apply"]}, {"command": "echo", "args": ["helm", "install", "app", "chart/"]}, From 8d7ec9d6b2c47f737f5aa4cdf10895ca7a1f9f21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:13:18 +0900 Subject: [PATCH 24/39] fix(scanner): bound direct argv identity and options --- appguardrail_core/claude_plugin_detector.py | 43 ++++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 07919414..4bf5af1c 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1722,9 +1722,25 @@ def collect(value: object) -> None: def _manifest_argv_command_line( - content: str, *, executable: str, verb: str + content: str, + *, + executable: str, + verb: str, + leading_value_option: str | None = None, ) -> int | None: - """Return the source line for one direct structural manifest argv command.""" + """Return the source line for one direct structural manifest argv command. + + Args: + content: Parsed-manifest source text. + executable: Exact executable basename without an .exe suffix. + verb: Exact write verb expected in argv. + leading_value_option: Optional single name=value global option + allowed before the verb. + + Returns: + The one-based command source line, or None when identity, argv + types, option grammar, or verb boundaries do not match. + """ try: payload = _load_manifest_json(content) except (_DuplicateJsonMember, _NonstandardJsonConstant, json.JSONDecodeError): @@ -1741,17 +1757,29 @@ def collect(value: object) -> None: args = value.get("args") if ( isinstance(command, str) - and command.strip() + and command + and command == command.strip() and isinstance(args, list) and args and all(isinstance(argument, str) for argument in args) ): command_name = ( - command.strip().replace("\\", "/").rsplit("/", 1)[-1].casefold() + command.replace("\\", "/").rsplit("/", 1)[-1].casefold() ) if command_name.endswith(".exe"): command_name = command_name[:-4] - if command_name == executable and args[0].casefold() == verb: + verb_index = 0 + if ( + leading_value_option is not None + and args[0].casefold().startswith(leading_value_option) + and len(args[0]) > len(leading_value_option) + ): + verb_index = 1 + if ( + command_name == executable + and verb_index < len(args) + and args[verb_index].casefold() == verb + ): found_line = _script_line(content, command) return for nested in value.values(): @@ -1961,7 +1989,10 @@ def _terraform_apply_command_hits( ) if manifest: line = _manifest_argv_command_line( - content, executable="terraform", verb="apply" + content, + executable="terraform", + verb="apply", + leading_value_option="-chdir=", ) if line is not None: return ( From 92568106aebdfcb729cd466fb5dbc5d124c9a4c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:15:51 +0900 Subject: [PATCH 25/39] test(scanner): reproduce nested shell c bypass --- tests/test_claude_plugin_terraform_helm.py | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index d930b775..9ae3d70b 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -241,6 +241,73 @@ def test_manifest_command_args_preserve_nonwrite_token_boundaries() -> None: assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) +def test_nested_shell_c_payloads_fail_admission() -> None: + """Direct shell -c payloads preserve executable deployment commands.""" + cases = ( + ("#!/bin/sh\nsh -c 'terraform apply -auto-approve'\n", _TERRAFORM_RULE), + ('#!/bin/sh\n/bin/bash -lc "helm install app chart/"\n', _HELM_RULE), + ( + "#!/bin/sh\nTF_IN_AUTOMATION=1 bash -ec 'terraform apply'\n", + _TERRAFORM_RULE, + ), + ( + "#!/bin/sh\necho checked; sh -c 'echo ok && terraform apply'\n", + _TERRAFORM_RULE, + ), + ) + for body, expected_rule in cases: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + + assert expected_rule in rule_ids + + +def test_manifest_nested_shell_c_payloads_fail_admission() -> None: + """Shell-string and direct-argv manifest payloads use the same boundary.""" + manifests = ( + {"command": "bash -c 'terraform apply -auto-approve'"}, + {"command": "bash", "args": ["-c", "helm install app chart/"]}, + {"command": "/bin/sh", "args": ["-lc", "terraform apply"]}, + ) + for manifest in manifests: + content = json.dumps({"mcpServers": {"writer": manifest}}) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", content) + + assert any(hit.rule_id in _THIS_CLASS for hit in hits) + + +def test_nested_shell_c_payload_boundaries_stay_negative() -> None: + """Reporting, wrapper, malformed, and non-write shell payloads stay inert.""" + hook_bodies = ( + "#!/bin/sh\necho \"sh -c 'terraform apply'\"\n", + "#!/bin/sh\ncommand=\"sh -c 'helm install app chart/'\"\n", + "#!/bin/sh\nfalse sh -c 'terraform apply'\n", + "#!/bin/sh\nwrapper sh -c 'helm install app chart/'\n", + "#!/bin/sh\nsh -c \"echo 'terraform apply'\"\n", + "#!/bin/sh\nsh -c 'command=helm install app chart/'\n", + "#!/bin/sh\nsh -c 'terraform plan'\n", + "#!/bin/sh\nsh -c 'helm install-chart app chart/'\n", + "#!/bin/sh\necho ok # ; sh -c 'terraform apply'\n", + ) + for body in hook_bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + manifests = ( + {"command": "bash", "args": ["terraform apply"]}, + {"command": "bash", "args": ["-c"]}, + {"command": "bash", "args": "-c terraform apply"}, + {"command": "bash", "args": ["-c", "terraform", "apply"]}, + {"command": "echo", "args": ["sh", "-c", "terraform apply"]}, + {"command": "wrapper", "args": ["bash", "-c", "helm install"]}, + {"command": "bash", "args": ["-c", "terraform apply", 1]}, + ) + for manifest in manifests: + content = json.dumps({"mcpServers": {"reader": manifest}}) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", content) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + def test_empty_hook_is_not_this_class() -> None: """Empty hook text is not terraform or helm write authority.""" hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", "") From feeac8243a1af2565c7a01e85fdc0ef72f4a34d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:17:31 +0900 Subject: [PATCH 26/39] fix(scanner): inspect bounded shell c payloads --- appguardrail_core/claude_plugin_detector.py | 194 +++++++++++++++----- 1 file changed, 149 insertions(+), 45 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 4bf5af1c..1728434d 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -63,6 +63,7 @@ import os from pathlib import Path import re +import shlex import stat import tarfile from typing import Final, Iterable @@ -371,6 +372,8 @@ _REPORTING_BUILTINS: Final = frozenset( {":", "echo", "false", "print", "printf", "true"} ) +_SHELL_COMMAND_INTERPRETERS: Final = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) +_SHELL_ASSIGNMENT_PREFIX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") _FIRST_SHELL_TOKEN = re.compile(r"\s*(:|[A-Za-z0-9_./+-]+)") _LITERAL_HEREDOC_OPEN = re.compile( r"<<(?P-)?[ \t]*(?P['\"]?)" @@ -1721,67 +1724,36 @@ def collect(value: object) -> None: return tuple(found) -def _manifest_argv_command_line( - content: str, - *, - executable: str, - verb: str, - leading_value_option: str | None = None, -) -> int | None: - """Return the source line for one direct structural manifest argv command. +def _direct_executable_basename(command: str) -> str: + """Return a direct executable basename without changing token identity.""" + if not command or command != command.strip(): + return "" + name = command.replace("\\", "/").rsplit("/", 1)[-1].casefold() + return name[:-4] if name.endswith(".exe") else name - Args: - content: Parsed-manifest source text. - executable: Exact executable basename without an .exe suffix. - verb: Exact write verb expected in argv. - leading_value_option: Optional single name=value global option - allowed before the verb. - Returns: - The one-based command source line, or None when identity, argv - types, option grammar, or verb boundaries do not match. - """ +def _manifest_argv_sources( + content: str, +) -> tuple[tuple[str, tuple[str, ...], int], ...]: + """Return typed direct-argv records from structural manifest objects.""" try: payload = _load_manifest_json(content) except (_DuplicateJsonMember, _NonstandardJsonConstant, json.JSONDecodeError): - return None + return () - found_line: int | None = None + found: list[tuple[str, tuple[str, ...], int]] = [] def collect(value: object) -> None: - nonlocal found_line - if found_line is not None: - return if isinstance(value, dict): command = value.get("command") args = value.get("args") if ( isinstance(command, str) and command - and command == command.strip() and isinstance(args, list) - and args and all(isinstance(argument, str) for argument in args) ): - command_name = ( - command.replace("\\", "/").rsplit("/", 1)[-1].casefold() - ) - if command_name.endswith(".exe"): - command_name = command_name[:-4] - verb_index = 0 - if ( - leading_value_option is not None - and args[0].casefold().startswith(leading_value_option) - and len(args[0]) > len(leading_value_option) - ): - verb_index = 1 - if ( - command_name == executable - and verb_index < len(args) - and args[verb_index].casefold() == verb - ): - found_line = _script_line(content, command) - return + found.append((command, tuple(args), _script_line(content, command))) for nested in value.values(): collect(nested) elif isinstance(value, list): @@ -1789,7 +1761,46 @@ def collect(value: object) -> None: collect(nested) collect(payload) - return found_line + return tuple(found) + + +def _manifest_argv_command_line( + content: str, + *, + executable: str, + verb: str, + leading_value_option: str | None = None, +) -> int | None: + """Return the source line for one direct structural manifest argv command. + + Args: + content: Parsed-manifest source text. + executable: Exact executable basename without an .exe suffix. + verb: Exact write verb expected in argv. + leading_value_option: Optional single name=value global option + allowed before the verb. + + Returns: + The one-based command source line, or None when identity, argv + types, option grammar, or verb boundaries do not match. + """ + for command, args, line in _manifest_argv_sources(content): + command_name = _direct_executable_basename(command) + verb_index = 0 + if ( + leading_value_option is not None + and args + and args[0].casefold().startswith(leading_value_option) + and len(args[0]) > len(leading_value_option) + ): + verb_index = 1 + if ( + command_name == executable + and verb_index < len(args) + and args[verb_index].casefold() == verb + ): + return line + return None def _hosted_command_sources( @@ -1971,6 +1982,73 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] return None +def _shell_command_option(token: str) -> bool: + """Return whether one short shell option cluster requests -c execution.""" + option = token.casefold() + return ( + option.startswith("-") + and not option.startswith("--") + and option[1:].isalpha() + and "c" in option[1:] + ) + + +def _nested_shell_payload_sources( + content: str, *, manifest: bool +) -> tuple[tuple[str, int], ...]: + """Return bounded direct shell -c payloads with their source line.""" + found: list[tuple[str, int]] = [] + for source, first_line in _hosted_command_sources(content, manifest=manifest): + inert_payloads = _literal_heredoc_payload_spans(source) + source_offset = 0 + for line_index, raw_line in enumerate(source.splitlines(keepends=True)): + line = raw_line.rstrip("\r\n") + comment_at = _unquoted_hash_index(line) + executable_line = line if comment_at is None else line[:comment_at] + for segment_start, segment_end in _iter_unquoted_segment_bounds( + executable_line + ): + absolute_start = source_offset + segment_start + if any( + start <= absolute_start < end for start, end in inert_payloads + ): + continue + segment = executable_line[segment_start:segment_end] + try: + tokens = shlex.split(segment, comments=False, posix=True) + except ValueError: + continue + token_index = 0 + while ( + token_index < len(tokens) + and _SHELL_ASSIGNMENT_PREFIX.match(tokens[token_index]) + ): + token_index += 1 + if token_index + 2 >= len(tokens): + continue + if ( + _direct_executable_basename(tokens[token_index]) + not in _SHELL_COMMAND_INTERPRETERS + or not _shell_command_option(tokens[token_index + 1]) + ): + continue + payload = tokens[token_index + 2] + if payload: + found.append((payload, first_line + line_index)) + source_offset += len(raw_line) + + if manifest: + for command, args, line in _manifest_argv_sources(content): + if ( + _direct_executable_basename(command) in _SHELL_COMMAND_INTERPRETERS + and len(args) >= 2 + and _shell_command_option(args[0]) + and args[1] + ): + found.append((args[1], line)) + return tuple(found) + + def _terraform_apply_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: @@ -2003,6 +2081,19 @@ def _terraform_apply_command_hits( message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, ), ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _TERRAFORM_APPLY_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-terraform-apply-command", + line=first_line + source[: match.start()].count("\n"), + snippet="terraform apply", + message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, + ), + ) return () @@ -2033,6 +2124,19 @@ def _helm_install_command_hits( message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE, ), ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _HELM_INSTALL_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-helm-install-command", + line=first_line + source[: match.start()].count("\n"), + snippet="helm install", + message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE, + ), + ) return () From 4ff4ff3f706b9f6a44f0ca9c769be704fcea5e4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:19:34 +0900 Subject: [PATCH 27/39] fix(scanner): require deployment verb token boundary --- appguardrail_core/claude_plugin_detector.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 1728434d..2373acf7 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -367,8 +367,12 @@ r"\bdocker(?:\s+image)?\s+push\b", re.IGNORECASE, ) -_TERRAFORM_APPLY_COMMAND = re.compile(r"\bterraform\s+apply\b", re.IGNORECASE) -_HELM_INSTALL_COMMAND = re.compile(r"\bhelm\s+install\b", re.IGNORECASE) +_TERRAFORM_APPLY_COMMAND = re.compile( + r"\bterraform\s+apply(?=$|[\s;&|()<>])", re.IGNORECASE +) +_HELM_INSTALL_COMMAND = re.compile( + r"\bhelm\s+install(?=$|[\s;&|()<>])", re.IGNORECASE +) _REPORTING_BUILTINS: Final = frozenset( {":", "echo", "false", "print", "printf", "true"} ) From 9cc0e8f53531c05347589ead4c0ed7ac51b299f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:24:38 +0900 Subject: [PATCH 28/39] test(scanner): reproduce split shell option boundaries --- tests/test_claude_plugin_terraform_helm.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 9ae3d70b..b717ef82 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -250,6 +250,11 @@ def test_nested_shell_c_payloads_fail_admission() -> None: "#!/bin/sh\nTF_IN_AUTOMATION=1 bash -ec 'terraform apply'\n", _TERRAFORM_RULE, ), + ("#!/bin/sh\nbash -e -c 'terraform apply'\n", _TERRAFORM_RULE), + ( + "#!/bin/sh\nbash --noprofile -c 'helm install app chart/'\n", + _HELM_RULE, + ), ( "#!/bin/sh\necho checked; sh -c 'echo ok && terraform apply'\n", _TERRAFORM_RULE, @@ -268,6 +273,11 @@ def test_manifest_nested_shell_c_payloads_fail_admission() -> None: {"command": "bash -c 'terraform apply -auto-approve'"}, {"command": "bash", "args": ["-c", "helm install app chart/"]}, {"command": "/bin/sh", "args": ["-lc", "terraform apply"]}, + {"command": "bash", "args": ["-e", "-c", "terraform apply"]}, + { + "command": "bash", + "args": ["--noprofile", "-c", "helm install app chart/"], + }, ) for manifest in manifests: content = json.dumps({"mcpServers": {"writer": manifest}}) @@ -288,6 +298,10 @@ def test_nested_shell_c_payload_boundaries_stay_negative() -> None: "#!/bin/sh\nsh -c 'terraform plan'\n", "#!/bin/sh\nsh -c 'helm install-chart app chart/'\n", "#!/bin/sh\necho ok # ; sh -c 'terraform apply'\n", + "#!/bin/sh\nbash -C 'terraform apply'\n", + "#!/bin/sh\nbash -gc 'terraform apply'\n", + "#!/bin/sh\nbash -g -c 'terraform apply'\n", + "#!/bin/sh\nbash -o pipefail -c 'terraform apply'\n", ) for body in hook_bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) @@ -301,6 +315,10 @@ def test_nested_shell_c_payload_boundaries_stay_negative() -> None: {"command": "echo", "args": ["sh", "-c", "terraform apply"]}, {"command": "wrapper", "args": ["bash", "-c", "helm install"]}, {"command": "bash", "args": ["-c", "terraform apply", 1]}, + {"command": "bash", "args": ["-C", "terraform apply"]}, + {"command": "bash", "args": ["-gc", "terraform apply"]}, + {"command": "bash", "args": ["-g", "-c", "terraform apply"]}, + {"command": "bash", "args": ["-o", "pipefail", "-c", "terraform apply"]}, ) for manifest in manifests: content = json.dumps({"mcpServers": {"reader": manifest}}) From 15ced2b610045331f10f8243a2a7eb1ee1db3751 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:25:35 +0900 Subject: [PATCH 29/39] fix(scanner): parse bounded shell option sequence --- appguardrail_core/claude_plugin_detector.py | 64 +++++++++++++-------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 2373acf7..b657bdab 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -377,6 +377,10 @@ {":", "echo", "false", "print", "printf", "true"} ) _SHELL_COMMAND_INTERPRETERS: Final = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) +_SHELL_NO_VALUE_SHORT_OPTIONS: Final = frozenset("eflnruvx") +_BASH_NO_VALUE_LONG_OPTIONS: Final = frozenset( + {"--noprofile", "--norc", "--posix", "--restricted", "--verbose"} +) _SHELL_ASSIGNMENT_PREFIX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") _FIRST_SHELL_TOKEN = re.compile(r"\s*(:|[A-Za-z0-9_./+-]+)") _LITERAL_HEREDOC_OPEN = re.compile( @@ -1986,15 +1990,27 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] return None -def _shell_command_option(token: str) -> bool: - """Return whether one short shell option cluster requests -c execution.""" - option = token.casefold() - return ( - option.startswith("-") - and not option.startswith("--") - and option[1:].isalpha() - and "c" in option[1:] - ) +def _shell_payload_index( + arguments: tuple[str, ...] | list[str], *, shell_name: str +) -> int | None: + """Return the payload index after bounded no-value options ending in -c.""" + for index, token in enumerate(arguments): + if shell_name == "bash" and token in _BASH_NO_VALUE_LONG_OPTIONS: + continue + if not token.startswith("-") or token.startswith("--"): + return None + flags = token[1:] + if not flags or any( + flag not in _SHELL_NO_VALUE_SHORT_OPTIONS and flag != "c" + for flag in flags + ): + return None + if "c" in flags: + if flags.count("c") != 1 or not flags.endswith("c"): + return None + payload_index = index + 1 + return payload_index if payload_index < len(arguments) else None + return None def _nested_shell_payload_sources( @@ -2028,28 +2044,30 @@ def _nested_shell_payload_sources( and _SHELL_ASSIGNMENT_PREFIX.match(tokens[token_index]) ): token_index += 1 - if token_index + 2 >= len(tokens): + if token_index >= len(tokens): continue - if ( - _direct_executable_basename(tokens[token_index]) - not in _SHELL_COMMAND_INTERPRETERS - or not _shell_command_option(tokens[token_index + 1]) - ): + shell_name = _direct_executable_basename(tokens[token_index]) + if shell_name not in _SHELL_COMMAND_INTERPRETERS: + continue + shell_arguments = tokens[token_index + 1 :] + payload_index = _shell_payload_index( + shell_arguments, shell_name=shell_name + ) + if payload_index is None: continue - payload = tokens[token_index + 2] + payload = shell_arguments[payload_index] if payload: found.append((payload, first_line + line_index)) source_offset += len(raw_line) if manifest: for command, args, line in _manifest_argv_sources(content): - if ( - _direct_executable_basename(command) in _SHELL_COMMAND_INTERPRETERS - and len(args) >= 2 - and _shell_command_option(args[0]) - and args[1] - ): - found.append((args[1], line)) + shell_name = _direct_executable_basename(command) + if shell_name not in _SHELL_COMMAND_INTERPRETERS: + continue + payload_index = _shell_payload_index(args, shell_name=shell_name) + if payload_index is not None and args[payload_index]: + found.append((args[payload_index], line)) return tuple(found) From 40d862dcbbd327bd3a426734f0af62e67a3d1750 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:31:31 +0900 Subject: [PATCH 30/39] test(scanner): cover shell option execution state --- tests/test_claude_plugin_terraform_helm.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index b717ef82..2f734a00 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -251,6 +251,8 @@ def test_nested_shell_c_payloads_fail_admission() -> None: _TERRAFORM_RULE, ), ("#!/bin/sh\nbash -e -c 'terraform apply'\n", _TERRAFORM_RULE), + ("#!/bin/sh\nbash -ce 'terraform apply'\n", _TERRAFORM_RULE), + ("#!/bin/sh\nsh -cx 'helm install app chart/'\n", _HELM_RULE), ( "#!/bin/sh\nbash --noprofile -c 'helm install app chart/'\n", _HELM_RULE, @@ -274,6 +276,8 @@ def test_manifest_nested_shell_c_payloads_fail_admission() -> None: {"command": "bash", "args": ["-c", "helm install app chart/"]}, {"command": "/bin/sh", "args": ["-lc", "terraform apply"]}, {"command": "bash", "args": ["-e", "-c", "terraform apply"]}, + {"command": "bash", "args": ["-ce", "terraform apply"]}, + {"command": "sh", "args": ["-cx", "helm install app chart/"]}, { "command": "bash", "args": ["--noprofile", "-c", "helm install app chart/"], @@ -302,6 +306,10 @@ def test_nested_shell_c_payload_boundaries_stay_negative() -> None: "#!/bin/sh\nbash -gc 'terraform apply'\n", "#!/bin/sh\nbash -g -c 'terraform apply'\n", "#!/bin/sh\nbash -o pipefail -c 'terraform apply'\n", + "#!/bin/sh\nbash -nc 'terraform apply'\n", + "#!/bin/sh\nsh -cn 'terraform apply'\n", + "#!/bin/sh\ndash -r -c 'terraform apply'\n", + "#!/bin/sh\nbash -e --noprofile -c 'terraform apply'\n", ) for body in hook_bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) @@ -319,6 +327,13 @@ def test_nested_shell_c_payload_boundaries_stay_negative() -> None: {"command": "bash", "args": ["-gc", "terraform apply"]}, {"command": "bash", "args": ["-g", "-c", "terraform apply"]}, {"command": "bash", "args": ["-o", "pipefail", "-c", "terraform apply"]}, + {"command": "bash", "args": ["-nc", "terraform apply"]}, + {"command": "sh", "args": ["-cn", "terraform apply"]}, + {"command": "dash", "args": ["-r", "-c", "terraform apply"]}, + { + "command": "bash", + "args": ["-e", "--noprofile", "-c", "terraform apply"], + }, ) for manifest in manifests: content = json.dumps({"mcpServers": {"reader": manifest}}) From a9b72debe4e74454d1d0490f14f00fd877024de2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:33:21 +0900 Subject: [PATCH 31/39] fix(scanner): model executable shell option states --- appguardrail_core/claude_plugin_detector.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index b657bdab..4b2be98e 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -377,7 +377,8 @@ {":", "echo", "false", "print", "printf", "true"} ) _SHELL_COMMAND_INTERPRETERS: Final = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) -_SHELL_NO_VALUE_SHORT_OPTIONS: Final = frozenset("eflnruvx") +_SHELL_NO_VALUE_SHORT_OPTIONS: Final = frozenset("efluvx") +_BASH_NO_VALUE_SHORT_OPTIONS: Final = frozenset("r") _BASH_NO_VALUE_LONG_OPTIONS: Final = frozenset( {"--noprofile", "--norc", "--posix", "--restricted", "--verbose"} ) @@ -1993,20 +1994,29 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] def _shell_payload_index( arguments: tuple[str, ...] | list[str], *, shell_name: str ) -> int | None: - """Return the payload index after bounded no-value options ending in -c.""" + """Return the payload index after bounded executable shell options.""" + seen_short_option = False for index, token in enumerate(arguments): if shell_name == "bash" and token in _BASH_NO_VALUE_LONG_OPTIONS: + if seen_short_option: + return None continue if not token.startswith("-") or token.startswith("--"): return None + seen_short_option = True flags = token[1:] + allowed_flags = _SHELL_NO_VALUE_SHORT_OPTIONS + if shell_name == "bash": + allowed_flags |= _BASH_NO_VALUE_SHORT_OPTIONS if not flags or any( - flag not in _SHELL_NO_VALUE_SHORT_OPTIONS and flag != "c" + flag not in allowed_flags and flag not in {"c", "n"} for flag in flags ): return None + if "n" in flags: + return None if "c" in flags: - if flags.count("c") != 1 or not flags.endswith("c"): + if flags.count("c") != 1: return None payload_index = index + 1 return payload_index if payload_index < len(arguments) else None From 89115aa107a4ba89ec638f3f69feada54856da8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:36:31 +0900 Subject: [PATCH 32/39] test(scanner): cover repeated and interactive shell options --- tests/test_claude_plugin_terraform_helm.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 2f734a00..d00574a1 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -253,6 +253,9 @@ def test_nested_shell_c_payloads_fail_admission() -> None: ("#!/bin/sh\nbash -e -c 'terraform apply'\n", _TERRAFORM_RULE), ("#!/bin/sh\nbash -ce 'terraform apply'\n", _TERRAFORM_RULE), ("#!/bin/sh\nsh -cx 'helm install app chart/'\n", _HELM_RULE), + ("#!/bin/sh\nsh -cc 'terraform apply'\n", _TERRAFORM_RULE), + ("#!/bin/sh\ndash -s -c 'terraform apply'\n", _TERRAFORM_RULE), + ("#!/bin/sh\nbash --login -c 'helm install app chart/'\n", _HELM_RULE), ( "#!/bin/sh\nbash --noprofile -c 'helm install app chart/'\n", _HELM_RULE, @@ -278,6 +281,9 @@ def test_manifest_nested_shell_c_payloads_fail_admission() -> None: {"command": "bash", "args": ["-e", "-c", "terraform apply"]}, {"command": "bash", "args": ["-ce", "terraform apply"]}, {"command": "sh", "args": ["-cx", "helm install app chart/"]}, + {"command": "sh", "args": ["-cc", "terraform apply"]}, + {"command": "dash", "args": ["-i", "-c", "terraform apply"]}, + {"command": "bash", "args": ["--login", "-c", "helm install app chart/"]}, { "command": "bash", "args": ["--noprofile", "-c", "helm install app chart/"], From 37bdc4de6966142e834fe64f98ab05fec851af39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:37:10 +0900 Subject: [PATCH 33/39] fix(scanner): admit executable shell option forms --- appguardrail_core/claude_plugin_detector.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 4b2be98e..3d4c5cce 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -377,10 +377,10 @@ {":", "echo", "false", "print", "printf", "true"} ) _SHELL_COMMAND_INTERPRETERS: Final = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) -_SHELL_NO_VALUE_SHORT_OPTIONS: Final = frozenset("efluvx") +_SHELL_NO_VALUE_SHORT_OPTIONS: Final = frozenset("efilsuvx") _BASH_NO_VALUE_SHORT_OPTIONS: Final = frozenset("r") _BASH_NO_VALUE_LONG_OPTIONS: Final = frozenset( - {"--noprofile", "--norc", "--posix", "--restricted", "--verbose"} + {"--login", "--noprofile", "--norc", "--posix", "--restricted", "--verbose"} ) _SHELL_ASSIGNMENT_PREFIX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") _FIRST_SHELL_TOKEN = re.compile(r"\s*(:|[A-Za-z0-9_./+-]+)") @@ -2016,8 +2016,6 @@ def _shell_payload_index( if "n" in flags: return None if "c" in flags: - if flags.count("c") != 1: - return None payload_index = index + 1 return payload_index if payload_index < len(arguments) else None return None From 71c61877eb461c780448504b1ce9bf100730a65b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:41:28 +0900 Subject: [PATCH 34/39] test(scanner): cover Bash execution-preserving options --- tests/test_claude_plugin_terraform_helm.py | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index d00574a1..c7bc2b52 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -271,6 +271,30 @@ def test_nested_shell_c_payloads_fail_admission() -> None: assert expected_rule in rule_ids + for option in ( + "-ac", + "-bc", + "-hc", + "-kc", + "-mc", + "-pc", + "-tc", + "-Bc", + "-Cc", + "-Ec", + "-Hc", + "-Pc", + "-Tc", + ): + body = f"#!/bin/sh\nbash {option} 'terraform apply'\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _TERRAFORM_RULE for hit in hits) + + for option in ("--debug", "--debugger", "--noediting", "--pretty-print"): + body = f"#!/bin/sh\nbash {option} -c 'terraform apply'\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _TERRAFORM_RULE for hit in hits) + def test_manifest_nested_shell_c_payloads_fail_admission() -> None: """Shell-string and direct-argv manifest payloads use the same boundary.""" @@ -295,6 +319,35 @@ def test_manifest_nested_shell_c_payloads_fail_admission() -> None: assert any(hit.rule_id in _THIS_CLASS for hit in hits) + bash_options = ( + "-ac", + "-bc", + "-hc", + "-kc", + "-mc", + "-pc", + "-tc", + "-Bc", + "-Cc", + "-Ec", + "-Hc", + "-Pc", + "-Tc", + "--debug", + "--debugger", + "--noediting", + "--pretty-print", + ) + for option in bash_options: + args = [option, "terraform apply"] + if option.startswith("--"): + args.insert(1, "-c") + content = json.dumps( + {"mcpServers": {"writer": {"command": "bash", "args": args}}} + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", content) + assert any(hit.rule_id == _TERRAFORM_RULE for hit in hits) + def test_nested_shell_c_payload_boundaries_stay_negative() -> None: """Reporting, wrapper, malformed, and non-write shell payloads stay inert.""" From 8d35ff91e042761d1fda0554b5c93d116d7c8865 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:42:11 +0900 Subject: [PATCH 35/39] fix(scanner): cover Bash execution-preserving options --- appguardrail_core/claude_plugin_detector.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 3d4c5cce..66406396 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -378,9 +378,20 @@ ) _SHELL_COMMAND_INTERPRETERS: Final = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) _SHELL_NO_VALUE_SHORT_OPTIONS: Final = frozenset("efilsuvx") -_BASH_NO_VALUE_SHORT_OPTIONS: Final = frozenset("r") +_BASH_NO_VALUE_SHORT_OPTIONS: Final = frozenset("abhkmprtBCEHPT") _BASH_NO_VALUE_LONG_OPTIONS: Final = frozenset( - {"--login", "--noprofile", "--norc", "--posix", "--restricted", "--verbose"} + { + "--debug", + "--debugger", + "--login", + "--noediting", + "--noprofile", + "--norc", + "--posix", + "--pretty-print", + "--restricted", + "--verbose", + } ) _SHELL_ASSIGNMENT_PREFIX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") _FIRST_SHELL_TOKEN = re.compile(r"\s*(:|[A-Za-z0-9_./+-]+)") From 66674723c0c1414719c2c92a31dff0a6042af03d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:17:01 +0900 Subject: [PATCH 36/39] fix(scanner): reuse command parser for kubectl and Docker --- appguardrail_core/claude_plugin_detector.py | 139 +++++++++++++------- 1 file changed, 91 insertions(+), 48 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 66406396..1d49cd1c 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -362,9 +362,11 @@ r"\bgh\s+release\s+(?Pcreate|upload|delete|edit)\b", re.IGNORECASE, ) -_KUBECTL_APPLY_COMMAND = re.compile(r"\bkubectl\s+apply\b", re.IGNORECASE) +_KUBECTL_APPLY_COMMAND = re.compile( + r"\bkubectl\s+apply(?=$|[\s;&|()<>])", re.IGNORECASE +) _DOCKER_PUSH_COMMAND = re.compile( - r"\bdocker(?:\s+image)?\s+push\b", + r"\bdocker(?:\s+image)?\s+push(?=$|[\s;&|()<>])", re.IGNORECASE, ) _TERRAFORM_APPLY_COMMAND = re.compile( @@ -884,8 +886,8 @@ def inspect_claude_plugin_file( hits.extend(_github_write_token_hits(content)) hits.extend(_github_merge_command_hits(content)) hits.extend(_github_release_command_hits(content)) - hits.extend(_kubectl_apply_command_hits(content)) - hits.extend(_docker_push_command_hits(content)) + hits.extend(_kubectl_apply_command_hits(content, manifest=manifest)) + hits.extend(_docker_push_command_hits(content, manifest=manifest)) hits.extend(_terraform_apply_command_hits(content, manifest=manifest)) hits.extend(_helm_install_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) @@ -1558,51 +1560,92 @@ def _github_release_command_hits(content: str) -> tuple[PluginHit, ...]: ) -def _kubectl_apply_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return ``kubectl apply`` findings with a command label, not manifests. - - Args: - content: Hook or manifest text. - - Returns: - One hit when ``kubectl apply`` is present. ``kubectl get`` and - README wording are not this class. - """ - match = _KUBECTL_APPLY_COMMAND.search(content) - if match is None: - return () - return ( - PluginHit( - rule_id="claude-plugin-kubectl-apply-command", - line=content[: match.start()].count("\n") + 1, - snippet="kubectl apply", - message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE, - ), - ) - - -def _docker_push_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return ``docker push`` findings with a command label, not image names. - - Args: - content: Hook or manifest text. - - Returns: - One hit for ``docker push`` or ``docker image push``. - ``docker ps``, ``docker pull``, and socket binds are not this class. - """ - match = _DOCKER_PUSH_COMMAND.search(content) - if match is None: - return () - return ( - PluginHit( - rule_id="claude-plugin-docker-push-command", - line=content[: match.start()].count("\n") + 1, - snippet="docker push", - message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE, - ), - ) +def _kubectl_apply_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable kubectl apply findings, including typed argv.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-kubectl-apply-command", + line=first_line + source[: match.start()].count("\n"), + snippet="kubectl apply", + message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE, + ), + ) + if manifest: + line = _manifest_argv_command_line( + content, executable="kubectl", verb="apply" + ) + if line is not None: + return ( + PluginHit( + rule_id="claude-plugin-kubectl-apply-command", + line=line, + snippet="kubectl apply", + message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE, + ), + ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-kubectl-apply-command", + line=first_line + source[: match.start()].count("\n"), + snippet="kubectl apply", + message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE, + ), + ) + return () +def _docker_push_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable Docker push findings, including typed argv.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _DOCKER_PUSH_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-docker-push-command", + line=first_line + source[: match.start()].count("\n"), + snippet="docker push", + message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE, + ), + ) + if manifest: + for command, args, line in _manifest_argv_sources(content): + if _direct_executable_basename(command) != "docker": + continue + folded = tuple(argument.casefold() for argument in args) + if folded[:1] == ("push",) or folded[:2] == ("image", "push"): + return ( + PluginHit( + rule_id="claude-plugin-docker-push-command", + line=line, + snippet="docker push", + message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE, + ), + ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _DOCKER_PUSH_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-docker-push-command", + line=first_line + source[: match.start()].count("\n"), + snippet="docker push", + message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE, + ), + ) + return () def _unquoted_hash_index(line: str) -> int | None: """Return the index of an unquoted ``#`` shell comment, if any. From 0093507fba039757a1c43afddb4028374c898359 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:17:33 +0900 Subject: [PATCH 37/39] test(scanner): retain kubectl and Docker parser regressions --- tests/test_claude_plugin_deployment_write.py | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/test_claude_plugin_deployment_write.py b/tests/test_claude_plugin_deployment_write.py index 5a1a7c83..93896be6 100644 --- a/tests/test_claude_plugin_deployment_write.py +++ b/tests/test_claude_plugin_deployment_write.py @@ -259,3 +259,78 @@ def test_vendored_hook_is_not_this_class(tmp_path: Path, command: str) -> None: vendor.chmod(0o755) assert _hits(root, _KUBECTL_RULE) == [] assert _hits(root, _DOCKER_PUSH_RULE) == [] + +def _direct_rule_ids(content: str, *, manifest: bool = False) -> set[str]: + """Return deployment-write rule identities for one in-memory surface.""" + filename = "plugin.json" if manifest else "deploy.sh" + path = ".claude-plugin/plugin.json" if manifest else "hooks/deploy.sh" + return { + hit.rule_id + for hit in inspect_claude_plugin_file(filename, path, content) + if hit.rule_id in _THIS_CLASS + } + + +@pytest.mark.parametrize( + ("payload", "expected_rule"), + ( + ({"command": "kubectl", "args": ["apply", "-f", "deploy.yml"]}, _KUBECTL_RULE), + ({"command": "/usr/bin/kubectl", "args": ["apply"]}, _KUBECTL_RULE), + ({"command": "docker", "args": ["push", "example/app:1"]}, _DOCKER_PUSH_RULE), + ( + {"command": "docker.exe", "args": ["image", "push", "example/app:1"]}, + _DOCKER_PUSH_RULE, + ), + ), +) +def test_manifest_typed_argv_detects_deployment_write( + payload: dict[str, object], expected_rule: str +) -> None: + """Typed argv preserves executable and argument identity.""" + assert expected_rule in _direct_rule_ids(json.dumps(payload), manifest=True) + + +@pytest.mark.parametrize( + ("command", "expected_rule"), + ( + ("sh -c 'kubectl apply -f deploy.yml'", _KUBECTL_RULE), + ("bash -lc 'docker image push example/app:1'", _DOCKER_PUSH_RULE), + ), +) +def test_nested_shell_payload_detects_deployment_write( + command: str, expected_rule: str +) -> None: + """A bounded shell -c payload remains executable command text.""" + assert expected_rule in _direct_rule_ids(command) + + +@pytest.mark.parametrize( + "content", + ( + json.dumps({"description": "kubectl apply is forbidden"}), + "echo 'docker push example/app:1'", + "sh -nc 'kubectl apply -f deploy.yml'", + "VALUE='docker push example/app:1'", + ), +) +def test_inert_prose_reporting_and_noexec_payload_stay_negative(content: str) -> None: + """Descriptions, reporting arguments, and noexec payloads are inert.""" + assert _direct_rule_ids(content, manifest=content.startswith("{")) == set() + + +@pytest.mark.parametrize( + "payload", + ( + {"command": "kubectl", "args": ["apply-now"]}, + {"command": " kubectl ", "args": ["apply"]}, + {"command": "docker", "args": ["pull", "example/app:1"]}, + {"command": "docker", "args": ["image", "pushLocal"]}, + {"command": "docker", "args": "push example/app:1"}, + {"command": "docker", "args": ["push", 1]}, + ), +) +def test_manifest_typed_argv_near_misses_stay_negative( + payload: dict[str, object] +) -> None: + """Malformed types and near verbs do not broaden authority detection.""" + assert _direct_rule_ids(json.dumps(payload), manifest=True) == set() From 2a5509840d0e6130117f644d92a951e0f0c910cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:41:07 +0900 Subject: [PATCH 38/39] fix(scanner): inherit GitHub command parser --- appguardrail_core/claude_plugin_detector.py | 148 +++++++++++++------- 1 file changed, 99 insertions(+), 49 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 1d49cd1c..a2e5db72 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -357,9 +357,12 @@ _GITHUB_TOKEN = re.compile( r"\b(?Pghp_|github_pat_|gho_|ghu_|ghs_)[A-Za-z0-9_]{20,}\b" ) -_GITHUB_MERGE_COMMAND = re.compile(r"\bgh\s+pr\s+merge\b", re.IGNORECASE) +_GITHUB_MERGE_COMMAND = re.compile( + r"\bgh\s+pr\s+merge(?=$|[\s;&|()<>])", re.IGNORECASE +) _GITHUB_RELEASE_COMMAND = re.compile( - r"\bgh\s+release\s+(?Pcreate|upload|delete|edit)\b", + r"\bgh\s+release\s+(?Pcreate|upload|delete|edit)" + r"(?=$|[\s;&|()<>])", re.IGNORECASE, ) _KUBECTL_APPLY_COMMAND = re.compile( @@ -884,8 +887,8 @@ def inspect_claude_plugin_file( hits.extend(_package_lifecycle_hits(content)) if manifest or hook_surface: hits.extend(_github_write_token_hits(content)) - hits.extend(_github_merge_command_hits(content)) - hits.extend(_github_release_command_hits(content)) + hits.extend(_github_merge_command_hits(content, manifest=manifest)) + hits.extend(_github_release_command_hits(content, manifest=manifest)) hits.extend(_kubectl_apply_command_hits(content, manifest=manifest)) hits.extend(_docker_push_command_hits(content, manifest=manifest)) hits.extend(_terraform_apply_command_hits(content, manifest=manifest)) @@ -1513,52 +1516,99 @@ def _github_write_token_hits(content: str) -> tuple[PluginHit, ...]: ) -def _github_merge_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return ``gh pr merge`` findings with a command label, not tokens. - - Args: - content: Hook or manifest text. - - Returns: - One hit when the merge CLI is present. Empty when the text only - lists, views, or reviews pull requests. - """ - match = _GITHUB_MERGE_COMMAND.search(content) - if match is None: - return () - return ( - PluginHit( - rule_id="claude-plugin-github-merge-command", - line=content[: match.start()].count("\n") + 1, - snippet="gh pr merge", - message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, - ), - ) - - -def _github_release_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return GitHub CLI release write-verb findings without secret bodies. - - Args: - content: Hook or manifest text. - - Returns: - One hit for ``create``, ``upload``, ``delete``, or ``edit``. - ``gh release list`` and ``gh release view`` are not this class. - """ - match = _GITHUB_RELEASE_COMMAND.search(content) - if match is None: - return () - verb = match.group("verb").lower() - return ( - PluginHit( - rule_id="claude-plugin-github-release-command", - line=content[: match.start()].count("\n") + 1, - snippet=f"gh release {verb}", - message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, - ), - ) +def _github_merge_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable GitHub merge findings, including typed argv.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _GITHUB_MERGE_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-github-merge-command", + line=first_line + source[: match.start()].count("\n"), + snippet="gh pr merge", + message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + ), + ) + if manifest: + for command, args, line in _manifest_argv_sources(content): + folded = tuple(argument.casefold() for argument in args) + if ( + _direct_executable_basename(command) == "gh" + and folded[:2] == ("pr", "merge") + ): + return ( + PluginHit( + rule_id="claude-plugin-github-merge-command", + line=line, + snippet="gh pr merge", + message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + ), + ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _GITHUB_MERGE_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-github-merge-command", + line=first_line + source[: match.start()].count("\n"), + snippet="gh pr merge", + message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + ), + ) + return () +def _github_release_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable GitHub release findings, including typed argv.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _GITHUB_RELEASE_COMMAND) + if match is not None: + verb = match.group("verb").lower() + return ( + PluginHit( + rule_id="claude-plugin-github-release-command", + line=first_line + source[: match.start()].count("\n"), + snippet=f"gh release {verb}", + message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + ), + ) + if manifest: + for command, args, line in _manifest_argv_sources(content): + folded = tuple(argument.casefold() for argument in args) + if ( + _direct_executable_basename(command) == "gh" + and len(folded) >= 2 + and folded[0] == "release" + and folded[1] in {"create", "upload", "delete", "edit"} + ): + return ( + PluginHit( + rule_id="claude-plugin-github-release-command", + line=line, + snippet=f"gh release {folded[1]}", + message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + ), + ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _GITHUB_RELEASE_COMMAND) + if match is not None: + verb = match.group("verb").lower() + return ( + PluginHit( + rule_id="claude-plugin-github-release-command", + line=first_line + source[: match.start()].count("\n"), + snippet=f"gh release {verb}", + message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + ), + ) + return () def _kubectl_apply_command_hits( content: str, *, manifest: bool = False From 62d6bfe63eb30ab3838c792e781d98beeba11bb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:43:18 +0900 Subject: [PATCH 39/39] test(scanner): retain GitHub command-context corpus --- ...test_claude_plugin_github_merge_release.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_claude_plugin_github_merge_release.py b/tests/test_claude_plugin_github_merge_release.py index 14916cca..d6a495bb 100644 --- a/tests/test_claude_plugin_github_merge_release.py +++ b/tests/test_claude_plugin_github_merge_release.py @@ -245,3 +245,79 @@ def test_github_write_token_without_merge_stays_the_pat_class(tmp_path: Path) -> assert any(hit.rule_id == _WRITE_TOKEN_RULE for hit in hits) assert all(hit.rule_id != _MERGE_RULE for hit in hits) assert all(hit.rule_id != _RELEASE_RULE for hit in hits) +def _direct_rule_ids(content: str, *, manifest: bool = False) -> set[str]: + """Return GitHub-command rule identities for one in-memory surface.""" + filename = "plugin.json" if manifest else "deploy.sh" + path = ".claude-plugin/plugin.json" if manifest else "hooks/deploy.sh" + return { + hit.rule_id + for hit in inspect_claude_plugin_file(filename, path, content) + if hit.rule_id in _THIS_CLASS + } + + +@pytest.mark.parametrize( + ("payload", "expected_rule"), + ( + ({"command": "gh", "args": ["pr", "merge", "42"]}, _MERGE_RULE), + ( + {"command": "/usr/bin/gh", "args": ["release", "create", "v1"]}, + _RELEASE_RULE, + ), + ( + {"command": "gh.exe", "args": ["release", "upload", "v1", "a"]}, + _RELEASE_RULE, + ), + ), +) +def test_manifest_typed_argv_detects_github_writes( + payload: dict[str, object], expected_rule: str +) -> None: + """Typed argv preserves executable and argument identity.""" + assert expected_rule in _direct_rule_ids(json.dumps(payload), manifest=True) + + +@pytest.mark.parametrize( + ("command", "expected_rule"), + ( + ("sh -c 'gh pr merge 42'", _MERGE_RULE), + ("bash -lc 'gh release delete v1 --yes'", _RELEASE_RULE), + ), +) +def test_nested_shell_payload_detects_github_writes( + command: str, expected_rule: str +) -> None: + """A bounded shell -c payload remains executable command text.""" + assert expected_rule in _direct_rule_ids(command) + + +@pytest.mark.parametrize( + "content", + ( + json.dumps({"description": "gh pr merge is forbidden"}), + "echo 'gh release create v1'", + "sh -nc 'gh pr merge 42'", + "VALUE='gh release edit v1'", + ), +) +def test_inert_github_text_stays_negative(content: str) -> None: + """Descriptions, reporting, assignments, and noexec payloads are inert.""" + assert _direct_rule_ids(content, manifest=content.startswith("{")) == set() + + +@pytest.mark.parametrize( + "payload", + ( + {"command": "gh", "args": ["pr", "merge-now"]}, + {"command": " gh ", "args": ["pr", "merge"]}, + {"command": "gh", "args": ["release", "list"]}, + {"command": "gh", "args": ["release", "createLocal"]}, + {"command": "gh", "args": "pr merge 42"}, + {"command": "gh", "args": ["release", 1]}, + ), +) +def test_manifest_typed_argv_near_misses_stay_negative( + payload: dict[str, object] +) -> None: + """Malformed types and near verbs do not broaden GitHub write detection.""" + assert _direct_rule_ids(json.dumps(payload), manifest=True) == set()