plugin-scanner 1.4.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. codex_plugin_scanner/__init__.py +29 -0
  2. codex_plugin_scanner/action_runner.py +470 -0
  3. codex_plugin_scanner/checks/__init__.py +0 -0
  4. codex_plugin_scanner/checks/best_practices.py +238 -0
  5. codex_plugin_scanner/checks/claude.py +285 -0
  6. codex_plugin_scanner/checks/code_quality.py +115 -0
  7. codex_plugin_scanner/checks/ecosystem_common.py +34 -0
  8. codex_plugin_scanner/checks/gemini.py +196 -0
  9. codex_plugin_scanner/checks/manifest.py +501 -0
  10. codex_plugin_scanner/checks/manifest_support.py +61 -0
  11. codex_plugin_scanner/checks/marketplace.py +334 -0
  12. codex_plugin_scanner/checks/opencode.py +223 -0
  13. codex_plugin_scanner/checks/operational_security.py +346 -0
  14. codex_plugin_scanner/checks/security.py +447 -0
  15. codex_plugin_scanner/checks/skill_security.py +241 -0
  16. codex_plugin_scanner/cli.py +467 -0
  17. codex_plugin_scanner/config.py +76 -0
  18. codex_plugin_scanner/ecosystems/__init__.py +15 -0
  19. codex_plugin_scanner/ecosystems/base.py +20 -0
  20. codex_plugin_scanner/ecosystems/claude.py +112 -0
  21. codex_plugin_scanner/ecosystems/codex.py +94 -0
  22. codex_plugin_scanner/ecosystems/detect.py +46 -0
  23. codex_plugin_scanner/ecosystems/gemini.py +80 -0
  24. codex_plugin_scanner/ecosystems/opencode.py +184 -0
  25. codex_plugin_scanner/ecosystems/registry.py +41 -0
  26. codex_plugin_scanner/ecosystems/types.py +45 -0
  27. codex_plugin_scanner/integrations/__init__.py +5 -0
  28. codex_plugin_scanner/integrations/cisco_skill_scanner.py +200 -0
  29. codex_plugin_scanner/lint_fixes.py +105 -0
  30. codex_plugin_scanner/marketplace_support.py +100 -0
  31. codex_plugin_scanner/models.py +177 -0
  32. codex_plugin_scanner/path_support.py +46 -0
  33. codex_plugin_scanner/policy.py +140 -0
  34. codex_plugin_scanner/quality_artifact.py +91 -0
  35. codex_plugin_scanner/repo_detect.py +137 -0
  36. codex_plugin_scanner/reporting.py +376 -0
  37. codex_plugin_scanner/rules/__init__.py +6 -0
  38. codex_plugin_scanner/rules/registry.py +101 -0
  39. codex_plugin_scanner/rules/specs.py +26 -0
  40. codex_plugin_scanner/scanner.py +557 -0
  41. codex_plugin_scanner/submission.py +284 -0
  42. codex_plugin_scanner/suppressions.py +87 -0
  43. codex_plugin_scanner/trust_domain_scoring.py +22 -0
  44. codex_plugin_scanner/trust_helpers.py +207 -0
  45. codex_plugin_scanner/trust_mcp_scoring.py +116 -0
  46. codex_plugin_scanner/trust_models.py +85 -0
  47. codex_plugin_scanner/trust_plugin_scoring.py +180 -0
  48. codex_plugin_scanner/trust_scoring.py +52 -0
  49. codex_plugin_scanner/trust_skill_scoring.py +296 -0
  50. codex_plugin_scanner/trust_specs.py +286 -0
  51. codex_plugin_scanner/verification.py +964 -0
  52. codex_plugin_scanner/version.py +3 -0
  53. plugin_scanner-1.4.15.dist-info/METADATA +596 -0
  54. plugin_scanner-1.4.15.dist-info/RECORD +57 -0
  55. plugin_scanner-1.4.15.dist-info/WHEEL +4 -0
  56. plugin_scanner-1.4.15.dist-info/entry_points.txt +4 -0
  57. plugin_scanner-1.4.15.dist-info/licenses/LICENSE +120 -0
