@testzugang/pi-plugin-dependency-audit 0.1.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/README.md +19 -0
- package/package.json +23 -0
- package/skills/dependency-audit/README.md +105 -0
- package/skills/dependency-audit/SKILL.md +353 -0
- package/skills/dependency-audit/config.json +3 -0
- package/skills/dependency-audit/examples/github-actions-static-audit.yml +46 -0
- package/skills/dependency-audit/examples/sample-commands.md +110 -0
- package/skills/dependency-audit/rules/iocs.txt +23 -0
- package/skills/dependency-audit/rules/review-policy.md +38 -0
- package/skills/dependency-audit/scripts/npm_ts_static_triage.py +1345 -0
- package/skills/dependency-audit/scripts/pi-check-all-updates.sh +15 -0
- package/skills/dependency-audit/scripts/pi-check-current-global-versions.sh +37 -0
- package/skills/dependency-audit/scripts/pi-check-git-source-updates.sh +57 -0
- package/skills/dependency-audit/scripts/pi-check-latest-npm-versions.sh +25 -0
- package/skills/dependency-audit/scripts/pi-default-git-repos.txt +4 -0
- package/skills/dependency-audit/scripts/pi-default-packages.txt +16 -0
- package/skills/dependency-audit/scripts/pi-interactive-update.py +151 -0
- package/skills/dependency-audit/scripts/run_pi_dependency_audit.py +528 -0
- package/skills/dependency-audit/scripts/summarize_pi_dependency_audit.py +242 -0
- package/skills/dependency-audit/templates/report.md +102 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
SECURITY_ENV_VARS = [
|
|
17
|
+
"GITHUB_TOKEN",
|
|
18
|
+
"GH_TOKEN",
|
|
19
|
+
"NPM_TOKEN",
|
|
20
|
+
"NODE_AUTH_TOKEN",
|
|
21
|
+
"AWS_ACCESS_KEY_ID",
|
|
22
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
23
|
+
"AWS_SESSION_TOKEN",
|
|
24
|
+
"VAULT_TOKEN",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
28
|
+
SKILL_DIR = SCRIPT_DIR.parent
|
|
29
|
+
TRIAGE_SCRIPT = SCRIPT_DIR / "npm_ts_static_triage.py"
|
|
30
|
+
DEFAULT_PACKAGES_FILE = SCRIPT_DIR / "pi-default-packages.txt"
|
|
31
|
+
DEFAULT_GIT_REPOS_FILE = SCRIPT_DIR / "pi-default-git-repos.txt"
|
|
32
|
+
REPO_CONFIG_PATH = SKILL_DIR / "config.json"
|
|
33
|
+
HOME_CONFIG_PATH = Path.home() / ".pi" / "dependency-audit.json"
|
|
34
|
+
DEFAULT_CONFIG = {
|
|
35
|
+
"min_update_age_hours": 24,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
40
|
+
return subprocess.run(
|
|
41
|
+
cmd,
|
|
42
|
+
cwd=str(cwd) if cwd else None,
|
|
43
|
+
text=True,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
check=check,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def unset_security_env() -> None:
|
|
50
|
+
for env_var in SECURITY_ENV_VARS:
|
|
51
|
+
os.environ.pop(env_var, None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def utcnow() -> dt.datetime:
|
|
55
|
+
return dt.datetime.now(dt.timezone.utc)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_iso_datetime(value: str | None) -> dt.datetime | None:
|
|
59
|
+
if not value:
|
|
60
|
+
return None
|
|
61
|
+
text = value.strip()
|
|
62
|
+
if not text:
|
|
63
|
+
return None
|
|
64
|
+
if text.endswith("Z"):
|
|
65
|
+
text = text[:-1] + "+00:00"
|
|
66
|
+
try:
|
|
67
|
+
parsed = dt.datetime.fromisoformat(text)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
if parsed.tzinfo is None:
|
|
71
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
72
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def age_hours(since: dt.datetime | None) -> float | None:
|
|
76
|
+
if since is None:
|
|
77
|
+
return None
|
|
78
|
+
delta = utcnow() - since
|
|
79
|
+
return max(0.0, delta.total_seconds() / 3600.0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_non_comment_lines(path: Path) -> list[str]:
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return []
|
|
85
|
+
out: list[str] = []
|
|
86
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
87
|
+
value = line.strip()
|
|
88
|
+
if not value or value.startswith("#"):
|
|
89
|
+
continue
|
|
90
|
+
out.append(value)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_json_file(path: Path) -> dict[str, Any]:
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return {}
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
99
|
+
except json.JSONDecodeError as exc:
|
|
100
|
+
raise RuntimeError(f"Invalid JSON config at {path}: {exc}") from exc
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
raise RuntimeError(f"Config at {path} must be a JSON object")
|
|
103
|
+
return data
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalize_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
107
|
+
cfg = dict(DEFAULT_CONFIG)
|
|
108
|
+
cfg.update(raw)
|
|
109
|
+
|
|
110
|
+
value = cfg.get("min_update_age_hours", DEFAULT_CONFIG["min_update_age_hours"])
|
|
111
|
+
try:
|
|
112
|
+
number = float(value)
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
number = float(DEFAULT_CONFIG["min_update_age_hours"])
|
|
115
|
+
cfg["min_update_age_hours"] = max(0.0, number)
|
|
116
|
+
return cfg
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_config(config_override: str) -> tuple[dict[str, Any], list[str]]:
|
|
120
|
+
merged: dict[str, Any] = {}
|
|
121
|
+
sources: list[str] = []
|
|
122
|
+
|
|
123
|
+
for path in [REPO_CONFIG_PATH, HOME_CONFIG_PATH]:
|
|
124
|
+
if path.exists():
|
|
125
|
+
merged.update(read_json_file(path))
|
|
126
|
+
sources.append(str(path))
|
|
127
|
+
|
|
128
|
+
if config_override:
|
|
129
|
+
override_path = Path(config_override)
|
|
130
|
+
merged.update(read_json_file(override_path))
|
|
131
|
+
sources.append(str(override_path))
|
|
132
|
+
|
|
133
|
+
return normalize_config(merged), sources
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def npm_global_root() -> Path:
|
|
137
|
+
override = os.environ.get("NPM_GLOBAL_ROOT", "").strip()
|
|
138
|
+
if override:
|
|
139
|
+
return Path(override)
|
|
140
|
+
result = run(["npm", "root", "-g"])
|
|
141
|
+
return Path(result.stdout.strip())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def current_global_version(global_root: Path, package: str) -> str | None:
|
|
145
|
+
package_json = global_root / package / "package.json"
|
|
146
|
+
if not package_json.exists():
|
|
147
|
+
return None
|
|
148
|
+
result = run(["node", "-p", f"require('{package_json}').version"], check=False)
|
|
149
|
+
version = (result.stdout or "").strip()
|
|
150
|
+
return version or None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def npm_latest_version(package: str) -> str | None:
|
|
154
|
+
result = run(["npm", "view", package, "version"], check=False)
|
|
155
|
+
version = (result.stdout or "").strip()
|
|
156
|
+
return version or None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def npm_version_published_at(package: str, version: str) -> dt.datetime | None:
|
|
160
|
+
result = run(["npm", "view", package, "time", "--json"], check=False)
|
|
161
|
+
if result.returncode != 0:
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
data = json.loads((result.stdout or "").strip())
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
return None
|
|
167
|
+
if not isinstance(data, dict):
|
|
168
|
+
return None
|
|
169
|
+
raw = data.get(version)
|
|
170
|
+
return parse_iso_datetime(raw if isinstance(raw, str) else None)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def npm_tarball_url(package: str, version: str) -> str:
|
|
174
|
+
result = run(["npm", "view", f"{package}@{version}", "dist.tarball"])
|
|
175
|
+
url = result.stdout.strip()
|
|
176
|
+
if not url:
|
|
177
|
+
raise RuntimeError(f"No dist.tarball returned for {package}@{version}")
|
|
178
|
+
return url
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def scan_with_triage(target: Path, mode: str, report_json: Path) -> dict[str, Any]:
|
|
182
|
+
run(
|
|
183
|
+
[
|
|
184
|
+
"python3",
|
|
185
|
+
str(TRIAGE_SCRIPT),
|
|
186
|
+
str(target),
|
|
187
|
+
"--mode",
|
|
188
|
+
mode,
|
|
189
|
+
"--json",
|
|
190
|
+
str(report_json),
|
|
191
|
+
]
|
|
192
|
+
)
|
|
193
|
+
return json.loads(report_json.read_text(encoding="utf-8"))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def audit_npm_packages(workspace: Path, packages: list[str], min_age_hours: float) -> list[dict[str, Any]]:
|
|
197
|
+
results: list[dict[str, Any]] = []
|
|
198
|
+
global_root = npm_global_root()
|
|
199
|
+
|
|
200
|
+
print(f"[npm] Checking {len(packages)} package(s)…")
|
|
201
|
+
for idx, package in enumerate(packages, start=1):
|
|
202
|
+
print(f"[npm {idx}/{len(packages)}] {package}")
|
|
203
|
+
current = current_global_version(global_root, package)
|
|
204
|
+
latest = npm_latest_version(package)
|
|
205
|
+
|
|
206
|
+
if current is None:
|
|
207
|
+
print(" - not installed")
|
|
208
|
+
results.append(
|
|
209
|
+
{
|
|
210
|
+
"name": package,
|
|
211
|
+
"type": "npm",
|
|
212
|
+
"status": "not_installed",
|
|
213
|
+
"current": None,
|
|
214
|
+
"latest": latest,
|
|
215
|
+
"decision": "SKIP_NOT_INSTALLED",
|
|
216
|
+
"counts": {},
|
|
217
|
+
"findings": [],
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if latest is None:
|
|
223
|
+
print(f" - registry lookup failed (current={current})")
|
|
224
|
+
results.append(
|
|
225
|
+
{
|
|
226
|
+
"name": package,
|
|
227
|
+
"type": "npm",
|
|
228
|
+
"status": "registry_lookup_failed",
|
|
229
|
+
"current": current,
|
|
230
|
+
"latest": None,
|
|
231
|
+
"decision": "ERROR",
|
|
232
|
+
"counts": {},
|
|
233
|
+
"findings": [],
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if current == latest:
|
|
239
|
+
print(f" - up to date ({current})")
|
|
240
|
+
results.append(
|
|
241
|
+
{
|
|
242
|
+
"name": package,
|
|
243
|
+
"type": "npm",
|
|
244
|
+
"status": "up_to_date",
|
|
245
|
+
"current": current,
|
|
246
|
+
"latest": latest,
|
|
247
|
+
"decision": "PASS_UP_TO_DATE",
|
|
248
|
+
"counts": {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0},
|
|
249
|
+
"findings": [],
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
published_at = npm_version_published_at(package, latest)
|
|
255
|
+
update_age = age_hours(published_at)
|
|
256
|
+
if update_age is not None and update_age < min_age_hours:
|
|
257
|
+
print(f" - too fresh: {current} -> {latest} ({update_age:.1f}h < {min_age_hours:.1f}h)")
|
|
258
|
+
results.append(
|
|
259
|
+
{
|
|
260
|
+
"name": package,
|
|
261
|
+
"type": "npm",
|
|
262
|
+
"status": "too_fresh",
|
|
263
|
+
"current": current,
|
|
264
|
+
"latest": latest,
|
|
265
|
+
"published_at": published_at.isoformat() if published_at else None,
|
|
266
|
+
"update_age_hours": round(update_age, 3) if update_age is not None else None,
|
|
267
|
+
"min_update_age_hours": min_age_hours,
|
|
268
|
+
"decision": "SKIP_TOO_FRESH",
|
|
269
|
+
"counts": {},
|
|
270
|
+
"findings": [],
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
print(f" - update found: {current} -> {latest}")
|
|
277
|
+
tarball_url = npm_tarball_url(package, latest)
|
|
278
|
+
tarball_path = workspace / f"{package.replace('/', '_')}@{latest}.tgz"
|
|
279
|
+
urllib.request.urlretrieve(tarball_url, tarball_path)
|
|
280
|
+
|
|
281
|
+
report_json = workspace / f"{package.replace('/', '_')}_{latest}_report.json"
|
|
282
|
+
report_data = scan_with_triage(tarball_path, "package", report_json)
|
|
283
|
+
decision = report_data.get("decision", "UNKNOWN")
|
|
284
|
+
print(f" - triage decision: {decision}")
|
|
285
|
+
|
|
286
|
+
results.append(
|
|
287
|
+
{
|
|
288
|
+
"name": package,
|
|
289
|
+
"type": "npm",
|
|
290
|
+
"status": "update_available",
|
|
291
|
+
"current": current,
|
|
292
|
+
"latest": latest,
|
|
293
|
+
"published_at": published_at.isoformat() if published_at else None,
|
|
294
|
+
"update_age_hours": round(update_age, 3) if update_age is not None else None,
|
|
295
|
+
"min_update_age_hours": min_age_hours,
|
|
296
|
+
"decision": decision,
|
|
297
|
+
"counts": report_data.get("counts_by_severity", {}),
|
|
298
|
+
"findings": report_data.get("findings", []),
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
except Exception as exc: # noqa: BLE001
|
|
302
|
+
print(f" - error: {exc}")
|
|
303
|
+
results.append(
|
|
304
|
+
{
|
|
305
|
+
"name": package,
|
|
306
|
+
"type": "npm",
|
|
307
|
+
"status": "error",
|
|
308
|
+
"current": current,
|
|
309
|
+
"latest": latest,
|
|
310
|
+
"decision": "ERROR",
|
|
311
|
+
"error": str(exc),
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return results
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def repo_update_info(repo_path: Path) -> tuple[str, str, str, dt.datetime | None] | None:
|
|
319
|
+
if not repo_path.exists() or not (repo_path / ".git").exists():
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
branch = run(["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"], check=False).stdout.strip() or "HEAD"
|
|
323
|
+
current = run(["git", "-C", str(repo_path), "rev-parse", "HEAD"], check=False).stdout.strip() or "UNKNOWN"
|
|
324
|
+
run(["git", "-C", str(repo_path), "fetch", "--quiet", "origin"], check=False)
|
|
325
|
+
|
|
326
|
+
remote = "NO_REMOTE_BRANCH"
|
|
327
|
+
remote_time: dt.datetime | None = None
|
|
328
|
+
if branch not in {"HEAD", "DETACHED"}:
|
|
329
|
+
remote_ref = f"origin/{branch}"
|
|
330
|
+
remote = run(["git", "-C", str(repo_path), "rev-parse", remote_ref], check=False).stdout.strip() or "NO_REMOTE_BRANCH"
|
|
331
|
+
if remote not in {"NO_REMOTE_BRANCH", ""}:
|
|
332
|
+
time_out = run(["git", "-C", str(repo_path), "show", "-s", "--format=%cI", remote_ref], check=False).stdout.strip()
|
|
333
|
+
remote_time = parse_iso_datetime(time_out)
|
|
334
|
+
return branch, current, remote, remote_time
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def audit_git_repos(workspace: Path, repos: list[str], min_age_hours: float) -> list[dict[str, Any]]:
|
|
338
|
+
results: list[dict[str, Any]] = []
|
|
339
|
+
|
|
340
|
+
print(f"[git] Checking {len(repos)} repo(s)…")
|
|
341
|
+
for idx, repo in enumerate(repos, start=1):
|
|
342
|
+
repo_path = Path(repo)
|
|
343
|
+
print(f"[git {idx}/{len(repos)}] {repo_path}")
|
|
344
|
+
info = repo_update_info(repo_path)
|
|
345
|
+
if info is None:
|
|
346
|
+
print(" - missing or not a git repo")
|
|
347
|
+
results.append(
|
|
348
|
+
{
|
|
349
|
+
"name": repo,
|
|
350
|
+
"type": "git",
|
|
351
|
+
"status": "missing_or_not_git",
|
|
352
|
+
"decision": "SKIP_MISSING",
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
branch, current, remote, remote_time = info
|
|
358
|
+
origin_url = run(["git", "-C", str(repo_path), "remote", "get-url", "origin"], check=False).stdout.strip()
|
|
359
|
+
|
|
360
|
+
if remote in {"NO_REMOTE_BRANCH", ""}:
|
|
361
|
+
print(f" - no remote branch for {branch}")
|
|
362
|
+
results.append(
|
|
363
|
+
{
|
|
364
|
+
"name": repo_path.name,
|
|
365
|
+
"type": "git",
|
|
366
|
+
"status": "unknown",
|
|
367
|
+
"branch": branch,
|
|
368
|
+
"current": current,
|
|
369
|
+
"latest": None,
|
|
370
|
+
"decision": "SKIP_NO_REMOTE_BRANCH",
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
if current == remote:
|
|
376
|
+
print(f" - up to date on {branch}")
|
|
377
|
+
results.append(
|
|
378
|
+
{
|
|
379
|
+
"name": repo_path.name,
|
|
380
|
+
"type": "git",
|
|
381
|
+
"status": "up_to_date",
|
|
382
|
+
"branch": branch,
|
|
383
|
+
"current": current,
|
|
384
|
+
"latest": remote,
|
|
385
|
+
"decision": "PASS_UP_TO_DATE",
|
|
386
|
+
"counts": {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0},
|
|
387
|
+
"findings": [],
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
remote_age = age_hours(remote_time)
|
|
393
|
+
if remote_age is not None and remote_age < min_age_hours:
|
|
394
|
+
print(f" - too fresh: {current[:8]} -> {remote[:8]} ({remote_age:.1f}h < {min_age_hours:.1f}h)")
|
|
395
|
+
results.append(
|
|
396
|
+
{
|
|
397
|
+
"name": repo_path.name,
|
|
398
|
+
"type": "git",
|
|
399
|
+
"status": "too_fresh",
|
|
400
|
+
"branch": branch,
|
|
401
|
+
"current": current,
|
|
402
|
+
"latest": remote,
|
|
403
|
+
"published_at": remote_time.isoformat() if remote_time else None,
|
|
404
|
+
"update_age_hours": round(remote_age, 3) if remote_age is not None else None,
|
|
405
|
+
"min_update_age_hours": min_age_hours,
|
|
406
|
+
"decision": "SKIP_TOO_FRESH",
|
|
407
|
+
"counts": {},
|
|
408
|
+
"findings": [],
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
print(f" - update found: {current[:8]} -> {remote[:8]}")
|
|
415
|
+
clone_target = workspace / f"{repo_path.name}_latest"
|
|
416
|
+
if clone_target.exists():
|
|
417
|
+
shutil.rmtree(clone_target)
|
|
418
|
+
|
|
419
|
+
run(["git", "clone", "--no-checkout", origin_url, str(clone_target)])
|
|
420
|
+
run(["git", "-C", str(clone_target), "checkout", "--detach", remote])
|
|
421
|
+
|
|
422
|
+
report_json = workspace / f"{repo_path.name}_{remote[:8]}_report.json"
|
|
423
|
+
report_data = scan_with_triage(clone_target, "repo", report_json)
|
|
424
|
+
decision = report_data.get("decision", "UNKNOWN")
|
|
425
|
+
print(f" - triage decision: {decision}")
|
|
426
|
+
|
|
427
|
+
results.append(
|
|
428
|
+
{
|
|
429
|
+
"name": repo_path.name,
|
|
430
|
+
"type": "git",
|
|
431
|
+
"status": "update_available",
|
|
432
|
+
"branch": branch,
|
|
433
|
+
"current": current,
|
|
434
|
+
"latest": remote,
|
|
435
|
+
"published_at": remote_time.isoformat() if remote_time else None,
|
|
436
|
+
"update_age_hours": round(remote_age, 3) if remote_age is not None else None,
|
|
437
|
+
"min_update_age_hours": min_age_hours,
|
|
438
|
+
"decision": decision,
|
|
439
|
+
"counts": report_data.get("counts_by_severity", {}),
|
|
440
|
+
"findings": report_data.get("findings", []),
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
except Exception as exc: # noqa: BLE001
|
|
444
|
+
print(f" - error: {exc}")
|
|
445
|
+
results.append(
|
|
446
|
+
{
|
|
447
|
+
"name": repo_path.name,
|
|
448
|
+
"type": "git",
|
|
449
|
+
"status": "error",
|
|
450
|
+
"branch": branch,
|
|
451
|
+
"current": current,
|
|
452
|
+
"latest": remote,
|
|
453
|
+
"decision": "ERROR",
|
|
454
|
+
"error": str(exc),
|
|
455
|
+
}
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return results
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def summarize_results(results: list[dict[str, Any]]) -> None:
|
|
462
|
+
status_counts: dict[str, int] = {}
|
|
463
|
+
decision_counts: dict[str, int] = {}
|
|
464
|
+
for item in results:
|
|
465
|
+
status = str(item.get("status", "unknown"))
|
|
466
|
+
decision = str(item.get("decision", "UNKNOWN"))
|
|
467
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
468
|
+
decision_counts[decision] = decision_counts.get(decision, 0) + 1
|
|
469
|
+
|
|
470
|
+
print("\nSummary by status:")
|
|
471
|
+
for key in sorted(status_counts):
|
|
472
|
+
print(f" - {key}: {status_counts[key]}")
|
|
473
|
+
|
|
474
|
+
print("Summary by decision:")
|
|
475
|
+
for key in sorted(decision_counts):
|
|
476
|
+
print(f" - {key}: {decision_counts[key]}")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def parse_args() -> argparse.Namespace:
|
|
480
|
+
parser = argparse.ArgumentParser(description="Static audit for global pi dependency updates.")
|
|
481
|
+
parser.add_argument("--packages-file", default=str(DEFAULT_PACKAGES_FILE), help="Path to newline-separated npm package list")
|
|
482
|
+
parser.add_argument("--repos-file", default=str(DEFAULT_GIT_REPOS_FILE), help="Path to newline-separated git repo path list")
|
|
483
|
+
parser.add_argument("--workspace", default="", help="Workspace dir (default: temporary directory)")
|
|
484
|
+
parser.add_argument("--output", default="/tmp/pi_audit_aggregated.json", help="Aggregated JSON output path")
|
|
485
|
+
parser.add_argument("--config", default="", help="Optional config JSON path (highest precedence)")
|
|
486
|
+
return parser.parse_args()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def main() -> int:
|
|
490
|
+
args = parse_args()
|
|
491
|
+
unset_security_env()
|
|
492
|
+
|
|
493
|
+
if not TRIAGE_SCRIPT.exists():
|
|
494
|
+
print(f"Missing triage script: {TRIAGE_SCRIPT}", file=sys.stderr)
|
|
495
|
+
return 2
|
|
496
|
+
|
|
497
|
+
config, config_sources = load_config(args.config)
|
|
498
|
+
min_age_hours = float(config.get("min_update_age_hours", DEFAULT_CONFIG["min_update_age_hours"]))
|
|
499
|
+
|
|
500
|
+
print(f"Config: min_update_age_hours={min_age_hours:.1f}")
|
|
501
|
+
if config_sources:
|
|
502
|
+
print("Config sources:")
|
|
503
|
+
for source in config_sources:
|
|
504
|
+
print(f" - {source}")
|
|
505
|
+
else:
|
|
506
|
+
print("Config sources: defaults only")
|
|
507
|
+
|
|
508
|
+
packages = read_non_comment_lines(Path(args.packages_file))
|
|
509
|
+
repos = read_non_comment_lines(Path(args.repos_file))
|
|
510
|
+
|
|
511
|
+
workspace = Path(args.workspace) if args.workspace else Path(tempfile.mkdtemp(prefix="pi-audit-"))
|
|
512
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
513
|
+
|
|
514
|
+
print(f"Workspace: {workspace}")
|
|
515
|
+
|
|
516
|
+
results: list[dict[str, Any]] = []
|
|
517
|
+
results.extend(audit_npm_packages(workspace, packages, min_age_hours))
|
|
518
|
+
results.extend(audit_git_repos(workspace, repos, min_age_hours))
|
|
519
|
+
|
|
520
|
+
output = Path(args.output)
|
|
521
|
+
output.write_text(json.dumps(results, indent=2), encoding="utf-8")
|
|
522
|
+
print(f"Wrote aggregated report: {output}")
|
|
523
|
+
summarize_results(results)
|
|
524
|
+
return 0
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
if __name__ == "__main__":
|
|
528
|
+
raise SystemExit(main())
|