@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.
@@ -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())