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.
- dashboard/__init__.py +10 -0
- dashboard/backend/__init__.py +11 -0
- dashboard/backend/__main__.py +6 -0
- dashboard/backend/ai/__init__.py +6 -0
- dashboard/backend/ai/provider.py +102 -0
- dashboard/backend/ai/service.py +757 -0
- dashboard/backend/api/__init__.py +5 -0
- dashboard/backend/api/ai.py +142 -0
- dashboard/backend/api/dashboard.py +34 -0
- dashboard/backend/api/deps.py +51 -0
- dashboard/backend/api/findings.py +102 -0
- dashboard/backend/api/intel.py +41 -0
- dashboard/backend/api/repositories.py +62 -0
- dashboard/backend/api/scans.py +168 -0
- dashboard/backend/api/system.py +69 -0
- dashboard/backend/config.py +180 -0
- dashboard/backend/main.py +183 -0
- dashboard/backend/persistence/__init__.py +5 -0
- dashboard/backend/persistence/database.py +278 -0
- dashboard/backend/persistence/store.py +600 -0
- dashboard/backend/profiles.py +141 -0
- dashboard/backend/schemas.py +452 -0
- dashboard/backend/security.py +158 -0
- dashboard/backend/services/__init__.py +7 -0
- dashboard/backend/services/dashboard.py +172 -0
- dashboard/backend/services/intel.py +341 -0
- dashboard/backend/services/paths.py +38 -0
- dashboard/backend/services/repositories.py +123 -0
- dashboard/backend/services/scans.py +328 -0
- dashboard/backend/services/settings.py +49 -0
- dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
- dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
- dashboard/frontend/dist/index.html +29 -0
- dashboard/frontend/dist/phone-mascots.gif +0 -0
- dashboard/launcher.py +48 -0
- vibe_coding_qa/__init__.py +140 -0
- vibe_coding_qa/__main__.py +5 -0
- vibe_coding_qa/ai_review.py +165 -0
- vibe_coding_qa/cli.py +157 -0
- vibe_coding_qa/engine.py +860 -0
- vibe_coding_qa/fixes.py +222 -0
- vibe_coding_qa/gates.py +300 -0
- vibe_coding_qa/profile.py +471 -0
- vibe_coding_qa/repo.py +188 -0
- vibe_coding_qa/reporters/__init__.py +253 -0
- vibe_coding_qa/scanners/__init__.py +195 -0
- vibe_coding_qa/scanners/compliance.py +167 -0
- vibe_coding_qa/scanners/database.py +316 -0
- vibe_coding_qa/scanners/dependencies.py +595 -0
- vibe_coding_qa/scanners/infrastructure.py +125 -0
- vibe_coding_qa/scanners/performance.py +84 -0
- vibe_coding_qa/scanners/quality.py +129 -0
- vibe_coding_qa/scanners/security.py +521 -0
- vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
- vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
- vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
- vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
- 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
|