Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ jobs:
--test tests/test_claude_plugin_dynamic_eval.py \
--test tests/test_claude_plugin_hidden_executable.py \
--test tests/test_claude_plugin_browser_profile.py \
--test tests/test_claude_plugin_deceptive_description.py
--test tests/test_claude_plugin_deceptive_description.py \
--test tests/test_claude_plugin_nonstandard_json.py
- name: Verify 100% statement coverage for Claude plugin scan CLI
if: matrix.python-version == '3.13'
run: |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@
`claude-plugin-deceptive-description`. An honest network mention, an
empty description, and a matching local echo helper are not that class.
Inventory remains evidence, not permission.
Manifest ``NaN``, ``Infinity``, and ``-Infinity`` fail as
`claude-plugin-nonstandard-json-constant`. Duplicate object members stay
`claude-plugin-duplicate-json-member`. A finite JSON number is not that
class.
34 changes: 31 additions & 3 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
executable or config surface, archive path
escape, unadmitted nested submodule, hardcoded GitHub write token, Docker
socket bind, host browser-profile store, secret copied into a network
request, a description that denies inventoried write, network, GitHub
request, a non-standard JSON constant, a description that denies inventoried
write, network, GitHub
write, credential, remote MCP, or shell capabilities, or a released
skill-supply-chain finding on a plugin skill/agent surface is a policy
finding. Capability inventory is evidence,
Expand Down Expand Up @@ -69,6 +70,11 @@
"keys conceal identity and must fail admission. "
"[CWE-20 - Improper Input Validation]"
)
CLAUDE_PLUGIN_NONSTANDARD_JSON_MESSAGE: Final = (
"Claude plugin manifest contains a non-standard JSON constant. NaN, "
"Infinity, and -Infinity are not JSON numbers and must fail admission. "
"[CWE-20 - Improper Input Validation]"
)
CLAUDE_PLUGIN_UNBOUNDED_MCP_MESSAGE: Final = (
"Claude plugin starts a remote or stdio MCP server without a bounded "
"schema and source or authentication identity. Inventory is not permission. "
Expand Down Expand Up @@ -1219,6 +1225,16 @@ def _inspect_manifest(content: str) -> tuple[PluginHit, ...]:
message=CLAUDE_PLUGIN_DUPLICATE_JSON_MESSAGE,
),
)
except _NonstandardJsonConstant as exc:
token = str(exc)
return (
PluginHit(
rule_id="claude-plugin-nonstandard-json-constant",
line=_line_of(content, token),
snippet=_sanitize_plugin_snippet(token)[:120],
message=CLAUDE_PLUGIN_NONSTANDARD_JSON_MESSAGE,
),
)
except json.JSONDecodeError:
return ()
for entry in _plugin_entries(payload):
Expand Down Expand Up @@ -1266,8 +1282,12 @@ class _DuplicateJsonMember(ValueError):
"""Raised when a JSON object repeats a member name."""


class _NonstandardJsonConstant(ValueError):
"""Raised when JSON contains NaN, Infinity, or -Infinity."""


def _load_manifest_json(content: str) -> object:
"""Parse JSON while rejecting duplicate object members."""
"""Parse JSON while rejecting duplicate members and non-standard constants."""

def object_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
"""Fail closed when a JSON object repeats a member name."""
Expand All @@ -1280,7 +1300,15 @@ def object_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
result[key] = value
return result

return json.loads(content, object_pairs_hook=object_pairs)
def parse_constant(name: str) -> object:
"""Fail closed on NaN, Infinity, and -Infinity."""
raise _NonstandardJsonConstant(name)

return json.loads(
content,
object_pairs_hook=object_pairs,
parse_constant=parse_constant,
)


def _mcp_is_bounded(server: dict) -> bool:
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-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`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent surfaces, deterministic scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, 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-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`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent surfaces, deterministic scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, 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
3 changes: 2 additions & 1 deletion docs/doctoring/cwl-security-issue-detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent 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 and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, 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, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent 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 and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, 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 |
Expand All @@ -35,6 +35,7 @@ workflow families remain owned by PRs #1088 and #966.

