From 705a7e0e88dad2cca7ff463d9d4739bdc889c090 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 00:59:55 +0900 Subject: [PATCH 1/2] test(scanner): reject non-standard plugin JSON constants RED contract for NaN, Infinity, and -Infinity in plugin manifests. Duplicate members stay their own class. Relates to #1099. --- tests/test_claude_plugin_nonstandard_json.py | 208 +++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/test_claude_plugin_nonstandard_json.py diff --git a/tests/test_claude_plugin_nonstandard_json.py b/tests/test_claude_plugin_nonstandard_json.py new file mode 100644 index 00000000..21fc6491 --- /dev/null +++ b/tests/test_claude_plugin_nonstandard_json.py @@ -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) From 3f709b0caf1252393f73dfef3ba95e732ab13360 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 01:01:23 +0900 Subject: [PATCH 2/2] feat(scanner): reject non-standard plugin JSON constants Fail closed on NaN, Infinity, and -Infinity in plugin manifests. Duplicate members stay their own class. Relates to #1099. --- .github/workflows/tests.yml | 3 +- .../1099-claude-plugin-supply-chain.md | 4 +++ appguardrail_core/claude_plugin_detector.py | 34 +++++++++++++++++-- docs/TRACEABILITY.md | 2 +- .../doctoring/cwl-security-issue-detectors.md | 3 +- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1231ad01..9a409d34 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: | diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 389d1127..2c6ca630 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -56,3 +56,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. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 69c1501c..70c6e94c 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -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, @@ -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. " @@ -1213,6 +1219,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): @@ -1260,8 +1276,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.""" @@ -1274,7 +1294,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: diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index df061e2c..4d9c2ac0 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-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` | diff --git a/docs/doctoring/cwl-security-issue-detectors.md b/docs/doctoring/cwl-security-issue-detectors.md index fef955dc..1eb15bd4 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, 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 | @@ -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`