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 @@ -55,7 +55,8 @@ jobs:
--module appguardrail_core/claude_plugin_detector.py \
--test tests/test_claude_plugin_supply_chain.py \
--test tests/test_claude_plugin_scan_cli.py \
--test tests/test_claude_plugin_license_mismatch.py
--test tests/test_claude_plugin_license_mismatch.py \
--test tests/test_claude_plugin_postinstall_download.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
7 changes: 4 additions & 3 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
inventory evidence. Secret references require a complete environment-variable
name, so longer documentation variables do not collide with protected names.
Unsigned `curl`/`wget` executable fetches and
unpinned pip/npm/cargo URL installs fail admission; a `package.json`
plus lockfile without a postinstall download stays `package_install`
inventory. Plugin skill/agent surfaces reuse released #1036 rule
unpinned pip/npm/cargo URL installs fail admission; `package.json`
`preinstall`/`install`/`postinstall` scripts that download or execute
an unsigned payload fail closed on the same rules, while a lockfile-only
tree without those downloads stays `package_install` inventory. Plugin skill/agent surfaces reuse released #1036 rule
identities (`skill-name-homoglyph-confusable`,
`skill-manifest-prompt-injection-payload`,
`skill-doc-exfiltration-endpoint-directive`,
Expand Down
165 changes: 145 additions & 20 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

Findings come from parsed manifests and executable surfaces, not from issue
titles. A floating Git ref, provider secret, pipe-to-shell installer,
unsigned executable download, unpinned package URL install, undeclared hook,
archive path escape, unadmitted nested submodule, hardcoded GitHub write
token, Docker socket bind, secret copied into a network request, or a
released skill-supply-chain finding on a plugin skill/agent surface is a
policy finding. Capability inventory is evidence, not permission: presence
of a capability is not a finding by itself. Skill homoglyph, injection,
exfiltration, and placeholder hits reuse #1036 rule identities.
unsigned executable download, package.json lifecycle download, unpinned
package URL install, undeclared hook, archive path escape, unadmitted nested
submodule, hardcoded GitHub write token, Docker socket bind, secret copied
into a network request, or a released skill-supply-chain finding on a plugin
skill/agent surface is a policy finding. Capability inventory is evidence,
not permission: presence of a capability is not a finding by itself. Skill
homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule
identities. A lockfile-backed package.json without a lifecycle download
stays inventory.
"""

from __future__ import annotations
Expand Down Expand Up @@ -37,8 +39,9 @@
"[CWE-798 - Use of Hard-coded Credentials]"
)
CLAUDE_PLUGIN_PIPE_TO_SHELL_MESSAGE: Final = (
"Claude plugin hook downloads a mutable script and pipes it to a shell. "
"Pin and verify installers; do not execute unsigned remote content. "
"Claude plugin hook or package lifecycle script downloads a mutable "
"script and pipes it to a shell. Pin and verify installers; do not "
"execute unsigned remote content. "
"[CWE-494 - Download of Code Without Integrity Check]"
)
CLAUDE_PLUGIN_UNDECLARED_EXECUTABLE_MESSAGE: Final = (
Expand Down Expand Up @@ -119,14 +122,16 @@
"[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]"
)
CLAUDE_PLUGIN_UNSIGNED_EXECUTABLE_DOWNLOAD_MESSAGE: Final = (
"Claude plugin hook downloads an unsigned executable and makes it "
"runnable. Pin and verify binaries; do not fetch mutable runtime "
"payloads. [CWE-494 - Download of Code Without Integrity Check]"
"Claude plugin hook or package lifecycle script downloads an unsigned "
"executable and makes it runnable. Pin and verify binaries; do not "
"fetch mutable runtime payloads. "
"[CWE-494 - Download of Code Without Integrity Check]"
)
CLAUDE_PLUGIN_UNPINNED_PACKAGE_INSTALL_MESSAGE: Final = (
"Claude plugin hook installs a package from an unpinned URL. Pin "
"versions and integrity hashes; do not install mutable remote "
"artifacts. [CWE-494 - Download of Code Without Integrity Check]"
"Claude plugin hook or package lifecycle script installs a package "
"from an unpinned URL. Pin versions and integrity hashes; do not "
"install mutable remote artifacts. "
"[CWE-494 - Download of Code Without Integrity Check]"
)
_MCP_FILENAMES: Final = frozenset({".mcp.json", "mcp.json"})
_MAX_PACKAGE_FILES: Final = 10_000
Expand Down Expand Up @@ -190,6 +195,7 @@
"yarn.lock",
}
)
_LIFECYCLE_SCRIPT_NAMES: Final = ("preinstall", "install", "postinstall")
_EXECUTABLE_SUFFIXES = frozenset(
{".sh", ".bash", ".zsh", ".js", ".mjs", ".cjs", ".ts", ".py"}
)
Expand Down Expand Up @@ -426,12 +432,14 @@ def inspect_claude_plugin_file(

Returns:
Zero or more hits. Unrelated files return an empty tuple.
``package.json`` is inspected only for npm install lifecycle scripts.
"""
posix = relative_path.replace("\\", "/")
hits: list[PluginHit] = []
manifest = _is_manifest(filename, posix)
hook_surface = _is_hook_surface(filename, posix)
if not manifest and not hook_surface:
lifecycle_surface = _is_package_lifecycle_surface(filename)
if not manifest and not hook_surface and not lifecycle_surface:
return ()
if manifest:
hits.extend(_inspect_manifest(content))
Expand All @@ -453,9 +461,12 @@ def inspect_claude_plugin_file(
if hook_surface:
hits.extend(_unsigned_executable_download_hits(content))
hits.extend(_unpinned_package_install_hits(content))
hits.extend(_github_write_token_hits(content))
hits.extend(_docker_socket_hits(content))
hits.extend(_secret_to_network_hits(content))
if lifecycle_surface:
hits.extend(_package_lifecycle_hits(content))
if manifest or hook_surface:
hits.extend(_github_write_token_hits(content))
hits.extend(_docker_socket_hits(content))
hits.extend(_secret_to_network_hits(content))
return tuple(hits)


Expand Down Expand Up @@ -833,6 +844,119 @@ def _is_hook_surface(filename: str, posix: str) -> bool:
return suffix in _EXECUTABLE_SUFFIXES or suffix == ""


def _is_package_lifecycle_surface(filename: str) -> bool:
"""Return whether the file is an npm ``package.json`` lifecycle surface."""
return filename == "package.json"


def _lifecycle_script_values(content: str) -> tuple[tuple[str, str], ...]:
"""Return ``(name, script)`` pairs for npm install lifecycle scripts."""
try:
payload = json.loads(content)
except json.JSONDecodeError:
return ()
if not isinstance(payload, dict):
return ()
scripts = payload.get("scripts")
if not isinstance(scripts, dict):
return ()
found: list[tuple[str, str]] = []
for name in _LIFECYCLE_SCRIPT_NAMES:
value = scripts.get(name)
if isinstance(value, str) and value.strip():
found.append((name, value))
return tuple(found)


def _script_line(content: str, body: str) -> int:
"""Return the 1-based line of a lifecycle script body in package.json."""
if body in content:
return _line_of(content, body)
return _line_of(content, json.dumps(body)[1:-1])


def _package_lifecycle_hits(content: str) -> tuple[PluginHit, ...]:
"""Return unsigned-download findings from package.json lifecycle scripts.

Only ``preinstall``, ``install``, and ``postinstall`` script strings are
scanned. Other script names and non-script fields stay inventory.

Args:
content: Raw ``package.json`` text.

Returns:
Hits using the existing unsigned-download, pipe-to-shell, and
unpinned-package rule identities. Empty when no lifecycle script
downloads or executes an unsigned payload.
"""
hits: list[PluginHit] = []
for _name, body in _lifecycle_script_values(content):
line = _script_line(content, body)
match = _PIPE_TO_SHELL.search(body)
if match is not None:
hits.append(
PluginHit(
rule_id="claude-plugin-pipe-to-shell",
line=line,
snippet=_sanitize_plugin_snippet(match.group(0).splitlines()[0]),
message=CLAUDE_PLUGIN_PIPE_TO_SHELL_MESSAGE,
)
)
for hit in _unsigned_executable_download_hits(body):
hits.append(
PluginHit(
rule_id=hit.rule_id,
line=line,
snippet=hit.snippet,
message=hit.message,
)
)
for hit in _unpinned_package_install_hits(body):
hits.append(
PluginHit(
rule_id=hit.rule_id,
line=line,
snippet=hit.snippet,
message=hit.message,
)
)
return tuple(hits)


def _already_inspected_package_json(relative: str) -> bool:
"""Return whether ``relative`` is already scanned under plugin or hook dirs."""
posix = relative.replace("\\", "/")
if posix.startswith(".claude-plugin/"):
return True
return posix.split("/", 1)[0] in _HOOK_DIRS


def _package_lifecycle_file_hits(root: Path) -> tuple[PluginHit, ...]:
"""Inspect ``package.json`` files that hook and plugin-dir walks miss.

Args:
root: Materialized plugin tree.

Returns:
Lifecycle-script findings from package.json files outside
``.claude-plugin/`` and hook directories. Unreadable files yield no
hits.
"""
hits: list[PluginHit] = []
for path in _walk_entries(root):
if path.is_symlink() or not path.is_file() or path.name != "package.json":
continue
relative = path.relative_to(root).as_posix()
if _already_inspected_package_json(relative):
continue
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
content = ""
hits.extend(inspect_claude_plugin_file(path.name, relative, content))
return tuple(hits)


def _github_write_token_hits(content: str) -> tuple[PluginHit, ...]:
"""Return hardcoded GitHub PAT or app-token findings without secret bodies."""
match = _GITHUB_TOKEN.search(content)
Expand Down Expand Up @@ -1599,7 +1723,7 @@ def _plugin_identity(root: Path) -> dict[str, str]:


def _collect_plugin_hits(root: Path) -> tuple[PluginHit, ...]:
"""Combine package-level and per-file Claude plugin findings."""
"""Combine package-level, hook, and package.json lifecycle findings."""
hits = list(scan_claude_plugin_package(root))
for mcp_name in _MCP_FILENAMES:
mcp_path = root / mcp_name
Expand Down Expand Up @@ -1635,6 +1759,7 @@ def _collect_plugin_hits(root: Path) -> tuple[PluginHit, ...]:
except (OSError, UnicodeDecodeError):
content = ""
hits.extend(inspect_claude_plugin_file(path.name, relative, content))
hits.extend(_package_lifecycle_file_hits(root))
hits.extend(_skill_supply_chain_hits(root))
return tuple(hits)

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`, `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-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-secret-to-network`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent 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-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-secret-to-network`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent 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, unpinned package URL installs, GitHub write tokens, Docker socket binds, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, 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, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, 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 @@ -79,7 +79,8 @@ files being scanned, then applies the union of relevant checks. Examples:
- `claude-plugin-*`: CWE-494/CWE-798/CWE-829/CWE-250/CWE-200 plugin
marketplace provenance, provider secrets, GitHub write tokens, Docker
socket binds, secret-to-network flows, pipe-to-shell installers,
unsigned executable downloads, unpinned package URL installs,
unsigned executable downloads including package.json lifecycle scripts,
unpinned package URL installs,
undeclared executables, reused #1036 skill-supply-chain identities on
plugin skill/agent surfaces, and fail-closed replay of a stale or
mismatched scan receipt.
Expand Down
Loading