From 13b97b41ffa2824106f5c157b4d742549e88cf71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 02:00:09 +0900 Subject: [PATCH 1/2] test(scanner): reject non-NFC plugin identity names RED contract for combining-mark plugin and marketplace names. NFC Hangul and ASCII stay admitted. Relates to #1099. --- tests/test_claude_plugin_normalized_name.py | 204 ++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/test_claude_plugin_normalized_name.py diff --git a/tests/test_claude_plugin_normalized_name.py b/tests/test_claude_plugin_normalized_name.py new file mode 100644 index 00000000..81aaa645 --- /dev/null +++ b/tests/test_claude_plugin_normalized_name.py @@ -0,0 +1,204 @@ +"""Plugin identity names must match their NFC form.""" + +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" +_NFC_RULE = "claude-plugin-inconsistent-normalized-name" +_UTF8_RULE = "claude-plugin-malformed-utf8" +_JSON_RULE = "claude-plugin-nonstandard-json-constant" +_CONCEAL_RULE = "claude-plugin-concealed-identity" +_NFC_NAME = "caf\u00e9" +_NFD_NAME = "cafe\u0301" +_SECRET = "sk-example-must-not-leak" +_BIDI = "\u202e" + + +def _write_json(path: Path, payload: dict) -> None: + """Write one JSON document under ``path`` using NFC-preserving UTF-8.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _licensed_plugin(root: Path, *, name: str = "safe-plugin") -> Path: + """Write a pinned licensed plugin with one declared shell hook.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": name, + "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 test_nfd_plugin_name_fails_admission(tmp_path: Path) -> None: + """A combining-mark plugin name is not NFC and must fail closed.""" + root = _licensed_plugin(tmp_path, name=_NFD_NAME) + body = (root / ".claude-plugin" / "plugin.json").read_text(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 == _NFC_RULE for hit in hits) + assert receipt.scan_result == "fail" + assert _NFC_RULE in receipt.finding_summary + + +def test_nfc_plugin_name_is_not_this_finding(tmp_path: Path) -> None: + """A precomposed accented name is already NFC.""" + root = _licensed_plugin(tmp_path, name=_NFC_NAME) + body = (root / ".claude-plugin" / "plugin.json").read_text(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 != _NFC_RULE for hit in hits) + assert _NFC_RULE not in receipt.finding_summary + assert receipt.scan_result == "pass" + + +def test_hangul_composed_name_is_not_this_finding(tmp_path: Path) -> None: + """Korean Hangul syllables are NFC and stay admitted.""" + root = _licensed_plugin(tmp_path, name="가드") + receipt = build_claude_plugin_scan_receipt(root) + assert _NFC_RULE not in receipt.finding_summary + assert receipt.scan_result == "pass" + + +def test_marketplace_nfd_plugin_entry_is_reported() -> None: + """Marketplace plugin entries use the same NFC identity contract.""" + body = json.dumps( + { + "plugins": [ + { + "name": _NFD_NAME, + "source": {"ref": _PINNED_COMMIT}, + } + ] + }, + ensure_ascii=False, + ) + hits = inspect_claude_plugin_file( + "marketplace.json", + ".claude-plugin/marketplace.json", + body, + ) + assert any(hit.rule_id == _NFC_RULE for hit in hits) + + +def test_ascii_name_is_not_this_finding() -> None: + """ASCII plugin names are already NFC.""" + hits = inspect_claude_plugin_file( + "plugin.json", + ".claude-plugin/plugin.json", + json.dumps({"name": "safe-plugin", "version": "1.0.0"}), + ) + assert all(hit.rule_id != _NFC_RULE for hit in hits) + + +def test_malformed_utf8_owner_is_unchanged() -> None: + """#1154 invalid UTF-8 stays that class, not this NFC class.""" + hits = inspect_claude_plugin_file( + "plugin.json", + ".claude-plugin/plugin.json", + "{\n \"name\": \"\udcff\"\n}\n", + ) + # The inspect path takes str; truncated UTF-8 is a bytes-only #1154 case. + # A replacement-character name is NFC and is not this finding. + assert all(hit.rule_id != _NFC_RULE for hit in hits) + + +def test_infinity_stays_nonstandard_json_class() -> None: + """#1153 Infinity stays the JSON-constant class.""" + body = ( + "{\n" + ' "name": "safe-plugin",\n' + ' "timeout": Infinity\n' + "}\n" + ) + hits = inspect_claude_plugin_file( + "plugin.json", + ".claude-plugin/plugin.json", + body, + ) + rule_ids = {hit.rule_id for hit in hits} + assert _JSON_RULE in rule_ids + assert _NFC_RULE not in rule_ids + + +def test_bidi_stays_concealment_class() -> None: + """Bidi marks stay `claude-plugin-concealed-identity`.""" + body = json.dumps({"name": f"safe{_BIDI}plugin"}, ensure_ascii=False) + hits = inspect_claude_plugin_file( + "plugin.json", + ".claude-plugin/plugin.json", + body, + ) + rule_ids = {hit.rule_id for hit in hits} + assert _CONCEAL_RULE in rule_ids + assert _NFC_RULE not in rule_ids + + +def test_readme_nfd_is_not_a_manifest_finding() -> None: + """README text is not a plugin identity surface.""" + hits = inspect_claude_plugin_file( + "README.md", + "README.md", + f"name: {_NFD_NAME}\n", + ) + assert all(hit.rule_id != _NFC_RULE for hit in hits) + + +def test_normalized_name_snippets_omit_secrets_and_bidi(tmp_path: Path) -> None: + """NFC-mismatch snippets omit secret literals, bidi, and raw combining marks.""" + root = _licensed_plugin(tmp_path, name=_NFD_NAME) + payload = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + payload["token"] = _SECRET + _write_json(root / ".claude-plugin" / "plugin.json", payload) + body = (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + hits = [ + hit + for hit in inspect_claude_plugin_file( + "plugin.json", + ".claude-plugin/plugin.json", + body, + ) + if hit.rule_id == _NFC_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 all("\u0301" not in hit.snippet for hit in hits) + assert all(hit.snippet == "name" for hit in hits) + assert _SECRET not in serialized + assert _NFC_RULE in receipt.finding_summary From b85b936f04558f66af23a3b65d8ea38df9397d6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 02:02:24 +0900 Subject: [PATCH 2/2] feat(scanner): reject non-NFC plugin identity names Fail closed when a plugin or marketplace name is not Unicode NFC. Hangul and precomposed Latin stay admitted. Relates to #1099. --- .github/workflows/tests.yml | 3 +- .../1099-claude-plugin-supply-chain.md | 4 ++ appguardrail_core/claude_plugin_detector.py | 40 +++++++++++++++++++ docs/TRACEABILITY.md | 2 +- .../doctoring/cwl-security-issue-detectors.md | 3 +- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba9d39b5..e51d0ae2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,7 +62,8 @@ jobs: --test tests/test_claude_plugin_browser_profile.py \ --test tests/test_claude_plugin_deceptive_description.py \ --test tests/test_claude_plugin_nonstandard_json.py \ - --test tests/test_claude_plugin_malformed_utf8.py + --test tests/test_claude_plugin_malformed_utf8.py \ + --test tests/test_claude_plugin_normalized_name.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 39ac3967..18d137d6 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -63,3 +63,7 @@ fail as `claude-plugin-malformed-utf8`. Valid CJK stays admitted. Bidi and control concealment stay `claude-plugin-concealed-identity`. Snippets are short labels and omit raw invalid bytes. + Plugin or marketplace identity names that are not Unicode NFC fail as + `claude-plugin-inconsistent-normalized-name`. Precomposed Latin and + Hangul names stay admitted. Combining-mark bytes do not appear in + snippets. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index d9d1a43a..85b77d28 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -8,6 +8,7 @@ escape, unadmitted nested submodule, hardcoded GitHub write token, Docker socket bind, host browser-profile store, secret copied into a network request, a non-standard JSON constant, malformed UTF-8 JSON bytes, a +non-NFC identity name, 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 @@ -29,6 +30,7 @@ import re import tarfile from typing import Final, Iterable +import unicodedata import zipfile from .claude_plugin_sarif import finding_summary_to_sarif, sarif_document_sha256 @@ -75,6 +77,12 @@ "Infinity, and -Infinity are not JSON numbers and must fail admission. " "[CWE-20 - Improper Input Validation]" ) +CLAUDE_PLUGIN_NORMALIZED_NAME_MESSAGE: Final = ( + "Claude plugin identity name is not Unicode NFC. Decode and normalize " + "the declared name before admission so catalog and artifact identities " + "compare as one object. " + "[CWE-451 - User Interface (UI) Misrepresentation of Critical Information]" +) CLAUDE_PLUGIN_MALFORMED_UTF8_MESSAGE: Final = ( "Claude plugin manifest is not valid UTF-8. Truncated multibyte " "sequences, invalid continuation bytes, and lone surrogates must fail " @@ -1316,6 +1324,7 @@ def _inspect_manifest(content: str) -> tuple[PluginHit, ...]: ) ) hits.extend(_mcp_hits(payload, content)) + hits.extend(_normalized_name_hits(payload, content)) secret = _PROVIDER_SECRET.search(content) if secret is not None: hits.append( @@ -1415,6 +1424,37 @@ def _mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: return tuple(hits) +def _normalized_name_hits(payload: object, content: str) -> tuple[PluginHit, ...]: + """Return hits when a plugin identity name is not Unicode NFC. + + Combining-mark (NFD) names conceal catalog identity. ASCII and + precomposed Hangul/Latin names are already NFC and stay negative. + + Args: + payload: Parsed plugin or marketplace JSON. + content: Original manifest text for line numbers. + + Returns: + Zero or more hits. Snippets are the label ``name`` only. + """ + hits: list[PluginHit] = [] + for entry in _plugin_entries(payload): + name = entry.get("name") + if not isinstance(name, str) or not name: + continue + if unicodedata.normalize("NFC", name) == name: + continue + hits.append( + PluginHit( + rule_id="claude-plugin-inconsistent-normalized-name", + line=_line_of(content, name), + snippet="name", + message=CLAUDE_PLUGIN_NORMALIZED_NAME_MESSAGE, + ) + ) + return tuple(hits) + + def _plugin_entries(payload: object) -> Iterable[dict]: """Yield plugin objects from a marketplace document or single plugin.""" if isinstance(payload, dict): diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 6fc0b144..e3c42166 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-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-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `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 8a634e39..167e89ab 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, non-standard JSON constants, malformed UTF-8 JSON bytes, 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, malformed UTF-8 JSON bytes, non-NFC identity names, 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 | @@ -37,6 +37,7 @@ workflow families remain owned by PRs #1088 and #966. - `tests/test_claude_plugin_deceptive_description.py` - `tests/test_claude_plugin_nonstandard_json.py` - `tests/test_claude_plugin_malformed_utf8.py` +- `tests/test_claude_plugin_normalized_name.py` - `tests/test_password_indirection_precision.py` - `tests/test_cwl_security_issue_inventory.py` - `tests/fixtures/cwl-security-issue-inventory.json`