code-guardian-mcp 0.2.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.
- code_guardian/__init__.py +8 -0
- code_guardian/__main__.py +6 -0
- code_guardian/catalogue.py +216 -0
- code_guardian/cli.py +146 -0
- code_guardian/complete.py +318 -0
- code_guardian/data/known_packages.txt.gz +0 -0
- code_guardian/explain.py +168 -0
- code_guardian/finding.py +503 -0
- code_guardian/goal.py +131 -0
- code_guardian/manifests.py +361 -0
- code_guardian/mcp_server.py +214 -0
- code_guardian/orphans.py +613 -0
- code_guardian/outputs.py +156 -0
- code_guardian/report.py +383 -0
- code_guardian/scanners.py +800 -0
- code_guardian/score.py +208 -0
- code_guardian/supply.py +216 -0
- code_guardian/walk.py +184 -0
- code_guardian/works.py +405 -0
- code_guardian_mcp-0.2.0.dist-info/METADATA +551 -0
- code_guardian_mcp-0.2.0.dist-info/RECORD +25 -0
- code_guardian_mcp-0.2.0.dist-info/WHEEL +5 -0
- code_guardian_mcp-0.2.0.dist-info/entry_points.txt +3 -0
- code_guardian_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- code_guardian_mcp-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Complete checks: does it have what it needs? File presence plus basic parsing; nothing is run.
|
|
2
|
+
|
|
3
|
+
complete.license_missing WARN no LICENSE / LICENCE / COPYING file at the root
|
|
4
|
+
complete.license_undeclared WARN packaging metadata exists but declares no licence
|
|
5
|
+
complete.readme_missing WARN no README at the root
|
|
6
|
+
complete.readme_short WARN README under 200 bytes
|
|
7
|
+
complete.readme_sections WARN README (200+ bytes) without an install or a usage heading
|
|
8
|
+
complete.ci_missing WARN no CI configuration
|
|
9
|
+
complete.metadata WARN pyproject [project] without name, version or requires-python
|
|
10
|
+
complete.lockfile WARN an application (see APP MARKERS) without a lockfile
|
|
11
|
+
complete.env_example WARN an application reads environment variables but has no .env.example
|
|
12
|
+
deps.declared_unused INFO a runtime dependency that nothing imports
|
|
13
|
+
|
|
14
|
+
Application markers (a library, with [project] and none of these, needs no lockfile): a Dockerfile or
|
|
15
|
+
Containerfile; a __main__.py when the project declares no [project.scripts]; a module-level Flask/FastAPI/
|
|
16
|
+
Starlette/Quart/Sanic app object or Django get_wsgi_application()/get_asgi_application(). Markers under examples/,
|
|
17
|
+
docs/ and tests/ do not count.
|
|
18
|
+
|
|
19
|
+
Undeclared dependencies are not checked here: works.import_unresolved owns them.
|
|
20
|
+
|
|
21
|
+
Public contract:
|
|
22
|
+
readme_missing_sections(text) -> ["install", "usage"] subset
|
|
23
|
+
app_markers(project) -> [str] env_reads(rel, tree) -> [(line, name or None)]
|
|
24
|
+
check(project) -> [finding]
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
import tomllib
|
|
31
|
+
|
|
32
|
+
from .finding import make_finding, summarize
|
|
33
|
+
from .manifests import _cfg, declared as declared_deps, scripts as manifest_scripts
|
|
34
|
+
from .orphans import is_test
|
|
35
|
+
from .works import import_declared, normalise
|
|
36
|
+
|
|
37
|
+
README_MIN = 200
|
|
38
|
+
LICENSE_NAMES = re.compile(r"(?i)^(licen[cs]e|copying|unlicense)([.-].*)?$")
|
|
39
|
+
README_NAMES = re.compile(r"(?i)^readme(\.(md|markdown|rst|txt))?$")
|
|
40
|
+
INSTALL_WORDS = re.compile(r"(?i)\b(install|installation|installing|setup|set up|getting started|quick ?start)\b")
|
|
41
|
+
USAGE_WORDS = re.compile(r"(?i)\b(usage|use|using|examples?|getting started|quick ?start|tutorial|how to|run|running|"
|
|
42
|
+
r"commands?|cli)\b")
|
|
43
|
+
CI_FILES = (".gitlab-ci.yml", ".travis.yml", "azure-pipelines.yml", "Jenkinsfile", "bitbucket-pipelines.yml",
|
|
44
|
+
".drone.yml", "appveyor.yml", ".appveyor.yml", ".cirrus.yml", ".woodpecker.yml")
|
|
45
|
+
CI_DIRS = (".github/workflows/", ".circleci/", ".woodpecker/", ".buildkite/")
|
|
46
|
+
LOCKFILES = ("uv.lock", "poetry.lock", "Pipfile.lock", "pdm.lock", "pixi.lock", "requirements.lock")
|
|
47
|
+
ENV_EXAMPLES = (".env.example", ".env.sample", ".env.template", ".env.dist", "example.env", "sample.env", "env.example")
|
|
48
|
+
APP_CALLS = ("Flask", "FastAPI", "Starlette", "Quart", "Sanic", "get_wsgi_application", "get_asgi_application")
|
|
49
|
+
NOT_PRODUCT = frozenset({"examples", "example", "docs", "doc", "samples", "sample", "demo", "demos", "tutorial",
|
|
50
|
+
"tests", "test", "testing", "benchmarks", "bench"})
|
|
51
|
+
NOT_IMPORTED = frozenset({"gunicorn", "uvicorn", "hypercorn", "waitress", "daphne", "setuptools", "wheel", "pip",
|
|
52
|
+
"build", "twine", "black", "ruff", "flake8", "mypy", "pylint", "isort", "pre-commit", "tox",
|
|
53
|
+
"nox", "coverage", "pytest", "ipykernel", "jupyter", "notebook", "certifi"})
|
|
54
|
+
NOT_IMPORTED_PREFIXES = ("types-", "pytest-", "flake8-", "sphinx", "mkdocs")
|
|
55
|
+
ENV_CALLS = frozenset({"os.getenv", "os.getenvb", "os.environ.get", "os.environ.setdefault", "dotenv.load_dotenv",
|
|
56
|
+
"dotenv.dotenv_values", "dotenv.find_dotenv"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _finding(code, level, node, summary, evidence=(), try_=()):
|
|
60
|
+
return make_finding(code, "partial", level, node, summary, evidence=list(evidence), try_=list(try_))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _headings(text):
|
|
64
|
+
lines = text.splitlines()
|
|
65
|
+
out = [ln.lstrip("#").strip() for ln in lines if re.match(r"\s{0,3}#{1,6}\s", ln)]
|
|
66
|
+
for prev, line in zip(lines, lines[1:]):
|
|
67
|
+
if prev.strip() and re.fullmatch(r"\s*([=\-~^*+#`'\".:_])\1{0,}\s*", line) and len(line.strip()) >= 1:
|
|
68
|
+
out.append(prev.strip())
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def readme_missing_sections(text):
|
|
73
|
+
heads = _headings(text)
|
|
74
|
+
missing = []
|
|
75
|
+
if not any(INSTALL_WORDS.search(h) for h in heads):
|
|
76
|
+
missing.append("install")
|
|
77
|
+
if not any(USAGE_WORDS.search(h) for h in heads):
|
|
78
|
+
missing.append("usage")
|
|
79
|
+
return missing
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _product(rel):
|
|
83
|
+
return not is_test(rel) and not any(p in NOT_PRODUCT for p in rel.split("/")[:-1])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _call_name(func):
|
|
87
|
+
return func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def app_markers(project):
|
|
91
|
+
files, trees, texts = project["files"], project["trees"], project["texts"]
|
|
92
|
+
out = []
|
|
93
|
+
for rel in files:
|
|
94
|
+
name = rel.rsplit("/", 1)[-1]
|
|
95
|
+
if _product(rel) and (name in ("Dockerfile", "Containerfile") or name.endswith(".Dockerfile")
|
|
96
|
+
or name.startswith("Dockerfile.")):
|
|
97
|
+
out.append(name if "/" not in rel else rel)
|
|
98
|
+
has_scripts = any(manifest_scripts(rel, text) for rel, text in texts.items())
|
|
99
|
+
if not has_scripts:
|
|
100
|
+
out.extend("%s (no [project.scripts])" % rel for rel in sorted(trees)
|
|
101
|
+
if rel.rsplit("/", 1)[-1] == "__main__.py" and _product(rel))
|
|
102
|
+
for rel in sorted(trees):
|
|
103
|
+
if not _product(rel):
|
|
104
|
+
continue
|
|
105
|
+
for stmt in trees[rel].body:
|
|
106
|
+
value = getattr(stmt, "value", None)
|
|
107
|
+
if isinstance(stmt, (ast.Assign, ast.AnnAssign)) and isinstance(value, ast.Call) \
|
|
108
|
+
and _call_name(value.func) in APP_CALLS:
|
|
109
|
+
out.append("%s:%d %s()" % (rel, stmt.lineno, _call_name(value.func)))
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_lockfile(rel, text):
|
|
114
|
+
name = rel.rsplit("/", 1)[-1]
|
|
115
|
+
if name in LOCKFILES:
|
|
116
|
+
return True
|
|
117
|
+
if name.startswith("requirements") and name.endswith(".txt") and text is not None:
|
|
118
|
+
reqs = [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.strip().startswith(("#", "-"))]
|
|
119
|
+
return bool(reqs) and ("--hash=" in text or all("==" in r for r in reqs))
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def env_reads(rel, tree):
|
|
124
|
+
"""[(line, variable name or None)] for os.environ / os.getenv / dotenv use in one file."""
|
|
125
|
+
aliases = {}
|
|
126
|
+
for n in ast.walk(tree):
|
|
127
|
+
if isinstance(n, ast.ImportFrom) and n.module in ("os", "dotenv"):
|
|
128
|
+
for a in n.names:
|
|
129
|
+
aliases[a.asname or a.name] = "%s.%s" % (n.module, a.name)
|
|
130
|
+
out = []
|
|
131
|
+
for n in ast.walk(tree):
|
|
132
|
+
name, key = None, None
|
|
133
|
+
if isinstance(n, ast.Call):
|
|
134
|
+
target = n.func
|
|
135
|
+
dotted = _dotted(target, aliases)
|
|
136
|
+
if dotted in ENV_CALLS:
|
|
137
|
+
name, key = dotted, n.args[0] if n.args else None
|
|
138
|
+
elif isinstance(n, ast.Subscript) and _dotted(n.value, aliases) in ("os.environ", "os.environb"):
|
|
139
|
+
name, key = "os.environ", n.slice
|
|
140
|
+
if name:
|
|
141
|
+
out.append((n.lineno, key.value if isinstance(key, ast.Constant) and isinstance(key.value, str) else None))
|
|
142
|
+
return sorted(set(out), key=lambda t: (t[0], t[1] or ""))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _dotted(node, aliases):
|
|
146
|
+
parts = []
|
|
147
|
+
while isinstance(node, ast.Attribute):
|
|
148
|
+
parts.append(node.attr)
|
|
149
|
+
node = node.value
|
|
150
|
+
if not isinstance(node, ast.Name):
|
|
151
|
+
return ""
|
|
152
|
+
return ".".join([aliases.get(node.id, node.id)] + parts[::-1])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _license_declared(texts, trees):
|
|
156
|
+
"""None when there is no packaging metadata to declare a licence in; else whether one is declared."""
|
|
157
|
+
has_meta, declared = False, False
|
|
158
|
+
if "pyproject.toml" in texts:
|
|
159
|
+
try:
|
|
160
|
+
data = tomllib.loads(texts["pyproject.toml"])
|
|
161
|
+
except tomllib.TOMLDecodeError:
|
|
162
|
+
data = {}
|
|
163
|
+
project = data.get("project")
|
|
164
|
+
poetry = (data.get("tool") or {}).get("poetry")
|
|
165
|
+
if isinstance(project, dict):
|
|
166
|
+
has_meta = True
|
|
167
|
+
declared |= bool(project.get("license") or project.get("license-files")
|
|
168
|
+
or any("License ::" in c for c in project.get("classifiers") or []))
|
|
169
|
+
if isinstance(poetry, dict) and poetry.get("name"):
|
|
170
|
+
has_meta = True
|
|
171
|
+
declared |= bool(poetry.get("license"))
|
|
172
|
+
if "setup.cfg" in texts:
|
|
173
|
+
cp = _cfg(texts["setup.cfg"])
|
|
174
|
+
if cp is not None and cp.has_section("metadata"):
|
|
175
|
+
has_meta = True
|
|
176
|
+
declared |= any(cp.has_option("metadata", k) for k in ("license", "license_file", "license_files")) \
|
|
177
|
+
or "License ::" in texts["setup.cfg"]
|
|
178
|
+
if "setup.py" in trees:
|
|
179
|
+
for n in ast.walk(trees["setup.py"]):
|
|
180
|
+
if isinstance(n, ast.Call) and _call_name(n.func) == "setup":
|
|
181
|
+
has_meta = True
|
|
182
|
+
declared |= any(kw.arg in ("license", "license_files") for kw in n.keywords) \
|
|
183
|
+
or "License ::" in ast.dump(n)
|
|
184
|
+
return declared if has_meta else None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _project_line(text):
|
|
188
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
189
|
+
if line.strip() == "[project]":
|
|
190
|
+
return i
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check(project):
|
|
195
|
+
files, texts, trees = project["files"], project["texts"], project["trees"]
|
|
196
|
+
root_files = {f for f in files if "/" not in f}
|
|
197
|
+
out = []
|
|
198
|
+
if not any(LICENSE_NAMES.match(f) for f in root_files):
|
|
199
|
+
out.append(_finding("complete.license_missing", "WARN", ".", "no licence file: nobody may legally reuse this code",
|
|
200
|
+
["looked for: LICENSE, LICENCE, COPYING, UNLICENSE (any extension)"],
|
|
201
|
+
["add a LICENSE file (choosealicense.com helps pick one)"]))
|
|
202
|
+
declared_licence = _license_declared(texts, trees)
|
|
203
|
+
if declared_licence is False:
|
|
204
|
+
where = "pyproject.toml:%d" % _project_line(texts["pyproject.toml"]) if "pyproject.toml" in texts else \
|
|
205
|
+
"setup.cfg:1" if "setup.cfg" in texts else "setup.py:1"
|
|
206
|
+
out.append(_finding("complete.license_undeclared", "WARN", where, "the package metadata declares no licence",
|
|
207
|
+
["no license / license-files / License classifier"],
|
|
208
|
+
['add license = "MIT" (or your licence) to [project]']))
|
|
209
|
+
readmes = sorted(f for f in root_files if README_NAMES.match(f))
|
|
210
|
+
if not readmes:
|
|
211
|
+
out.append(_finding("complete.readme_missing", "WARN", ".", "no README: nobody can tell what this is or how to run it",
|
|
212
|
+
["looked for: README, README.md, README.rst, README.txt"], ["add a README.md"]))
|
|
213
|
+
else:
|
|
214
|
+
text = project["read"](readmes[0])
|
|
215
|
+
size = len(text.encode("utf-8"))
|
|
216
|
+
if size < README_MIN:
|
|
217
|
+
out.append(_finding("complete.readme_short", "WARN", "%s:1" % readmes[0],
|
|
218
|
+
"README is %d bytes: too short to explain install and usage" % size,
|
|
219
|
+
["minimum: %d bytes" % README_MIN], ["describe what it does, how to install, how to use"]))
|
|
220
|
+
else:
|
|
221
|
+
missing = readme_missing_sections(text)
|
|
222
|
+
if missing:
|
|
223
|
+
out.append(_finding("complete.readme_sections", "WARN", "%s:1" % readmes[0],
|
|
224
|
+
"README has no %s section" % " or ".join(missing),
|
|
225
|
+
["missing heading: %s" % ", ".join(missing)],
|
|
226
|
+
["add an 'Install' and a 'Usage' heading"]))
|
|
227
|
+
if not any(f in CI_FILES or f.startswith(CI_DIRS) for f in files):
|
|
228
|
+
out.append(_finding("complete.ci_missing", "WARN", ".", "no CI configuration: nothing runs the tests on each change",
|
|
229
|
+
["looked for: .github/workflows/, .gitlab-ci.yml, .circleci/, and other CI files"],
|
|
230
|
+
["add a CI workflow that runs the tests"]))
|
|
231
|
+
if "pyproject.toml" in texts:
|
|
232
|
+
try:
|
|
233
|
+
meta = tomllib.loads(texts["pyproject.toml"]).get("project")
|
|
234
|
+
except tomllib.TOMLDecodeError:
|
|
235
|
+
meta = None
|
|
236
|
+
if isinstance(meta, dict):
|
|
237
|
+
dynamic = set(meta.get("dynamic") or [])
|
|
238
|
+
missing = [k for k in ("name", "version", "requires-python") if not meta.get(k) and k not in dynamic]
|
|
239
|
+
if missing:
|
|
240
|
+
out.append(_finding("complete.metadata", "WARN", "pyproject.toml:%d" % _project_line(texts["pyproject.toml"]),
|
|
241
|
+
"pyproject.toml [project] is missing: %s" % ", ".join(missing),
|
|
242
|
+
["required: name, version (or dynamic), requires-python"],
|
|
243
|
+
["add the missing keys to [project]"]))
|
|
244
|
+
markers = app_markers(project)
|
|
245
|
+
if markers and not any(_is_lockfile(f, texts.get(f)) for f in files):
|
|
246
|
+
out.append(_finding("complete.lockfile", "WARN", ".", "an application without a lockfile: installs can differ run to run",
|
|
247
|
+
["application marker: %s" % markers[0],
|
|
248
|
+
"looked for: %s, a fully pinned requirements.txt" % ", ".join(LOCKFILES)],
|
|
249
|
+
["commit a lockfile (uv lock, poetry lock, or pip-compile)"]))
|
|
250
|
+
reads = [(rel, line, key) for rel in sorted(trees) if _product(rel) for line, key in env_reads(rel, trees[rel])]
|
|
251
|
+
if markers and reads and not any(f.rsplit("/", 1)[-1] in ENV_EXAMPLES for f in files):
|
|
252
|
+
names = sorted({k for _, _, k in reads if k})
|
|
253
|
+
out.append(_finding("complete.env_example", "WARN", "%s:%d" % reads[0][:2],
|
|
254
|
+
"the code reads environment variables but there is no .env.example",
|
|
255
|
+
["reads: %s" % (", ".join(names[:10]) or "names built at runtime")],
|
|
256
|
+
["add a .env.example listing every variable with a safe placeholder"]))
|
|
257
|
+
out.extend(_declared_unused(texts, trees))
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _declared_unused(texts, trees):
|
|
262
|
+
imported = set()
|
|
263
|
+
for tree in trees.values():
|
|
264
|
+
for n in ast.walk(tree):
|
|
265
|
+
if isinstance(n, ast.Import):
|
|
266
|
+
imported.update(a.name.split(".")[0] for a in n.names)
|
|
267
|
+
elif isinstance(n, ast.ImportFrom) and not n.level and n.module:
|
|
268
|
+
imported.add(n.module.split(".")[0])
|
|
269
|
+
out, seen = [], set()
|
|
270
|
+
for rel in sorted(texts):
|
|
271
|
+
runtime = rel.rsplit("/", 1)[-1] in ("pyproject.toml", "setup.py", "setup.cfg", "Pipfile", "requirements.txt")
|
|
272
|
+
if not runtime:
|
|
273
|
+
continue
|
|
274
|
+
for dist, line, kind in declared_deps(rel, texts[rel], trees.get(rel)):
|
|
275
|
+
key = normalise(dist)
|
|
276
|
+
if kind not in ("runtime", "requirements") or key in seen or key in NOT_IMPORTED \
|
|
277
|
+
or key.startswith(NOT_IMPORTED_PREFIXES) or key.endswith("-stubs"):
|
|
278
|
+
continue
|
|
279
|
+
seen.add(key)
|
|
280
|
+
if not any(import_declared(m, {key}) for m in imported):
|
|
281
|
+
out.append(_finding("deps.declared_unused", "INFO", "%s:%d" % (rel, line),
|
|
282
|
+
"dependency %s is declared but never imported" % dist,
|
|
283
|
+
["kind: %s" % kind], ["remove it, or note why it is needed (a plugin or a runner)"]))
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv=None):
|
|
288
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
289
|
+
if "--selftest" in argv:
|
|
290
|
+
return selftest()
|
|
291
|
+
sys.stderr.write("usage: python -m code_guardian.complete --selftest\n")
|
|
292
|
+
return 2
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def selftest():
|
|
296
|
+
env = ast.parse("import os\nfrom os import getenv as g\nA = os.environ['A']\nB = g('B')\nC = os.environ.get(x)\n"
|
|
297
|
+
"from dotenv import load_dotenv\nload_dotenv()\n")
|
|
298
|
+
bare = {"files": ["app.py"], "texts": {}, "trees": {"app.py": ast.parse("x = 1\n")}, "read": lambda rel: ""}
|
|
299
|
+
codes = sorted(f["code"] for f in check(bare))
|
|
300
|
+
app = {"files": ["Dockerfile", "w.py"], "texts": {}, "trees": {"w.py": ast.parse("from flask import Flask\napp = Flask(__name__)\n")}}
|
|
301
|
+
checks = [
|
|
302
|
+
("sections", readme_missing_sections("# x\n## Install\n## Usage\n") == []
|
|
303
|
+
and readme_missing_sections("# x\n## API\n") == ["install", "usage"]),
|
|
304
|
+
("setext", readme_missing_sections("Quickstart\n==========\n") == []),
|
|
305
|
+
("env_reads", [k for _, k in env_reads("a.py", env)] == ["A", "B", None, None]),
|
|
306
|
+
("bare", codes == ["complete.ci_missing", "complete.license_missing", "complete.readme_missing"]),
|
|
307
|
+
("markers", app_markers(app) == ["Dockerfile", "w.py:2 Flask()"]),
|
|
308
|
+
("lockfile", _is_lockfile("requirements.txt", "a==1\nb==2\n") and not _is_lockfile("requirements.txt", "a>=1\n")
|
|
309
|
+
and _is_lockfile("uv.lock", None)),
|
|
310
|
+
("licence_meta", _license_declared({"pyproject.toml": '[project]\nname="x"\n'}, {}) is False
|
|
311
|
+
and _license_declared({}, {}) is None
|
|
312
|
+
and _license_declared({"pyproject.toml": '[project]\nlicense = "MIT"\n'}, {}) is True),
|
|
313
|
+
]
|
|
314
|
+
return summarize("complete", checks)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
sys.exit(main())
|
|
Binary file
|
code_guardian/explain.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""--explain: ask a local model to put the findings into plain English. It never adds, removes or rescores one.
|
|
2
|
+
|
|
3
|
+
Only the findings travel: code, level, file:line and the summary (at most 120 characters each), plus the score,
|
|
4
|
+
grade and hygiene counts. No source code, no evidence lines. The request goes to the one URL the user configured,
|
|
5
|
+
directly (no proxy), and nowhere else. Any failure leaves the report as it was and adds one note line.
|
|
6
|
+
|
|
7
|
+
Environment:
|
|
8
|
+
CODE_GUARDIAN_LLM_URL Ollama .../api/chat or OpenAI-compatible .../v1/chat/completions (a bare host or a
|
|
9
|
+
.../v1 base is completed by path); http or https only
|
|
10
|
+
CODE_GUARDIAN_LLM_MODEL model name
|
|
11
|
+
CODE_GUARDIAN_LLM_KEY optional bearer token for an OpenAI-compatible server
|
|
12
|
+
CODE_GUARDIAN_LLM_TIMEOUT seconds (default 60)
|
|
13
|
+
|
|
14
|
+
Public contract:
|
|
15
|
+
LABEL
|
|
16
|
+
payload(report) -> dict # exactly what is sent
|
|
17
|
+
endpoint(url) -> (api, url) # api is "ollama" or "openai"; raises ValueError
|
|
18
|
+
explain(report, env=None) -> (text or None, note or None)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import http.server
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
from .finding import summarize
|
|
32
|
+
|
|
33
|
+
LABEL = "model explanation (not part of the score)"
|
|
34
|
+
SYSTEM = ("You explain the results of a static code scan to someone who does not write code. It scores three "
|
|
35
|
+
"things out of 100, higher is better: Safe (is it safe to run?), Works (will it work?) and Complete (does "
|
|
36
|
+
"it have what it needs?), plus an Overall score and grade. Use only the JSON you are given. Do not add, "
|
|
37
|
+
"remove, merge or re-grade findings, and do not invent file names. In at most 150 words: say what the "
|
|
38
|
+
"scores mean, then the two or three most important things to do first, in plain words.")
|
|
39
|
+
FIELDS = ("code", "level", "node", "summary")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def payload(report):
|
|
43
|
+
s = report.get("scores") or {}
|
|
44
|
+
hygiene = report.get("hygiene") or {}
|
|
45
|
+
return {"scores": {k: s.get(k) for k in ("safe", "works", "complete", "overall", "grade")},
|
|
46
|
+
"findings": [{k: f.get(k) for k in FIELDS} for f in report.get("findings") or []],
|
|
47
|
+
"hygiene": {"source": hygiene.get("source"), "counts": hygiene.get("counts") or {},
|
|
48
|
+
"findings": [{k: f.get(k) for k in FIELDS} for f in hygiene.get("findings") or []]}}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def endpoint(url):
|
|
52
|
+
parts = urllib.parse.urlsplit(url)
|
|
53
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
54
|
+
raise ValueError("CODE_GUARDIAN_LLM_URL must be an http(s) URL")
|
|
55
|
+
path = parts.path.rstrip("/")
|
|
56
|
+
if path.endswith("/chat/completions"):
|
|
57
|
+
api = "openai"
|
|
58
|
+
elif path.endswith("/api/chat"):
|
|
59
|
+
api = "ollama"
|
|
60
|
+
elif path.endswith("/v1"):
|
|
61
|
+
api, path = "openai", path + "/chat/completions"
|
|
62
|
+
elif not path:
|
|
63
|
+
api, path = "ollama", "/api/chat"
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError("CODE_GUARDIAN_LLM_URL path must end in /api/chat or /v1/chat/completions")
|
|
66
|
+
return api, urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _request(api, model, report):
|
|
70
|
+
messages = [{"role": "system", "content": SYSTEM},
|
|
71
|
+
{"role": "user", "content": json.dumps(payload(report), sort_keys=True)}]
|
|
72
|
+
if api == "ollama":
|
|
73
|
+
return {"model": model, "messages": messages, "stream": False, "options": {"temperature": 0}}
|
|
74
|
+
return {"model": model, "messages": messages, "temperature": 0, "stream": False}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _text(api, body):
|
|
78
|
+
if api == "ollama":
|
|
79
|
+
return ((body.get("message") or {}).get("content") or "").strip()
|
|
80
|
+
choices = body.get("choices") or [{}]
|
|
81
|
+
return (((choices[0] or {}).get("message") or {}).get("content") or "").strip()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def explain(report, env=None):
|
|
85
|
+
env = os.environ if env is None else env
|
|
86
|
+
url = (env.get("CODE_GUARDIAN_LLM_URL") or "").strip()
|
|
87
|
+
model = (env.get("CODE_GUARDIAN_LLM_MODEL") or "").strip()
|
|
88
|
+
if not url:
|
|
89
|
+
return None, "explain skipped: CODE_GUARDIAN_LLM_URL is not set"
|
|
90
|
+
if not model:
|
|
91
|
+
return None, "explain skipped: CODE_GUARDIAN_LLM_MODEL is not set"
|
|
92
|
+
try:
|
|
93
|
+
api, full = endpoint(url)
|
|
94
|
+
timeout = float(env.get("CODE_GUARDIAN_LLM_TIMEOUT") or 60)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
return None, "explain skipped: %s" % exc
|
|
97
|
+
headers = {"Content-Type": "application/json"}
|
|
98
|
+
if api == "openai" and env.get("CODE_GUARDIAN_LLM_KEY"):
|
|
99
|
+
headers["Authorization"] = "Bearer " + env["CODE_GUARDIAN_LLM_KEY"]
|
|
100
|
+
req = urllib.request.Request(full, data=json.dumps(_request(api, model, report)).encode("utf-8"),
|
|
101
|
+
headers=headers, method="POST")
|
|
102
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
103
|
+
started = time.perf_counter()
|
|
104
|
+
try:
|
|
105
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
106
|
+
body = json.loads(resp.read().decode("utf-8"))
|
|
107
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
108
|
+
return None, "explain skipped: the model at %s did not answer (%s)" % (urllib.parse.urlsplit(full).netloc,
|
|
109
|
+
type(exc).__name__)
|
|
110
|
+
text = _text(api, body) if isinstance(body, dict) else ""
|
|
111
|
+
if not text:
|
|
112
|
+
return None, "explain skipped: the model returned no text"
|
|
113
|
+
return text, "explain: %s answered in %.1f s" % (api, time.perf_counter() - started)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv=None):
|
|
117
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
118
|
+
if "--selftest" in argv:
|
|
119
|
+
return selftest()
|
|
120
|
+
sys.stderr.write("usage: python -m code_guardian.explain --selftest\n")
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def selftest():
|
|
125
|
+
seen = []
|
|
126
|
+
|
|
127
|
+
class Stub(http.server.BaseHTTPRequestHandler):
|
|
128
|
+
def do_POST(self): # noqa: N802
|
|
129
|
+
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
130
|
+
seen.append((self.path, body))
|
|
131
|
+
reply = ({"message": {"content": "All clear."}} if self.path == "/api/chat"
|
|
132
|
+
else {"choices": [{"message": {"content": "Fix the key."}}]})
|
|
133
|
+
data = json.dumps(reply).encode()
|
|
134
|
+
self.send_response(200)
|
|
135
|
+
self.send_header("Content-Length", str(len(data)))
|
|
136
|
+
self.end_headers()
|
|
137
|
+
self.wfile.write(data)
|
|
138
|
+
|
|
139
|
+
def log_message(self, *args):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Stub)
|
|
143
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
144
|
+
base = "http://127.0.0.1:%d" % server.server_port
|
|
145
|
+
report = {"scores": {"safe": 60, "overall": 80, "grade": "D"}, "findings": [
|
|
146
|
+
{"code": "secret.key", "level": "FAIL", "node": "a.py:1", "summary": "s", "evidence": ["SOURCE"], "try": []}],
|
|
147
|
+
"hygiene": {"source": "inferred", "counts": {"orphan.dead": 0}, "findings": []}}
|
|
148
|
+
try:
|
|
149
|
+
ollama = explain(report, {"CODE_GUARDIAN_LLM_URL": base, "CODE_GUARDIAN_LLM_MODEL": "m"})
|
|
150
|
+
openai = explain(report, {"CODE_GUARDIAN_LLM_URL": base + "/v1", "CODE_GUARDIAN_LLM_MODEL": "m"})
|
|
151
|
+
finally:
|
|
152
|
+
server.shutdown()
|
|
153
|
+
server.server_close()
|
|
154
|
+
dead = explain(report, {"CODE_GUARDIAN_LLM_URL": base, "CODE_GUARDIAN_LLM_MODEL": "m", "CODE_GUARDIAN_LLM_TIMEOUT": "2"})
|
|
155
|
+
checks = [
|
|
156
|
+
("ollama", ollama[0] == "All clear." and seen[0][0] == "/api/chat"),
|
|
157
|
+
("openai", openai[0] == "Fix the key." and seen[1][0] == "/v1/chat/completions"),
|
|
158
|
+
("findings_only", "SOURCE" not in json.dumps([b for _, b in seen]) and '"evidence"' not in json.dumps(seen)),
|
|
159
|
+
("unset", explain(report, {}) == (None, "explain skipped: CODE_GUARDIAN_LLM_URL is not set")),
|
|
160
|
+
("unreachable_soft", dead[0] is None and dead[1].startswith("explain skipped: the model at")),
|
|
161
|
+
("scheme", explain(report, {"CODE_GUARDIAN_LLM_URL": "file:///x", "CODE_GUARDIAN_LLM_MODEL": "m"})[0] is None),
|
|
162
|
+
("endpoint", endpoint(base + "/v1/chat/completions")[0] == "openai" and endpoint(base + "/api/chat")[0] == "ollama"),
|
|
163
|
+
]
|
|
164
|
+
return summarize("explain", checks)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|