From cc3db8cf98a88cbeae92ee0c8b9a25ef45123e0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 08:22:31 +0900 Subject: [PATCH 1/4] test(scanner): fail closed on plugin cookie and token stores Lock ~/.netrc, ~/.aws/credentials, gh hosts, Docker config.json, cookies.txt, ~/.curl_home, and ~/.ssh/id_* as fail-closed findings. Keep Chrome/Firefox profiles, hardcoded ghp_, and gh pr merge on their existing classes. Relates to #1099. --- tests/test_claude_plugin_credential_store.py | 278 +++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 tests/test_claude_plugin_credential_store.py diff --git a/tests/test_claude_plugin_credential_store.py b/tests/test_claude_plugin_credential_store.py new file mode 100644 index 00000000..1533905c --- /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 ~/.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] == [] From 9370a0dd44e9af15c7b6ccb7165c042ad3de377c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 08:26:15 +0900 Subject: [PATCH 2/4] feat(scanner): reject plugin access to cookie and token stores Fail closed on hook and manifest paths into ~/.netrc, AWS credentials, GitHub CLI hosts, Docker auth, cookie jars, and SSH private keys as claude-plugin-credential-store-access. Browser profiles, hardcoded PATs, and gh pr merge stay their existing classes. Relates to #1099. --- .github/workflows/tests.yml | 3 +- .../1099-claude-plugin-supply-chain.md | 7 ++ appguardrail_core/claude_plugin_detector.py | 104 +++++++++++++++++- docs/TRACEABILITY.md | 2 +- .../doctoring/cwl-security-issue-detectors.md | 2 +- docs/sast-dast-rule-research.md | 11 +- tests/test_claude_plugin_credential_store.py | 2 +- 7 files changed, 121 insertions(+), 10 deletions(-) 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 5df1bdb5..177e56e1 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -132,3 +132,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 09d359c4..6f8a11bf 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -7,9 +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, -secret copied into MCP env or args, +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 or args, a non-standard JSON constant, malformed UTF-8 JSON bytes, a non-NFC identity name, conflicting plugin/skill/command identity, undeclared vendored or generated third-party @@ -20,6 +20,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 @@ -226,6 +231,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 " @@ -335,6 +347,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|" @@ -519,6 +565,7 @@ ), ), ("credential_access", _PROVIDER_SECRET), + ("credential_access", _CREDENTIAL_STORE), ( "deployment_write", re.compile( @@ -775,6 +822,7 @@ def inspect_claude_plugin_file( hits.extend(_github_release_command_hits(content)) 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) @@ -1502,6 +1550,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 de8593ad..9c2c0dbe 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, and secrets copied into MCP env/args, 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, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, first-party SHA256SUMS/`*.sha256` checksum mismatch against bytes on disk, 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, and secrets copied into MCP env/args, 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, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, first-party SHA256SUMS/`*.sha256` checksum mismatch against bytes on disk, 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 index 1533905c..2abc5b52 100644 --- a/tests/test_claude_plugin_credential_store.py +++ b/tests/test_claude_plugin_credential_store.py @@ -237,7 +237,7 @@ 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 ~/.aws/credentials '{_SECRET}{_BIDI}'\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) From 1f611b9821ef0fe57edb419224655ebb6da8d74a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:38:14 +0900 Subject: [PATCH 3/4] fix(scanner): inherit GitHub command parser --- appguardrail_core/claude_plugin_detector.py | 632 ++++++++++++++++++-- 1 file changed, 598 insertions(+), 34 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 6f8a11bf..5b987f64 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -58,6 +58,7 @@ import os from pathlib import Path import re +import shlex import stat import tarfile from typing import Final, Iterable @@ -331,11 +332,41 @@ _GITHUB_TOKEN = re.compile( r"\b(?Pghp_|github_pat_|gho_|ghu_|ghs_)[A-Za-z0-9_]{20,}\b" ) -_GITHUB_MERGE_COMMAND = re.compile(r"\bgh\s+pr\s+merge\b", re.IGNORECASE) +_GITHUB_MERGE_COMMAND = re.compile( + r"\bgh\s+pr\s+merge(?=$|[\s;&|()<>])", re.IGNORECASE +) _GITHUB_RELEASE_COMMAND = re.compile( - r"\bgh\s+release\s+(?Pcreate|upload|delete|edit)\b", + r"\bgh\s+release\s+(?Pcreate|upload|delete|edit)" + r"(?=$|[\s;&|()<>])", re.IGNORECASE, ) +_REPORTING_BUILTINS: Final = frozenset( + {":", "echo", "false", "print", "printf", "true"} +) +_SHELL_COMMAND_INTERPRETERS: Final = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) +_SHELL_NO_VALUE_SHORT_OPTIONS: Final = frozenset("efilsuvx") +_BASH_NO_VALUE_SHORT_OPTIONS: Final = frozenset("abhkmprtBCEHPT") +_BASH_NO_VALUE_LONG_OPTIONS: Final = frozenset( + { + "--debug", + "--debugger", + "--login", + "--noediting", + "--noprofile", + "--norc", + "--posix", + "--pretty-print", + "--restricted", + "--verbose", + } +) +_SHELL_ASSIGNMENT_PREFIX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +_FIRST_SHELL_TOKEN = re.compile(r"\s*(:|[A-Za-z0-9_./+-]+)") +_LITERAL_HEREDOC_OPEN = re.compile( + r"<<(?P-)?[ \t]*(?P['\"]?)" + r"(?P[A-Za-z_][A-Za-z0-9_]*)(?P=quote)" + r"(?=$|[ \t;&|()<>])" +) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -818,8 +849,8 @@ def inspect_claude_plugin_file( hits.extend(_package_lifecycle_hits(content)) if manifest or hook_surface: hits.extend(_github_write_token_hits(content)) - hits.extend(_github_merge_command_hits(content)) - hits.extend(_github_release_command_hits(content)) + hits.extend(_github_merge_command_hits(content, manifest=manifest)) + 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)) @@ -1443,52 +1474,585 @@ def _github_write_token_hits(content: str) -> tuple[PluginHit, ...]: ) -def _github_merge_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return ``gh pr merge`` findings with a command label, not tokens. +def _unquoted_hash_index(line: str) -> int | None: + """Return the index of an unquoted ``#`` shell comment, if any. Args: - content: Hook or manifest text. + line: One hook or manifest line without a trailing newline. + + Returns: + The comment index, or ``None`` when every ``#`` is quoted or escaped. + """ + in_single = False + in_double = False + escaped = False + for index, char in enumerate(line): + if escaped: + escaped = False + continue + if char == "\\" and not in_single: + escaped = True + continue + if char == "'" and not in_double: + in_single = not in_single + continue + if char == '"' and not in_single: + in_double = not in_double + continue + if char == "#" and not in_single and not in_double: + return index + return None + + +def _iter_unquoted_segment_bounds(line: str) -> tuple[tuple[int, int], ...]: + """Return start/end offsets of unquoted shell command segments. + + Args: + line: One hook or manifest line without a trailing newline. + + Returns: + Inclusive-start exclusive-end spans split on unquoted ``&&``, + ``||``, ``;``, ``|``, and ``&``. Quoted lookalikes stay one span. + """ + bounds: list[tuple[int, int]] = [] + start = 0 + in_single = False + in_double = False + escaped = False + length = len(line) + index = 0 + while index < length: + char = line[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\" and not in_single: + escaped = True + index += 1 + continue + if char == "'" and not in_double: + in_single = not in_single + index += 1 + continue + if char == '"' and not in_single: + in_double = not in_double + index += 1 + continue + if in_single or in_double: + index += 1 + continue + two = line[index : index + 2] + if two in {"&&", "||"}: + bounds.append((start, index)) + start = index + 2 + index += 2 + continue + if char in {";", "|", "&"}: + bounds.append((start, index)) + start = index + 1 + index += 1 + continue + index += 1 + bounds.append((start, length)) + return tuple(bounds) + + +def _first_shell_token(segment: str) -> str: + """Return the first command basename of a shell segment. + + Args: + segment: One unquoted command fragment. Returns: - One hit when the merge CLI is present. Empty when the text only - lists, views, or reviews pull requests. + A lowercase basename such as ``echo``. Empty when the fragment + has no command token. """ - match = _GITHUB_MERGE_COMMAND.search(content) + match = _FIRST_SHELL_TOKEN.match(segment) if match is None: + return "" + name = match.group(1).rsplit("/", 1)[-1] + if name.lower().endswith(".exe"): + name = name[:-4] + return name.lower() + + +def _is_reporting_builtin_segment(segment: str) -> bool: + """Return whether the command does not execute its argument text. + + Args: + segment: One unquoted command fragment. + + Returns: + ``True`` for no-op, status, and reporting commands, including path + and ``.exe`` spellings. + """ + return _first_shell_token(segment) in _REPORTING_BUILTINS + + +def _manifest_command_sources(content: str) -> tuple[tuple[str, int], ...]: + """Return structural manifest command strings with source line numbers.""" + try: + payload = _load_manifest_json(content) + except (_DuplicateJsonMember, _NonstandardJsonConstant, json.JSONDecodeError): return () - return ( - PluginHit( - rule_id="claude-plugin-github-merge-command", - line=content[: match.start()].count("\n") + 1, - snippet="gh pr merge", - message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, - ), - ) + + found: list[tuple[str, int]] = [] + + def collect(value: object) -> None: + if isinstance(value, dict): + for key, nested in value.items(): + if key == "command" and isinstance(nested, str) and nested.strip(): + found.append((nested, _script_line(content, nested))) + else: + collect(nested) + elif isinstance(value, list): + for nested in value: + collect(nested) + + collect(payload) + return tuple(found) + + +def _direct_executable_basename(command: str) -> str: + """Return a direct executable basename without changing token identity.""" + if not command or command != command.strip(): + return "" + name = command.replace("\\", "/").rsplit("/", 1)[-1].casefold() + return name[:-4] if name.endswith(".exe") else name + + +def _manifest_argv_sources( + content: str, +) -> tuple[tuple[str, tuple[str, ...], int], ...]: + """Return typed direct-argv records from structural manifest objects.""" + try: + payload = _load_manifest_json(content) + except (_DuplicateJsonMember, _NonstandardJsonConstant, json.JSONDecodeError): + return () + + found: list[tuple[str, tuple[str, ...], int]] = [] + + def collect(value: object) -> None: + if isinstance(value, dict): + command = value.get("command") + args = value.get("args") + if ( + isinstance(command, str) + and command + and isinstance(args, list) + and all(isinstance(argument, str) for argument in args) + ): + found.append((command, tuple(args), _script_line(content, command))) + for nested in value.values(): + collect(nested) + elif isinstance(value, list): + for nested in value: + collect(nested) + + collect(payload) + return tuple(found) + + +def _manifest_argv_command_line( + content: str, + *, + executable: str, + verb: str, + leading_value_option: str | None = None, +) -> int | None: + """Return the source line for one direct structural manifest argv command. + + Args: + content: Parsed-manifest source text. + executable: Exact executable basename without an .exe suffix. + verb: Exact write verb expected in argv. + leading_value_option: Optional single name=value global option + allowed before the verb. + + Returns: + The one-based command source line, or None when identity, argv + types, option grammar, or verb boundaries do not match. + """ + for command, args, line in _manifest_argv_sources(content): + command_name = _direct_executable_basename(command) + verb_index = 0 + if ( + leading_value_option is not None + and args + and args[0].casefold().startswith(leading_value_option) + and len(args[0]) > len(leading_value_option) + ): + verb_index = 1 + if ( + command_name == executable + and verb_index < len(args) + and args[verb_index].casefold() == verb + ): + return line + return None + + +def _hosted_command_sources( + content: str, *, manifest: bool +) -> tuple[tuple[str, int], ...]: + """Return shell text sources for one hook or structural manifest.""" + if manifest: + return _manifest_command_sources(content) + return ((content, 1),) + + +def _shell_command_context_start(line: str, offset: int) -> int | None: + """Return the executable shell-frame start containing ``offset``. + + Args: + line: One hook or manifest command line. + offset: Zero-based match offset within ``line``. + + Returns: + The start of the root, ``$(...)``, or backtick command frame. + ``None`` means the offset is inert single- or double-quoted prose. + """ + frames: list[tuple[str, int, str, int]] = [("", 0, "", 0)] + escaped = False + index = 0 + while index < offset: + frame_end, frame_start, quote, depth = frames[-1] + char = line[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\" and quote != "'": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + frames[-1] = (frame_end, frame_start, "" if quote == "'" else "'", depth) + index += 1 + continue + if char == '"' and quote != "'": + frames[-1] = (frame_end, frame_start, "" if quote == '"' else '"', depth) + index += 1 + continue + if quote != "'" and line[index : index + 2] == "$(": + frames.append((")", index + 2, "", 1)) + index += 2 + continue + if quote != "'" and char == "`": + if frame_end == "`": + frames.pop() + else: + frames.append(("`", index + 1, "", 0)) + index += 1 + continue + if quote: + index += 1 + continue + if frame_end == ")" and char == "(": + frames[-1] = (frame_end, frame_start, quote, depth + 1) + elif frame_end == ")" and char == ")": + if depth == 1: + frames.pop() + else: + frames[-1] = (frame_end, frame_start, quote, depth - 1) + index += 1 + _frame_end, frame_start, quote, _depth = frames[-1] + return None if quote else frame_start + + +def _literal_heredoc_payload_spans(content: str) -> tuple[tuple[int, int], ...]: + """Return closed literal here-document payload spans. + + Args: + content: One hook or structural manifest command string. + + Returns: + Inclusive-start exclusive-end spans for payloads with one confidently + parsed identifier delimiter on the opener line. Quoted delimiters and + tab-stripping forms are supported. Ambiguous or unclosed forms stay + executable for fail-closed analysis. + """ + spans: list[tuple[int, int]] = [] + active: tuple[str, bool, int] | None = None + offset = 0 + for raw_line in content.splitlines(keepends=True): + line = raw_line.rstrip("\r\n") + if active is not None: + delimiter, strip_tabs, payload_start = active + candidate = line.lstrip("\t") if strip_tabs else line + if candidate == delimiter: + spans.append((payload_start, offset)) + active = None + offset += len(raw_line) + continue + + comment_at = _unquoted_hash_index(line) + openers = tuple( + match + for match in _LITERAL_HEREDOC_OPEN.finditer(line) + if (comment_at is None or match.start() < comment_at) + and _shell_command_context_start(line, match.start()) is not None + ) + if len(openers) == 1: + opener = openers[0] + active = ( + opener.group("delimiter"), + opener.group("strip") is not None, + offset + len(raw_line), + ) + offset += len(raw_line) + return tuple(spans) -def _github_release_command_hits(content: str) -> tuple[PluginHit, ...]: - """Return GitHub CLI release write-verb findings without secret bodies. +def _match_starts_in_shell_assignment_value(segment: str, offset: int) -> bool: + """Return whether ``offset`` starts inside an unquoted assignment word. + + Args: + segment: One shell command segment. + offset: Zero-based match offset within ``segment``. + + Returns: + True when the current shell word before ``offset`` contains ``=``. + An assignment followed by whitespace and a real command returns False. + """ + prefix = segment[:offset] + if not prefix or prefix[-1].isspace(): + return False + return "=" in prefix.rsplit(maxsplit=1)[-1] + + +def _executable_command_match( content: str, pattern: re.Pattern[str] +) -> re.Match[str] | None: + """Return the first regex match that is an executable command context. + + Unquoted ``#`` comments, quoted prose, closed literal here-document + payloads, shell assignment values, and ``echo``/``printf``/``print`` + segments are not executable. Direct + commands inside ``$(...)`` or backticks remain executable. Args: content: Hook or manifest text. + pattern: Compiled command regex. Returns: - One hit for ``create``, ``upload``, ``delete``, or ``edit``. - ``gh release list`` and ``gh release view`` are not this class. + The first executable match, or ``None``. """ - match = _GITHUB_RELEASE_COMMAND.search(content) - if match is None: - return () - verb = match.group("verb").lower() - return ( - PluginHit( - rule_id="claude-plugin-github-release-command", - line=content[: match.start()].count("\n") + 1, - snippet=f"gh release {verb}", - message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, - ), - ) + if not content: + return None + inert_payloads = _literal_heredoc_payload_spans(content) + for match in pattern.finditer(content): + if any(start <= match.start() < end for start, end in inert_payloads): + continue + line_start = content.rfind("\n", 0, match.start()) + 1 + line_end = content.find("\n", match.start()) + if line_end < 0: + line_end = len(content) + line = content[line_start:line_end] + relative = match.start() - line_start + context_start = _shell_command_context_start(line, relative) + if context_start is None: + continue + context = line[context_start:] + context_relative = relative - context_start + comment_at = _unquoted_hash_index(context) + if comment_at is not None and context_relative >= comment_at: + continue + for segment_start, segment_end in _iter_unquoted_segment_bounds(context): + if segment_start <= context_relative < segment_end: + segment = context[segment_start:segment_end] + segment_relative = context_relative - segment_start + if not _is_reporting_builtin_segment( + segment + ) and not _match_starts_in_shell_assignment_value( + segment, segment_relative + ): + return match + break + return None + + +def _shell_payload_index( + arguments: tuple[str, ...] | list[str], *, shell_name: str +) -> int | None: + """Return the payload index after bounded executable shell options.""" + seen_short_option = False + for index, token in enumerate(arguments): + if shell_name == "bash" and token in _BASH_NO_VALUE_LONG_OPTIONS: + if seen_short_option: + return None + continue + if not token.startswith("-") or token.startswith("--"): + return None + seen_short_option = True + flags = token[1:] + allowed_flags = _SHELL_NO_VALUE_SHORT_OPTIONS + if shell_name == "bash": + allowed_flags |= _BASH_NO_VALUE_SHORT_OPTIONS + if not flags or any( + flag not in allowed_flags and flag not in {"c", "n"} + for flag in flags + ): + return None + if "n" in flags: + return None + if "c" in flags: + payload_index = index + 1 + return payload_index if payload_index < len(arguments) else None + return None + + +def _nested_shell_payload_sources( + content: str, *, manifest: bool +) -> tuple[tuple[str, int], ...]: + """Return bounded direct shell -c payloads with their source line.""" + found: list[tuple[str, int]] = [] + for source, first_line in _hosted_command_sources(content, manifest=manifest): + inert_payloads = _literal_heredoc_payload_spans(source) + source_offset = 0 + for line_index, raw_line in enumerate(source.splitlines(keepends=True)): + line = raw_line.rstrip("\r\n") + comment_at = _unquoted_hash_index(line) + executable_line = line if comment_at is None else line[:comment_at] + for segment_start, segment_end in _iter_unquoted_segment_bounds( + executable_line + ): + absolute_start = source_offset + segment_start + if any( + start <= absolute_start < end for start, end in inert_payloads + ): + continue + segment = executable_line[segment_start:segment_end] + try: + tokens = shlex.split(segment, comments=False, posix=True) + except ValueError: + continue + token_index = 0 + while ( + token_index < len(tokens) + and _SHELL_ASSIGNMENT_PREFIX.match(tokens[token_index]) + ): + token_index += 1 + if token_index >= len(tokens): + continue + shell_name = _direct_executable_basename(tokens[token_index]) + if shell_name not in _SHELL_COMMAND_INTERPRETERS: + continue + shell_arguments = tokens[token_index + 1 :] + payload_index = _shell_payload_index( + shell_arguments, shell_name=shell_name + ) + if payload_index is None: + continue + payload = shell_arguments[payload_index] + if payload: + found.append((payload, first_line + line_index)) + source_offset += len(raw_line) + + if manifest: + for command, args, line in _manifest_argv_sources(content): + shell_name = _direct_executable_basename(command) + if shell_name not in _SHELL_COMMAND_INTERPRETERS: + continue + payload_index = _shell_payload_index(args, shell_name=shell_name) + if payload_index is not None and args[payload_index]: + found.append((args[payload_index], line)) + return tuple(found) + + +def _github_merge_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable GitHub merge findings, including typed argv.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _GITHUB_MERGE_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-github-merge-command", + line=first_line + source[: match.start()].count("\n"), + snippet="gh pr merge", + message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + ), + ) + if manifest: + for command, args, line in _manifest_argv_sources(content): + folded = tuple(argument.casefold() for argument in args) + if ( + _direct_executable_basename(command) == "gh" + and folded[:2] == ("pr", "merge") + ): + return ( + PluginHit( + rule_id="claude-plugin-github-merge-command", + line=line, + snippet="gh pr merge", + message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + ), + ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _GITHUB_MERGE_COMMAND) + if match is not None: + return ( + PluginHit( + rule_id="claude-plugin-github-merge-command", + line=first_line + source[: match.start()].count("\n"), + snippet="gh pr merge", + message=CLAUDE_PLUGIN_GITHUB_MERGE_COMMAND_MESSAGE, + ), + ) + return () +def _github_release_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return executable GitHub release findings, including typed argv.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _GITHUB_RELEASE_COMMAND) + if match is not None: + verb = match.group("verb").lower() + return ( + PluginHit( + rule_id="claude-plugin-github-release-command", + line=first_line + source[: match.start()].count("\n"), + snippet=f"gh release {verb}", + message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + ), + ) + if manifest: + for command, args, line in _manifest_argv_sources(content): + folded = tuple(argument.casefold() for argument in args) + if ( + _direct_executable_basename(command) == "gh" + and len(folded) >= 2 + and folded[0] == "release" + and folded[1] in {"create", "upload", "delete", "edit"} + ): + return ( + PluginHit( + rule_id="claude-plugin-github-release-command", + line=line, + snippet=f"gh release {folded[1]}", + message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + ), + ) + for source, first_line in _nested_shell_payload_sources( + content, manifest=manifest + ): + match = _executable_command_match(source, _GITHUB_RELEASE_COMMAND) + if match is not None: + verb = match.group("verb").lower() + return ( + PluginHit( + rule_id="claude-plugin-github-release-command", + line=first_line + source[: match.start()].count("\n"), + snippet=f"gh release {verb}", + message=CLAUDE_PLUGIN_GITHUB_RELEASE_COMMAND_MESSAGE, + ), + ) + return () def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: """Return findings for eval/exec/compile/Function on hook surfaces.""" From 4eb3c29dbac71045d23367e60fcf5a0eae15d601 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:38:24 +0900 Subject: [PATCH 4/4] test(scanner): retain GitHub command-context corpus --- ...test_claude_plugin_github_merge_release.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/test_claude_plugin_github_merge_release.py b/tests/test_claude_plugin_github_merge_release.py index 5f721281..a754a968 100644 --- a/tests/test_claude_plugin_github_merge_release.py +++ b/tests/test_claude_plugin_github_merge_release.py @@ -244,3 +244,80 @@ def test_github_write_token_without_merge_stays_the_pat_class(tmp_path: Path) -> assert any(hit.rule_id == _WRITE_TOKEN_RULE for hit in hits) assert all(hit.rule_id != _MERGE_RULE for hit in hits) assert all(hit.rule_id != _RELEASE_RULE for hit in hits) + +def _direct_rule_ids(content: str, *, manifest: bool = False) -> set[str]: + """Return GitHub-command rule identities for one in-memory surface.""" + filename = "plugin.json" if manifest else "deploy.sh" + path = ".claude-plugin/plugin.json" if manifest else "hooks/deploy.sh" + return { + hit.rule_id + for hit in inspect_claude_plugin_file(filename, path, content) + if hit.rule_id in _THIS_CLASS + } + + +@pytest.mark.parametrize( + ("payload", "expected_rule"), + ( + ({"command": "gh", "args": ["pr", "merge", "42"]}, _MERGE_RULE), + ( + {"command": "/usr/bin/gh", "args": ["release", "create", "v1"]}, + _RELEASE_RULE, + ), + ( + {"command": "gh.exe", "args": ["release", "upload", "v1", "a"]}, + _RELEASE_RULE, + ), + ), +) +def test_manifest_typed_argv_detects_github_writes( + payload: dict[str, object], expected_rule: str +) -> None: + """Typed argv preserves executable and argument identity.""" + assert expected_rule in _direct_rule_ids(json.dumps(payload), manifest=True) + + +@pytest.mark.parametrize( + ("command", "expected_rule"), + ( + ("sh -c 'gh pr merge 42'", _MERGE_RULE), + ("bash -lc 'gh release delete v1 --yes'", _RELEASE_RULE), + ), +) +def test_nested_shell_payload_detects_github_writes( + command: str, expected_rule: str +) -> None: + """A bounded shell -c payload remains executable command text.""" + assert expected_rule in _direct_rule_ids(command) + + +@pytest.mark.parametrize( + "content", + ( + json.dumps({"description": "gh pr merge is forbidden"}), + "echo 'gh release create v1'", + "sh -nc 'gh pr merge 42'", + "VALUE='gh release edit v1'", + ), +) +def test_inert_github_text_stays_negative(content: str) -> None: + """Descriptions, reporting, assignments, and noexec payloads are inert.""" + assert _direct_rule_ids(content, manifest=content.startswith("{")) == set() + + +@pytest.mark.parametrize( + "payload", + ( + {"command": "gh", "args": ["pr", "merge-now"]}, + {"command": " gh ", "args": ["pr", "merge"]}, + {"command": "gh", "args": ["release", "list"]}, + {"command": "gh", "args": ["release", "createLocal"]}, + {"command": "gh", "args": "pr merge 42"}, + {"command": "gh", "args": ["release", 1]}, + ), +) +def test_manifest_typed_argv_near_misses_stay_negative( + payload: dict[str, object] +) -> None: + """Malformed types and near verbs do not broaden GitHub write detection.""" + assert _direct_rule_ids(json.dumps(payload), manifest=True) == set()