codex-plugin-scanner 1.0.0__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.
- codex_plugin_scanner/__init__.py +14 -0
- codex_plugin_scanner/checks/__init__.py +0 -0
- codex_plugin_scanner/checks/best_practices.py +171 -0
- codex_plugin_scanner/checks/code_quality.py +91 -0
- codex_plugin_scanner/checks/manifest.py +121 -0
- codex_plugin_scanner/checks/marketplace.py +130 -0
- codex_plugin_scanner/checks/security.py +185 -0
- codex_plugin_scanner/cli.py +154 -0
- codex_plugin_scanner/models.py +57 -0
- codex_plugin_scanner/scanner.py +44 -0
- codex_plugin_scanner-1.0.0.dist-info/METADATA +174 -0
- codex_plugin_scanner-1.0.0.dist-info/RECORD +15 -0
- codex_plugin_scanner-1.0.0.dist-info/WHEEL +4 -0
- codex_plugin_scanner-1.0.0.dist-info/entry_points.txt +2 -0
- codex_plugin_scanner-1.0.0.dist-info/licenses/LICENSE +118 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Codex Plugin Scanner - security and best-practices scanner for Codex CLI plugins."""
|
|
2
|
+
|
|
3
|
+
from .models import GRADE_LABELS, CategoryResult, CheckResult, ScanResult, get_grade
|
|
4
|
+
from .scanner import scan_plugin
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"GRADE_LABELS",
|
|
9
|
+
"CategoryResult",
|
|
10
|
+
"CheckResult",
|
|
11
|
+
"ScanResult",
|
|
12
|
+
"get_grade",
|
|
13
|
+
"scan_plugin",
|
|
14
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Best practice checks (25 points)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..models import CheckResult
|
|
8
|
+
from .manifest import load_manifest
|
|
9
|
+
|
|
10
|
+
ENV_FILES = {".env", ".env.local", ".env.production"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_readme(plugin_dir: Path) -> CheckResult:
|
|
14
|
+
exists = (plugin_dir / "README.md").exists()
|
|
15
|
+
return CheckResult(
|
|
16
|
+
name="README.md found",
|
|
17
|
+
passed=exists,
|
|
18
|
+
points=5 if exists else 0,
|
|
19
|
+
max_points=5,
|
|
20
|
+
message="README.md found" if exists else "README.md not found",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_skills_directory(plugin_dir: Path) -> CheckResult:
|
|
25
|
+
manifest = load_manifest(plugin_dir)
|
|
26
|
+
if manifest is None:
|
|
27
|
+
return CheckResult(
|
|
28
|
+
name="Skills directory exists if declared",
|
|
29
|
+
passed=True,
|
|
30
|
+
points=5,
|
|
31
|
+
max_points=5,
|
|
32
|
+
message="Cannot parse manifest, skipping check",
|
|
33
|
+
)
|
|
34
|
+
skills = manifest.get("skills")
|
|
35
|
+
if not skills:
|
|
36
|
+
return CheckResult(
|
|
37
|
+
name="Skills directory exists if declared",
|
|
38
|
+
passed=True,
|
|
39
|
+
points=5,
|
|
40
|
+
max_points=5,
|
|
41
|
+
message="No skills field declared, check not applicable",
|
|
42
|
+
)
|
|
43
|
+
skills_path = plugin_dir / skills
|
|
44
|
+
if skills_path.is_dir():
|
|
45
|
+
return CheckResult(
|
|
46
|
+
name="Skills directory exists if declared",
|
|
47
|
+
passed=True,
|
|
48
|
+
points=5,
|
|
49
|
+
max_points=5,
|
|
50
|
+
message=f'Skills directory "{skills}" exists',
|
|
51
|
+
)
|
|
52
|
+
return CheckResult(
|
|
53
|
+
name="Skills directory exists if declared",
|
|
54
|
+
passed=False,
|
|
55
|
+
points=0,
|
|
56
|
+
max_points=5,
|
|
57
|
+
message=f'Skills directory "{skills}" declared but not found',
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_skill_frontmatter(plugin_dir: Path) -> CheckResult:
|
|
62
|
+
manifest = load_manifest(plugin_dir)
|
|
63
|
+
if manifest is None:
|
|
64
|
+
return CheckResult(
|
|
65
|
+
name="SKILL.md frontmatter",
|
|
66
|
+
passed=True,
|
|
67
|
+
points=5,
|
|
68
|
+
max_points=5,
|
|
69
|
+
message="Cannot parse manifest, skipping check",
|
|
70
|
+
)
|
|
71
|
+
skills = manifest.get("skills")
|
|
72
|
+
if not skills:
|
|
73
|
+
return CheckResult(
|
|
74
|
+
name="SKILL.md frontmatter",
|
|
75
|
+
passed=True,
|
|
76
|
+
points=5,
|
|
77
|
+
max_points=5,
|
|
78
|
+
message="No skills field declared, check not applicable",
|
|
79
|
+
)
|
|
80
|
+
skills_path = plugin_dir / skills
|
|
81
|
+
if not skills_path.is_dir():
|
|
82
|
+
return CheckResult(
|
|
83
|
+
name="SKILL.md frontmatter",
|
|
84
|
+
passed=True,
|
|
85
|
+
points=5,
|
|
86
|
+
max_points=5,
|
|
87
|
+
message="Skills directory not found, skipping check",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
skill_files = list(skills_path.glob("*/SKILL.md"))
|
|
91
|
+
if not skill_files:
|
|
92
|
+
return CheckResult(
|
|
93
|
+
name="SKILL.md frontmatter",
|
|
94
|
+
passed=True,
|
|
95
|
+
points=5,
|
|
96
|
+
max_points=5,
|
|
97
|
+
message="No SKILL.md files found, nothing to check",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
issues: list[str] = []
|
|
101
|
+
for sf in skill_files:
|
|
102
|
+
try:
|
|
103
|
+
content = sf.read_text(encoding="utf-8")
|
|
104
|
+
except OSError: # pragma: no cover
|
|
105
|
+
continue
|
|
106
|
+
if "---" not in content:
|
|
107
|
+
issues.append(str(sf.relative_to(plugin_dir)))
|
|
108
|
+
continue
|
|
109
|
+
parts = content.split("---", 2)
|
|
110
|
+
if len(parts) < 3:
|
|
111
|
+
issues.append(str(sf.relative_to(plugin_dir)))
|
|
112
|
+
continue
|
|
113
|
+
fm = parts[1]
|
|
114
|
+
if "name:" not in fm or "description:" not in fm:
|
|
115
|
+
issues.append(str(sf.relative_to(plugin_dir)))
|
|
116
|
+
|
|
117
|
+
if not issues:
|
|
118
|
+
return CheckResult(
|
|
119
|
+
name="SKILL.md frontmatter",
|
|
120
|
+
passed=True,
|
|
121
|
+
points=5,
|
|
122
|
+
max_points=5,
|
|
123
|
+
message="All SKILL.md files have valid frontmatter",
|
|
124
|
+
)
|
|
125
|
+
return CheckResult(
|
|
126
|
+
name="SKILL.md frontmatter",
|
|
127
|
+
passed=False,
|
|
128
|
+
points=0,
|
|
129
|
+
max_points=5,
|
|
130
|
+
message=f"SKILL.md missing valid frontmatter in: {', '.join(issues)}",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_no_env_files(plugin_dir: Path) -> CheckResult:
|
|
135
|
+
found = [f for f in ENV_FILES if (plugin_dir / f).exists()]
|
|
136
|
+
if not found:
|
|
137
|
+
return CheckResult(
|
|
138
|
+
name="No .env files committed",
|
|
139
|
+
passed=True,
|
|
140
|
+
points=5,
|
|
141
|
+
max_points=5,
|
|
142
|
+
message="No .env files found",
|
|
143
|
+
)
|
|
144
|
+
return CheckResult(
|
|
145
|
+
name="No .env files committed",
|
|
146
|
+
passed=False,
|
|
147
|
+
points=0,
|
|
148
|
+
max_points=5,
|
|
149
|
+
message=f".env files found: {', '.join(found)}",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def check_codexignore(plugin_dir: Path) -> CheckResult:
|
|
154
|
+
exists = (plugin_dir / ".codexignore").exists()
|
|
155
|
+
return CheckResult(
|
|
156
|
+
name=".codexignore found",
|
|
157
|
+
passed=exists,
|
|
158
|
+
points=5 if exists else 0,
|
|
159
|
+
max_points=5,
|
|
160
|
+
message=".codexignore found" if exists else ".codexignore not found",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_best_practice_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
|
|
165
|
+
return (
|
|
166
|
+
check_readme(plugin_dir),
|
|
167
|
+
check_skills_directory(plugin_dir),
|
|
168
|
+
check_skill_frontmatter(plugin_dir),
|
|
169
|
+
check_no_env_files(plugin_dir),
|
|
170
|
+
check_codexignore(plugin_dir),
|
|
171
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Code quality checks (10 points)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..models import CheckResult
|
|
9
|
+
|
|
10
|
+
CODE_EXTS = {".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"}
|
|
11
|
+
EXCLUDED_DIRS = {"node_modules", ".git", "dist", ".next", "coverage", "__pycache__", ".venv", "venv"}
|
|
12
|
+
|
|
13
|
+
EVAL_RE = re.compile(r"\beval\s*\(")
|
|
14
|
+
FUNCTION_RE = re.compile(r"new\s+Function\s*\(")
|
|
15
|
+
SHELL_INJECT_RE = re.compile(
|
|
16
|
+
r"`[^`]*\$\{[^}]+\}[^`]*`"
|
|
17
|
+
r"[\s\S]{0,30}"
|
|
18
|
+
r"\b(exec|spawn|execSync|spawnSync|os\.system|subprocess)\b"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_code_files(plugin_dir: Path) -> list[Path]:
|
|
23
|
+
files = []
|
|
24
|
+
for p in plugin_dir.rglob("*"):
|
|
25
|
+
if not p.is_file() or p.suffix not in CODE_EXTS:
|
|
26
|
+
continue
|
|
27
|
+
if any(part in EXCLUDED_DIRS for part in p.parts):
|
|
28
|
+
continue
|
|
29
|
+
files.append(p)
|
|
30
|
+
return files
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_no_eval(plugin_dir: Path) -> CheckResult:
|
|
34
|
+
findings: list[str] = []
|
|
35
|
+
for fpath in _find_code_files(plugin_dir):
|
|
36
|
+
try:
|
|
37
|
+
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
38
|
+
except OSError:
|
|
39
|
+
continue
|
|
40
|
+
if EVAL_RE.search(content):
|
|
41
|
+
findings.append(f"{fpath.relative_to(plugin_dir)}: eval()")
|
|
42
|
+
if FUNCTION_RE.search(content):
|
|
43
|
+
findings.append(f"{fpath.relative_to(plugin_dir)}: new Function()")
|
|
44
|
+
if not findings:
|
|
45
|
+
return CheckResult(
|
|
46
|
+
name="No eval or Function constructor",
|
|
47
|
+
passed=True,
|
|
48
|
+
points=5,
|
|
49
|
+
max_points=5,
|
|
50
|
+
message="No eval() or new Function() usage detected",
|
|
51
|
+
)
|
|
52
|
+
return CheckResult(
|
|
53
|
+
name="No eval or Function constructor",
|
|
54
|
+
passed=False,
|
|
55
|
+
points=0,
|
|
56
|
+
max_points=5,
|
|
57
|
+
message=f"Found: {', '.join(findings[:3])}",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_no_shell_injection(plugin_dir: Path) -> CheckResult:
|
|
62
|
+
findings: list[str] = []
|
|
63
|
+
for fpath in _find_code_files(plugin_dir):
|
|
64
|
+
try:
|
|
65
|
+
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
if SHELL_INJECT_RE.search(content):
|
|
69
|
+
findings.append(str(fpath.relative_to(plugin_dir)))
|
|
70
|
+
if not findings:
|
|
71
|
+
return CheckResult(
|
|
72
|
+
name="No shell injection patterns",
|
|
73
|
+
passed=True,
|
|
74
|
+
points=5,
|
|
75
|
+
max_points=5,
|
|
76
|
+
message="No shell injection patterns detected",
|
|
77
|
+
)
|
|
78
|
+
return CheckResult(
|
|
79
|
+
name="No shell injection patterns",
|
|
80
|
+
passed=False,
|
|
81
|
+
points=0,
|
|
82
|
+
max_points=5,
|
|
83
|
+
message=f"Shell injection patterns in: {', '.join(findings)}",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_code_quality_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
|
|
88
|
+
return (
|
|
89
|
+
check_no_eval(plugin_dir),
|
|
90
|
+
check_no_shell_injection(plugin_dir),
|
|
91
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Manifest validation checks (25 points)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..models import CheckResult
|
|
10
|
+
|
|
11
|
+
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+")
|
|
12
|
+
KEBAB_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_manifest(plugin_dir: Path) -> dict | None:
|
|
16
|
+
p = plugin_dir / ".codex-plugin" / "plugin.json"
|
|
17
|
+
if not p.exists():
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
21
|
+
except (json.JSONDecodeError, OSError):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_plugin_json_exists(plugin_dir: Path) -> CheckResult:
|
|
26
|
+
p = plugin_dir / ".codex-plugin" / "plugin.json"
|
|
27
|
+
exists = p.exists()
|
|
28
|
+
return CheckResult(
|
|
29
|
+
name="plugin.json exists",
|
|
30
|
+
passed=exists,
|
|
31
|
+
points=5 if exists else 0,
|
|
32
|
+
max_points=5,
|
|
33
|
+
message="plugin.json found" if exists else "plugin.json not found at .codex-plugin/plugin.json",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_valid_json(plugin_dir: Path) -> CheckResult:
|
|
38
|
+
p = plugin_dir / ".codex-plugin" / "plugin.json"
|
|
39
|
+
try:
|
|
40
|
+
content = p.read_text(encoding="utf-8")
|
|
41
|
+
json.loads(content)
|
|
42
|
+
return CheckResult(name="Valid JSON", passed=True, points=5, max_points=5, message="plugin.json is valid JSON")
|
|
43
|
+
except Exception:
|
|
44
|
+
return CheckResult(
|
|
45
|
+
name="Valid JSON", passed=False, points=0, max_points=5, message="plugin.json is not valid JSON"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_required_fields(plugin_dir: Path) -> CheckResult:
|
|
50
|
+
manifest = load_manifest(plugin_dir)
|
|
51
|
+
if manifest is None:
|
|
52
|
+
return CheckResult(
|
|
53
|
+
name="Required fields present", passed=False, points=0, max_points=8, message="Cannot parse plugin.json"
|
|
54
|
+
)
|
|
55
|
+
required = ["name", "version", "description"]
|
|
56
|
+
missing = [f for f in required if not manifest.get(f) or not isinstance(manifest.get(f), str)]
|
|
57
|
+
if not missing:
|
|
58
|
+
return CheckResult(
|
|
59
|
+
name="Required fields present",
|
|
60
|
+
passed=True,
|
|
61
|
+
points=8,
|
|
62
|
+
max_points=8,
|
|
63
|
+
message="All required fields (name, version, description) present",
|
|
64
|
+
)
|
|
65
|
+
return CheckResult(
|
|
66
|
+
name="Required fields present",
|
|
67
|
+
passed=False,
|
|
68
|
+
points=0,
|
|
69
|
+
max_points=8,
|
|
70
|
+
message=f"Missing required fields: {', '.join(missing)}",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_semver(plugin_dir: Path) -> CheckResult:
|
|
75
|
+
manifest = load_manifest(plugin_dir)
|
|
76
|
+
if manifest is None:
|
|
77
|
+
return CheckResult(
|
|
78
|
+
name="Version follows semver", passed=False, points=0, max_points=4, message="Cannot parse plugin.json"
|
|
79
|
+
)
|
|
80
|
+
version = manifest.get("version", "")
|
|
81
|
+
if version and SEMVER_RE.match(str(version)):
|
|
82
|
+
return CheckResult(
|
|
83
|
+
name="Version follows semver",
|
|
84
|
+
passed=True,
|
|
85
|
+
points=4,
|
|
86
|
+
max_points=4,
|
|
87
|
+
message=f'Version "{version}" follows semver',
|
|
88
|
+
)
|
|
89
|
+
return CheckResult(
|
|
90
|
+
name="Version follows semver",
|
|
91
|
+
passed=False,
|
|
92
|
+
points=0,
|
|
93
|
+
max_points=4,
|
|
94
|
+
message=f'Version "{version}" does not follow semver (expected X.Y.Z)',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_kebab_case(plugin_dir: Path) -> CheckResult:
|
|
99
|
+
manifest = load_manifest(plugin_dir)
|
|
100
|
+
if manifest is None:
|
|
101
|
+
return CheckResult(
|
|
102
|
+
name="Name is kebab-case", passed=False, points=0, max_points=3, message="Cannot parse plugin.json"
|
|
103
|
+
)
|
|
104
|
+
name = manifest.get("name", "")
|
|
105
|
+
if name and KEBAB_RE.match(str(name)):
|
|
106
|
+
return CheckResult(
|
|
107
|
+
name="Name is kebab-case", passed=True, points=3, max_points=3, message=f'Name "{name}" is kebab-case'
|
|
108
|
+
)
|
|
109
|
+
return CheckResult(
|
|
110
|
+
name="Name is kebab-case", passed=False, points=0, max_points=3, message=f'Name "{name}" should be kebab-case'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_manifest_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
|
|
115
|
+
return (
|
|
116
|
+
check_plugin_json_exists(plugin_dir),
|
|
117
|
+
check_valid_json(plugin_dir),
|
|
118
|
+
check_required_fields(plugin_dir),
|
|
119
|
+
check_semver(plugin_dir),
|
|
120
|
+
check_kebab_case(plugin_dir),
|
|
121
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Marketplace validation checks (10 points)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..models import CheckResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_marketplace_json(plugin_dir: Path) -> CheckResult:
|
|
12
|
+
mp = plugin_dir / "marketplace.json"
|
|
13
|
+
if not mp.exists():
|
|
14
|
+
return CheckResult(
|
|
15
|
+
name="marketplace.json valid",
|
|
16
|
+
passed=True,
|
|
17
|
+
points=5,
|
|
18
|
+
max_points=5,
|
|
19
|
+
message="No marketplace.json found, check not applicable",
|
|
20
|
+
)
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(mp.read_text(encoding="utf-8"))
|
|
23
|
+
except json.JSONDecodeError:
|
|
24
|
+
return CheckResult(
|
|
25
|
+
name="marketplace.json valid",
|
|
26
|
+
passed=False,
|
|
27
|
+
points=0,
|
|
28
|
+
max_points=5,
|
|
29
|
+
message="marketplace.json is not valid JSON",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if not data.get("name") or not isinstance(data.get("name"), str):
|
|
33
|
+
return CheckResult(
|
|
34
|
+
name="marketplace.json valid",
|
|
35
|
+
passed=False,
|
|
36
|
+
points=0,
|
|
37
|
+
max_points=5,
|
|
38
|
+
message='marketplace.json missing "name" field',
|
|
39
|
+
)
|
|
40
|
+
if not isinstance(data.get("plugins"), list):
|
|
41
|
+
return CheckResult(
|
|
42
|
+
name="marketplace.json valid",
|
|
43
|
+
passed=False,
|
|
44
|
+
points=0,
|
|
45
|
+
max_points=5,
|
|
46
|
+
message='marketplace.json missing "plugins" array',
|
|
47
|
+
)
|
|
48
|
+
for i, plugin in enumerate(data["plugins"]):
|
|
49
|
+
if not plugin.get("source") or not isinstance(plugin.get("source"), str):
|
|
50
|
+
return CheckResult(
|
|
51
|
+
name="marketplace.json valid",
|
|
52
|
+
passed=False,
|
|
53
|
+
points=0,
|
|
54
|
+
max_points=5,
|
|
55
|
+
message=f'marketplace.json plugin[{i}] missing "source" field',
|
|
56
|
+
)
|
|
57
|
+
if not plugin.get("policy") or not isinstance(plugin.get("policy"), dict):
|
|
58
|
+
return CheckResult(
|
|
59
|
+
name="marketplace.json valid",
|
|
60
|
+
passed=False,
|
|
61
|
+
points=0,
|
|
62
|
+
max_points=5,
|
|
63
|
+
message=f'marketplace.json plugin[{i}] missing "policy" field',
|
|
64
|
+
)
|
|
65
|
+
return CheckResult(
|
|
66
|
+
name="marketplace.json valid", passed=True, points=5, max_points=5, message="marketplace.json is valid"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_policy_fields(plugin_dir: Path) -> CheckResult:
|
|
71
|
+
mp = plugin_dir / "marketplace.json"
|
|
72
|
+
if not mp.exists():
|
|
73
|
+
return CheckResult(
|
|
74
|
+
name="Policy fields present",
|
|
75
|
+
passed=True,
|
|
76
|
+
points=5,
|
|
77
|
+
max_points=5,
|
|
78
|
+
message="No marketplace.json found, check not applicable",
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(mp.read_text(encoding="utf-8"))
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return CheckResult(
|
|
84
|
+
name="Policy fields present",
|
|
85
|
+
passed=True,
|
|
86
|
+
points=5,
|
|
87
|
+
max_points=5,
|
|
88
|
+
message="Cannot parse marketplace.json, skipping check",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
plugins = data.get("plugins", [])
|
|
92
|
+
if not plugins:
|
|
93
|
+
return CheckResult(
|
|
94
|
+
name="Policy fields present",
|
|
95
|
+
passed=True,
|
|
96
|
+
points=5,
|
|
97
|
+
max_points=5,
|
|
98
|
+
message="No plugins in marketplace.json, nothing to check",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
issues: list[str] = []
|
|
102
|
+
for i, plugin in enumerate(plugins):
|
|
103
|
+
policy = plugin.get("policy") or {}
|
|
104
|
+
if not policy.get("installation"):
|
|
105
|
+
issues.append(f"plugin[{i}]: missing policy.installation")
|
|
106
|
+
if not policy.get("authentication"):
|
|
107
|
+
issues.append(f"plugin[{i}]: missing policy.authentication")
|
|
108
|
+
|
|
109
|
+
if not issues:
|
|
110
|
+
return CheckResult(
|
|
111
|
+
name="Policy fields present",
|
|
112
|
+
passed=True,
|
|
113
|
+
points=5,
|
|
114
|
+
max_points=5,
|
|
115
|
+
message="All plugins have required policy fields",
|
|
116
|
+
)
|
|
117
|
+
return CheckResult(
|
|
118
|
+
name="Policy fields present",
|
|
119
|
+
passed=False,
|
|
120
|
+
points=0,
|
|
121
|
+
max_points=5,
|
|
122
|
+
message=f"Policy issues: {', '.join(issues[:3])}",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_marketplace_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
|
|
127
|
+
return (
|
|
128
|
+
check_marketplace_json(plugin_dir),
|
|
129
|
+
check_policy_fields(plugin_dir),
|
|
130
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Security checks (30 points)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..models import CheckResult
|
|
9
|
+
|
|
10
|
+
# Patterns for hardcoded secrets
|
|
11
|
+
SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
12
|
+
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key
|
|
13
|
+
re.compile(r"aws_secret_access_key\s*[=:]\s*[\"']?[A-Za-z0-9/+=]{40}", re.I),
|
|
14
|
+
re.compile(r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
|
|
15
|
+
re.compile(r"password\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
|
|
16
|
+
re.compile(r"secret\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
|
|
17
|
+
re.compile(r"token\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
|
|
18
|
+
re.compile(r"api_?key\s*[=:]\s*[\"'][^\s\"']{8,}", re.I),
|
|
19
|
+
re.compile(r"API_KEY\s*[=:]\s*[\"'][^\s\"']{8,}"),
|
|
20
|
+
re.compile(r"PRIVATE_KEY\s*[=:]\s*[\"'][^\s\"']{8,}"),
|
|
21
|
+
re.compile(r"ghp_[A-Za-z0-9]{36}"), # GitHub PAT
|
|
22
|
+
re.compile(r"gho_[A-Za-z0-9]{36}"), # GitHub OAuth
|
|
23
|
+
re.compile(r"ghu_[A-Za-z0-9]{36}"), # GitHub user token
|
|
24
|
+
re.compile(r"ghs_[A-Za-z0-9]{36}"), # GitHub app token
|
|
25
|
+
re.compile(r"glpat-[A-Za-z0-9\-]{20}"), # GitLab PAT
|
|
26
|
+
re.compile(r"xox[bpas]-[A-Za-z0-9\-]{10,}"), # Slack tokens
|
|
27
|
+
re.compile(r"sk-[A-Za-z0-9]{48}"), # OpenAI key
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
EXCLUDED_DIRS = {"node_modules", ".git", "dist", ".next", "coverage", ".turbo", "__pycache__", ".venv", "venv"}
|
|
31
|
+
|
|
32
|
+
BINARY_EXTS = {
|
|
33
|
+
".png",
|
|
34
|
+
".jpg",
|
|
35
|
+
".jpeg",
|
|
36
|
+
".gif",
|
|
37
|
+
".ico",
|
|
38
|
+
".svg",
|
|
39
|
+
".webp",
|
|
40
|
+
".woff",
|
|
41
|
+
".woff2",
|
|
42
|
+
".ttf",
|
|
43
|
+
".eot",
|
|
44
|
+
".otf",
|
|
45
|
+
".zip",
|
|
46
|
+
".tar",
|
|
47
|
+
".gz",
|
|
48
|
+
".7z",
|
|
49
|
+
".rar",
|
|
50
|
+
".lock",
|
|
51
|
+
".wasm",
|
|
52
|
+
".pyc",
|
|
53
|
+
".so",
|
|
54
|
+
".dylib",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
DANGEROUS_MCP_PATTERNS: list[re.Pattern[str]] = [
|
|
58
|
+
re.compile(r"rm\s+-rf"),
|
|
59
|
+
re.compile(r"\bsudo\b"),
|
|
60
|
+
re.compile(r"curl\b.*\|\s*(ba)?sh"),
|
|
61
|
+
re.compile(r"wget\b.*\|\s*(ba)?sh"),
|
|
62
|
+
re.compile(r"bash\s+-c"),
|
|
63
|
+
re.compile(r"\beval\b"),
|
|
64
|
+
re.compile(r"\bexec\b"),
|
|
65
|
+
re.compile(r"powershell\s+-c", re.I),
|
|
66
|
+
re.compile(r"cmd\s*/c", re.I),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _scan_all_files(plugin_dir: Path) -> list[Path]:
|
|
71
|
+
"""Recursively find all files, skipping excluded dirs."""
|
|
72
|
+
files = []
|
|
73
|
+
for p in plugin_dir.rglob("*"):
|
|
74
|
+
if not p.is_file():
|
|
75
|
+
continue
|
|
76
|
+
if any(part in EXCLUDED_DIRS for part in p.parts):
|
|
77
|
+
continue
|
|
78
|
+
if p.suffix.lower() in BINARY_EXTS:
|
|
79
|
+
continue
|
|
80
|
+
files.append(p)
|
|
81
|
+
return files
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_security_md(plugin_dir: Path) -> CheckResult:
|
|
85
|
+
exists = (plugin_dir / "SECURITY.md").exists()
|
|
86
|
+
return CheckResult(
|
|
87
|
+
name="SECURITY.md found",
|
|
88
|
+
passed=exists,
|
|
89
|
+
points=5 if exists else 0,
|
|
90
|
+
max_points=5,
|
|
91
|
+
message="SECURITY.md found" if exists else "SECURITY.md not found",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_license(plugin_dir: Path) -> CheckResult:
|
|
96
|
+
lp = plugin_dir / "LICENSE"
|
|
97
|
+
if not lp.exists():
|
|
98
|
+
return CheckResult(name="LICENSE found", passed=False, points=0, max_points=5, message="LICENSE file not found")
|
|
99
|
+
try:
|
|
100
|
+
content = lp.read_text(encoding="utf-8", errors="ignore")
|
|
101
|
+
if "Apache" in content and ("2.0" in content or "www.apache.org" in content):
|
|
102
|
+
return CheckResult(
|
|
103
|
+
name="LICENSE found", passed=True, points=5, max_points=5, message="LICENSE found (Apache-2.0)"
|
|
104
|
+
)
|
|
105
|
+
if "MIT" in content and "Permission is hereby granted" in content:
|
|
106
|
+
return CheckResult(name="LICENSE found", passed=True, points=5, max_points=5, message="LICENSE found (MIT)")
|
|
107
|
+
return CheckResult(
|
|
108
|
+
name="LICENSE found", passed=True, points=5, max_points=5, message="LICENSE found (not Apache-2.0 or MIT)"
|
|
109
|
+
)
|
|
110
|
+
except OSError:
|
|
111
|
+
return CheckResult(
|
|
112
|
+
name="LICENSE found", passed=False, points=0, max_points=5, message="LICENSE exists but could not be read"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check_no_hardcoded_secrets(plugin_dir: Path) -> CheckResult:
|
|
117
|
+
findings: list[str] = []
|
|
118
|
+
for fpath in _scan_all_files(plugin_dir):
|
|
119
|
+
try:
|
|
120
|
+
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
121
|
+
except OSError:
|
|
122
|
+
continue
|
|
123
|
+
for pattern in SECRET_PATTERNS:
|
|
124
|
+
if pattern.search(content):
|
|
125
|
+
findings.append(str(fpath.relative_to(plugin_dir)))
|
|
126
|
+
break
|
|
127
|
+
if not findings:
|
|
128
|
+
return CheckResult(
|
|
129
|
+
name="No hardcoded secrets", passed=True, points=10, max_points=10, message="No hardcoded secrets detected"
|
|
130
|
+
)
|
|
131
|
+
shown = findings[:5]
|
|
132
|
+
suffix = f" and {len(findings) - 5} more" if len(findings) > 5 else ""
|
|
133
|
+
return CheckResult(
|
|
134
|
+
name="No hardcoded secrets",
|
|
135
|
+
passed=False,
|
|
136
|
+
points=0,
|
|
137
|
+
max_points=10,
|
|
138
|
+
message=f"Hardcoded secrets found in: {', '.join(shown)}{suffix}",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_no_dangerous_mcp(plugin_dir: Path) -> CheckResult:
|
|
143
|
+
mcp_path = plugin_dir / ".mcp.json"
|
|
144
|
+
if not mcp_path.exists():
|
|
145
|
+
return CheckResult(
|
|
146
|
+
name="No dangerous MCP commands",
|
|
147
|
+
passed=True,
|
|
148
|
+
points=10,
|
|
149
|
+
max_points=10,
|
|
150
|
+
message="No .mcp.json found, skipping check",
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
content = mcp_path.read_text(encoding="utf-8")
|
|
154
|
+
except OSError:
|
|
155
|
+
return CheckResult(
|
|
156
|
+
name="No dangerous MCP commands", passed=True, points=10, max_points=10, message="Could not read .mcp.json"
|
|
157
|
+
)
|
|
158
|
+
found: list[str] = []
|
|
159
|
+
for pattern in DANGEROUS_MCP_PATTERNS:
|
|
160
|
+
if pattern.search(content):
|
|
161
|
+
found.append(pattern.pattern)
|
|
162
|
+
if not found:
|
|
163
|
+
return CheckResult(
|
|
164
|
+
name="No dangerous MCP commands",
|
|
165
|
+
passed=True,
|
|
166
|
+
points=10,
|
|
167
|
+
max_points=10,
|
|
168
|
+
message="No dangerous commands found in .mcp.json",
|
|
169
|
+
)
|
|
170
|
+
return CheckResult(
|
|
171
|
+
name="No dangerous MCP commands",
|
|
172
|
+
passed=False,
|
|
173
|
+
points=0,
|
|
174
|
+
max_points=10,
|
|
175
|
+
message=f"Dangerous patterns in .mcp.json: {', '.join(found)}",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def run_security_checks(plugin_dir: Path) -> tuple[CheckResult, ...]:
|
|
180
|
+
return (
|
|
181
|
+
check_security_md(plugin_dir),
|
|
182
|
+
check_license(plugin_dir),
|
|
183
|
+
check_no_hardcoded_secrets(plugin_dir),
|
|
184
|
+
check_no_dangerous_mcp(plugin_dir),
|
|
185
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Codex Plugin Scanner - CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .models import GRADE_LABELS
|
|
11
|
+
from .scanner import scan_plugin
|
|
12
|
+
|
|
13
|
+
__version__ = "1.0.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_plain_text(result) -> str:
|
|
17
|
+
"""Build plain text output from a scan result."""
|
|
18
|
+
lines = [f"🔗 Codex Plugin Scanner v{__version__}", f"Scanning: {result.plugin_dir}", ""]
|
|
19
|
+
for category in result.categories:
|
|
20
|
+
cat_score = sum(c.points for c in category.checks)
|
|
21
|
+
cat_max = sum(c.max_points for c in category.checks)
|
|
22
|
+
lines.append(f"── {category.name} ({cat_score}/{cat_max}) ──")
|
|
23
|
+
for check in category.checks:
|
|
24
|
+
icon = "✅" if check.passed else "⚠️"
|
|
25
|
+
pts = f"+{check.points}" if check.passed else "+0"
|
|
26
|
+
lines.append(f" {icon} {check.name:<42} {pts}")
|
|
27
|
+
lines.append("")
|
|
28
|
+
separator = "━" * 37
|
|
29
|
+
label = GRADE_LABELS.get(result.grade, "Unknown")
|
|
30
|
+
lines += [separator, f"Final Score: {result.score}/100 ({result.grade} - {label})", separator]
|
|
31
|
+
return "\n".join(lines)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_rich_text(result) -> str:
|
|
35
|
+
"""Build rich markup text from a scan result."""
|
|
36
|
+
lines = [f"[bold cyan]🔗 Codex Plugin Scanner v{__version__}[/bold cyan]"]
|
|
37
|
+
lines.append(f"Scanning: {result.plugin_dir}")
|
|
38
|
+
lines.append("")
|
|
39
|
+
for category in result.categories:
|
|
40
|
+
cat_score = sum(c.points for c in category.checks)
|
|
41
|
+
cat_max = sum(c.max_points for c in category.checks)
|
|
42
|
+
lines.append(f"[bold yellow]── {category.name} ({cat_score}/{cat_max}) ──[/bold yellow]")
|
|
43
|
+
for check in category.checks:
|
|
44
|
+
icon = "✅" if check.passed else "⚠️"
|
|
45
|
+
style = "[green]" if check.passed else "[red]"
|
|
46
|
+
pts = f"[green]+{check.points}[/green]" if check.passed else "[red]+0[/red]"
|
|
47
|
+
lines.append(f" {icon} {style}{check.name:<42}[/]{pts}")
|
|
48
|
+
lines.append("")
|
|
49
|
+
separator = "━" * 37
|
|
50
|
+
grade = result.grade
|
|
51
|
+
gc = {"A": "bold green", "B": "green", "C": "yellow", "D": "red", "F": "bold red"}.get(grade, "red")
|
|
52
|
+
label = GRADE_LABELS.get(grade, "Unknown")
|
|
53
|
+
lines += [
|
|
54
|
+
f"[bold]{separator}[/bold]",
|
|
55
|
+
f"Final Score: [bold]{result.score}[/bold]/100 ([{gc}]{grade} - {label}[/{gc}])",
|
|
56
|
+
f"[bold]{separator}[/bold]",
|
|
57
|
+
]
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_text(result) -> str:
|
|
62
|
+
"""Format scan result as terminal output. Returns plain text string."""
|
|
63
|
+
plain = _build_plain_text(result)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from rich.console import Console
|
|
67
|
+
|
|
68
|
+
console = Console()
|
|
69
|
+
console.print(_build_rich_text(result))
|
|
70
|
+
except ImportError:
|
|
71
|
+
print(plain)
|
|
72
|
+
|
|
73
|
+
return plain
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_json(result) -> str:
|
|
77
|
+
"""Format scan result as JSON."""
|
|
78
|
+
data = {
|
|
79
|
+
"score": result.score,
|
|
80
|
+
"grade": result.grade,
|
|
81
|
+
"categories": [
|
|
82
|
+
{
|
|
83
|
+
"name": cat.name,
|
|
84
|
+
"score": sum(c.points for c in cat.checks),
|
|
85
|
+
"max": sum(c.max_points for c in cat.checks),
|
|
86
|
+
"checks": [
|
|
87
|
+
{
|
|
88
|
+
"name": c.name,
|
|
89
|
+
"passed": c.passed,
|
|
90
|
+
"points": c.points,
|
|
91
|
+
"maxPoints": c.max_points,
|
|
92
|
+
"message": c.message,
|
|
93
|
+
}
|
|
94
|
+
for c in cat.checks
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
for cat in result.categories
|
|
98
|
+
],
|
|
99
|
+
"timestamp": result.timestamp,
|
|
100
|
+
"pluginDir": result.plugin_dir,
|
|
101
|
+
}
|
|
102
|
+
return json.dumps(data, indent=2)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: list[str] | None = None) -> int:
|
|
106
|
+
"""Run the CLI. Returns exit code."""
|
|
107
|
+
parser = argparse.ArgumentParser(
|
|
108
|
+
prog="codex-plugin-scanner",
|
|
109
|
+
description="Scan a Codex plugin directory for best practices and security",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument("plugin_dir", help="Path to the plugin directory to scan")
|
|
112
|
+
parser.add_argument("--json", action="store_true", help="Output results as JSON")
|
|
113
|
+
parser.add_argument("--output", "-o", help="Write JSON report to file")
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--min-score",
|
|
116
|
+
type=int,
|
|
117
|
+
default=0,
|
|
118
|
+
help="Exit with code 1 if score is below this threshold (default: 0)",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
resolved = Path(args.plugin_dir).resolve()
|
|
124
|
+
if not resolved.is_dir():
|
|
125
|
+
print(f'Error: "{resolved}" is not a directory.', file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
result = scan_plugin(args.plugin_dir)
|
|
129
|
+
|
|
130
|
+
if args.json or args.output:
|
|
131
|
+
output = format_json(result)
|
|
132
|
+
if args.output:
|
|
133
|
+
out_path = Path(args.output)
|
|
134
|
+
out_path.write_text(output, encoding="utf-8")
|
|
135
|
+
print(f"Report written to {out_path}")
|
|
136
|
+
else:
|
|
137
|
+
print(output)
|
|
138
|
+
else:
|
|
139
|
+
text = format_text(result)
|
|
140
|
+
if text:
|
|
141
|
+
print(text)
|
|
142
|
+
|
|
143
|
+
if result.score < args.min_score:
|
|
144
|
+
print(
|
|
145
|
+
f"Score {result.score} is below minimum threshold {args.min_score}",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
raise SystemExit(main()) # pragma: no cover
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Codex Plugin Scanner - types and data classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class CheckResult:
|
|
10
|
+
"""Result of an individual check."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
passed: bool
|
|
14
|
+
points: int
|
|
15
|
+
max_points: int
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class CategoryResult:
|
|
21
|
+
"""A category containing multiple checks."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
checks: tuple[CheckResult, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ScanResult:
|
|
29
|
+
"""Full result of scanning a plugin directory."""
|
|
30
|
+
|
|
31
|
+
score: int
|
|
32
|
+
grade: str
|
|
33
|
+
categories: tuple[CategoryResult, ...]
|
|
34
|
+
timestamp: str
|
|
35
|
+
plugin_dir: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_grade(score: int) -> str:
|
|
39
|
+
"""Convert a numeric score to a letter grade."""
|
|
40
|
+
if score >= 90:
|
|
41
|
+
return "A"
|
|
42
|
+
if score >= 80:
|
|
43
|
+
return "B"
|
|
44
|
+
if score >= 70:
|
|
45
|
+
return "C"
|
|
46
|
+
if score >= 60:
|
|
47
|
+
return "D"
|
|
48
|
+
return "F"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
GRADE_LABELS: dict[str, str] = {
|
|
52
|
+
"A": "Excellent",
|
|
53
|
+
"B": "Good",
|
|
54
|
+
"C": "Acceptable",
|
|
55
|
+
"D": "Needs Improvement",
|
|
56
|
+
"F": "Failing",
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Codex Plugin Scanner - core scanning engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .checks.best_practices import run_best_practice_checks
|
|
9
|
+
from .checks.code_quality import run_code_quality_checks
|
|
10
|
+
from .checks.manifest import run_manifest_checks
|
|
11
|
+
from .checks.marketplace import run_marketplace_checks
|
|
12
|
+
from .checks.security import run_security_checks
|
|
13
|
+
from .models import CategoryResult, ScanResult, get_grade
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def scan_plugin(plugin_dir: str | Path) -> ScanResult:
|
|
17
|
+
"""Scan a Codex plugin directory and return a scored result.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
plugin_dir: Path to the plugin directory to scan.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
ScanResult with score 0-100, grade A-F, and per-category breakdowns.
|
|
24
|
+
"""
|
|
25
|
+
resolved = Path(plugin_dir).resolve()
|
|
26
|
+
|
|
27
|
+
categories: list[CategoryResult] = [
|
|
28
|
+
CategoryResult(name="Manifest Validation", checks=run_manifest_checks(resolved)),
|
|
29
|
+
CategoryResult(name="Security", checks=run_security_checks(resolved)),
|
|
30
|
+
CategoryResult(name="Best Practices", checks=run_best_practice_checks(resolved)),
|
|
31
|
+
CategoryResult(name="Marketplace", checks=run_marketplace_checks(resolved)),
|
|
32
|
+
CategoryResult(name="Code Quality", checks=run_code_quality_checks(resolved)),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
score = sum(c.points for cat in categories for c in cat.checks)
|
|
36
|
+
grade = get_grade(score)
|
|
37
|
+
|
|
38
|
+
return ScanResult(
|
|
39
|
+
score=score,
|
|
40
|
+
grade=grade,
|
|
41
|
+
categories=tuple(categories),
|
|
42
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
43
|
+
plugin_dir=str(resolved),
|
|
44
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-plugin-scanner
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Security and best-practices scanner for Codex CLI plugins. Scores plugins 0-100.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hashgraph-online/codex-plugin-scanner
|
|
6
|
+
Project-URL: Repository, https://github.com/hashgraph-online/codex-plugin-scanner
|
|
7
|
+
Project-URL: Issues, https://github.com/hashgraph-online/codex-plugin-scanner/issues
|
|
8
|
+
Author-email: Hashgraph Online <dev@hol.org>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,codex,mcp,plugin,scanner,security
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# 🔗 Codex Plugin Scanner
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/codex-plugin-scanner/)
|
|
34
|
+
[](https://pypi.org/project/codex-plugin-scanner/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
[](https://github.com/hashgraph-online/codex-plugin-scanner/actions/workflows/ci.yml)
|
|
37
|
+
[](https://scorecard.dev/viewer/?uri=github.com/hashgraph-online/codex-plugin-scanner)
|
|
38
|
+
|
|
39
|
+
A security and best-practices scanner for [Codex CLI plugins](https://developers.openai.com/codex/plugins). Scans plugin directories and outputs a score from 0-100.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install codex-plugin-scanner
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or run directly without installing:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pipx run codex-plugin-scanner ./my-plugin
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Scan a plugin directory
|
|
57
|
+
codex-plugin-scanner ./my-plugin
|
|
58
|
+
|
|
59
|
+
# Output as JSON
|
|
60
|
+
codex-plugin-scanner ./my-plugin --json
|
|
61
|
+
|
|
62
|
+
# Write report to file
|
|
63
|
+
codex-plugin-scanner ./my-plugin --output report.json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Example Output
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
🔗 Codex Plugin Scanner v1.0.0
|
|
70
|
+
Scanning: ./my-plugin
|
|
71
|
+
|
|
72
|
+
── Manifest Validation (25/25) ──
|
|
73
|
+
✅ plugin.json exists +5
|
|
74
|
+
✅ Valid JSON +5
|
|
75
|
+
✅ Required fields present +8
|
|
76
|
+
✅ Version follows semver +4
|
|
77
|
+
✅ Name is kebab-case +3
|
|
78
|
+
|
|
79
|
+
── Security (30/30) ──
|
|
80
|
+
✅ SECURITY.md found +5
|
|
81
|
+
✅ LICENSE found (Apache-2.0) +5
|
|
82
|
+
✅ No hardcoded secrets detected +10
|
|
83
|
+
✅ No dangerous MCP commands +10
|
|
84
|
+
|
|
85
|
+
── Best Practices (25/25) ──
|
|
86
|
+
✅ README.md found +5
|
|
87
|
+
✅ Skills directory exists if declared +5
|
|
88
|
+
✅ SKILL.md frontmatter +5
|
|
89
|
+
✅ No .env files committed +5
|
|
90
|
+
✅ .codexignore found +5
|
|
91
|
+
|
|
92
|
+
── Marketplace (10/10) ──
|
|
93
|
+
✅ marketplace.json valid +5
|
|
94
|
+
✅ Policy fields present +5
|
|
95
|
+
|
|
96
|
+
── Code Quality (10/10) ──
|
|
97
|
+
✅ No eval or Function constructor +5
|
|
98
|
+
✅ No shell injection patterns +5
|
|
99
|
+
|
|
100
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
101
|
+
Final Score: 100/100 (A - Excellent)
|
|
102
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Scoring Breakdown
|
|
106
|
+
|
|
107
|
+
| Category | Max Points | Checks |
|
|
108
|
+
|----------|-----------|--------|
|
|
109
|
+
| Manifest Validation | 25 | plugin.json exists, valid JSON, required fields, semver version, kebab-case name |
|
|
110
|
+
| Security | 30 | SECURITY.md, LICENSE, no hardcoded secrets, no dangerous MCP commands |
|
|
111
|
+
| Best Practices | 25 | README.md, skills directory, SKILL.md frontmatter, no .env files, .codexignore |
|
|
112
|
+
| Marketplace | 10 | marketplace.json valid, policy fields present |
|
|
113
|
+
| Code Quality | 10 | no eval/Function, no shell injection |
|
|
114
|
+
|
|
115
|
+
### Grade Scale
|
|
116
|
+
|
|
117
|
+
| Score | Grade | Meaning |
|
|
118
|
+
|-------|-------|---------|
|
|
119
|
+
| 90-100 | A | Excellent |
|
|
120
|
+
| 80-89 | B | Good |
|
|
121
|
+
| 70-79 | C | Acceptable |
|
|
122
|
+
| 60-69 | D | Needs Improvement |
|
|
123
|
+
| 0-59 | F | Failing |
|
|
124
|
+
|
|
125
|
+
## Security Checks
|
|
126
|
+
|
|
127
|
+
The scanner detects:
|
|
128
|
+
|
|
129
|
+
- **Hardcoded secrets**: AWS keys, GitHub tokens, OpenAI keys, Slack tokens, GitLab tokens, generic password/secret/token patterns
|
|
130
|
+
- **Dangerous MCP commands**: `rm -rf`, `sudo`, `curl|sh`, `wget|sh`, `eval`, `exec`, `powershell -c`
|
|
131
|
+
- **Shell injection**: template literals with unsanitized interpolation in exec/spawn calls
|
|
132
|
+
- **Unsafe code**: `eval()` and `new Function()` usage
|
|
133
|
+
|
|
134
|
+
## Use as a GitHub Action
|
|
135
|
+
|
|
136
|
+
Add to your plugin's CI:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
- name: Install scanner
|
|
140
|
+
run: pip install codex-plugin-scanner
|
|
141
|
+
- name: Scan plugin
|
|
142
|
+
run: codex-plugin-scanner ./my-plugin
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Use as a pre-commit hook
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
repos:
|
|
149
|
+
- repo: local
|
|
150
|
+
hooks:
|
|
151
|
+
- id: codex-plugin-scanner
|
|
152
|
+
name: Codex Plugin Scanner
|
|
153
|
+
entry: codex-plugin-scanner
|
|
154
|
+
language: system
|
|
155
|
+
types: [directory]
|
|
156
|
+
pass_filenames: false
|
|
157
|
+
args: ["./"]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pip install -e ".[dev]"
|
|
164
|
+
pytest
|
|
165
|
+
ruff check src/
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Contributing
|
|
169
|
+
|
|
170
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
[Apache-2.0](LICENSE) - Hashgraph Online
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
codex_plugin_scanner/__init__.py,sha256=i2n3XpdZTRz03Q8m-XMzEiVjTa8ztagE-KNOK2xHEtg,359
|
|
2
|
+
codex_plugin_scanner/cli.py,sha256=JnoliIK98ObwSCXEUQDb7N9SObTmuEzFPicpu_4T_1g,5268
|
|
3
|
+
codex_plugin_scanner/models.py,sha256=f9LB9Jfozt8nLThh2_zSNk1SJ7NO4TlfOTUDIvXdOYE,1099
|
|
4
|
+
codex_plugin_scanner/scanner.py,sha256=PwtA1G6MTDiKEoNB-5hXbagPY6jl1j2OVdBQMrYCsto,1593
|
|
5
|
+
codex_plugin_scanner/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
codex_plugin_scanner/checks/best_practices.py,sha256=krGlH0LKglIkeDpAppKkWNnJ0akCwIU6RlEO4_prb_E,5095
|
|
7
|
+
codex_plugin_scanner/checks/code_quality.py,sha256=HqxM5O0vF0cddZMKm-IxZc6hLe9aEr3-D9HxMZgFMoI,2790
|
|
8
|
+
codex_plugin_scanner/checks/manifest.py,sha256=XamGZHndQWXEtY5pu6pHzn65GGLqU6X3UoclRweTTtc,4032
|
|
9
|
+
codex_plugin_scanner/checks/marketplace.py,sha256=KM9dATcmbel_OlBaV3YTYvPJE76xZYftkQ-rOjDtX-Y,4157
|
|
10
|
+
codex_plugin_scanner/checks/security.py,sha256=odWrNV-IFP_LDwZeLP_vuuKhoiLhdQh3SAlF2evXEA8,6137
|
|
11
|
+
codex_plugin_scanner-1.0.0.dist-info/METADATA,sha256=ltdI7OiZsQh1UI9UCPMAcLUpKOtit-4Z6FV-rCG7a34,5931
|
|
12
|
+
codex_plugin_scanner-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
codex_plugin_scanner-1.0.0.dist-info/entry_points.txt,sha256=Rk3DayqmbOKDsNT29p8sWSUM9rvGD1XGYTPtWwLoTHY,71
|
|
14
|
+
codex_plugin_scanner-1.0.0.dist-info/licenses/LICENSE,sha256=h2qHK0hIjysdHOFg3u6YBgFFJE3Wh2moOSfgVUFwdtU,5651
|
|
15
|
+
codex_plugin_scanner-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity.
|
|
18
|
+
|
|
19
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
20
|
+
exercising permissions granted by this License.
|
|
21
|
+
|
|
22
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
23
|
+
including but not limited to software source code, documentation
|
|
24
|
+
source, and configuration files.
|
|
25
|
+
|
|
26
|
+
"Object" form shall mean any form resulting from mechanical
|
|
27
|
+
transformation or translation of a Source form.
|
|
28
|
+
|
|
29
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
30
|
+
Object form.
|
|
31
|
+
|
|
32
|
+
"Derivative Works" shall mean any work that is based on the Work
|
|
33
|
+
and for which the editorial revisions, annotations, elaborations,
|
|
34
|
+
or other modifications represent, as a whole, an original work of
|
|
35
|
+
authorship.
|
|
36
|
+
|
|
37
|
+
"Contribution" shall mean any work of authorship, including the
|
|
38
|
+
original version of the Work and any modifications or additions.
|
|
39
|
+
|
|
40
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
41
|
+
on behalf of whom a Contribution has been received.
|
|
42
|
+
|
|
43
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
44
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
45
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
46
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
47
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
48
|
+
Work and such Derivative Works in Source or Object form.
|
|
49
|
+
|
|
50
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
51
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
52
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
53
|
+
(except as stated in this section) patent license to make, have made,
|
|
54
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
55
|
+
where such license applies only to those patent claims licensable
|
|
56
|
+
by such Contributor that are necessarily infringed by their
|
|
57
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
58
|
+
with the Work to which such Contribution(s) was submitted.
|
|
59
|
+
|
|
60
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
61
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
62
|
+
modifications, and in Source or Object form, provided that You
|
|
63
|
+
meet the following conditions:
|
|
64
|
+
|
|
65
|
+
(a) You must give any other recipients of the Work or
|
|
66
|
+
Derivative Works a copy of this License; and
|
|
67
|
+
|
|
68
|
+
(b) You must cause any modified files to carry prominent notices
|
|
69
|
+
stating that You changed the files; and
|
|
70
|
+
|
|
71
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
72
|
+
that You distribute, all copyright, patent, trademark, and
|
|
73
|
+
attribution notices from the Source form of the Work; and
|
|
74
|
+
|
|
75
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
76
|
+
distribution, then any Derivative Works that You distribute must
|
|
77
|
+
include a readable copy of the attribution notices contained
|
|
78
|
+
within such NOTICE file.
|
|
79
|
+
|
|
80
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
81
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
82
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
83
|
+
this License, without any additional terms or conditions.
|
|
84
|
+
|
|
85
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
86
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
87
|
+
except as required for reasonable and customary use in describing the
|
|
88
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
89
|
+
|
|
90
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
91
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
92
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
93
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
94
|
+
implied, including, without limitation, any warranties or conditions
|
|
95
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
96
|
+
PARTICULAR PURPOSE.
|
|
97
|
+
|
|
98
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
99
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
100
|
+
unless required by applicable law, Contributor be liable for any
|
|
101
|
+
damages, including any direct, indirect, special, incidental, or
|
|
102
|
+
consequential damages.
|
|
103
|
+
|
|
104
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
105
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
106
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
107
|
+
or other liability obligations and/or rights consistent with this
|
|
108
|
+
License.
|
|
109
|
+
|
|
110
|
+
END OF TERMS AND CONDITIONS
|
|
111
|
+
|
|
112
|
+
Copyright 2026 Hashgraph Online
|
|
113
|
+
|
|
114
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
115
|
+
you may not use this file except in compliance with the License.
|
|
116
|
+
You may obtain a copy of the License at
|
|
117
|
+
|
|
118
|
+
http://www.apache.org/licenses/LICENSE-2.0
|