- `tests/test_claude_plugin_supply_chain.py`
- `tests/test_claude_plugin_deceptive_description.py`
- `tests/test_claude_plugin_nonstandard_json.py`
- `tests/test_password_indirection_precision.py`
- `tests/test_cwl_security_issue_inventory.py`
- `tests/fixtures/cwl-security-issue-inventory.json`
Expand Down
208 changes: 208 additions & 0 deletions tests/test_claude_plugin_nonstandard_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Plugin manifests must reject non-standard JSON constants."""

from __future__ import annotations

import json
from pathlib import Path

from appguardrail_core.claude_plugin_detector import (
build_claude_plugin_scan_receipt,
inspect_claude_plugin_file,
)


_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17"
_JSON_RULE = "claude-plugin-nonstandard-json-constant"
_DUP_RULE = "claude-plugin-duplicate-json-member"
_DECEPTIVE_RULE = "claude-plugin-deceptive-description"
_SECRET = "sk-example-must-not-leak"
_BIDI = "\u202e"


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) -> 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("#!/bin/sh\necho session\n", encoding="utf-8")
(root / "LICENSE").write_text("MIT\n", encoding="utf-8")
return root


def _manifest_with(constant: str) -> str:
"""Return a pinned plugin.json body containing one non-standard constant."""
return (
"{\n"
' "name": "safe-plugin",\n'
' "version": "1.0.0",\n'
f' "timeout": {constant},\n'
' "source": {\n'
' "source": "github",\n'
' "repo": "example/safe-plugin",\n'
f' "ref": "{_PINNED_COMMIT}"\n'
" },\n"
' "hooks": {"PreToolUse": [{"command": "hooks/session.sh"}]}\n'
"}\n"
)


def test_nan_timeout_in_plugin_json_fails_admission(tmp_path: Path) -> None:
"""``NaN`` is not a JSON number and must fail closed on plugin.json."""
root = _licensed_plugin(tmp_path)
body = _manifest_with("NaN")
(root / ".claude-plugin" / "plugin.json").write_text(body, encoding="utf-8")
hits = inspect_claude_plugin_file(
"plugin.json",
".claude-plugin/plugin.json",
body,
)
receipt = build_claude_plugin_scan_receipt(root)
assert any(hit.rule_id == _JSON_RULE for hit in hits)
assert receipt.scan_result == "fail"
assert _JSON_RULE in receipt.finding_summary


def test_infinity_and_negative_infinity_are_reported() -> None:
"""``Infinity`` and ``-Infinity`` are the same non-standard class."""
inf_hits = inspect_claude_plugin_file(
"plugin.json",
".claude-plugin/plugin.json",
_manifest_with("Infinity"),
)
neg_hits = inspect_claude_plugin_file(
"marketplace.json",
".claude-plugin/marketplace.json",
_manifest_with("-Infinity"),
)
assert any(hit.rule_id == _JSON_RULE for hit in inf_hits)
assert any(hit.rule_id == _JSON_RULE for hit in neg_hits)
assert all("NaN" not in hit.snippet or hit.snippet == "NaN" for hit in inf_hits)


def test_standard_json_number_is_not_this_finding(tmp_path: Path) -> None:
"""A finite JSON number is not a non-standard constant."""
root = _licensed_plugin(tmp_path)
body = _manifest_with("1.5")
(root / ".claude-plugin" / "plugin.json").write_text(body, encoding="utf-8")
hits = inspect_claude_plugin_file(
"plugin.json",
".claude-plugin/plugin.json",
body,
)
receipt = build_claude_plugin_scan_receipt(root)
assert all(hit.rule_id != _JSON_RULE for hit in hits)
assert _JSON_RULE not in receipt.finding_summary
assert receipt.scan_result == "pass"


def test_duplicate_json_member_stays_duplicate_class() -> None:
"""Repeated object members stay ``claude-plugin-duplicate-json-member``."""
body = (
"{\n"
' "name": "safe-plugin",\n'
' "name": "other",\n'
' "version": "1.0.0"\n'
"}\n"
)
hits = inspect_claude_plugin_file(
"plugin.json",
".claude-plugin/plugin.json",
body,
)
rule_ids = {hit.rule_id for hit in hits}
assert _DUP_RULE in rule_ids
assert _JSON_RULE not in rule_ids


def test_readme_nan_is_not_a_manifest_finding() -> None:
"""README text is not a Claude plugin JSON manifest."""
hits = inspect_claude_plugin_file(
"README.md",
"README.md",
"timeout: NaN\nInfinity is not a JSON number.\n",
)
assert all(hit.rule_id != _JSON_RULE for hit in hits)


def test_malformed_json_is_not_this_finding() -> None:
"""A truncated object is a parse failure, not a non-standard constant."""
hits = inspect_claude_plugin_file(
"plugin.json",
".claude-plugin/plugin.json",
'{"name": "safe-plugin",',
)
assert all(hit.rule_id != _JSON_RULE for hit in hits)


def test_deceptive_description_owner_is_unchanged(tmp_path: Path) -> None:
"""#1151 deceptive descriptions are not this JSON-constant class."""
root = _licensed_plugin(tmp_path)
_write_json(
root / ".claude-plugin" / "plugin.json",
{
"name": "safe-plugin",
"version": "1.0.0",
"description": "read-only local helper",
"source": {
"source": "github",
"repo": "example/safe-plugin",
"ref": _PINNED_COMMIT,
},
"hooks": {"PreToolUse": [{"command": "hooks/session.sh"}]},
},
)
(root / "hooks" / "session.sh").write_text(
"#!/bin/sh\ncurl https://example.com/health\n",
encoding="utf-8",
)
receipt = build_claude_plugin_scan_receipt(root)
assert _DECEPTIVE_RULE in receipt.finding_summary
assert _JSON_RULE not in receipt.finding_summary


def test_nonstandard_json_snippets_omit_secrets_and_bidi(tmp_path: Path) -> None:
"""Non-standard-constant snippets omit secret literals and raw bidi."""
root = _licensed_plugin(tmp_path)
body = (
"{\n"
f' "name": "safe-plugin{_BIDI}",\n'
f' "token": "{_SECRET}",\n'
' "timeout": NaN\n'
"}\n"
)
(root / ".claude-plugin" / "plugin.json").write_text(body, encoding="utf-8")
hits = [
hit
for hit in inspect_claude_plugin_file(
"plugin.json",
".claude-plugin/plugin.json",
body,
)
if hit.rule_id == _JSON_RULE
]
receipt = build_claude_plugin_scan_receipt(root)
serialized = json.dumps(receipt.as_dict())
assert hits
assert all(_SECRET not in hit.snippet for hit in hits)
assert all(_BIDI not in hit.snippet for hit in hits)
assert _SECRET not in serialized
assert _BIDI not in serialized
assert all(hit.snippet in {"NaN", "Infinity", "-Infinity"} for hit in hits)