vibe-coding-qa 0.5.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 (58) hide show
  1. dashboard/__init__.py +10 -0
  2. dashboard/backend/__init__.py +11 -0
  3. dashboard/backend/__main__.py +6 -0
  4. dashboard/backend/ai/__init__.py +6 -0
  5. dashboard/backend/ai/provider.py +102 -0
  6. dashboard/backend/ai/service.py +757 -0
  7. dashboard/backend/api/__init__.py +5 -0
  8. dashboard/backend/api/ai.py +142 -0
  9. dashboard/backend/api/dashboard.py +34 -0
  10. dashboard/backend/api/deps.py +51 -0
  11. dashboard/backend/api/findings.py +102 -0
  12. dashboard/backend/api/intel.py +41 -0
  13. dashboard/backend/api/repositories.py +62 -0
  14. dashboard/backend/api/scans.py +168 -0
  15. dashboard/backend/api/system.py +69 -0
  16. dashboard/backend/config.py +180 -0
  17. dashboard/backend/main.py +183 -0
  18. dashboard/backend/persistence/__init__.py +5 -0
  19. dashboard/backend/persistence/database.py +278 -0
  20. dashboard/backend/persistence/store.py +600 -0
  21. dashboard/backend/profiles.py +141 -0
  22. dashboard/backend/schemas.py +452 -0
  23. dashboard/backend/security.py +158 -0
  24. dashboard/backend/services/__init__.py +7 -0
  25. dashboard/backend/services/dashboard.py +172 -0
  26. dashboard/backend/services/intel.py +341 -0
  27. dashboard/backend/services/paths.py +38 -0
  28. dashboard/backend/services/repositories.py +123 -0
  29. dashboard/backend/services/scans.py +328 -0
  30. dashboard/backend/services/settings.py +49 -0
  31. dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
  32. dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
  33. dashboard/frontend/dist/index.html +29 -0
  34. dashboard/frontend/dist/phone-mascots.gif +0 -0
  35. dashboard/launcher.py +48 -0
  36. vibe_coding_qa/__init__.py +140 -0
  37. vibe_coding_qa/__main__.py +5 -0
  38. vibe_coding_qa/ai_review.py +165 -0
  39. vibe_coding_qa/cli.py +157 -0
  40. vibe_coding_qa/engine.py +860 -0
  41. vibe_coding_qa/fixes.py +222 -0
  42. vibe_coding_qa/gates.py +300 -0
  43. vibe_coding_qa/profile.py +471 -0
  44. vibe_coding_qa/repo.py +188 -0
  45. vibe_coding_qa/reporters/__init__.py +253 -0
  46. vibe_coding_qa/scanners/__init__.py +195 -0
  47. vibe_coding_qa/scanners/compliance.py +167 -0
  48. vibe_coding_qa/scanners/database.py +316 -0
  49. vibe_coding_qa/scanners/dependencies.py +595 -0
  50. vibe_coding_qa/scanners/infrastructure.py +125 -0
  51. vibe_coding_qa/scanners/performance.py +84 -0
  52. vibe_coding_qa/scanners/quality.py +129 -0
  53. vibe_coding_qa/scanners/security.py +521 -0
  54. vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
  55. vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
  56. vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
  57. vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
  58. vibe_coding_qa-0.5.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,595 @@
