From 391358ff856d6a59e95a3dad6cb07b8c4e8637bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 08:36:42 +0900 Subject: [PATCH 1/7] test(scanner): fail closed on plugin kubectl apply and docker push Lock hook and manifest kubectl apply and docker push as fail-closed findings. Keep kubectl get, docker ps, terraform apply, helm install, README wording, and GitHub merge on their existing classes. Relates to #1099. --- tests/test_claude_plugin_deployment_write.py | 262 +++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/test_claude_plugin_deployment_write.py diff --git a/tests/test_claude_plugin_deployment_write.py b/tests/test_claude_plugin_deployment_write.py new file mode 100644 index 00000000..7f256c50 --- /dev/null +++ b/tests/test_claude_plugin_deployment_write.py @@ -0,0 +1,262 @@ +"""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) == [] From 936311c206ce4aa283e0ec643e2f03f594b5bed1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 08:36:51 +0900 Subject: [PATCH 2/7] feat(scanner): reject plugin kubectl apply and docker push Fail closed on hook and manifest kubectl apply as claude-plugin-kubectl-apply-command and docker push as claude-plugin-docker-push-command. kubectl get, docker ps, terraform apply, helm install, README wording, merge CLI, and Docker sockets stay their existing classes. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 19 ++--- appguardrail_core/claude_plugin_detector.py | 71 ++++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 9 ++- tests/test_claude_plugin_credential_store.py | 6 +- ...test_claude_plugin_github_merge_release.py | 7 +- tests/test_claude_plugin_supply_chain.py | 6 +- 7 files changed, 96 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 177e56e1..14aa6bc9 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. Unsigned `curl`/`wget` executable fetches and + admission as policy findings; `gh issue create` stays inventory + evidence. Unsigned `curl`/`wget` executable fetches and unpinned pip/npm/cargo URL installs fail admission; `package.json` `preinstall`/`install`/`postinstall` scripts that download or execute an unsigned payload fail closed on the same rules, while a lockfile-only @@ -127,15 +127,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 6f8a11bf..5d5f7b92 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -20,13 +20,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 @@ -220,6 +222,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 " @@ -336,6 +348,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) +_DOCKER_PUSH_COMMAND = re.compile( + r"\bdocker(?:\s+image)?\s+push\b", + re.IGNORECASE, +) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -820,6 +837,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(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -1490,6 +1509,52 @@ 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 _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 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_github_merge_release.py b/tests/test_claude_plugin_github_merge_release.py index 5f721281..14916cca 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 diff --git a/tests/test_claude_plugin_supply_chain.py b/tests/test_claude_plugin_supply_chain.py index b92be9d8..ea750acf 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) @@ -2360,7 +2361,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 From e6d606246de48f3da1cebe5fde60dd62f9b0b99e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:08:11 +0900 Subject: [PATCH 3/7] test(scanner): reproduce typed deployment command gaps --- 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 7f256c50..f8798835 100644 --- a/tests/test_claude_plugin_deployment_write.py +++ b/tests/test_claude_plugin_deployment_write.py @@ -260,3 +260,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 a6efcf514ed13c491888727606b0e56b5689f02d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:11:00 +0900 Subject: [PATCH 4/7] fix(scanner): parse executable deployment commands --- appguardrail_core/claude_plugin_detector.py | 641 ++++++++++++++++++-- 1 file changed, 590 insertions(+), 51 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 5d5f7b92..b3746582 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -60,6 +60,7 @@ import os from pathlib import Path import re +import shlex import stat import tarfile from typing import Final, Iterable @@ -348,11 +349,40 @@ 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, ) +_REPORTING_BUILTINS: Final = frozenset( + {":", "echo", "false", "print", "printf", "true"} +) +_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("abhkmprtBCEHPT") +_BASH_NO_VALUE_LONG_OPTIONS: Final = frozenset( + { + "--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_./+-]+)") +_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, @@ -837,8 +867,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(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -1509,70 +1539,579 @@ 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. +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: - One hit when ``kubectl apply`` is present. ``kubectl get`` and - README wording are not this class. + The comment index, or ``None`` when every ``#`` is quoted or escaped. """ - 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, - ), - ) + 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 _docker_push_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return ``docker push`` findings with a command label, not image names. +def _iter_unquoted_segment_bounds(line: str) -> tuple[tuple[int, int], ...]: + """Return start/end offsets of unquoted shell command segments. Args: - content: Hook or manifest text. + line: One hook or manifest line without a trailing newline. Returns: - One hit for ``docker push`` or ``docker image push``. - ``docker ps``, ``docker pull``, and socket binds are not this class. + Inclusive-start exclusive-end spans split on unquoted ``&&``, + ``||``, ``;``, ``|``, and ``&``. Quoted lookalikes stay one span. """ - match = _DOCKER_PUSH_COMMAND.search(content) + 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: + A lowercase basename such as ``echo``. Empty when the fragment + has no command token. + """ + 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 command does not execute its argument text. + + Args: + segment: One unquoted command fragment. + + Returns: + ``True`` for no-op, status, and reporting commands, 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-docker-push-command", - line=content[: match.start()].count("\n") + 1, - snippet="docker push", - message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE, - ), - ) + found: list[tuple[str, int]] = [] -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: + 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 _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 + + +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 () - token = match.group(0).strip() - if "(" in token: - label = token.split("(", 1)[0].strip()[:40] - else: - label = token.split()[0][:40] - return ( - PluginHit( - rule_id="claude-plugin-dynamic-eval", - line=content[: match.start()].count("\n") + 1, - snippet=label, - message=CLAUDE_PLUGIN_DYNAMIC_EVAL_MESSAGE, - ), - ) + + found: list[tuple[str, tuple[str, ...], int]] = [] + + def collect(value: object) -> None: + if isinstance(value, dict): + command = value.get("command") + args = value.get("args") + if ( + isinstance(command, str) + and command + and isinstance(args, list) + and all(isinstance(argument, str) for argument in args) + ): + found.append((command, tuple(args), _script_line(content, command))) + for nested in value.values(): + collect(nested) + elif isinstance(value, list): + for nested in value: + collect(nested) + + collect(payload) + 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( + 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 _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 _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. + + 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, closed literal here-document + payloads, shell assignment values, and ``echo``/``printf``/``print`` + segments are not executable. Direct + commands inside ``$(...)`` or backticks remain executable. + + Args: + content: Hook or manifest text. + pattern: Compiled command regex. + + Returns: + The first executable match, or ``None``. + """ + 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: + line_end = len(content) + line = content[line_start:line_end] + relative = match.start() - line_start + 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 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( + segment + ) and not _match_starts_in_shell_assignment_value( + segment, segment_relative + ): + return match + break + return None + + +def _shell_payload_index( + arguments: tuple[str, ...] | list[str], *, shell_name: str +) -> int | None: + """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 allowed_flags and flag not in {"c", "n"} + for flag in flags + ): + return None + if "n" in flags: + return None + if "c" in flags: + payload_index = index + 1 + return payload_index if payload_index < len(arguments) else None + return None + + +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 >= len(tokens): + continue + 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 = 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): + 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) + + +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 _docker_socket_hits(content: str) -> tuple[PluginHit, ...]: From de1b7c1f443a8347f1212e72ab4ed7ff281903c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:15:14 +0900 Subject: [PATCH 5/7] fix(scanner): preserve adjacent dynamic-eval analyzer --- appguardrail_core/claude_plugin_detector.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index b3746582..8fbfb229 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -2068,7 +2068,6 @@ def _kubectl_apply_command_hits( ) return () - def _docker_push_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: @@ -2113,6 +2112,25 @@ def _docker_push_command_hits( ) 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: + return () + token = match.group(0).strip() + if "(" in token: + label = token.split("(", 1)[0].strip()[:40] + else: + label = token.split()[0][:40] + return ( + PluginHit( + rule_id="claude-plugin-dynamic-eval", + line=content[: match.start()].count("\n") + 1, + snippet=label, + message=CLAUDE_PLUGIN_DYNAMIC_EVAL_MESSAGE, + ), + ) + def _docker_socket_hits(content: str) -> tuple[PluginHit, ...]: """Return host Docker-socket findings from hook or manifest text.""" From 29d322748931ae3200978e6f42a27775b0b51149 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:39:47 +0900 Subject: [PATCH 6/7] 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 8fbfb229..2be78b34 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -344,9 +344,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( @@ -865,8 +868,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(_docker_socket_hits(content)) @@ -1492,52 +1495,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 _unquoted_hash_index(line: str) -> int | None: """Return the index of an unquoted ``#`` shell comment, if any. From 8f23772a59a272446a93b1ab9334a7b1fa21461b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:39:56 +0900 Subject: [PATCH 7/7] 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()