From 1b02a97fe5cce8c59b346611011696ab9c103b41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 18:00:23 +0900 Subject: [PATCH 01/15] test(scanner): fail closed on plugin deno publish and pod trunk push Hook and manifest deno publish and pod trunk push must fail closed. deno info, pod install, pod lib lint, comments, echo lookalikes, assignment values, and README wording stay inventory. Relates to #1099. --- tests/test_claude_plugin_deno_pod.py | 234 +++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/test_claude_plugin_deno_pod.py diff --git a/tests/test_claude_plugin_deno_pod.py b/tests/test_claude_plugin_deno_pod.py new file mode 100644 index 00000000..f6d0cc18 --- /dev/null +++ b/tests/test_claude_plugin_deno_pod.py @@ -0,0 +1,234 @@ +"""Hook deno publish and pod trunk push fail closed; info/install stay inventory.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + _collect_plugin_hits, + build_claude_plugin_scan_receipt, + inspect_claude_plugin_file, + inventory_claude_plugin_capabilities, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_DENO_RULE = "claude-plugin-deno-publish-command" +_POD_RULE = "claude-plugin-pod-trunk-push-command" +_SBT_RULE = "claude-plugin-sbt-publish-command" +_SECRET = "sk-deno-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_DENO_RULE, _POD_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_deno_publish_fails_admission(tmp_path: Path) -> None: + """``deno publish`` on a hook is JSR write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndeno publish --allow-slow-types\n") + hits = _hits(root, _DENO_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "deno publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _DENO_RULE in receipt.finding_summary + assert _POD_RULE not in receipt.finding_summary + assert _SBT_RULE not in receipt.finding_summary + assert inventory["package_install"] is True + + +def test_hook_pod_trunk_push_fails_admission(tmp_path: Path) -> None: + """``pod trunk push`` on a hook is CocoaPods trunk write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\npod trunk push App.podspec\n") + hits = _hits(root, _POD_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "pod trunk push" for hit in hits) + assert receipt.scan_result == "fail" + assert _POD_RULE in receipt.finding_summary + assert _DENO_RULE not in receipt.finding_summary + + +def test_deno_info_and_pod_install_stay_inventory(tmp_path: Path) -> None: + """Read-only deno info and local pod install stay inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndeno info\npod install\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_pod_lib_lint_is_not_this_class(tmp_path: Path) -> None: + """``pod lib lint`` stays local validation, not trunk write.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\npod lib lint\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_sbt_and_deno_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: + """One hook can fail closed on both sbt publish and deno publish.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nsbt publish && deno publish\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _SBT_RULE) + assert _hits(root, _DENO_RULE) + assert receipt.scan_result == "fail" + assert _POD_RULE not in receipt.finding_summary + + +def test_sbt_publish_stays_the_sbt_class() -> None: + """``sbt publish`` remains the sbt class, not JSR.""" + body = "#!/bin/sh\nsbt publish\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _SBT_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) + + +def test_comment_and_echo_deno_pod_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# deno publish\necho "pod trunk push App.podspec"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_assignment_values_are_not_this_class() -> None: + """An unquoted assignment value cannot turn its following word into the CLI.""" + bodies = ( + "#!/bin/sh\nmessage=deno publish --allow-slow-types\n", + "#!/bin/sh\ncommand=pod trunk push App.podspec\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 registry write.""" + bodies = ( + "#!/bin/sh\nDENO_DIR=/tmp deno publish --allow-slow-types\n", + "#!/bin/sh\nCOCOAPODS_TRUNK_TOKEN=x pod trunk push App.podspec\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id in _THIS_CLASS for hit in hits) + + +def test_readme_deno_pod_is_not_this_class(tmp_path: Path) -> None: + """README deno/pod wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text( + "deno publish --allow-slow-types\npod trunk push App.podspec\n", + encoding="utf-8", + ) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["package_install"] is True + + +def test_echo_then_real_deno_publish_still_fails() -> None: + """``echo done && deno publish`` still runs the registry write.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "done" && deno publish --allow-slow-types\n', + ) + assert any( + hit.rule_id == _DENO_RULE and hit.snippet == "deno publish" 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\ndeno publish --token {_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + deno_hits = [hit for hit in hits if hit.rule_id == _DENO_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert deno_hits + for hit in deno_hits: + assert hit.snippet == "deno 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_pod_trunk_push_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that pushes to CocoaPods trunk is that class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], + "PostToolUse": [{"command": "pod trunk push App.podspec"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _POD_RULE) + assert receipt.scan_result == "fail" + + +def test_manifest_prose_is_not_this_class(tmp_path: Path) -> None: + """Marketplace description prose about deno 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 deno publish or pod trunk push." + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" From c86ac8c3a9e5a71ab1002819312226954fab02f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 18:03:24 +0900 Subject: [PATCH 02/15] feat(scanner): reject plugin deno publish and pod trunk push Fail closed on executable deno publish and pod trunk push. deno info, pod install, and pod lib lint stay inventory. sbt publish stays the sbt class. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 4 +- appguardrail_core/claude_plugin_detector.py | 80 ++++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 4 +- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 202af29e..6a2edc18 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -174,6 +174,8 @@ `claude-plugin-luarocks-upload-command`. ``sbt publish`` and ``sbt publishSigned`` fail as `claude-plugin-sbt-publish-command`. ``conan upload`` fails as `claude-plugin-conan-upload-command`. + ``deno publish`` fails as `claude-plugin-deno-publish-command`. + ``pod trunk push`` fails as `claude-plugin-pod-trunk-push-command`. Hook comments and ``echo``/``printf`` lookalikes are not those classes. ``terraform plan``, ``helm list``, @@ -181,7 +183,7 @@ ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, ``nuget list``, ``hex info``, ``conda list``, ``cabal list``, ``mvn package``, ``gradle tasks``, ``luarocks list``, ``sbt compile``, - and ``conan list`` + ``conan list``, ``deno info``, and ``pod install`` 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 0c29d995..aafb7118 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -403,6 +403,16 @@ "package is write authority on Conan Center. Remove the command. " "[CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_DENO_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs deno publish. Publishing a " + "package is write authority on JSR. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_POD_TRUNK_PUSH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs pod trunk push. Publishing a " + "podspec is write authority on CocoaPods trunk. 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 " @@ -591,6 +601,14 @@ r"\bconan\s+upload\b", re.IGNORECASE, ) +_DENO_PUBLISH_COMMAND = re.compile( + r"\bdeno\s+publish\b", + re.IGNORECASE, +) +_POD_TRUNK_PUSH_COMMAND = re.compile( + r"\bpod\s+trunk\s+push\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( @@ -888,7 +906,9 @@ r"gradlew?\s+publish|" r"luarocks\s+upload|" r"sbt\s+publish(?:Signed)?|" - r"conan\s+upload)\b", + r"conan\s+upload|" + r"deno\s+publish|" + r"pod\s+trunk\s+push)\b", re.IGNORECASE, ), ), @@ -1125,6 +1145,8 @@ def inspect_claude_plugin_file( hits.extend(_luarocks_upload_command_hits(content, manifest=manifest)) hits.extend(_sbt_publish_command_hits(content, manifest=manifest)) hits.extend(_conan_upload_command_hits(content, manifest=manifest)) + hits.extend(_deno_publish_command_hits(content, manifest=manifest)) + hits.extend(_pod_trunk_push_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -2898,6 +2920,62 @@ def _conan_upload_command_hits( return () +def _deno_publish_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``deno publish`` findings with a command label, not package names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``deno publish``. ``deno info`` is not + this class. ``sbt publish`` stays the sbt class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _DENO_PUBLISH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-deno-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet="deno publish", + message=CLAUDE_PLUGIN_DENO_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + +def _pod_trunk_push_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``pod trunk push`` findings with a command label, not pod names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``pod trunk push``. ``pod install`` and + ``pod lib lint`` are not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _POD_TRUNK_PUSH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-pod-trunk-push-command", + line=first_line + source[: match.start()].count("\n"), + snippet="pod trunk push", + message=CLAUDE_PLUGIN_POD_TRUNK_PUSH_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 9a37ba1b..be3a4218 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-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-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `claude-plugin-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub publish`, `claude-plugin-hex-publish-command` for `hex publish`/`mix hex.publish`, `claude-plugin-conda-upload-command` for `conda upload`/`anaconda upload`, `claude-plugin-cabal-upload-command` for `cabal upload`/`cabal v2-upload`, `claude-plugin-mvn-deploy-command` for `mvn deploy`, `claude-plugin-gradle-publish-command` for `gradle publish`/`gradlew publish`, `claude-plugin-luarocks-upload-command` for `luarocks upload`, `claude-plugin-sbt-publish-command` for quoted or unquoted exact `sbt publish`/`sbt publishSigned` tasks, including shell substitutions (`publishLocal` remains negative), `claude-plugin-conan-upload-command` for `conan upload`, `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-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `claude-plugin-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub publish`, `claude-plugin-hex-publish-command` for `hex publish`/`mix hex.publish`, `claude-plugin-conda-upload-command` for `conda upload`/`anaconda upload`, `claude-plugin-cabal-upload-command` for `cabal upload`/`cabal v2-upload`, `claude-plugin-mvn-deploy-command` for `mvn deploy`, `claude-plugin-gradle-publish-command` for `gradle publish`/`gradlew publish`, `claude-plugin-luarocks-upload-command` for `luarocks upload`, `claude-plugin-sbt-publish-command` for quoted or unquoted exact `sbt publish`/`sbt publishSigned` tasks, including shell substitutions (`publishLocal` remains negative), `claude-plugin-conan-upload-command` for `conan upload`, `claude-plugin-deno-publish-command` for `deno publish`, `claude-plugin-pod-trunk-push-command` for `pod trunk 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 942ed9bb..0a0a7606 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -129,6 +129,8 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-luarocks-upload-command` for ``luarocks upload``, `claude-plugin-sbt-publish-command` for ``sbt publish``, `claude-plugin-conan-upload-command` for ``conan upload``, + `claude-plugin-deno-publish-command` for ``deno publish``, + `claude-plugin-pod-trunk-push-command` for ``pod trunk push``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and @@ -140,7 +142,7 @@ files being scanned, then applies the union of relevant checks. Examples: ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, ``nuget list``, ``hex info``, ``conda list``, ``cabal list``, ``mvn package``, ``gradle tasks``, ``luarocks list``, ``sbt compile``, - and ``conan list`` stay inventory. + ``conan list``, ``deno info``, and ``pod 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 From f214437889ce4e184508a49ad9f754d4014bb674 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 18:03:24 +0900 Subject: [PATCH 03/15] test(scanner): use real backticks in quoted sbt substitution fixture The inherited #1188 substitution case used an invalid \\` escape, so the hook body was not a command substitution. Relates to #1099. --- tests/test_claude_plugin_sbt_conan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_claude_plugin_sbt_conan.py b/tests/test_claude_plugin_sbt_conan.py index 9557405c..23897373 100644 --- a/tests/test_claude_plugin_sbt_conan.py +++ b/tests/test_claude_plugin_sbt_conan.py @@ -263,7 +263,7 @@ def test_quoted_sbt_task_in_substitution_still_fails() -> None: """Quoted sbt publish tasks inside shell substitutions remain executable.""" bodies = ( '#!/bin/sh\nresult=$(sbt "publishSigned")\n', - "#!/bin/sh\nresult=\`sbt 'publish'\`\n", + "#!/bin/sh\nresult=`sbt 'publish'`\n", ) for body in bodies: hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) From e41648b4bd8e0dd82c7e69b0c8eb7ccc56efb9e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 18:58:31 +0900 Subject: [PATCH 04/15] test(scanner): reproduce quoted deno and pod publish tasks --- tests/test_claude_plugin_deno_pod.py | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_claude_plugin_deno_pod.py b/tests/test_claude_plugin_deno_pod.py index f6d0cc18..04820ad4 100644 --- a/tests/test_claude_plugin_deno_pod.py +++ b/tests/test_claude_plugin_deno_pod.py @@ -232,3 +232,32 @@ def test_manifest_prose_is_not_this_class(tmp_path: Path) -> None: assert _hits(root, _DENO_RULE) == [] assert _hits(root, _POD_RULE) == [] assert receipt.scan_result == "pass" + + + +def test_quoted_deno_publish_task_fails_admission(tmp_path: Path) -> None: + """A quoted exact Deno task remains executable JSR write authority.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\ndeno "publish" --allow-slow-types\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _DENO_RULE) + assert receipt.scan_result == "fail" + assert inventory["package_install"] is True + + +def test_quoted_pod_push_task_fails_admission(tmp_path: Path) -> None: + """A quoted exact CocoaPods verb remains executable trunk write authority.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\npod trunk 'push' App.podspec\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _POD_RULE) + assert receipt.scan_result == "fail" + assert inventory["package_install"] is True From 471a89fcd3c3e956f206e3bc4238ec8a4df5b2fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 18:59:26 +0900 Subject: [PATCH 05/15] fix(scanner): detect quoted deno and pod publish tasks --- appguardrail_core/claude_plugin_detector.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index aafb7118..822bb4c4 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -602,11 +602,14 @@ re.IGNORECASE, ) _DENO_PUBLISH_COMMAND = re.compile( - r"\bdeno\s+publish\b", + r"\bdeno[ \t]+(?P['\"]?)publish(?P=quote)" + r"(?=$|[ \t;&|`\)])", re.IGNORECASE, ) _POD_TRUNK_PUSH_COMMAND = re.compile( - r"\bpod\s+trunk\s+push\b", + r"\bpod[ \t]+(?P['\"]?)trunk(?P=trunk_quote)" + r"[ \t]+(?P['\"]?)push(?P=push_quote)" + r"(?=$|[ \t;&|`\)])", re.IGNORECASE, ) _REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) @@ -907,8 +910,9 @@ r"luarocks\s+upload|" r"sbt\s+publish(?:Signed)?|" r"conan\s+upload|" - r"deno\s+publish|" - r"pod\s+trunk\s+push)\b", + r"deno[ \t]+(?:publish|\"publish\"|'publish')|" + r"pod[ \t]+(?:trunk|\"trunk\"|'trunk')[ \t]+" + r"(?:push|\"push\"|'push'))\b", re.IGNORECASE, ), ), From 0616f328803d91122d93d1bb1792df04d5953223 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:03:58 +0900 Subject: [PATCH 06/15] fix(scanner): align quoted publish capability inventory --- appguardrail_core/claude_plugin_detector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 822bb4c4..7ded32f0 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -910,9 +910,9 @@ r"luarocks\s+upload|" r"sbt\s+publish(?:Signed)?|" r"conan\s+upload|" - r"deno[ \t]+(?:publish|\"publish\"|'publish')|" + r"deno[ \t]+(?:publish|\"publish(?=\")|'publish(?='))|" r"pod[ \t]+(?:trunk|\"trunk\"|'trunk')[ \t]+" - r"(?:push|\"push\"|'push'))\b", + r"(?:push|\"push(?=\")|'push(?=')))\b", re.IGNORECASE, ), ), From 34bb01390db73e82e632ee8a8503096a94f2e9ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:06:22 +0900 Subject: [PATCH 07/15] docs(scanner): record quoted deno and pod command boundary --- CHANGELOG.d/1099-claude-plugin-supply-chain.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 6a2edc18..d525901f 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -174,8 +174,9 @@ `claude-plugin-luarocks-upload-command`. ``sbt publish`` and ``sbt publishSigned`` fail as `claude-plugin-sbt-publish-command`. ``conan upload`` fails as `claude-plugin-conan-upload-command`. - ``deno publish`` fails as `claude-plugin-deno-publish-command`. - ``pod trunk push`` fails as `claude-plugin-pod-trunk-push-command`. + Quoted or unquoted exact ``deno publish`` tasks fail as + `claude-plugin-deno-publish-command`. Quoted or unquoted exact + ``pod trunk push`` tasks fail as `claude-plugin-pod-trunk-push-command`. Hook comments and ``echo``/``printf`` lookalikes are not those classes. ``terraform plan``, ``helm list``, From 9e3f2c30e698e16b710e2d89891963c899cf827d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:06:24 +0900 Subject: [PATCH 08/15] docs(scanner): bind quoted registry task evidence --- docs/sast-dast-rule-research.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sast-dast-rule-research.md b/docs/sast-dast-rule-research.md index 0a0a7606..1536f978 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -129,8 +129,10 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-luarocks-upload-command` for ``luarocks upload``, `claude-plugin-sbt-publish-command` for ``sbt publish``, `claude-plugin-conan-upload-command` for ``conan upload``, - `claude-plugin-deno-publish-command` for ``deno publish``, - `claude-plugin-pod-trunk-push-command` for ``pod trunk push``, + `claude-plugin-deno-publish-command` for quoted or unquoted exact + ``deno publish`` tasks, + `claude-plugin-pod-trunk-push-command` for quoted or unquoted exact + ``pod trunk push`` tasks, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and From c13142c6447877a65ad282a0ee482372b6efb7d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:06:26 +0900 Subject: [PATCH 09/15] docs(scanner): trace quoted deno and pod tasks --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index be3a4218..dd3c564a 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-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-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `claude-plugin-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub publish`, `claude-plugin-hex-publish-command` for `hex publish`/`mix hex.publish`, `claude-plugin-conda-upload-command` for `conda upload`/`anaconda upload`, `claude-plugin-cabal-upload-command` for `cabal upload`/`cabal v2-upload`, `claude-plugin-mvn-deploy-command` for `mvn deploy`, `claude-plugin-gradle-publish-command` for `gradle publish`/`gradlew publish`, `claude-plugin-luarocks-upload-command` for `luarocks upload`, `claude-plugin-sbt-publish-command` for quoted or unquoted exact `sbt publish`/`sbt publishSigned` tasks, including shell substitutions (`publishLocal` remains negative), `claude-plugin-conan-upload-command` for `conan upload`, `claude-plugin-deno-publish-command` for `deno publish`, `claude-plugin-pod-trunk-push-command` for `pod trunk push`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, fail-closed receipt verification | implemented-branch | +| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-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-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `claude-plugin-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub publish`, `claude-plugin-hex-publish-command` for `hex publish`/`mix hex.publish`, `claude-plugin-conda-upload-command` for `conda upload`/`anaconda upload`, `claude-plugin-cabal-upload-command` for `cabal upload`/`cabal v2-upload`, `claude-plugin-mvn-deploy-command` for `mvn deploy`, `claude-plugin-gradle-publish-command` for `gradle publish`/`gradlew publish`, `claude-plugin-luarocks-upload-command` for `luarocks upload`, `claude-plugin-sbt-publish-command` for quoted or unquoted exact `sbt publish`/`sbt publishSigned` tasks, including shell substitutions (`publishLocal` remains negative), `claude-plugin-conan-upload-command` for `conan upload`, `claude-plugin-deno-publish-command` for quoted or unquoted exact `deno publish` tasks, `claude-plugin-pod-trunk-push-command` for quoted or unquoted exact `pod trunk push` tasks, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, fail-closed receipt verification | implemented-branch | | Orphaned GitHub Actions registry identities (#929) | owned by PR #966 / issue #929; live registry DAST | mapped-family only; this successor does not ship or close the detector | | Org security-failure CI tickets without copied vuln evidence | documented non-detectable family | snapshot in `tests/fixtures/cwl-security-issue-inventory.json` | From 18be77f1271b2c4d3f9dc0e59608715eb410c5af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:56:12 +0900 Subject: [PATCH 10/15] test(scanner): reproduce quoted plugin CLI name bypass --- tests/test_claude_plugin_deno_pod.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_claude_plugin_deno_pod.py b/tests/test_claude_plugin_deno_pod.py index 04820ad4..9749a170 100644 --- a/tests/test_claude_plugin_deno_pod.py +++ b/tests/test_claude_plugin_deno_pod.py @@ -261,3 +261,15 @@ def test_quoted_pod_push_task_fails_admission(tmp_path: Path) -> None: assert _hits(root, _POD_RULE) assert receipt.scan_result == "fail" assert inventory["package_install"] is True + + + +def test_quoted_cli_names_still_fail_admission() -> None: + """Quoting an exact CLI name does not remove its registry write authority.""" + cases = ( + ('#!/bin/sh\n"deno" publish --allow-slow-types\n', _DENO_RULE), + ("#!/bin/sh\n'pod' trunk push App.podspec\n", _POD_RULE), + ) + for body, expected_rule in cases: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == expected_rule for hit in hits) From c0124b7ce4be48b007d0e6700863d577cbc70158 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:58:45 +0900 Subject: [PATCH 11/15] test(scanner): lock quoted CLI admission and inventory --- tests/test_claude_plugin_deno_pod.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_claude_plugin_deno_pod.py b/tests/test_claude_plugin_deno_pod.py index 9749a170..4d30824d 100644 --- a/tests/test_claude_plugin_deno_pod.py +++ b/tests/test_claude_plugin_deno_pod.py @@ -264,12 +264,14 @@ def test_quoted_pod_push_task_fails_admission(tmp_path: Path) -> None: -def test_quoted_cli_names_still_fail_admission() -> None: +def test_quoted_cli_names_still_fail_admission(tmp_path: Path) -> None: """Quoting an exact CLI name does not remove its registry write authority.""" cases = ( - ('#!/bin/sh\n"deno" publish --allow-slow-types\n', _DENO_RULE), - ("#!/bin/sh\n'pod' trunk push App.podspec\n", _POD_RULE), + ("deno", '#!/bin/sh\n"deno" publish --allow-slow-types\n', _DENO_RULE), + ("pod", "#!/bin/sh\n'pod' trunk push App.podspec\n", _POD_RULE), ) - for body, expected_rule in cases: - hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) - assert any(hit.rule_id == expected_rule for hit in hits) + for name, body, expected_rule in cases: + root = _licensed_plugin(tmp_path / name, body) + assert _hits(root, expected_rule) + assert build_claude_plugin_scan_receipt(root).scan_result == "fail" + assert inventory_claude_plugin_capabilities(root)["package_install"] is True From 31ff8f492843f8d6d34dc1a6e8445d9904c35172 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 20:00:48 +0900 Subject: [PATCH 12/15] fix(scanner): recognize quoted Deno and CocoaPods CLI names --- appguardrail_core/claude_plugin_detector.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 7ded32f0..2cb3119d 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -602,12 +602,14 @@ re.IGNORECASE, ) _DENO_PUBLISH_COMMAND = re.compile( - r"\bdeno[ \t]+(?P['\"]?)publish(?P=quote)" + r"(?['\"]?)deno(?P=cli_quote)" + r"[ \t]+(?P['\"]?)publish(?P=quote)" r"(?=$|[ \t;&|`\)])", re.IGNORECASE, ) _POD_TRUNK_PUSH_COMMAND = re.compile( - r"\bpod[ \t]+(?P['\"]?)trunk(?P=trunk_quote)" + r"(?['\"]?)pod(?P=cli_quote)" + r"[ \t]+(?P['\"]?)trunk(?P=trunk_quote)" r"[ \t]+(?P['\"]?)push(?P=push_quote)" r"(?=$|[ \t;&|`\)])", re.IGNORECASE, @@ -897,7 +899,7 @@ ( "package_install", re.compile( - r"\b(?:pip|npm|pnpm|yarn|uv|cargo|apt-get)\s+install\b|" + r"(? Date: Tue, 8 Sep 2026 20:02:56 +0900 Subject: [PATCH 13/15] fix(scanner): align quoted CLI capability boundary --- appguardrail_core/claude_plugin_detector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 2cb3119d..6953511f 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -900,7 +900,7 @@ "package_install", re.compile( r"(? Date: Tue, 8 Sep 2026 20:05:47 +0900 Subject: [PATCH 14/15] test(scanner): reproduce quoted publish suffix inventory false positive --- tests/test_claude_plugin_deno_pod.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_claude_plugin_deno_pod.py b/tests/test_claude_plugin_deno_pod.py index 4d30824d..44e343b2 100644 --- a/tests/test_claude_plugin_deno_pod.py +++ b/tests/test_claude_plugin_deno_pod.py @@ -275,3 +275,17 @@ def test_quoted_cli_names_still_fail_admission(tmp_path: Path) -> None: assert _hits(root, expected_rule) assert build_claude_plugin_scan_receipt(root).scan_result == "fail" assert inventory_claude_plugin_capabilities(root)["package_install"] is True + + + +def test_quoted_task_suffixes_are_not_publish_capabilities(tmp_path: Path) -> None: + """Quoted near-task names stay outside admission and capability inventory.""" + cases = ( + ("deno-near", '#!/bin/sh\ndeno "publish"Local\n'), + ("pod-near", "#!/bin/sh\npod trunk 'push'Local\n"), + ) + for name, body in cases: + root = _licensed_plugin(tmp_path / name, body) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert inventory_claude_plugin_capabilities(root)["package_install"] is False From f7964beb79be28ca7acc46971a2137830d1781ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 20:06:47 +0900 Subject: [PATCH 15/15] fix(scanner): bound quoted publish inventory tokens --- appguardrail_core/claude_plugin_detector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 6953511f..1dcffbfc 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -913,10 +913,10 @@ r"sbt\s+publish(?:Signed)?|" r"conan\s+upload|" r"(?:deno|\"deno\"|'deno')[ \t]+" - r"(?:publish|\"publish(?=\")|'publish(?='))|" + r"(?:publish|\"publish\"|'publish')|" r"(?:pod|\"pod\"|'pod')[ \t]+" r"(?:trunk|\"trunk\"|'trunk')[ \t]+" - r"(?:push|\"push(?=\")|'push(?=')))\b", + r"(?:push|\"push\"|'push'))(?![A-Za-z0-9_])", re.IGNORECASE, ), ),