diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4a0c9e02..3ce1145a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,7 +54,8 @@ jobs: python -m scripts.ci.verify_module_coverage \ --module appguardrail_core/claude_plugin_detector.py \ --test tests/test_claude_plugin_supply_chain.py \ - --test tests/test_claude_plugin_scan_cli.py + --test tests/test_claude_plugin_scan_cli.py \ + --test tests/test_claude_plugin_license_mismatch.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 c47bd8e4..42a14e1f 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -37,4 +37,7 @@ identity that disagrees with the retrieved artifact fails closed. Receipt `sarif_sha256` is the SHA-256 of a deterministic SARIF 2.1.0 document covering the same finding rule_ids as `finding_summary`. + LICENSE/NOTICE absence still fails closed; conflicting SPDX identifiers + across the declared license field, LICENSE, and NOTICE fail as + `claude-plugin-license-mismatch` without inventing legal approval. `.claude-plugin/` is included in the scan walk. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 5f7b63e4..07cb16a8 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -66,6 +66,17 @@ "evidence without inventing legal approval. " "[CWE-1104 - Use of Unmaintained Third Party Components]" ) +CLAUDE_PLUGIN_LICENSE_MISMATCH_MESSAGE: Final = ( + "Claude plugin license evidence names more than one SPDX identifier. " + "Record the conflict without inventing legal approval. " + "[CWE-1104 - Use of Unmaintained Third Party Components]" +) +_SPDX_TOKEN = re.compile( + r"\b(Apache-2\.0|MIT|BSD-2-Clause|BSD-3-Clause|GPL-3\.0-only|" + r"GPL-3\.0-or-later|LGPL-3\.0-only|AGPL-3\.0-only|MPL-2\.0|ISC|" + r"Unlicense|CC0-1\.0|0BSD)\b", + re.IGNORECASE, +) CLAUDE_PLUGIN_CONCEALED_IDENTITY_MESSAGE: Final = ( "Claude plugin manifest contains concealed control or bidirectional " "formatting characters. Decode identity before admission. " @@ -455,10 +466,10 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: root: Scan root that may contain ``.claude-plugin/``. Returns: - Undeclared executable, license, size, symlink, archive traversal, and - unadmitted-submodule findings. Empty when the tree is not a plugin - package or every hook is a declared regular file. Inventory presence - is not a finding. + Undeclared executable, license absence or SPDX mismatch, size, symlink, + archive traversal, and unadmitted-submodule findings. Empty when the + tree is not a plugin package or every hook is a declared regular file. + Inventory presence is not a finding. """ plugin_dir = root / ".claude-plugin" if not plugin_dir.is_dir() or plugin_dir.is_symlink(): @@ -467,6 +478,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: if not manifest_path.is_file() or manifest_path.is_symlink(): manifest_path = plugin_dir / "marketplace.json" declared: set[str] = set() + payload: object = {} if manifest_path.is_file() and not manifest_path.is_symlink(): try: payload = json.loads(manifest_path.read_text(encoding="utf-8")) @@ -484,6 +496,10 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: file=".claude-plugin", ) ) + elif isinstance(payload, dict): + hits.extend(_license_mismatch_hits(root, payload)) + else: + hits.extend(_license_mismatch_hits(root, {})) _, file_count, scanned_byte_count = _artifact_digest(root) if file_count > _MAX_PACKAGE_FILES or scanned_byte_count > _MAX_PACKAGE_BYTES: hits.append( @@ -1180,12 +1196,59 @@ def _license_summary(root: Path) -> str: """Return present license path names or ``absent`` without legal approval.""" names = [ path.relative_to(root).as_posix() - for path in _walk_entries(root) - if not path.is_symlink() and path.name.upper().startswith("LICENSE") + for path in _license_evidence_paths(root) ] return ",".join(names) if names else "absent" +def _license_evidence_paths(root: Path) -> tuple[Path, ...]: + """Return LICENSE* and NOTICE* regular files, never following symlinks.""" + found: list[Path] = [] + for path in _walk_entries(root): + if path.is_symlink() or not path.is_file(): + continue + upper = path.name.upper() + if upper.startswith("LICENSE") or upper.startswith("NOTICE"): + found.append(path) + return tuple(found) + + +def _spdx_tokens_from_text(text: str) -> set[str]: + """Return known SPDX identifiers found in ``text`` without legal approval.""" + return {match.group(1).upper() for match in _SPDX_TOKEN.finditer(text)} + + +def _declared_license_expression(payload: dict) -> str: + """Return a string license field from a plugin manifest, if present.""" + value = payload.get("license") + return value if isinstance(value, str) else "" + + +def _license_mismatch_hits(root: Path, payload: dict) -> tuple[PluginHit, ...]: + """Return a finding when SPDX tokens in license evidence disagree.""" + tokens: set[str] = set() + declared = _declared_license_expression(payload) + tokens.update(_spdx_tokens_from_text(declared)) + for path in _license_evidence_paths(root): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + tokens.update(_spdx_tokens_from_text(text)) + if len(tokens) < 2: + return () + snippet = ",".join(sorted(tokens))[:120] + return ( + PluginHit( + rule_id="claude-plugin-license-mismatch", + line=1, + snippet=snippet, + message=CLAUDE_PLUGIN_LICENSE_MISMATCH_MESSAGE, + file=".claude-plugin", + ), + ) + + def _empty_identity() -> dict[str, str]: """Return blank plugin identity fields.""" return { diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 30bea557..bb5a6ab1 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`, `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-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `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`, `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-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `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 d01af07a..d3b876b6 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, unpinned package URL installs, GitHub write tokens, Docker socket binds, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, 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, unpinned package URL installs, GitHub write tokens, Docker socket binds, 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, 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 | diff --git a/tests/test_claude_plugin_license_mismatch.py b/tests/test_claude_plugin_license_mismatch.py new file mode 100644 index 00000000..8a88010a --- /dev/null +++ b/tests/test_claude_plugin_license_mismatch.py @@ -0,0 +1,128 @@ +"""LICENSE/NOTICE evidence must fail closed on SPDX mismatch, not legal approval.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + build_claude_plugin_scan_receipt, + scan_claude_plugin_package, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_MISMATCH_RULE = "claude-plugin-license-mismatch" +_MISSING_RULE = "claude-plugin-license-missing" + + +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 _plugin(root: Path, *, license_field: str | None = "MIT") -> Path: + """Write a pinned plugin with an optional declared license expression.""" + manifest: dict[str, object] = { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + } + if license_field is not None: + manifest["license"] = license_field + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + _write_json(root / ".claude-plugin" / "marketplace.json", manifest) + return root + + +def test_matching_declared_mit_and_license_file_is_not_a_finding(tmp_path: Path) -> None: + """Matching MIT evidence is recorded, not a mismatch finding.""" + root = _plugin(tmp_path) + (root / "LICENSE").write_text("MIT License\nPermission is hereby granted.\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + assert receipt.scan_result == "pass" + assert _MISMATCH_RULE not in receipt.finding_summary + assert _MISSING_RULE not in receipt.finding_summary + assert "LICENSE" in receipt.license_evidence_summary + + +def test_declared_apache_with_mit_license_file_is_mismatch(tmp_path: Path) -> None: + """A declared Apache-2.0 expression cannot sit on an MIT license file.""" + root = _plugin(tmp_path, license_field="Apache-2.0") + (root / "LICENSE").write_text("MIT License\nPermission is hereby granted.\n", encoding="utf-8") + hits = scan_claude_plugin_package(root) + receipt = build_claude_plugin_scan_receipt(root) + assert any(hit.rule_id == _MISMATCH_RULE for hit in hits) + assert receipt.scan_result == "fail" + assert _MISMATCH_RULE in receipt.finding_summary + assert _MISSING_RULE not in receipt.finding_summary + + +def test_mit_license_and_apache_notice_is_mismatch(tmp_path: Path) -> None: + """LICENSE and NOTICE SPDX tokens must not contradict each other.""" + root = _plugin(tmp_path, license_field=None) + (root / "LICENSE").write_text("MIT License\n", encoding="utf-8") + (root / "NOTICE").write_text("Apache-2.0\nCopyright 2026 Example\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + assert receipt.scan_result == "fail" + assert _MISMATCH_RULE in receipt.finding_summary + + +def test_notice_only_is_not_license_missing(tmp_path: Path) -> None: + """A NOTICE file is license evidence and is not treated as absence.""" + root = _plugin(tmp_path, license_field="MIT") + (root / "NOTICE").write_text("MIT\nCopyright 2026 Example\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + assert _MISSING_RULE not in receipt.finding_summary + assert "NOTICE" in receipt.license_evidence_summary + assert receipt.scan_result == "pass" + + +def test_copyright_only_notice_does_not_invent_spdx(tmp_path: Path) -> None: + """A NOTICE without an SPDX token is not a mismatch against MIT.""" + root = _plugin(tmp_path) + (root / "LICENSE").write_text("MIT License\n", encoding="utf-8") + (root / "NOTICE").write_text("Copyright 2026 Example Inc.\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + assert receipt.scan_result == "pass" + assert _MISMATCH_RULE not in receipt.finding_summary + + +def test_non_object_manifest_still_compares_license_files(tmp_path: Path) -> None: + """File SPDX tokens still conflict when the manifest is not an object.""" + root = tmp_path + plugin_dir = root / ".claude-plugin" + plugin_dir.mkdir() + (plugin_dir / "plugin.json").write_text("[]\n", encoding="utf-8") + (root / "LICENSE").write_text("MIT License\n", encoding="utf-8") + (root / "NOTICE").write_text("Apache-2.0\n", encoding="utf-8") + hits = scan_claude_plugin_package(root) + assert any(hit.rule_id == _MISMATCH_RULE for hit in hits) + + +def test_non_string_license_field_is_ignored(tmp_path: Path) -> None: + """Object license fields are not SPDX evidence.""" + root = _plugin(tmp_path, license_field=None) + manifest = json.loads((root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) + manifest["license"] = {"type": "MIT"} + (root / ".claude-plugin" / "plugin.json").write_text( + json.dumps(manifest) + "\n", encoding="utf-8" + ) + (root / "LICENSE").write_text("MIT License\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + assert _MISMATCH_RULE not in receipt.finding_summary + + +def test_unreadable_license_file_is_skipped(tmp_path: Path) -> None: + """Invalid LICENSE bytes do not crash mismatch collection.""" + from appguardrail_core import claude_plugin_detector as detector + + root = _plugin(tmp_path, license_field="Apache-2.0") + (root / "LICENSE").write_bytes(b"\xff\xfe") + hits = detector._license_mismatch_hits(root, {"license": "Apache-2.0"}) + assert all(hit.rule_id == _MISMATCH_RULE for hit in hits) or hits == ()