patchradar 2026.8.1__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.
patchradar/__init__.py ADDED
File without changes
File without changes
patchradar/api/main.py ADDED
@@ -0,0 +1,78 @@
1
+ from fastapi import FastAPI
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.responses import HTMLResponse
4
+ from pathlib import Path
5
+ from patchradar.db.database import (
6
+ init_db, get_watchlist, add_to_watchlist,
7
+ remove_from_watchlist, get_cves
8
+ )
9
+ from patchradar.collectors.nvd import fetch_cves as nvd_fetch
10
+ import asyncio
11
+
12
+ app = FastAPI(title="PatchRadar", version="2026.8.1")
13
+
14
+ @app.on_event("startup")
15
+ async def startup():
16
+ await init_db()
17
+
18
+ @app.get("/api/watchlist")
19
+ async def api_watchlist():
20
+ items = await get_watchlist()
21
+ return {"watchlist": items}
22
+
23
+ @app.post("/api/watchlist/{software}")
24
+ async def api_add(software: str):
25
+ added = await add_to_watchlist(software)
26
+ return {"added": added, "software": software}
27
+
28
+ @app.delete("/api/watchlist/{software}")
29
+ async def api_remove(software: str):
30
+ removed = await remove_from_watchlist(software)
31
+ return {"removed": removed, "software": software}
32
+
33
+ @app.get("/api/cves")
34
+ async def api_cves(software: str = None, limit: int = 50):
35
+ cves = await get_cves(software=software, limit=limit)
36
+ return {"cves": cves, "total": len(cves)}
37
+
38
+ @app.post("/api/scan")
39
+ async def api_scan(days: int = 7):
40
+ from patchradar.db.database import save_cve
41
+ watchlist = await get_watchlist()
42
+ total = 0
43
+ results = {}
44
+ for sw in watchlist:
45
+ cves = await nvd_fetch(sw, days_back=days)
46
+ for cve in cves:
47
+ await save_cve(cve)
48
+ results[sw] = len(cves)
49
+ total += len(cves)
50
+ return {"total": total, "by_software": results}
51
+
52
+ @app.get("/api/stats")
53
+ async def api_stats():
54
+ from patchradar.db.database import DB_PATH
55
+ import aiosqlite
56
+ async with aiosqlite.connect(DB_PATH) as db:
57
+ async with db.execute("SELECT COUNT(*) FROM cves") as cur:
58
+ total = (await cur.fetchone())[0]
59
+ async with db.execute(
60
+ "SELECT severity, COUNT(*) FROM cves GROUP BY severity"
61
+ ) as cur:
62
+ by_severity = dict(await cur.fetchall())
63
+ async with db.execute(
64
+ "SELECT software, COUNT(*) FROM cves GROUP BY software ORDER BY COUNT(*) DESC"
65
+ ) as cur:
66
+ by_software = dict(await cur.fetchall())
67
+ watchlist = await get_watchlist()
68
+ return {
69
+ "total_cves": total,
70
+ "watched": len(watchlist),
71
+ "by_severity": by_severity,
72
+ "by_software": by_software,
73
+ }
74
+
75
+ @app.get("/", response_class=HTMLResponse)
76
+ async def index():
77
+ html_path = Path(__file__).parent / "templates" / "index.html"
78
+ return HTMLResponse(content=html_path.read_text())
@@ -0,0 +1,334 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PatchRadar 🛡️</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #e6edf3; min-height: 100vh; }
10
+ header { background: #161b22; border-bottom: 1px solid #30363d; padding: 1rem 2rem; display: flex; align-items: center; gap: 1rem; }
11
+ header h1 { font-size: 1.4rem; font-weight: 600; }
12
+ header span { font-size: 1.5rem; }
13
+ .badge { background: #238636; color: #fff; font-size: 0.7rem; padding: 2px 8px; border-radius: 20px; font-weight: 600; }
14
+ main { padding: 2rem; max-width: 1400px; margin: 0 auto; }
15
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
16
+ .stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.2rem; }
17
+ .stat-card .label { font-size: 0.75rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
18
+ .stat-card .value { font-size: 2rem; font-weight: 700; }
19
+ .stat-card.critical .value { color: #f85149; }
20
+ .stat-card.high .value { color: #e3b341; }
21
+ .stat-card.medium .value { color: #d29922; }
22
+ .stat-card.low .value { color: #3fb950; }
23
+ .stat-card.total .value { color: #58a6ff; }
24
+ .grid { display: grid; grid-template-columns: 300px 1fr; gap: 1.5rem; }
25
+ .panel { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; }
26
+ .panel h2 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #e6edf3; }
27
+ .watchlist-item { display: flex; align-items: center; justify-content: space-between; padding: 0.6rem 0.8rem; border-radius: 6px; margin-bottom: 0.4rem; background: #0d1117; border: 1px solid #30363d; }
28
+ .watchlist-item span { font-size: 0.9rem; }
29
+ .btn-remove { background: none; border: none; color: #f85149; cursor: pointer; font-size: 1rem; padding: 2px 6px; border-radius: 4px; }
30
+ .btn-remove:hover { background: #2d1316; }
31
+ .add-form { display: flex; gap: 0.5rem; margin-top: 1rem; }
32
+ .add-form input { flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 0.5rem 0.8rem; color: #e6edf3; font-size: 0.9rem; outline: none; }
33
+ .add-form input:focus { border-color: #58a6ff; }
34
+ .btn { padding: 0.5rem 1rem; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: opacity 0.2s; }
35
+ .btn:hover { opacity: 0.85; }
36
+ .btn-primary { background: #238636; color: #fff; }
37
+ .btn-scan { background: #1f6feb; color: #fff; width: 100%; margin-top: 1rem; padding: 0.7rem; }
38
+ .filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
39
+ .filter-btn { padding: 0.3rem 0.8rem; border-radius: 20px; border: 1px solid #30363d; background: #0d1117; color: #8b949e; cursor: pointer; font-size: 0.8rem; }
40
+ .filter-btn.active { border-color: #58a6ff; color: #58a6ff; background: #0c2d6b; }
41
+ .filter-btn.critical.active { border-color: #f85149; color: #f85149; background: #2d1316; }
42
+ .filter-btn.high.active { border-color: #e3b341; color: #e3b341; background: #2d2005; }
43
+ .filter-btn.medium.active { border-color: #d29922; color: #d29922; background: #2d1f00; }
44
+ .filter-btn.low.active { border-color: #3fb950; color: #3fb950; background: #0d2b1a; }
45
+ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
46
+ th { text-align: left; padding: 0.6rem 0.8rem; color: #8b949e; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; border-bottom: 1px solid #30363d; }
47
+ td { padding: 0.7rem 0.8rem; border-bottom: 1px solid #21262d; vertical-align: top; }
48
+ tr:hover td { background: #1c2128; }
49
+ .severity-badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 0.72rem; font-weight: 700; }
50
+ .sev-CRITICAL { background: #2d1316; color: #f85149; }
51
+ .sev-HIGH { background: #2d2005; color: #e3b341; }
52
+ .sev-MEDIUM { background: #2d1f00; color: #d29922; }
53
+ .sev-LOW { background: #0d2b1a; color: #3fb950; }
54
+ .sev-UNKNOWN { background: #21262d; color: #8b949e; }
55
+ .score { font-weight: 700; }
56
+ .score.critical { color: #f85149; }
57
+ .score.high { color: #e3b341; }
58
+ .score.medium { color: #d29922; }
59
+ .score.low { color: #3fb950; }
60
+ .cve-link { color: #58a6ff; text-decoration: none; font-family: monospace; }
61
+ .cve-link:hover { text-decoration: underline; }
62
+ .desc { color: #8b949e; max-width: 400px; }
63
+ .source-badge { font-size: 0.7rem; background: #21262d; padding: 2px 6px; border-radius: 4px; color: #8b949e; }
64
+ .scanning { opacity: 0.6; }
65
+ .toast { position: fixed; bottom: 2rem; right: 2rem; background: #238636; color: #fff; padding: 0.8rem 1.2rem; border-radius: 8px; font-size: 0.9rem; display: none; z-index: 100; }
66
+ .empty { text-align: center; padding: 3rem; color: #8b949e; }
67
+ .chart-container { margin-top: 1rem; }
68
+ .chart-bar { display: flex; align-items: center; gap: 0.8rem; margin-bottom: 0.6rem; }
69
+ .chart-bar .label { width: 80px; font-size: 0.8rem; color: #8b949e; text-align: right; }
70
+ .chart-bar .bar-wrap { flex: 1; background: #21262d; border-radius: 4px; height: 20px; overflow: hidden; }
71
+ .chart-bar .bar { height: 100%; border-radius: 4px; transition: width 0.5s ease; display: flex; align-items: center; padding-left: 8px; font-size: 0.75rem; font-weight: 600; }
72
+ .bar-critical { background: #f85149; }
73
+ .bar-high { background: #e3b341; }
74
+ .bar-medium { background: #d29922; }
75
+ .bar-low { background: #3fb950; }
76
+ .bar-unknown { background: #8b949e; }
77
+ .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #fff3; border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 6px; }
78
+ @keyframes spin { to { transform: rotate(360deg); } }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <header>
83
+ <span>🛡️</span>
84
+ <h1>PatchRadar</h1>
85
+ <span class="badge">2026.8.1</span>
86
+ </header>
87
+
88
+ <main>
89
+ <!-- Stats -->
90
+ <div class="stats-grid" id="stats-grid">
91
+ <div class="stat-card total"><div class="label">Total CVEs</div><div class="value" id="stat-total">—</div></div>
92
+ <div class="stat-card critical"><div class="label">Critical</div><div class="value" id="stat-critical">—</div></div>
93
+ <div class="stat-card high"><div class="label">High</div><div class="value" id="stat-high">—</div></div>
94
+ <div class="stat-card medium"><div class="label">Medium</div><div class="value" id="stat-medium">—</div></div>
95
+ <div class="stat-card low"><div class="label">Low</div><div class="value" id="stat-low">—</div></div>
96
+ <div class="stat-card total"><div class="label">Watching</div><div class="value" id="stat-watched">—</div></div>
97
+ </div>
98
+
99
+ <div class="grid">
100
+ <!-- Watchlist -->
101
+ <div>
102
+ <div class="panel">
103
+ <h2>📋 Watchlist</h2>
104
+ <div id="watchlist-items"></div>
105
+ <div class="add-form">
106
+ <input type="text" id="add-input" placeholder="Add software..." onkeydown="if(event.key==='Enter')addSoftware()">
107
+ <button class="btn btn-primary" onclick="addSoftware()">Add</button>
108
+ </div>
109
+ <button class="btn btn-scan" id="scan-btn" onclick="scanAll()">🔍 Scan All</button>
110
+ </div>
111
+
112
+ <!-- Chart -->
113
+ <div class="panel" style="margin-top: 1rem;">
114
+ <h2>📊 By Severity</h2>
115
+ <div class="chart-container" id="severity-chart"></div>
116
+ </div>
117
+
118
+ <!-- By Software -->
119
+ <div class="panel" style="margin-top: 1rem;">
120
+ <h2>📦 By Software</h2>
121
+ <div class="chart-container" id="software-chart"></div>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- CVE Table -->
126
+ <div class="panel">
127
+ <h2>🚨 CVEs</h2>
128
+ <div class="filters">
129
+ <button class="filter-btn active" onclick="setFilter('ALL', this)">All</button>
130
+ <button class="filter-btn critical" onclick="setFilter('CRITICAL', this)">Critical</button>
131
+ <button class="filter-btn high" onclick="setFilter('HIGH', this)">High</button>
132
+ <button class="filter-btn medium" onclick="setFilter('MEDIUM', this)">Medium</button>
133
+ <button class="filter-btn low" onclick="setFilter('LOW', this)">Low</button>
134
+ </div>
135
+ <div id="cve-table-wrap">
136
+ <div class="empty">Run a scan to see CVEs</div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </main>
141
+
142
+ <div class="toast" id="toast"></div>
143
+
144
+ <script>
145
+ let allCves = [];
146
+ let currentFilter = 'ALL';
147
+
148
+ async function api(path, method='GET', body=null) {
149
+ const opts = { method, headers: {'Content-Type': 'application/json'} };
150
+ if (body) opts.body = JSON.stringify(body);
151
+ const r = await fetch(path, opts);
152
+ return r.json();
153
+ }
154
+
155
+ function toast(msg, color='#238636') {
156
+ const t = document.getElementById('toast');
157
+ t.textContent = msg;
158
+ t.style.background = color;
159
+ t.style.display = 'block';
160
+ setTimeout(() => t.style.display = 'none', 3000);
161
+ }
162
+
163
+ async function loadWatchlist() {
164
+ const data = await api('/api/watchlist');
165
+ const el = document.getElementById('watchlist-items');
166
+ if (!data.watchlist.length) {
167
+ el.innerHTML = '<div style="color:#8b949e;font-size:0.85rem;padding:0.5rem 0">No software added yet</div>';
168
+ return;
169
+ }
170
+ el.innerHTML = data.watchlist.map(sw => `
171
+ <div class="watchlist-item">
172
+ <span>📦 ${sw}</span>
173
+ <button class="btn-remove" onclick="removeSoftware('${sw}')" title="Remove">✕</button>
174
+ </div>
175
+ `).join('');
176
+ }
177
+
178
+ async function addSoftware() {
179
+ const input = document.getElementById('add-input');
180
+ const sw = input.value.trim();
181
+ if (!sw) return;
182
+ const data = await api(`/api/watchlist/${encodeURIComponent(sw)}`, 'POST');
183
+ if (data.added) {
184
+ toast(`✅ Added ${sw}`);
185
+ input.value = '';
186
+ await loadWatchlist();
187
+ await loadStats();
188
+ } else {
189
+ toast(`⚠️ ${sw} already in watchlist`, '#d29922');
190
+ }
191
+ }
192
+
193
+ async function removeSoftware(sw) {
194
+ await api(`/api/watchlist/${encodeURIComponent(sw)}`, 'DELETE');
195
+ toast(`🗑️ Removed ${sw}`, '#f85149');
196
+ await loadWatchlist();
197
+ await loadStats();
198
+ await loadCves();
199
+ }
200
+
201
+ async function scanAll() {
202
+ const btn = document.getElementById('scan-btn');
203
+ btn.innerHTML = '<span class="spinner"></span>Scanning...';
204
+ btn.disabled = true;
205
+ try {
206
+ const data = await api('/api/scan?days=30', 'POST');
207
+ toast(`✅ Found ${data.total} CVEs`);
208
+ await loadCves();
209
+ await loadStats();
210
+ } finally {
211
+ btn.innerHTML = '🔍 Scan All';
212
+ btn.disabled = false;
213
+ }
214
+ }
215
+
216
+ async function loadCves() {
217
+ const data = await api('/api/cves?limit=200');
218
+ allCves = data.cves;
219
+ renderTable();
220
+ }
221
+
222
+ function setFilter(severity, btn) {
223
+ currentFilter = severity;
224
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
225
+ btn.classList.add('active');
226
+ renderTable();
227
+ }
228
+
229
+ function severityClass(s) {
230
+ const m = {'CRITICAL':'critical','HIGH':'high','MEDIUM':'medium','LOW':'low'};
231
+ return m[(s||'').toUpperCase()] || '';
232
+ }
233
+
234
+ function scoreClass(score) {
235
+ if (!score) return '';
236
+ if (score >= 9.0) return 'critical';
237
+ if (score >= 7.0) return 'high';
238
+ if (score >= 4.0) return 'medium';
239
+ return 'low';
240
+ }
241
+
242
+ function renderTable() {
243
+ const filtered = currentFilter === 'ALL'
244
+ ? allCves
245
+ : allCves.filter(c => (c.severity||'').toUpperCase() === currentFilter);
246
+
247
+ const wrap = document.getElementById('cve-table-wrap');
248
+ if (!filtered.length) {
249
+ wrap.innerHTML = '<div class="empty">No CVEs found. Run a scan first.</div>';
250
+ return;
251
+ }
252
+
253
+ wrap.innerHTML = `
254
+ <table>
255
+ <thead>
256
+ <tr>
257
+ <th>CVE ID</th>
258
+ <th>Software</th>
259
+ <th>Score</th>
260
+ <th>Severity</th>
261
+ <th>Description</th>
262
+ <th>Source</th>
263
+ <th>Published</th>
264
+ </tr>
265
+ </thead>
266
+ <tbody>
267
+ ${filtered.map(cve => `
268
+ <tr>
269
+ <td><a class="cve-link" href="${cve.url||'#'}" target="_blank">${cve.id}</a></td>
270
+ <td><span class="source-badge">${cve.software||''}</span></td>
271
+ <td><span class="score ${scoreClass(cve.cvss_score)}">${cve.cvss_score ? cve.cvss_score.toFixed(1) : 'N/A'}</span></td>
272
+ <td><span class="severity-badge sev-${(cve.severity||'UNKNOWN').toUpperCase()}">${cve.severity||'UNKNOWN'}</span></td>
273
+ <td class="desc">${(cve.description||'').substring(0, 120)}${(cve.description||'').length > 120 ? '...' : ''}</td>
274
+ <td><span class="source-badge">${cve.source||''}</span></td>
275
+ <td style="color:#8b949e;font-size:0.8rem">${cve.published_at ? cve.published_at.substring(0,10) : ''}</td>
276
+ </tr>
277
+ `).join('')}
278
+ </tbody>
279
+ </table>
280
+ `;
281
+ }
282
+
283
+ async function loadStats() {
284
+ const data = await api('/api/stats');
285
+ document.getElementById('stat-total').textContent = data.total_cves;
286
+ document.getElementById('stat-watched').textContent = data.watched;
287
+ const sev = data.by_severity || {};
288
+ document.getElementById('stat-critical').textContent = sev['CRITICAL'] || 0;
289
+ document.getElementById('stat-high').textContent = sev['HIGH'] || 0;
290
+ document.getElementById('stat-medium').textContent = sev['MEDIUM'] || 0;
291
+ document.getElementById('stat-low').textContent = sev['LOW'] || 0;
292
+
293
+ const total = data.total_cves || 1;
294
+ const sevOrder = ['CRITICAL','HIGH','MEDIUM','LOW','UNKNOWN'];
295
+ const sevColors = {'CRITICAL':'critical','HIGH':'high','MEDIUM':'medium','LOW':'low','UNKNOWN':'unknown'};
296
+ document.getElementById('severity-chart').innerHTML = sevOrder
297
+ .filter(s => sev[s])
298
+ .map(s => `
299
+ <div class="chart-bar">
300
+ <div class="label">${s}</div>
301
+ <div class="bar-wrap">
302
+ <div class="bar bar-${sevColors[s]}" style="width:${Math.round((sev[s]/total)*100)}%">
303
+ ${sev[s]}
304
+ </div>
305
+ </div>
306
+ </div>
307
+ `).join('');
308
+
309
+ const bySw = data.by_software || {};
310
+ const maxSw = Math.max(...Object.values(bySw), 1);
311
+ document.getElementById('software-chart').innerHTML = Object.entries(bySw)
312
+ .sort((a,b) => b[1]-a[1])
313
+ .map(([sw, count]) => `
314
+ <div class="chart-bar">
315
+ <div class="label">${sw}</div>
316
+ <div class="bar-wrap">
317
+ <div class="bar bar-high" style="width:${Math.round((count/maxSw)*100)}%">
318
+ ${count}
319
+ </div>
320
+ </div>
321
+ </div>
322
+ `).join('');
323
+ }
324
+
325
+ async function init() {
326
+ await loadWatchlist();
327
+ await loadStats();
328
+ await loadCves();
329
+ }
330
+
331
+ init();
332
+ </script>
333
+ </body>
334
+ </html>
patchradar/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ import typer
2
+ import asyncio
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from rich import box
6
+ from rich.text import Text
7
+ from patchradar.db.database import (
8
+ init_db, add_to_watchlist, get_watchlist,
9
+ remove_from_watchlist, get_cves, save_cve
10
+ )
11
+ from patchradar.collectors.nvd import fetch_cves
12
+
13
+ app = typer.Typer(
14
+ name="patchradar",
15
+ help="Realtime CVE intelligence for your software stack 🛡️",
16
+ add_completion=False,
17
+ )
18
+ console = Console()
19
+
20
+ def run(coro):
21
+ return asyncio.run(coro)
22
+
23
+ @app.callback()
24
+ def startup():
25
+ run(init_db())
26
+
27
+ @app.command()
28
+ def add(software: str = typer.Argument(..., help="Software to monitor")):
29
+ """Add software to your watchlist."""
30
+ added = run(add_to_watchlist(software))
31
+ if added:
32
+ console.print(f"✅ [green]Added[/green] [bold]{software}[/bold] to watchlist")
33
+ else:
34
+ console.print(f"⚠️ [yellow]{software}[/yellow] is already in your watchlist")
35
+
36
+ @app.command()
37
+ def remove(software: str = typer.Argument(..., help="Software to remove")):
38
+ """Remove software from your watchlist."""
39
+ removed = run(remove_from_watchlist(software))
40
+ if removed:
41
+ console.print(f"🗑️ [red]Removed[/red] [bold]{software}[/bold] from watchlist")
42
+ else:
43
+ console.print(f"❌ [red]{software}[/red] not found in watchlist")
44
+
45
+ @app.command(name="list")
46
+ def list_watchlist():
47
+ """Show your current watchlist."""
48
+ items = run(get_watchlist())
49
+ if not items:
50
+ console.print("📭 Your watchlist is empty. Use [bold]patchradar add <software>[/bold]")
51
+ return
52
+ table = Table(title="🛡️ PatchRadar Watchlist", box=box.ROUNDED)
53
+ table.add_column("Software", style="cyan bold")
54
+ for item in items:
55
+ table.add_row(item)
56
+ console.print(table)
57
+
58
+ @app.command()
59
+ def scan(
60
+ software: str = typer.Argument(None, help="Software to scan (or all watchlist)"),
61
+ days: int = typer.Option(7, "--days", "-d", help="Days back to search"),
62
+ ):
63
+ """Scan for CVEs affecting your software."""
64
+ async def _scan():
65
+ targets = [software] if software else await get_watchlist()
66
+ if not targets:
67
+ console.print("📭 Nothing to scan. Add software with [bold]patchradar add[/bold]")
68
+ return
69
+
70
+ total = 0
71
+ for target in targets:
72
+ with console.status(f"[cyan]Scanning {target}...[/cyan]"):
73
+ cves = await fetch_cves(target, days_back=days)
74
+
75
+ for cve in cves:
76
+ await save_cve(cve)
77
+
78
+ total += len(cves)
79
+ if cves:
80
+ _print_cves(target, cves)
81
+ else:
82
+ console.print(f"✅ [green]{target}[/green] — no CVEs found in last {days} days")
83
+
84
+ console.print(f"\n📊 Total: [bold]{total}[/bold] CVEs found")
85
+
86
+ run(_scan())
87
+
88
+ @app.command()
89
+ def status():
90
+ """Show latest CVEs from your watchlist."""
91
+ async def _status():
92
+ cves = await get_cves(limit=20)
93
+ if not cves:
94
+ console.print("📭 No CVEs in database yet. Run [bold]patchradar scan[/bold]")
95
+ return
96
+ _print_cves_table(cves)
97
+ run(_status())
98
+
99
+ def _print_cves(software: str, cves: list):
100
+ table = Table(
101
+ title=f"🚨 CVEs for [bold]{software}[/bold]",
102
+ box=box.ROUNDED,
103
+ show_lines=True
104
+ )
105
+ table.add_column("CVE ID", style="bold cyan", no_wrap=True)
106
+ table.add_column("Score", justify="center", width=6)
107
+ table.add_column("Severity", justify="center", width=10)
108
+ table.add_column("Description", max_width=60)
109
+
110
+ for cve in cves:
111
+ score = cve.get("cvss_score")
112
+ severity = cve.get("severity", "UNKNOWN")
113
+ score_str = f"{score:.1f}" if score else "N/A"
114
+ severity_color = {
115
+ "CRITICAL": "red bold",
116
+ "HIGH": "red",
117
+ "MEDIUM": "yellow",
118
+ "LOW": "green",
119
+ }.get(severity.upper(), "white")
120
+
121
+ table.add_row(
122
+ cve["id"],
123
+ score_str,
124
+ Text(severity, style=severity_color),
125
+ cve.get("description", "")[:120] + "..." if len(cve.get("description", "")) > 120 else cve.get("description", ""),
126
+ )
127
+ console.print(table)
128
+
129
+ def _print_cves_table(cves: list):
130
+ table = Table(title="🛡️ PatchRadar — Latest CVEs", box=box.ROUNDED, show_lines=True)
131
+ table.add_column("CVE ID", style="bold cyan", no_wrap=True)
132
+ table.add_column("Software", style="blue")
133
+ table.add_column("Score", justify="center", width=6)
134
+ table.add_column("Severity", justify="center", width=10)
135
+ table.add_column("Source", width=6)
136
+
137
+ for cve in cves:
138
+ score = cve.get("cvss_score")
139
+ severity = cve.get("severity", "UNKNOWN")
140
+ score_str = f"{score:.1f}" if score else "N/A"
141
+ severity_color = {
142
+ "CRITICAL": "red bold",
143
+ "HIGH": "red",
144
+ "MEDIUM": "yellow",
145
+ "LOW": "green",
146
+ }.get(severity.upper(), "white")
147
+
148
+ table.add_row(
149
+ cve["id"],
150
+ cve.get("software", ""),
151
+ score_str,
152
+ Text(severity, style=severity_color),
153
+ cve.get("source", ""),
154
+ )
155
+ console.print(table)
156
+
157
+
158
+ @app.command()
159
+ def serve(
160
+ host: str = typer.Option("127.0.0.1", "--host", "-h"),
161
+ port: int = typer.Option(8000, "--port", "-p"),
162
+ ):
163
+ """Launch the PatchRadar web UI."""
164
+ import uvicorn
165
+ console.print(f"🛡️ [bold]PatchRadar[/bold] UI → [cyan]http://{host}:{port}[/cyan]")
166
+ uvicorn.run("patchradar.api.main:app", host=host, port=port, reload=False)
167
+
168
+
169
+ def main():
170
+ app()
171
+
172
+ if __name__ == "__main__":
173
+ main()
File without changes
File without changes
File without changes
@@ -0,0 +1,66 @@
1
+ import httpx
2
+ from datetime import datetime, timedelta
3
+
4
+ NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
5
+
6
+ async def fetch_cves(keyword: str, days_back: int = 7) -> list[dict]:
7
+ """Fetch CVEs from NVD for a given keyword."""
8
+ start = (datetime.utcnow() - timedelta(days=days_back)).strftime(
9
+ "%Y-%m-%dT00:00:00.000"
10
+ )
11
+ end = datetime.utcnow().strftime("%Y-%m-%dT23:59:59.999")
12
+
13
+ params = {
14
+ "keywordSearch": keyword,
15
+ "pubStartDate": start,
16
+ "pubEndDate": end,
17
+ "resultsPerPage": 50,
18
+ }
19
+
20
+ async with httpx.AsyncClient(timeout=30.0) as client:
21
+ try:
22
+ response = await client.get(NVD_API, params=params)
23
+ response.raise_for_status()
24
+ data = response.json()
25
+ except Exception as e:
26
+ return []
27
+
28
+ results = []
29
+ for item in data.get("vulnerabilities", []):
30
+ cve = item.get("cve", {})
31
+ cve_id = cve.get("id", "")
32
+
33
+ # Descrizione inglese
34
+ descriptions = cve.get("descriptions", [])
35
+ description = next(
36
+ (d["value"] for d in descriptions if d["lang"] == "en"), ""
37
+ )
38
+
39
+ # CVSS score
40
+ cvss_score = None
41
+ cvss_version = None
42
+ severity = "UNKNOWN"
43
+ metrics = cve.get("metrics", {})
44
+
45
+ for version in ["cvssMetricV40", "cvssMetricV31", "cvssMetricV30", "cvssMetricV2"]:
46
+ if version in metrics and metrics[version]:
47
+ m = metrics[version][0]
48
+ cvss_data = m.get("cvssData", {})
49
+ cvss_score = cvss_data.get("baseScore")
50
+ cvss_version = cvss_data.get("version")
51
+ severity = m.get("baseSeverity", cvss_data.get("baseSeverity", "UNKNOWN"))
52
+ break
53
+
54
+ results.append({
55
+ "id": cve_id,
56
+ "software": keyword.lower(),
57
+ "description": description,
58
+ "cvss_score": cvss_score,
59
+ "cvss_version": cvss_version,
60
+ "severity": severity,
61
+ "published_at": cve.get("published"),
62
+ "source": "NVD",
63
+ "url": f"https://nvd.nist.gov/vuln/detail/{cve_id}",
64
+ })
65
+
66
+ return results
File without changes
File without changes
@@ -0,0 +1,92 @@
1
+ import aiosqlite
2
+ import asyncio
3
+ from pathlib import Path
4
+
5
+ DB_PATH = Path.home() / ".patchradar" / "patchradar.db"
6
+
7
+ async def init_db():
8
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
9
+ async with aiosqlite.connect(DB_PATH) as db:
10
+ await db.execute("""
11
+ CREATE TABLE IF NOT EXISTS watchlist (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ name TEXT UNIQUE NOT NULL,
14
+ added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
15
+ )
16
+ """)
17
+ await db.execute("""
18
+ CREATE TABLE IF NOT EXISTS cves (
19
+ id TEXT PRIMARY KEY,
20
+ software TEXT NOT NULL,
21
+ description TEXT,
22
+ cvss_score REAL,
23
+ cvss_version TEXT,
24
+ severity TEXT,
25
+ published_at TIMESTAMP,
26
+ source TEXT,
27
+ url TEXT,
28
+ seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
29
+ )
30
+ """)
31
+ await db.commit()
32
+
33
+ async def add_to_watchlist(name: str) -> bool:
34
+ async with aiosqlite.connect(DB_PATH) as db:
35
+ try:
36
+ await db.execute(
37
+ "INSERT INTO watchlist (name) VALUES (?)", (name.lower(),)
38
+ )
39
+ await db.commit()
40
+ return True
41
+ except aiosqlite.IntegrityError:
42
+ return False
43
+
44
+ async def get_watchlist() -> list[str]:
45
+ async with aiosqlite.connect(DB_PATH) as db:
46
+ async with db.execute("SELECT name FROM watchlist ORDER BY name") as cursor:
47
+ rows = await cursor.fetchall()
48
+ return [row[0] for row in rows]
49
+
50
+ async def remove_from_watchlist(name: str) -> bool:
51
+ async with aiosqlite.connect(DB_PATH) as db:
52
+ cursor = await db.execute(
53
+ "DELETE FROM watchlist WHERE name = ?", (name.lower(),)
54
+ )
55
+ await db.commit()
56
+ return cursor.rowcount > 0
57
+
58
+ async def save_cve(cve: dict) -> bool:
59
+ async with aiosqlite.connect(DB_PATH) as db:
60
+ try:
61
+ await db.execute("""
62
+ INSERT OR IGNORE INTO cves
63
+ (id, software, description, cvss_score, cvss_version,
64
+ severity, published_at, source, url)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
66
+ """, (
67
+ cve["id"], cve["software"], cve["description"],
68
+ cve.get("cvss_score"), cve.get("cvss_version"),
69
+ cve.get("severity"), cve.get("published_at"),
70
+ cve.get("source"), cve.get("url")
71
+ ))
72
+ await db.commit()
73
+ return cursor.rowcount > 0
74
+ except Exception:
75
+ return False
76
+
77
+ async def get_cves(software: str = None, limit: int = 50) -> list[dict]:
78
+ async with aiosqlite.connect(DB_PATH) as db:
79
+ db.row_factory = aiosqlite.Row
80
+ if software:
81
+ async with db.execute(
82
+ "SELECT * FROM cves WHERE software = ? ORDER BY published_at DESC LIMIT ?",
83
+ (software.lower(), limit)
84
+ ) as cursor:
85
+ rows = await cursor.fetchall()
86
+ else:
87
+ async with db.execute(
88
+ "SELECT * FROM cves ORDER BY published_at DESC LIMIT ?",
89
+ (limit,)
90
+ ) as cursor:
91
+ rows = await cursor.fetchall()
92
+ return [dict(row) for row in rows]
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: patchradar
3
+ Version: 2026.8.1
4
+ Summary: Realtime CVE intelligence for your software stack
5
+ Project-URL: Homepage, https://github.com/maksimtech/patchradar
6
+ Project-URL: Repository, https://github.com/maksimtech/patchradar
7
+ Project-URL: Issues, https://github.com/maksimtech/patchradar/issues
8
+ Author-email: maksimtech <github@maksimtech.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cve,monitoring,patch-tuesday,security,vulnerability
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: System :: Monitoring
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: aiosqlite>=0.20.0
25
+ Requires-Dist: apscheduler>=3.10.0
26
+ Requires-Dist: fastapi>=0.115.0
27
+ Requires-Dist: httpx>=0.28.0
28
+ Requires-Dist: pydantic>=2.0.0
29
+ Requires-Dist: rich>=13.0.0
30
+ Requires-Dist: typer>=0.12.0
31
+ Requires-Dist: uvicorn>=0.30.0
32
+ Description-Content-Type: text/markdown
33
+
34
+ # PatchRadar 🛡️
35
+
36
+ > Know when your software is vulnerable — before attackers do.
37
+
38
+ PatchRadar monitors CVE feeds in realtime and alerts you when a new vulnerability affects your software stack. No more manually checking NVD, MSRC, or Snyk — just add your software and let PatchRadar watch for you.
39
+
40
+ ![Python](https://img.shields.io/badge/python-3.11+-blue?style=flat-square)
41
+ ![CalVer](https://img.shields.io/badge/calver-2026.8.2-green?style=flat-square)
42
+ ![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)
43
+ ![PyPI](https://img.shields.io/pypi/v/patchradar?style=flat-square)
44
+
45
+ ---
46
+
47
+ ## ✨ Features
48
+
49
+ - 🔍 **Realtime CVE monitoring** — scans NVD and other sources for new vulnerabilities
50
+ - 📋 **Personal watchlist** — add any software you want to monitor
51
+ - 🎨 **Beautiful web UI** — dark theme dashboard with charts and filters
52
+ - 💻 **CLI first** — full command line interface for automation
53
+ - 📊 **CVSS scoring** — color-coded severity (Critical / High / Medium / Low)
54
+ - 💾 **Local SQLite** — all data stored locally, no cloud, no account needed
55
+ - 🐍 **Python 3.11+** — modern async architecture with httpx and FastAPI
56
+
57
+ ---
58
+
59
+ ## 🚀 Installation
60
+
61
+ ```bash
62
+ pip install patchradar
63
+ ```
64
+
65
+ ---
66
+
67
+ ## 📖 Usage
68
+
69
+ ### CLI
70
+
71
+ ```bash
72
+ # Add software to your watchlist
73
+ patchradar add proxmox
74
+ patchradar add bitwarden
75
+ patchradar add "windows 10"
76
+
77
+ # Show your watchlist
78
+ patchradar list
79
+
80
+ # Scan for CVEs (last 30 days)
81
+ patchradar scan --days 30
82
+
83
+ # Show latest CVEs in terminal
84
+ patchradar status
85
+
86
+ # Remove software
87
+ patchradar remove proxmox
88
+ ```
89
+
90
+ ### Web UI
91
+
92
+ ```bash
93
+ patchradar serve
94
+ # Open http://localhost:8000
95
+ ```
96
+
97
+ ---
98
+
99
+ ## 📡 Sources
100
+
101
+ | Source | Type | Status |
102
+ |--------|------|--------|
103
+ | [NVD](https://nvd.nist.gov) | CVE Database | ✅ Active |
104
+ | MSRC | Microsoft Security | 🔜 Coming soon |
105
+ | Snyk | Package vulnerabilities | 🔜 Coming soon |
106
+ | Debian Security | Linux packages | 🔜 Coming soon |
107
+
108
+ ---
109
+
110
+ ## 🗓️ Versioning
111
+
112
+ PatchRadar uses [CalVer](https://calver.org) — `YYYY.MM.PATCH`.
113
+
114
+ | Version | Date | Notes |
115
+ |---------|------|-------|
116
+ | 2026.8.2 | 2026-08-14 | Web UI added |
117
+ | 2026.8.1 | 2026-08-14 | Initial release |
118
+
119
+ ---
120
+
121
+ ## 🤝 Contributing
122
+
123
+ Contributions are welcome! Feel free to open issues or pull requests.
124
+
125
+ ---
126
+
127
+ ## 📄 License
128
+
129
+ MIT — see [LICENSE](LICENSE) for details.
130
+
131
+ ---
132
+
133
+ <div align="center">
134
+ Built with ❤️ by <a href="https://github.com/maksimtech">maksimtech</a>
135
+ </div>
@@ -0,0 +1,17 @@
1
+ patchradar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ patchradar/cli.py,sha256=7yzfQuZlnRYov9mFYpHRXFMao_BWo1eDpIgh-uG8hPk,5612
3
+ patchradar/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ patchradar/api/main.py,sha256=UivQpBJOQm2CA-pag0YCdp8dtbVe3ZbxBu9z3hxvHx8,2559
5
+ patchradar/api/templates/index.html,sha256=xRMO6ADFay6j03fLXV7e-21VZ2735LBWr_l5BQhEHkk,14417
6
+ patchradar/collectors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ patchradar/collectors/debian.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ patchradar/collectors/msrc.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ patchradar/collectors/nvd.py,sha256=1N4FwhS-SQui6iDrIU8kEgGx4qSGNHzja1nIikKquTs,2176
10
+ patchradar/collectors/snyk.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ patchradar/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ patchradar/db/database.py,sha256=ay4tbWy6cb96bd2gVkLdIjxE8Pq91xIswmLo_yuCAI8,3319
13
+ patchradar-2026.8.1.dist-info/METADATA,sha256=V5EjycaE4g7G_b_mFqgsAybGta4Lmrxo7Z4RP3UKFN4,3735
14
+ patchradar-2026.8.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ patchradar-2026.8.1.dist-info/entry_points.txt,sha256=Ntu4w-8dIjOirtqdy0hEhxtRmUSeWMtlFp-GOFHUpao,51
16
+ patchradar-2026.8.1.dist-info/licenses/LICENSE,sha256=uR7jbxtbfdZLJqaw7t_-MvvRrxEBKlC6DjeCx1cpYhM,1067
17
+ patchradar-2026.8.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ patchradar = patchradar.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maksimtech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.