diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 3408726c..9356db59 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -22,8 +22,8 @@ source, or marketplace identity, or replay against mutated bytes; verification is not Noema admission. Hardcoded GitHub PAT or app tokens, host Docker socket binds, and named secrets copied into curl/wget/fetch fail - admission as policy findings; `gh issue create` and `docker push` stay - inventory evidence. Secret references require a complete environment-variable + admission as policy findings; `gh issue create` stays inventory evidence. + Secret references require a complete environment-variable name, so longer documentation variables do not collide with protected names. Unsigned `curl`/`wget` executable fetches and unpinned pip/npm/cargo URL installs fail admission; `package.json` @@ -130,15 +130,18 @@ Hook or manifest ``gh pr merge`` fails as `claude-plugin-github-merge-command`. ``gh release create``, ``upload``, ``delete``, or ``edit`` fails as - `claude-plugin-github-release-command`. ``gh issue create``, - ``gh pr review``, ``gh release list``, ``kubectl apply``, and - ``docker push`` stay inventory. Hardcoded PATs stay - `claude-plugin-github-write-token`. Snippets are command labels, not - tokens. + `claude-plugin-github-release-command`. Hook or manifest + ``kubectl apply`` fails as `claude-plugin-kubectl-apply-command`. + ``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 + PATs stay `claude-plugin-github-write-token`. Snippets are command + labels, not tokens. Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``, ``~/.config/gh/hosts.yml``, Docker ``config.json`` auth, ``cookies.txt``, ``~/.curl_home``, and ``~/.ssh/id_*`` private keys fail as `claude-plugin-credential-store-access`. Chrome and Firefox profile stores stay `claude-plugin-browser-profile-access`. README AWS wording, - ``gh issue create``, ``docker push``, and a declared ``0755`` echo hook + ``gh issue create``, and a declared ``0755`` echo hook are not that class. Snippets are path labels, not secret values. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index a43a2aa4..95497524 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -21,13 +21,15 @@ finding. Capability inventory is evidence, 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 paths into ``~/.netrc``, ``~/.aws/credentials``, +Hook or manifest ``kubectl apply`` and ``docker push`` fail closed as +deployment-write command findings. 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 apply``, and -``docker push`` stay inventory. Skill +``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, +``terraform apply``, and ``helm install`` 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 @@ -222,6 +224,16 @@ "delete, or edit. Publishing a release is write authority. Remove the " "command. [CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs kubectl apply. Applying manifests " + "is write authority on a cluster. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs docker push. Pushing an image is " + "write authority on a registry. 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 " @@ -341,6 +353,13 @@ r"(?=$|[\s;&|()<>])", 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(?=$|[\s;&|()<>])", + re.IGNORECASE, +) _REPORTING_BUILTINS: Final = frozenset( {":", "echo", "false", "print", "printf", "true"} ) @@ -855,6 +874,8 @@ def inspect_claude_plugin_file( hits.extend(_github_write_token_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(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -1484,6 +1505,100 @@ def _github_write_token_hits(content: str) -> tuple[PluginHit, ...]: ) +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 _unquoted_hash_index(line: str) -> int | None: """Return the index of an unquoted ``#`` shell comment, if any. @@ -1970,96 +2085,89 @@ def _nested_shell_payload_sources( return tuple(found) -def _github_merge_command_hits( +def _kubectl_apply_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: - """Return executable GitHub merge findings, including typed argv.""" + """Return executable kubectl apply findings, including typed argv.""" for source, first_line in _hosted_command_sources(content, manifest=manifest): - match = _executable_command_match(source, _GITHUB_MERGE_COMMAND) + match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND) if match is not None: return ( PluginHit( - rule_id="claude-plugin-github-merge-command", + rule_id="claude-plugin-kubectl-apply-command", line=first_line + source[: match.start()].count("\n"), - snippet="gh pr merge", - message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + snippet="kubectl apply", + message=CLAUDE_PLUGIN_KUBECTL_APPLY_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, - ), - ) + 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, _GITHUB_MERGE_COMMAND) + match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND) if match is not None: return ( PluginHit( - rule_id="claude-plugin-github-merge-command", + rule_id="claude-plugin-kubectl-apply-command", line=first_line + source[: match.start()].count("\n"), - snippet="gh pr merge", - message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + snippet="kubectl apply", + message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE, ), ) return () -def _github_release_command_hits( +def _docker_push_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: - """Return executable GitHub release findings, including typed argv.""" + """Return executable Docker push findings, including typed argv.""" for source, first_line in _hosted_command_sources(content, manifest=manifest): - match = _executable_command_match(source, _GITHUB_RELEASE_COMMAND) + match = _executable_command_match(source, _DOCKER_PUSH_COMMAND) if match is not None: - verb = match.group("verb").lower() return ( PluginHit( - rule_id="claude-plugin-github-release-command", + rule_id="claude-plugin-docker-push-command", line=first_line + source[: match.start()].count("\n"), - snippet=f"gh release {verb}", - message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + 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 ( - _direct_executable_basename(command) == "gh" - and len(folded) >= 2 - and folded[0] == "release" - and folded[1] in {"create", "upload", "delete", "edit"} - ): + if folded[:1] == ("push",) or folded[:2] == ("image", "push"): return ( PluginHit( - rule_id="claude-plugin-github-release-command", + rule_id="claude-plugin-docker-push-command", line=line, - snippet=f"gh release {folded[1]}", - message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + 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, _GITHUB_RELEASE_COMMAND) + match = _executable_command_match(source, _DOCKER_PUSH_COMMAND) if match is not None: - verb = match.group("verb").lower() return ( PluginHit( - rule_id="claude-plugin-github-release-command", + rule_id="claude-plugin-docker-push-command", line=first_line + source[: match.start()].count("\n"), - snippet=f"gh release {verb}", - message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + snippet="docker push", + message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE, ), ) return () diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index e1765c2c..7d1289dd 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-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-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 0c76c258..b6c24b52 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -96,13 +96,16 @@ files being scanned, then applies the union of relevant checks. Examples: disagrees with artifact 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, and + create/upload/delete/edit, + `claude-plugin-kubectl-apply-command` for ``kubectl apply``, + `claude-plugin-docker-push-command` for ``docker push``, 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``, and - ``docker push`` stay inventory. + `claude-plugin-github-write-token`. ``gh issue create``, ``gh pr review``, + ``kubectl get``, ``docker ps``, ``terraform apply``, and ``helm install`` + 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_credential_store.py b/tests/test_claude_plugin_credential_store.py index 2abc5b52..81c3b8f1 100644 --- a/tests/test_claude_plugin_credential_store.py +++ b/tests/test_claude_plugin_credential_store.py @@ -187,13 +187,13 @@ def test_gh_issue_create_stays_inventory(tmp_path: Path) -> None: assert inventory["credential_access"] is False -def test_docker_push_stays_inventory(tmp_path: Path) -> None: - """``docker push`` stays deployment inventory, not Docker auth-store access.""" +def test_docker_push_is_not_credential_store(tmp_path: Path) -> None: + """``docker push`` is not Docker auth-store access.""" root = _licensed_plugin(tmp_path, "#!/bin/sh\ndocker push example/app:1\n") receipt = build_claude_plugin_scan_receipt(root) inventory = inventory_claude_plugin_capabilities(root) assert _hits(root, _STORE_RULE) == [] - assert receipt.scan_result == "pass" + assert _STORE_RULE not in receipt.finding_summary assert inventory["deployment_write"] is True diff --git a/tests/test_claude_plugin_deployment_write.py b/tests/test_claude_plugin_deployment_write.py new file mode 100644 index 00000000..f8798835 --- /dev/null +++ b/tests/test_claude_plugin_deployment_write.py @@ -0,0 +1,337 @@ +"""Hook kubectl apply and docker push fail closed; reads stay inventory.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +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" +_KUBECTL_RULE = "claude-plugin-kubectl-apply-command" +_DOCKER_PUSH_RULE = "claude-plugin-docker-push-command" +_MERGE_RULE = "claude-plugin-github-merge-command" +_SOCKET_RULE = "claude-plugin-docker-socket" +_WRITE_TOKEN_RULE = "claude-plugin-github-write-token" +_SECRET = "sk-deploy-must-not-leak" +_BIDI = "\u202e" +_TEST_GITHUB_PAT = "ghp_" + ("A" * 36) +_THIS_CLASS = frozenset({_KUBECTL_RULE, _DOCKER_PUSH_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_kubectl_apply_fails_admission(tmp_path: Path) -> None: + """``kubectl apply`` on a hook is cluster write authority, not inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nkubectl apply -f deploy.yml\n") + hits = _hits(root, _KUBECTL_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "kubectl apply" for hit in hits) + assert receipt.scan_result == "fail" + assert _KUBECTL_RULE in receipt.finding_summary + assert _DOCKER_PUSH_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_hook_docker_push_fails_admission(tmp_path: Path) -> None: + """``docker push`` on a hook is registry write authority, not inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndocker push example/app:1\n") + hits = _hits(root, _DOCKER_PUSH_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "docker push" for hit in hits) + assert receipt.scan_result == "fail" + assert _DOCKER_PUSH_RULE in receipt.finding_summary + assert _KUBECTL_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_docker_image_push_is_the_same_class(tmp_path: Path) -> None: + """``docker image push`` canonicalizes to the docker-push command label.""" + body = "#!/bin/sh\ndocker image push example/app:1\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + root = _licensed_plugin(tmp_path, body) + assert _hits(root, _DOCKER_PUSH_RULE) + assert any( + hit.rule_id == _DOCKER_PUSH_RULE and hit.snippet == "docker push" for hit in hits + ) + + +def test_kubectl_get_and_docker_ps_stay_inventory(tmp_path: Path) -> None: + """Read-only cluster and daemon commands stay inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nkubectl get pods\ndocker ps\n") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _KUBECTL_RULE) == [] + assert _hits(root, _DOCKER_PUSH_RULE) == [] + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + 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.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nterraform apply -auto-approve\nhelm install app chart/\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_kubectl_apply_is_not_this_class(tmp_path: Path) -> None: + """README deploy wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("kubectl apply -f deploy.yml\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _KUBECTL_RULE) == [] + assert receipt.scan_result == "pass" + assert _KUBECTL_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_kubectl_and_docker_push_on_one_hook_are_distinct_findings( + tmp_path: Path, +) -> None: + """One hook can fail closed on both cluster apply and registry push.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nkubectl apply -f deploy.yml\ndocker push example/app:1\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _KUBECTL_RULE) + assert _hits(root, _DOCKER_PUSH_RULE) + assert receipt.scan_result == "fail" + assert _KUBECTL_RULE in receipt.finding_summary + assert _DOCKER_PUSH_RULE in receipt.finding_summary + assert _MERGE_RULE not in receipt.finding_summary + + +def test_case_insensitive_kubectl_apply_fails_admission(tmp_path: Path) -> None: + """``KUBECTL APPLY`` is the same cluster-write class.""" + body = "#!/bin/sh\nKUBECTL APPLY -f deploy.yml\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + root = _licensed_plugin(tmp_path, body) + assert _hits(root, _KUBECTL_RULE) + assert any( + hit.rule_id == _KUBECTL_RULE and hit.snippet == "kubectl apply" for hit in hits + ) + + +def test_case_insensitive_docker_push_fails_admission() -> None: + """``DOCKER PUSH`` canonicalizes the snippet to ``docker push``.""" + body = "#!/bin/sh\nDOCKER PUSH example/app:1\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any( + hit.rule_id == _DOCKER_PUSH_RULE and hit.snippet == "docker push" for hit in hits + ) + + +def test_snippets_are_command_labels_not_tokens_or_secrets(tmp_path: Path) -> None: + """Snippets name the CLI command and omit tokens, secrets, and bidi.""" + body = ( + f"#!/bin/sh\nexport GH_TOKEN={_TEST_GITHUB_PAT}\n" + f"kubectl apply -f '{_SECRET}{_BIDI}.yml'\n" + ) + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + kubectl_hits = [hit for hit in hits if hit.rule_id == _KUBECTL_RULE] + receipt = build_claude_plugin_scan_receipt(root) + payload = json.dumps(receipt.as_dict()) + + assert kubectl_hits + for hit in kubectl_hits: + assert hit.snippet == "kubectl apply" + assert _TEST_GITHUB_PAT not in hit.snippet + 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 + assert any(hit.rule_id == _WRITE_TOKEN_RULE for hit in hits) + + +def test_plugin_manifest_kubectl_apply_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that applies is the same cluster class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PostToolUse": [{"command": "kubectl apply -f deploy.yml"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _KUBECTL_RULE) + assert receipt.scan_result == "fail" + assert _KUBECTL_RULE in receipt.finding_summary + + +def test_empty_hook_is_not_this_class() -> None: + """Empty hook text is not cluster or registry 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_docker_socket_without_push_stays_the_socket_class() -> None: + """A Docker socket bind without push stays the socket class.""" + hits = inspect_claude_plugin_file( + "run.sh", + "hooks/run.sh", + "docker -H unix:///var/run/docker.sock ps\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _SOCKET_RULE in rule_ids + assert _DOCKER_PUSH_RULE not in rule_ids + + +def test_gh_pr_merge_without_deploy_stays_the_merge_class() -> None: + """Merge CLI without kubectl or docker push stays the merge class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\ngh pr merge 1 --squash\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _MERGE_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) + + +@pytest.mark.parametrize("command", ("kubectl apply", "docker push")) +def test_vendored_hook_is_not_this_class(tmp_path: Path, command: str) -> None: + """Vendored trees stay the vendored-scope class, not deployment-write.""" + root = _licensed_plugin(tmp_path) + vendor = root / "vendor" / "hooks" / "session.sh" + vendor.parent.mkdir(parents=True, exist_ok=True) + vendor.write_text(f"#!/bin/sh\n{command}\n", encoding="utf-8") + 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() diff --git a/tests/test_claude_plugin_github_merge_release.py b/tests/test_claude_plugin_github_merge_release.py index a754a968..d6a495bb 100644 --- a/tests/test_claude_plugin_github_merge_release.py +++ b/tests/test_claude_plugin_github_merge_release.py @@ -127,8 +127,10 @@ def test_gh_issue_create_and_pr_review_stay_inventory(tmp_path: Path) -> None: assert inventory["github_release"] is False -def test_kubectl_apply_and_docker_push_stay_inventory(tmp_path: Path) -> None: - """Deployment writes stay inventory; this slice does not own that family.""" +def test_kubectl_apply_and_docker_push_are_not_merge_or_release( + tmp_path: Path, +) -> None: + """Deployment writes are not the merge or release command family.""" root = _licensed_plugin( tmp_path, "#!/bin/sh\nkubectl apply -f deploy.yml\ndocker push example/app:1\n", @@ -137,7 +139,6 @@ def test_kubectl_apply_and_docker_push_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 @@ -244,7 +245,6 @@ 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" diff --git a/tests/test_claude_plugin_supply_chain.py b/tests/test_claude_plugin_supply_chain.py index 92dfe2c7..5c691993 100644 --- a/tests/test_claude_plugin_supply_chain.py +++ b/tests/test_claude_plugin_supply_chain.py @@ -909,8 +909,8 @@ def test_declared_capability_signals_remain_evidence_not_findings( ) -> None: """GitHub, deploy, package, browser names, and filesystem signals stay inventory. - Merge and release CLI write verbs on the same hook fail closed as - command findings. Issue create, PR review, and kubectl apply do not. + Merge, release, and kubectl apply CLI write verbs on the same hook + fail closed as command findings. Issue create and PR review do not. """ from appguardrail_core.claude_plugin_detector import ( build_claude_plugin_scan_receipt, @@ -950,6 +950,7 @@ def test_declared_capability_signals_remain_evidence_not_findings( assert receipt.scan_result == "fail" assert "claude-plugin-github-merge-command" in receipt.finding_summary assert "claude-plugin-github-release-command" in receipt.finding_summary + assert "claude-plugin-kubectl-apply-command" in receipt.finding_summary assert "claude-plugin-github-write-token" not in receipt.finding_summary assert receipt.capability_inventory_sha256 == _inventory_digest(inventory) @@ -2471,7 +2472,6 @@ def test_docker_push_without_socket_stays_inventory(tmp_path: Path) -> None: receipt = build_claude_plugin_scan_receipt(root) assert inventory["deployment_write"] is True - assert receipt.scan_result == "pass" assert _DOCKER_SOCKET_RULE not in receipt.finding_summary assert _GITHUB_WRITE_TOKEN_RULE not in receipt.finding_summary assert _SECRET_TO_NETWORK_RULE not in receipt.finding_summary