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 9356db59..9e180c1c 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -135,7 +135,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 95497524..66cabe05 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -22,14 +22,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 @@ -234,6 +237,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 " @@ -360,6 +373,12 @@ r"\bdocker(?:\s+image)?\s+push(?=$|[\s;&|()<>])", 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"} ) @@ -876,6 +895,8 @@ def inspect_claude_plugin_file( 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)) + 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)) @@ -1599,6 +1620,93 @@ def _github_release_command_hits( ) return () +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. @@ -2085,93 +2193,97 @@ def _nested_shell_payload_sources( return tuple(found) -def _kubectl_apply_command_hits( +def _terraform_apply_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: - """Return executable kubectl apply findings, including typed argv.""" + """Return executable terraform apply findings without vars.""" 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, - ), - ) + 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, + ), + ) if manifest: line = _manifest_argv_command_line( - content, executable="kubectl", verb="apply" + content, + executable="terraform", + verb="apply", + leading_value_option="-chdir=", ) if line is not None: return ( PluginHit( - rule_id="claude-plugin-kubectl-apply-command", + rule_id="claude-plugin-terraform-apply-command", line=line, - snippet="kubectl apply", - message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE, + snippet="terraform apply", + message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, ), ) for source, first_line in _nested_shell_payload_sources( content, manifest=manifest ): - match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND) + match = _executable_command_match(source, _TERRAFORM_APPLY_COMMAND) if match is not None: return ( PluginHit( - rule_id="claude-plugin-kubectl-apply-command", + rule_id="claude-plugin-terraform-apply-command", line=first_line + source[: match.start()].count("\n"), - snippet="kubectl apply", - message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE, + snippet="terraform apply", + message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE, ), ) return () -def _docker_push_command_hits( + +def _helm_install_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: - """Return executable Docker push findings, including typed argv.""" + """Return executable helm install findings without chart names.""" for source, first_line in _hosted_command_sources(content, manifest=manifest): - match = _executable_command_match(source, _DOCKER_PUSH_COMMAND) - if match is not None: + 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, + ), + ) + if manifest: + line = _manifest_argv_command_line(content, executable="helm", verb="install") + if line 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, + rule_id="claude-plugin-helm-install-command", + line=line, + snippet="helm install", + message=CLAUDE_PLUGIN_HELM_INSTALL_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) + match = _executable_command_match(source, _HELM_INSTALL_COMMAND) if match is not None: return ( PluginHit( - rule_id="claude-plugin-docker-push-command", + rule_id="claude-plugin-helm-install-command", line=first_line + source[: match.start()].count("\n"), - snippet="docker push", - message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE, + 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) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 7d1289dd..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-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` | 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 diff --git a/tests/test_claude_plugin_deployment_write.py b/tests/test_claude_plugin_deployment_write.py index f8798835..93896be6 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..c7bc2b52 --- /dev/null +++ b/tests/test_claude_plugin_terraform_helm.py @@ -0,0 +1,565 @@ +"""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_manifest_command_args_preserve_executable_argv() -> None: + """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, + ), + ) + 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 expected_rule in rule_ids + assert len(rule_ids & _THIS_CLASS) == 1 + + +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": "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/"]}, + {"command": "helm", "args": "install"}, + {"command": "terraform", "args": ["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_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\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, + ), + ( + "#!/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 + + 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.""" + manifests = ( + {"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": ["-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/"], + }, + ) + 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) + + 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.""" + 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", + "#!/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", + "#!/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) + 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]}, + {"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"]}, + {"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}}) + 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", "") + 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) + + +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) + +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) + + + +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) + + +def test_here_document_payload_is_not_an_executable_command() -> None: + """Literal here-document payload is data, even when it names deployment CLIs.""" + bodies = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n", + "#!/bin/sh\ncat <<-EOF\n\thelm install app chart/\n\tEOF\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_command_after_here_document_still_fails() -> None: + """An inert payload cannot hide a later executable deployment command.""" + body = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n" + "helm install app chart/\n" + ) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _TERRAFORM_RULE not in rule_ids + assert _HELM_RULE in rule_ids + + +def test_heredoc_opener_lookalikes_do_not_hide_real_commands() -> None: + """Quoted or commented opener text cannot suppress a later real command.""" + bodies = ( + '#!/bin/sh\necho "< 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)