From 32533831851d88c8490e10d86dc1f44fc158a29e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:11:21 +0900 Subject: [PATCH 1/9] test(scanner): fail closed on plugin npm twine and cargo publish Lock executable npm publish, twine upload, and cargo publish. Keep npm pack, cargo check, comments, echo lookalikes, and s3 writes on their existing classes. Relates to #1099. --- tests/test_claude_plugin_registry_publish.py | 211 +++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 tests/test_claude_plugin_registry_publish.py diff --git a/tests/test_claude_plugin_registry_publish.py b/tests/test_claude_plugin_registry_publish.py new file mode 100644 index 00000000..b43d3895 --- /dev/null +++ b/tests/test_claude_plugin_registry_publish.py @@ -0,0 +1,211 @@ +"""Hook npm publish, twine upload, and cargo publish fail closed.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + _collect_plugin_hits, + build_claude_plugin_scan_receipt, + inspect_claude_plugin_file, + inventory_claude_plugin_capabilities, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_NPM_RULE = "claude-plugin-npm-publish-command" +_PYPI_RULE = "claude-plugin-pypi-upload-command" +_CARGO_RULE = "claude-plugin-cargo-publish-command" +_S3_RULE = "claude-plugin-aws-s3-write-command" +_SECRET = "sk-publish-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_NPM_RULE, _PYPI_RULE, _CARGO_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_npm_publish_fails_admission(tmp_path: Path) -> None: + """``npm publish`` on a hook is registry write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nnpm publish --access public\n") + hits = _hits(root, _NPM_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "npm publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _NPM_RULE in receipt.finding_summary + assert _PYPI_RULE not in receipt.finding_summary + assert _S3_RULE not in receipt.finding_summary + assert inventory["package_install"] is True + + +def test_hook_twine_upload_fails_admission(tmp_path: Path) -> None: + """``twine upload`` on a hook is PyPI write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ntwine upload dist/*\n") + hits = _hits(root, _PYPI_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "twine upload" for hit in hits) + assert receipt.scan_result == "fail" + assert _PYPI_RULE in receipt.finding_summary + assert _NPM_RULE not in receipt.finding_summary + + +def test_hook_cargo_publish_fails_admission(tmp_path: Path) -> None: + """``cargo publish`` on a hook is crates.io write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ncargo publish --allow-dirty\n") + hits = _hits(root, _CARGO_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "cargo publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _CARGO_RULE in receipt.finding_summary + + +def test_npm_pack_and_cargo_check_stay_inventory(tmp_path: Path) -> None: + """Read-only pack and check commands stay inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nnpm pack\ncargo check\npip install .\n") + receipt = build_claude_plugin_scan_receipt(root) + + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_comment_and_echo_publish_are_not_this_class(tmp_path: Path) -> None: + """Unquoted comments and echo lookalikes are not executable publishes.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\n# npm publish\necho "cargo publish"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_readme_npm_publish_is_not_this_class(tmp_path: Path) -> None: + """README publish wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("npm publish --access public\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _NPM_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["package_install"] is True + + +def test_three_registries_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: + """One hook can fail closed on npm, PyPI, and cargo publish.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nnpm publish\ntwine upload dist/*\ncargo publish\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _NPM_RULE) + assert _hits(root, _PYPI_RULE) + assert _hits(root, _CARGO_RULE) + assert receipt.scan_result == "fail" + assert _S3_RULE not in receipt.finding_summary + + +def test_python_module_twine_upload_is_the_same_class() -> None: + """``python -m twine upload`` is the same PyPI class.""" + body = "#!/bin/sh\npython -m twine upload dist/*\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any( + hit.rule_id == _PYPI_RULE and hit.snippet == "twine upload" for hit in hits + ) + + +def test_snippets_are_command_labels_not_secrets(tmp_path: Path) -> None: + """Snippets name the CLI command and omit secrets and bidi.""" + body = f"#!/bin/sh\nnpm publish --otp {_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + npm_hits = [hit for hit in hits if hit.rule_id == _NPM_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert npm_hits + for hit in npm_hits: + assert hit.snippet == "npm publish" + assert _SECRET not in hit.snippet + assert _BIDI not in hit.snippet + assert _SECRET not in hit.message + assert _SECRET not in payload + assert _BIDI not in payload + + +def test_plugin_manifest_cargo_publish_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that publishes crates is the cargo class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PostToolUse": [{"command": "cargo publish --allow-dirty"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _CARGO_RULE) + assert receipt.scan_result == "fail" + + +def test_s3_write_without_publish_stays_the_s3_class() -> None: + """Object-store writes without registry publish stay the s3 class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\naws s3 sync ./dist s3://bucket/app\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _S3_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) + + +def test_manifest_description_publish_prose_is_not_this_class(tmp_path: Path) -> None: + """Marketplace description prose about npm publish is not a command.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["description"] = "Never runs npm publish against the public registry." + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" From ffcb8efa8b69adf3d842ed7ce509012f273ebb33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:11:22 +0900 Subject: [PATCH 2/9] feat(scanner): reject plugin npm twine and cargo publish Fail closed on executable npm publish as claude-plugin-npm-publish-command, twine upload as claude-plugin-pypi-upload-command, and cargo publish as claude-plugin-cargo-publish-command. Reads, comments, and echo lookalikes stay inventory. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 7 +- appguardrail_core/claude_plugin_detector.py | 118 +++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 5 +- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 0e5806de..e95dbab8 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -152,12 +152,15 @@ ``az webapp deploy`` fails as `claude-plugin-az-deploy-command`. ``aws s3 sync`` and ``aws s3 cp`` fail as `claude-plugin-aws-s3-write-command`. ``az containerapp up`` fails as - `claude-plugin-az-containerapp-up-command`. + `claude-plugin-az-containerapp-up-command`. ``npm publish`` fails as + `claude-plugin-npm-publish-command`. ``twine upload`` fails as + `claude-plugin-pypi-upload-command`. ``cargo publish`` fails as + `claude-plugin-cargo-publish-command`. Hook comments and ``echo``/``printf`` lookalikes are not those classes. ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, - and ``az account show`` + ``az account show``, ``npm pack``, and ``cargo check`` stay inventory. Hardcoded PATs stay `claude-plugin-github-write-token`. Snippets are command labels, not tokens. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index bfe4deec..e3e6a83a 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -29,10 +29,13 @@ deploy``, and ``az webapp deploy`` fail closed as cloud-deploy command findings. Hook or manifest ``aws s3 sync``, ``aws s3 cp``, and ``az containerapp up`` fail closed as object-store and Container Apps -writes. Unquoted ``#`` comments and +writes. Hook or manifest ``npm publish``, ``twine upload``, and +``cargo publish`` fail closed as registry-publish command findings. +Unquoted ``#`` comments and ``echo``/``printf``/``print`` lookalikes are not that class. ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, -``aws s3 ls``, ``gcloud config list``, and ``az account show`` +``aws s3 ls``, ``gcloud config list``, ``az account show``, +``npm pack``, and ``cargo check`` stay inventory. Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth ``config.json``, cookie jars, and @@ -41,7 +44,8 @@ Hardcoded PATs stay write-token findings. ``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, -``aws s3 ls``, ``gcloud config list``, and ``az account show`` +``aws s3 ls``, ``gcloud config list``, ``az account show``, +``npm pack``, and ``cargo check`` stay inventory. Skill homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule identities. Skill, command, or agent text that hides tool use, rewrites @@ -308,6 +312,21 @@ "a Container Apps revision is write authority. Remove the command. " "[CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_NPM_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs npm publish. Publishing a package " + "is write authority on the npm registry. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_PYPI_UPLOAD_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs twine upload. Uploading a " + "distribution is write authority on PyPI. Remove the command. " + "[CWE-250 - Execution with Unnecessary Privileges]" +) +CLAUDE_PLUGIN_CARGO_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs cargo publish. Publishing a crate " + "is write authority on crates.io. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) CLAUDE_PLUGIN_DOCKER_SOCKET_MESSAGE: Final = ( "Claude plugin hook reaches the host Docker socket. Socket access is host " "control, not an image push. Remove the socket bind and keep builds " @@ -448,6 +467,9 @@ r"\baz\s+containerapp\s+up\b", re.IGNORECASE, ) +_NPM_PUBLISH_COMMAND = re.compile(r"\bnpm\s+publish\b", re.IGNORECASE) +_PYPI_UPLOAD_COMMAND = re.compile(r"\btwine\s+upload\b", re.IGNORECASE) +_CARGO_PUBLISH_COMMAND = re.compile(r"\bcargo\s+publish\b", re.IGNORECASE) _REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) _FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") _DOCKER_SOCKET = re.compile( @@ -728,7 +750,8 @@ ( "package_install", re.compile( - r"\b(?:pip|npm|pnpm|yarn|uv|cargo|apt-get)\s+install\b", + r"\b(?:pip|npm|pnpm|yarn|uv|cargo|apt-get)\s+install\b|" + r"\b(?:npm\s+publish|twine\s+upload|cargo\s+publish)\b", re.IGNORECASE, ), ), @@ -948,6 +971,9 @@ def inspect_claude_plugin_file( hits.extend(_az_deploy_command_hits(content, manifest=manifest)) hits.extend(_aws_s3_write_command_hits(content, manifest=manifest)) hits.extend(_az_containerapp_up_command_hits(content, manifest=manifest)) + hits.extend(_npm_publish_command_hits(content, manifest=manifest)) + hits.extend(_pypi_upload_command_hits(content, manifest=manifest)) + hits.extend(_cargo_publish_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -2077,6 +2103,90 @@ def _az_containerapp_up_command_hits( return () +def _npm_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``npm publish`` findings with a command label, not tokens. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``npm publish``. ``npm pack``, comments, + and echo lookalikes are not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _NPM_PUBLISH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-npm-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet="npm publish", + message=CLAUDE_PLUGIN_NPM_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + +def _pypi_upload_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``twine upload`` findings with a command label, not filenames. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``twine upload``. ``pip install`` is not + this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _PYPI_UPLOAD_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-pypi-upload-command", + line=first_line + source[: match.start()].count("\n"), + snippet="twine upload", + message=CLAUDE_PLUGIN_PYPI_UPLOAD_COMMAND_MESSAGE, + ), + ) + return () + + +def _cargo_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``cargo publish`` findings with a command label, not crate names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``cargo publish``. ``cargo check`` is not + this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _CARGO_PUBLISH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-cargo-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet="cargo publish", + message=CLAUDE_PLUGIN_CARGO_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: """Return findings for eval/exec/compile/Function on hook surfaces.""" match = _DYNAMIC_EVAL.search(content) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index bfc1e5a0..4b704f6a 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -22,7 +22,7 @@ | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | | GitHub Actions transport-only polling loop (#1087, #938 vertical slice) | owned by PR #1088 / issue #1087; YAML rules and RED precision contracts | mapped-family only; this successor does not ship or close the detector | | Password/database-url/auth-comment precision and test-file context (#1106) | existing `_scan_file` rules `hardcoded-password`, `hardcoded-database-url`, `todo-skip-auth`, `_finding_context` | implemented-branch regression lock | -| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-excessive-path-depth` when a materialized file or archive member nests past 32 path components, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-aws-s3-write-command` for hook or manifest `aws s3 sync`/`cp`, `claude-plugin-az-containerapp-up-command` for `az containerapp up`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, fail-closed receipt verification | implemented-branch | +| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-excessive-path-depth` when a materialized file or archive member nests past 32 path components, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-aws-s3-write-command` for hook or manifest `aws s3 sync`/`cp`, `claude-plugin-az-containerapp-up-command` for `az containerapp up`, `claude-plugin-npm-publish-command` for hook or manifest `npm publish`, `claude-plugin-pypi-upload-command` for `twine upload`, `claude-plugin-cargo-publish-command` for `cargo publish`, `claude-plugin-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 9c3f952f..e6dba772 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -112,6 +112,9 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-az-deploy-command` for ``az webapp deploy``, `claude-plugin-aws-s3-write-command` for ``aws s3 sync``/``cp``, `claude-plugin-az-containerapp-up-command` for ``az containerapp up``, + `claude-plugin-npm-publish-command` for ``npm publish``, + `claude-plugin-pypi-upload-command` for ``twine upload``, + `claude-plugin-cargo-publish-command` for ``cargo publish``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and @@ -120,7 +123,7 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-github-write-token`. ``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, - and ``az account show`` stay inventory. + ``az account show``, ``npm pack``, and ``cargo check`` stay inventory. - Mapped, not owned here: GitHub Actions transport-only poll loops (#1087, PR #1088) and orphaned workflow registry DAST (#929, PR #966). - `tool-execute-parameters-passthrough`: Strix-observed dynamic tool execution From a8661cdbedc9ba31e097b2a6225549234f51e9ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:19:20 +0900 Subject: [PATCH 3/9] fix(stack): carry object-store direction RED into registry successor --- tests/test_claude_plugin_object_store.py | 27 +++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_claude_plugin_object_store.py b/tests/test_claude_plugin_object_store.py index 1abce52e..f809921f 100644 --- a/tests/test_claude_plugin_object_store.py +++ b/tests/test_claude_plugin_object_store.py @@ -1,4 +1,4 @@ -"""Hook aws s3 writes and az containerapp up fail closed; ls stays inventory.""" +"""Hook aws s3 writes and az containerapp up fail closed; reads stay inventory.""" from __future__ import annotations @@ -58,7 +58,7 @@ def _hits(root: Path, rule_id: str): def test_hook_aws_s3_sync_fails_admission(tmp_path: Path) -> None: - """``aws s3 sync`` on a hook is object-store write authority.""" + """``aws s3 sync`` to S3 is object-store write authority.""" root = _licensed_plugin(tmp_path, "#!/bin/sh\naws s3 sync ./dist s3://bucket/app\n") hits = _hits(root, _S3_RULE) receipt = build_claude_plugin_scan_receipt(root) @@ -74,12 +74,33 @@ def test_hook_aws_s3_sync_fails_admission(tmp_path: Path) -> None: def test_hook_aws_s3_cp_fails_admission() -> None: - """``aws s3 cp`` is the same object-store write class.""" + """``aws s3 cp`` to S3 is object-store write authority.""" body = "#!/bin/sh\naws s3 cp artifact.tgz s3://bucket/app.tgz\n" hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) assert any(hit.rule_id == _S3_RULE and hit.snippet == "aws s3 cp" for hit in hits) +def test_hook_aws_s3_cp_download_stays_inventory() -> None: + """A provable S3-to-local copy is read authority, not this write class.""" + body = "#!/bin/sh\naws s3 cp s3://bucket/app.tgz ./app.tgz\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert not any(hit.rule_id == _S3_RULE for hit in hits) + + +def test_hook_aws_s3_sync_download_stays_inventory() -> None: + """A provable S3-to-local sync is read authority, not this write class.""" + body = "#!/bin/sh\naws s3 sync s3://bucket/app ./app\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert not any(hit.rule_id == _S3_RULE for hit in hits) + + +def test_hook_aws_s3_to_s3_copy_still_fails_admission() -> None: + """S3-to-S3 copy still writes the destination bucket.""" + body = "#!/bin/sh\naws s3 cp s3://source/app.tgz s3://target/app.tgz\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _S3_RULE for hit in hits) + + def test_hook_az_containerapp_up_fails_admission(tmp_path: Path) -> None: """``az containerapp up`` on a hook is Azure write authority.""" root = _licensed_plugin(tmp_path, "#!/bin/sh\naz containerapp up --name app\n") From 2caa30ab160ac090a828eb0705bac15cc6632f43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:12:14 +0900 Subject: [PATCH 4/9] fix(scanner): inherit quoted command-context repair --- appguardrail_core/claude_plugin_detector.py | 84 ++++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 0d3ee5e1..a04b2a41 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1886,14 +1886,73 @@ def _hosted_command_sources( return ((content, 1),) +def _shell_command_context_start(line: str, offset: int) -> int | None: + """Return the executable shell-frame start containing ``offset``. + + Args: + line: One hook or manifest command line. + offset: Zero-based match offset within ``line``. + + Returns: + The start of the root, ``$(...)``, or backtick command frame. + ``None`` means the offset is inert single- or double-quoted prose. + """ + frames: list[tuple[str, int, str, int]] = [("", 0, "", 0)] + escaped = False + index = 0 + while index < offset: + frame_end, frame_start, quote, depth = frames[-1] + char = line[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\" and quote != "'": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + frames[-1] = (frame_end, frame_start, "" if quote == "'" else "'", depth) + index += 1 + continue + if char == '"' and quote != "'": + frames[-1] = (frame_end, frame_start, "" if quote == '"' else '"', depth) + index += 1 + continue + if quote != "'" and line[index : index + 2] == "$(": + frames.append((")", index + 2, "", 1)) + index += 2 + continue + if quote != "'" and char == "`": + if frame_end == "`": + frames.pop() + else: + frames.append(("`", index + 1, "", 0)) + index += 1 + continue + if quote: + index += 1 + continue + if frame_end == ")" and char == "(": + frames[-1] = (frame_end, frame_start, quote, depth + 1) + elif frame_end == ")" and char == ")": + if depth == 1: + frames.pop() + else: + frames[-1] = (frame_end, frame_start, quote, depth - 1) + index += 1 + _frame_end, frame_start, quote, _depth = frames[-1] + return None if quote else frame_start + + def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments and ``echo``/``printf``/``print`` segments are - not executable. Manifest JSON command strings remain searchable - because they are not reporting builtins. + Unquoted ``#`` comments, quoted prose, and + ``echo``/``printf``/``print`` segments are not executable. Direct + commands inside ``$(...)`` or backticks remain executable. Args: content: Hook or manifest text. @@ -1911,17 +1970,22 @@ def _executable_command_match( line_end = len(content) line = content[line_start:line_end] relative = match.start() - line_start - comment_at = _unquoted_hash_index(line) - if comment_at is not None and relative >= comment_at: + context_start = _shell_command_context_start(line, relative) + if context_start is None: continue - for start, end in _iter_unquoted_segment_bounds(line): - if start <= relative < end: - if not _is_reporting_builtin_segment(line[start:end]): + 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: + if not _is_reporting_builtin_segment( + context[segment_start:segment_end] + ): return match break return None - - def _vercel_deploy_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: From a101e718207c4c45de23b14909874bfbbc7409c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:12:16 +0900 Subject: [PATCH 5/9] test(scanner): inherit quoted command-context regressions --- tests/test_claude_plugin_terraform_helm.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 4a95efdf..d413aa2c 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -254,3 +254,25 @@ def test_later_executable_command_after_reporting_segment_still_fails() -> None: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) assert any(hit.rule_id in _THIS_CLASS for hit in hits) +def test_quoted_shell_prose_is_not_executable() -> None: + """Quoted command names and reporting substitutions are inert prose.""" + bodies = ( + '#!/bin/sh\nmessage="terraform apply -auto-approve"\n', + '#!/bin/sh\nif [ "$mode" = "helm install app chart/" ]; then echo safe; fi\n', + "#!/bin/sh\nmessage='helm install app chart/'\n", + '#!/bin/sh\nresult="$(echo \'terraform apply -auto-approve\')"\n', + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_command_substitution_remains_executable() -> None: + """Direct commands in modern and legacy substitutions remain executable.""" + bodies = ( + '#!/bin/sh\nresult="$(terraform apply -auto-approve)"\n', + "#!/bin/sh\nresult=`helm install app chart/`\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id in _THIS_CLASS for hit in hits) From 77eae8527ce18c6c1566e9c50cae8857c25e0ef9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:24 +0900 Subject: [PATCH 6/9] test(scanner): inherit assignment command boundary --- tests/test_claude_plugin_terraform_helm.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index d413aa2c..b80b6877 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -276,3 +276,25 @@ def test_command_substitution_remains_executable() -> None: for body in bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) assert any(hit.rule_id in _THIS_CLASS for hit in hits) + + +def test_assignment_values_are_not_executable_commands() -> None: + """An unquoted assignment value cannot turn its following word into the CLI.""" + bodies = ( + "#!/bin/sh\nmessage=terraform apply -auto-approve\n", + "#!/bin/sh\ncommand=helm install app chart/\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_environment_assignment_before_real_command_still_fails() -> None: + """Environment assignments do not hide a later executable deployment CLI.""" + bodies = ( + "#!/bin/sh\nTF_IN_AUTOMATION=1 terraform apply -auto-approve\n", + "#!/bin/sh\nHELM_NAMESPACE=prod helm install app chart/\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id in _THIS_CLASS for hit in hits) From 9ed0b5097b6fb50e674a127fcd22df1636ea0214 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:37:26 +0900 Subject: [PATCH 7/9] fix(scanner): inherit assignment command boundary --- appguardrail_core/claude_plugin_detector.py | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index a04b2a41..3fa9bc76 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1945,12 +1945,28 @@ def _shell_command_context_start(line: str, offset: int) -> int | None: return None if quote else frame_start -def _executable_command_match( - content: str, pattern: re.Pattern[str] +def _match_starts_in_shell_assignment_value(segment: str, offset: int) -> bool: + """Return whether ``offset`` starts inside an unquoted assignment word. + + Args: + segment: One shell command segment. + offset: Zero-based match offset within ``segment``. + + Returns: + True when the current shell word before ``offset`` contains ``=``. + An assignment followed by whitespace and a real command returns False. + """ + prefix = segment[:offset] + if not prefix or prefix[-1].isspace(): + return False + return "=" in prefix.rsplit(maxsplit=1)[-1] + + +def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments, quoted prose, and + Unquoted ``#`` comments, quoted prose, shell assignment values, and ``echo``/``printf``/``print`` segments are not executable. Direct commands inside ``$(...)`` or backticks remain executable. @@ -1980,8 +1996,12 @@ def _executable_command_match( continue for segment_start, segment_end in _iter_unquoted_segment_bounds(context): if segment_start <= context_relative < segment_end: + segment = context[segment_start:segment_end] + segment_relative = context_relative - segment_start if not _is_reporting_builtin_segment( - context[segment_start:segment_end] + segment + ) and not _match_starts_in_shell_assignment_value( + segment, segment_relative ): return match break From b2892ab164877e38ddeb6f37fc443815615d636e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:45 +0900 Subject: [PATCH 8/9] fix(scanner): carry heredoc command boundary --- appguardrail_core/claude_plugin_detector.py | 57 ++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 3fa9bc76..b9a6d044 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -472,6 +472,11 @@ _CARGO_PUBLISH_COMMAND = re.compile(r"\bcargo\s+publish\b", re.IGNORECASE) _REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) _FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") +_LITERAL_HEREDOC_OPEN = re.compile( + r"<<(?P-)?[ \t]*(?P['\"]?)" + r"(?P[A-Za-z_][A-Za-z0-9_]*)(?P=quote)" + r"(?=$|[ \t;&|()<>])" +) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -1945,6 +1950,50 @@ def _shell_command_context_start(line: str, offset: int) -> int | None: return None if quote else frame_start +def _literal_heredoc_payload_spans(content: str) -> tuple[tuple[int, int], ...]: + """Return closed literal here-document payload spans. + + Args: + content: One hook or structural manifest command string. + + Returns: + Inclusive-start exclusive-end spans for payloads with one confidently + parsed identifier delimiter on the opener line. Quoted delimiters and + tab-stripping forms are supported. Ambiguous or unclosed forms stay + executable for fail-closed analysis. + """ + spans: list[tuple[int, int]] = [] + active: tuple[str, bool, int] | None = None + offset = 0 + for raw_line in content.splitlines(keepends=True): + line = raw_line.rstrip("\r\n") + if active is not None: + delimiter, strip_tabs, payload_start = active + candidate = line.lstrip("\t") if strip_tabs else line + if candidate == delimiter: + spans.append((payload_start, offset)) + active = None + offset += len(raw_line) + continue + + comment_at = _unquoted_hash_index(line) + openers = tuple( + match + for match in _LITERAL_HEREDOC_OPEN.finditer(line) + if (comment_at is None or match.start() < comment_at) + and _shell_command_context_start(line, match.start()) is not None + ) + if len(openers) == 1: + opener = openers[0] + active = ( + opener.group("delimiter"), + opener.group("strip") is not None, + offset + len(raw_line), + ) + offset += len(raw_line) + return tuple(spans) + + def _match_starts_in_shell_assignment_value(segment: str, offset: int) -> bool: """Return whether ``offset`` starts inside an unquoted assignment word. @@ -1966,8 +2015,9 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments, quoted prose, shell assignment values, and - ``echo``/``printf``/``print`` segments are not executable. Direct + Unquoted ``#`` comments, quoted prose, closed literal here-document + payloads, shell assignment values, and ``echo``/``printf``/``print`` + segments are not executable. Direct commands inside ``$(...)`` or backticks remain executable. Args: @@ -1979,7 +2029,10 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] """ if not content: return None + inert_payloads = _literal_heredoc_payload_spans(content) for match in pattern.finditer(content): + if any(start <= match.start() < end for start, end in inert_payloads): + continue line_start = content.rfind("\n", 0, match.start()) + 1 line_end = content.find("\n", match.start()) if line_end < 0: From 08b1d2bbc7a51870a75d1c9e55add6ad0e6f0a2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:14:47 +0900 Subject: [PATCH 9/9] test(scanner): carry heredoc command regressions --- tests/test_claude_plugin_terraform_helm.py | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index b80b6877..fe3bc41e 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -212,6 +212,7 @@ def test_kubectl_apply_without_terraform_stays_the_kubectl_class() -> None: assert _KUBECTL_RULE in rule_ids assert _THIS_CLASS.isdisjoint(rule_ids) + def test_hook_comments_and_reporting_builtins_are_not_commands() -> None: """Comments and reporting builtins do not execute terraform or Helm.""" bodies = ( @@ -278,6 +279,7 @@ def test_command_substitution_remains_executable() -> None: assert any(hit.rule_id in _THIS_CLASS for hit in hits) + def test_assignment_values_are_not_executable_commands() -> None: """An unquoted assignment value cannot turn its following word into the CLI.""" bodies = ( @@ -298,3 +300,38 @@ def test_environment_assignment_before_real_command_still_fails() -> None: for body in bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) assert any(hit.rule_id in _THIS_CLASS for hit in hits) + + +def test_here_document_payload_is_not_an_executable_command() -> None: + """Literal here-document payload is data, even when it names deployment CLIs.""" + bodies = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n", + "#!/bin/sh\ncat <<-EOF\n\thelm install app chart/\n\tEOF\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_command_after_here_document_still_fails() -> None: + """An inert payload cannot hide a later executable deployment command.""" + body = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n" + "helm install app chart/\n" + ) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _TERRAFORM_RULE not in rule_ids + assert _HELM_RULE in rule_ids + + +def test_heredoc_opener_lookalikes_do_not_hide_real_commands() -> None: + """Quoted or commented opener text cannot suppress a later real command.""" + bodies = ( + '#!/bin/sh\necho "<