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/__init__.py +3 -0
- crowbar/catalog.py +174 -0
- crowbar/cli.py +1417 -0
- crowbar/datasets.py +584 -0
- crowbar/db.py +47 -0
- crowbar/indexer.py +985 -0
- crowbar/intel.py +562 -0
- crowbar/loaders.py +382 -0
- crowbar/mcp.py +387 -0
- crowbar/paths.py +54 -0
- crowbar/query.py +551 -0
- crowbar_cli-0.1.0.dist-info/METADATA +287 -0
- crowbar_cli-0.1.0.dist-info/RECORD +16 -0
- crowbar_cli-0.1.0.dist-info/WHEEL +4 -0
- crowbar_cli-0.1.0.dist-info/entry_points.txt +4 -0
- crowbar_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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}
|