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,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 @@
|
|
|
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 }}>={{ 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())
|