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 @@ -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: |
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 @@ -65,3 +65,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.
40 changes: 40 additions & 0 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -1322,6 +1330,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(
Expand Down Expand Up @@ -1421,6 +1430,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):
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-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` |

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, 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 |
Expand All @@ -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`
Expand Down
204 changes: 204 additions & 0 deletions tests/test_claude_plugin_normalized_name.py
Original file line number Diff line number Diff line change
@@ -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