Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
0e1f365
test(scanner): fail closed on plugin terraform apply and helm install
seonghobae Sep 8, 2026
e465cd7
feat(scanner): fail closed on plugin terraform apply and helm install
seonghobae Sep 8, 2026
33aae04
test(scanner): reproduce terraform and helm command-context false pos…
seonghobae Sep 8, 2026
1f901a3
fix(scanner): parse executable terraform and helm commands
seonghobae Sep 8, 2026
7051748
fix(scanner): restore executable command parser syntax
seonghobae Sep 8, 2026
41669d6
fix(scanner): define command-context parser tokens
seonghobae Sep 8, 2026
d7384e5
test(scanner): reject quoted deployment command prose
seonghobae Sep 8, 2026
cb208db
fix(scanner): distinguish quoted prose from shell commands
seonghobae Sep 8, 2026
3c5fdc4
fix(scanner): remove invalid backtick escapes
seonghobae Sep 8, 2026
3a5b147
test(scanner): use literal legacy substitution syntax
seonghobae Sep 8, 2026
90ae232
test(scanner): expose assignment-value command false positive
seonghobae Sep 8, 2026
1e6a3eb
fix(scanner): ignore command names inside shell assignments
seonghobae Sep 8, 2026
6f6be77
test(scanner): reproduce heredoc command prose false positive
seonghobae Sep 8, 2026
12faf2f
fix(scanner): ignore closed literal heredoc payloads
seonghobae Sep 8, 2026
fa9df24
test(scanner): preserve commands after heredoc lookalikes
seonghobae Sep 8, 2026
184b4b4
docs(scanner): trace heredoc command boundary
seonghobae Sep 8, 2026
5bdcde7
test(scanner): reproduce no-op argument command false positives
seonghobae Sep 8, 2026
873370b
fix(scanner): ignore no-op command argument prose
seonghobae Sep 8, 2026
82a0cc8
test(scanner): reproduce manifest argv write bypass
seonghobae Sep 8, 2026
969842d
fix(scanner): preserve structured manifest argv
seonghobae Sep 8, 2026
c76b1bd
test(scanner): bound manifest argv to direct executable
seonghobae Sep 8, 2026
18ca616
fix(scanner): distinguish direct manifest argv
seonghobae Sep 8, 2026
52a6911
test(scanner): preserve argv identity and terraform options
seonghobae Sep 8, 2026
8d7ec9d
fix(scanner): bound direct argv identity and options
seonghobae Sep 8, 2026
9256810
test(scanner): reproduce nested shell c bypass
seonghobae Sep 8, 2026
feeac82
fix(scanner): inspect bounded shell c payloads
seonghobae Sep 8, 2026
4ff4ff3
fix(scanner): require deployment verb token boundary
seonghobae Sep 8, 2026
9cc0e8f
test(scanner): reproduce split shell option boundaries
seonghobae Sep 8, 2026
15ced2b
fix(scanner): parse bounded shell option sequence
seonghobae Sep 8, 2026
40d862d
test(scanner): cover shell option execution state
seonghobae Sep 8, 2026
a9b72de
fix(scanner): model executable shell option states
seonghobae Sep 8, 2026
89115aa
test(scanner): cover repeated and interactive shell options
seonghobae Sep 8, 2026
37bdc4d
fix(scanner): admit executable shell option forms
seonghobae Sep 8, 2026
71c6187
test(scanner): cover Bash execution-preserving options
seonghobae Sep 8, 2026
8d35ff9
fix(scanner): cover Bash execution-preserving options
seonghobae Sep 8, 2026
6667472
fix(scanner): reuse command parser for kubectl and Docker
seonghobae Sep 8, 2026
0093507
test(scanner): retain kubectl and Docker parser regressions
seonghobae Sep 8, 2026
825bf7e
chore(stack): integrate deployment parser prerequisite
seonghobae Sep 8, 2026
2a55098
fix(scanner): inherit GitHub command parser
seonghobae Sep 8, 2026
62d6bfe
test(scanner): retain GitHub command-context corpus
seonghobae Sep 8, 2026
5e40f91
chore(stack): integrate deployment-write predecessor
seonghobae Sep 8, 2026
6ec09ee
chore(stack): integrate current deployment-write predecessor
seonghobae Sep 12, 2026
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
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ jobs:
--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_credential_store.py
--test tests/test_claude_plugin_credential_store.py \
--test tests/test_claude_plugin_deployment_write.py \
--test tests/test_claude_plugin_terraform_helm.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@
``docker push`` and ``docker image push`` fail as
`claude-plugin-docker-push-command`. ``gh issue create``,
``gh pr review``, ``gh release list``, ``kubectl get``, ``docker ps``,
``terraform apply``, and ``helm install`` stay inventory. Hardcoded
``terraform apply`` fails as `claude-plugin-terraform-apply-command`.
``helm install`` fails as `claude-plugin-helm-install-command`.
``terraform plan``, ``helm list``, ``vercel deploy``, and ``fly deploy``
stay inventory. Hardcoded
PATs stay `claude-plugin-github-write-token`. Snippets are command
labels, not tokens.
Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``,
Expand Down
208 changes: 160 additions & 48 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@
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 ``kubectl apply`` and ``docker push`` fail closed as
deployment-write command findings. Hook or manifest paths into
deployment-write command findings. Hook or manifest ``terraform apply``
and ``helm install`` fail closed as infra-write command findings.
``terraform plan``, ``helm list``, ``vercel deploy``, and ``fly deploy``
stay inventory. 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 get``, ``docker ps``,
``terraform apply``, and ``helm install`` stay inventory. Skill
``terraform plan``, and ``helm list`` stay inventory. Skill
homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule
identities. Skill, command, or agent text that hides tool use, rewrites
the system prompt, or escalates the declared goal is a separate
Expand Down Expand Up @@ -234,6 +237,16 @@
"write authority on a registry. Remove the command. "
"[CWE-250 - Execution with Unnecessary Privileges]"
)
CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE: Final = (
"Claude plugin hook or manifest runs terraform apply. Applying "
"infrastructure is write authority. Remove the command. "
"[CWE-269 - Improper Privilege Management]"
)
CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE: Final = (
"Claude plugin hook or manifest runs helm install. Installing a chart "
"is write authority on a cluster. 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 @@ -360,6 +373,12 @@
r"\bdocker(?:\s+image)?\s+push(?=$|[\s;&|()<>])",
re.IGNORECASE,
)
_TERRAFORM_APPLY_COMMAND = re.compile(
r"\bterraform\s+apply(?=$|[\s;&|()<>])", re.IGNORECASE
)
_HELM_INSTALL_COMMAND = re.compile(
r"\bhelm\s+install(?=$|[\s;&|()<>])", re.IGNORECASE
)
_REPORTING_BUILTINS: Final = frozenset(
{":", "echo", "false", "print", "printf", "true"}
)
Expand Down Expand Up @@ -876,6 +895,8 @@ def inspect_claude_plugin_file(
hits.extend(_github_release_command_hits(content, manifest=manifest))
hits.extend(_kubectl_apply_command_hits(content, manifest=manifest))
hits.extend(_docker_push_command_hits(content, manifest=manifest))
hits.extend(_terraform_apply_command_hits(content, manifest=manifest))
hits.extend(_helm_install_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 @@ -1599,6 +1620,93 @@ def _github_release_command_hits(
)
return ()

def _kubectl_apply_command_hits(
content: str, *, manifest: bool = False
) -> tuple[PluginHit, ...]:
"""Return executable kubectl apply findings, including typed argv."""
for source, first_line in _hosted_command_sources(content, manifest=manifest):
match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND)
if match is not None:
return (
PluginHit(
rule_id="claude-plugin-kubectl-apply-command",
line=first_line + source[: match.start()].count("\n"),
snippet="kubectl apply",
message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE,
),
)
if manifest:
line = _manifest_argv_command_line(
content, executable="kubectl", verb="apply"
)
if line is not None:
return (
PluginHit(
rule_id="claude-plugin-kubectl-apply-command",
line=line,
snippet="kubectl apply",
message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE,
),
)
for source, first_line in _nested_shell_payload_sources(
content, manifest=manifest
):
match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND)
if match is not None:
return (
PluginHit(
rule_id="claude-plugin-kubectl-apply-command",
line=first_line + source[: match.start()].count("\n"),
snippet="kubectl apply",
message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE,
),
)
return ()

