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
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ jobs:
--test tests/test_claude_plugin_secret_to_prompt.py \
--test tests/test_claude_plugin_secret_to_mcp.py \
--test tests/test_claude_plugin_command_skill_reuse.py \
--test tests/test_claude_plugin_hide_actions.py
--test tests/test_claude_plugin_hide_actions.py \
--test tests/test_claude_plugin_insecure_file_mode.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,8 @@
`claude-plugin-goal-escalation-directive`. Honest ``report each tool
call to the user`` wording, README prose, and vendored copies are not
that class. #1036 injection and exfil identities stay on their rules.
Setuid or setgid executable and hook files fail as
`claude-plugin-setuid-executable`. World-writable executable and hook
files fail as `claude-plugin-world-writable-executable`. A declared
``0755`` hook, world-writable LICENSE, vendored copies, Git metadata,
and ``.mcp.json`` are not that class.
91 changes: 85 additions & 6 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule
identities. Skill, command, or agent text that hides tool use, rewrites
the system prompt, or escalates the declared goal is a separate
instruction-override family. A lockfile-backed package.json without a
instruction-override family. Setuid, setgid, or world-writable executable
and hook files fail admission. A lockfile-backed package.json without a
lifecycle download stays inventory. Vendored trees are one scope finding,
not hook scans.
"""
Expand All @@ -35,6 +36,7 @@
import os
from pathlib import Path
import re
import stat
import tarfile
from typing import Final, Iterable
import unicodedata
Expand Down Expand Up @@ -142,6 +144,16 @@
"budget. Hostile oversized trees fail admission. "
"[CWE-400 - Uncontrolled Resource Consumption]"
)
CLAUDE_PLUGIN_SETUID_EXECUTABLE_MESSAGE: Final = (
"Claude plugin executable or hook has the setuid or setgid bit. "
"Privilege-elevating modes fail admission. "
"[CWE-732 - Incorrect Permission Assignment for Critical Resource]"
)
CLAUDE_PLUGIN_WORLD_WRITABLE_EXECUTABLE_MESSAGE: Final = (
"Claude plugin executable or hook is world-writable. Tamperable host "
"modes fail admission. "
"[CWE-732 - Incorrect Permission Assignment for Critical Resource]"
)
CLAUDE_PLUGIN_SOURCE_MISMATCH_MESSAGE: Final = (
"Claude plugin marketplace identity does not match the retrieved artifact "
"ref, repository, or source path. Bind admission to one exact object. "
Expand Down Expand Up @@ -767,11 +779,12 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]:
Undeclared executable, hidden undeclared executable or config,
undeclared vendored or generated scope, license absence or SPDX
mismatch, size, symlink, archive traversal, unadmitted-submodule,
and deceptive description findings. Empty when the tree is not a
plugin package or every hook is a declared regular file. Inventory
presence is not a finding. An empty description is not this class.
Git metadata is not a plugin executable surface. ``.mcp.json``
stays the MCP class. Vendored trees are one scope finding.
setuid or world-writable executable modes, and deceptive
description findings. Empty when the tree is not a plugin package
or every hook is a declared regular file. Inventory presence is
not a finding. An empty description is not this class. Git
metadata is not a plugin executable surface. ``.mcp.json`` stays
the MCP class. Vendored trees are one scope finding.
"""
plugin_dir = root / ".claude-plugin"
if not plugin_dir.is_dir() or plugin_dir.is_symlink():
Expand Down Expand Up @@ -851,6 +864,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]:
)
)
hits.extend(_hidden_undeclared_executable_hits(root, declared))
hits.extend(_insecure_file_mode_hits(root))
hits.extend(_deceptive_description_hits(root))
return tuple(hits)

