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 @@ -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: |
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.d/1099-claude-plugin-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
75 changes: 69 additions & 6 deletions appguardrail_core/claude_plugin_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. "
Expand Down Expand Up @@ -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():
Expand All @@ -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"))
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
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`, `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` |

Expand Down
2 changes: 1 addition & 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, 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 |
Expand Down
128 changes: 128 additions & 0 deletions tests/test_claude_plugin_license_mismatch.py
Original file line number Diff line number Diff line change
@@ -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 == ()