def _docker_push_command_hits(
content: str, *, manifest: bool = False
) -> tuple[PluginHit, ...]:
"""Return executable Docker push findings, including typed argv."""
for source, first_line in _hosted_command_sources(content, manifest=manifest):
match = _executable_command_match(source, _DOCKER_PUSH_COMMAND)
if match is not None:
return (
PluginHit(
rule_id="claude-plugin-docker-push-command",
line=first_line + source[: match.start()].count("\n"),
snippet="docker push",
message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE,
),
)
if manifest:
for command, args, line in _manifest_argv_sources(content):
if _direct_executable_basename(command) != "docker":
continue
folded = tuple(argument.casefold() for argument in args)
if folded[:1] == ("push",) or folded[:2] == ("image", "push"):
return (
PluginHit(
rule_id="claude-plugin-docker-push-command",
line=line,
snippet="docker push",
message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE,
),
)
for source, first_line in _nested_shell_payload_sources(
content, manifest=manifest
):
match = _executable_command_match(source, _DOCKER_PUSH_COMMAND)
if match is not None:
return (
PluginHit(
rule_id="claude-plugin-docker-push-command",
line=first_line + source[: match.start()].count("\n"),
snippet="docker push",
message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE,
),
)
return ()

def _unquoted_hash_index(line: str) -> int | None:
"""Return the index of an unquoted ``#`` shell comment, if any.

Expand Down Expand Up @@ -2085,93 +2193,97 @@ def _nested_shell_payload_sources(
return tuple(found)


def _kubectl_apply_command_hits(
def _terraform_apply_command_hits(
content: str, *, manifest: bool = False
) -> tuple[PluginHit, ...]:
"""Return executable kubectl apply findings, including typed argv."""
"""Return executable terraform apply findings without vars."""
for source, first_line in _hosted_command_sources(content, manifest=manifest):
match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND)
if match is not None:
return (
PluginHit(
rule_id="claude-plugin-kubectl-apply-command",
line=first_line + source[: match.start()].count("\n"),
snippet="kubectl apply",
message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE,
),
)
match = _executable_command_match(source, _TERRAFORM_APPLY_COMMAND)
if match is None:
continue
return (
PluginHit(
rule_id="claude-plugin-terraform-apply-command",
line=first_line + source[: match.start()].count("\n"),
snippet="terraform apply",
message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE,
),
)
if manifest:
line = _manifest_argv_command_line(
content, executable="kubectl", verb="apply"
content,
executable="terraform",
verb="apply",
leading_value_option="-chdir=",
)
if line is not None:
return (
PluginHit(
rule_id="claude-plugin-kubectl-apply-command",
rule_id="claude-plugin-terraform-apply-command",
line=line,
snippet="kubectl apply",
message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE,
snippet="terraform apply",
message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE,
),
)
for source, first_line in _nested_shell_payload_sources(
content, manifest=manifest
):
match = _executable_command_match(source, _KUBECTL_APPLY_COMMAND)
match = _executable_command_match(source, _TERRAFORM_APPLY_COMMAND)
if match is not None:
return (
PluginHit(
rule_id="claude-plugin-kubectl-apply-command",
rule_id="claude-plugin-terraform-apply-command",
line=first_line + source[: match.start()].count("\n"),
snippet="kubectl apply",
message=CLAUDE_PLUGIN_KUBECTL_APPLY_COMMAND_MESSAGE,
snippet="terraform apply",
message=CLAUDE_PLUGIN_TERRAFORM_APPLY_COMMAND_MESSAGE,
),
)
return ()

def _docker_push_command_hits(

def _helm_install_command_hits(
content: str, *, manifest: bool = False
) -> tuple[PluginHit, ...]:
"""Return executable Docker push findings, including typed argv."""
"""Return executable helm install findings without chart names."""
for source, first_line in _hosted_command_sources(content, manifest=manifest):
match = _executable_command_match(source, _DOCKER_PUSH_COMMAND)
if match is not None:
match = _executable_command_match(source, _HELM_INSTALL_COMMAND)
if match is None:
continue
return (
PluginHit(
rule_id="claude-plugin-helm-install-command",
line=first_line + source[: match.start()].count("\n"),
snippet="helm install",
message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE,
),
)
if manifest:
line = _manifest_argv_command_line(content, executable="helm", verb="install")
if line is not None:
return (
PluginHit(
rule_id="claude-plugin-docker-push-command",
line=first_line + source[: match.start()].count("\n"),
snippet="docker push",
message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE,
rule_id="claude-plugin-helm-install-command",
line=line,
snippet="helm install",
message=CLAUDE_PLUGIN_HELM_INSTALL_COMMAND_MESSAGE,
),
)
if manifest:
for command, args, line in _manifest_argv_sources(content):
if _direct_executable_basename(command) != "docker":
continue
folded = tuple(argument.casefold() for argument in args)
if folded[:1] == ("push",) or folded[:2] == ("image", "push"):
return (
PluginHit(
rule_id="claude-plugin-docker-push-command",
line=line,
snippet="docker push",
message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE,
),
)
for source, first_line in _nested_shell_payload_sources(
content, manifest=manifest
):
match = _executable_command_match(source, _DOCKER_PUSH_COMMAND)
match = _executable_command_match(source, _HELM_INSTALL_COMMAND)
if match is not None:
return (
PluginHit(
rule_id="claude-plugin-docker-push-command",
rule_id="claude-plugin-helm-install-command",
line=first_line + source[: match.start()].count("\n"),
snippet="docker push",
message=CLAUDE_PLUGIN_DOCKER_PUSH_COMMAND_MESSAGE,
snippet="helm install",
message=CLAUDE_PLUGIN_HELM_INSTALL_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
Loading