1
+ """Dependency, lockfile, CI, and supply-chain scanner.
2
+
3
+ This scanner performs local checks and optional, explicit OSV lookups.
4
+ Findings are labeled as 'heuristic' or 'verified' based on available evidence.
5
+ """
6
+
7
+ import contextvars
8
+ import json
9
+ import os
10
+ import re
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ from pathlib import Path
16
+
17
+ from . import BaseScanner, is_ignored, load_ignore_patterns
18
+ from vibe_coding_qa import Finding, Severity
19
+
20
+
21
+ _OSV_ENDPOINT = "https://api.osv.dev/v1/query"
22
+ # Process-level cache: (ecosystem, name, version) -> (expiry_monotonic, advisories).
23
+ _OSV_CACHE: dict[tuple[str, str, str], tuple[float, list[dict]]] = {}
24
+
25
+ # Per-context override of the OSV switch. The dashboard runs scans in worker
26
+ # threads; a ContextVar keeps each scan's decision isolated instead of mutating
27
+ # the global environment (which concurrent scans would race on).
28
+ _OSV_OVERRIDE: contextvars.ContextVar[bool | None] = contextvars.ContextVar("vcqa_osv_override", default=None)
29
+
30
+
31
+ def set_osv_enabled(enabled: bool | None) -> contextvars.Token:
32
+ """Override the OSV switch for the current context; returns a reset token."""
33
+ return _OSV_OVERRIDE.set(enabled)
34
+
35
+
36
+ def reset_osv_enabled(token: contextvars.Token) -> None:
37
+ _OSV_OVERRIDE.reset(token)
38
+
39
+
40
+ def osv_enabled() -> bool:
41
+ override = _OSV_OVERRIDE.get()
42
+ if override is not None:
43
+ return override
44
+ return os.environ.get("VCQA_OSV_ENABLED", "0").lower() in ("1", "true", "yes")
45
+
46
+
47
+ def clear_osv_cache() -> None:
48
+ """Clear the OSV response cache (used by tests and long-running callers)."""
49
+ _OSV_CACHE.clear()
50
+
51
+
52
+ def resolved_dependencies(repo: Path) -> list[tuple[str, str, str, Path]]:
53
+ """Public wrapper: the resolved (ecosystem, name, version, lockfile) list."""
54
+ return DependencyScanner(Path(repo))._resolved_dependencies()
55
+
56
+
57
+ def osv_lookup(ecosystem: str, name: str, version: str, timeout: float = 8.0) -> list[dict]:
58
+ """Public wrapper: query OSV for advisories affecting one resolved package."""
59
+ scanner = DependencyScanner(Path("."))
60
+ scanner._OSV_FETCH_TIMEOUT = timeout
61
+ return scanner._osv_lookup(ecosystem, name, version)
62
+
63
+
64
+ class DependencyScanner(BaseScanner):
65
+ module = "dependencies"
66
+
67
+ def __init__(self, repo: Path):
68
+ super().__init__(repo)
69
+ self.notes: list[str] = []
70
+ self._reported_vulns: set[tuple[str, str]] = set()
71
+
72
+ _OSV_MAX_BATCH = 250
73
+ _OSV_MAX_CONCURRENCY = 5
74
+ _OSV_FETCH_TIMEOUT = 8.0
75
+ _OSV_CACHE_TTL = 3600.0
76
+ _OSV_DEFAULT_BUDGET = 30.0
77
+
78
+ # Locally maintained affected ranges (heuristic, not live advisories).
79
+ # Each entry is matched against an *exact resolved version*, never reported
80
+ # merely because a package has a historical CVE. Confirm with OSV before
81
+ # treating any of these as a confirmed current vulnerability.
82
+ KNOWN_VULNERABLE_RANGES = {
83
+ "lodash": {
84
+ "ecosystem": "npm",
85
+ "affected": "<4.17.21",
86
+ "cve": "CVE-2021-23337",
87
+ "description": "command injection via template in lodash",
88
+ },
89
+ "express": {
90
+ "ecosystem": "npm",
91
+ "affected": "<4.17.3",
92
+ "cve": "CVE-2022-24999",
93
+ "description": "open redirect via qs in Express",
94
+ },
95
+ "jquery": {
96
+ "ecosystem": "npm",
97
+ "affected": "<3.5.0",
98
+ "cve": "CVE-2020-11023",
99
+ "description": "XSS via HTML containing <option> elements",
100
+ },
101
+ }
102
+
103
+ # Historically compromised packages (supply chain incidents)
104
+ # These are packages that were compromised at some point in history.
105
+ # Presence alone doesn't mean the current version is malicious.
106
+ HISTORICALLY_COMPROMISED = {
107
+ "event-stream": {
108
+ "incident": "event-stream@3.3.6 contained malicious code targeting Copay wallet (Nov 2018)",
109
+ "cve": "CVE-2018-19207",
110
+ },
111
+ "flatmap-stream": {
112
+ "incident": "flatmap-stream@0.1.1 was a malicious dependency of event-stream",
113
+ "cve": "CVE-2018-19207",
114
+ },
115
+ "left-pad": {
116
+ "incident": "left-pad was unpublished in 2016 causing widespread breakage; not malicious but demonstrates supply chain fragility",
117
+ "cve": None,
118
+ },
119
+ }
120
+
121
+ def scan(self) -> list[Finding]:
122
+ self._scan_package_json()
123
+ self._scan_known_vulnerable_resolved()
124
+ self._scan_python_deps()
125
+ self._scan_lockfile_consistency()
126
+ self._scan_ci_workflows()
127
+ self._scan_dockerfile()
128
+ self._scan_github_actions()
129
+ self._scan_osv()
130
+ return self.findings
131
+
132
+ def _scan_osv(self) -> None:
133
+ if not osv_enabled():
134
+ self.notes.append("OSV lookups are disabled; dependency findings are based on local heuristics only.")
135
+ return
136
+ dependencies = self._resolved_dependencies()
137
+ if not dependencies:
138
+ self.notes.append("OSV lookup enabled, but no supported resolved dependencies were found in package-lock.json, yarn.lock, pnpm-lock.yaml, Poetry/uv lockfiles, Pipfile.lock, or pinned requirements.txt.")
139
+ return
140
+ if len(dependencies) > self._OSV_MAX_BATCH:
141
+ self.notes.append(f"OSV query scope limited to the first {self._OSV_MAX_BATCH} of {len(dependencies)} resolved dependencies.")
142
+ dependencies = dependencies[:self._OSV_MAX_BATCH]
143
+
144
+ budget = self._osv_budget_seconds()
145
+ deadline = time.monotonic() + budget
146
+ advisories_by_index: dict[int, list[dict]] = {}
147
+ failures: list[str] = []
148
+ deferred = 0
149
+ workers = max(1, min(self._OSV_MAX_CONCURRENCY, len(dependencies)))
150
+ with ThreadPoolExecutor(max_workers=workers) as pool:
151
+ futures = {}
152
+ for index, (ecosystem, name, version, _lockfile) in enumerate(dependencies):
153
+ if time.monotonic() >= deadline:
154
+ deferred = len(dependencies) - index
155
+ break
156
+ futures[pool.submit(self._osv_lookup, ecosystem, name, version)] = index
157
+ for future in as_completed(futures):
158
+ index = futures[future]
159
+ try:
160
+ advisories_by_index[index] = future.result()
161
+ except (OSError, urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
162
+ name, version = dependencies[index][1], dependencies[index][2]
163
+ failures.append(f"{name}@{version} ({type(exc).__name__})")
164
+
165
+ checked = 0
166
+ for index, advisories in sorted(advisories_by_index.items()):
167
+ _ecosystem, name, version, lockfile = dependencies[index]
168
+ checked += 1
169
+ for advisory in advisories:
170
+ advisory_id = advisory.get("id", "OSV advisory")
171
+ aliases = advisory.get("aliases", [])
172
+ if aliases:
173
+ advisory_id = f"{advisory_id} ({', '.join(aliases[:3])})"
174
+ database_severity = (advisory.get("database_specific") or {}).get("severity", "").lower()
175
+ severity = {
176
+ "critical": Severity.CRITICAL,
177
+ "high": Severity.HIGH,
178
+ "moderate": Severity.MEDIUM,
179
+ "medium": Severity.MEDIUM,
180
+ "low": Severity.LOW,
181
+ }.get(database_severity, Severity.MEDIUM)
182
+ self.add(
183
+ rule="osv-verified-vulnerability",
184
+ severity=severity,
185
+ file=lockfile,
186
+ line=self._dependency_line(lockfile, name, version),
187
+ message=f"OSV reports {name}@{version} as affected by {advisory_id}. Source: https://osv.dev/vulnerability/{advisory.get('id', '')}.",
188
+ suggestion="Review the advisory's affected range and upgrade to a version confirmed as patched.",
189
+ confidence="high",
190
+ )
191
+ note = f"OSV checked {checked} resolved dependencies; matches are advisory-backed and require review of affected ranges."
192
+ if failures:
193
+ note += f" {len(failures)} lookup(s) could not be verified: {', '.join(failures[:5])}."
194
+ if deferred:
195
+ note += f" {deferred} dependency(ies) were skipped after the {budget:.0f}s global time budget was exhausted."
196
+ self.notes.append(note)
197
+
198
+ @classmethod
199
+ def _osv_budget_seconds(cls) -> float:
200
+ raw = os.environ.get("VCQA_OSV_BUDGET_SECONDS", str(cls._OSV_DEFAULT_BUDGET))
201
+ try:
202
+ return max(1.0, float(raw))
203
+ except ValueError:
204
+ return cls._OSV_DEFAULT_BUDGET
205
+
206
+ def _osv_lookup(self, ecosystem: str, name: str, version: str) -> list[dict]:
207
+ key = (ecosystem, name, version)
208
+ now = time.monotonic()
209
+ cached = _OSV_CACHE.get(key)
210
+ if cached and cached[0] > now:
211
+ return cached[1]
212
+ advisories = self._osv_request(ecosystem, name, version)
213
+ _OSV_CACHE[key] = (now + self._OSV_CACHE_TTL, advisories)
214
+ return advisories
215
+
216
+ def _osv_request(self, ecosystem: str, name: str, version: str) -> list[dict]:
217
+ payload = json.dumps({"package": {"name": name, "ecosystem": ecosystem}, "version": version}).encode("utf-8")
218
+ request = urllib.request.Request(
219
+ _OSV_ENDPOINT,
220
+ data=payload,
221
+ headers={"Content-Type": "application/json", "User-Agent": "vibe-coding-qa"},
222
+ method="POST",
223
+ )
224
+ with urllib.request.urlopen(request, timeout=self._OSV_FETCH_TIMEOUT) as response:
225
+ advisory_data = json.loads(response.read().decode("utf-8"))
226
+ return advisory_data.get("vulns", [])
227
+
228
+ def _resolved_dependencies(self) -> list[tuple[str, str, str, Path]]:
229
+ found: list[tuple[str, str, str, Path]] = []
230
+ lockfile = self.repo / "package-lock.json"
231
+ if lockfile.is_file():
232
+ try:
233
+ lock_data = json.loads(lockfile.read_text(encoding="utf-8", errors="ignore"))
234
+ for package_path, package in lock_data.get("packages", {}).items():
235
+ if not package_path.startswith("node_modules/"):
236
+ continue
237
+ name = package_path.rsplit("node_modules/", 1)[-1]
238
+ version = package.get("version")
239
+ if version:
240
+ found.append(("npm", name, str(version), lockfile))
241
+ if not lock_data.get("packages"):
242
+ self._walk_npm_dependencies(lock_data.get("dependencies", {}), lockfile, found)
243
+ except (OSError, json.JSONDecodeError):
244
+ self.notes.append("Could not parse package-lock.json for OSV checks.")
245
+
246
+ yarn_lock = self.repo / "yarn.lock"
247
+ if yarn_lock.is_file():
248
+ found.extend(self._parse_yarn_lock(yarn_lock))
249
+ pnpm_lock = self.repo / "pnpm-lock.yaml"
250
+ if pnpm_lock.is_file():
251
+ found.extend(self._parse_pnpm_lock(pnpm_lock))
252
+
253
+ for lock_name in ("requirements.txt",):
254
+ requirements = self.repo / lock_name
255
+ if requirements.is_file():
256
+ for raw in requirements.read_text(encoding="utf-8", errors="ignore").splitlines():
257
+ match = re.match(r"\s*([A-Za-z0-9_.-]+)==([A-Za-z0-9_.+-]+)\s*(?:#.*)?$", raw)
258
+ if match:
259
+ found.append(("PyPI", match.group(1), match.group(2), requirements))
260
+
261
+ for lock_name in ("poetry.lock", "uv.lock"):
262
+ lockfile = self.repo / lock_name
263
+ if lockfile.is_file():
264
+ found.extend(self._parse_toml_lock(lockfile))
265
+
266
+ pipfile_lock = self.repo / "Pipfile.lock"
267
+ if pipfile_lock.is_file():
268
+ try:
269
+ lock_data = json.loads(pipfile_lock.read_text(encoding="utf-8", errors="ignore"))
270
+ for section in ("default", "develop"):
271
+ for name, package in lock_data.get(section, {}).items():
272
+ version = package.get("version", "")
273
+ if version.startswith("=="):
274
+ found.append(("PyPI", name, version[2:], pipfile_lock))
275
+ except (OSError, json.JSONDecodeError):
276
+ self.notes.append("Could not parse Pipfile.lock for OSV checks.")
277
+
278
+ return list(dict.fromkeys(found))
279
+
280
+ @staticmethod
281
+ def _parse_yarn_lock(lockfile: Path) -> list[tuple[str, str, str, Path]]:
282
+ found: list[tuple[str, str, str, Path]] = []
283
+ selectors: list[str] = []
284
+ version: str | None = None
285
+
286
+ def flush() -> None:
287
+ if not version:
288
+ return
289
+ for selector in selectors:
290
+ selector = selector.strip().strip(',').strip().strip('"\'')
291
+ if selector.startswith("@"):
292
+ separator = selector.find("@", selector.find("/") + 1)
293
+ else:
294
+ separator = selector.find("@")
295
+ if separator > 0:
296
+ name = selector[:separator]
297
+ found.append(("npm", name, version, lockfile))
298
+
299
+ for raw in lockfile.read_text(encoding="utf-8", errors="ignore").splitlines():
300
+ if raw and not raw[0].isspace() and raw.rstrip().endswith(":"):
301
+ flush()
302
+ selectors = raw.rstrip()[:-1].split(",")
303
+ version = None
304
+ else:
305
+ match = re.match(r"\s+version\s+[\"']?([^\"'\s]+)", raw)
306
+ if match:
307
+ version = match.group(1)
308
+ flush()
309
+ return found
310
+
311
+ @staticmethod
312
+ def _parse_pnpm_lock(lockfile: Path) -> list[tuple[str, str, str, Path]]:
313
+ found: list[tuple[str, str, str, Path]] = []
314
+ in_packages = False
315
+ for raw in lockfile.read_text(encoding="utf-8", errors="ignore").splitlines():
316
+ if raw and not raw[0].isspace():
317
+ in_packages = raw.rstrip().rstrip(":") == "packages"
318
+ continue
319
+ if not in_packages or len(raw) - len(raw.lstrip()) != 2 or not raw.strip().endswith(":"):
320
+ continue
321
+ selector = raw.strip()[:-1].strip().strip('"\'').lstrip("/")
322
+ if selector.startswith("@"):
323
+ separator = selector.rfind("@")
324
+ else:
325
+ separator = selector.rfind("@")
326
+ if separator <= 0:
327
+ continue
328
+ name = selector[:separator]
329
+ version = selector[separator + 1:].split("(", 1)[0]
330
+ if name and re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?", version):
331
+ found.append(("npm", name, version, lockfile))
332
+ return found
333
+
334
+ @staticmethod
335
+ def _parse_toml_lock(lockfile: Path) -> list[tuple[str, str, str, Path]]:
336
+ found: list[tuple[str, str, str, Path]] = []
337
+ name: str | None = None
338
+ version: str | None = None
339
+
340
+ def flush() -> None:
341
+ if name and version:
342
+ found.append(("PyPI", name, version, lockfile))
343
+
344
+ for raw in lockfile.read_text(encoding="utf-8", errors="ignore").splitlines():
345
+ if raw.strip() == "[[package]]":
346
+ flush()
347
+ name = None
348
+ version = None
349
+ continue
350
+ name_match = re.match(r"\s*name\s*=\s*[\"']([^\"']+)", raw)
351
+ version_match = re.match(r"\s*version\s*=\s*[\"']([^\"']+)", raw)
352
+ if name_match:
353
+ name = name_match.group(1)
354
+ if version_match:
355
+ version = version_match.group(1)
356
+ flush()
357
+ return found
358
+
359
+
360
+ def _walk_npm_dependencies(self, dependencies: dict, lockfile: Path, found: list[tuple[str, str, str, Path]]) -> None:
361
+ for name, package in dependencies.items():
362
+ version = package.get("version")
363
+ if version:
364
+ found.append(("npm", name, str(version), lockfile))
365
+ self._walk_npm_dependencies(package.get("dependencies", {}), lockfile, found)
366
+
367
+ @staticmethod
368
+ def _dependency_line(lockfile: Path, name: str, version: str) -> int:
369
+ try:
370
+ lines = lockfile.read_text(encoding="utf-8", errors="ignore").splitlines()
371
+ markers = (f"node_modules/{name}", f"{name}@", f'name = "{name}"', f'"{name}":', f"{name}==")
372
+ version_pattern = re.compile(r"(?<![A-Za-z0-9.+-])" + re.escape(version) + r"(?![A-Za-z0-9.+-])")
373
+ for index, line in enumerate(lines):
374
+ if any(marker in line for marker in markers):
375
+ nearby = "\n".join(lines[index:index + 8])
376
+ if version_pattern.search(nearby):
377
+ return index + 1
378
+ except OSError:
379
+ pass
380
+ return 1
381
+
382
+ def _scan_package_json(self) -> None:
383
+ pkg = self.repo / "package.json"
384
+ if not pkg.is_file():
385
+ return
386
+ try:
387
+ data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
388
+ except Exception:
389
+ return
390
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
391
+ for name, version in deps.items():
392
+ cleaned = version.strip()
393
+ pinned = bool(re.fullmatch(r"\d+\.\d+\.\d+", cleaned))
394
+
395
+ if pinned and name in self.KNOWN_VULNERABLE_RANGES:
396
+ vuln_info = self.KNOWN_VULNERABLE_RANGES[name]
397
+ if self._satisfies(cleaned, vuln_info["affected"]):
398
+ self._report_known_vulnerability(pkg, 1, name, cleaned, vuln_info)
399
+
400
+ # Check for historically compromised packages
401
+ if name in self.HISTORICALLY_COMPROMISED:
402
+ incident = self.HISTORICALLY_COMPROMISED[name]
403
+ self.add(
404
+ rule="historically-compromised-dependency",
405
+ severity=Severity.MEDIUM, # Lower severity - historical incident, not current threat
406
+ file=pkg,
407
+ line=1,
408
+ message=f"Dependency '{name}' was involved in a historical supply-chain incident: {incident['incident']}.",
409
+ suggestion=f"Verify the current version is not affected. Check {incident['cve'] or 'supply chain advisories'} for details. This is a historical finding, not a confirmed current vulnerability.",
410
+ confidence="low", # Historical incident, not current verification
411
+ )
412
+
413
+ def _scan_known_vulnerable_resolved(self) -> None:
414
+ """Apply the locally maintained ranges to resolved (including transitive) npm deps."""
415
+ for ecosystem, name, version, lockfile in self._resolved_dependencies():
416
+ if ecosystem != "npm" or name not in self.KNOWN_VULNERABLE_RANGES:
417
+ continue
418
+ if (name, version) in self._reported_vulns:
419
+ continue
420
+ vuln_info = self.KNOWN_VULNERABLE_RANGES[name]
421
+ if self._satisfies(version, vuln_info["affected"]):
422
+ self._report_known_vulnerability(lockfile, self._dependency_line(lockfile, name, version), name, version, vuln_info)
423
+
424
+ def _report_known_vulnerability(self, file: Path, line: int, name: str, version: str, vuln_info: dict) -> None:
425
+ self._reported_vulns.add((name, version))
426
+ self.add(
427
+ rule="vulnerable-dependency",
428
+ severity=Severity.MEDIUM,
429
+ file=file,
430
+ line=line,
431
+ message=(
432
+ f"Heuristic: {name}@{version} falls within the locally known affected range "
433
+ f"'{vuln_info['affected']}' ({vuln_info['cve']}: {vuln_info['description']}). "
434
+ f"Confirm this exact version against OSV or the vendor advisory."
435
+ ),
436
+ suggestion="Upgrade to a version the advisory marks as patched, then re-verify with OSV.",
437
+ confidence="low",
438
+ )
439
+
440
+ def _scan_python_deps(self) -> None:
441
+ req = self.repo / "requirements.txt"
442
+ if not req.is_file():
443
+ return
444
+ for i, raw in enumerate(req.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
445
+ line = raw.strip()
446
+ if not line or line.startswith("#"):
447
+ continue
448
+ if "==" not in line:
449
+ self.add(
450
+ rule="unpinned-python-dependency",
451
+ severity=Severity.LOW,
452
+ file=req,
453
+ line=i,
454
+ message="Python dependency is not pinned.",
455
+ suggestion="Pin versions and use a lockfile for reproducible, auditable builds.",
456
+ confidence="low",
457
+ )
458
+
459
+ def _scan_lockfile_consistency(self) -> None:
460
+ pkg = self.repo / "package.json"
461
+ has_lock = any((self.repo / name).is_file() for name in ("package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"))
462
+ if pkg.is_file() and not has_lock:
463
+ self.add(
464
+ rule="missing-lockfile",
465
+ severity=Severity.MEDIUM,
466
+ file=pkg,
467
+ line=1,
468
+ message="package.json exists without a lockfile.",
469
+ suggestion="Commit a lockfile to ensure reproducible installs and supply-chain audits.",
470
+ confidence="high",
471
+ )
472
+
473
+ def _scan_ci_workflows(self) -> None:
474
+ workflows = self.repo / ".github" / "workflows"
475
+ if not workflows.is_dir():
476
+ return
477
+ for path in workflows.glob("*.yml"):
478
+ self._check_workflow(path)
479
+ for path in workflows.glob("*.yaml"):
480
+ self._check_workflow(path)
481
+
482
+ def _check_workflow(self, path: Path) -> None:
483
+ text = path.read_text(encoding="utf-8", errors="ignore").lower()
484
+ lines = text.splitlines()
485
+ for i, raw in enumerate(lines, 1):
486
+ lowered = raw.lower()
487
+ if "pull_request_target" in lowered:
488
+ self.add(
489
+ rule="ci-pull-request-target",
490
+ severity=Severity.HIGH,
491
+ file=path,
492
+ line=i,
493
+ message="Workflow uses pull_request_target; review for cache/p checkout injection risks.",
494
+ suggestion="Prefer pull_request where possible, pin checkout ref to the merge commit, and avoid running untrusted code with secrets.",
495
+ confidence="medium",
496
+ )
497
+ if "actions/checkout@v1" in lowered or "actions/checkout@v2" in lowered:
498
+ self.add(
499
+ rule="ci-outdated-checkout",
500
+ severity=Severity.LOW,
501
+ file=path,
502
+ line=i,
503
+ message="Workflow uses an old actions/checkout version.",
504
+ suggestion="Upgrade to actions/checkout@v4 and set persist-credentials: false when not needed.",
505
+ confidence="medium",
506
+ )
507
+ if re.search(r"secrets\.[a-z0-9_]+", raw, re.IGNORECASE) and ("pull_request" in text or "pull_request_target" in text):
508
+ # Only flag if not in an environment context.
509
+ if "environment:" not in raw and "environment:" not in "\n".join(lines[max(0, i - 5):i]):
510
+ self.add(
511
+ rule="ci-secret-in-pr-workflow",
512
+ severity=Severity.MEDIUM,
513
+ file=path,
514
+ line=i,
515
+ message="Workflow may expose secrets to pull-request builds.",
516
+ suggestion="Scope secrets to protected environments and avoid using them in jobs triggered by forks.",
517
+ confidence="low",
518
+ )
519
+
520
+ def _scan_dockerfile(self) -> None:
521
+ ignore = load_ignore_patterns(self.repo)
522
+ for path in self.repo.rglob("Dockerfile*"):
523
+ if is_ignored(path, self.repo, ignore):
524
+ continue
525
+ text = path.read_text(encoding="utf-8", errors="ignore")
526
+ for i, raw in enumerate(text.splitlines(), 1):
527
+ if re.search(r"^FROM\s+.*:latest", raw, re.IGNORECASE):
528
+ self.add(
529
+ rule="docker-latest-tag",
530
+ severity=Severity.LOW,
531
+ file=path,
532
+ line=i,
533
+ message="Docker base image uses the :latest tag.",
534
+ suggestion="Pin a specific digest or version tag for reproducible builds.",
535
+ confidence="medium",
536
+ )
537
+ if re.search(r"^USER\s+root\s*$", raw, re.IGNORECASE):
538
+ self.add(
539
+ rule="docker-root-user",
540
+ severity=Severity.MEDIUM,
541
+ file=path,
542
+ line=i,
543
+ message="Dockerfile explicitly runs as root.",
544
+ suggestion="Create and switch to a non-root user after installing dependencies.",
545
+ confidence="medium",
546
+ )
547
+ if re.search(r"EXPOSE\s+\d+", raw, re.IGNORECASE) and "EXPOSE 443" not in raw.upper():
548
+ self.add(
549
+ rule="docker-exposed-port-review",
550
+ severity=Severity.INFO,
551
+ file=path,
552
+ line=i,
553
+ message="Dockerfile exposes a port; confirm TLS terminates at the edge.",
554
+ suggestion="Terminate TLS at the reverse proxy or load balancer; do not expose plain HTTP directly.",
555
+ confidence="low",
556
+ )
557
+
558
+ def _scan_github_actions(self) -> None:
559
+ # Additional high-level checks already covered in _scan_ci_workflows.
560
+ pass
561
+
562
+ @staticmethod
563
+ def _version_tuple(version: str):
564
+ return tuple(int(p) for p in re.split(r"[.-]", version) if p.isdigit())
565
+
566
+ @classmethod
567
+ def _satisfies(cls, version: str, constraint: str) -> bool:
568
+ """Return True when a concrete version falls inside a comma-joined range.
569
+
570
+ Supports ``<``, ``<=``, ``>``, ``>=`` and ``=`` against numeric dotted
571
+ versions (e.g. ``"<3.5.0"`` or ``">=1.2.0,<1.5.0"``).
572
+ """
573
+ actual = cls._version_tuple(version)
574
+ if not actual:
575
+ return False
576
+ for clause in constraint.split(","):
577
+ match = re.fullmatch(r"(<=|>=|<|>|=)?\s*(\d+(?:\.\d+)*)", clause.strip())
578
+ if not match:
579
+ return False
580
+ operator = match.group(1) or "="
581
+ bound = tuple(int(p) for p in match.group(2).split("."))
582
+ length = max(len(actual), len(bound))
583
+ left = actual + (0,) * (length - len(actual))
584
+ right = bound + (0,) * (length - len(bound))
585
+ if operator == "<" and not left < right:
586
+ return False
587
+ if operator == "<=" and not left <= right:
588
+ return False
589
+ if operator == ">" and not left > right:
590
+ return False
591
+ if operator == ">=" and not left >= right:
592
+ return False
593
+ if operator == "=" and not left == right:
594
+ return False
595
+ return True