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 @@ -57,7 +57,8 @@ jobs:
--test tests/test_claude_plugin_scan_cli.py \
--test tests/test_claude_plugin_license_mismatch.py \
--test tests/test_claude_plugin_postinstall_download.py \
--test tests/test_claude_plugin_dynamic_eval.py
--test tests/test_claude_plugin_dynamic_eval.py \
--test tests/test_claude_plugin_hidden_executable.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
7 changes: 5 additions & 2 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@
across the declared license field, LICENSE, and NOTICE fail as
`claude-plugin-license-mismatch` without inventing legal approval.
Hook `eval`/`exec`/`compile`/`Function` and shell `eval` fail as
`claude-plugin-dynamic-eval`. `.claude-plugin/` is included in the
scan walk.
`claude-plugin-dynamic-eval`. Hidden undeclared executable or config
surfaces (``.bin/run.sh``, ``.hooks/secret.py``) fail as
`claude-plugin-hidden-undeclared-executable`. `.git/` metadata,
`.gitignore`, LICENSE, declared `hooks/pre.sh`, and `.mcp.json` are
not that class. `.claude-plugin/` is included in the scan walk.
110 changes: 105 additions & 5 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
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, package.json lifecycle download, unpinned
package URL install, dynamic eval/exec, undeclared hook, archive path
package URL install, dynamic eval/exec, undeclared hook, hidden undeclared
executable or config surface, 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
Expand Down Expand Up @@ -50,6 +51,12 @@
"in the plugin manifest. Unknown hooks fail admission until classified. "
"[CWE-829 - Inclusion of Functionality from Untrusted Control Sphere]"
)
CLAUDE_PLUGIN_HIDDEN_UNDECLARED_EXECUTABLE_MESSAGE: Final = (
"Claude plugin package contains a hidden executable or configuration "
"surface that is not declared in the plugin manifest. Dotfile names and "
"hidden directories fail admission until classified. "
"[CWE-829 - Inclusion of Functionality from Untrusted Control Sphere]"
)
CLAUDE_PLUGIN_SYMLINK_ESCAPE_MESSAGE: Final = (
"Claude plugin package contains a symbolic link. Symlinks are not followed "
"and fail admission until the exact regular-file identity is declared. "
Expand Down Expand Up @@ -212,6 +219,15 @@
)
_SHELL_SUFFIXES: Final = frozenset({".sh", ".bash", ".zsh"})
_HOOK_DIRS = ("hooks", "scripts", "commands")
_HIDDEN_CONFIG_SUFFIXES: Final = frozenset(
{".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env"}
)
_GIT_METADATA_NAMES: Final = frozenset(
{".gitignore", ".gitattributes", ".gitmodules"}
)
_CLAUDE_PLUGIN_MANIFEST_NAMES: Final = frozenset(
{"plugin.json", "marketplace.json"}
)
_SKILL_SUPPLY_CHAIN_RULE_IDS: Final = frozenset(
{
"skill-name-homoglyph-confusable",
Expand Down Expand Up @@ -489,10 +505,12 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]:
root: Scan root that may contain ``.claude-plugin/``.

Returns:
Undeclared executable, license absence or SPDX mismatch, size, symlink,
archive traversal, and unadmitted-submodule findings. Empty when the
tree is not a plugin package or every hook is a declared regular file.
Inventory presence is not a finding.
Undeclared executable, hidden undeclared executable or config,
license absence or SPDX mismatch, size, symlink, archive traversal,
and unadmitted-submodule findings. Empty when the tree is not a
plugin package or every hook is a declared regular file. Inventory
presence is not a finding. Git metadata is not a plugin executable
surface. ``.mcp.json`` stays the MCP class.
"""
plugin_dir = root / ".claude-plugin"
if not plugin_dir.is_dir() or plugin_dir.is_symlink():
Expand Down Expand Up @@ -567,6 +585,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]:
file=relative,
)
)
hits.extend(_hidden_undeclared_executable_hits(root, declared))
return tuple(hits)


Expand Down Expand Up @@ -1276,6 +1295,87 @@ def _collect_declared(value: object, declared: set[str]) -> None:
_collect_declared(item, declared)


def _is_git_metadata_path(relative: str) -> bool:
"""Return whether ``relative`` is Git metadata, not a plugin surface.

``.git/`` internals, gitlink files named ``.git``, and
ignore/attributes/modules files are VCS metadata. They are not Claude
plugin executable or config surfaces, including when nested under a
vendor path.
"""
return any(
part == ".git" or part in _GIT_METADATA_NAMES for part in relative.split("/")
)


def _is_hidden_plugin_path(relative: str) -> bool:
"""Return whether a package-relative path uses a hidden name or directory."""
return any(part.startswith(".") for part in relative.split("/"))


def _is_hidden_executable_or_config_surface(path: Path, relative: str) -> bool:
"""Return whether a hidden path is an executable, script, or config surface.

Documented ``.mcp.json`` and ``.claude-plugin`` manifests are not this
class. Git metadata is not a plugin executable surface.
"""
if not _is_hidden_plugin_path(relative) or _is_git_metadata_path(relative):
return False
if path.name in _MCP_FILENAMES:
return False
parts = relative.split("/")
if (
len(parts) >= 2
and parts[-2] == ".claude-plugin"
and parts[-1] in _CLAUDE_PLUGIN_MANIFEST_NAMES
):
return False
suffix = path.suffix.lower()
return (
suffix in _EXECUTABLE_SUFFIXES
or suffix == ""
or suffix in _HIDDEN_CONFIG_SUFFIXES
)


def _hidden_undeclared_executable_hits(
root: Path, declared: set[str]
) -> tuple[PluginHit, ...]:
"""Return findings for hidden undeclared executable or config surfaces.

Args:
root: Materialized plugin tree.
declared: Hook and command paths declared in the plugin manifest.

Returns:
Hits for hidden paths such as ``.bin/run.sh`` or ``.hooks/secret.py``
that are executable, script, or config surfaces and are not
declared. Empty when every hidden surface is Git metadata,
documented MCP or plugin manifest, or already declared. Non-hidden
extras under ``hooks/``, ``scripts/``, or ``commands/`` stay
``claude-plugin-undeclared-executable``.
"""
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 relative in declared:
continue
if not _is_hidden_executable_or_config_surface(path, relative):
continue
hits.append(
PluginHit(
rule_id="claude-plugin-hidden-undeclared-executable",
line=1,
snippet=_sanitize_path_snippet(path.name),
message=CLAUDE_PLUGIN_HIDDEN_UNDECLARED_EXECUTABLE_MESSAGE,
file=relative,
)
)
return tuple(hits)


def _line_of(content: str, token: str) -> int:
"""Return the 1-based line where ``token`` first appears."""
index = content.find(token)
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-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `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-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-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 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, dynamic eval/exec on hook 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, 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, 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
Loading