@@ -0,0 +1,116 @@
1
+ """HCS-style MCP trust scoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .models import CategoryResult
8
+ from .trust_helpers import (
9
+ build_adapter_score,
10
+ build_domain_score,
11
+ category_checks,
12
+ check_percent,
13
+ is_https_url,
14
+ load_mcp_payload,
15
+ )
16
+ from .trust_models import TrustDomainScore
17
+ from .trust_specs import MCP_TRUST_SPEC
18
+
19
+
20
+ def build_mcp_domain(plugin_dir: Path, categories: tuple[CategoryResult, ...]) -> TrustDomainScore | None:
21
+ payload_state = load_mcp_payload(plugin_dir)
22
+ if payload_state is None:
23
+ return None
24
+
25
+ payload = payload_state.payload
26
+ security_checks = category_checks(categories, "Security")
27
+ remotes = payload.get("remotes")
28
+ servers = payload.get("mcpServers")
29
+ remote_entries = remotes if isinstance(remotes, list) else []
30
+ local_servers = servers if isinstance(servers, dict) else {}
31
+ has_named_surfaces = bool(remote_entries) or bool(local_servers)
32
+ secure_remote_urls = (
33
+ all(isinstance(entry, dict) and is_https_url(str(entry.get("url", ""))) for entry in remote_entries)
34
+ if remote_entries
35
+ else True
36
+ )
37
+ local_commands_valid = (
38
+ all(
39
+ isinstance(config, dict)
40
+ and isinstance(config.get("command"), str)
41
+ and bool(config.get("command"))
42
+ and (
43
+ "args" not in config
44
+ or (
45
+ isinstance(config.get("args"), list) and all(isinstance(value, str) for value in config.get("args"))
46
+ )
47
+ )
48
+ for config in local_servers.values()
49
+ )
50
+ if local_servers
51
+ else True
52
+ )
53
+ config_shape = payload_state.parse_valid and (
54
+ (remotes is None or isinstance(remotes, list)) and (servers is None or isinstance(servers, dict))
55
+ )
56
+
57
+ spec_by_id = {adapter.adapter_id: adapter for adapter in MCP_TRUST_SPEC.adapters}
58
+ adapters = (
59
+ build_adapter_score(
60
+ spec_by_id["verification.config-integrity"],
61
+ component_scores={"score": 100.0} if payload_state.parse_valid else None,
62
+ rationales={
63
+ "score": (
64
+ "The .mcp.json file parsed successfully."
65
+ if payload_state.parse_valid
66
+ else "The .mcp.json file did not parse, so config-integrity remains 0."
67
+ )
68
+ },
69
+ ),
70
+ build_adapter_score(
71
+ spec_by_id["verification.execution-safety"],
72
+ component_scores={"score": check_percent(security_checks, "No dangerous MCP commands")},
73
+ rationales={"score": "Execution safety follows the scanner's dangerous-command check."},
74
+ ),
75
+ build_adapter_score(
76
+ spec_by_id["verification.transport-security"],
77
+ component_scores={"score": check_percent(security_checks, "MCP remote transports are hardened")},
78
+ rationales={"score": "Transport security follows the scanner's hardened-remote check."},
79
+ ),
80
+ build_adapter_score(
81
+ spec_by_id["metadata.server-naming"],
82
+ component_scores={"score": 100.0} if has_named_surfaces else None,
83
+ rationales={
84
+ "score": (
85
+ "At least one MCP surface is explicitly named."
86
+ if has_named_surfaces
87
+ else "No local or remote MCP surfaces are declared."
88
+ )
89
+ },
90
+ ),
91
+ build_adapter_score(
92
+ spec_by_id["metadata.command-or-endpoint"],
93
+ component_scores=(
94
+ {"score": 100.0} if has_named_surfaces and secure_remote_urls and local_commands_valid else None
95
+ ),
96
+ rationales={
97
+ "score": (
98
+ "Every MCP surface declares a concrete command or HTTPS endpoint."
99
+ if has_named_surfaces and secure_remote_urls and local_commands_valid
100
+ else "At least one MCP surface is missing a valid command or secure endpoint."
101
+ )
102
+ },
103
+ ),
104
+ build_adapter_score(
105
+ spec_by_id["metadata.config-shape"],
106
+ component_scores={"score": 100.0} if config_shape else None,
107
+ rationales={
108
+ "score": (
109
+ "The top-level MCP config containers match the expected shape."
110
+ if config_shape
111
+ else "The MCP config containers do not match the expected shape."
112
+ )
113
+ },
114
+ ),
115
+ )
116
+ return build_domain_score(domain="mcp", spec=MCP_TRUST_SPEC, adapters=adapters)
@@ -0,0 +1,85 @@
1
+ """Trust provenance data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal
7
+
8
+ ContributionMode = Literal["conditional", "universal", "scoped"]
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class TrustComponentScore:
13
+ """One scored trust component inside an adapter."""
14
+
15
+ key: str
16
+ score: float
17
+ rationale: str
18
+ evidence: tuple[str, ...] = ()
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class TrustAdapterScore:
23
+ """Weighted adapter score inside a trust domain."""
24
+
25
+ adapter_id: str
26
+ label: str
27
+ weight: float
28
+ contribution_mode: ContributionMode
29
+ applicable: bool
30
+ emitted: bool
31
+ included_in_denominator: bool
32
+ score: float
33
+ components: tuple[TrustComponentScore, ...]
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class TrustDomainScore:
38
+ """One trust domain such as plugin, skills, or MCP."""
39
+
40
+ domain: str
41
+ label: str
42
+ spec_id: str
43
+ spec_version: str
44
+ spec_path: str
45
+ derived_from: tuple[str, ...]
46
+ profile_id: str
47
+ profile_version: str
48
+ score: float
49
+ adapters: tuple[TrustAdapterScore, ...]
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class TrustReport:
54
+ """Overall trust report emitted alongside scan results."""
55
+
56
+ total: float
57
+ include_external: bool
58
+ computed_at: str
59
+ domains: tuple[TrustDomainScore, ...] = ()
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class TrustAdapterSpec:
64
+ """Definition of a trust adapter and its components."""
65
+
66
+ adapter_id: str
67
+ label: str
68
+ weight: float
69
+ component_keys: tuple[str, ...]
70
+ contribution_mode: ContributionMode = "conditional"
71
+ default_component_key: str = "score"
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class TrustSpecDefinition:
76
+ """Definition of a trust scoring specification."""
77
+
78
+ spec_id: str
79
+ version: str
80
+ label: str
81
+ spec_path: str
82
+ derived_from: tuple[str, ...]
83
+ profile_id: str
84
+ profile_version: str
85
+ adapters: tuple[TrustAdapterSpec, ...] = field(default_factory=tuple)
@@ -0,0 +1,180 @@
1
+ """HCS-style top-level plugin trust scoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .checks.manifest import load_manifest
8
+ from .models import CategoryResult
9
+ from .trust_helpers import build_adapter_score, build_domain_score, category_checks, check_percent, is_https_url
10
+ from .trust_models import TrustDomainScore
11
+ from .trust_specs import PLUGIN_TRUST_SPEC
12
+
13
+
14
+ def build_plugin_domain(plugin_dir: Path, categories: tuple[CategoryResult, ...]) -> TrustDomainScore:
15
+ manifest = load_manifest(plugin_dir)
16
+ manifest_checks = category_checks(categories, "Manifest Validation")
17
+ security_checks = category_checks(categories, "Security")
18
+ operational_checks = category_checks(categories, "Operational Security")
19
+ best_checks = category_checks(categories, "Best Practices")
20
+ marketplace_checks = category_checks(categories, "Marketplace")
21
+ interface = (
22
+ manifest.get("interface") if isinstance(manifest, dict) and isinstance(manifest.get("interface"), dict) else {}
23
+ )
24
+ interface_declared = isinstance(manifest, dict) and "interface" in manifest
25
+ author = manifest.get("author") if isinstance(manifest, dict) and isinstance(manifest.get("author"), dict) else {}
26
+ has_author = isinstance(author.get("name"), str) and bool(author.get("name"))
27
+ has_homepage = is_https_url(manifest.get("homepage") if isinstance(manifest, dict) else None)
28
+ has_repository = is_https_url(manifest.get("repository") if isinstance(manifest, dict) else None)
29
+ keywords = manifest.get("keywords") if isinstance(manifest, dict) else None
30
+ keyword_count = len(keywords) if isinstance(keywords, list) else 0
31
+ has_interface_category = isinstance(interface.get("category"), str) and bool(interface.get("category"))
32
+ discoverability = (
33
+ 100.0
34
+ if has_interface_category and keyword_count >= 3
35
+ else 75.0
36
+ if has_interface_category or keyword_count >= 1
37
+ else 0.0
38
+ )
39
+ provenance = (
40
+ 100.0
41
+ if has_author and has_homepage and has_repository
42
+ else 70.0
43
+ if has_author and (has_homepage or has_repository)
44
+ else 40.0
45
+ if has_author or has_repository or has_homepage
46
+ else 0.0
47
+ )
48
+ has_marketplace_surface = (plugin_dir / "marketplace.json").exists()
49
+ has_mcp_surface = (plugin_dir / ".mcp.json").exists()
50
+
51
+ spec_by_id = {adapter.adapter_id: adapter for adapter in PLUGIN_TRUST_SPEC.adapters}
52
+ adapters = (
53
+ build_adapter_score(
54
+ spec_by_id["verification.manifest-integrity"],
55
+ component_scores={
56
+ "score": (
57
+ check_percent(manifest_checks, "plugin.json exists") * 0.20
58
+ + check_percent(manifest_checks, "Valid JSON") * 0.20
59
+ + check_percent(manifest_checks, "Required fields present") * 0.35
60
+ + check_percent(manifest_checks, "Version follows semver") * 0.25
61
+ )
62
+ },
63
+ rationales={
64
+ "score": "Manifest integrity blends existence, JSON validity, required fields, and semver checks."
65
+ },
66
+ ),
67
+ build_adapter_score(
68
+ spec_by_id["verification.interface-integrity"],
69
+ component_scores={
70
+ "score": (
71
+ check_percent(manifest_checks, "Interface metadata complete if declared") * 0.60
72
+ + check_percent(manifest_checks, "Interface links and assets valid if declared") * 0.40
73
+ )
74
+ }
75
+ if interface_declared
76
+ else None,
77
+ rationales={"score": "Interface integrity applies when the plugin declares an install surface."},
78
+ applicable=interface_declared,
79
+ ),
80
+ build_adapter_score(
81
+ spec_by_id["verification.path-safety"],
82
+ component_scores={"score": check_percent(manifest_checks, "Declared paths are safe")},
83
+ rationales={"score": "Path safety uses the scanner's declared-path safety check."},
84
+ ),
85
+ build_adapter_score(
86
+ spec_by_id["verification.marketplace-alignment"],
87
+ component_scores={
88
+ "score": (
89
+ check_percent(marketplace_checks, "marketplace.json valid") * 0.35
90
+ + check_percent(marketplace_checks, "Policy fields present") * 0.35
91
+ + check_percent(marketplace_checks, "Marketplace sources are safe") * 0.30
92
+ )
93
+ }
94
+ if has_marketplace_surface
95
+ else None,
96
+ rationales={
97
+ "score": "Marketplace alignment applies when repository-scoped marketplace metadata is present."
98
+ },
99
+ applicable=has_marketplace_surface,
100
+ ),
101
+ build_adapter_score(
102
+ spec_by_id["security.disclosure"],
103
+ component_scores={"score": check_percent(security_checks, "SECURITY.md found")},
104
+ rationales={"score": "Disclosure is one explicit signal, not a proxy for the entire security posture."},
105
+ ),
106
+ build_adapter_score(
107
+ spec_by_id["security.license"],
108
+ component_scores={"score": check_percent(security_checks, "LICENSE found")},
109
+ rationales={"score": "License clarity remains a separate scored signal."},
110
+ ),
111
+ build_adapter_score(
112
+ spec_by_id["security.secret-hygiene"],
113
+ component_scores={"score": check_percent(security_checks, "No hardcoded secrets")},
114
+ rationales={"score": "Secret hygiene uses the scanner's hardcoded-secret detection."},
115
+ ),
116
+ build_adapter_score(
117
+ spec_by_id["security.mcp-safety"],
118
+ component_scores={
119
+ "score": (
120
+ check_percent(security_checks, "No dangerous MCP commands") * 0.50
121
+ + check_percent(security_checks, "MCP remote transports are hardened") * 0.50
122
+ )
123
+ }
124
+ if has_mcp_surface
125
+ else None,
126
+ rationales={"score": "MCP safety applies only when the plugin declares an MCP surface."},
127
+ applicable=has_mcp_surface,
128
+ ),
129
+ build_adapter_score(
130
+ spec_by_id["security.approval-hygiene"],
131
+ component_scores={"score": check_percent(security_checks, "No approval bypass defaults")},
132
+ rationales={"score": "Approval hygiene checks for bypass-style defaults."},
133
+ ),
134
+ build_adapter_score(
135
+ spec_by_id["metadata.documentation"],
136
+ component_scores={"score": check_percent(best_checks, "README.md found")},
137
+ rationales={"score": "Documentation reflects README coverage for operators and maintainers."},
138
+ ),
139
+ build_adapter_score(
140
+ spec_by_id["metadata.manifest-metadata"],
141
+ component_scores={"score": check_percent(manifest_checks, "Recommended metadata present")},
142
+ rationales={"score": "Manifest metadata tracks the scanner's recommended-metadata check."},
143
+ ),
144
+ build_adapter_score(
145
+ spec_by_id["metadata.discoverability"],
146
+ component_scores={"score": discoverability},
147
+ rationales={"score": "Discoverability uses category plus keyword coverage."},
148
+ ),
149
+ build_adapter_score(
150
+ spec_by_id["metadata.provenance"],
151
+ component_scores={"score": provenance},
152
+ rationales={"score": "Provenance reflects author, homepage, and repository metadata coverage."},
153
+ ),
154
+ build_adapter_score(
155
+ spec_by_id["operations.action-pinning"],
156
+ component_scores={"score": check_percent(operational_checks, "Third-party GitHub Actions pinned to SHAs")},
157
+ rationales={"score": "Action pinning uses the scanner's immutable-action check."},
158
+ ),
159
+ build_adapter_score(
160
+ spec_by_id["operations.permission-scope"],
161
+ component_scores={"score": check_percent(operational_checks, "No write-all GitHub Actions permissions")},
162
+ rationales={"score": "Permission scope uses the least-privilege workflow check."},
163
+ ),
164
+ build_adapter_score(
165
+ spec_by_id["operations.untrusted-checkout"],
166
+ component_scores={"score": check_percent(operational_checks, "No privileged untrusted checkout patterns")},
167
+ rationales={"score": "Untrusted-checkout protection uses the scanner's privileged-workflow check."},
168
+ ),
169
+ build_adapter_score(
170
+ spec_by_id["operations.update-automation"],
171
+ component_scores={
172
+ "score": (
173
+ check_percent(operational_checks, "Dependabot configured for automation surfaces") * 0.50
174
+ + check_percent(operational_checks, "Dependency manifests have lockfiles") * 0.50
175
+ )
176
+ },
177
+ rationales={"score": "Update automation combines Dependabot coverage and lockfile hygiene."},
178
+ ),
179
+ )
180
+ return build_domain_score(domain="plugin", spec=PLUGIN_TRUST_SPEC, adapters=adapters)
@@ -0,0 +1,52 @@
1
+ """Trust scoring and provenance formatting for scan results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from .checks.skill_security import SkillSecurityContext
9
+ from .models import CategoryResult
10
+ from .trust_domain_scoring import build_mcp_domain, build_plugin_domain, build_skill_domain
11
+ from .trust_helpers import normalize_report_total, round_trust_score
12
+ from .trust_models import TrustReport
13
+
14
+
15
+ def build_plugin_trust_report(
16
+ plugin_dir: Path,
17
+ categories: tuple[CategoryResult, ...],
18
+ skill_security_context: SkillSecurityContext,
19
+ ) -> TrustReport:
20
+ computed_at = datetime.now(timezone.utc).isoformat()
21
+ domains = [
22
+ domain
23
+ for domain in (
24
+ build_plugin_domain(plugin_dir, categories),
25
+ build_skill_domain(plugin_dir, skill_security_context),
26
+ build_mcp_domain(plugin_dir, categories),
27
+ )
28
+ if domain is not None
29
+ ]
30
+ scores = tuple(domain.score for domain in domains)
31
+ return TrustReport(
32
+ total=normalize_report_total(scores),
33
+ include_external=False,
34
+ computed_at=computed_at,
35
+ domains=tuple(domains),
36
+ )
37
+
38
+
39
+ def build_repository_trust_report(plugin_reports: tuple[TrustReport, ...]) -> TrustReport:
40
+ if not plugin_reports:
41
+ return TrustReport(
42
+ total=0.0,
43
+ include_external=False,
44
+ computed_at=datetime.now(timezone.utc).isoformat(),
45
+ domains=(),
46
+ )
47
+ return TrustReport(
48
+ total=round_trust_score(min(report.total for report in plugin_reports)),
49
+ include_external=False,
50
+ computed_at=max(report.computed_at for report in plugin_reports),
51
+ domains=(),
52
+ )