task-pipeline-skill 1.74.0 → 1.76.0
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.
- package/CHANGELOG.md +148 -0
- package/README.md +1 -0
- package/SKILL-CARD.md +1 -1
- package/package.json +5 -4
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/commands/project-audit.md +23 -0
- package/plugins/task-pipeline/skills/project-audit/SKILL.md +202 -0
- package/plugins/task-pipeline/skills/project-audit/references/probes.md +199 -0
- package/plugins/task-pipeline/skills/project-audit/scripts/audit.py +1267 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +17 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +92 -0
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The mechanical half of a project audit: collect, compare, report.
|
|
3
|
+
|
|
4
|
+
**What this file is for, and what it deliberately is not.** The skill beside it
|
|
5
|
+
owns *judgement* — which seams matter, how to read a silence, when an axis is
|
|
6
|
+
exhausted. This owns *mechanics*: what the project is, what can be measured
|
|
7
|
+
without being asked twice, and the two artefacts a run leaves behind. The split
|
|
8
|
+
is not tidiness. A judgement encoded in a script becomes a gate that refuses
|
|
9
|
+
things nobody decided to refuse; a mechanic left in prose becomes a step nobody
|
|
10
|
+
runs. Both failures have shipped in this family and both are on its boards.
|
|
11
|
+
|
|
12
|
+
**Stdlib only** (`references/portability.md`): `scripts/` is the one Claude Code
|
|
13
|
+
capability that travels to every channel, and a dependency here would make the
|
|
14
|
+
audit Claude-Code-shaped. That constraint is why the HTML is a string and the
|
|
15
|
+
tokeniser is a ratio nobody is asked to trust.
|
|
16
|
+
|
|
17
|
+
**Three verdicts, not two.** `clean`, `finding`, and `blind`. A probe that could
|
|
18
|
+
not look returns `blind` with the reason, and the reason reaches the page. This
|
|
19
|
+
is `references/audit.md`'s *silence is not a reading* raised from a command to a
|
|
20
|
+
probe: without the third value, "no Sentry configured" and "no errors" produce
|
|
21
|
+
the same empty section, and the second is what a reader takes away.
|
|
22
|
+
|
|
23
|
+
**Committed state is the subject.** Every probe reads `git ls-files` /
|
|
24
|
+
`git show`, and the working tree's disagreement is disclosed rather than failing
|
|
25
|
+
the run — the family's standing instruction #10, learned from two guards that
|
|
26
|
+
reported a state no clone could reproduce.
|
|
27
|
+
|
|
28
|
+
Exit codes: `0` the audit ran, findings or not; `1` it could not start. Findings
|
|
29
|
+
never change the code, because the operator asked for a report and a report that
|
|
30
|
+
exits non-zero is a gate somebody has to disarm.
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import collections
|
|
34
|
+
import datetime
|
|
35
|
+
import hashlib
|
|
36
|
+
import html as _html
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
SCHEMA = "project-audit/1"
|
|
45
|
+
VERDICTS = ("clean", "finding", "blind")
|
|
46
|
+
PHASES = ("discover", "probe", "prod", "seams", "report", "propose")
|
|
47
|
+
OUT_DIR = os.path.join("docs", "audit")
|
|
48
|
+
|
|
49
|
+
# How long a command may run before the probe behind it is called blind. A probe
|
|
50
|
+
# that hangs is worse than one that fails: it takes the whole audit with it.
|
|
51
|
+
TIMEOUT = 45
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# the three values a probe may return
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
class Result(object):
|
|
59
|
+
"""A probe's answer. Constructing one with a fourth verdict is refused.
|
|
60
|
+
|
|
61
|
+
The vocabulary is closed on purpose. An open one grows a `partial` and a
|
|
62
|
+
`warn` within a month, and then the page has five sections nobody can rank.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("verdict", "reason", "evidence", "findings")
|
|
66
|
+
|
|
67
|
+
def __init__(self, verdict, reason="", evidence=None, findings=()):
|
|
68
|
+
if verdict not in VERDICTS:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"verdict %r is not one of %r" % (verdict, list(VERDICTS)))
|
|
71
|
+
self.verdict = verdict
|
|
72
|
+
self.reason = reason
|
|
73
|
+
self.evidence = evidence
|
|
74
|
+
self.findings = list(findings)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
Probe = collections.namedtuple("Probe", "id phase needs run")
|
|
78
|
+
|
|
79
|
+
PROBES = []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def probe(id, phase, needs=()):
|
|
83
|
+
"""Register a probe. `needs` are capability names, checked before it runs."""
|
|
84
|
+
def wrap(fn):
|
|
85
|
+
PROBES.append(Probe(id=id, phase=phase, needs=tuple(needs), run=fn))
|
|
86
|
+
return fn
|
|
87
|
+
return wrap
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Ctx(object):
|
|
91
|
+
"""What a probe is given. Never a live handle — probes ask, they do not own."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, root, profile, capabilities, offline=False,
|
|
94
|
+
out_rel=OUT_DIR):
|
|
95
|
+
self.root = root
|
|
96
|
+
self.profile = profile
|
|
97
|
+
self.capabilities = set(capabilities)
|
|
98
|
+
self.offline = offline
|
|
99
|
+
# Where this run writes. A probe excludes it so the audit does not
|
|
100
|
+
# read its own artefacts as project state.
|
|
101
|
+
self.out_rel = out_rel
|
|
102
|
+
|
|
103
|
+
def sh(self, *args, **kw):
|
|
104
|
+
"""Run a command and return `(returncode, stdout, stderr)`.
|
|
105
|
+
|
|
106
|
+
Never raises. A probe decides what an empty answer means; this only
|
|
107
|
+
reports what happened, including the case where the binary is absent.
|
|
108
|
+
"""
|
|
109
|
+
cwd = kw.pop("cwd", self.root)
|
|
110
|
+
try:
|
|
111
|
+
p = subprocess.run(list(args), cwd=cwd, capture_output=True,
|
|
112
|
+
text=True, timeout=TIMEOUT)
|
|
113
|
+
return p.returncode, p.stdout, p.stderr
|
|
114
|
+
except FileNotFoundError as exc:
|
|
115
|
+
return 127, "", str(exc)
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
return 124, "", "timed out after %ss" % TIMEOUT
|
|
118
|
+
except OSError as exc: # permissions, exec format
|
|
119
|
+
return 126, "", "%s: %s" % (type(exc).__name__, exc)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def classify_output(returncode, out, err):
|
|
123
|
+
"""`read` only when something actually came back.
|
|
124
|
+
|
|
125
|
+
A zero exit with no output is the exact shape of both *nothing is wrong* and
|
|
126
|
+
*the instrument never looked*, so this refuses to call it an answer.
|
|
127
|
+
"""
|
|
128
|
+
if returncode != 0:
|
|
129
|
+
return "blind"
|
|
130
|
+
if not (out or "").strip():
|
|
131
|
+
return "blind"
|
|
132
|
+
return "read"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_probe(p, ctx):
|
|
136
|
+
"""Run one probe with its two guards, and never let it take the run down."""
|
|
137
|
+
missing = [n for n in p.needs if n not in ctx.capabilities]
|
|
138
|
+
if missing:
|
|
139
|
+
return Result("blind", "needs %s, not available here"
|
|
140
|
+
% ", ".join(sorted(missing)))
|
|
141
|
+
try:
|
|
142
|
+
result = p.run(ctx)
|
|
143
|
+
except Exception as exc: # noqa: BLE001 — deliberate
|
|
144
|
+
return Result("blind", "%s: %s" % (type(exc).__name__, exc))
|
|
145
|
+
if result is None:
|
|
146
|
+
# Standing instruction #1: a component that never received its input
|
|
147
|
+
# fails OPEN and is indistinguishable from one that approved.
|
|
148
|
+
return Result("blind", "the probe returned nothing")
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# git — the committed tree is the subject
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def _git(root, *args):
|
|
157
|
+
try:
|
|
158
|
+
p = subprocess.run(["git", "-C", root] + list(args),
|
|
159
|
+
capture_output=True, text=True, timeout=TIMEOUT)
|
|
160
|
+
return p.returncode, p.stdout, p.stderr
|
|
161
|
+
except Exception as exc: # noqa: BLE001
|
|
162
|
+
return 127, "", str(exc)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def is_repo(root):
|
|
166
|
+
rc, out, _ = _git(root, "rev-parse", "--is-inside-work-tree")
|
|
167
|
+
return rc == 0 and out.strip() == "true"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def tracked_files(root):
|
|
171
|
+
"""What a clone would get. Never `os.walk` — that reads build residue."""
|
|
172
|
+
rc, out, _ = _git(root, "ls-files")
|
|
173
|
+
if rc != 0:
|
|
174
|
+
return []
|
|
175
|
+
return [line for line in out.split("\n") if line.strip()]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def worktree_state(root, ignore=()):
|
|
179
|
+
"""Disclose the disagreement; do not fail on it (standing instruction #10).
|
|
180
|
+
|
|
181
|
+
`ignore` exists because of a defect this suite caught on its own three-run
|
|
182
|
+
fixture: run 1 read a clean tree, run 2 read `docs/audit/` — the artefacts
|
|
183
|
+
run 1 had just written — and reported the project dirty. An instrument that
|
|
184
|
+
reads its own output is measuring itself, and the second reading is the one
|
|
185
|
+
an operator would have acted on.
|
|
186
|
+
"""
|
|
187
|
+
# `-uall` matters and is not a preference. Plain `--porcelain` collapses an
|
|
188
|
+
# untracked directory to its shallowest path -- `?? docs/`, never
|
|
189
|
+
# `?? docs/audit/x.html` -- so an exclusion by path silently fails to match.
|
|
190
|
+
# Widening the match to "either is a prefix of the other" would fix this run
|
|
191
|
+
# and hide every other new file under `docs/`, which is worse than the bug.
|
|
192
|
+
rc, out, _ = _git(root, "status", "--porcelain", "-uall")
|
|
193
|
+
if rc != 0:
|
|
194
|
+
return {"dirty": None, "paths": [], "reason": "not a git repository"}
|
|
195
|
+
skip = tuple(i.rstrip("/") + "/" for i in ignore)
|
|
196
|
+
paths = [line[3:].strip().strip('"') for line in out.split("\n") if line.strip()]
|
|
197
|
+
paths = [p for p in paths if not any(p.startswith(s) for s in skip)]
|
|
198
|
+
return {"dirty": bool(paths), "paths": paths, "reason": ""}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def submodules(root):
|
|
202
|
+
rc, out, _ = _git(root, "submodule", "status")
|
|
203
|
+
if rc != 0 or not out.strip():
|
|
204
|
+
return []
|
|
205
|
+
rows = []
|
|
206
|
+
for line in out.split("\n"):
|
|
207
|
+
if not line.strip():
|
|
208
|
+
continue
|
|
209
|
+
parts = line.split()
|
|
210
|
+
if len(parts) >= 2:
|
|
211
|
+
rows.append({"sha": parts[0].lstrip("+-U"), "path": parts[1],
|
|
212
|
+
"describe": parts[2].strip("()") if len(parts) > 2 else ""})
|
|
213
|
+
return rows
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# discovery — what this project IS, before anything is measured about it
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
MANIFESTS = [
|
|
221
|
+
("package.json", "javascript", "npm"),
|
|
222
|
+
("pyproject.toml", "python", ""),
|
|
223
|
+
("setup.py", "python", ""),
|
|
224
|
+
("requirements.txt", "python", "pip"),
|
|
225
|
+
("go.mod", "go", "go"),
|
|
226
|
+
("Cargo.toml", "rust", "cargo"),
|
|
227
|
+
("composer.json", "php", "composer"),
|
|
228
|
+
("Gemfile", "ruby", "bundler"),
|
|
229
|
+
("pom.xml", "java", "maven"),
|
|
230
|
+
("build.gradle", "java", "gradle"),
|
|
231
|
+
("pubspec.yaml", "dart", "pub"),
|
|
232
|
+
("Package.swift", "swift", "spm"),
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
LOCKS = {
|
|
236
|
+
"package-lock.json": "npm", "yarn.lock": "yarn", "pnpm-lock.yaml": "pnpm",
|
|
237
|
+
"bun.lockb": "bun", "poetry.lock": "poetry", "uv.lock": "uv",
|
|
238
|
+
"Pipfile.lock": "pipenv", "Cargo.lock": "cargo", "go.sum": "go",
|
|
239
|
+
"composer.lock": "composer", "Gemfile.lock": "bundler",
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
CI_MARKERS = [
|
|
243
|
+
(".github/workflows", "github-actions"),
|
|
244
|
+
(".gitlab-ci.yml", "gitlab-ci"),
|
|
245
|
+
("Jenkinsfile", "jenkins"),
|
|
246
|
+
(".circleci/config.yml", "circleci"),
|
|
247
|
+
("azure-pipelines.yml", "azure"),
|
|
248
|
+
(".drone.yml", "drone"),
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
DEPLOY_MARKERS = [
|
|
252
|
+
("Dockerfile", "docker"), ("docker-compose.yml", "compose"),
|
|
253
|
+
("fly.toml", "fly"), ("vercel.json", "vercel"),
|
|
254
|
+
("netlify.toml", "netlify"), ("wrangler.toml", "cloudflare-workers"),
|
|
255
|
+
("app.yaml", "app-engine"), ("Procfile", "procfile"),
|
|
256
|
+
("serverless.yml", "serverless"), ("k8s", "kubernetes"),
|
|
257
|
+
("charts", "helm"), ("terraform", "terraform"),
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
TELEMETRY_HINTS = [
|
|
261
|
+
("sentry", "sentry"), ("@sentry/", "sentry"), ("sentry-sdk", "sentry"),
|
|
262
|
+
("opentelemetry", "opentelemetry"), ("bugsnag", "bugsnag"),
|
|
263
|
+
("rollbar", "rollbar"), ("datadog", "datadog"), ("dd-trace", "datadog"),
|
|
264
|
+
("posthog", "posthog"), ("mixpanel", "mixpanel"), ("amplitude", "amplitude"),
|
|
265
|
+
("newrelic", "new-relic"), ("prometheus", "prometheus"),
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
DOC_MARKERS = ["README.md", "docs", "CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md",
|
|
269
|
+
"CHANGELOG.md", "docs/adr", "docs/evidence", "ARCHITECTURE.md"]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read(path, limit=200000):
|
|
273
|
+
try:
|
|
274
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
275
|
+
return fh.read(limit)
|
|
276
|
+
except OSError:
|
|
277
|
+
return ""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _manifest_version(root):
|
|
281
|
+
"""The version the project states about itself, from the manifest it uses."""
|
|
282
|
+
pj = os.path.join(root, "package.json")
|
|
283
|
+
if os.path.exists(pj):
|
|
284
|
+
try:
|
|
285
|
+
return (json.loads(_read(pj)) or {}).get("version")
|
|
286
|
+
except ValueError:
|
|
287
|
+
return None
|
|
288
|
+
pt = os.path.join(root, "pyproject.toml")
|
|
289
|
+
if os.path.exists(pt):
|
|
290
|
+
m = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)', _read(pt))
|
|
291
|
+
if m:
|
|
292
|
+
return m.group(1)
|
|
293
|
+
cg = os.path.join(root, "Cargo.toml")
|
|
294
|
+
if os.path.exists(cg):
|
|
295
|
+
m = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)', _read(cg))
|
|
296
|
+
if m:
|
|
297
|
+
return m.group(1)
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _manifest_name(root):
|
|
302
|
+
pj = os.path.join(root, "package.json")
|
|
303
|
+
if os.path.exists(pj):
|
|
304
|
+
try:
|
|
305
|
+
return (json.loads(_read(pj)) or {}).get("name")
|
|
306
|
+
except ValueError:
|
|
307
|
+
pass
|
|
308
|
+
pt = os.path.join(root, "pyproject.toml")
|
|
309
|
+
if os.path.exists(pt):
|
|
310
|
+
m = re.search(r'(?m)^\s*name\s*=\s*["\']([^"\']+)', _read(pt))
|
|
311
|
+
if m:
|
|
312
|
+
return m.group(1)
|
|
313
|
+
return os.path.basename(os.path.abspath(root))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def discover(root):
|
|
317
|
+
"""The profile every later phase is chosen from.
|
|
318
|
+
|
|
319
|
+
Deliberately answers about the *committed* project where git is available:
|
|
320
|
+
a `node_modules` directory says nothing about what the project is, and a
|
|
321
|
+
walk of the working tree would find thousands of them.
|
|
322
|
+
"""
|
|
323
|
+
root = os.path.abspath(root)
|
|
324
|
+
tracked = set(tracked_files(root)) if is_repo(root) else set()
|
|
325
|
+
|
|
326
|
+
def present(rel):
|
|
327
|
+
if tracked:
|
|
328
|
+
return rel in tracked or any(p.startswith(rel.rstrip("/") + "/")
|
|
329
|
+
for p in tracked)
|
|
330
|
+
return os.path.exists(os.path.join(root, rel))
|
|
331
|
+
|
|
332
|
+
languages, managers = [], []
|
|
333
|
+
for fname, lang, mgr in MANIFESTS:
|
|
334
|
+
if present(fname):
|
|
335
|
+
if lang not in languages:
|
|
336
|
+
languages.append(lang)
|
|
337
|
+
if mgr and mgr not in managers:
|
|
338
|
+
managers.append(mgr)
|
|
339
|
+
for lock, mgr in LOCKS.items():
|
|
340
|
+
if present(lock) and mgr not in managers:
|
|
341
|
+
managers.append(mgr)
|
|
342
|
+
|
|
343
|
+
ci = [name for marker, name in CI_MARKERS if present(marker)]
|
|
344
|
+
deploy = [name for marker, name in DEPLOY_MARKERS if present(marker)]
|
|
345
|
+
docs = [d for d in DOC_MARKERS if present(d)]
|
|
346
|
+
subs = submodules(root)
|
|
347
|
+
|
|
348
|
+
# A monorepo by any of the three shapes that actually change how it is read.
|
|
349
|
+
nested_manifests = [p for p in tracked
|
|
350
|
+
if p.count("/") >= 1 and os.path.basename(p) in
|
|
351
|
+
{m[0] for m in MANIFESTS}]
|
|
352
|
+
workspaces = False
|
|
353
|
+
pj = os.path.join(root, "package.json")
|
|
354
|
+
if os.path.exists(pj):
|
|
355
|
+
try:
|
|
356
|
+
workspaces = bool((json.loads(_read(pj)) or {}).get("workspaces"))
|
|
357
|
+
except ValueError:
|
|
358
|
+
workspaces = False
|
|
359
|
+
|
|
360
|
+
manifest_blob = " ".join(
|
|
361
|
+
_read(os.path.join(root, f)) for f in
|
|
362
|
+
("package.json", "pyproject.toml", "requirements.txt", "go.mod",
|
|
363
|
+
"Cargo.toml", "composer.json") if os.path.exists(os.path.join(root, f))
|
|
364
|
+
).lower()
|
|
365
|
+
telemetry = sorted({name for hint, name in TELEMETRY_HINTS
|
|
366
|
+
if hint in manifest_blob})
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
"root": root,
|
|
370
|
+
"name": _manifest_name(root),
|
|
371
|
+
"version": _manifest_version(root),
|
|
372
|
+
"vcs": "git" if is_repo(root) else "none",
|
|
373
|
+
"languages": sorted(languages),
|
|
374
|
+
"managers": sorted(managers),
|
|
375
|
+
"monorepo": bool(workspaces or subs or len(nested_manifests) > 1),
|
|
376
|
+
"workspaces": workspaces,
|
|
377
|
+
"submodules": subs,
|
|
378
|
+
"ci": ci,
|
|
379
|
+
"deploy": deploy,
|
|
380
|
+
"docs": docs,
|
|
381
|
+
"telemetry": telemetry,
|
|
382
|
+
"tracked_files": len(tracked),
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# REQ-04 — one version, two trees
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def _digest(blob):
|
|
391
|
+
if isinstance(blob, str):
|
|
392
|
+
blob = blob.encode("utf-8", "replace")
|
|
393
|
+
return hashlib.sha256(blob).hexdigest()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def compare_channels(label, trees):
|
|
397
|
+
"""Do the channels that claim one label actually ship one tree?
|
|
398
|
+
|
|
399
|
+
`trees` maps a channel name to `{path: content}`, or to `None` where the
|
|
400
|
+
channel could not be fetched. The whole point of this function is that it
|
|
401
|
+
never looks at the label: yesterday's family defect shipped three channels
|
|
402
|
+
agreeing on `1.15.0` and disagreeing by 231 lines, under a pin checker that
|
|
403
|
+
was green because it compared the two strings.
|
|
404
|
+
"""
|
|
405
|
+
blind = sorted(name for name, tree in trees.items() if tree is None)
|
|
406
|
+
readable = {name: tree for name, tree in trees.items() if tree is not None}
|
|
407
|
+
if len(readable) < 2:
|
|
408
|
+
return {"label": label, "diverged": None, "differing": [],
|
|
409
|
+
"blind": blind, "channels": sorted(trees)}
|
|
410
|
+
|
|
411
|
+
per_path = collections.defaultdict(dict)
|
|
412
|
+
for channel, tree in readable.items():
|
|
413
|
+
for path, content in tree.items():
|
|
414
|
+
per_path[path][channel] = _digest(content)
|
|
415
|
+
|
|
416
|
+
# Two different facts, and conflating them produces a false positive of the
|
|
417
|
+
# exact shape this whole skill exists to catch. A path in one channel and
|
|
418
|
+
# not another is *packaging* -- an npm tarball ships what `files` allows and
|
|
419
|
+
# nothing else, so `.github/` is absent by design. A path in BOTH channels
|
|
420
|
+
# whose bytes differ is *divergence*: one version string, two artefacts.
|
|
421
|
+
# The first draft counted both and reported 22 differing paths on a member
|
|
422
|
+
# where one file had actually moved.
|
|
423
|
+
differing, only_in = [], collections.defaultdict(list)
|
|
424
|
+
for path, by_channel in per_path.items():
|
|
425
|
+
if len(by_channel) != len(readable):
|
|
426
|
+
for channel in readable:
|
|
427
|
+
if channel not in by_channel:
|
|
428
|
+
only_in[channel].append(path)
|
|
429
|
+
continue
|
|
430
|
+
if len(set(by_channel.values())) > 1:
|
|
431
|
+
differing.append(path)
|
|
432
|
+
return {"label": label, "diverged": bool(differing),
|
|
433
|
+
"differing": sorted(differing), "blind": blind,
|
|
434
|
+
"only_in": {k: sorted(v) for k, v in only_in.items()},
|
|
435
|
+
"channels": sorted(trees)}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# REQ-09 — a secret's place and class, never its value
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
SECRET_PATTERNS = [
|
|
443
|
+
("npm token", re.compile(r"\bnpm_[A-Za-z0-9]{36}\b")),
|
|
444
|
+
("github token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
|
|
445
|
+
("openai-style key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
|
|
446
|
+
("linear api key", re.compile(r"\blin_api_[A-Za-z0-9]{20,}\b")),
|
|
447
|
+
("aws access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
448
|
+
("slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
|
|
449
|
+
("google api key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
|
|
450
|
+
("stripe secret", re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}\b")),
|
|
451
|
+
("private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
452
|
+
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\."
|
|
453
|
+
r"[A-Za-z0-9_-]{10,}\b")),
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip",
|
|
457
|
+
".gz", ".tgz", ".woff", ".woff2", ".ttf", ".mp4", ".mp3", ".lockb"}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def scan_secrets(root, paths=None):
|
|
461
|
+
"""Findings that name where and which, and carry no part of the value.
|
|
462
|
+
|
|
463
|
+
The redaction is total rather than a prefix: a report is a file people
|
|
464
|
+
forward, and half a credential plus its context is often enough to finish.
|
|
465
|
+
"""
|
|
466
|
+
root = os.path.abspath(root)
|
|
467
|
+
if paths is None:
|
|
468
|
+
paths = tracked_files(root) or []
|
|
469
|
+
out = []
|
|
470
|
+
for rel in paths:
|
|
471
|
+
if os.path.splitext(rel)[1].lower() in SKIP_EXT:
|
|
472
|
+
continue
|
|
473
|
+
full = os.path.join(root, rel)
|
|
474
|
+
if not os.path.isfile(full):
|
|
475
|
+
continue
|
|
476
|
+
try:
|
|
477
|
+
if os.path.getsize(full) > 2_000_000:
|
|
478
|
+
continue
|
|
479
|
+
except OSError:
|
|
480
|
+
continue
|
|
481
|
+
text = _read(full)
|
|
482
|
+
if not text:
|
|
483
|
+
continue
|
|
484
|
+
for line_no, line in enumerate(text.split("\n"), 1):
|
|
485
|
+
for name, pattern in SECRET_PATTERNS:
|
|
486
|
+
if pattern.search(line):
|
|
487
|
+
out.append({
|
|
488
|
+
"where": "%s:%d" % (rel, line_no),
|
|
489
|
+
"class": name,
|
|
490
|
+
"remedy": "rotate the credential at its issuer, then "
|
|
491
|
+
"remove it from the tree; if it is in "
|
|
492
|
+
"history, rotation is the fix and rewriting "
|
|
493
|
+
"history is not",
|
|
494
|
+
"in_history": False,
|
|
495
|
+
})
|
|
496
|
+
break
|
|
497
|
+
return out
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def scan_secret_history(root, limit=400):
|
|
501
|
+
"""The same classes, in what git still holds. Also value-free."""
|
|
502
|
+
rc, out, _ = _git(root, "log", "--format=%H", "-n", str(limit))
|
|
503
|
+
if rc != 0:
|
|
504
|
+
return []
|
|
505
|
+
found = []
|
|
506
|
+
for sha in [s for s in out.split("\n") if s.strip()][:limit]:
|
|
507
|
+
rc2, diff, _ = _git(root, "show", "--format=", "--unified=0", sha)
|
|
508
|
+
if rc2 != 0 or not diff:
|
|
509
|
+
continue
|
|
510
|
+
for name, pattern in SECRET_PATTERNS:
|
|
511
|
+
if pattern.search(diff):
|
|
512
|
+
found.append({"where": "commit %s" % sha[:12], "class": name,
|
|
513
|
+
"remedy": "rotate the credential at its issuer",
|
|
514
|
+
"in_history": True})
|
|
515
|
+
break
|
|
516
|
+
return found
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
# findings, their identity, and the board's own arithmetic
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
def finding_id(probe_id, where, title):
|
|
524
|
+
"""Stable across a rewording, distinct across a place.
|
|
525
|
+
|
|
526
|
+
An id derived from the free text makes every run report every finding as
|
|
527
|
+
new, and a ratchet whose diff is always total is a snapshot with extra
|
|
528
|
+
steps.
|
|
529
|
+
"""
|
|
530
|
+
norm = re.sub(r"[^a-z0-9]+", " ", (title or "").lower()).strip()
|
|
531
|
+
key = "|".join([probe_id or "", where or "", norm])
|
|
532
|
+
return "f-" + hashlib.sha256(key.encode("utf-8")).hexdigest()[:12]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def priority(blast, age_runs, effort):
|
|
536
|
+
"""The board's formula, unchanged: `P = blast × (1 + age_runs) / effort`."""
|
|
537
|
+
effort = effort or 1
|
|
538
|
+
return round(float(blast) * (1 + float(age_runs)) / float(effort), 2)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def board_row(finding, board_id, source):
|
|
542
|
+
"""One line, pipes escaped, in the columns the board already has.
|
|
543
|
+
|
|
544
|
+
A row that spans lines, or carries a bare pipe, shifts every later column —
|
|
545
|
+
the family has already lost four ids and one status cell that way.
|
|
546
|
+
"""
|
|
547
|
+
def cell(value):
|
|
548
|
+
text = " ".join(str(value or "").split())
|
|
549
|
+
return text.replace("|", r"\|")
|
|
550
|
+
|
|
551
|
+
what = "**%s** %s" % (cell(finding.get("title")),
|
|
552
|
+
cell(finding.get("remedy")))
|
|
553
|
+
return "| %s | %s | %s | %s | %s | %s | %s | open |" % (
|
|
554
|
+
board_id, what, cell(source), finding.get("blast", 2),
|
|
555
|
+
finding.get("runs_open", 0), finding.get("effort", 2),
|
|
556
|
+
finding.get("p", priority(finding.get("blast", 2),
|
|
557
|
+
finding.get("runs_open", 0),
|
|
558
|
+
finding.get("effort", 2))))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
# the ratchet
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
def _ids(payload):
|
|
566
|
+
return [f.get("id") for f in (payload or {}).get("findings", []) if f.get("id")]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def ratchet(current, prior):
|
|
570
|
+
"""What moved. A first run says so rather than calling everything new."""
|
|
571
|
+
now = set(_ids(current))
|
|
572
|
+
if prior is None:
|
|
573
|
+
return {"first_run": True, "closed": [], "new": [], "carried": [],
|
|
574
|
+
"unranked": sorted(now)}
|
|
575
|
+
before = set(_ids(prior))
|
|
576
|
+
return {
|
|
577
|
+
"first_run": False,
|
|
578
|
+
"closed": sorted(before - now),
|
|
579
|
+
"new": sorted(now - before),
|
|
580
|
+
"carried": sorted(now & before),
|
|
581
|
+
"unranked": [],
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def carry_forward(current, prior):
|
|
586
|
+
"""Age a surviving finding, and keep the date it was first seen.
|
|
587
|
+
|
|
588
|
+
`runs_open` is the age term in the board's priority, so a row nobody picks
|
|
589
|
+
up rises on its own. Without this, an audit's findings are all equally new
|
|
590
|
+
forever and the ordering never learns anything.
|
|
591
|
+
"""
|
|
592
|
+
prior_by_id = {f.get("id"): f for f in (prior or {}).get("findings", [])}
|
|
593
|
+
for f in current.get("findings", []):
|
|
594
|
+
old = prior_by_id.get(f.get("id"))
|
|
595
|
+
if old:
|
|
596
|
+
f["first_seen"] = old.get("first_seen", f.get("first_seen"))
|
|
597
|
+
f["runs_open"] = int(old.get("runs_open", 0)) + 1
|
|
598
|
+
else:
|
|
599
|
+
f.setdefault("runs_open", 0)
|
|
600
|
+
f["p"] = priority(f.get("blast", 2), f.get("runs_open", 0),
|
|
601
|
+
f.get("effort", 2))
|
|
602
|
+
return current
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def load_prior(out_dir, exclude=None):
|
|
606
|
+
"""The newest sidecar that is not this run's own."""
|
|
607
|
+
if not os.path.isdir(out_dir):
|
|
608
|
+
return None
|
|
609
|
+
names = sorted(n for n in os.listdir(out_dir)
|
|
610
|
+
if n.endswith(".json") and n != (exclude or ""))
|
|
611
|
+
for name in reversed(names):
|
|
612
|
+
try:
|
|
613
|
+
with open(os.path.join(out_dir, name), encoding="utf-8") as fh:
|
|
614
|
+
payload = json.load(fh)
|
|
615
|
+
if payload.get("schema") == SCHEMA:
|
|
616
|
+
return payload
|
|
617
|
+
except (ValueError, OSError):
|
|
618
|
+
continue
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
# the page
|
|
624
|
+
# ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
STAMP_RE = re.compile(
|
|
627
|
+
r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:?\d{2})?")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def normalise_for_compare(body):
|
|
631
|
+
"""Everything a second run legitimately changes, removed.
|
|
632
|
+
|
|
633
|
+
Standing instruction #2 asks for three real runs against a real tree; this
|
|
634
|
+
is what makes their outputs comparable without making the comparison
|
|
635
|
+
vacuous — only timestamps and the absolute root are erased.
|
|
636
|
+
"""
|
|
637
|
+
body = STAMP_RE.sub("<STAMP>", body)
|
|
638
|
+
body = re.sub(r"/(?:private/)?(?:tmp|var)/[^\s\"'<]+", "<PATH>", body)
|
|
639
|
+
return body
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
CSS = """
|
|
643
|
+
:root{--bg:#fbfaf8;--surface:#fff;--surface-2:#f4f2ee;--ink:#1a1917;
|
|
644
|
+
--ink-soft:#5e5b55;--ink-faint:#8b8780;--line:#e2ded6;--line-2:#c9c4b8;
|
|
645
|
+
--ok:#0f7a4a;--ok-bg:#e8f5ee;--warn:#8a5a00;--warn-bg:#fdf3e0;
|
|
646
|
+
--bad:#a32a24;--bad-bg:#fdeceb;--info:#1f5c8f;--info-bg:#e9f1f8;--accent:#2a4d8f;
|
|
647
|
+
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
648
|
+
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif}
|
|
649
|
+
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
|
|
650
|
+
--bg:#16171a;--surface:#1d1f23;--surface-2:#23262b;--ink:#eceae6;
|
|
651
|
+
--ink-soft:#a8a49c;--ink-faint:#7c7871;--line:#2e3238;--line-2:#40454d;
|
|
652
|
+
--ok:#6fd39b;--ok-bg:#17332a;--warn:#e0a94a;--warn-bg:#33290f;
|
|
653
|
+
--bad:#f08a84;--bad-bg:#3a1c1a;--info:#8ec2ee;--info-bg:#15293a;--accent:#8fb4ee}}
|
|
654
|
+
*{box-sizing:border-box}
|
|
655
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.62 var(--sans);
|
|
656
|
+
padding:0 0 5rem}
|
|
657
|
+
.wrap{max-width:58rem;margin:0 auto;padding:0 1.5rem}
|
|
658
|
+
header.top{border-bottom:1px solid var(--line);background:var(--surface);
|
|
659
|
+
padding:2.4rem 0 1.8rem;margin-bottom:2.2rem}
|
|
660
|
+
.kicker{font:600 .72rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
|
|
661
|
+
color:var(--ink-faint);margin:0 0 .8rem}
|
|
662
|
+
h1{font-size:1.95rem;line-height:1.18;margin:0 0 .6rem;letter-spacing:-.02em;font-weight:640}
|
|
663
|
+
.lede{font-size:1.03rem;color:var(--ink-soft);margin:0;max-width:44rem}
|
|
664
|
+
h2{font-size:1.28rem;margin:2.8rem 0 .5rem;font-weight:640;padding-bottom:.4rem;
|
|
665
|
+
border-bottom:2px solid var(--line-2)}
|
|
666
|
+
h3{font-size:1rem;margin:1.6rem 0 .35rem;font-weight:650}
|
|
667
|
+
p{margin:.6rem 0}
|
|
668
|
+
code{font:.85em/1.45 var(--mono);background:var(--surface-2);padding:.1em .34em;
|
|
669
|
+
border-radius:4px;border:1px solid var(--line)}
|
|
670
|
+
pre{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;
|
|
671
|
+
padding:.8rem 1rem;overflow-x:auto;font:.79rem/1.55 var(--mono);margin:.6rem 0}
|
|
672
|
+
pre code{background:none;border:none;padding:0}
|
|
673
|
+
.tw{overflow-x:auto;margin:.9rem 0;border:1px solid var(--line);border-radius:8px;
|
|
674
|
+
background:var(--surface)}
|
|
675
|
+
table{border-collapse:collapse;width:100%;font-size:.86rem}
|
|
676
|
+
th,td{padding:.48rem .7rem;text-align:left;border-bottom:1px solid var(--line);
|
|
677
|
+
vertical-align:top}
|
|
678
|
+
th{background:var(--surface-2);font-weight:650;font-size:.76rem;letter-spacing:.02em;
|
|
679
|
+
text-transform:uppercase;color:var(--ink-soft);white-space:nowrap}
|
|
680
|
+
tbody tr:last-child td{border-bottom:none}
|
|
681
|
+
td.num,th.num{text-align:right;font-family:var(--mono);font-size:.81rem;white-space:nowrap}
|
|
682
|
+
td.mono{font-family:var(--mono);font-size:.79rem}
|
|
683
|
+
.pill{display:inline-block;font:600 .7rem/1.35 var(--mono);padding:.12rem .45rem;
|
|
684
|
+
border-radius:4px;white-space:nowrap}
|
|
685
|
+
.p-ok{background:var(--ok-bg);color:var(--ok)}
|
|
686
|
+
.p-warn{background:var(--warn-bg);color:var(--warn)}
|
|
687
|
+
.p-bad{background:var(--bad-bg);color:var(--bad)}
|
|
688
|
+
.p-info{background:var(--info-bg);color:var(--info)}
|
|
689
|
+
.p-neutral{background:var(--surface-2);color:var(--ink-faint)}
|
|
690
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(8rem,1fr));
|
|
691
|
+
gap:.7rem;margin:1.3rem 0}
|
|
692
|
+
.stat{background:var(--surface);border:1px solid var(--line);border-radius:8px;
|
|
693
|
+
padding:.7rem .85rem}
|
|
694
|
+
.stat .v{font:650 1.35rem/1.1 var(--sans);letter-spacing:-.02em;display:block}
|
|
695
|
+
.stat .k{font:.69rem/1.3 var(--mono);color:var(--ink-faint);text-transform:uppercase;
|
|
696
|
+
letter-spacing:.05em}
|
|
697
|
+
.card{border:1px solid var(--line);border-left:3px solid var(--line-2);
|
|
698
|
+
background:var(--surface);border-radius:6px;padding:.9rem 1.1rem;margin:1rem 0}
|
|
699
|
+
.card.bad{border-left-color:var(--bad)}.card.warn{border-left-color:var(--warn)}
|
|
700
|
+
.card.info{border-left-color:var(--info)}.card.ok{border-left-color:var(--ok)}
|
|
701
|
+
.card h3{margin-top:0}
|
|
702
|
+
.meta{font:.73rem/1.4 var(--mono);color:var(--ink-faint);margin:.3rem 0 .5rem}
|
|
703
|
+
.note{font-size:.86rem;color:var(--ink-soft);border-left:2px solid var(--line-2);
|
|
704
|
+
padding-left:.8rem;margin:.8rem 0}
|
|
705
|
+
footer{margin-top:3.5rem;padding-top:1.3rem;border-top:1px solid var(--line);
|
|
706
|
+
font-size:.81rem;color:var(--ink-faint)}
|
|
707
|
+
@media (max-width:640px){h1{font-size:1.5rem}}
|
|
708
|
+
"""
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _e(value):
|
|
712
|
+
return _html.escape(str(value if value is not None else ""), quote=True)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _sev_pill(sev):
|
|
716
|
+
cls = {"critical": "p-bad", "high": "p-bad", "medium": "p-warn",
|
|
717
|
+
"low": "p-info"}.get(str(sev).lower(), "p-neutral")
|
|
718
|
+
return '<span class="pill %s">%s</span>' % (cls, _e(sev))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def render_html(payload):
|
|
722
|
+
"""A self-contained page. No external request is reachable from it.
|
|
723
|
+
|
|
724
|
+
Everything a probe could not do is rendered as loudly as everything it
|
|
725
|
+
found — the *what was not looked at* table is not an appendix, because a
|
|
726
|
+
page that hides its blind spots is read as a clean bill.
|
|
727
|
+
"""
|
|
728
|
+
p = payload.get("profile", {}) or {}
|
|
729
|
+
counts = payload.get("counts", {}) or {}
|
|
730
|
+
rat = payload.get("ratchet", {}) or {}
|
|
731
|
+
findings = payload.get("findings", []) or []
|
|
732
|
+
probes = payload.get("probes", []) or []
|
|
733
|
+
blind = [x for x in probes if x.get("verdict") == "blind"]
|
|
734
|
+
|
|
735
|
+
out = []
|
|
736
|
+
add = out.append
|
|
737
|
+
add("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">")
|
|
738
|
+
add('<meta name="viewport" content="width=device-width, initial-scale=1">')
|
|
739
|
+
add("<title>Audit — %s</title>" % _e(p.get("name") or "project"))
|
|
740
|
+
add("<style>%s</style>\n</head>\n<body>" % CSS)
|
|
741
|
+
|
|
742
|
+
add('<header class="top"><div class="wrap">')
|
|
743
|
+
add('<p class="kicker">project-audit · %s · read-only</p>'
|
|
744
|
+
% _e(payload.get("generated_at", "")))
|
|
745
|
+
add("<h1>%s</h1>" % _e(p.get("name") or "project"))
|
|
746
|
+
add('<p class="lede">Every number below was produced by a command this run '
|
|
747
|
+
'executed. What could not be measured is listed as such, never omitted '
|
|
748
|
+
'and never counted as clean.</p>')
|
|
749
|
+
add("</div></header>")
|
|
750
|
+
|
|
751
|
+
add('<div class="wrap">')
|
|
752
|
+
|
|
753
|
+
# --- headline -----------------------------------------------------------
|
|
754
|
+
add('<div class="stats">')
|
|
755
|
+
for value, key in (
|
|
756
|
+
(counts.get("findings", len(findings)), "findings"),
|
|
757
|
+
(counts.get("probes_run", 0), "probes run"),
|
|
758
|
+
(counts.get("probes_blind", len(blind)), "blind"),
|
|
759
|
+
(len(rat.get("closed", [])), "closed since last"),
|
|
760
|
+
(len(rat.get("new", [])), "new"),
|
|
761
|
+
(sum(1 for f in findings if int(f.get("runs_open", 0)) >= 3), "open 3+ runs"),
|
|
762
|
+
):
|
|
763
|
+
add('<div class="stat"><span class="v">%s</span>'
|
|
764
|
+
'<span class="k">%s</span></div>' % (_e(value), _e(key)))
|
|
765
|
+
add("</div>")
|
|
766
|
+
|
|
767
|
+
if rat.get("first_run"):
|
|
768
|
+
add('<p class="note"><strong>First run.</strong> There is no earlier '
|
|
769
|
+
'sidecar in this directory, so nothing is reported as closed or '
|
|
770
|
+
'new — a diff against a run that never happened would be a claim '
|
|
771
|
+
'about nothing. The next audit will have both columns.</p>')
|
|
772
|
+
|
|
773
|
+
# --- what this project is ----------------------------------------------
|
|
774
|
+
add("<h2>What this project is</h2>")
|
|
775
|
+
add('<div class="tw"><table><tbody>')
|
|
776
|
+
for label, value in (
|
|
777
|
+
("version", p.get("version") or "— (no manifest version)"),
|
|
778
|
+
("languages", ", ".join(p.get("languages") or []) or "—"),
|
|
779
|
+
("package managers", ", ".join(p.get("managers") or []) or "—"),
|
|
780
|
+
("monorepo", "yes" if p.get("monorepo") else "no"),
|
|
781
|
+
("submodules", len(p.get("submodules") or []) or "0"),
|
|
782
|
+
("CI", ", ".join(p.get("ci") or []) or "none configured"),
|
|
783
|
+
("deploy targets", ", ".join(p.get("deploy") or []) or "none declared"),
|
|
784
|
+
("error telemetry", ", ".join(p.get("telemetry") or [])
|
|
785
|
+
or "none found in manifests"),
|
|
786
|
+
("tracked files", p.get("tracked_files", 0)),
|
|
787
|
+
):
|
|
788
|
+
add("<tr><th>%s</th><td>%s</td></tr>" % (_e(label), _e(value)))
|
|
789
|
+
add("</tbody></table></div>")
|
|
790
|
+
|
|
791
|
+
# --- findings -----------------------------------------------------------
|
|
792
|
+
add("<h2>Findings</h2>")
|
|
793
|
+
if not findings:
|
|
794
|
+
add('<p>No finding survived this run\'s probes. That is a statement '
|
|
795
|
+
'about the probes that ran — read the next section before taking '
|
|
796
|
+
'it as a clean bill.</p>')
|
|
797
|
+
else:
|
|
798
|
+
for f in sorted(findings, key=lambda x: -float(x.get("p", 0))):
|
|
799
|
+
cls = {"critical": "bad", "high": "bad", "medium": "warn"}.get(
|
|
800
|
+
str(f.get("severity", "")).lower(), "info")
|
|
801
|
+
add('<div class="card %s">' % cls)
|
|
802
|
+
add("<h3>%s %s</h3>" % (_sev_pill(f.get("severity", "info")),
|
|
803
|
+
_e(f.get("title"))))
|
|
804
|
+
age = int(f.get("runs_open", 0))
|
|
805
|
+
add('<p class="meta">%s · P=%s · first seen %s%s</p>' % (
|
|
806
|
+
_e(f.get("where") or "—"), _e(f.get("p", "—")),
|
|
807
|
+
_e(f.get("first_seen") or "—"),
|
|
808
|
+
(" · open %d runs" % age) if age else ""))
|
|
809
|
+
if f.get("detail"):
|
|
810
|
+
add("<p>%s</p>" % _e(f["detail"]))
|
|
811
|
+
if f.get("evidence"):
|
|
812
|
+
add("<pre><code>%s</code></pre>" % _e(f["evidence"]))
|
|
813
|
+
if f.get("remedy"):
|
|
814
|
+
add("<p><strong>Remedy.</strong> %s</p>" % _e(f["remedy"]))
|
|
815
|
+
add("</div>")
|
|
816
|
+
|
|
817
|
+
# --- the blind spots, deliberately not an appendix ----------------------
|
|
818
|
+
add("<h2>What was not looked at</h2>")
|
|
819
|
+
add('<p>A probe that could not run returns <code>blind</code>, never '
|
|
820
|
+
'<code>clean</code>. An empty section here would mean every probe '
|
|
821
|
+
'answered — not that nothing is wrong.</p>')
|
|
822
|
+
if not blind:
|
|
823
|
+
add('<p><span class="pill p-ok">every probe answered</span></p>')
|
|
824
|
+
else:
|
|
825
|
+
add('<div class="tw"><table><thead><tr><th>Probe</th><th>Phase</th>'
|
|
826
|
+
"<th>Why not</th></tr></thead><tbody>")
|
|
827
|
+
for b in blind:
|
|
828
|
+
add("<tr><td class=\"mono\">%s</td><td>%s</td><td>%s</td></tr>"
|
|
829
|
+
% (_e(b.get("id")), _e(b.get("phase")), _e(b.get("reason"))))
|
|
830
|
+
add("</tbody></table></div>")
|
|
831
|
+
|
|
832
|
+
# --- the ratchet --------------------------------------------------------
|
|
833
|
+
if not rat.get("first_run"):
|
|
834
|
+
add("<h2>What moved since the last audit</h2>")
|
|
835
|
+
add('<div class="tw"><table><thead><tr><th>Movement</th>'
|
|
836
|
+
'<th class="num">Count</th><th>Ids</th></tr></thead><tbody>')
|
|
837
|
+
for label, key in (("closed", "closed"), ("new", "new"),
|
|
838
|
+
("still open", "carried")):
|
|
839
|
+
ids = rat.get(key, [])
|
|
840
|
+
add('<tr><td>%s</td><td class="num">%d</td><td class="mono">%s</td></tr>'
|
|
841
|
+
% (_e(label), len(ids), _e(", ".join(ids[:12]) or "—")))
|
|
842
|
+
add("</tbody></table></div>")
|
|
843
|
+
stale = [f for f in findings if int(f.get("runs_open", 0)) >= 3]
|
|
844
|
+
if stale:
|
|
845
|
+
add('<p class="note"><strong>%d finding(s) have survived three or '
|
|
846
|
+
'more audits.</strong> That is itself the finding: a defect '
|
|
847
|
+
'nobody picks up is a decision nobody wrote down.</p>'
|
|
848
|
+
% len(stale))
|
|
849
|
+
|
|
850
|
+
# --- probes ran ---------------------------------------------------------
|
|
851
|
+
add("<h2>Probes</h2>")
|
|
852
|
+
add('<div class="tw"><table><thead><tr><th>Probe</th><th>Phase</th>'
|
|
853
|
+
"<th>Verdict</th><th>Note</th></tr></thead><tbody>")
|
|
854
|
+
for x in probes:
|
|
855
|
+
v = x.get("verdict", "")
|
|
856
|
+
pill = {"clean": "p-ok", "finding": "p-bad",
|
|
857
|
+
"blind": "p-neutral"}.get(v, "p-neutral")
|
|
858
|
+
add('<tr><td class="mono">%s</td><td>%s</td>'
|
|
859
|
+
'<td><span class="pill %s">%s</span></td><td>%s</td></tr>'
|
|
860
|
+
% (_e(x.get("id")), _e(x.get("phase")), pill, _e(v),
|
|
861
|
+
_e(x.get("reason") or "")))
|
|
862
|
+
add("</tbody></table></div>")
|
|
863
|
+
|
|
864
|
+
add("<footer><p>Generated by <code>project-audit</code> at %s against "
|
|
865
|
+
"<code>%s</code>. Read-only: this run changed nothing but the two "
|
|
866
|
+
"files it wrote. Sidecar: <code>%s</code>.</p></footer>"
|
|
867
|
+
% (_e(payload.get("generated_at", "")), _e(payload.get("root", "")),
|
|
868
|
+
_e(payload.get("sidecar", ""))))
|
|
869
|
+
add("</div>\n</body>\n</html>")
|
|
870
|
+
return "\n".join(out)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def open_in_browser(path, opener=None):
|
|
874
|
+
"""Best effort, and a miss never fails the run (REQ-10)."""
|
|
875
|
+
candidates = [opener] if opener else (
|
|
876
|
+
["open"] if sys.platform == "darwin" else
|
|
877
|
+
["xdg-open"] if sys.platform.startswith("linux") else ["start"])
|
|
878
|
+
for cmd in candidates:
|
|
879
|
+
if not cmd:
|
|
880
|
+
continue
|
|
881
|
+
if shutil.which(cmd) is None:
|
|
882
|
+
return False, "%s is not on PATH — open %s yourself" % (cmd, path)
|
|
883
|
+
try:
|
|
884
|
+
rc = subprocess.run([cmd, path], capture_output=True,
|
|
885
|
+
timeout=TIMEOUT).returncode
|
|
886
|
+
except Exception as exc: # noqa: BLE001
|
|
887
|
+
return False, "%s: %s" % (type(exc).__name__, exc)
|
|
888
|
+
if rc == 0:
|
|
889
|
+
return True, ""
|
|
890
|
+
return False, "%s exited %d — open %s yourself" % (cmd, rc, path)
|
|
891
|
+
return False, "no opener for this platform — open %s yourself" % path
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
# ---------------------------------------------------------------------------
|
|
895
|
+
# the probes that ship with the collector
|
|
896
|
+
# ---------------------------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
def _finding(probe_id, where, title, severity, blast, effort, remedy,
|
|
899
|
+
detail="", evidence=""):
|
|
900
|
+
return {
|
|
901
|
+
"id": finding_id(probe_id, where, title), "probe": probe_id,
|
|
902
|
+
"title": title, "severity": severity, "where": where,
|
|
903
|
+
"detail": detail, "evidence": evidence, "remedy": remedy,
|
|
904
|
+
"blast": blast, "effort": effort, "runs_open": 0,
|
|
905
|
+
"first_seen": datetime.date.today().isoformat(),
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@probe("secrets-tree", "probe", needs=("git",))
|
|
910
|
+
def _p_secrets_tree(ctx):
|
|
911
|
+
rows = scan_secrets(ctx.root)
|
|
912
|
+
if not rows:
|
|
913
|
+
return Result("clean", "no credential pattern in the tracked tree")
|
|
914
|
+
return Result("finding", "%d credential pattern(s)" % len(rows), findings=[
|
|
915
|
+
_finding("secrets-tree", r["where"],
|
|
916
|
+
"A %s is committed in the tree" % r["class"],
|
|
917
|
+
"critical", 3, 1, r["remedy"]) for r in rows])
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@probe("secrets-history", "probe", needs=("git",))
|
|
921
|
+
def _p_secrets_history(ctx):
|
|
922
|
+
rows = scan_secret_history(ctx.root)
|
|
923
|
+
if not rows:
|
|
924
|
+
return Result("clean", "no credential pattern in the last 400 commits")
|
|
925
|
+
return Result("finding", "%d in history" % len(rows), findings=[
|
|
926
|
+
_finding("secrets-history", r["where"],
|
|
927
|
+
"A %s appears in git history" % r["class"],
|
|
928
|
+
"critical", 3, 2, r["remedy"]) for r in rows])
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
@probe("worktree", "probe", needs=("git",))
|
|
932
|
+
def _p_worktree(ctx):
|
|
933
|
+
state = worktree_state(ctx.root, ignore=(ctx.out_rel,))
|
|
934
|
+
if state.get("dirty") is None:
|
|
935
|
+
return Result("blind", state.get("reason") or "cannot read git status")
|
|
936
|
+
if not state["dirty"]:
|
|
937
|
+
return Result("clean", "working tree clean")
|
|
938
|
+
return Result("clean", "%d uncommitted path(s) — disclosed, not a finding: "
|
|
939
|
+
"an audit reports the committed project"
|
|
940
|
+
% len(state["paths"]))
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
@probe("telemetry", "prod", needs=())
|
|
944
|
+
def _p_telemetry(ctx):
|
|
945
|
+
found = ctx.profile.get("telemetry") or []
|
|
946
|
+
if found:
|
|
947
|
+
return Result("clean", "declares %s" % ", ".join(found))
|
|
948
|
+
langs = ctx.profile.get("languages") or []
|
|
949
|
+
deploy = ctx.profile.get("deploy") or []
|
|
950
|
+
if not deploy:
|
|
951
|
+
return Result("clean", "no deploy target declared — a library or tool, "
|
|
952
|
+
"for which absent telemetry is a design, not a gap")
|
|
953
|
+
return Result("finding", "no error reporting found", findings=[_finding(
|
|
954
|
+
"telemetry", "manifests",
|
|
955
|
+
"A deployed surface reports no errors anywhere its maintainer can see",
|
|
956
|
+
"medium", 2, 2,
|
|
957
|
+
"add an error reporter, or record the decision not to — the gap worth "
|
|
958
|
+
"closing is that nobody wrote down which it is",
|
|
959
|
+
detail="Deploy targets declared (%s) with no telemetry dependency in "
|
|
960
|
+
"any manifest. A failure on a user's machine is invisible."
|
|
961
|
+
% (", ".join(deploy) or "none"),
|
|
962
|
+
evidence="languages=%s deploy=%s telemetry=[]"
|
|
963
|
+
% (",".join(langs), ",".join(deploy)))])
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
@probe("ci-present", "prod", needs=())
|
|
967
|
+
def _p_ci(ctx):
|
|
968
|
+
if ctx.profile.get("ci"):
|
|
969
|
+
return Result("clean", "CI configured: %s"
|
|
970
|
+
% ", ".join(ctx.profile["ci"]))
|
|
971
|
+
return Result("finding", "no CI configuration", findings=[_finding(
|
|
972
|
+
"ci-present", "repository root",
|
|
973
|
+
"Nothing runs the checks except a person remembering to",
|
|
974
|
+
"medium", 2, 2,
|
|
975
|
+
"add a workflow that runs the project's own test command on push",
|
|
976
|
+
evidence="no .github/workflows, .gitlab-ci.yml, Jenkinsfile or "
|
|
977
|
+
".circleci/config.yml in the tracked tree")])
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@probe("docs-present", "seams", needs=())
|
|
981
|
+
def _p_docs(ctx):
|
|
982
|
+
docs = ctx.profile.get("docs") or []
|
|
983
|
+
if "README.md" in docs:
|
|
984
|
+
return Result("clean", "%d documentation marker(s): %s"
|
|
985
|
+
% (len(docs), ", ".join(docs)))
|
|
986
|
+
return Result("finding", "no README", findings=[_finding(
|
|
987
|
+
"docs-present", "repository root",
|
|
988
|
+
"The project has no README, so its entry point is a person",
|
|
989
|
+
"low", 1, 1, "write the four lines: what it is, how to run it, how to "
|
|
990
|
+
"test it, where the docs are")])
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
@probe("gitignore-secrets", "probe", needs=("git",))
|
|
994
|
+
def _p_gitignore(ctx):
|
|
995
|
+
tracked = tracked_files(ctx.root)
|
|
996
|
+
risky = [p for p in tracked
|
|
997
|
+
if os.path.basename(p) in (".env", ".npmrc", ".pypirc",
|
|
998
|
+
"id_rsa", "credentials")
|
|
999
|
+
or p.endswith(".pem") or p.endswith(".p12")]
|
|
1000
|
+
if not risky:
|
|
1001
|
+
return Result("clean", "no credential-shaped file is tracked")
|
|
1002
|
+
return Result("finding", "%d tracked" % len(risky), findings=[_finding(
|
|
1003
|
+
"gitignore-secrets", p,
|
|
1004
|
+
"A file that normally holds credentials is tracked by git",
|
|
1005
|
+
"high", 3, 1,
|
|
1006
|
+
"move it out of the tree and add it to .gitignore; if it ever held a "
|
|
1007
|
+
"live value, rotate that value first") for p in risky])
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _tree_from_tag(root, ref, limit=4000):
|
|
1011
|
+
"""`{path: content}` for a git ref, read through git rather than the disk."""
|
|
1012
|
+
rc, out, _ = _git(root, "ls-tree", "-r", "--name-only", ref)
|
|
1013
|
+
if rc != 0:
|
|
1014
|
+
return None
|
|
1015
|
+
tree = {}
|
|
1016
|
+
for rel in [x for x in out.split("\n") if x.strip()][:limit]:
|
|
1017
|
+
if os.path.splitext(rel)[1].lower() in SKIP_EXT:
|
|
1018
|
+
continue
|
|
1019
|
+
rc2, blob, _ = _git(root, "show", "%s:%s" % (ref, rel))
|
|
1020
|
+
if rc2 == 0:
|
|
1021
|
+
tree[rel] = blob
|
|
1022
|
+
return tree
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _tree_from_npm(name, version, workdir):
|
|
1026
|
+
"""`{path: content}` for what the registry actually serves."""
|
|
1027
|
+
import tarfile
|
|
1028
|
+
import urllib.request
|
|
1029
|
+
spec = "%s@%s" % (name, version)
|
|
1030
|
+
try:
|
|
1031
|
+
p = subprocess.run(["npm", "view", spec, "dist.tarball"],
|
|
1032
|
+
capture_output=True, text=True, timeout=TIMEOUT)
|
|
1033
|
+
except Exception: # noqa: BLE001
|
|
1034
|
+
return None
|
|
1035
|
+
url = (p.stdout or "").strip().split("\n")[0]
|
|
1036
|
+
if p.returncode != 0 or not url.startswith("https://"):
|
|
1037
|
+
return None
|
|
1038
|
+
tgz = os.path.join(workdir, "pkg.tgz")
|
|
1039
|
+
try:
|
|
1040
|
+
with urllib.request.urlopen(url, timeout=TIMEOUT) as resp:
|
|
1041
|
+
with open(tgz, "wb") as fh:
|
|
1042
|
+
shutil.copyfileobj(resp, fh, length=1 << 20)
|
|
1043
|
+
tree = {}
|
|
1044
|
+
with tarfile.open(tgz) as tar:
|
|
1045
|
+
for member in tar.getmembers():
|
|
1046
|
+
if not member.isfile() or member.size > 2_000_000:
|
|
1047
|
+
continue
|
|
1048
|
+
rel = member.name.split("/", 1)[-1] # strip the `package/` root
|
|
1049
|
+
if os.path.splitext(rel)[1].lower() in SKIP_EXT:
|
|
1050
|
+
continue
|
|
1051
|
+
fh = tar.extractfile(member)
|
|
1052
|
+
if fh is None:
|
|
1053
|
+
continue
|
|
1054
|
+
tree[rel] = fh.read().decode("utf-8", "replace")
|
|
1055
|
+
return tree
|
|
1056
|
+
except Exception: # noqa: BLE001
|
|
1057
|
+
return None
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
@probe("channel-divergence", "prod", needs=("git",))
|
|
1061
|
+
def _p_channels(ctx):
|
|
1062
|
+
"""One version string, more than one tree — the class a pin check cannot see.
|
|
1063
|
+
|
|
1064
|
+
**Which pair, and why not the obvious one.** The first draft compared the
|
|
1065
|
+
npm tarball against the git tag and reported `clean`. Those two agree by
|
|
1066
|
+
construction — npm publishes *from* the tag — so the answer was a tautology,
|
|
1067
|
+
and a tautology returning green is the `false success` shape `gates.md`
|
|
1068
|
+
names: a mechanism trusted by its own reply.
|
|
1069
|
+
|
|
1070
|
+
The channels a consumer actually installs from disagree elsewhere. npm
|
|
1071
|
+
serves the **tag**; a plugin marketplace and a skills CLI serve the **branch
|
|
1072
|
+
tip**. Measured in this family on 2026-08-22: npm served `agent_sync.py` at
|
|
1073
|
+
4344 lines while the marketplace served 4575, and all three answered
|
|
1074
|
+
`1.15.0`. The check only means something when the two sides both claim the
|
|
1075
|
+
same version, so that is the precondition rather than the finding.
|
|
1076
|
+
"""
|
|
1077
|
+
version = ctx.profile.get("version")
|
|
1078
|
+
name = ctx.profile.get("name")
|
|
1079
|
+
if not version:
|
|
1080
|
+
return Result("blind", "no version in a manifest to make a claim about")
|
|
1081
|
+
rc, _, _ = _git(ctx.root, "rev-parse", "--verify", "v%s^{}" % version)
|
|
1082
|
+
if rc != 0:
|
|
1083
|
+
return Result("blind", "HEAD says %s and no tag v%s exists — the "
|
|
1084
|
+
"channels make no common claim yet"
|
|
1085
|
+
% (version, version))
|
|
1086
|
+
|
|
1087
|
+
channels = {"git-tag v%s" % version: _tree_from_tag(ctx.root, "v%s" % version),
|
|
1088
|
+
"branch tip (HEAD)": _tree_from_tag(ctx.root, "HEAD")}
|
|
1089
|
+
if {"npm", "network"} <= ctx.capabilities and name:
|
|
1090
|
+
import tempfile
|
|
1091
|
+
work = tempfile.mkdtemp(prefix="pa-channels-")
|
|
1092
|
+
try:
|
|
1093
|
+
channels["npm %s" % name] = _tree_from_npm(name, version, work)
|
|
1094
|
+
finally:
|
|
1095
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
1096
|
+
|
|
1097
|
+
verdict = compare_channels(version, channels)
|
|
1098
|
+
if verdict["diverged"] is None:
|
|
1099
|
+
return Result("blind", "fewer than two channels were readable: %s"
|
|
1100
|
+
% ", ".join(verdict["blind"]))
|
|
1101
|
+
if not verdict["diverged"]:
|
|
1102
|
+
return Result("clean", "%d channel(s) claiming %s ship the same tree"
|
|
1103
|
+
% (len(channels) - len(verdict["blind"]), version))
|
|
1104
|
+
|
|
1105
|
+
paths = verdict["differing"]
|
|
1106
|
+
shown = ", ".join(paths[:5]) + (" …" if len(paths) > 5 else "")
|
|
1107
|
+
return Result("finding", "%d path(s) differ" % len(paths), findings=[_finding(
|
|
1108
|
+
"channel-divergence", "v%s" % version,
|
|
1109
|
+
"One version string, more than one tree",
|
|
1110
|
+
"critical", 3, 1,
|
|
1111
|
+
"tag the tree the channels should share — a patch release from the "
|
|
1112
|
+
"branch tip, or a revert of what landed after the tag; a version that "
|
|
1113
|
+
"identifies two artefacts cannot be reasoned about, and no version "
|
|
1114
|
+
"check comparing strings will ever say so",
|
|
1115
|
+
detail="%s all answer to %s and disagree on %d path(s). Anything that "
|
|
1116
|
+
"compares version STRINGS stays green through this."
|
|
1117
|
+
% (", ".join(sorted(verdict["channels"])), version, len(paths)),
|
|
1118
|
+
evidence="differing: %s" % shown)])
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
@probe("published-version", "prod", needs=("npm", "network"))
|
|
1122
|
+
def _p_published(ctx):
|
|
1123
|
+
name = ctx.profile.get("name")
|
|
1124
|
+
version = ctx.profile.get("version")
|
|
1125
|
+
if not (name and version):
|
|
1126
|
+
return Result("blind", "no name+version in a manifest")
|
|
1127
|
+
try:
|
|
1128
|
+
p = subprocess.run(["npm", "view", name, "version"],
|
|
1129
|
+
capture_output=True, text=True, timeout=TIMEOUT)
|
|
1130
|
+
except Exception as exc: # noqa: BLE001
|
|
1131
|
+
return Result("blind", "%s: %s" % (type(exc).__name__, exc))
|
|
1132
|
+
if classify_output(p.returncode, p.stdout, p.stderr) == "blind":
|
|
1133
|
+
return Result("blind", "npm view said nothing about %s — unpublished, "
|
|
1134
|
+
"private, or the registry is unreachable" % name)
|
|
1135
|
+
latest = p.stdout.strip().split("\n")[0]
|
|
1136
|
+
if latest == version:
|
|
1137
|
+
return Result("clean", "the registry serves %s, the manifest says %s"
|
|
1138
|
+
% (latest, version))
|
|
1139
|
+
return Result("finding", "registry %s vs manifest %s" % (latest, version),
|
|
1140
|
+
findings=[_finding(
|
|
1141
|
+
"published-version", "package.json",
|
|
1142
|
+
"The registry and the manifest disagree about the current version",
|
|
1143
|
+
"high", 3, 1,
|
|
1144
|
+
"publish the manifest's version, or bring the manifest back to what "
|
|
1145
|
+
"shipped; a reader cannot tell which one is the product",
|
|
1146
|
+
evidence="npm view %s version -> %s; manifest -> %s"
|
|
1147
|
+
% (name, latest, version))])
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
# ---------------------------------------------------------------------------
|
|
1151
|
+
# the run
|
|
1152
|
+
# ---------------------------------------------------------------------------
|
|
1153
|
+
|
|
1154
|
+
def capabilities(root, offline):
|
|
1155
|
+
"""What this machine can actually do, resolved once.
|
|
1156
|
+
|
|
1157
|
+
A probe asks for a capability by name and never shells out to find out —
|
|
1158
|
+
that way the reason a probe was skipped is a fact the report can print.
|
|
1159
|
+
"""
|
|
1160
|
+
caps = set()
|
|
1161
|
+
if is_repo(root):
|
|
1162
|
+
caps.add("git")
|
|
1163
|
+
for binary, name in (("gh", "gh"), ("npm", "npm"), ("python3", "python3"),
|
|
1164
|
+
("node", "node"), ("cargo", "cargo"), ("go", "go"),
|
|
1165
|
+
("docker", "docker")):
|
|
1166
|
+
if shutil.which(binary):
|
|
1167
|
+
caps.add(name)
|
|
1168
|
+
if not offline:
|
|
1169
|
+
caps.add("network")
|
|
1170
|
+
return caps
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def collect(root, offline=False, out_rel=OUT_DIR):
|
|
1174
|
+
root = os.path.abspath(root)
|
|
1175
|
+
profile = discover(root)
|
|
1176
|
+
caps = capabilities(root, offline)
|
|
1177
|
+
ctx = Ctx(root=root, profile=profile, capabilities=caps, offline=offline,
|
|
1178
|
+
out_rel=out_rel)
|
|
1179
|
+
|
|
1180
|
+
rows, findings = [], []
|
|
1181
|
+
for p in PROBES:
|
|
1182
|
+
result = run_probe(p, ctx)
|
|
1183
|
+
rows.append({"id": p.id, "phase": p.phase, "needs": list(p.needs),
|
|
1184
|
+
"verdict": result.verdict, "reason": result.reason})
|
|
1185
|
+
findings.extend(result.findings)
|
|
1186
|
+
|
|
1187
|
+
return {
|
|
1188
|
+
"schema": SCHEMA,
|
|
1189
|
+
"generated_at": datetime.datetime.now(
|
|
1190
|
+
datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1191
|
+
"root": root,
|
|
1192
|
+
"profile": profile,
|
|
1193
|
+
"capabilities": sorted(caps),
|
|
1194
|
+
"probes": rows,
|
|
1195
|
+
"findings": findings,
|
|
1196
|
+
"counts": {
|
|
1197
|
+
"probes_run": sum(1 for r in rows if r["verdict"] != "blind"),
|
|
1198
|
+
"probes_blind": sum(1 for r in rows if r["verdict"] == "blind"),
|
|
1199
|
+
"findings": len(findings),
|
|
1200
|
+
},
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def main(argv=None):
|
|
1205
|
+
ap = argparse.ArgumentParser(
|
|
1206
|
+
description="Collect a project audit and write two artefacts.")
|
|
1207
|
+
ap.add_argument("--root", default=".", help="project root (default: .)")
|
|
1208
|
+
ap.add_argument("--out", default=None,
|
|
1209
|
+
help="output directory (default: <root>/docs/audit)")
|
|
1210
|
+
ap.add_argument("--no-open", action="store_true",
|
|
1211
|
+
help="do not open the report in a browser")
|
|
1212
|
+
ap.add_argument("--offline", action="store_true",
|
|
1213
|
+
help="skip every probe that needs the network")
|
|
1214
|
+
ap.add_argument("--json", action="store_true",
|
|
1215
|
+
help="print the sidecar to stdout instead of a summary")
|
|
1216
|
+
args = ap.parse_args(argv)
|
|
1217
|
+
|
|
1218
|
+
root = os.path.abspath(args.root)
|
|
1219
|
+
if not os.path.isdir(root):
|
|
1220
|
+
sys.stderr.write("not a directory: %s\n" % root)
|
|
1221
|
+
return 1
|
|
1222
|
+
|
|
1223
|
+
out_dir = args.out or os.path.join(root, OUT_DIR)
|
|
1224
|
+
out_rel = os.path.relpath(out_dir, root)
|
|
1225
|
+
payload = collect(root, offline=args.offline, out_rel=out_rel)
|
|
1226
|
+
|
|
1227
|
+
day = payload["generated_at"][:10]
|
|
1228
|
+
base = "%s-audit" % day
|
|
1229
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1230
|
+
prior = load_prior(out_dir, exclude=base + ".json")
|
|
1231
|
+
payload = carry_forward(payload, prior)
|
|
1232
|
+
payload["ratchet"] = ratchet(payload, prior)
|
|
1233
|
+
payload["sidecar"] = os.path.join(out_dir, base + ".json")
|
|
1234
|
+
|
|
1235
|
+
json_path = os.path.join(out_dir, base + ".json")
|
|
1236
|
+
html_path = os.path.join(out_dir, base + ".html")
|
|
1237
|
+
with open(json_path, "w", encoding="utf-8") as fh:
|
|
1238
|
+
json.dump(payload, fh, indent=1, ensure_ascii=False, sort_keys=True)
|
|
1239
|
+
fh.write("\n")
|
|
1240
|
+
with open(html_path, "w", encoding="utf-8") as fh:
|
|
1241
|
+
fh.write(render_html(payload))
|
|
1242
|
+
|
|
1243
|
+
if args.json:
|
|
1244
|
+
print(json.dumps(payload, indent=1, ensure_ascii=False))
|
|
1245
|
+
else:
|
|
1246
|
+
c = payload["counts"]
|
|
1247
|
+
r = payload["ratchet"]
|
|
1248
|
+
print("project-audit %s — %d finding(s), %d probe(s) ran, %d blind"
|
|
1249
|
+
% (payload["profile"].get("name"), c["findings"],
|
|
1250
|
+
c["probes_run"], c["probes_blind"]))
|
|
1251
|
+
if r["first_run"]:
|
|
1252
|
+
print(" first run in %s — no earlier sidecar to compare against"
|
|
1253
|
+
% out_dir)
|
|
1254
|
+
else:
|
|
1255
|
+
print(" closed %d · new %d · still open %d"
|
|
1256
|
+
% (len(r["closed"]), len(r["new"]), len(r["carried"])))
|
|
1257
|
+
print(" %s\n %s" % (html_path, json_path))
|
|
1258
|
+
|
|
1259
|
+
if not args.no_open:
|
|
1260
|
+
ok, note = open_in_browser(html_path)
|
|
1261
|
+
if not ok:
|
|
1262
|
+
sys.stderr.write("could not open the report: %s\n" % note)
|
|
1263
|
+
return 0
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
if __name__ == "__main__":
|
|
1267
|
+
sys.exit(main())
|