Expand Down Expand Up @@ -2144,6 +2158,71 @@ def _hidden_undeclared_executable_hits(
return tuple(hits)


def _is_mode_sensitive_surface(path: Path, relative: str) -> bool:
"""Return whether ``relative`` is an executable or hook host-fs surface.

LICENSE, README, and other documentation without an executable suffix
are not this class. MCP manifests stay the MCP class.
"""
if path.name in _MCP_FILENAMES or _is_git_metadata_path(relative):
return False
posix = relative.replace("\\", "/")
first = posix.split("/", 1)[0]
suffix = path.suffix.lower()
if first in _HOOK_DIRS:
return True
return suffix in _EXECUTABLE_SUFFIXES


def _insecure_file_mode_hits(root: Path) -> tuple[PluginHit, ...]:
"""Return findings for setuid, setgid, or world-writable hook files.

Args:
root: Materialized plugin tree.

Returns:
Hits for executable or hook files whose mode has setuid, setgid,
or other-write. Empty when every such file is ``0755``/``0644``,
vendored, a symlink, or Git metadata. LICENSE world-write is not
this class. Snippets are path labels.
"""
hits: list[PluginHit] = []
for path in _walk_entries(root):
if path.is_symlink() or not path.is_file():
continue
relative = path.relative_to(root).as_posix()
if _is_vendored_scope_relative(relative):
continue
if not _is_mode_sensitive_surface(path, relative):
continue
try:
mode = os.lstat(path).st_mode
except OSError:
continue
snippet = _sanitize_path_snippet(path.name)
if mode & (stat.S_ISUID | stat.S_ISGID):
hits.append(
PluginHit(
rule_id="claude-plugin-setuid-executable",
line=1,
snippet=snippet,
message=CLAUDE_PLUGIN_SETUID_EXECUTABLE_MESSAGE,
file=relative,
)
)
if mode & stat.S_IWOTH:
hits.append(
PluginHit(
rule_id="claude-plugin-world-writable-executable",
line=1,
snippet=snippet,
message=CLAUDE_PLUGIN_WORLD_WRITABLE_EXECUTABLE_MESSAGE,
file=relative,
)
)
return tuple(hits)


def _deceptive_description_hits(root: Path) -> tuple[PluginHit, ...]:
"""Return findings when a description denies inventoried capabilities.

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`, 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 and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, 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`, 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 and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, 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
2 changes: 1 addition & 1 deletion docs/doctoring/cwl-security-issue-detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ every frozen family. It implements only the unique families it owns.
|---|---|---|---|---|
| Transport-only Actions polling | SAST | #1087, #938 | PR #1088 / issue #1087 | maps only |
| Secret indirection / auth comments | SAST | #1106 | this successor | implements regression lock on existing `_scan_file` rules, including LifeOS #247 test-title/authority wording |
| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, secrets copied into MCP env/args/command/URL/headers, and hide-actions / self-modify / goal-escalation wording on skill/command/agent surfaces, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent/command surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification |
| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, secrets copied into MCP env/args/command/URL/headers, hide-actions / self-modify / goal-escalation wording on skill/command/agent surfaces, and setuid/setgid or world-writable executable and hook modes, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent/command surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification |
| Orphaned Actions workflows | DAST | #929 | PR #966 / issue #929 | maps only |
| Org CI failure without evidence | non-detectable | 353 tickets | inventory snapshot | maps only |
| UX / control-plane product gaps | non-detectable | #871, #928 | out of SAST/DAST scope | maps only |
Expand Down
3 changes: 2 additions & 1 deletion docs/sast-dast-rule-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ files being scanned, then applies the union of relevant checks. Examples:
undeclared executables, undeclared vendored or generated code scope,
reused #1036 skill-supply-chain identities on
plugin skill/agent/command surfaces, hide-actions / self-modify /
goal-escalation instruction wording on those surfaces, and fail-closed
goal-escalation instruction wording on those surfaces, setuid/setgid or
world-writable executable and hook modes, and fail-closed
replay of a stale or mismatched scan receipt.
- Mapped, not owned here: GitHub Actions transport-only poll loops (#1087,
PR #1088) and orphaned workflow registry DAST (#929, PR #966).
Expand Down
Loading