devguard 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devguard/INTEGRATION_SUMMARY.md +121 -0
- devguard/__init__.py +3 -0
- devguard/__main__.py +6 -0
- devguard/checkers/__init__.py +41 -0
- devguard/checkers/api_usage.py +523 -0
- devguard/checkers/aws_cost.py +331 -0
- devguard/checkers/aws_iam.py +284 -0
- devguard/checkers/base.py +25 -0
- devguard/checkers/container.py +137 -0
- devguard/checkers/domain.py +189 -0
- devguard/checkers/firecrawl.py +117 -0
- devguard/checkers/fly.py +225 -0
- devguard/checkers/github.py +210 -0
- devguard/checkers/npm.py +327 -0
- devguard/checkers/npm_security.py +244 -0
- devguard/checkers/redteam.py +290 -0
- devguard/checkers/secret.py +279 -0
- devguard/checkers/swarm.py +376 -0
- devguard/checkers/tailscale.py +143 -0
- devguard/checkers/tailsnitch.py +303 -0
- devguard/checkers/tavily.py +179 -0
- devguard/checkers/vercel.py +192 -0
- devguard/cli.py +1510 -0
- devguard/cli_helpers.py +189 -0
- devguard/config.py +249 -0
- devguard/core.py +293 -0
- devguard/dashboard.py +715 -0
- devguard/discovery.py +363 -0
- devguard/http_client.py +142 -0
- devguard/llm_service.py +481 -0
- devguard/mcp_server.py +259 -0
- devguard/metrics.py +144 -0
- devguard/models.py +208 -0
- devguard/reporting.py +1571 -0
- devguard/sarif.py +295 -0
- devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
- devguard/scripts/README.md +221 -0
- devguard/scripts/auto_fix_recommendations.py +145 -0
- devguard/scripts/generate_npmignore.py +175 -0
- devguard/scripts/generate_security_report.py +324 -0
- devguard/scripts/prepublish_check.sh +29 -0
- devguard/scripts/redteam_npm_packages.py +1262 -0
- devguard/scripts/review_all_repos.py +300 -0
- devguard/spec.py +617 -0
- devguard/sweeps/__init__.py +23 -0
- devguard/sweeps/ai_editor_config_audit.py +697 -0
- devguard/sweeps/cargo_publish_audit.py +655 -0
- devguard/sweeps/dependency_audit.py +419 -0
- devguard/sweeps/gitignore_audit.py +336 -0
- devguard/sweeps/local_dev.py +260 -0
- devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
- devguard/sweeps/project_flaudit.py +636 -0
- devguard/sweeps/public_github_secrets.py +680 -0
- devguard/sweeps/publish_audit.py +478 -0
- devguard/sweeps/ssh_key_audit.py +327 -0
- devguard/utils.py +174 -0
- devguard-0.2.0.dist-info/METADATA +225 -0
- devguard-0.2.0.dist-info/RECORD +60 -0
- devguard-0.2.0.dist-info/WHEEL +4 -0
- devguard-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Dependency audit sweep: scan local repos for known vulnerabilities in dependencies.
|
|
2
|
+
|
|
3
|
+
Discovers git repos under a dev root, detects language by manifest/lock files,
|
|
4
|
+
and runs the appropriate audit tool (cargo-audit, npm audit, pip-audit).
|
|
5
|
+
Produces a unified report with per-repo findings bucketed by severity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _utc_now() -> str:
|
|
24
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _default_dev_root() -> Path:
|
|
28
|
+
return Path(os.getenv("DEV_DIR") or "~/Documents/dev").expanduser()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Repo discovery (same pattern as gitignore_audit)
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _iter_git_repos(root: Path, max_depth: int) -> list[Path]:
|
|
36
|
+
"""Discover git repos under root, bounded by max_depth."""
|
|
37
|
+
root = root.resolve()
|
|
38
|
+
max_depth = max(0, min(int(max_depth), 6))
|
|
39
|
+
junk = {
|
|
40
|
+
"node_modules", ".venv", "venv", "dist", "build", ".git",
|
|
41
|
+
".cache", ".state", "__pycache__", "_trash", "_scratch",
|
|
42
|
+
"_external", "_archive", "_forks",
|
|
43
|
+
}
|
|
44
|
+
repos: list[Path] = []
|
|
45
|
+
stack: list[tuple[Path, int]] = [(root, 0)]
|
|
46
|
+
seen: set[Path] = set()
|
|
47
|
+
while stack:
|
|
48
|
+
cur, depth = stack.pop()
|
|
49
|
+
if cur in seen:
|
|
50
|
+
continue
|
|
51
|
+
seen.add(cur)
|
|
52
|
+
if (cur / ".git").exists():
|
|
53
|
+
repos.append(cur)
|
|
54
|
+
continue
|
|
55
|
+
if depth >= max_depth:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
for child in cur.iterdir():
|
|
59
|
+
if not child.is_dir():
|
|
60
|
+
continue
|
|
61
|
+
name = child.name
|
|
62
|
+
if depth == 0 and name in junk:
|
|
63
|
+
continue
|
|
64
|
+
if name.startswith("."):
|
|
65
|
+
continue
|
|
66
|
+
stack.append((child, depth + 1))
|
|
67
|
+
except Exception:
|
|
68
|
+
continue
|
|
69
|
+
return sorted(repos)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Language / engine detection
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
# Maps lock/manifest files to (language, engine_name).
|
|
77
|
+
_MANIFEST_MAP: list[tuple[str, str, str]] = [
|
|
78
|
+
("Cargo.lock", "rust", "cargo-audit"),
|
|
79
|
+
("package-lock.json", "js", "npm-audit"),
|
|
80
|
+
("yarn.lock", "js", "npm-audit"),
|
|
81
|
+
("pnpm-lock.yaml", "js", "npm-audit"),
|
|
82
|
+
("uv.lock", "python", "pip-audit"),
|
|
83
|
+
("requirements.txt", "python", "pip-audit"),
|
|
84
|
+
("poetry.lock", "python", "pip-audit"),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class DetectedEngine:
|
|
90
|
+
language: str
|
|
91
|
+
engine: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect_engines(repo: Path) -> list[DetectedEngine]:
|
|
95
|
+
"""Detect which audit engines apply to a repo based on manifest files."""
|
|
96
|
+
seen_engines: set[str] = set()
|
|
97
|
+
results: list[DetectedEngine] = []
|
|
98
|
+
for filename, lang, engine in _MANIFEST_MAP:
|
|
99
|
+
if engine in seen_engines:
|
|
100
|
+
continue
|
|
101
|
+
if (repo / filename).exists():
|
|
102
|
+
seen_engines.add(engine)
|
|
103
|
+
results.append(DetectedEngine(language=lang, engine=engine))
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# JSON output parsers
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
SEVERITY_BUCKETS = ("critical", "high", "medium", "low")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class VulnSummary:
|
|
116
|
+
id: str
|
|
117
|
+
severity: str # one of SEVERITY_BUCKETS or "unknown"
|
|
118
|
+
package: str
|
|
119
|
+
title: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cargo_severity_from_categories(categories: list[str]) -> str:
|
|
123
|
+
"""Infer severity from cargo-audit advisory categories when no explicit severity."""
|
|
124
|
+
high_cats = {"memory-corruption", "memory-exposure", "code-execution"}
|
|
125
|
+
medium_cats = {"denial-of-service", "crypto-failure", "thread-safety"}
|
|
126
|
+
for cat in categories:
|
|
127
|
+
if cat in high_cats:
|
|
128
|
+
return "high"
|
|
129
|
+
for cat in categories:
|
|
130
|
+
if cat in medium_cats:
|
|
131
|
+
return "medium"
|
|
132
|
+
return "unknown"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_cargo_audit_json(raw: str) -> list[VulnSummary]:
|
|
136
|
+
"""Parse `cargo audit --json` output."""
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(raw)
|
|
139
|
+
except (json.JSONDecodeError, ValueError):
|
|
140
|
+
return []
|
|
141
|
+
vulns: list[VulnSummary] = []
|
|
142
|
+
for v in data.get("vulnerabilities", {}).get("list", []):
|
|
143
|
+
advisory = v.get("advisory", {})
|
|
144
|
+
pkg = v.get("package", {})
|
|
145
|
+
# Try explicit severity, then CVSS, then infer from categories
|
|
146
|
+
sev_str = _normalize_severity(advisory.get("severity"))
|
|
147
|
+
if sev_str == "unknown" and advisory.get("cvss"):
|
|
148
|
+
sev_str = _normalize_severity(str(advisory["cvss"]).split("/")[0])
|
|
149
|
+
if sev_str == "unknown":
|
|
150
|
+
sev_str = _cargo_severity_from_categories(advisory.get("categories", []))
|
|
151
|
+
# Informational advisories (unmaintained, etc.) are low severity
|
|
152
|
+
if advisory.get("informational") is not None:
|
|
153
|
+
sev_str = "low"
|
|
154
|
+
vulns.append(VulnSummary(
|
|
155
|
+
id=advisory.get("id", "UNKNOWN"),
|
|
156
|
+
severity=sev_str,
|
|
157
|
+
package=pkg.get("name", "unknown"),
|
|
158
|
+
title=advisory.get("title", ""),
|
|
159
|
+
))
|
|
160
|
+
return vulns
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_npm_audit_json(raw: str) -> list[VulnSummary]:
|
|
164
|
+
"""Parse `npm audit --json` output."""
|
|
165
|
+
try:
|
|
166
|
+
data = json.loads(raw)
|
|
167
|
+
except (json.JSONDecodeError, ValueError):
|
|
168
|
+
return []
|
|
169
|
+
vulns: list[VulnSummary] = []
|
|
170
|
+
# npm v7+ audit JSON uses "vulnerabilities" dict keyed by package name
|
|
171
|
+
vuln_dict = data.get("vulnerabilities", {})
|
|
172
|
+
if isinstance(vuln_dict, dict):
|
|
173
|
+
for pkg_name, info in vuln_dict.items():
|
|
174
|
+
if not isinstance(info, dict):
|
|
175
|
+
continue
|
|
176
|
+
sev_str = _normalize_severity(info.get("severity", "unknown"))
|
|
177
|
+
# Extract title from via list (first dict entry) or fall back to name
|
|
178
|
+
title = ""
|
|
179
|
+
via = info.get("via", [])
|
|
180
|
+
for v_item in via:
|
|
181
|
+
if isinstance(v_item, dict) and v_item.get("title"):
|
|
182
|
+
title = v_item["title"]
|
|
183
|
+
break
|
|
184
|
+
vulns.append(VulnSummary(
|
|
185
|
+
id=info.get("name", pkg_name),
|
|
186
|
+
severity=sev_str,
|
|
187
|
+
package=pkg_name,
|
|
188
|
+
title=title or pkg_name,
|
|
189
|
+
))
|
|
190
|
+
return vulns
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_pip_audit_json(raw: str) -> list[VulnSummary]:
|
|
194
|
+
"""Parse `pip-audit --format=json` output."""
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(raw)
|
|
197
|
+
except (json.JSONDecodeError, ValueError):
|
|
198
|
+
return []
|
|
199
|
+
vulns: list[VulnSummary] = []
|
|
200
|
+
# pip-audit outputs a list of dicts, each with "name", "version", "vulns"
|
|
201
|
+
if isinstance(data, list):
|
|
202
|
+
for entry in data:
|
|
203
|
+
pkg = entry.get("name", "unknown")
|
|
204
|
+
for v in entry.get("vulns", []):
|
|
205
|
+
sev_str = _normalize_severity(v.get("fix_versions", [""])[0] if v.get("fix_versions") else "")
|
|
206
|
+
# pip-audit doesn't always include severity; use id-based lookup
|
|
207
|
+
vuln_id = v.get("id", "UNKNOWN")
|
|
208
|
+
desc = v.get("description", "")
|
|
209
|
+
# Attempt to extract severity from aliases or description
|
|
210
|
+
aliases = v.get("aliases", [])
|
|
211
|
+
sev_str = _normalize_severity(v.get("severity", "unknown"))
|
|
212
|
+
vulns.append(VulnSummary(
|
|
213
|
+
id=vuln_id,
|
|
214
|
+
severity=sev_str,
|
|
215
|
+
package=pkg,
|
|
216
|
+
title=desc[:120] if desc else vuln_id,
|
|
217
|
+
))
|
|
218
|
+
return vulns
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _normalize_severity(raw: str | None) -> str:
|
|
222
|
+
"""Normalize severity string to one of the standard buckets."""
|
|
223
|
+
if not raw:
|
|
224
|
+
return "unknown"
|
|
225
|
+
low = raw.strip().lower()
|
|
226
|
+
if low in SEVERITY_BUCKETS:
|
|
227
|
+
return low
|
|
228
|
+
# Map common aliases
|
|
229
|
+
if low in ("info", "informational", "negligible", "none"):
|
|
230
|
+
return "low"
|
|
231
|
+
if low in ("moderate", "mod"):
|
|
232
|
+
return "medium"
|
|
233
|
+
return "unknown"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Per-repo audit runner
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
_ENGINE_COMMANDS: dict[str, tuple[list[str], str | None]] = {
|
|
241
|
+
# (argv, which_binary_to_check)
|
|
242
|
+
"cargo-audit": (["cargo", "audit", "--json"], "cargo-audit"),
|
|
243
|
+
"npm-audit": (["npm", "audit", "--json"], "npm"),
|
|
244
|
+
"pip-audit": (["pip-audit", "--format=json", "--output=-"], "pip-audit"),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_ENGINE_PARSERS: dict[str, Any] = {
|
|
248
|
+
"cargo-audit": parse_cargo_audit_json,
|
|
249
|
+
"npm-audit": parse_npm_audit_json,
|
|
250
|
+
"pip-audit": parse_pip_audit_json,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class RepoAuditResult:
|
|
256
|
+
repo_path: str
|
|
257
|
+
engines_run: list[str] = field(default_factory=list)
|
|
258
|
+
vulns: list[dict[str, str]] = field(default_factory=list)
|
|
259
|
+
severity_counts: dict[str, int] = field(default_factory=dict)
|
|
260
|
+
skipped_engines: list[str] = field(default_factory=list)
|
|
261
|
+
error: str | None = None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _audit_repo(
|
|
265
|
+
repo: Path,
|
|
266
|
+
engines: list[str],
|
|
267
|
+
timeout_s: int,
|
|
268
|
+
) -> RepoAuditResult:
|
|
269
|
+
"""Run applicable audit tools on a single repo."""
|
|
270
|
+
detected = detect_engines(repo)
|
|
271
|
+
result = RepoAuditResult(repo_path=str(repo))
|
|
272
|
+
counts: Counter[str] = Counter()
|
|
273
|
+
|
|
274
|
+
for det in detected:
|
|
275
|
+
if det.engine not in engines:
|
|
276
|
+
result.skipped_engines.append(det.engine)
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
cmd_spec = _ENGINE_COMMANDS.get(det.engine)
|
|
280
|
+
if cmd_spec is None:
|
|
281
|
+
continue
|
|
282
|
+
argv, which_bin = cmd_spec
|
|
283
|
+
|
|
284
|
+
# Check tool availability
|
|
285
|
+
if which_bin and not shutil.which(which_bin):
|
|
286
|
+
result.skipped_engines.append(f"{det.engine} (not installed)")
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
proc = subprocess.run(
|
|
291
|
+
argv,
|
|
292
|
+
cwd=str(repo),
|
|
293
|
+
capture_output=True,
|
|
294
|
+
text=True,
|
|
295
|
+
timeout=timeout_s,
|
|
296
|
+
)
|
|
297
|
+
# cargo-audit and npm audit return non-zero when vulns are found;
|
|
298
|
+
# that is expected -- we still parse stdout.
|
|
299
|
+
raw = proc.stdout or ""
|
|
300
|
+
except subprocess.TimeoutExpired:
|
|
301
|
+
result.skipped_engines.append(f"{det.engine} (timeout)")
|
|
302
|
+
continue
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
result.skipped_engines.append(f"{det.engine} ({exc})")
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
parser = _ENGINE_PARSERS.get(det.engine)
|
|
308
|
+
if parser is None:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
vulns = parser(raw)
|
|
312
|
+
result.engines_run.append(det.engine)
|
|
313
|
+
for v in vulns:
|
|
314
|
+
result.vulns.append({
|
|
315
|
+
"id": v.id,
|
|
316
|
+
"severity": v.severity,
|
|
317
|
+
"package": v.package,
|
|
318
|
+
"title": v.title,
|
|
319
|
+
"engine": det.engine,
|
|
320
|
+
})
|
|
321
|
+
counts[v.severity] += 1
|
|
322
|
+
|
|
323
|
+
result.severity_counts = dict(counts)
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Main entry point
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def audit_dependencies(
|
|
332
|
+
*,
|
|
333
|
+
dev_root: Path | None = None,
|
|
334
|
+
max_depth: int = 2,
|
|
335
|
+
exclude_repo_globs: list[str] | None = None,
|
|
336
|
+
engines: list[str] | None = None,
|
|
337
|
+
max_concurrency: int = 4,
|
|
338
|
+
timeout_s: int = 120,
|
|
339
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
340
|
+
"""Audit dependencies across local repos for known vulnerabilities.
|
|
341
|
+
|
|
342
|
+
Returns (report_dict, errors_list).
|
|
343
|
+
"""
|
|
344
|
+
errors: list[str] = []
|
|
345
|
+
root = dev_root if dev_root is not None else _default_dev_root()
|
|
346
|
+
all_engines = engines or ["cargo-audit", "npm-audit", "pip-audit"]
|
|
347
|
+
|
|
348
|
+
repos = _iter_git_repos(root, max_depth=max_depth)
|
|
349
|
+
globs = [g for g in (exclude_repo_globs or []) if isinstance(g, str) and g.strip()]
|
|
350
|
+
if globs:
|
|
351
|
+
repos = [r for r in repos if not any(fnmatch.fnmatch(str(r), g) for g in globs)]
|
|
352
|
+
|
|
353
|
+
results: list[RepoAuditResult] = []
|
|
354
|
+
|
|
355
|
+
def _run(repo: Path) -> RepoAuditResult:
|
|
356
|
+
try:
|
|
357
|
+
return _audit_repo(repo, engines=all_engines, timeout_s=timeout_s)
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
return RepoAuditResult(repo_path=str(repo), error=str(exc))
|
|
360
|
+
|
|
361
|
+
with ThreadPoolExecutor(max_workers=max_concurrency) as pool:
|
|
362
|
+
futures = {pool.submit(_run, r): r for r in repos}
|
|
363
|
+
for fut in as_completed(futures):
|
|
364
|
+
res = fut.result()
|
|
365
|
+
results.append(res)
|
|
366
|
+
if res.error:
|
|
367
|
+
errors.append(f"{res.repo_path}: {res.error}")
|
|
368
|
+
|
|
369
|
+
# Sort by severity (critical first), then by vuln count descending
|
|
370
|
+
def _sort_key(r: RepoAuditResult) -> tuple[int, int, str]:
|
|
371
|
+
crit = r.severity_counts.get("critical", 0)
|
|
372
|
+
high = r.severity_counts.get("high", 0)
|
|
373
|
+
total = len(r.vulns)
|
|
374
|
+
return (-crit, -high, -total, r.repo_path) # type: ignore[return-value]
|
|
375
|
+
|
|
376
|
+
results.sort(key=_sort_key)
|
|
377
|
+
|
|
378
|
+
# Aggregate severity counts
|
|
379
|
+
total_counts: Counter[str] = Counter()
|
|
380
|
+
for r in results:
|
|
381
|
+
total_counts.update(r.severity_counts)
|
|
382
|
+
|
|
383
|
+
repos_with_vulns = [r for r in results if r.vulns]
|
|
384
|
+
|
|
385
|
+
report: dict[str, Any] = {
|
|
386
|
+
"generated_at": _utc_now(),
|
|
387
|
+
"scope": {
|
|
388
|
+
"dev_root": str(root),
|
|
389
|
+
"repos_scanned": len(repos),
|
|
390
|
+
"max_depth": max_depth,
|
|
391
|
+
"exclude_repo_globs": globs,
|
|
392
|
+
"engines_requested": all_engines,
|
|
393
|
+
},
|
|
394
|
+
"summary": {
|
|
395
|
+
"repos_with_vulns": len(repos_with_vulns),
|
|
396
|
+
"total_vulns": sum(len(r.vulns) for r in results),
|
|
397
|
+
"severity_counts": {s: total_counts.get(s, 0) for s in SEVERITY_BUCKETS},
|
|
398
|
+
"unknown_severity": total_counts.get("unknown", 0),
|
|
399
|
+
},
|
|
400
|
+
"repos": [
|
|
401
|
+
{
|
|
402
|
+
"repo_path": r.repo_path,
|
|
403
|
+
"engines_run": r.engines_run,
|
|
404
|
+
"skipped_engines": r.skipped_engines,
|
|
405
|
+
"vuln_count": len(r.vulns),
|
|
406
|
+
"severity_counts": r.severity_counts,
|
|
407
|
+
"vulns": r.vulns[:100], # cap per repo
|
|
408
|
+
}
|
|
409
|
+
for r in results
|
|
410
|
+
if r.vulns or r.skipped_engines
|
|
411
|
+
][:200],
|
|
412
|
+
"errors": errors,
|
|
413
|
+
}
|
|
414
|
+
return report, errors
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def write_report(path: Path, report: dict[str, Any]) -> None:
|
|
418
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
path.write_text(json.dumps(report, indent=2) + "\n")
|