diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 81917a83..88816141 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -153,6 +153,9 @@ `claude-plugin-aws-deploy-command`. ``gcloud run|app|functions deploy`` fails as `claude-plugin-gcloud-deploy-command`. ``az webapp deploy`` fails as `claude-plugin-az-deploy-command`. + ``aws s3 sync`` and ``aws s3 cp`` fail as + `claude-plugin-aws-s3-write-command`. ``az containerapp up`` fails as + `claude-plugin-az-containerapp-up-command`. Hook comments and ``echo``/``printf`` lookalikes are not those classes. ``terraform plan``, ``helm list``, diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index d915d2c0..e9725011 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -28,7 +28,9 @@ hosted-deploy command findings. Hook or manifest ``aws cloudformation deploy``, ``aws deploy create-deployment``, ``gcloud run|app|functions deploy``, and ``az webapp deploy`` fail closed as cloud-deploy command -findings. Unquoted ``#`` comments and +findings. Hook or manifest ``aws s3 sync``, ``aws s3 cp``, and +``az containerapp up`` fail closed as object-store and Container Apps +writes. Unquoted ``#`` comments and ``echo``/``printf``/``print`` lookalikes are not that class. ``terraform plan``, ``helm list``, ``vercel ls``, ``fly status``, ``aws s3 ls``, ``gcloud config list``, and ``az account show`` @@ -298,6 +300,16 @@ "Azure web app is write authority. Remove the command. " "[CWE-269 - Improper Privilege Management]" ) +CLAUDE_PLUGIN_AWS_S3_WRITE_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs aws s3 sync or aws s3 cp. " + "Copying objects into a bucket is write authority. Remove the " + "command. [CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_AZ_CONTAINERAPP_UP_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs az containerapp up. Publishing " + "a Container Apps revision is write authority. Remove the command. " + "[CWE-250 - Execution with Unnecessary Privileges]" +) CLAUDE_PLUGIN_DOCKER_SOCKET_MESSAGE: Final = ( "Claude plugin hook reaches the host Docker socket. Socket access is host " "control, not an image push. Remove the socket bind and keep builds " @@ -442,6 +454,11 @@ re.IGNORECASE, ) _AZ_DEPLOY_COMMAND = re.compile(r"\baz\s+webapp\s+deploy\b", re.IGNORECASE) +_AWS_S3_WRITE_COMMAND = re.compile(r"\baws\s+s3\s+(?Psync|cp)\b", re.IGNORECASE) +_AZ_CONTAINERAPP_UP_COMMAND = re.compile( + r"\baz\s+containerapp\s+up\b", + re.IGNORECASE, +) _REPORTING_BUILTINS: Final = frozenset( {":", "echo", "false", "print", "printf", "true"} ) @@ -707,8 +724,9 @@ re.compile( r"\b(?:kubectl\s+apply|terraform\s+apply|helm\s+install|" r"vercel\s+deploy|fly(?:ctl)?\s+deploy|docker\s+push|" - r"aws\s+(?:cloudformation\s+deploy|deploy\s+create-deployment)|" - r"gcloud\s+(?:run|app|functions)\s+deploy|az\s+webapp\s+deploy)\b", + r"aws\s+(?:cloudformation\s+deploy|deploy\s+create-deployment|s3\s+(?:sync|cp))|" + r"gcloud\s+(?:run|app|functions)\s+deploy|az\s+webapp\s+deploy|" + r"az\s+containerapp\s+up)\b", re.IGNORECASE, ), ), @@ -967,6 +985,8 @@ def inspect_claude_plugin_file( hits.extend(_aws_deploy_command_hits(content, manifest=manifest)) hits.extend(_gcloud_deploy_command_hits(content, manifest=manifest)) hits.extend(_az_deploy_command_hits(content, manifest=manifest)) + hits.extend(_aws_s3_write_command_hits(content, manifest=manifest)) + hits.extend(_az_containerapp_up_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -2514,6 +2534,88 @@ def _az_deploy_command_hits( return () +def _is_literal_s3_download( + content: str, match: re.Match[str] +) -> bool: + """Return whether one direct S3 command provably reads to a local path.""" + 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 + command_end = match.end() - line_start + for start, end in _iter_unquoted_segment_bounds(line): + if start <= relative < end: + operands = line[command_end:end].split() + return ( + len(operands) == 2 + and operands[0].lower().startswith("s3://") + and not operands[1].lower().startswith("s3://") + and not operands[1].startswith("-") + and all( + re.fullmatch(r"[A-Za-z0-9._~:/+-]+", operand) + for operand in operands + ) + ) + return False + + +def _aws_s3_write_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return S3-destination write findings without copying operand URIs.""" + for source, first_line in _hosted_command_sources(content, manifest=manifest): + search_from = 0 + while search_from < len(source): + fragment = source[search_from:] + match = _executable_command_match(fragment, _AWS_S3_WRITE_COMMAND) + if match is None: + break + absolute_start = search_from + match.start() + if not _is_literal_s3_download(fragment, match): + verb = match.group("verb").lower() + return ( + PluginHit( + rule_id="claude-plugin-aws-s3-write-command", + line=first_line + source[:absolute_start].count("\n"), + snippet="aws s3 " + verb, + message=CLAUDE_PLUGIN_AWS_S3_WRITE_COMMAND_MESSAGE, + ), + ) + search_from += match.end() + return () + + +def _az_containerapp_up_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``az containerapp up`` findings with a command label, not names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit when executable ``az containerapp up`` is present. + ``az account show``, comments, and echo lookalikes are not this + class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _AZ_CONTAINERAPP_UP_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-az-containerapp-up-command", + line=first_line + source[: match.start()].count("\n"), + snippet="az containerapp up", + message=CLAUDE_PLUGIN_AZ_CONTAINERAPP_UP_COMMAND_MESSAGE, + ), + ) + return () + + def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: """Return findings for eval/exec/compile/Function on hook surfaces.""" match = _DYNAMIC_EVAL.search(content) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 5b14a3d6..bfc1e5a0 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-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-excessive-path-depth` when a materialized file or archive member nests past 32 path components, `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-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, 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-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-excessive-path-depth` when a materialized file or archive member nests past 32 path components, `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-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-aws-s3-write-command` for hook or manifest `aws s3 sync`/`cp`, `claude-plugin-az-containerapp-up-command` for `az containerapp up`, `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/sast-dast-rule-research.md b/docs/sast-dast-rule-research.md index f5f0c68c..9c3f952f 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -109,7 +109,10 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-fly-deploy-command` for ``fly deploy``, `claude-plugin-aws-deploy-command` for ``aws cloudformation deploy``, `claude-plugin-gcloud-deploy-command` for ``gcloud run deploy``, - `claude-plugin-az-deploy-command` for ``az webapp deploy``, and + `claude-plugin-az-deploy-command` for ``az webapp deploy``, + `claude-plugin-aws-s3-write-command` for ``aws s3 sync``/``cp``, + `claude-plugin-az-containerapp-up-command` for ``az containerapp up``, + 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 diff --git a/tests/test_claude_plugin_cloud_deploy.py b/tests/test_claude_plugin_cloud_deploy.py index 608772b7..127f84b8 100644 --- a/tests/test_claude_plugin_cloud_deploy.py +++ b/tests/test_claude_plugin_cloud_deploy.py @@ -196,6 +196,7 @@ def test_plugin_manifest_az_webapp_deploy_fails_admission(tmp_path: Path) -> Non (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") ) manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], "PostToolUse": [{"command": "az webapp deploy --name app"}], } _write_json(root / ".claude-plugin" / "plugin.json", manifest) @@ -225,6 +226,7 @@ def test_manifest_cloud_prose_and_reporting_commands_are_not_this_class( manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["description"] = "operators may later run gcloud run deploy" manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], "PostToolUse": [ {"command": "hooks/session.sh"}, {"command": 'echo "aws cloudformation deploy"'}, @@ -247,6 +249,7 @@ def test_manifest_reporting_command_does_not_hide_later_cloud_deploy( manifest_path = root / ".claude-plugin" / "plugin.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], "PostToolUse": [ {"command": 'echo "gcloud run deploy"'}, {"command": "aws cloudformation deploy --stack-name app"}, diff --git a/tests/test_claude_plugin_hosted_deploy.py b/tests/test_claude_plugin_hosted_deploy.py index be1165e4..616db2ae 100644 --- a/tests/test_claude_plugin_hosted_deploy.py +++ b/tests/test_claude_plugin_hosted_deploy.py @@ -251,6 +251,7 @@ def test_plugin_manifest_fly_deploy_fails_admission(tmp_path: Path) -> None: (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") ) manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], "PostToolUse": [{"command": "fly deploy --now"}], } _write_json(root / ".claude-plugin" / "plugin.json", manifest) @@ -345,6 +346,7 @@ def test_manifest_reporting_commands_and_description_are_not_this_class( manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["description"] = "operators may later run vercel deploy" manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], "PostToolUse": [ {"command": "hooks/session.sh"}, {"command": 'echo "fly deploy"'}, @@ -369,6 +371,7 @@ def test_manifest_reporting_command_does_not_hide_later_deploy( manifest_path = root / ".claude-plugin" / "plugin.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], "PostToolUse": [ {"command": 'echo "fly deploy"'}, {"command": "vercel deploy --prod"}, diff --git a/tests/test_claude_plugin_object_store.py b/tests/test_claude_plugin_object_store.py new file mode 100644 index 00000000..6c9e2bae --- /dev/null +++ b/tests/test_claude_plugin_object_store.py @@ -0,0 +1,233 @@ +"""Hook aws s3 writes and az containerapp up fail closed; reads stay inventory.""" + +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" +_S3_RULE = "claude-plugin-aws-s3-write-command" +_CONTAINERAPP_RULE = "claude-plugin-az-containerapp-up-command" +_AWS_DEPLOY_RULE = "claude-plugin-aws-deploy-command" +_AZ_DEPLOY_RULE = "claude-plugin-az-deploy-command" +_SECRET = "sk-object-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_S3_RULE, _CONTAINERAPP_RULE}) + + +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_aws_s3_sync_fails_admission(tmp_path: Path) -> None: + """``aws s3 sync`` to S3 is object-store write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\naws s3 sync ./dist s3://bucket/app\n") + hits = _hits(root, _S3_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "aws s3 sync" for hit in hits) + assert receipt.scan_result == "fail" + assert _S3_RULE in receipt.finding_summary + assert _CONTAINERAPP_RULE not in receipt.finding_summary + assert _AWS_DEPLOY_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_hook_aws_s3_cp_fails_admission() -> None: + """``aws s3 cp`` to S3 is object-store write authority.""" + body = "#!/bin/sh\naws s3 cp artifact.tgz s3://bucket/app.tgz\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _S3_RULE and hit.snippet == "aws s3 cp" for hit in hits) + + +def test_hook_aws_s3_cp_download_stays_inventory() -> None: + """A provable S3-to-local copy is read authority, not this write class.""" + body = "#!/bin/sh\naws s3 cp s3://bucket/app.tgz ./app.tgz\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert not any(hit.rule_id == _S3_RULE for hit in hits) + + +def test_hook_aws_s3_sync_download_stays_inventory() -> None: + """A provable S3-to-local sync is read authority, not this write class.""" + body = "#!/bin/sh\naws s3 sync s3://bucket/app ./app\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert not any(hit.rule_id == _S3_RULE for hit in hits) + + +def test_hook_aws_s3_to_s3_copy_still_fails_admission() -> None: + """S3-to-S3 copy still writes the destination bucket.""" + body = "#!/bin/sh\naws s3 cp s3://source/app.tgz s3://target/app.tgz\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _S3_RULE for hit in hits) + + +def test_hook_az_containerapp_up_fails_admission(tmp_path: Path) -> None: + """``az containerapp up`` on a hook is Azure write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\naz containerapp up --name app\n") + hits = _hits(root, _CONTAINERAPP_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "az containerapp up" for hit in hits) + assert receipt.scan_result == "fail" + assert _CONTAINERAPP_RULE in receipt.finding_summary + assert _AZ_DEPLOY_RULE not in receipt.finding_summary + + +def test_aws_s3_ls_stays_inventory(tmp_path: Path) -> None: + """``aws s3 ls`` stays inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\naws s3 ls\n") + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _S3_RULE) == [] + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_comment_and_echo_object_store_are_not_this_class(tmp_path: Path) -> None: + """Unquoted comments and echo lookalikes are not executable s3 writes.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\n# aws s3 sync ./dist s3://bucket\necho "az containerapp up"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_readme_s3_sync_is_not_this_class(tmp_path: Path) -> None: + """README object-store wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("aws s3 sync ./dist s3://bucket/app\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _S3_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["deployment_write"] is True + + +def test_echo_then_real_s3_sync_still_fails() -> None: + """``echo done && aws s3 sync`` still runs the object-store write.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "done" && aws s3 sync ./dist s3://bucket\n', + ) + assert any(hit.rule_id == _S3_RULE and hit.snippet == "aws s3 sync" for hit in hits) + + +def test_snippets_are_command_labels_not_secrets(tmp_path: Path) -> None: + """Snippets name the CLI command and omit secrets and bidi.""" + body = f"#!/bin/sh\naws s3 cp secret.bin s3://bucket/{_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + s3_hits = [hit for hit in hits if hit.rule_id == _S3_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert s3_hits + for hit in s3_hits: + assert hit.snippet == "aws s3 cp" + 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 + + +def test_plugin_manifest_containerapp_up_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that runs containerapp up is that class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], + "PostToolUse": [{"command": "az containerapp up --name app"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _CONTAINERAPP_RULE) + assert receipt.scan_result == "fail" + assert _CONTAINERAPP_RULE in receipt.finding_summary + + +def test_manifest_prose_and_reporting_are_not_this_class(tmp_path: Path) -> None: + """Manifest prose and reporting-only values are not object-store writes.""" + root = _licensed_plugin(tmp_path) + manifest_path = root / ".claude-plugin" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["description"] = "operators may later run aws s3 sync" + manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], + "PostToolUse": [{"command": 'echo "az containerapp up"'}], + } + _write_json(manifest_path, manifest) + receipt = build_claude_plugin_scan_receipt(root) + + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_cloudformation_deploy_stays_the_aws_deploy_class() -> None: + """CloudFormation deploy without s3/containerapp stays the aws-deploy class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\naws cloudformation deploy --stack-name app\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _AWS_DEPLOY_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) + + +def test_download_before_later_upload_still_fails_admission() -> None: + """A safe first command cannot hide a later S3 destination write.""" + body = ( + "#!/bin/sh\n" + "aws s3 cp s3://bucket/input.tgz ./input.tgz && " + "aws s3 cp ./output.tgz s3://bucket/output.tgz\n" + ) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _S3_RULE for hit in hits) +