Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@
`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`.
`claude-plugin-cargo-publish-command`. ``pnpm publish`` fails as
`claude-plugin-pnpm-publish-command`. ``uv publish`` fails as
`claude-plugin-uv-publish-command`. ``poetry publish`` fails as
`claude-plugin-poetry-publish-command`. ``yarn npm publish`` stays
`claude-plugin-npm-publish-command`.
Hook comments and
``echo``/``printf`` lookalikes are not those classes.
``terraform plan``, ``helm list``,
Expand Down
111 changes: 110 additions & 1 deletion appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
``az containerapp up`` fail closed as object-store and Container Apps
writes. Hook or manifest ``npm publish``, ``twine upload``, and
``cargo publish`` fail closed as registry-publish command findings.
Hook or manifest ``pnpm publish``, ``uv publish``, and
``poetry publish`` fail closed as alternate-manager registry writes.
Unquoted ``#`` comments and
``echo``/``printf``/``print`` lookalikes are not that class.
``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``,
Expand Down Expand Up @@ -327,6 +329,21 @@
"is write authority on crates.io. Remove the command. "
"[CWE-269 - Improper Privilege Management]"
)
CLAUDE_PLUGIN_PNPM_PUBLISH_COMMAND_MESSAGE: Final = (
"Claude plugin hook or manifest runs pnpm publish. Publishing a "
"package is write authority on the npm registry. Remove the command. "
"[CWE-269 - Improper Privilege Management]"
)
CLAUDE_PLUGIN_UV_PUBLISH_COMMAND_MESSAGE: Final = (
"Claude plugin hook or manifest runs uv publish. Publishing a "
"distribution is write authority on PyPI. Remove the command. "
"[CWE-250 - Execution with Unnecessary Privileges]"
)
CLAUDE_PLUGIN_POETRY_PUBLISH_COMMAND_MESSAGE: Final = (
"Claude plugin hook or manifest runs poetry publish. Publishing a "
"distribution is write authority on PyPI. 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 "
Expand Down Expand Up @@ -470,6 +487,9 @@
_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)
_PNPM_PUBLISH_COMMAND = re.compile(r"\bpnpm\s+publish\b", re.IGNORECASE)
_UV_PUBLISH_COMMAND = re.compile(r"\buv\s+publish\b", re.IGNORECASE)
_POETRY_PUBLISH_COMMAND = re.compile(r"\bpoetry\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(
Expand Down Expand Up @@ -756,7 +776,8 @@
"package_install",
re.compile(
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",
r"\b(?:npm\s+publish|pnpm\s+publish|twine\s+upload|cargo\s+publish|"
r"uv\s+publish|poetry\s+publish)\b",
re.IGNORECASE,
),
),
Expand Down Expand Up @@ -979,6 +1000,9 @@ def inspect_claude_plugin_file(
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(_pnpm_publish_command_hits(content, manifest=manifest))
hits.extend(_uv_publish_command_hits(content, manifest=manifest))
hits.extend(_poetry_publish_command_hits(content, manifest=manifest))
hits.extend(_docker_socket_hits(content))
hits.extend(_browser_profile_hits(content))
hits.extend(_credential_store_hits(content))
Expand Down Expand Up @@ -2340,6 +2364,91 @@ def _cargo_publish_command_hits(
return ()


def _pnpm_publish_command_hits(
content: str, *, manifest: bool = False
) -> tuple[PluginHit, ...]:
"""Return ``pnpm 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 ``pnpm publish``. ``npm publish`` and
``yarn npm publish`` stay the npm class. ``pnpm list`` is not
this class.
"""
for source, first_line in _hosted_command_sources(content, manifest=manifest):
match = _executable_command_match(source, _PNPM_PUBLISH_COMMAND)
if match is None:
continue
return (
PluginHit(
rule_id="claude-plugin-pnpm-publish-command",
line=first_line + source[: match.start()].count("\n"),
snippet="pnpm publish",
message=CLAUDE_PLUGIN_PNPM_PUBLISH_COMMAND_MESSAGE,
),
)
return ()


def _uv_publish_command_hits(
content: str, *, manifest: bool = False
) -> tuple[PluginHit, ...]:
"""Return ``uv 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 ``uv publish``. ``twine upload`` stays
the PyPI class. ``uv pip list`` is not this class.
"""
for source, first_line in _hosted_command_sources(content, manifest=manifest):
match = _executable_command_match(source, _UV_PUBLISH_COMMAND)
if match is None:
continue
return (
PluginHit(
rule_id="claude-plugin-uv-publish-command",
line=first_line + source[: match.start()].count("\n"),
snippet="uv publish",
message=CLAUDE_PLUGIN_UV_PUBLISH_COMMAND_MESSAGE,
),
)
return ()


def _poetry_publish_command_hits(
content: str, *, manifest: bool = False
) -> tuple[PluginHit, ...]:
"""Return ``poetry publish`` findings with a command label, not names.

Args:
content: Hook or manifest text.
manifest: When true, only structural command values are scanned.

Returns:
One hit for executable ``poetry publish``. ``twine upload`` stays
the PyPI class. Comments and echo lookalikes are not this class.
"""
for source, first_line in _hosted_command_sources(content, manifest=manifest):
match = _executable_command_match(source, _POETRY_PUBLISH_COMMAND)
if match is None:
continue
return (
PluginHit(
rule_id="claude-plugin-poetry-publish-command",
line=first_line + source[: match.start()].count("\n"),
snippet="poetry publish",
message=CLAUDE_PLUGIN_POETRY_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)
Expand Down
2 changes: 1 addition & 1 deletion docs/TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-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-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` |

Expand Down
3 changes: 3 additions & 0 deletions docs/sast-dast-rule-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ files being scanned, then applies the union of relevant checks. Examples:
`claude-plugin-npm-publish-command` for ``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``,
and
`claude-plugin-credential-store-access` for host ``~/.netrc``,
``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and
Expand Down
Loading