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 @@ -76,7 +76,8 @@ jobs:
--test tests/test_claude_plugin_policy_provenance.py \
--test tests/test_claude_plugin_sbom_receipt.py \
--test tests/test_claude_plugin_checksum_mismatch.py \
--test tests/test_claude_plugin_github_merge_release.py
--test tests/test_claude_plugin_github_merge_release.py \
--test tests/test_claude_plugin_credential_store.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,10 @@
``docker push`` stay inventory. Hardcoded PATs stay
`claude-plugin-github-write-token`. Snippets are command labels, not
tokens.
Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``,
``~/.config/gh/hosts.yml``, Docker ``config.json`` auth, ``cookies.txt``,
``~/.curl_home``, and ``~/.ssh/id_*`` private keys fail as
`claude-plugin-credential-store-access`. Chrome and Firefox profile
stores stay `claude-plugin-browser-profile-access`. README AWS wording,
``gh issue create``, ``docker push``, and a declared ``0755`` echo hook
are not that class. Snippets are path labels, not secret values.
103 changes: 101 additions & 2 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
executable or config surface, archive path
escape, decompression bomb or nested-archive depth, unadmitted nested
submodule, hardcoded GitHub write token, GitHub merge or release CLI command, Docker
socket bind, host browser-profile store, secret copied into a network
request, secret copied into a prompt, log, or subprocess environment,
socket bind, host browser-profile store, host cookie or token store,
secret copied into a network request, secret copied into a prompt, log,
or subprocess environment,
secret copied into MCP env, args, command, URL, or headers,
a non-standard JSON constant, malformed UTF-8 JSON bytes, a
non-NFC identity name, conflicting plugin/skill/command identity,
Expand All @@ -20,6 +21,11 @@
finding. Capability inventory is evidence,
not permission, except that hook or manifest ``gh pr merge`` and
``gh release create|upload|delete|edit`` fail closed as command findings.
Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``,
GitHub CLI hosts, Docker auth ``config.json``, cookie jars, and
``~/.ssh/id_*`` private keys fail closed as credential-store findings.
Chrome and Firefox profile stores stay browser-profile findings.
Hardcoded PATs stay write-token findings.
``gh issue create``, ``gh pr review``, ``kubectl apply``, and
``docker push`` stay inventory. Skill
homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule
Expand Down Expand Up @@ -227,6 +233,13 @@
"browser. Remove the profile path. "
"[CWE-219 - Sensitive Information in Browser's History/Cache/Cookies]"
)
CLAUDE_PLUGIN_CREDENTIAL_STORE_MESSAGE: Final = (
"Claude plugin hook or manifest reaches a host cookie or token store. "
"Netrc, cloud credentials, GitHub CLI hosts, Docker registry auth, "
"cookie jars, and SSH private keys are credential access, not browser "
"profile stores. Remove the store path. "
"[CWE-522 - Insufficiently Protected Credentials]"
)
CLAUDE_PLUGIN_DECEPTIVE_DESCRIPTION_MESSAGE: Final = (
"Claude plugin, skill, or command description claims innocuous, "
"read-only, or local-only behavior while the capability inventory "
Expand Down Expand Up @@ -366,6 +379,40 @@
r"\.mozilla/firefox|cookies\.sqlite|Login Data)",
re.IGNORECASE,
)
_CREDENTIAL_STORE_PATTERNS: Final = (
(re.compile(r"(?:~[/\\])?\.netrc\b|_netrc\b", re.IGNORECASE), "~/.netrc"),
(
re.compile(r"(?:~[/\\])?\.aws[/\\]credentials\b", re.IGNORECASE),
"~/.aws/credentials",
),
(
re.compile(r"(?:~[/\\])?\.config[/\\]gh[/\\]hosts\.ya?ml\b", re.IGNORECASE),
"~/.config/gh/hosts.yml",
),
(
re.compile(r"(?:~[/\\])?\.docker[/\\]config\.json\b", re.IGNORECASE),
"~/.docker/config.json",
),
(
re.compile(r"(?:~[/\\])?\.curl_home\b", re.IGNORECASE),
"~/.curl_home",
),
(
re.compile(r"(?<![A-Za-z0-9._-])cookies\.txt\b", re.IGNORECASE),
"cookies.txt",
),
(
re.compile(
r"(?:~[/\\])?\.ssh[/\\](?P<name>id_[A-Za-z0-9_]+)(?![A-Za-z0-9_.])",
re.IGNORECASE,
),
"",
),
)
_CREDENTIAL_STORE = re.compile(
"|".join(pattern.pattern for pattern, _label in _CREDENTIAL_STORE_PATTERNS),
re.IGNORECASE,
)
_SECRET_TO_NETWORK = re.compile(
r"(?:curl|wget|fetch)\b[^\n]*\$(?:\{)?(?P<name>"
r"OPENAI_API_KEY|NVIDIA_NIM_API_KEY(?:_SUB)?|BYTEZ_API_KEY|"
Expand Down Expand Up @@ -553,6 +600,7 @@
),
),
("credential_access", _PROVIDER_SECRET),
("credential_access", _CREDENTIAL_STORE),
(
"deployment_write",
re.compile(
Expand Down Expand Up @@ -809,6 +857,7 @@ def inspect_claude_plugin_file(
hits.extend(_github_release_command_hits(content, manifest=manifest))
hits.extend(_docker_socket_hits(content))
hits.extend(_browser_profile_hits(content))
hits.extend(_credential_store_hits(content))
hits.extend(_secret_to_network_hits(content))
hits.extend(_secret_to_prompt_hits(content))
return tuple(hits)
Expand Down Expand Up @@ -2075,6 +2124,56 @@ def _browser_profile_hits(content: str) -> tuple[PluginHit, ...]:
)


def _credential_store_label(match: re.Match[str], default_label: str) -> str:
"""Return a path label for one host cookie or token store.

Args:
match: One credential-store regular-expression match.
default_label: Canonical path for non-SSH stores.

Returns:
A short path label with no secret values.
"""
if default_label:
return default_label
return f"~/.ssh/{match.group('name').lower()}"


def _credential_store_hits(content: str) -> tuple[PluginHit, ...]:
"""Return host cookie and token store findings from hook or manifest text.

``~/.netrc``, cloud credentials, GitHub CLI hosts, Docker registry
auth, cookie jars, and SSH private keys fail closed. Chrome and
Firefox profile stores stay ``claude-plugin-browser-profile-access``.
Snippets are path labels, not secret values or raw bidi.

Args:
content: Hook or manifest text.

Returns:
Zero or more hits, one per distinct store path label.
"""
hits: list[PluginHit] = []
seen: set[str] = set()
for pattern, label in _CREDENTIAL_STORE_PATTERNS:
for match in pattern.finditer(content):
snippet = _sanitize_plugin_snippet(
_credential_store_label(match, label)
)
if snippet in seen:
continue
seen.add(snippet)
hits.append(
PluginHit(
rule_id="claude-plugin-credential-store-access",
line=content[: match.start()].count("\n") + 1,
snippet=snippet[:120],
message=CLAUDE_PLUGIN_CREDENTIAL_STORE_MESSAGE,
)
)
return tuple(hits)


def _secret_to_network_hits(content: str) -> tuple[PluginHit, ...]:
"""Return findings when a named secret is copied into a network client."""
match = _SECRET_TO_NETWORK.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-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, 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-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `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
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, hide-actions / self-modify / goal-escalation wording on skill/command/agent surfaces, setuid/setgid or world-writable executable and hook modes, zip/tar decompression bombs, nested-archive depth, and pre-extraction aggregate byte budget, 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, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release and exact scan-policy digest, `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when first-party checksum evidence disagrees with artifact bytes, 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, setuid/setgid or world-writable executable and hook modes, zip/tar decompression bombs, nested-archive depth, and pre-extraction aggregate byte budget, 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, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release and exact scan-policy digest, `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when first-party checksum evidence disagrees with artifact bytes, GitHub merge and release CLI write verbs, host cookie and token stores that are not browser profiles, 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
11 changes: 8 additions & 3 deletions docs/sast-dast-rule-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ files being scanned, then applies the union of relevant checks. Examples:
- `java-jwt-none-algorithm`: JWT none algorithm marker.
- `java-objectinputstream-deserialization`: direct Java native deserialization
entry point, CWE-502.
- `claude-plugin-*`: CWE-494/CWE-798/CWE-829/CWE-250/CWE-269/CWE-200/CWE-451/CWE-693 plugin
- `claude-plugin-*`: CWE-494/CWE-798/CWE-829/CWE-250/CWE-269/CWE-200/CWE-451/CWE-522/CWE-693 plugin
marketplace provenance, provider secrets, GitHub write tokens, GitHub merge
and release CLI commands, Docker
socket binds, secret-to-network flows, secret-to-prompt and secret-to-log
Expand All @@ -94,9 +94,14 @@ files being scanned, then applies the union of relevant checks. Examples:
`sbom_sha256` of a deterministic CycloneDX 1.5 dependency document, and
`claude-plugin-checksum-mismatch` when a first-party checksum file
disagrees with artifact bytes on disk, `claude-plugin-github-merge-command`
for hook or manifest ``gh pr merge``, and
for hook or manifest ``gh pr merge``,
`claude-plugin-github-release-command` for ``gh release``
create/upload/delete/edit. ``gh issue create``, ``gh pr review``, and
create/upload/delete/edit, and
`claude-plugin-credential-store-access` for host ``~/.netrc``,
``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and
SSH private keys. Chrome/Firefox profile stores stay
`claude-plugin-browser-profile-access`. Hardcoded PATs stay
`claude-plugin-github-write-token`. ``gh issue create``, ``gh pr review``, and
``docker push`` stay inventory.
- Mapped, not owned here: GitHub Actions transport-only poll loops (#1087,
PR #1088) and orphaned workflow registry DAST (#929, PR #966).
Expand Down
Loading