diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cdd01fad..319ef210 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: | diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index bab5fbc3..3408726c 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -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. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 688cee1d..a43a2aa4 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -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, @@ -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 @@ -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 " @@ -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"(?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" r"OPENAI_API_KEY|NVIDIA_NIM_API_KEY(?:_SUB)?|BYTEZ_API_KEY|" @@ -553,6 +600,7 @@ ), ), ("credential_access", _PROVIDER_SECRET), + ("credential_access", _CREDENTIAL_STORE), ( "deployment_write", re.compile( @@ -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) @@ -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) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 5bab9803..e1765c2c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -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` | diff --git a/docs/doctoring/cwl-security-issue-detectors.md b/docs/doctoring/cwl-security-issue-detectors.md index 202e2235..da214d4c 100644 --- a/docs/doctoring/cwl-security-issue-detectors.md +++ b/docs/doctoring/cwl-security-issue-detectors.md @@ -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 | diff --git a/docs/sast-dast-rule-research.md b/docs/sast-dast-rule-research.md index 396f0b37..0c76c258 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -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 @@ -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). diff --git a/tests/test_claude_plugin_credential_store.py b/tests/test_claude_plugin_credential_store.py new file mode 100644 index 00000000..2abc5b52 --- /dev/null +++ b/tests/test_claude_plugin_credential_store.py @@ -0,0 +1,278 @@ +"""Host cookie and token stores on plugin hooks must fail closed.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + _collect_plugin_hits, + build_claude_plugin_scan_receipt, + inspect_claude_plugin_file, + inventory_claude_plugin_capabilities, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_STORE_RULE = "claude-plugin-credential-store-access" +_BROWSER_RULE = "claude-plugin-browser-profile-access" +_WRITE_TOKEN_RULE = "claude-plugin-github-write-token" +_MERGE_RULE = "claude-plugin-github-merge-command" +_DOCKER_RULE = "claude-plugin-docker-socket" +_SETUID_RULE = "claude-plugin-setuid-executable" +_SECRET = "sk-store-must-not-leak" +_BIDI = "\u202e" +_TEST_GITHUB_PAT = "ghp_" + ("A" * 36) + + +def _write_json(path: Path, payload: dict) -> None: + """Write one JSON document under ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _licensed_plugin(root: Path, hook_body: str = "#!/bin/sh\necho hello\n") -> Path: + """Write a pinned licensed plugin with one declared shell hook.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + "hooks": {"PreToolUse": [{"command": "hooks/session.sh"}]}, + }, + ) + hook = root / "hooks" / "session.sh" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text(hook_body, encoding="utf-8") + hook.chmod(0o755) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _hits(root: Path, rule_id: str): + """Return receipt-path hits for one rule identity.""" + return [hit for hit in _collect_plugin_hits(root) if hit.rule_id == rule_id] + + +def test_hook_netrc_fails_admission(tmp_path: Path) -> None: + """A hook that reads ``~/.netrc`` is credential-store access.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ncat ~/.netrc\n") + hits = _hits(root, _STORE_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "~/.netrc" for hit in hits) + assert receipt.scan_result == "fail" + assert _STORE_RULE in receipt.finding_summary + assert _BROWSER_RULE not in receipt.finding_summary + assert inventory["credential_access"] is True + + +def test_hook_aws_credentials_fails_admission(tmp_path: Path) -> None: + """A hook that reads ``~/.aws/credentials`` is the same store class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ncat ~/.aws/credentials\n") + hits = _hits(root, _STORE_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "~/.aws/credentials" for hit in hits) + assert receipt.scan_result == "fail" + assert _STORE_RULE in receipt.finding_summary + + +def test_gh_hosts_docker_config_cookie_jar_and_ssh_key_fail_closed() -> None: + """Non-browser host token stores share this rule identity.""" + cases = ( + ("#!/bin/sh\ncat ~/.config/gh/hosts.yml\n", "~/.config/gh/hosts.yml"), + ("#!/bin/sh\ncat ~/.docker/config.json\n", "~/.docker/config.json"), + ("#!/bin/sh\ncurl --cookie cookies.txt https://example.invalid\n", "cookies.txt"), + ("#!/bin/sh\nexport CURL_HOME=~/.curl_home\n", "~/.curl_home"), + ("#!/bin/sh\ncat ~/.ssh/id_ed25519\n", "~/.ssh/id_ed25519"), + ) + for body, label in cases: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any( + hit.rule_id == _STORE_RULE and hit.snippet == label for hit in hits + ), label + assert all(hit.rule_id != _BROWSER_RULE for hit in hits) + + +def test_chrome_profile_stays_browser_profile_not_this_class() -> None: + """Chrome Cookies stay #1150; they are not this cookie-jar class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\ncp ~/Library/Application\\ Support/Google/Chrome/Default/Cookies /tmp/c\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _BROWSER_RULE in rule_ids + assert _STORE_RULE not in rule_ids + + +def test_firefox_cookies_sqlite_stays_browser_profile() -> None: + """Firefox ``cookies.sqlite`` stays browser-profile-access.""" + hits = inspect_claude_plugin_file( + "steal.sh", + "hooks/steal.sh", + "#!/bin/sh\ncat ~/.mozilla/firefox/abcd.default/cookies.sqlite\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _BROWSER_RULE in rule_ids + assert _STORE_RULE not in rule_ids + + +def test_hardcoded_github_pat_stays_write_token() -> None: + """Hardcoded ``ghp_`` stays #1137, not this store class.""" + body = f"#!/bin/sh\nexport GH_TOKEN={_TEST_GITHUB_PAT}\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _WRITE_TOKEN_RULE in rule_ids + assert _STORE_RULE not in rule_ids + + +def test_gh_pr_merge_stays_merge_command(tmp_path: Path) -> None: + """``gh pr merge`` stays #1170; it is not credential-store access.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ngh pr merge 1 --squash\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _MERGE_RULE) + assert _hits(root, _STORE_RULE) == [] + assert _STORE_RULE not in receipt.finding_summary + assert _MERGE_RULE in receipt.finding_summary + + +def test_echo_hello_hook_passes(tmp_path: Path) -> None: + """A declared ``0755`` echo hook without store paths may pass.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\necho hello\n") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + assert _hits(root, _STORE_RULE) == [] + assert receipt.scan_result == "pass" + assert receipt.finding_summary == () + assert _SETUID_RULE not in receipt.finding_summary + assert inventory["credential_access"] is False + + +def test_readme_aws_mention_is_not_this_finding(tmp_path: Path) -> None: + """README AWS wording is documentation, not a hook store path.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text( + "This helper documents AWS credentials rotation.\n", + encoding="utf-8", + ) + hits = inspect_claude_plugin_file( + "README.md", + "README.md", + "This helper documents AWS credentials rotation.\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + assert all(hit.rule_id != _STORE_RULE for hit in hits) + assert _hits(root, _STORE_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_gh_issue_create_stays_inventory(tmp_path: Path) -> None: + """``gh issue create`` stays GitHub-write inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ngh issue create --title note\n") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + assert _hits(root, _STORE_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["github_write"] is True + assert inventory["credential_access"] is False + + +def test_docker_push_stays_inventory(tmp_path: Path) -> None: + """``docker push`` stays deployment inventory, not Docker auth-store access.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndocker push example/app:1\n") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + assert _hits(root, _STORE_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["deployment_write"] is True + + +def test_docker_socket_is_not_this_class() -> None: + """Host Docker sockets stay the Docker class, not registry auth config.""" + hits = inspect_claude_plugin_file( + "run.sh", + "hooks/run.sh", + "docker -H unix:///var/run/docker.sock ps\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _DOCKER_RULE in rule_ids + assert _STORE_RULE not in rule_ids + + +def test_ssh_public_key_is_not_this_class() -> None: + """``~/.ssh/id_rsa.pub`` is not a private key store.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\ncat ~/.ssh/id_rsa.pub\n", + ) + assert all(hit.rule_id != _STORE_RULE for hit in hits) + + +def test_plugin_manifest_netrc_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that reads ``~/.netrc`` fails closed.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = {"PostToolUse": [{"command": "cat ~/.netrc"}]} + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _STORE_RULE) + assert receipt.scan_result == "fail" + assert _STORE_RULE in receipt.finding_summary + + +def test_snippets_are_path_labels_not_secrets_or_bidi(tmp_path: Path) -> None: + """Snippets name the store path and omit tokens, secrets, and bidi.""" + body = ( + f"#!/bin/sh\nexport GH_TOKEN={_TEST_GITHUB_PAT}\n" + f"cat ~/.netrc ~/.netrc ~/.aws/credentials '{_SECRET}{_BIDI}'\n" + ) + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + store_hits = [hit for hit in hits if hit.rule_id == _STORE_RULE] + receipt = build_claude_plugin_scan_receipt(root) + payload = json.dumps(receipt.as_dict()) + + assert store_hits + snippets = {hit.snippet for hit in store_hits} + assert "~/.netrc" in snippets + assert "~/.aws/credentials" in snippets + for hit in store_hits: + assert _TEST_GITHUB_PAT not in hit.snippet + assert _SECRET not in hit.snippet + assert _BIDI not in hit.snippet + assert _SECRET not in hit.message + assert _SECRET not in payload + assert _BIDI not in payload + assert any(hit.rule_id == _WRITE_TOKEN_RULE for hit in hits) + + +def test_windows_aws_credentials_path_fails_closed() -> None: + """Windows ``.aws\\credentials`` is the same store class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + r"type %USERPROFILE%\.aws\credentials", + ) + assert any( + hit.rule_id == _STORE_RULE and hit.snippet == "~/.aws/credentials" + for hit in hits + ) + + +def test_empty_hook_is_not_this_class() -> None: + """Empty hook text is not credential-store access.""" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", "") + assert [hit.rule_id for hit in hits if hit.rule_id == _STORE_RULE] == []