cvescan 0.1.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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: cvescan
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: jinja2>=3.1.6
@@ -0,0 +1,6 @@
1
+ main.py,sha256=JQFOrmU8nSTuK2ZKHk3CviK71Y6LMECEn9NDT9ikiI8,25941
2
+ cvescan-0.1.0.dist-info/METADATA,sha256=6MbJryQKgkbCqa8DlsppZ6IuJPN7IxvHpiPOYJlUCaM,179
3
+ cvescan-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
4
+ cvescan-0.1.0.dist-info/entry_points.txt,sha256=-aGk8PLQj0U9tfyxdjMFbQ3bUPqp0O1URQCp2LvttCc,38
5
+ cvescan-0.1.0.dist-info/top_level.txt,sha256=ZAMgPdWghn6xTRBO6Kc3ML1y3ZrZLnjZlqbboKXc_AE,5
6
+ cvescan-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cvescan = main:main
@@ -0,0 +1 @@
1
+ main
main.py ADDED
@@ -0,0 +1,788 @@
1
+ import argparse
2
+ import json
3
+ import re
4
+ import sys
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.request import Request, urlopen
9
+
10
+ try:
11
+ from jinja2 import Environment, BaseLoader
12
+ except ImportError:
13
+ print("Error: jinja2 is required. Install with: pip install jinja2", file=sys.stderr)
14
+ sys.exit(1)
15
+
16
+
17
+ # =============================================================================
18
+ # EMBEDDED TEMPLATES
19
+ # =============================================================================
20
+
21
+ TEMPLATE_MARKDOWN = """\
22
+ # CVE Vulnerability Scan Report
23
+
24
+ **File scanned:** `{{ file_path }}`
25
+ **Ecosystem:** {{ ecosystem }}
26
+ **Scan time:** {{ scan_time }}
27
+ **Total packages:** {{ total_packages }}
28
+ **Packages with vulnerabilities:** {{ vulnerable_package_count }}
29
+
30
+ ## Summary
31
+ {% if not vulnerabilities %}
32
+
33
+ No known vulnerabilities found in the scanned packages.
34
+ {% else %}
35
+
36
+ ### Vulnerabilities Found
37
+
38
+ | Vulnerability | Package | Severity |
39
+ |---------------|---------|----------|
40
+ {% for item in vulnerabilities %}
41
+ | {{ item.vuln_id }} | {{ item.package }} | {{ item.severity }} |
42
+ {% endfor %}
43
+
44
+ ### Recommended Actions
45
+ {% if upgrades %}
46
+
47
+ #### Packages to Upgrade
48
+
49
+ | Package | Current Version | Upgrade To | Highest Severity |
50
+ |---------|-----------------|------------|------------------|
51
+ {% for upgrade in upgrades %}
52
+ | {{ upgrade.package }} | {{ upgrade.current }} | {{ upgrade.fixed }} | {{ upgrade.severity }} |
53
+ {% endfor %}
54
+
55
+ {% if ecosystem == "PyPI" %}
56
+ #### Upgrade Commands (pip)
57
+
58
+ ```bash
59
+ {% for upgrade in upgrades %}
60
+ pip install '{{ upgrade.package }}>={{ upgrade.fixed }}'
61
+ {% endfor %}
62
+ ```
63
+ {% elif ecosystem == "npm" %}
64
+ #### Upgrade Commands (npm)
65
+
66
+ ```bash
67
+ {% for upgrade in upgrades %}
68
+ npm install {{ upgrade.package }}@{{ upgrade.fixed }}
69
+ {% endfor %}
70
+ ```
71
+ {% endif %}
72
+ {% else %}
73
+ No automatic upgrades available. See individual vulnerabilities for mitigation guidance.
74
+ {% endif %}
75
+ {% for severity in severities %}
76
+ {% if severity.vulns %}
77
+
78
+ ## {{ severity.level }} Severity
79
+ {% for item in severity.vulns %}
80
+
81
+ ### {{ item.vuln_id }}
82
+
83
+ **Package:** `{{ item.package }}` (version {{ item.version }})
84
+ {% if item.cve_aliases %}
85
+
86
+ **CVE:** {{ item.cve_aliases | join(", ") }}
87
+ {% endif %}
88
+
89
+ **Description:** {{ item.summary }}
90
+ {% if item.details and item.details != item.summary %}
91
+ ```
92
+ {{ item.details | truncate(500) }}
93
+ ```
94
+ {% endif %}
95
+
96
+ **Recommended Action:**
97
+ {% if item.fixed_version %}
98
+ Upgrade `{{ item.package }}` to version **{{ item.fixed_version }}** or later.
99
+ {% else %}
100
+ Check the vulnerability references below for mitigation guidance.
101
+ {% endif %}
102
+ {% if item.references %}
103
+
104
+ **References:**
105
+ {% for ref in item.references[:5] %}
106
+ - [{{ ref.type }}]({{ ref.url }})
107
+ {% endfor %}
108
+ {% endif %}
109
+
110
+ ---
111
+ {% endfor %}
112
+ {% endif %}
113
+ {% endfor %}
114
+ {% endif %}
115
+
116
+ ---
117
+
118
+ *Report generated using OSV (Open Source Vulnerabilities) database.*
119
+ *For more information, visit: https://osv.dev/*
120
+ """
121
+
122
+ TEMPLATE_HTML = """\
123
+ <!DOCTYPE html>
124
+ <html lang="en">
125
+ <head>
126
+ <meta charset="UTF-8">
127
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
128
+ <title>CVE Vulnerability Scan Report</title>
129
+ <style>
130
+ body {
131
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
132
+ line-height: 1.6;
133
+ max-width: 1200px;
134
+ margin: 0 auto;
135
+ padding: 20px;
136
+ color: #333;
137
+ }
138
+ h1 { color: #1a1a1a; border-bottom: 2px solid #e1e1e1; padding-bottom: 10px; }
139
+ h2 { color: #2c3e50; margin-top: 30px; }
140
+ h3 { color: #34495e; }
141
+ table {
142
+ border-collapse: collapse;
143
+ width: 100%;
144
+ margin: 15px 0;
145
+ }
146
+ th, td {
147
+ border: 1px solid #ddd;
148
+ padding: 10px 12px;
149
+ text-align: left;
150
+ }
151
+ th {
152
+ background-color: #f5f5f5;
153
+ font-weight: 600;
154
+ }
155
+ tr:nth-child(even) { background-color: #fafafa; }
156
+ tr:hover { background-color: #f0f0f0; }
157
+ .severity-critical { color: #fff; background-color: #7b1fa2; font-weight: bold; }
158
+ .severity-high { color: #fff; background-color: #c62828; font-weight: bold; }
159
+ .severity-medium, .severity-moderate { color: #fff; background-color: #ef6c00; font-weight: bold; }
160
+ .severity-low { color: #fff; background-color: #2e7d32; font-weight: bold; }
161
+ .severity-unknown { color: #fff; background-color: #757575; }
162
+ .badge {
163
+ display: inline-block;
164
+ padding: 3px 8px;
165
+ border-radius: 4px;
166
+ font-size: 0.85em;
167
+ }
168
+ code {
169
+ background-color: #f4f4f4;
170
+ padding: 2px 6px;
171
+ border-radius: 3px;
172
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
173
+ }
174
+ pre {
175
+ background-color: #2d2d2d;
176
+ color: #f8f8f2;
177
+ padding: 15px;
178
+ border-radius: 5px;
179
+ overflow-x: auto;
180
+ }
181
+ pre code {
182
+ background-color: transparent;
183
+ padding: 0;
184
+ color: inherit;
185
+ }
186
+ blockquote {
187
+ border-left: 4px solid #ddd;
188
+ margin: 10px 0;
189
+ padding: 10px 20px;
190
+ background-color: #f9f9f9;
191
+ color: #666;
192
+ }
193
+ .meta { color: #666; margin-bottom: 20px; }
194
+ .meta strong { color: #333; }
195
+ .vulnerability-card {
196
+ border: 1px solid #e1e1e1;
197
+ border-radius: 8px;
198
+ padding: 20px;
199
+ margin: 15px 0;
200
+ background-color: #fff;
201
+ }
202
+ .vulnerability-card h3 {
203
+ margin-top: 0;
204
+ padding-bottom: 10px;
205
+ border-bottom: 1px solid #eee;
206
+ }
207
+ .references { margin-top: 15px; }
208
+ .references ul { margin: 5px 0; padding-left: 20px; }
209
+ .references a { color: #0066cc; }
210
+ .summary-section {
211
+ background-color: #f8f9fa;
212
+ border-radius: 8px;
213
+ padding: 20px;
214
+ margin: 20px 0;
215
+ }
216
+ hr { border: none; border-top: 1px solid #e1e1e1; margin: 30px 0; }
217
+ .footer {
218
+ text-align: center;
219
+ color: #888;
220
+ font-size: 0.9em;
221
+ margin-top: 40px;
222
+ }
223
+ .no-vulns {
224
+ background-color: #e8f5e9;
225
+ color: #2e7d32;
226
+ padding: 20px;
227
+ border-radius: 8px;
228
+ text-align: center;
229
+ font-size: 1.1em;
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <h1>CVE Vulnerability Scan Report</h1>
235
+
236
+ <div class="meta">
237
+ <p><strong>File scanned:</strong> <code>{{ file_path }}</code></p>
238
+ <p><strong>Ecosystem:</strong> {{ ecosystem }}</p>
239
+ <p><strong>Scan time:</strong> {{ scan_time }}</p>
240
+ <p><strong>Total packages:</strong> {{ total_packages }}</p>
241
+ <p><strong>Packages with vulnerabilities:</strong> {{ vulnerable_package_count }}</p>
242
+ </div>
243
+
244
+ <h2>Summary</h2>
245
+ {% if not vulnerabilities %}
246
+ <div class="no-vulns">
247
+ No known vulnerabilities found in the scanned packages.
248
+ </div>
249
+ {% else %}
250
+ <div class="summary-section">
251
+ <h3>Vulnerabilities Found</h3>
252
+ <table>
253
+ <thead>
254
+ <tr>
255
+ <th>Vulnerability</th>
256
+ <th>Package</th>
257
+ <th>Severity</th>
258
+ </tr>
259
+ </thead>
260
+ <tbody>
261
+ {% for item in vulnerabilities %}
262
+ <tr>
263
+ <td>{{ item.vuln_id }}</td>
264
+ <td><code>{{ item.package }}</code></td>
265
+ <td><span class="badge severity-{{ item.severity | lower }}">{{ item.severity }}</span></td>
266
+ </tr>
267
+ {% endfor %}
268
+ </tbody>
269
+ </table>
270
+
271
+ <h3>Recommended Actions</h3>
272
+ {% if upgrades %}
273
+ <h4>Packages to Upgrade</h4>
274
+ <table>
275
+ <thead>
276
+ <tr>
277
+ <th>Package</th>
278
+ <th>Current Version</th>
279
+ <th>Upgrade To</th>
280
+ <th>Highest Severity</th>
281
+ </tr>
282
+ </thead>
283
+ <tbody>
284
+ {% for upgrade in upgrades %}
285
+ <tr>
286
+ <td><code>{{ upgrade.package }}</code></td>
287
+ <td>{{ upgrade.current }}</td>
288
+ <td><strong>{{ upgrade.fixed }}</strong></td>
289
+ <td><span class="badge severity-{{ upgrade.severity | lower }}">{{ upgrade.severity }}</span></td>
290
+ </tr>
291
+ {% endfor %}
292
+ </tbody>
293
+ </table>
294
+
295
+ {% if ecosystem == "PyPI" %}
296
+ <h4>Upgrade Commands (pip)</h4>
297
+ <pre><code>{% for upgrade in upgrades %}pip install '{{ upgrade.package }}&gt;={{ upgrade.fixed }}'
298
+ {% endfor %}</code></pre>
299
+ {% elif ecosystem == "npm" %}
300
+ <h4>Upgrade Commands (npm)</h4>
301
+ <pre><code>{% for upgrade in upgrades %}npm install {{ upgrade.package }}@{{ upgrade.fixed }}
302
+ {% endfor %}</code></pre>
303
+ {% endif %}
304
+ {% else %}
305
+ <p>No automatic upgrades available. See individual vulnerabilities for mitigation guidance.</p>
306
+ {% endif %}
307
+ </div>
308
+
309
+ {% for severity in severities %}
310
+ {% if severity.vulns %}
311
+ <h2>{{ severity.level }} Severity</h2>
312
+ {% for item in severity.vulns %}
313
+ <div class="vulnerability-card">
314
+ <h3>{{ item.vuln_id }}</h3>
315
+ <p><strong>Package:</strong> <code>{{ item.package }}</code> (version {{ item.version }})</p>
316
+ {% if item.cve_aliases %}
317
+ <p><strong>CVE:</strong> {{ item.cve_aliases | join(", ") }}</p>
318
+ {% endif %}
319
+ <p><strong>Description:</strong> {{ item.summary }}</p>
320
+ {% if item.details and item.details != item.summary %}
321
+ <blockquote>{{ item.details | truncate(500) }}</blockquote>
322
+ {% endif %}
323
+ <p><strong>Recommended Action:</strong><br>
324
+ {% if item.fixed_version %}
325
+ Upgrade <code>{{ item.package }}</code> to version <strong>{{ item.fixed_version }}</strong> or later.
326
+ {% else %}
327
+ Check the vulnerability references below for mitigation guidance.
328
+ {% endif %}
329
+ </p>
330
+ {% if item.references %}
331
+ <div class="references">
332
+ <strong>References:</strong>
333
+ <ul>
334
+ {% for ref in item.references[:5] %}
335
+ <li><a href="{{ ref.url }}" target="_blank">{{ ref.type }}</a></li>
336
+ {% endfor %}
337
+ </ul>
338
+ </div>
339
+ {% endif %}
340
+ </div>
341
+ {% endfor %}
342
+ {% endif %}
343
+ {% endfor %}
344
+ {% endif %}
345
+
346
+ <hr>
347
+ <div class="footer">
348
+ <p><em>Report generated using OSV (Open Source Vulnerabilities) database.</em></p>
349
+ <p><em>For more information, visit: <a href="https://osv.dev/">https://osv.dev/</a></em></p>
350
+ </div>
351
+ </body>
352
+ </html>
353
+ """
354
+
355
+ # =============================================================================
356
+ # CONSTANTS
357
+ # =============================================================================
358
+
359
+ SEVERITY_ORDER = {
360
+ "CRITICAL": 5,
361
+ "HIGH": 4,
362
+ "MEDIUM": 3,
363
+ "MODERATE": 3,
364
+ "LOW": 2,
365
+ "UNKNOWN": 1,
366
+ }
367
+
368
+ SEVERITY_LEVELS = ["CRITICAL", "HIGH", "MEDIUM", "MODERATE", "LOW", "UNKNOWN"]
369
+
370
+
371
+ # =============================================================================
372
+ # TEMPLATE ENGINE SETUP
373
+ # =============================================================================
374
+
375
+ def create_jinja_env() -> Environment:
376
+ """Create and configure the Jinja2 environment with embedded templates."""
377
+ env = Environment(loader=BaseLoader())
378
+
379
+ # Register templates from embedded strings
380
+ env.globals["TEMPLATES"] = {
381
+ "markdown": TEMPLATE_MARKDOWN,
382
+ "html": TEMPLATE_HTML,
383
+ }
384
+
385
+ env.trim_blocks = True
386
+
387
+ return env
388
+
389
+
390
+ def render_template(env: Environment, template_name: str, context: dict) -> str:
391
+ """Render an embedded template with the given context."""
392
+ template_source = env.globals["TEMPLATES"].get(template_name)
393
+ if not template_source:
394
+ raise ValueError(f"Unknown template: {template_name}")
395
+
396
+ template = env.from_string(template_source)
397
+ return template.render(**context)
398
+
399
+
400
+ # =============================================================================
401
+ # UTILITY FUNCTIONS
402
+ # =============================================================================
403
+
404
+ def log(message: str) -> None:
405
+ """Print a message to stderr for progress/status updates."""
406
+ print(message, file=sys.stderr)
407
+
408
+
409
+ def log_warning(message: str) -> None:
410
+ """Print a warning message to stderr."""
411
+ print(f"WARNING: {message}", file=sys.stderr)
412
+
413
+
414
+ # =============================================================================
415
+ # FILE PARSERS
416
+ # =============================================================================
417
+
418
+ def detect_format(file_path: Path) -> str | None:
419
+ """Auto-detect file format based on filename."""
420
+ name = file_path.name.lower()
421
+
422
+ if name == "requirements.txt" or name.endswith(".txt"):
423
+ content = file_path.read_text()
424
+ if "==" in content or "-r " in content or "# via" in content:
425
+ return "requirements"
426
+ elif name == "pyproject.toml":
427
+ return "pyproject"
428
+ elif name == "uv.lock":
429
+ return "uv-lock"
430
+ elif name == "package-lock.json":
431
+ return "package-lock"
432
+
433
+ return None
434
+
435
+
436
+ def parse_requirements(file_path: Path) -> dict[str, str]:
437
+ """Parse a pip requirements.txt file."""
438
+ packages = {}
439
+ content = file_path.read_text()
440
+ pattern = re.compile(r"^([a-zA-Z][a-zA-Z0-9._-]*)==([^\s\\]+)", re.MULTILINE)
441
+
442
+ for match in pattern.finditer(content):
443
+ packages[match.group(1).lower()] = match.group(2)
444
+
445
+ return packages
446
+
447
+
448
+ def parse_pyproject(file_path: Path) -> dict[str, str]:
449
+ """Parse a pyproject.toml file for dependencies."""
450
+ packages = {}
451
+ content = file_path.read_text()
452
+
453
+ try:
454
+ import tomllib
455
+ data = tomllib.loads(content)
456
+ deps = []
457
+
458
+ if "project" in data:
459
+ deps.extend(data["project"].get("dependencies", []))
460
+ for group_deps in data["project"].get("optional-dependencies", {}).values():
461
+ deps.extend(group_deps)
462
+
463
+ if "tool" in data and "poetry" in data["tool"]:
464
+ poetry = data["tool"]["poetry"]
465
+ for dep_name, dep_spec in poetry.get("dependencies", {}).items():
466
+ if dep_name.lower() != "python":
467
+ if isinstance(dep_spec, str):
468
+ deps.append(f"{dep_name}{dep_spec}")
469
+ elif isinstance(dep_spec, dict) and "version" in dep_spec:
470
+ deps.append(f"{dep_name}{dep_spec['version']}")
471
+
472
+ for dep in deps:
473
+ match = re.match(r"([a-zA-Z][a-zA-Z0-9._-]*)\s*([=<>!~]+.+)?", dep)
474
+ if match:
475
+ name = match.group(1).lower()
476
+ version_spec = match.group(2) or ""
477
+ exact_match = re.search(r"==\s*([^\s,;]+)", version_spec)
478
+ packages[name] = exact_match.group(1) if exact_match else (version_spec.strip() or "*")
479
+
480
+ except ImportError:
481
+ log_warning("tomllib not available, using basic regex parsing for pyproject.toml")
482
+ dep_pattern = re.compile(r'"([a-zA-Z][a-zA-Z0-9._-]*)\s*([=<>!~][^"]*)"')
483
+ for match in dep_pattern.finditer(content):
484
+ name = match.group(1).lower()
485
+ version_spec = match.group(2)
486
+ exact_match = re.search(r"==\s*([^\s,;\"]+)", version_spec)
487
+ packages[name] = exact_match.group(1) if exact_match else version_spec.strip()
488
+
489
+ return packages
490
+
491
+
492
+ def parse_uv_lock(file_path: Path) -> dict[str, str]:
493
+ """Parse a uv.lock file (uv package manager lockfile)."""
494
+ packages = {}
495
+ content = file_path.read_text()
496
+
497
+ try:
498
+ import tomllib
499
+ data = tomllib.loads(content)
500
+
501
+ for pkg in data.get("package", []):
502
+ name = pkg.get("name", "").lower()
503
+ version = pkg.get("version", "")
504
+ source = pkg.get("source", {})
505
+
506
+ if isinstance(source, dict) and source.get("editable"):
507
+ continue
508
+
509
+ if name and version:
510
+ packages[name] = version
511
+
512
+ except ImportError:
513
+ log_warning("tomllib not available, using regex parsing for uv.lock")
514
+ package_pattern = re.compile(
515
+ r'\[\[package\]\]\s*\nname\s*=\s*"([^"]+)"\s*\nversion\s*=\s*"([^"]+)"',
516
+ re.MULTILINE,
517
+ )
518
+
519
+ for match in package_pattern.finditer(content):
520
+ name = match.group(1).lower()
521
+ version = match.group(2)
522
+ block_start = match.end()
523
+ next_block = content.find("[[package]]", block_start)
524
+ block_content = content[block_start:next_block] if next_block != -1 else content[block_start:]
525
+
526
+ if 'editable = "' not in block_content and "editable = '" not in block_content:
527
+ packages[name] = version
528
+
529
+ return packages
530
+
531
+
532
+ def parse_package_lock(file_path: Path) -> dict[str, str]:
533
+ """Parse an npm package-lock.json file."""
534
+ packages = {}
535
+ data = json.loads(file_path.read_text())
536
+ lock_version = data.get("lockfileVersion", 1)
537
+
538
+ if lock_version >= 2:
539
+ for pkg_path, pkg_info in data.get("packages", {}).items():
540
+ if pkg_path == "":
541
+ continue
542
+ parts = pkg_path.split("node_modules/")
543
+ if len(parts) > 1:
544
+ name = parts[-1]
545
+ version = pkg_info.get("version", "")
546
+ if name and version:
547
+ packages[name] = version
548
+ else:
549
+ def extract_deps(deps: dict):
550
+ for name, info in deps.items():
551
+ if isinstance(info, dict):
552
+ if version := info.get("version", ""):
553
+ packages[name] = version
554
+ if "dependencies" in info:
555
+ extract_deps(info["dependencies"])
556
+ extract_deps(data.get("dependencies", {}))
557
+
558
+ return packages
559
+
560
+
561
+ # =============================================================================
562
+ # CVE SCANNING
563
+ # =============================================================================
564
+
565
+ def query_osv(name: str, version: str, ecosystem: str) -> list[dict]:
566
+ """Query the OSV API for a specific package."""
567
+ url = "https://api.osv.dev/v1/query"
568
+
569
+ if not version or version == "*" or version.startswith(("<", ">", "~", "^")):
570
+ payload = {"package": {"name": name, "ecosystem": ecosystem}}
571
+ else:
572
+ payload = {"version": version, "package": {"name": name, "ecosystem": ecosystem}}
573
+
574
+ try:
575
+ request = Request(
576
+ url,
577
+ data=json.dumps(payload).encode("utf-8"),
578
+ headers={"Content-Type": "application/json"},
579
+ method="POST",
580
+ )
581
+ with urlopen(request, timeout=30) as response:
582
+ return json.loads(response.read().decode("utf-8")).get("vulns", [])
583
+ except HTTPError as e:
584
+ if e.code != 400:
585
+ log_warning(f"HTTP error querying {name}: {e.code}")
586
+ return []
587
+ except URLError as e:
588
+ log_warning(f"Network error querying {name}: {e}")
589
+ return []
590
+ except json.JSONDecodeError:
591
+ return []
592
+
593
+
594
+ def scan_packages(packages: dict[str, str], ecosystem: str) -> dict[str, list[dict]]:
595
+ """Query OSV API for vulnerabilities in packages."""
596
+ vulnerabilities = {}
597
+ total = len(packages)
598
+
599
+ for idx, (name, version) in enumerate(packages.items(), 1):
600
+ if idx % 20 == 0 or idx == total:
601
+ log(f" Scanning package {idx}/{total}...")
602
+
603
+ if vulns := query_osv(name, version, ecosystem):
604
+ vulnerabilities[name] = {"version": version, "vulns": vulns}
605
+
606
+ return vulnerabilities
607
+
608
+
609
+ def get_severity(vuln: dict) -> str:
610
+ """Extract severity from vulnerability data."""
611
+ if severity := vuln.get("database_specific", {}).get("severity"):
612
+ return severity.upper()
613
+
614
+ for affected in vuln.get("affected", []):
615
+ if severity := affected.get("ecosystem_specific", {}).get("severity"):
616
+ return severity.upper()
617
+
618
+ return "UNKNOWN"
619
+
620
+
621
+ def get_fixed_version(vuln: dict, package_name: str) -> str | None:
622
+ """Extract the fixed version from vulnerability data."""
623
+ for affected in vuln.get("affected", []):
624
+ if affected.get("package", {}).get("name", "").lower() == package_name.lower():
625
+ for rng in affected.get("ranges", []):
626
+ for event in rng.get("events", []):
627
+ if "fixed" in event:
628
+ return event["fixed"]
629
+ return None
630
+
631
+
632
+ # =============================================================================
633
+ # REPORT GENERATION
634
+ # =============================================================================
635
+
636
+ def build_template_context(
637
+ file_path: Path,
638
+ ecosystem: str,
639
+ packages: dict[str, str],
640
+ vulnerabilities: dict[str, list[dict]],
641
+ ) -> dict:
642
+ """Build the context dictionary for template rendering."""
643
+ all_vulns = []
644
+ for pkg_name, pkg_data in vulnerabilities.items():
645
+ version = pkg_data["version"]
646
+ for vuln in pkg_data["vulns"]:
647
+ severity = get_severity(vuln)
648
+ fixed_version = get_fixed_version(vuln, pkg_name)
649
+ aliases = vuln.get("aliases", [])
650
+ cve_aliases = [a for a in aliases if a.startswith("CVE-")]
651
+ references = vuln.get("references", [])
652
+
653
+ all_vulns.append({
654
+ "package": pkg_name,
655
+ "version": version,
656
+ "vuln_id": vuln.get("id", "Unknown"),
657
+ "severity": severity,
658
+ "fixed_version": fixed_version,
659
+ "summary": vuln.get("summary", "No description available"),
660
+ "details": vuln.get("details", ""),
661
+ "cve_aliases": cve_aliases,
662
+ "references": [
663
+ {"type": ref.get("type", "WEB"), "url": ref.get("url", "")}
664
+ for ref in references if ref.get("url")
665
+ ],
666
+ })
667
+
668
+ all_vulns.sort(key=lambda x: SEVERITY_ORDER.get(x["severity"], 0), reverse=True)
669
+
670
+ by_severity = {}
671
+ for v in all_vulns:
672
+ by_severity.setdefault(v["severity"], []).append(v)
673
+
674
+ severities = [{"level": level, "vulns": by_severity.get(level, [])} for level in SEVERITY_LEVELS]
675
+
676
+ upgrades_needed = {}
677
+ for item in all_vulns:
678
+ pkg = item["package"]
679
+ if item["fixed_version"]:
680
+ if pkg not in upgrades_needed:
681
+ upgrades_needed[pkg] = {
682
+ "package": pkg,
683
+ "current": item["version"],
684
+ "fixed": item["fixed_version"],
685
+ "severity": item["severity"],
686
+ }
687
+ else:
688
+ try:
689
+ from packaging.version import Version
690
+ if Version(item["fixed_version"]) > Version(upgrades_needed[pkg]["fixed"]):
691
+ upgrades_needed[pkg]["fixed"] = item["fixed_version"]
692
+ except (ImportError, Exception):
693
+ pass
694
+
695
+ sorted_upgrades = sorted(
696
+ upgrades_needed.values(),
697
+ key=lambda x: SEVERITY_ORDER.get(x["severity"], 0),
698
+ reverse=True,
699
+ )
700
+
701
+ return {
702
+ "file_path": str(file_path),
703
+ "ecosystem": ecosystem,
704
+ "scan_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
705
+ "total_packages": len(packages),
706
+ "vulnerable_package_count": len(vulnerabilities),
707
+ "vulnerabilities": all_vulns,
708
+ "severities": severities,
709
+ "upgrades": sorted_upgrades,
710
+ }
711
+
712
+
713
+ # =============================================================================
714
+ # MAIN
715
+ # =============================================================================
716
+
717
+ def main() -> int:
718
+ """Main entry point for the CVE scanner."""
719
+ parser = argparse.ArgumentParser(
720
+ description="Scan package dependency files for known CVEs (Jinja2 prototype).",
721
+ formatter_class=argparse.RawDescriptionHelpFormatter,
722
+ epilog="""
723
+ Examples:
724
+ python cve_scan.py requirements.txt
725
+ python cve_scan.py requirements.txt --report-format html
726
+ python cve_scan.py requirements.txt -o report.html --report-format html
727
+ """,
728
+ )
729
+ parser.add_argument("file_path", help="Path to the dependency file")
730
+ parser.add_argument(
731
+ "--format", "-f",
732
+ choices=["requirements", "pyproject", "uv-lock", "package-lock"],
733
+ help="Force a specific input file format",
734
+ )
735
+ parser.add_argument(
736
+ "--report-format", "-r",
737
+ choices=["markdown", "html"],
738
+ default="markdown",
739
+ help="Output report format (default: markdown)",
740
+ )
741
+ parser.add_argument("--output", "-o", help="Output file path (defaults to stdout)")
742
+
743
+ args = parser.parse_args()
744
+ file_path = Path(args.file_path)
745
+
746
+ if not file_path.exists():
747
+ log(f"Error: File not found: {file_path}")
748
+ return 1
749
+
750
+ file_format = args.format or detect_format(file_path)
751
+ if not file_format:
752
+ log(f"Error: Could not detect file format for: {file_path}")
753
+ return 1
754
+
755
+ log(f"Scanning {file_path} as {file_format} format...")
756
+
757
+ # Parse packages
758
+ parsers = {
759
+ "requirements": parse_requirements,
760
+ "pyproject": parse_pyproject,
761
+ "uv-lock": parse_uv_lock,
762
+ "package-lock": parse_package_lock,
763
+ }
764
+ packages = parsers[file_format](file_path)
765
+ ecosystem = "npm" if file_format == "package-lock" else "PyPI"
766
+
767
+ log(f"Found {len(packages)} packages to scan...")
768
+
769
+ # Scan for vulnerabilities
770
+ vulnerabilities = scan_packages(packages, ecosystem)
771
+
772
+ # Build context and render report
773
+ context = build_template_context(file_path, ecosystem, packages, vulnerabilities)
774
+ env = create_jinja_env()
775
+ report = render_template(env, args.report_format, context)
776
+
777
+ # Output
778
+ if args.output:
779
+ Path(args.output).write_text(report)
780
+ log(f"Report written to {args.output}")
781
+ else:
782
+ print(report)
783
+
784
+ return 1 if vulnerabilities else 0
785
+
786
+
787
+ if __name__ == "__main__":
788
+ sys.exit(main())