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/datasets.py ADDED
@@ -0,0 +1,584 @@
1
+ """Dataset registry, downloader, and freshness tracking."""
2
+ from __future__ import annotations
3
+
4
+ import calendar
5
+ import concurrent.futures as cf
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import re
11
+ import shutil
12
+ import sqlite3
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from dataclasses import dataclass
19
+
20
+ from . import db, paths
21
+
22
+ try:
23
+ import fcntl
24
+ except ImportError: # Windows
25
+ fcntl = None
26
+
27
+ from crowbar import __version__ as _V
28
+ UA = f"crowbar/{_V} (+https://github.com/kushvinth/crowbar)"
29
+
30
+
31
+ @dataclass
32
+ class Dataset:
33
+ name: str
34
+ group: str
35
+ kind: str # http | git
36
+ url: str
37
+ dest: str
38
+ note: str
39
+ tier: str = "core" # core | extra | bulk
40
+ sparse: str | None = None
41
+ optional: bool = False
42
+
43
+ @property
44
+ def path(self) -> pathlib.Path:
45
+ return paths.DATA / self.dest
46
+
47
+
48
+ def _nvd(year: int, kind: str = "year") -> Dataset:
49
+ if kind == "year":
50
+ return Dataset(f"nvd-{year}", "nvd", "http",
51
+ f"https://nvd.nist.gov/feeds/json/cve/2.0/nvdcve-2.0-{year}.json.gz",
52
+ f"nvd/nvdcve-2.0-{year}.json.gz", f"NVD CVE feed, year {year}")
53
+ return Dataset(f"nvd-{kind}", "nvd", "http",
54
+ f"https://nvd.nist.gov/feeds/json/cve/2.0/nvdcve-2.0-{kind}.json.gz",
55
+ f"nvd/nvdcve-2.0-{kind}.json.gz",
56
+ f"NVD CVE {kind} feed (last 8 days of changes)")
57
+
58
+
59
+ CORE_INTEL = [
60
+ Dataset("cveorg-cvelist", "cveorg", "http",
61
+ "https://codeload.github.com/CVEProject/cvelistV5/tar.gz/refs/heads/main",
62
+ "intel/cveorg/cvelistV5-main.tar.gz",
63
+ "MITRE CVE List v5 (CNA records, affected products, references)"),
64
+ Dataset("poc-in-github", "poc", "http",
65
+ "https://codeload.github.com/nomi-sec/PoC-in-GitHub/tar.gz/refs/heads/master",
66
+ "intel/poc-in-github/PoC-in-GitHub-master.tar.gz",
67
+ "PoC-in-GitHub (public PoC repo metadata; never executed)"),
68
+ Dataset("epss", "epss", "http",
69
+ "https://epss.empiricalsecurity.com/epss_scores-current.csv.gz",
70
+ "epss/epss_scores-current.csv.gz", "FIRST EPSS exploit-prediction scores"),
71
+ Dataset("kev", "kev", "http",
72
+ "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
73
+ "kev/known_exploited_vulnerabilities.json", "CISA Known Exploited Vulnerabilities"),
74
+ Dataset("cwe", "cwe", "http", "https://cwe.mitre.org/data/xml/cwec_latest.xml.zip",
75
+ "cwe/cwec_latest.xml.zip", "MITRE CWE taxonomy"),
76
+ Dataset("capec", "capec", "http", "https://capec.mitre.org/data/xml/capec_latest.xml",
77
+ "capec/capec_latest.xml", "MITRE CAPEC attack patterns"),
78
+ Dataset("exploitdb", "exploitdb", "http",
79
+ "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv",
80
+ "exploitdb/files_exploits.csv", "Exploit-DB index (46k entries)"),
81
+ Dataset("exploitdb-shellcodes", "exploitdb", "http",
82
+ "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_shellcodes.csv",
83
+ "exploitdb/files_shellcodes.csv", "Exploit-DB shellcode index"),
84
+ Dataset("nuclei", "nuclei", "git", "https://github.com/projectdiscovery/nuclei-templates.git",
85
+ "nuclei/templates", "Nuclei template library (YAML)"),
86
+ Dataset("metasploit", "metasploit", "http",
87
+ "https://api.github.com/repos/rapid7/metasploit-framework/git/trees/master?recursive=1",
88
+ "metasploit/tree.json", "Metasploit module tree"),
89
+ Dataset("models", "models", "http", "https://models.dev/api.json",
90
+ "models/models.dev.json", "LLM catalog: providers, context, price"),
91
+ ]
92
+
93
+ OSV_ECOSYSTEMS = [
94
+ ("npm", 209), ("PyPI", 35), ("Go", 12), ("Maven", 10), ("NuGet", 3),
95
+ ("RubyGems", 5), ("crates.io", 3), ("Packagist", 10), ("Debian", 73), ("Alpine", 4),
96
+ ]
97
+
98
+ BULK_INTEL = [
99
+ *[Dataset(f"osv-{eco.lower()}", "osv", "http",
100
+ f"https://osv-vulnerabilities.storage.googleapis.com/{eco}/all.zip",
101
+ f"osv/{eco}.zip", f"OSV advisories for {eco} (GHSA + ecosystem DBs)",
102
+ tier="bulk")
103
+ for eco, _ in OSV_ECOSYSTEMS],
104
+ Dataset("exploitdb-full", "exploitdb", "git",
105
+ "https://gitlab.com/exploit-database/exploitdb.git",
106
+ "exploitdb/full", "Exploit-DB full sources (code search, 47k files)",
107
+ tier="bulk", sparse="exploits"),
108
+ Dataset("msrc", "vendor", "msrc",
109
+ "https://api.msrc.microsoft.com/sug/v2.0/en-US/vulnerability",
110
+ "msrc/sug.json", "MSRC Security Update Guide (Windows/Edge CVEs, severity, impact)"),
111
+ Dataset("apple", "vendor", "apple", "https://support.apple.com/en-us/100100",
112
+ "apple", "Apple security releases (iOS/iPadOS/macOS/watchOS advisories)"),
113
+ Dataset("debian-tracker", "debian", "http",
114
+ "https://security-tracker.debian.org/tracker/data/json",
115
+ "debian/tracker.json", "Debian security tracker (per-package fixed versions)"),
116
+ ]
117
+
118
+ EXTRA_INTEL = [
119
+ Dataset("attack-mobile", "attack", "http",
120
+ "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/mobile-attack/mobile-attack.json",
121
+ "attack/mobile-attack.json", "ATT&CK Mobile STIX", tier="extra"),
122
+ Dataset("attack-ics", "attack", "http",
123
+ "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/ics-attack/ics-attack.json",
124
+ "attack/ics-attack.json", "ATT&CK ICS STIX", tier="extra"),
125
+ Dataset("vulnrichment", "vulnrichment", "git", "https://github.com/cisagov/vulnrichment.git",
126
+ "vulnrichment", "CISA Vulnrichment (SSVC decision points, ADP data)", tier="extra"),
127
+ ]
128
+
129
+ TOOLKIT = [
130
+ Dataset("gtfobins", "toolkit", "git", "https://github.com/GTFOBins/GTFOBins.github.io.git",
131
+ "toolkit/gtfobins", "GTFOBins (unix binary abuse)"),
132
+ Dataset("lolbas", "toolkit", "git", "https://github.com/LOLBAS-Project/LOLBAS.git",
133
+ "toolkit/lolbas", "LOLBAS (windows binary abuse)"),
134
+ Dataset("payloads", "toolkit", "git", "https://github.com/swisskyrepo/PayloadsAllTheThings.git",
135
+ "toolkit/payloads", "PayloadsAllTheThings (payload cheat sheets)"),
136
+ Dataset("msf-source", "metasploit", "git", "https://github.com/rapid7/metasploit-framework.git",
137
+ "metasploit/source", "Metasploit module sources (metadata + CVE refs)",
138
+ sparse="modules"),
139
+ ]
140
+
141
+
142
+ def attack_enterprise() -> Dataset:
143
+ return Dataset("attack-enterprise", "attack", "http",
144
+ "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/"
145
+ "enterprise-attack/enterprise-attack.json",
146
+ "attack/enterprise-attack.json", "ATT&CK Enterprise STIX")
147
+
148
+
149
+ def registry(with_extra: bool = False, with_bulk: bool = False) -> list[Dataset]:
150
+ items = [_nvd(y) for y in paths.YEARS]
151
+ items += [_nvd(0, "recent"), _nvd(0, "modified")]
152
+ items += CORE_INTEL + TOOLKIT
153
+ items.append(attack_enterprise())
154
+ if with_extra or with_bulk:
155
+ items += EXTRA_INTEL
156
+ if with_bulk:
157
+ items += BULK_INTEL
158
+ return items
159
+
160
+
161
+ def plan(only: list[str] | None = None, years: list[int] | None = None,
162
+ with_extra: bool = False, with_bulk: bool = False) -> list[Dataset]:
163
+ items = registry(with_extra=True, with_bulk=True)
164
+ if years:
165
+ keep = set(years)
166
+ items = [d for d in items if not d.name.startswith("nvd-2") or int(d.name.split("-")[1]) in keep]
167
+ if only:
168
+ want = {s.strip().lower() for s in only if s.strip()}
169
+ # explicitly named extra datasets are always honoured
170
+ items = [d for d in items if d.name in want
171
+ or (d.group in want and (d.tier == "core" or with_extra or with_bulk))]
172
+ else:
173
+ items = [d for d in items
174
+ if d.tier == "core" or (with_extra and d.tier == "extra")
175
+ or (with_bulk and d.tier in ("extra", "bulk"))]
176
+ return items
177
+
178
+
179
+ # ---------------------------------------------------------------- risk tiers
180
+ # User-facing size tiers, each a superset of the one above. Expressed as the set
181
+ # of dataset groups to include (reusing plan()); build() then loads whatever was
182
+ # synced, so a lite sync + build yields a lite index with no extra wiring.
183
+ TIER_NAMES = ["lite", "medium", "heavy", "extreme"]
184
+ TIER_SIZES = {"lite": "~150MB", "medium": "~500MB", "heavy": "~2GB", "extreme": "full"}
185
+ _TIER_ADD = {
186
+ "lite": {"nvd", "kev", "epss", "cwe"},
187
+ "medium": {"cveorg", "capec", "attack", "vulnrichment"},
188
+ "heavy": {"exploitdb", "nuclei", "metasploit", "osv", "poc", "toolkit"},
189
+ # extreme == every source (handled in tier_plan)
190
+ }
191
+
192
+
193
+ def tier_groups(tier: str) -> set[str]:
194
+ out: set[str] = set()
195
+ for t in TIER_NAMES:
196
+ out |= _TIER_ADD.get(t, set())
197
+ if t == tier:
198
+ break
199
+ return out
200
+
201
+
202
+
203
+ def tier_missing(tier: str | None) -> list[str]:
204
+ """Datasets the recorded tier promised but the manifest does not have,
205
+ i.e. downloads that failed or were never run. Empty when no tier is set."""
206
+ if not tier or tier not in TIER_NAMES:
207
+ return []
208
+ expected = {ds.name for ds in tier_plan(tier)}
209
+ present = set(load_manifest().get("datasets", {}).keys())
210
+ return sorted(expected - present)
211
+
212
+
213
+ def tier_plan(tier: str) -> list[Dataset]:
214
+ """Datasets for a risk tier. lite keeps only the last 2 NVD years plus the
215
+ recent/modified feeds; medium and up take the full NVD history."""
216
+ tier = (tier or "").lower()
217
+ if tier not in TIER_NAMES:
218
+ raise ValueError(f"unknown tier {tier!r}; choose: {', '.join(TIER_NAMES)}")
219
+ if tier == "extreme":
220
+ return plan(with_extra=True, with_bulk=True)
221
+ years = list(paths.YEARS[-2:]) if tier == "lite" else None
222
+ return plan(only=sorted(tier_groups(tier)), years=years,
223
+ with_extra=True, with_bulk=True)
224
+
225
+
226
+ # ---------------------------------------------------------------- manifest
227
+ def load_manifest() -> dict:
228
+ if paths.MANIFEST.exists():
229
+ try:
230
+ return json.loads(paths.MANIFEST.read_text())
231
+ except json.JSONDecodeError:
232
+ pass
233
+ return {"datasets": {}}
234
+
235
+
236
+ def save_manifest(m: dict) -> None:
237
+ paths.ensure_dirs()
238
+ m["generated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
239
+ paths.MANIFEST.write_text(json.dumps(m, indent=2, sort_keys=True) + "\n")
240
+
241
+
242
+ # ---------------------------------------------------------------- helpers
243
+ def sha256(path: pathlib.Path, chunk: int = 1 << 20) -> str:
244
+ h = hashlib.sha256()
245
+ with path.open("rb") as fh:
246
+ while True:
247
+ b = fh.read(chunk)
248
+ if not b:
249
+ break
250
+ h.update(b)
251
+ return h.hexdigest()
252
+
253
+
254
+ def human(n: float) -> str:
255
+ for unit in ("B", "KB", "MB", "GB"):
256
+ if n < 1024 or unit == "GB":
257
+ return f"{n:.1f}{unit}"
258
+ n /= 1024
259
+ return f"{n:.1f}GB"
260
+
261
+
262
+ def http_get(url: str, dest: pathlib.Path, retries: int = 4, timeout: int = 180,
263
+ since: str | None = None) -> tuple[int, str] | None:
264
+ """Download atomically. Returns (bytes, last_modified) or None on HTTP 304."""
265
+ import email.utils # noqa: F401
266
+ dest.parent.mkdir(parents=True, exist_ok=True)
267
+ tmp = dest.with_name(dest.name + ".part")
268
+ if tmp.exists():
269
+ tmp.unlink() # start clean; resume only within this call
270
+ last: Exception | None = None
271
+ lm = ""
272
+ failures = 0
273
+ while failures < retries:
274
+ have = start = tmp.stat().st_size if tmp.exists() else 0
275
+ try:
276
+ headers = {"User-Agent": UA, "Accept": "*/*", "Accept-Encoding": "identity"}
277
+ if since:
278
+ headers["If-Modified-Since"] = since
279
+ if have:
280
+ headers["Range"] = f"bytes={have}-" # resume a truncated download
281
+ req = urllib.request.Request(url, headers=headers)
282
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
283
+ if resp.status == 304:
284
+ return None
285
+ partial = resp.status == 206
286
+ if not partial:
287
+ have = 0 # server ignored Range: restart from scratch
288
+ with tmp.open("ab" if partial else "wb") as out:
289
+ shutil.copyfileobj(resp, out, length=1 << 20)
290
+ lm = resp.headers.get("Last-Modified") or lm
291
+ total = None
292
+ cr = resp.headers.get("Content-Range") # "bytes X-Y/TOTAL"
293
+ if cr and "/" in cr and cr.rsplit("/", 1)[-1].isdigit():
294
+ total = int(cr.rsplit("/", 1)[-1])
295
+ elif resp.headers.get("Content-Length") is not None:
296
+ total = have + int(resp.headers["Content-Length"])
297
+ got = tmp.stat().st_size
298
+ if got == 0:
299
+ raise IOError("empty response")
300
+ if total is not None and got != total:
301
+ # keep .part and resume the remaining bytes on the next attempt
302
+ raise IOError(f"truncated: {got}/{total} bytes (resuming)")
303
+ tmp.replace(dest)
304
+ return dest.stat().st_size, lm
305
+ except urllib.error.HTTPError as exc:
306
+ if exc.code == 304:
307
+ return None
308
+ if exc.code == 416 and tmp.exists():
309
+ tmp.unlink() # range unsatisfiable: our .part is stale
310
+ last = exc
311
+ failures += 1
312
+ except Exception as exc: # noqa: BLE001
313
+ last = exc # keep .part: a truncation only grows toward total
314
+ # a reset mid-stream that still moved bytes is progress, not a failure
315
+ # (NVD drops long transfers); the file size bounds how often this repeats
316
+ grew = tmp.exists() and tmp.stat().st_size > start
317
+ failures = 0 if grew else failures + 1
318
+ if failures < retries:
319
+ time.sleep(min(2 ** max(failures, 1), 10))
320
+ if tmp.exists():
321
+ tmp.unlink() # give up cleanly; never leave a partial behind
322
+ raise RuntimeError(f"download failed: {url}: {last}")
323
+
324
+
325
+ def http_date(iso: str | None) -> str | None:
326
+ if not iso:
327
+ return None
328
+ import datetime as _dt
329
+ import email.utils
330
+ try:
331
+ dt = _dt.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_dt.timezone.utc)
332
+ except ValueError:
333
+ return None
334
+ return email.utils.format_datetime(dt, usegmt=True)
335
+
336
+
337
+ APPLE_ROWS = re.compile(
338
+ r'<a href="(https://support\.apple\.com/en-us/\d+)"[^>]*>([^<]+)</a>.*?'
339
+ r'<td[^>]*><p[^>]*>(.*?)</p></td>\s*<td[^>]*><p[^>]*>([^<]*)</p></td>', re.S)
340
+
341
+
342
+ def http_text(url: str, timeout: int = 60) -> str:
343
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
344
+ with urllib.request.urlopen(req, timeout=timeout) as r:
345
+ return r.read().decode("utf-8", errors="replace")
346
+
347
+
348
+ def msrc_fetch(dest: pathlib.Path) -> int:
349
+ url = "https://api.msrc.microsoft.com/sug/v2.0/en-US/vulnerability"
350
+ rows: list[dict] = []
351
+ while url:
352
+ doc = json.loads(http_text(url, timeout=90))
353
+ rows += doc.get("value", [])
354
+ url = doc.get("@odata.nextLink")
355
+ time.sleep(0.2)
356
+ dest.parent.mkdir(parents=True, exist_ok=True)
357
+ dest.write_text(json.dumps(rows))
358
+ return dest.stat().st_size
359
+
360
+
361
+ def apple_fetch(dest: pathlib.Path) -> int:
362
+ html = http_text("https://support.apple.com/en-us/100100")
363
+ dest.mkdir(parents=True, exist_ok=True)
364
+ (dest / "index.html").write_text(html)
365
+ pages = dest / "pages"
366
+ pages.mkdir(exist_ok=True)
367
+ todo = []
368
+ for url in {u for u, *_ in APPLE_ROWS.findall(html)}:
369
+ page = pages / (url.rsplit("/", 1)[-1] + ".html")
370
+ if not page.exists():
371
+ todo.append((url, page))
372
+ with cf.ThreadPoolExecutor(max_workers=6) as pool:
373
+ def grab(pair):
374
+ url, page = pair
375
+ try:
376
+ page.write_text(http_text(url))
377
+ except Exception: # noqa: BLE001
378
+ pass
379
+ list(pool.map(grab, todo))
380
+ return sum(f.stat().st_size for f in dest.rglob("*") if f.is_file())
381
+
382
+
383
+ def git_clone(url: str, dest: pathlib.Path, pull: bool = False,
384
+ sparse: str | None = None) -> str:
385
+ if (dest / ".git").exists():
386
+ if not pull:
387
+ return "exists"
388
+ res = subprocess.run(["git", "-C", str(dest), "pull", "--ff-only", "--depth", "1"],
389
+ capture_output=True, text=True)
390
+ return "pulled" if res.returncode == 0 else "exists"
391
+ dest.parent.mkdir(parents=True, exist_ok=True)
392
+ if dest.exists() and not any(dest.iterdir()):
393
+ dest.rmdir()
394
+ cmd = ["git", "clone", "--depth", "1", "--single-branch"]
395
+ if sparse:
396
+ cmd += ["--filter=blob:none", "--sparse"]
397
+ cmd += [url, str(dest)]
398
+ res = subprocess.run(cmd, capture_output=True, text=True)
399
+ if res.returncode == 0 and sparse:
400
+ subprocess.run(["git", "-C", str(dest), "sparse-checkout", "set", sparse],
401
+ capture_output=True, text=True)
402
+ if res.returncode != 0:
403
+ tail = (res.stderr or res.stdout).strip().splitlines()[-1:] or ["git clone failed"]
404
+ raise RuntimeError(f"{url}: {tail[0]}")
405
+ return "cloned"
406
+
407
+
408
+ def git_head(dest: pathlib.Path) -> str:
409
+ """Resolved commit of a cloned repo - the git analogue of a file sha256,
410
+ recorded for provenance. (We fetch a mutable branch tip rather than a
411
+ pinned commit: full pinning needs curated per-repo SHAs + verification
412
+ infra, so this records what was fetched, it does not attest it.)"""
413
+ res = subprocess.run(["git", "-C", str(dest), "rev-parse", "HEAD"],
414
+ capture_output=True, text=True)
415
+ return res.stdout.strip() if res.returncode == 0 else ""
416
+
417
+
418
+ def sync_one(d: Dataset, manifest: dict, force: bool = False, pull: bool = False) -> dict:
419
+ rec = manifest["datasets"].get(d.name, {})
420
+ started = time.time()
421
+ lm = ""
422
+ drift = False
423
+ try:
424
+ if d.kind == "msrc":
425
+ size, digest = msrc_fetch(d.path), ""
426
+ elif d.kind == "apple":
427
+ size, digest = apple_fetch(d.path), ""
428
+ elif d.kind == "git":
429
+ state = git_clone(d.url, d.path, pull=pull, sparse=d.sparse)
430
+ head = git_head(d.path)
431
+ if state in ("exists", "pulled") and not force:
432
+ if head and d.name in manifest["datasets"]:
433
+ manifest["datasets"][d.name]["sha256"] = head # record commit
434
+ return {"name": d.name, "status": state, "bytes": rec.get("bytes", 0),
435
+ "commit": head[:12]}
436
+ size = sum(f.stat().st_size for f in d.path.rglob("*") if f.is_file())
437
+ digest = head
438
+ else:
439
+ # shortcut: the recorded sha256 is self-referential (file vs its own
440
+ # last hash) - no upstream signature exists for these feeds, so we
441
+ # can't attest authenticity, only make version drift visible.
442
+ if d.path.exists() and not force and rec.get("sha256") and sha256(d.path) == rec["sha256"]:
443
+ return {"name": d.name, "status": "same", "bytes": d.path.stat().st_size}
444
+ got = http_get(d.url, d.path,
445
+ since=None if force else http_date(rec.get("fetched_at")))
446
+ if got is None:
447
+ return {"name": d.name, "status": "same", "bytes": d.path.stat().st_size}
448
+ size, lm = got
449
+ digest = sha256(d.path)
450
+ drift = bool(rec.get("sha256")) and rec["sha256"] != digest
451
+ manifest["datasets"][d.name] = {
452
+ "group": d.group, "url": d.url, "path": d.dest, "bytes": size,
453
+ "sha256": digest, "tier": d.tier,
454
+ "fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
455
+ "http_last_modified": lm,
456
+ "note": d.note,
457
+ }
458
+ return {"name": d.name, "status": "ok", "bytes": size, "changed": drift,
459
+ "secs": round(time.time() - started, 1)}
460
+ except Exception as exc: # noqa: BLE001
461
+ return {"name": d.name, "status": "FAIL", "error": str(exc)[:300]}
462
+
463
+
464
+ def sync(items: list[Dataset], force: bool = False, jobs: int = 4,
465
+ pull: bool = False, progress=None) -> list[dict]:
466
+ manifest = load_manifest()
467
+ results: list[dict] = []
468
+ with cf.ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
469
+ futs = {pool.submit(sync_one, d, manifest, force, pull): d for d in items}
470
+ for fut in cf.as_completed(futs):
471
+ r = fut.result()
472
+ results.append(r)
473
+ if progress:
474
+ progress(r)
475
+ save_manifest(manifest)
476
+ return results
477
+
478
+
479
+ def freshness(manifest: dict | None = None) -> dict[str, dict]:
480
+ m = manifest or load_manifest()
481
+ return {k: {"fetched_at": v.get("fetched_at"), "bytes": v.get("bytes"),
482
+ "path": v.get("path"), "tier": v.get("tier", "core")}
483
+ for k, v in sorted(m.get("datasets", {}).items())}
484
+
485
+
486
+ # ---------------------------------------------------------------- index age
487
+ REFRESH_AFTER_DAYS = 1 # background refresh once the index is a day old
488
+ STALE_AFTER_DAYS = 7 # NVD's modified feed covers 8 days; older means a gap
489
+
490
+
491
+ def index_status() -> dict:
492
+ """How old the index is, from the meta rows each build/update writes with the data."""
493
+ try:
494
+ con = db.connect()
495
+ except (db.NotIndexed, sqlite3.Error):
496
+ meta = {}
497
+ else:
498
+ meta = db.meta(con)
499
+ con.close()
500
+ last = max(filter(None, (meta.get("updated_at"), meta.get("built_at"))), default=None)
501
+ age = None
502
+ if last:
503
+ age = round((time.time() - calendar.timegm(time.strptime(last, "%Y-%m-%dT%H:%M:%SZ")))
504
+ / 86400, 1)
505
+ return {"last_update": last, "age_days": age,
506
+ "stale": age is not None and age > STALE_AFTER_DAYS,
507
+ "tier": load_manifest().get("tier"),
508
+ "auto_update": not os.environ.get("CROWBAR_NO_AUTO_UPDATE")}
509
+
510
+
511
+ _held: list = [] # this process's open lock file; closing it releases the lock
512
+
513
+
514
+ def _flock(f) -> bool:
515
+ if fcntl is None:
516
+ return True
517
+ try:
518
+ fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
519
+ return True
520
+ except OSError:
521
+ return False
522
+
523
+
524
+ def acquire_update_lock() -> bool:
525
+ """Hold the writer lock until this process exits; False if another
526
+ sync/build/update already holds it (they share crowbar.db.tmp)."""
527
+ # shortcut: no cross-process lock without fcntl (Windows); add msvcrt.locking if Windows gets CI
528
+ if _held or fcntl is None:
529
+ return True
530
+ try:
531
+ paths.INDEX.mkdir(parents=True, exist_ok=True)
532
+ f = open(paths.INDEX / "update.lock", "a")
533
+ except OSError: # read-only index: nothing can write it, so there is nothing to guard
534
+ return True
535
+ if not _flock(f):
536
+ f.close()
537
+ return False
538
+ _held.append(f)
539
+ return True
540
+
541
+
542
+ def update_running() -> bool:
543
+ """True while some process holds the writer lock."""
544
+ if _held:
545
+ return True
546
+ lock = paths.INDEX / "update.lock"
547
+ if fcntl is None or not lock.exists():
548
+ return False
549
+ try:
550
+ with open(lock, "a") as f:
551
+ if _flock(f):
552
+ fcntl.flock(f, fcntl.LOCK_UN)
553
+ return False
554
+ return True
555
+ except OSError:
556
+ return False
557
+
558
+
559
+ def refresh_in_background() -> bool:
560
+ """Start a detached `crowbar update` when the index is over a day old.
561
+
562
+ At most one attempt per hour (lock file mtime), so an offline machine or a
563
+ failing source does not respawn it on every call, and never while another
564
+ update holds the lock. CROWBAR_NO_AUTO_UPDATE=1 turns it off."""
565
+ s = index_status()
566
+ if not s["auto_update"] or s["age_days"] is None or s["age_days"] < REFRESH_AFTER_DAYS:
567
+ return False
568
+ lock = paths.INDEX / "update.lock"
569
+ if lock.exists() and time.time() - lock.stat().st_mtime < 3600:
570
+ return False
571
+ if update_running(): # an update past the hour is still writing
572
+ return False
573
+ try:
574
+ lock.touch()
575
+ except OSError: # read-only index (e.g. a write-protected drive): query, don't refresh
576
+ return False
577
+ # nix-wrapped interpreters do not see the package on their own path
578
+ pkg = str(pathlib.Path(__file__).resolve().parents[1])
579
+ env = dict(os.environ, PYTHONPATH=os.pathsep.join(filter(None, (pkg, os.environ.get("PYTHONPATH")))))
580
+ with open(paths.INDEX / "update.log", "ab") as log:
581
+ subprocess.Popen([sys.executable, "-m", "crowbar.cli", "update", "--quiet", "--if-mounted"],
582
+ stdin=subprocess.DEVNULL, stdout=log, stderr=log, env=env,
583
+ cwd=pkg, start_new_session=True)
584
+ return True
crowbar/db.py ADDED
@@ -0,0 +1,47 @@
1
+ """SQLite helpers: read-only connections, metadata, schema version."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ from urllib.request import pathname2url
6
+
7
+ from . import paths
8
+
9
+
10
+ class NotIndexed(RuntimeError):
11
+ pass
12
+
13
+
14
+ def connect(readonly: bool = True) -> sqlite3.Connection:
15
+ if not paths.DB.exists():
16
+ raise NotIndexed("no index yet - run: crowbar build")
17
+ if readonly:
18
+ # pathname2url percent-encodes ?/#/%/spaces in the path (e.g. USB mounts);
19
+ # a bare f-string would misparse them as the URI query.
20
+ con = sqlite3.connect(f"file:{pathname2url(str(paths.DB))}?mode=ro", uri=True, timeout=30)
21
+ con.execute("PRAGMA query_only=ON")
22
+ else:
23
+ con = sqlite3.connect(paths.DB, timeout=60)
24
+ con.row_factory = sqlite3.Row
25
+ return con
26
+
27
+
28
+ def schema_version(con: sqlite3.Connection) -> int:
29
+ try:
30
+ row = con.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
31
+ return int(row[0]) if row else 0
32
+ except sqlite3.DatabaseError:
33
+ return 0
34
+
35
+
36
+ def meta(con: sqlite3.Connection) -> dict:
37
+ try:
38
+ return {r[0]: r[1] for r in con.execute("SELECT key, value FROM meta")}
39
+ except sqlite3.DatabaseError:
40
+ return {}
41
+
42
+
43
+ def counts(con: sqlite3.Connection) -> dict:
44
+ tables = [r[0] for r in con.execute(
45
+ "SELECT name FROM sqlite_master WHERE type='table' "
46
+ "AND name NOT LIKE '%_fts%' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
47
+ return {t: con.execute(f"SELECT count(*) FROM {t}").fetchone()[0] for t in tables}