Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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``,
Expand Down
108 changes: 105 additions & 3 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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+(?P<verb>sync|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"}
)
Expand Down Expand Up @@ -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,
),
),
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
| structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution |
| GitHub Actions transport-only polling loop (#1087, #938 vertical slice) | owned by PR #1088 / issue #1087; YAML rules and RED precision contracts | mapped-family only; this successor does not ship or close the detector |
| Password/database-url/auth-comment precision and test-file context (#1106) | existing `_scan_file` rules `hardcoded-password`, `hardcoded-database-url`, `todo-skip-auth`, `_finding_context` | implemented-branch regression lock |
| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-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` |

Expand Down
5 changes: 4 additions & 1 deletion docs/sast-dast-rule-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tests/test_claude_plugin_cloud_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"'},
Expand All @@ -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"},
Expand Down
3 changes: 3 additions & 0 deletions tests/test_claude_plugin_hosted_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"'},
Expand All @@ -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"},
Expand Down
Loading