crowbar-cli 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.
crowbar/indexer.py ADDED
@@ -0,0 +1,985 @@
1
+ """Build and update the crowbar SQLite index."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import gzip
6
+ import json
7
+ import pathlib
8
+ import re
9
+ import shutil
10
+ import sqlite3
11
+ import time
12
+ import xml.etree.ElementTree as ET
13
+ import zipfile
14
+
15
+ from . import intel, loaders, paths
16
+
17
+ CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}", re.I)
18
+
19
+ SCHEMA = """
20
+ CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT);
21
+
22
+ CREATE TABLE cve(
23
+ id TEXT PRIMARY KEY,
24
+ published TEXT, modified TEXT, status TEXT,
25
+ description TEXT,
26
+ cvss REAL, cvss_version TEXT, cvss_vector TEXT, severity TEXT,
27
+ kev INTEGER DEFAULT 0, epss REAL, epss_pct REAL,
28
+ has_exploit INTEGER DEFAULT 0, has_nuclei INTEGER DEFAULT 0, has_msf INTEGER DEFAULT 0,
29
+ year INTEGER
30
+ );
31
+ CREATE TABLE cve_cwe(cve_id TEXT, cwe TEXT);
32
+ CREATE UNIQUE INDEX cve_cwe_unique ON cve_cwe(cve_id, cwe);
33
+ CREATE TABLE cve_product(cve_id TEXT, vendor TEXT, product TEXT);
34
+ CREATE UNIQUE INDEX cve_product_unique ON cve_product(cve_id, vendor, product);
35
+ CREATE TABLE cpe_match(
36
+ cve_id TEXT, part TEXT, vendor TEXT, product TEXT, version TEXT,
37
+ v_start_incl TEXT, v_start_excl TEXT, v_end_incl TEXT, v_end_excl TEXT
38
+ );
39
+ CREATE UNIQUE INDEX cpe_match_unique ON cpe_match(
40
+ cve_id, part, vendor, product, version,
41
+ v_start_incl, v_start_excl, v_end_incl, v_end_excl
42
+ );
43
+ CREATE TABLE cve_ref(cve_id TEXT, url TEXT, tags TEXT);
44
+ CREATE UNIQUE INDEX cve_ref_unique ON cve_ref(cve_id, url);
45
+ CREATE TABLE cve_exploit(cve_id TEXT, exploit_id TEXT, PRIMARY KEY(cve_id, exploit_id));
46
+ CREATE TABLE cve_nuclei(cve_id TEXT, template_id TEXT, PRIMARY KEY(cve_id, template_id));
47
+ CREATE TABLE cve_msf(cve_id TEXT, module_path TEXT, PRIMARY KEY(cve_id, module_path));
48
+
49
+ CREATE TABLE kev(
50
+ cve TEXT PRIMARY KEY, vendor TEXT, product TEXT, name TEXT,
51
+ date_added TEXT, ransomware TEXT, action TEXT, due TEXT, notes TEXT,
52
+ epss REAL, epss_pct REAL
53
+ );
54
+ CREATE TABLE exploit(
55
+ id TEXT PRIMARY KEY, file TEXT, description TEXT, date TEXT, author TEXT,
56
+ type TEXT, platform TEXT, verified INTEGER, codes TEXT, tags TEXT, url TEXT, raw TEXT
57
+ );
58
+ CREATE TABLE shellcode(id TEXT PRIMARY KEY, file TEXT, description TEXT, date TEXT, platform TEXT, url TEXT);
59
+ CREATE TABLE nuclei(
60
+ id TEXT PRIMARY KEY, name TEXT, severity TEXT, tags TEXT, cve TEXT,
61
+ path TEXT, description TEXT, domain TEXT
62
+ );
63
+ CREATE TABLE msf(
64
+ path TEXT PRIMARY KEY, type TEXT, name TEXT, platform TEXT,
65
+ privileged INTEGER, disclosure_date TEXT, description TEXT, cves TEXT
66
+ );
67
+ CREATE TABLE attack(
68
+ id TEXT, domain TEXT, name TEXT, tactics TEXT, platforms TEXT,
69
+ description TEXT, url TEXT, PRIMARY KEY(id, domain)
70
+ );
71
+ CREATE TABLE cwe(id TEXT PRIMARY KEY, name TEXT, abstraction TEXT, status TEXT, description TEXT);
72
+ CREATE TABLE capec(id TEXT PRIMARY KEY, name TEXT, severity TEXT, likelihood TEXT, cwe TEXT, description TEXT);
73
+ CREATE TABLE model(
74
+ provider TEXT, provider_name TEXT, id TEXT, name TEXT, context INTEGER,
75
+ output_limit INTEGER, input_cost REAL, output_cost REAL, released TEXT,
76
+ reasoning INTEGER, tool_call INTEGER, vision INTEGER,
77
+ PRIMARY KEY(provider, id)
78
+ );
79
+ CREATE TABLE advisory(
80
+ source TEXT, id TEXT, ecosystem TEXT, package TEXT, cve TEXT,
81
+ summary TEXT, severity TEXT, published TEXT, modified TEXT, url TEXT,
82
+ aliases TEXT, affected_count INTEGER,
83
+ PRIMARY KEY(source, id)
84
+ );
85
+ CREATE TABLE package_range(
86
+ advisory_id TEXT, ecosystem TEXT, package TEXT, introduced TEXT,
87
+ fixed TEXT, last_affected TEXT, versions TEXT
88
+ );
89
+ CREATE TABLE debian(
90
+ package TEXT, release TEXT, cve TEXT, status TEXT, fixed TEXT,
91
+ urgency TEXT, description TEXT
92
+ );
93
+ CREATE TABLE os_advisory(
94
+ source TEXT, cve TEXT, platform TEXT, product TEXT, version TEXT, title TEXT,
95
+ severity TEXT, impact TEXT, exploited TEXT, released TEXT, url TEXT,
96
+ PRIMARY KEY(source, cve, product, version)
97
+ );
98
+ CREATE VIRTUAL TABLE exploit_code_fts USING fts5(
99
+ exploit_id UNINDEXED, path UNINDEXED, content, tokenize='porter unicode61'
100
+ );
101
+ CREATE TABLE toolkit(
102
+ source TEXT, name TEXT, path TEXT, description TEXT, tags TEXT
103
+ );
104
+ CREATE VIRTUAL TABLE toolkit_fts USING fts5(
105
+ source UNINDEXED, name, description, tags, tokenize='porter unicode61'
106
+ );
107
+ CREATE TABLE ssvc(
108
+ cve_id TEXT PRIMARY KEY, exploitation TEXT, automatable TEXT,
109
+ technical_impact TEXT, timestamp TEXT, source TEXT
110
+ );
111
+
112
+ -- Schema v3: CNA source of truth, PoC discovery, provenance.
113
+ CREATE TABLE cve_cna(
114
+ cve_id TEXT PRIMARY KEY,
115
+ assigner TEXT, state TEXT, published TEXT, updated TEXT,
116
+ title TEXT, description TEXT,
117
+ cvss REAL, cvss_version TEXT, cvss_vector TEXT, severity TEXT,
118
+ record_path TEXT, source TEXT, source_url TEXT, source_sha256 TEXT, fetched_at TEXT
119
+ );
120
+ CREATE TABLE cve_cna_ref(
121
+ cve_id TEXT, url TEXT, tags TEXT,
122
+ PRIMARY KEY(cve_id, url)
123
+ );
124
+ CREATE TABLE cve_cna_product(
125
+ cve_id TEXT, vendor TEXT, product TEXT,
126
+ PRIMARY KEY(cve_id, vendor, product)
127
+ );
128
+ CREATE TABLE cve_meta(
129
+ cve_id TEXT PRIMARY KEY,
130
+ published_year INTEGER, cve_year INTEGER,
131
+ has_poc INTEGER DEFAULT 0, poc_count INTEGER DEFAULT 0
132
+ );
133
+ CREATE TABLE poc(
134
+ source TEXT, cve_id TEXT, repo TEXT, url TEXT, repo_id INTEGER,
135
+ stars INTEGER, language TEXT, created_at TEXT, pushed_at TEXT,
136
+ description TEXT, fetched_at TEXT,
137
+ PRIMARY KEY(source, cve_id, url)
138
+ );
139
+ CREATE TABLE poc_orphan(cve_id TEXT PRIMARY KEY, count INTEGER);
140
+ CREATE TABLE cve_alias(
141
+ cve_id TEXT, alias TEXT, source TEXT,
142
+ PRIMARY KEY(cve_id, alias, source)
143
+ );
144
+ CREATE TABLE source_state(
145
+ source TEXT PRIMARY KEY, url TEXT, path TEXT, license TEXT, sha256 TEXT,
146
+ fetched_at TEXT, loaded_at TEXT, record_count INTEGER, status TEXT, note TEXT
147
+ );
148
+
149
+ CREATE VIRTUAL TABLE cve_fts USING fts5(
150
+ id UNINDEXED, description, products, tokenize='porter unicode61'
151
+ );
152
+ CREATE VIRTUAL TABLE exploit_fts USING fts5(
153
+ id UNINDEXED, description, tags, codes, tokenize='porter unicode61'
154
+ );
155
+ CREATE VIRTUAL TABLE nuclei_fts USING fts5(
156
+ id, name, tags, cve, tokenize='porter unicode61'
157
+ );
158
+ CREATE VIRTUAL TABLE model_fts USING fts5(
159
+ provider UNINDEXED, id UNINDEXED, name, tokenize='porter unicode61'
160
+ );
161
+ """
162
+
163
+ INDEXES = """
164
+ CREATE INDEX cve_year ON cve(year);
165
+ CREATE INDEX cve_sev ON cve(severity);
166
+ CREATE INDEX cve_cvss ON cve(cvss);
167
+ CREATE INDEX cve_kev ON cve(kev);
168
+ CREATE INDEX cve_epss ON cve(epss);
169
+ CREATE INDEX cve_exploit_flag ON cve(has_exploit);
170
+ CREATE INDEX cve_nuclei_flag ON cve(has_nuclei);
171
+ CREATE INDEX cve_msf_flag ON cve(has_msf);
172
+ CREATE INDEX cve_cwe_cwe ON cve_cwe(cwe);
173
+ CREATE INDEX cve_cwe_id ON cve_cwe(cve_id);
174
+ CREATE INDEX prod_product ON cve_product(product);
175
+ CREATE INDEX prod_vendor ON cve_product(vendor);
176
+ CREATE INDEX prod_id ON cve_product(cve_id);
177
+ CREATE INDEX cpe_product ON cpe_match(product);
178
+ CREATE INDEX cpe_vendor ON cpe_match(vendor);
179
+ CREATE INDEX cpe_id ON cpe_match(cve_id);
180
+ CREATE INDEX ref_id ON cve_ref(cve_id);
181
+ CREATE INDEX exp_date ON exploit(date);
182
+ CREATE INDEX exp_platform ON exploit(platform);
183
+ CREATE INDEX nuc_cve ON nuclei(cve);
184
+ CREATE INDEX nuc_sev ON nuclei(severity);
185
+ CREATE INDEX nuc_domain ON nuclei(domain);
186
+ CREATE INDEX kev_date ON kev(date_added);
187
+ CREATE INDEX cve_nuclei_id ON cve_nuclei(cve_id);
188
+ CREATE INDEX cve_msf_id ON cve_msf(cve_id);
189
+ CREATE INDEX ssvc_expl ON ssvc(exploitation);
190
+ CREATE INDEX adv_cve ON advisory(cve);
191
+ CREATE INDEX adv_pkg ON advisory(package);
192
+ CREATE INDEX adv_eco ON advisory(ecosystem);
193
+ CREATE INDEX pkg_pkg ON package_range(package);
194
+ CREATE INDEX pkg_adv ON package_range(advisory_id);
195
+ CREATE INDEX debian_pkg ON debian(package);
196
+ CREATE INDEX debian_cve ON debian(cve);
197
+ CREATE INDEX os_cve ON os_advisory(cve);
198
+ CREATE INDEX os_plat ON os_advisory(platform);
199
+ CREATE INDEX cve_cna_assigner ON cve_cna(assigner);
200
+ CREATE INDEX cve_meta_pubyear ON cve_meta(published_year);
201
+ CREATE INDEX cve_meta_cveyear ON cve_meta(cve_year);
202
+ CREATE INDEX cve_meta_poc ON cve_meta(has_poc);
203
+ CREATE INDEX poc_cve ON poc(cve_id);
204
+ CREATE INDEX poc_stars ON poc(stars);
205
+ CREATE INDEX poc_orphan_id ON poc_orphan(cve_id);
206
+ CREATE INDEX cve_alias_alias ON cve_alias(alias);
207
+ CREATE INDEX source_state_status ON source_state(status);
208
+ """
209
+
210
+ BATCH = 800
211
+
212
+
213
+ def log(msg: str) -> None:
214
+ print(f"[index] {msg}", flush=True)
215
+
216
+
217
+ def _norm(s: str | None, limit: int = 6000) -> str:
218
+ if not s:
219
+ return ""
220
+ return re.sub(r"\s+", " ", s).strip()[:limit]
221
+
222
+
223
+ # ---------------------------------------------------------------- NVD
224
+ def cpe_rows(cve: dict, cap: int = 400) -> list[tuple]:
225
+ seen: set[tuple] = set()
226
+ out: list[tuple] = []
227
+ for conf in cve.get("configurations") or []:
228
+ for node in conf.get("nodes") or []:
229
+ for m in node.get("cpeMatch") or []:
230
+ if m.get("vulnerable") is False:
231
+ continue
232
+ parts = (m.get("criteria") or "").split(":")
233
+ if len(parts) < 6:
234
+ continue
235
+ vendor, product = parts[3].lower(), parts[4].lower()
236
+ if vendor in ("*", "-") or product in ("*", "-"):
237
+ continue
238
+ row = (parts[2], vendor, product, parts[5],
239
+ m.get("versionStartIncluding"), m.get("versionStartExcluding"),
240
+ m.get("versionEndIncluding"), m.get("versionEndExcluding"))
241
+ if row in seen:
242
+ continue
243
+ seen.add(row)
244
+ out.append(row)
245
+ if len(out) >= cap:
246
+ return out
247
+ return out
248
+
249
+
250
+ def cvss_of(metrics: dict) -> tuple[float | None, str, str, str]:
251
+ for key in ("cvssMetricV40", "cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
252
+ for e in metrics.get(key) or []:
253
+ data = e.get("cvssData") or {}
254
+ score = data.get("baseScore")
255
+ if score is None:
256
+ continue
257
+ try:
258
+ score = float(score)
259
+ except (TypeError, ValueError):
260
+ continue # one malformed metric must not abort the whole feed
261
+ sev = data.get("baseSeverity") or e.get("baseSeverity") or ""
262
+ return (score, data.get("version") or key[-3:],
263
+ data.get("vectorString") or "", sev.upper())
264
+ return (None, "", "", "")
265
+
266
+
267
+ def parse_cve(item: dict, epss: dict, kev_ids: set[str]) -> dict:
268
+ c = item.get("cve") or {}
269
+ cid = c.get("id")
270
+ if not cid:
271
+ return {}
272
+ desc = ""
273
+ for d in c.get("descriptions") or []:
274
+ if d.get("lang") == "en":
275
+ desc = d.get("value") or ""
276
+ break
277
+ score, ver, vec, sev = cvss_of(c.get("metrics") or {})
278
+ e, pct = epss.get(cid, (None, None))
279
+ cwes, seen = [], set()
280
+ for w in c.get("weaknesses") or []:
281
+ for d in w.get("description") or []:
282
+ v = (d.get("value") or "").upper()
283
+ if v.startswith("CWE-") and v not in seen:
284
+ seen.add(v)
285
+ cwes.append(v)
286
+ cpes = cpe_rows(c)
287
+ prods = []
288
+ seen_p = set()
289
+ for _, vendor, product, *_ in cpes:
290
+ if (vendor, product) not in seen_p and len(prods) < 120:
291
+ seen_p.add((vendor, product))
292
+ prods.append((vendor, product))
293
+ refs = []
294
+ seen_r = set()
295
+ for r in (c.get("references") or [])[:60]:
296
+ u = r.get("url")
297
+ if u and u not in seen_r:
298
+ seen_r.add(u)
299
+ refs.append((u, ",".join(r.get("tags") or [])))
300
+ pub = c.get("published") or ""
301
+ return {
302
+ "row": (cid, pub, c.get("lastModified"), c.get("vulnStatus"), _norm(desc),
303
+ score, ver, vec, sev, 1 if cid in kev_ids else 0, e, pct,
304
+ int(pub[:4]) if pub[:4].isdigit() else None),
305
+ "cwes": [(cid, w) for w in cwes],
306
+ "prods": [(cid, v, p) for v, p in prods],
307
+ "cpes": [(cid, *r) for r in cpes],
308
+ "refs": [(cid, u, t) for u, t in refs],
309
+ "fts": (cid, _norm(desc, 4000), " ".join(f"{v} {p}" for v, p in prods)[:4000]),
310
+ }
311
+
312
+
313
+ def delete_cves(con: sqlite3.Connection, ids: list[str]) -> None:
314
+ for i in range(0, len(ids), BATCH):
315
+ chunk = ids[i:i + BATCH]
316
+ q = ",".join("?" * len(chunk))
317
+ for table in ("cve_cwe", "cve_product", "cpe_match", "cve_ref",
318
+ "cve_exploit", "cve_nuclei", "cve_msf", "cve_fts", "cve"):
319
+ con.execute(f"DELETE FROM {table} WHERE {'id' if table == 'cve' else 'cve_id' if table != 'cve_fts' else 'id'} IN ({q})", chunk)
320
+
321
+
322
+ def load_nvd(path: pathlib.Path, con: sqlite3.Connection, epss: dict,
323
+ kev_ids: set[str], replace: bool = False, progress=None) -> int:
324
+ if not path.exists():
325
+ return 0
326
+ with gzip.open(path, "rt", encoding="utf-8") as fh:
327
+ doc = json.load(fh)
328
+ items = doc.get("vulnerabilities", [])
329
+ if replace:
330
+ ids = [(i.get("cve") or {}).get("id") for i in items]
331
+ delete_cves(con, [i for i in ids if i])
332
+
333
+ rows, cwes, prods, cpes, refs, fts = [], [], [], [], [], []
334
+ for item in items:
335
+ p = parse_cve(item, epss, kev_ids)
336
+ if not p:
337
+ continue
338
+ rows.append(p["row"])
339
+ cwes += p["cwes"]
340
+ prods += p["prods"]
341
+ cpes += p["cpes"]
342
+ refs += p["refs"]
343
+ fts.append(p["fts"])
344
+ if len(rows) >= BATCH:
345
+ done = len(fts)
346
+ _flush(con, rows, cwes, prods, cpes, refs, fts)
347
+ rows, cwes, prods, cpes, refs, fts = [], [], [], [], [], []
348
+ if progress:
349
+ progress(f"{done} rows")
350
+ _flush(con, rows, cwes, prods, cpes, refs, fts)
351
+ con.commit()
352
+ return len(items)
353
+
354
+
355
+ def _flush(con, rows, cwes, prods, cpes, refs, fts) -> None:
356
+ if rows:
357
+ con.executemany(
358
+ "INSERT OR REPLACE INTO cve(id,published,modified,status,description,cvss,"
359
+ "cvss_version,cvss_vector,severity,kev,epss,epss_pct,year) "
360
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", rows)
361
+ if cwes:
362
+ con.executemany("INSERT OR IGNORE INTO cve_cwe VALUES (?,?)", cwes)
363
+ if prods:
364
+ con.executemany("INSERT OR IGNORE INTO cve_product VALUES (?,?,?)", prods)
365
+ if cpes:
366
+ con.executemany("INSERT OR IGNORE INTO cpe_match VALUES (?,?,?,?,?,?,?,?,?)", cpes)
367
+ if refs:
368
+ con.executemany("INSERT OR IGNORE INTO cve_ref VALUES (?,?,?)", refs)
369
+ if fts:
370
+ con.executemany("INSERT INTO cve_fts(id, description, products) VALUES (?,?,?)", fts)
371
+
372
+
373
+ # ---------------------------------------------------------------- intel
374
+ def load_kev(path: pathlib.Path, con: sqlite3.Connection, epss: dict | None = None) -> int:
375
+ if not path.exists():
376
+ return 0
377
+ doc = json.loads(path.read_text())
378
+ # indexes built before KEV carried EPSS: add the columns in place, no rebuild
379
+ have = {r[1] for r in con.execute("PRAGMA table_info(kev)")}
380
+ for col in ("epss", "epss_pct"):
381
+ if col not in have:
382
+ con.execute(f"ALTER TABLE kev ADD COLUMN {col} REAL")
383
+ epss = epss or {}
384
+ con.execute("DELETE FROM kev")
385
+ rows = [(v.get("cveID"), v.get("vendorProject"), v.get("product"),
386
+ v.get("vulnerabilityName"), v.get("dateAdded"),
387
+ v.get("knownRansomwareCampaignUse"), v.get("requiredAction"),
388
+ v.get("dueDate"), _norm(v.get("notes"), 1000),
389
+ *epss.get(v.get("cveID"), (None, None)))
390
+ for v in doc.get("vulnerabilities", [])]
391
+ con.executemany("INSERT OR REPLACE INTO kev(cve,vendor,product,name,date_added,ransomware,"
392
+ "action,due,notes,epss,epss_pct) VALUES (?,?,?,?,?,?,?,?,?,?,?)", rows)
393
+ con.execute("UPDATE cve SET kev=0 WHERE kev=1 AND id NOT IN (SELECT cve FROM kev)")
394
+ con.execute("UPDATE cve SET kev=1 WHERE id IN (SELECT cve FROM kev)")
395
+ con.commit()
396
+ return len(rows)
397
+
398
+
399
+ def read_epss(path: pathlib.Path) -> dict[str, tuple[float, float]]:
400
+ out: dict[str, tuple[float, float]] = {}
401
+ if not path.exists():
402
+ return out
403
+ opener = gzip.open if path.suffix == ".gz" else open
404
+ with opener(path, "rt", errors="replace") as fh:
405
+ for row in csv.reader(fh):
406
+ if not row or row[0].startswith("#") or row[0].lower() == "cve":
407
+ continue
408
+ if len(row) >= 3:
409
+ try:
410
+ out[row[0].upper()] = (float(row[1]), float(row[2]))
411
+ except ValueError:
412
+ continue
413
+ return out
414
+
415
+
416
+ def load_epss(path: pathlib.Path, con: sqlite3.Connection) -> int:
417
+ rows = read_epss(path)
418
+ if not rows:
419
+ return 0
420
+ con.execute("CREATE TEMP TABLE IF NOT EXISTS _epss_tmp(cve TEXT PRIMARY KEY, epss REAL, pct REAL)")
421
+ con.execute("DELETE FROM _epss_tmp")
422
+ con.executemany("INSERT OR REPLACE INTO _epss_tmp VALUES (?,?,?)",
423
+ [(k, v[0], v[1]) for k, v in rows.items()])
424
+ con.execute("UPDATE cve AS c SET epss=(SELECT epss FROM _epss_tmp WHERE cve=c.id),"
425
+ " epss_pct=(SELECT pct FROM _epss_tmp WHERE cve=c.id)"
426
+ " WHERE id IN (SELECT cve FROM _epss_tmp)")
427
+ con.execute("DROP TABLE _epss_tmp")
428
+ con.commit()
429
+ return len(rows)
430
+
431
+
432
+ def load_exploitdb(path: pathlib.Path, con: sqlite3.Connection) -> int:
433
+ if not path.exists():
434
+ return 0
435
+ con.execute("DELETE FROM exploit")
436
+ con.execute("DELETE FROM exploit_fts")
437
+ rows, fts = [], []
438
+ with path.open(encoding="utf-8", errors="replace", newline="") as fh:
439
+ for r in csv.DictReader(fh):
440
+ rid = (r.get("id") or "").strip()
441
+ if not rid:
442
+ continue
443
+ desc = _norm(r.get("description"), 500)
444
+ tags = (r.get("tags") or "").strip()
445
+ codes = (r.get("codes") or "").strip()
446
+ file = (r.get("file") or "").strip()
447
+ rows.append((rid, file, desc, r.get("date_published"), r.get("author"),
448
+ r.get("type"), r.get("platform"),
449
+ 1 if (r.get("verified") or "0").strip() in ("1", "true", "True") else 0,
450
+ codes, tags, f"https://www.exploit-db.com/exploits/{rid}",
451
+ f"https://gitlab.com/exploit-database/exploitdb/-/raw/main/{file}"))
452
+ fts.append((rid, desc, tags, codes))
453
+ # Exploit-DB occasionally lists the same id twice; keep one row and one FTS row.
454
+ seen, urows, ufts = set(), [], []
455
+ for row, frow in zip(rows, fts):
456
+ if row[0] in seen:
457
+ continue
458
+ seen.add(row[0])
459
+ urows.append(row)
460
+ ufts.append(frow)
461
+ con.executemany("INSERT OR REPLACE INTO exploit VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", urows)
462
+ con.executemany("INSERT INTO exploit_fts(id, description, tags, codes) VALUES (?,?,?,?)", ufts)
463
+ con.commit()
464
+ return len(rows)
465
+
466
+
467
+ def load_shellcodes(path: pathlib.Path, con: sqlite3.Connection) -> int:
468
+ if not path.exists():
469
+ return 0
470
+ con.execute("DELETE FROM shellcode")
471
+ rows = []
472
+ with path.open(encoding="utf-8", errors="replace", newline="") as fh:
473
+ for r in csv.DictReader(fh):
474
+ rid = (r.get("id") or "").strip()
475
+ if rid:
476
+ rows.append((rid, r.get("file"), _norm(r.get("description"), 300),
477
+ r.get("date_published"), r.get("platform"),
478
+ f"https://www.exploit-db.com/shellcodes/{rid}"))
479
+ con.executemany("INSERT OR REPLACE INTO shellcode VALUES (?,?,?,?,?,?)", rows)
480
+ con.commit()
481
+ return len(rows)
482
+
483
+
484
+ def link_exploits(con: sqlite3.Connection) -> int:
485
+ pairs = []
486
+ for rid, codes in con.execute("SELECT id, codes FROM exploit WHERE codes <> ''"):
487
+ for cid in set(x.upper() for x in CVE_RE.findall(codes)):
488
+ pairs.append((cid, rid))
489
+ con.execute("DELETE FROM cve_exploit")
490
+ con.executemany("INSERT OR IGNORE INTO cve_exploit VALUES (?,?)", pairs)
491
+ con.execute("UPDATE cve SET has_exploit=0 WHERE has_exploit=1")
492
+ con.execute("UPDATE cve SET has_exploit=1 WHERE id IN (SELECT cve_id FROM cve_exploit)")
493
+ con.commit()
494
+ return len(pairs)
495
+
496
+
497
+ NUCLEI_SKIP = {"workflows", "helpers", "profiles", "templates", ".github", "docs"}
498
+
499
+
500
+ def _block_value(lines: list[str], start: int, indent: int) -> str:
501
+ out = []
502
+ for ln in lines[start + 1:]:
503
+ if not ln.strip():
504
+ continue
505
+ if len(ln) - len(ln.lstrip()) <= indent:
506
+ break
507
+ out.append(ln.strip())
508
+ return " ".join(out)
509
+
510
+
511
+ def parse_nuclei(text: str) -> dict:
512
+ info = {"id": "", "name": "", "severity": "", "tags": "", "cve": "", "description": ""}
513
+ in_info, in_class = False, False
514
+ for i, ln in enumerate(text.splitlines()[:400]):
515
+ s = ln.strip()
516
+ ind = len(ln) - len(ln.lstrip())
517
+ if ind == 0 and s.startswith("id:"):
518
+ info["id"] = s.split(":", 1)[1].strip().strip("'\"")
519
+ elif ind == 0 and s.startswith("info:"):
520
+ in_info = True
521
+ continue
522
+ elif ind == 0 and s and not s.startswith("#"):
523
+ in_info = False
524
+ if not in_info:
525
+ continue
526
+ if s.startswith("name:") and not info["name"]:
527
+ info["name"] = s.split(":", 1)[1].strip().strip("'\"")
528
+ elif s.startswith("severity:") and not info["severity"]:
529
+ info["severity"] = s.split(":", 1)[1].strip().strip("'\"")
530
+ elif s.startswith("tags:") and not info["tags"]:
531
+ info["tags"] = s.split(":", 1)[1].strip().strip("'\"")
532
+ elif s.startswith("description:") and not info["description"]:
533
+ rest = s.split(":", 1)[1].strip()
534
+ info["description"] = _norm(rest if rest not in ("|", ">", "|-", ">-")
535
+ else _block_value(text.splitlines(), i, ind), 800)
536
+ elif s.startswith("classification:"):
537
+ in_class = True
538
+ elif in_class and s.startswith("cve-id:"):
539
+ rest = s.split(":", 1)[1].strip()
540
+ if rest.startswith("["):
541
+ info["cve"] = ",".join(CVE_RE.findall(rest.upper()))
542
+ elif rest:
543
+ info["cve"] = rest.strip("'\"").upper()
544
+ else:
545
+ got = []
546
+ for nxt in text.splitlines()[i + 1:i + 12]:
547
+ m = re.match(r"\s*-\s*(CVE-\d{4}-\d+)", nxt, re.I)
548
+ if m:
549
+ got.append(m.group(1).upper())
550
+ elif nxt.strip() and not nxt.strip().startswith("-"):
551
+ break
552
+ info["cve"] = ",".join(got)
553
+ return info
554
+
555
+
556
+ def load_nuclei(root: pathlib.Path, con: sqlite3.Connection) -> int:
557
+ if not root.exists():
558
+ return 0
559
+ con.execute("DELETE FROM nuclei")
560
+ con.execute("DELETE FROM nuclei_fts")
561
+ rows, fts = [], []
562
+ for p in root.rglob("*.y*ml"):
563
+ rel = p.relative_to(root)
564
+ if not rel.parts or rel.parts[0] in NUCLEI_SKIP:
565
+ continue
566
+ if p.stat().st_size > 400_000:
567
+ continue
568
+ try:
569
+ text = p.read_text(encoding="utf-8", errors="replace")
570
+ except OSError:
571
+ continue
572
+ if "id:" not in text[:2000]:
573
+ continue
574
+ info = parse_nuclei(text)
575
+ if not info["id"] or not info["name"]:
576
+ continue
577
+ domain = rel.parts[1 if len(rel.parts) > 2 else 0]
578
+ rows.append((info["id"], info["name"], info["severity"].lower(), info["tags"],
579
+ info["cve"], str(rel), info["description"], domain))
580
+ fts.append((info["id"], info["name"], info["tags"], info["cve"]))
581
+ con.executemany("INSERT OR REPLACE INTO nuclei VALUES (?,?,?,?,?,?,?,?)", rows)
582
+ con.executemany("INSERT INTO nuclei_fts(id, name, tags, cve) VALUES (?,?,?,?)", fts)
583
+ pair = []
584
+ for tid, cves in con.execute("SELECT id, cve FROM nuclei WHERE cve <> ''"):
585
+ for cid in set(cves.upper().split(",")):
586
+ if CVE_RE.fullmatch(cid):
587
+ pair.append((cid, tid))
588
+ con.execute("DELETE FROM cve_nuclei")
589
+ con.executemany("INSERT OR IGNORE INTO cve_nuclei VALUES (?,?)", pair)
590
+ con.execute("UPDATE cve SET has_nuclei=0 WHERE has_nuclei=1")
591
+ con.execute("UPDATE cve SET has_nuclei=1 WHERE id IN (SELECT cve_id FROM cve_nuclei)")
592
+ con.commit()
593
+ return len(rows)
594
+
595
+
596
+ def load_msf(tree: pathlib.Path, con: sqlite3.Connection) -> int:
597
+ if not tree.exists():
598
+ return 0
599
+ con.execute("DELETE FROM msf")
600
+ rows = []
601
+ for ent in json.loads(tree.read_text()).get("tree", []):
602
+ p = ent.get("path") or ""
603
+ if not p.startswith("modules/") or not p.endswith(".rb"):
604
+ continue
605
+ parts = p.split("/")
606
+ if len(parts) < 3:
607
+ continue
608
+ cves = ",".join(sorted({c.upper() for c in CVE_RE.findall(p)}))
609
+ rows.append((p, parts[1], "/".join(parts[2:]).removesuffix(".rb"),
610
+ "", 0, "", "", cves))
611
+ con.executemany("INSERT OR REPLACE INTO msf(path,type,name,platform,privileged,"
612
+ "disclosure_date,description,cves) VALUES (?,?,?,?,?,?,?,?)", rows)
613
+ pairs = []
614
+ for p, name in con.execute("SELECT path, name FROM msf"):
615
+ for cid in set(x.upper() for x in CVE_RE.findall(f"{p} {name}")):
616
+ pairs.append((cid, p))
617
+ con.execute("DELETE FROM cve_msf")
618
+ con.executemany("INSERT OR IGNORE INTO cve_msf VALUES (?,?)", pairs)
619
+ con.execute("UPDATE cve SET has_msf=0 WHERE has_msf=1")
620
+ con.execute("UPDATE cve SET has_msf=1 WHERE id IN (SELECT cve_id FROM cve_msf)")
621
+ con.commit()
622
+ return len(rows)
623
+
624
+
625
+ def load_attack(path: pathlib.Path, domain: str, con: sqlite3.Connection) -> int:
626
+ if not path.exists():
627
+ return 0
628
+ rows = []
629
+ for obj in json.loads(path.read_text()).get("objects", []):
630
+ if obj.get("type") != "attack-pattern" or obj.get("revoked") or obj.get("x_mitre_deprecated"):
631
+ continue
632
+ ext = ""
633
+ for ref in obj.get("external_references") or []:
634
+ if ref.get("source_name") == "mitre-attack":
635
+ ext = ref.get("external_id") or ""
636
+ if not ext:
637
+ continue
638
+ tactics = ",".join(p.get("phase_name", "") for p in obj.get("kill_chain_phases") or [])
639
+ rows.append((ext, domain, obj.get("name"), tactics,
640
+ ",".join(obj.get("x_mitre_platforms") or []),
641
+ _norm(obj.get("description"), 3000),
642
+ f"https://attack.mitre.org/techniques/{ext.replace('.', '/')}/"))
643
+ con.execute("DELETE FROM attack WHERE domain=?", (domain,))
644
+ con.executemany("INSERT OR REPLACE INTO attack VALUES (?,?,?,?,?,?,?)", rows)
645
+ con.commit()
646
+ return len(rows)
647
+
648
+
649
+ def _text(el) -> str:
650
+ return _norm(" ".join("".join(el.itertext()).split()), 4000)
651
+
652
+
653
+ def load_cwe(path: pathlib.Path, con: sqlite3.Connection) -> int:
654
+ if not path.exists():
655
+ return 0
656
+ zf = zipfile.ZipFile(path)
657
+ xml = zf.read(next(n for n in zf.namelist() if n.endswith(".xml")))
658
+ root = ET.fromstring(xml)
659
+ rows = []
660
+ for el in root.iter():
661
+ if not el.tag.endswith("Weakness") or not el.get("ID"):
662
+ continue
663
+ desc = next((_text(c) for c in el if c.tag.endswith("Description")), "")
664
+ rows.append(("CWE-" + el.attrib["ID"], el.get("Name"), el.get("Abstraction"),
665
+ el.get("Status"), desc))
666
+ con.execute("DELETE FROM cwe")
667
+ con.executemany("INSERT OR REPLACE INTO cwe VALUES (?,?,?,?,?)", rows)
668
+ con.commit()
669
+ return len(rows)
670
+
671
+
672
+ def load_capec(path: pathlib.Path, con: sqlite3.Connection) -> int:
673
+ if not path.exists():
674
+ return 0
675
+ root = ET.parse(path).getroot()
676
+ rows = []
677
+ for el in root.iter():
678
+ if not el.tag.endswith("Attack_Pattern") or not el.get("ID"):
679
+ continue
680
+ desc, cwes = "", []
681
+ for child in el:
682
+ tag = child.tag.split("}")[-1]
683
+ if tag == "Description":
684
+ desc = _text(child)
685
+ elif tag == "Related_Weaknesses":
686
+ cwes += ["CWE-" + w.attrib["CWE_ID"] for w in child if w.get("CWE_ID")]
687
+ rows.append(("CAPEC-" + el.attrib["ID"], el.get("Name"), el.get("Severity"),
688
+ el.get("Likelihood_Of_Attack"), ",".join(sorted(set(cwes))), desc))
689
+ con.execute("DELETE FROM capec")
690
+ con.executemany("INSERT OR REPLACE INTO capec VALUES (?,?,?,?,?,?)", rows)
691
+ con.commit()
692
+ return len(rows)
693
+
694
+
695
+ def load_models(path: pathlib.Path, con: sqlite3.Connection) -> int:
696
+ if not path.exists():
697
+ return 0
698
+ doc = json.loads(path.read_text())
699
+ rows, fts = [], []
700
+ for pid, prov in doc.items():
701
+ pname = prov.get("name") or pid
702
+ for mid, m in (prov.get("models") or {}).items():
703
+ cost, limit = m.get("cost") or {}, m.get("limit") or {}
704
+ modality = m.get("modalities") or {}
705
+ rows.append((pid, pname, mid, m.get("name") or mid,
706
+ limit.get("context"), limit.get("output"),
707
+ cost.get("input"), cost.get("output"), str(m.get("release_date") or ""),
708
+ 1 if m.get("reasoning") else 0, 1 if m.get("tool_call") else 0,
709
+ 1 if "image" in (modality.get("input") or []) else 0))
710
+ fts.append((pid, mid, f"{m.get('name') or mid} {pid} {pname}"))
711
+ con.execute("DELETE FROM model")
712
+ con.execute("DELETE FROM model_fts")
713
+ con.executemany("INSERT OR REPLACE INTO model VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", rows)
714
+ con.executemany("INSERT INTO model_fts(provider, id, name) VALUES (?,?,?)", fts)
715
+ con.commit()
716
+ return len(rows)
717
+
718
+
719
+ def load_ssvc(root: pathlib.Path, con: sqlite3.Connection) -> int:
720
+ """CISA Vulnrichment: SSVC decision points (Exploitation/Automatable/Technical Impact)."""
721
+ if not root.exists():
722
+ return 0
723
+ rows = []
724
+ for p in root.rglob("CVE-*.json"):
725
+ try:
726
+ doc = json.loads(p.read_text())
727
+ except (OSError, json.JSONDecodeError):
728
+ continue
729
+ cid = (doc.get("cveMetadata") or {}).get("cveId")
730
+ if not cid:
731
+ continue
732
+ for adp in (doc.get("containers") or {}).get("adp") or []:
733
+ for metric in adp.get("metrics") or []:
734
+ other = metric.get("other") or {}
735
+ if other.get("type") != "ssvc":
736
+ continue
737
+ content = other.get("content") or {}
738
+ opts = {}
739
+ for o in content.get("options") or []:
740
+ if isinstance(o, dict):
741
+ opts.update(o)
742
+ rows.append((cid, opts.get("Exploitation"), opts.get("Automatable"),
743
+ opts.get("Technical Impact"), content.get("timestamp"), "CISA-ADP"))
744
+ if rows:
745
+ con.execute("DELETE FROM ssvc")
746
+ con.executemany("INSERT OR REPLACE INTO ssvc VALUES (?,?,?,?,?,?)", rows)
747
+ con.commit()
748
+ return len(rows)
749
+
750
+
751
+ # ---------------------------------------------------------------- build/update
752
+ def create(path: pathlib.Path) -> sqlite3.Connection:
753
+ con = sqlite3.connect(path)
754
+ con.executescript("PRAGMA journal_mode=MEMORY; PRAGMA synchronous=OFF; PRAGMA temp_store=MEMORY;")
755
+ con.executescript(SCHEMA)
756
+ return con
757
+
758
+
759
+ def load_bulk(con: sqlite3.Connection, progress=None, heavy: bool = True) -> dict:
760
+ """Load every bulk corpus that is present on disk. Missing dirs are skipped."""
761
+ counts: dict[str, int] = {}
762
+ if heavy:
763
+ for z in sorted((paths.DATA / "osv").glob("*.zip")):
764
+ n = loaders.load_osv_zip(z, con)
765
+ counts[f"osv-{z.stem}"] = n
766
+ if progress:
767
+ progress(f"OSV {z.stem}: {n}")
768
+
769
+ if (paths.DATA / "debian/tracker.json").exists():
770
+ counts["debian"] = loaders.load_debian(paths.DATA / "debian/tracker.json", con)
771
+ if progress:
772
+ progress(f"Debian {counts['debian']}")
773
+
774
+ if (paths.DATA / "msrc/sug.json").exists():
775
+ counts["msrc"] = loaders.load_msrc(paths.DATA / "msrc/sug.json", con)
776
+ if progress:
777
+ progress(f"MSRC {counts['msrc']}")
778
+
779
+ if (paths.DATA / "apple").exists():
780
+ counts["apple"] = loaders.load_apple(paths.DATA / "apple", con)
781
+ if progress:
782
+ progress(f"Apple {counts['apple']}")
783
+
784
+ for src in ("gtfobins", "lolbas", "payloads"):
785
+ root = paths.DATA / "toolkit" / src
786
+ if root.exists():
787
+ counts[src] = loaders.load_toolkit(root, src, con)
788
+ if progress:
789
+ progress(f"{src} {counts[src]}")
790
+
791
+ src = paths.DATA / "metasploit/source"
792
+ if (src / "modules").exists():
793
+ counts["msf-source"] = loaders.load_msf_source(src, con)
794
+ if progress:
795
+ progress(f"Metasploit source {counts['msf-source']}")
796
+
797
+ if (paths.DATA / "exploitdb/full/exploits").exists() and heavy:
798
+ counts["exploit-code"] = loaders.load_exploit_code(paths.DATA / "exploitdb/full", con)
799
+ if progress:
800
+ progress(f"Exploit-DB code {counts['exploit-code']} files")
801
+ return counts
802
+
803
+
804
+ def build(quick: bool = False, with_extra: bool = False,
805
+ with_bulk: bool = False, progress=None) -> dict:
806
+ t0 = time.time()
807
+ paths.ensure_dirs()
808
+ tmp = paths.DB.with_suffix(".db.tmp")
809
+ if tmp.exists():
810
+ tmp.unlink()
811
+ con = create(tmp)
812
+
813
+ epss = read_epss(paths.DATA / "epss/epss_scores-current.csv.gz")
814
+ if progress:
815
+ progress(f"EPSS {len(epss)}")
816
+ kev_path = paths.DATA / "kev/known_exploited_vulnerabilities.json"
817
+ n = load_kev(kev_path, con, epss)
818
+ if progress:
819
+ progress(f"KEV {n}")
820
+ kev_ids = {r[0] for r in con.execute("SELECT cve FROM kev")}
821
+
822
+ total = 0
823
+ for y in paths.YEARS:
824
+ p = paths.DATA / f"nvd/nvdcve-2.0-{y}.json.gz"
825
+ if not p.exists():
826
+ continue
827
+ n = load_nvd(p, con, epss, kev_ids)
828
+ total += n
829
+ if progress:
830
+ progress(f"NVD {y}: {n} ({total})")
831
+
832
+ intel.load_all(con, epss=epss, kev_ids=kev_ids, progress=progress)
833
+
834
+ n = load_exploitdb(paths.DATA / "exploitdb/files_exploits.csv", con)
835
+ load_shellcodes(paths.DATA / "exploitdb/files_shellcodes.csv", con)
836
+ if progress:
837
+ progress(f"Exploit-DB {n}")
838
+ if progress:
839
+ progress(f"exploit links {link_exploits(con)}")
840
+
841
+ if not quick:
842
+ n = load_nuclei(paths.DATA / "nuclei/templates", con)
843
+ if progress:
844
+ progress(f"Nuclei {n}")
845
+ if not (paths.DATA / "metasploit/source/modules").exists():
846
+ n = load_msf(paths.DATA / "metasploit/tree.json", con)
847
+ if progress:
848
+ progress(f"Metasploit {n}")
849
+ load_attack(paths.DATA / "attack/enterprise-attack.json", "enterprise", con)
850
+ for extra in ("mobile", "ics"):
851
+ load_attack(paths.DATA / f"attack/{extra}-attack.json", extra, con)
852
+ if progress:
853
+ progress(f"ATT&CK {con.execute('SELECT count(*) FROM attack').fetchone()[0]}")
854
+
855
+ load_cwe(paths.DATA / "cwe/cwec_latest.xml.zip", con)
856
+ load_capec(paths.DATA / "capec/capec_latest.xml", con)
857
+ load_models(paths.DATA / "models/models.dev.json", con)
858
+ if with_extra or (paths.DATA / "vulnrichment").exists():
859
+ n = load_ssvc(paths.DATA / "vulnrichment", con)
860
+ if progress:
861
+ progress(f"SSVC {n}")
862
+ if not quick:
863
+ load_bulk(con, progress)
864
+
865
+ intel.rebuild_cve_meta(con)
866
+ intel.stub_missing_cwes(con)
867
+ intel.rebuild_aliases(con)
868
+ con.commit()
869
+ con.executescript(INDEXES)
870
+ for k, v in (("schema", str(paths.SCHEMA_VERSION)),
871
+ ("built_at", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
872
+ ("updated_at", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))):
873
+ con.execute("INSERT OR REPLACE INTO meta VALUES (?,?)", (k, v))
874
+ con.commit()
875
+ con.execute("PRAGMA journal_mode=DELETE")
876
+ con.execute("VACUUM")
877
+ con.close()
878
+ tmp.replace(paths.DB)
879
+
880
+ stats = summarize()
881
+ stats["build_seconds"] = round(time.time() - t0, 1)
882
+ _write_stats(stats)
883
+ paths.save_state(schema=paths.SCHEMA_VERSION)
884
+ return stats
885
+
886
+
887
+ def update(deep: bool = False, progress=None) -> dict:
888
+ """Apply the NVD `modified` feed + refresh intel tables, then swap atomically.
889
+
890
+ All work happens on a copy (``crowbar.db.tmp``); only a complete run replaces the
891
+ live index via ``os.replace``, so a crash or kill leaves the old DB intact.
892
+ """
893
+ t0 = time.time()
894
+ if not paths.DB.exists():
895
+ raise FileNotFoundError(f"no index to update: {paths.DB} (run: crowbar build)")
896
+ tmp = paths.DB.with_suffix(".db.tmp")
897
+ if tmp.exists():
898
+ tmp.unlink()
899
+ shutil.copy2(paths.DB, tmp)
900
+ con = sqlite3.connect(tmp, timeout=90)
901
+ con.row_factory = sqlite3.Row
902
+ try:
903
+ try:
904
+ schema = int(con.execute("SELECT value FROM meta WHERE key='schema'").fetchone()[0])
905
+ except (sqlite3.DatabaseError, TypeError, ValueError):
906
+ schema = 0
907
+ if schema < paths.SCHEMA_VERSION:
908
+ intel.migrate(con, progress)
909
+
910
+ epss = read_epss(paths.DATA / "epss/epss_scores-current.csv.gz")
911
+ if progress:
912
+ progress(f"EPSS {len(epss)}")
913
+ n = load_kev(paths.DATA / "kev/known_exploited_vulnerabilities.json", con, epss)
914
+ if progress:
915
+ progress(f"KEV {n}")
916
+ kev_ids = {r[0] for r in con.execute("SELECT cve FROM kev")}
917
+
918
+ changed = 0
919
+ for feed in ("modified", "recent"):
920
+ p = paths.DATA / f"nvd/nvdcve-2.0-{feed}.json.gz"
921
+ if p.exists():
922
+ changed += load_nvd(p, con, epss, kev_ids, replace=True)
923
+ if progress:
924
+ progress(f"CVE rows touched {changed}")
925
+
926
+ intel.load_all(con, epss=epss, kev_ids=kev_ids, progress=progress)
927
+
928
+ if paths.DATA.joinpath("exploitdb/files_exploits.csv").exists():
929
+ load_exploitdb(paths.DATA / "exploitdb/files_exploits.csv", con)
930
+ load_shellcodes(paths.DATA / "exploitdb/files_shellcodes.csv", con)
931
+ link_exploits(con)
932
+ if deep:
933
+ load_nuclei(paths.DATA / "nuclei/templates", con)
934
+ if not (paths.DATA / "metasploit/source/modules").exists():
935
+ load_msf(paths.DATA / "metasploit/tree.json", con)
936
+ for dom, f in (("enterprise", "enterprise"), ("mobile", "mobile"), ("ics", "ics")):
937
+ load_attack(paths.DATA / f"attack/{f}-attack.json", dom, con)
938
+ load_bulk(con, progress, heavy=False)
939
+ load_models(paths.DATA / "models/models.dev.json", con)
940
+ load_cwe(paths.DATA / "cwe/cwec_latest.xml.zip", con)
941
+ load_capec(paths.DATA / "capec/capec_latest.xml", con)
942
+ if (paths.DATA / "vulnrichment").exists():
943
+ load_ssvc(paths.DATA / "vulnrichment", con)
944
+
945
+ intel.rebuild_cve_meta(con)
946
+ intel.stub_missing_cwes(con)
947
+ intel.rebuild_aliases(con)
948
+
949
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
950
+ con.execute("INSERT OR REPLACE INTO meta VALUES ('updated_at', ?)", (now,))
951
+ con.commit()
952
+ stats = {"updated_at": now, "rows_touched": changed,
953
+ "seconds": round(time.time() - t0, 1),
954
+ "cve": con.execute("SELECT count(*) FROM cve").fetchone()[0]}
955
+ con.close()
956
+ except BaseException:
957
+ con.close()
958
+ tmp.unlink(missing_ok=True)
959
+ raise
960
+ tmp.replace(paths.DB)
961
+
962
+ full = summarize()
963
+ full.update(stats)
964
+ _write_stats(full)
965
+ return stats
966
+
967
+
968
+ def _write_stats(stats: dict) -> None:
969
+ import json as _json
970
+ (paths.INDEX / "stats.json").write_text(_json.dumps(stats, indent=2, default=str) + "\n")
971
+
972
+
973
+ def summarize() -> dict:
974
+ con = sqlite3.connect(f"file:{paths.DB}?mode=ro", uri=True)
975
+ out = {}
976
+ for (t,) in con.execute("SELECT name FROM sqlite_master WHERE type='table' "
977
+ "AND name NOT LIKE '%_fts%' AND name NOT LIKE 'sqlite_%'"):
978
+ out[t] = con.execute(f"SELECT count(*) FROM {t}").fetchone()[0]
979
+ try:
980
+ out["meta"] = {k: v for k, v in con.execute("SELECT key, value FROM meta")}
981
+ except sqlite3.DatabaseError:
982
+ out["meta"] = {}
983
+ con.close()
984
+ out["db_bytes"] = paths.DB.stat().st_size
985
+ return out