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.
Files changed (60) hide show
  1. devguard/INTEGRATION_SUMMARY.md +121 -0
  2. devguard/__init__.py +3 -0
  3. devguard/__main__.py +6 -0
  4. devguard/checkers/__init__.py +41 -0
  5. devguard/checkers/api_usage.py +523 -0
  6. devguard/checkers/aws_cost.py +331 -0
  7. devguard/checkers/aws_iam.py +284 -0
  8. devguard/checkers/base.py +25 -0
  9. devguard/checkers/container.py +137 -0
  10. devguard/checkers/domain.py +189 -0
  11. devguard/checkers/firecrawl.py +117 -0
  12. devguard/checkers/fly.py +225 -0
  13. devguard/checkers/github.py +210 -0
  14. devguard/checkers/npm.py +327 -0
  15. devguard/checkers/npm_security.py +244 -0
  16. devguard/checkers/redteam.py +290 -0
  17. devguard/checkers/secret.py +279 -0
  18. devguard/checkers/swarm.py +376 -0
  19. devguard/checkers/tailscale.py +143 -0
  20. devguard/checkers/tailsnitch.py +303 -0
  21. devguard/checkers/tavily.py +179 -0
  22. devguard/checkers/vercel.py +192 -0
  23. devguard/cli.py +1510 -0
  24. devguard/cli_helpers.py +189 -0
  25. devguard/config.py +249 -0
  26. devguard/core.py +293 -0
  27. devguard/dashboard.py +715 -0
  28. devguard/discovery.py +363 -0
  29. devguard/http_client.py +142 -0
  30. devguard/llm_service.py +481 -0
  31. devguard/mcp_server.py +259 -0
  32. devguard/metrics.py +144 -0
  33. devguard/models.py +208 -0
  34. devguard/reporting.py +1571 -0
  35. devguard/sarif.py +295 -0
  36. devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
  37. devguard/scripts/README.md +221 -0
  38. devguard/scripts/auto_fix_recommendations.py +145 -0
  39. devguard/scripts/generate_npmignore.py +175 -0
  40. devguard/scripts/generate_security_report.py +324 -0
  41. devguard/scripts/prepublish_check.sh +29 -0
  42. devguard/scripts/redteam_npm_packages.py +1262 -0
  43. devguard/scripts/review_all_repos.py +300 -0
  44. devguard/spec.py +617 -0
  45. devguard/sweeps/__init__.py +23 -0
  46. devguard/sweeps/ai_editor_config_audit.py +697 -0
  47. devguard/sweeps/cargo_publish_audit.py +655 -0
  48. devguard/sweeps/dependency_audit.py +419 -0
  49. devguard/sweeps/gitignore_audit.py +336 -0
  50. devguard/sweeps/local_dev.py +260 -0
  51. devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
  52. devguard/sweeps/project_flaudit.py +636 -0
  53. devguard/sweeps/public_github_secrets.py +680 -0
  54. devguard/sweeps/publish_audit.py +478 -0
  55. devguard/sweeps/ssh_key_audit.py +327 -0
  56. devguard/utils.py +174 -0
  57. devguard-0.2.0.dist-info/METADATA +225 -0
  58. devguard-0.2.0.dist-info/RECORD +60 -0
  59. devguard-0.2.0.dist-info/WHEEL +4 -0
  60. 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")