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/loaders.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Loaders for the bulk OSV / vendor / toolkit corpora."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
import zipfile
|
|
9
|
+
|
|
10
|
+
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}", re.I)
|
|
11
|
+
|
|
12
|
+
MSRC_IMPACT = {
|
|
13
|
+
"Elevation of Privilege": "EoP",
|
|
14
|
+
"Remote Code Execution": "RCE",
|
|
15
|
+
"Information Disclosure": "InfoDisclosure",
|
|
16
|
+
"Denial of Service": "DoS",
|
|
17
|
+
"Spoofing": "Spoofing",
|
|
18
|
+
"Security Feature Bypass": "Bypass",
|
|
19
|
+
"Tampering": "Tampering",
|
|
20
|
+
"Defense in Depth": "DefenseInDepth",
|
|
21
|
+
"Not a Vulnerability": "",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
MSRC_SEVERITY = {100000000: "Critical", 100000001: "Important",
|
|
25
|
+
100000002: "Moderate", 100000003: "Low"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _norm(s: str | None, limit: int = 4000) -> str:
|
|
29
|
+
if not s:
|
|
30
|
+
return ""
|
|
31
|
+
return re.sub(r"\s+", " ", s).strip()[:limit]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- OSV
|
|
35
|
+
def load_osv_zip(path: pathlib.Path, con: sqlite3.Connection) -> int:
|
|
36
|
+
"""One OSV ecosystem zip -> advisory + package_range rows."""
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return 0
|
|
39
|
+
eco_default = path.stem
|
|
40
|
+
adv: dict[tuple[str, str], dict] = {}
|
|
41
|
+
ranges: list[tuple] = []
|
|
42
|
+
with zipfile.ZipFile(path) as zf:
|
|
43
|
+
for name in zf.namelist():
|
|
44
|
+
if not name.endswith(".json"):
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
doc = json.loads(zf.read(name))
|
|
48
|
+
except (json.JSONDecodeError, KeyError):
|
|
49
|
+
continue
|
|
50
|
+
aid = doc.get("id")
|
|
51
|
+
if not aid:
|
|
52
|
+
continue
|
|
53
|
+
aliases = [str(a) for a in doc.get("aliases") or []]
|
|
54
|
+
cve = next((a.upper() for a in aliases if CVE_RE.fullmatch(a.upper())), "")
|
|
55
|
+
if not cve:
|
|
56
|
+
m = CVE_RE.search(doc.get("summary") or "")
|
|
57
|
+
cve = m.group(0).upper() if m else ""
|
|
58
|
+
sev = ""
|
|
59
|
+
for s in doc.get("severity") or []:
|
|
60
|
+
if s.get("score"):
|
|
61
|
+
sev = s["score"]
|
|
62
|
+
sev = sev or (doc.get("database_specific") or {}).get("severity") or ""
|
|
63
|
+
affected = doc.get("affected") or []
|
|
64
|
+
first_eco, first_pkg = "", ""
|
|
65
|
+
for a in affected:
|
|
66
|
+
pkg = a.get("package") or {}
|
|
67
|
+
eco = pkg.get("ecosystem") or eco_default
|
|
68
|
+
pname = (pkg.get("name") or "").lower()
|
|
69
|
+
if not pname:
|
|
70
|
+
continue
|
|
71
|
+
first_eco = first_eco or eco
|
|
72
|
+
first_pkg = first_pkg or pname
|
|
73
|
+
for rng in a.get("ranges") or []:
|
|
74
|
+
intro = fixed = last = ""
|
|
75
|
+
for ev in rng.get("events") or []:
|
|
76
|
+
if "introduced" in ev and not intro:
|
|
77
|
+
intro = str(ev["introduced"])
|
|
78
|
+
if "fixed" in ev and not fixed:
|
|
79
|
+
fixed = str(ev["fixed"])
|
|
80
|
+
if "last_affected" in ev and not last:
|
|
81
|
+
last = str(ev["last_affected"])
|
|
82
|
+
ranges.append((aid, eco, pname, intro, fixed, last,
|
|
83
|
+
",".join(a.get("versions") or [])[:1000]))
|
|
84
|
+
if not a.get("ranges") and a.get("versions"):
|
|
85
|
+
ranges.append((aid, eco, pname, "", "", "",
|
|
86
|
+
",".join(a["versions"])[:1000]))
|
|
87
|
+
adv[(aid, eco_default)] = {
|
|
88
|
+
"id": aid, "ecosystem": first_eco, "package": first_pkg, "cve": cve,
|
|
89
|
+
"summary": _norm(doc.get("summary") or doc.get("details"), 800),
|
|
90
|
+
"severity": sev, "published": doc.get("published"),
|
|
91
|
+
"modified": doc.get("modified"),
|
|
92
|
+
"url": f"https://osv.dev/vulnerability/{aid}",
|
|
93
|
+
"aliases": ",".join(aliases)[:500],
|
|
94
|
+
"affected": len(affected),
|
|
95
|
+
}
|
|
96
|
+
rows = [(r["id"], "osv", r["ecosystem"], r["package"], r["cve"], r["summary"],
|
|
97
|
+
r["severity"], r["published"], r["modified"], r["url"], r["aliases"],
|
|
98
|
+
r["affected"]) for r in adv.values()]
|
|
99
|
+
con.executemany(
|
|
100
|
+
"INSERT OR REPLACE INTO advisory(source,id,ecosystem,package,cve,summary,"
|
|
101
|
+
"severity,published,modified,url,aliases,affected_count) VALUES ('osv',?,?,?,?,?,?,?,?,?,?,?)",
|
|
102
|
+
[(r[0], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10], r[11]) for r in rows])
|
|
103
|
+
# Reloading a zip re-REPLACEs advisory rows; clear their old ranges too so
|
|
104
|
+
# package_range stays in sync instead of accumulating duplicate rows.
|
|
105
|
+
con.executemany("DELETE FROM package_range WHERE advisory_id=?",
|
|
106
|
+
[(r[0],) for r in rows])
|
|
107
|
+
con.executemany(
|
|
108
|
+
"INSERT INTO package_range(advisory_id,ecosystem,package,introduced,fixed,"
|
|
109
|
+
"last_affected,versions) VALUES (?,?,?,?,?,?,?)", ranges)
|
|
110
|
+
con.commit()
|
|
111
|
+
return len(rows)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------- Debian
|
|
115
|
+
def load_debian(path: pathlib.Path, con: sqlite3.Connection) -> int:
|
|
116
|
+
"""Debian tracker: package -> CVE -> per-release status/fixed version."""
|
|
117
|
+
if not path.exists():
|
|
118
|
+
return 0
|
|
119
|
+
doc = json.loads(path.read_text())
|
|
120
|
+
rows = []
|
|
121
|
+
for pkg, cves in doc.items():
|
|
122
|
+
if not isinstance(cves, dict):
|
|
123
|
+
continue
|
|
124
|
+
for cve, info in cves.items():
|
|
125
|
+
if not CVE_RE.fullmatch(cve):
|
|
126
|
+
continue
|
|
127
|
+
desc = _norm((info or {}).get("description"), 500)
|
|
128
|
+
for rel, rinfo in ((info or {}).get("releases") or {}).items():
|
|
129
|
+
rows.append((pkg.lower(), rel, cve, (rinfo or {}).get("status", ""),
|
|
130
|
+
(rinfo or {}).get("fixed_version", ""),
|
|
131
|
+
(rinfo or {}).get("urgency", ""), desc))
|
|
132
|
+
con.execute("DELETE FROM debian")
|
|
133
|
+
con.executemany("INSERT INTO debian(package,release,cve,status,fixed,urgency,"
|
|
134
|
+
"description) VALUES (?,?,?,?,?,?,?)", rows)
|
|
135
|
+
con.commit()
|
|
136
|
+
return len(rows)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------- Microsoft
|
|
140
|
+
def _sev_from_cvss(score: float) -> str:
|
|
141
|
+
"""Microsoft severity label derived from a CVSS base score (used only when
|
|
142
|
+
MSRC omits the label but ships a score)."""
|
|
143
|
+
return ("Critical" if score >= 9 else "Important" if score >= 7
|
|
144
|
+
else "Moderate" if score >= 4 else "Low")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_msrc(path: pathlib.Path, con: sqlite3.Connection) -> int:
|
|
148
|
+
"""MSRC Security Update Guide: CVE, title, severity, impact, product tag."""
|
|
149
|
+
if not path.exists():
|
|
150
|
+
return 0
|
|
151
|
+
rows = []
|
|
152
|
+
for v in json.loads(path.read_text()):
|
|
153
|
+
cid = v.get("cveNumber")
|
|
154
|
+
if not cid:
|
|
155
|
+
continue
|
|
156
|
+
title = v.get("cveTitle") or ""
|
|
157
|
+
impact = MSRC_IMPACT.get(v.get("impact") or "", "")
|
|
158
|
+
if not impact:
|
|
159
|
+
low = title.lower()
|
|
160
|
+
for kw, val in (("remote code execution", "RCE"),
|
|
161
|
+
("elevation of privilege", "EoP"),
|
|
162
|
+
("information disclosure", "InfoDisclosure"),
|
|
163
|
+
("denial of service", "DoS"),
|
|
164
|
+
("security feature bypass", "Bypass"),
|
|
165
|
+
("spoofing", "Spoofing"),
|
|
166
|
+
("tampering", "Tampering")):
|
|
167
|
+
if kw in low:
|
|
168
|
+
impact = val
|
|
169
|
+
break
|
|
170
|
+
severity = MSRC_SEVERITY.get(v.get("severityId"), "")
|
|
171
|
+
raw_sev = (v.get("severity") or "").strip()
|
|
172
|
+
if raw_sev and raw_sev.lower() != "none":
|
|
173
|
+
severity = raw_sev
|
|
174
|
+
if not severity: # recover from a CVSS score if present
|
|
175
|
+
score = v.get("baseScore")
|
|
176
|
+
if not score:
|
|
177
|
+
for sset in (v.get("cvssScoreSets") or []):
|
|
178
|
+
if sset.get("baseScore"):
|
|
179
|
+
score = sset["baseScore"]
|
|
180
|
+
break
|
|
181
|
+
if score:
|
|
182
|
+
try:
|
|
183
|
+
severity = _sev_from_cvss(float(score))
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
pass
|
|
186
|
+
# MSRC sends "Yes"/"No"; the old truthiness test marked 13.4k "No" rows.
|
|
187
|
+
exploited = "exploited" if str(v.get("exploited") or "").lower() == "yes" else ""
|
|
188
|
+
rows.append((
|
|
189
|
+
cid, "windows", (v.get("tag") or "Microsoft").lower(),
|
|
190
|
+
f"{(v.get('releaseNumber') or '')}",
|
|
191
|
+
title, severity, impact, exploited,
|
|
192
|
+
v.get("releaseDate") or "",
|
|
193
|
+
v.get("mitreUrl") or f"https://msrc.microsoft.com/update-guide/vulnerability/{cid}",
|
|
194
|
+
))
|
|
195
|
+
con.execute("DELETE FROM os_advisory WHERE source='msrc'")
|
|
196
|
+
con.executemany(
|
|
197
|
+
"INSERT OR REPLACE INTO os_advisory(source,cve,platform,product,version,title,"
|
|
198
|
+
"severity,impact,exploited,released,url) VALUES ('msrc',?,?,?,?,?,?,?,?,?,?)", rows)
|
|
199
|
+
con.commit()
|
|
200
|
+
return len(rows)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------- Apple
|
|
204
|
+
APPLE_ROWS = re.compile(
|
|
205
|
+
r'<a href="(https://support\.apple\.com/en-us/\d+)"[^>]*>([^<]+)</a>.*?'
|
|
206
|
+
r'<td[^>]*><p[^>]*>(.*?)</p></td>\s*<td[^>]*><p[^>]*>([^<]*)</p></td>',
|
|
207
|
+
re.S)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_apple(root: pathlib.Path, con: sqlite3.Connection) -> int:
|
|
211
|
+
"""Apple security releases: product/version -> CVE list scraped from advisories."""
|
|
212
|
+
index = root / "index.html"
|
|
213
|
+
if not index.exists():
|
|
214
|
+
return 0
|
|
215
|
+
html = index.read_text(encoding="utf-8", errors="replace")
|
|
216
|
+
rows = []
|
|
217
|
+
for url, title, _devices, date in APPLE_ROWS.findall(html):
|
|
218
|
+
title = re.sub(r"\s+", " ", title).strip()
|
|
219
|
+
page = root / "pages" / (url.rsplit("/", 1)[-1] + ".html")
|
|
220
|
+
cves: list[str] = []
|
|
221
|
+
exploited_map: dict[str, str] = {}
|
|
222
|
+
if page.exists():
|
|
223
|
+
txt = page.read_text(encoding="utf-8", errors="replace")
|
|
224
|
+
cves = sorted({c.upper() for c in CVE_RE.findall(txt)})
|
|
225
|
+
# Apple notes exploitation in each CVE's block, just before its id:
|
|
226
|
+
# attribute "exploited" to a CVE if the phrase is in its preceding segment.
|
|
227
|
+
last = 0
|
|
228
|
+
for m in CVE_RE.finditer(txt):
|
|
229
|
+
seg = txt[last:m.start()]
|
|
230
|
+
if re.search(r"(actively )?(been )?exploited", seg, re.I) and \
|
|
231
|
+
re.search(r"aware|report|exploited in", seg, re.I):
|
|
232
|
+
exploited_map[m.group(0).upper()] = "exploited"
|
|
233
|
+
last = m.start()
|
|
234
|
+
platform = "macos" if title.lower().startswith("macos") else \
|
|
235
|
+
"ios" if any(k in title.lower() for k in ("ios", "ipados")) else "apple"
|
|
236
|
+
version = ""
|
|
237
|
+
m = re.search(r"(\d+(?:\.\d+)*)", title)
|
|
238
|
+
if m:
|
|
239
|
+
version = m.group(1)
|
|
240
|
+
if not cves:
|
|
241
|
+
rows.append(("APPLE:" + url.rsplit("/", 1)[-1], "", platform, title.lower(),
|
|
242
|
+
version, title, "", "", "", date.strip(), url))
|
|
243
|
+
continue
|
|
244
|
+
for c in cves:
|
|
245
|
+
rows.append(("APPLE:" + url.rsplit("/", 1)[-1], c, platform, title.lower(),
|
|
246
|
+
version, title, "", "", exploited_map.get(c, ""), date.strip(), url))
|
|
247
|
+
con.execute("DELETE FROM os_advisory WHERE source='apple'")
|
|
248
|
+
con.executemany(
|
|
249
|
+
"INSERT OR REPLACE INTO os_advisory(source,cve,platform,product,version,title,"
|
|
250
|
+
"severity,impact,exploited,released,url) VALUES ('apple',?,?,?,?,?,?,?,?,?,?)",
|
|
251
|
+
[(r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10]) for r in rows])
|
|
252
|
+
con.commit()
|
|
253
|
+
return len(rows)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------- Metasploit
|
|
257
|
+
NAME_RE = re.compile(r"'Name'\s*=>\s*['\"](.+?)['\"]")
|
|
258
|
+
PLAT_RE = re.compile(r"'Platform'\s*=>\s*(.+)$", re.M)
|
|
259
|
+
PRIV_RE = re.compile(r"'Privileged'\s*=>\s*(true|false)")
|
|
260
|
+
DATE_RE = re.compile(r"'DisclosureDate'\s*=>\s*['\"](.+?)['\"]")
|
|
261
|
+
DESC_RE = re.compile(r"'Description'\s*=>\s*%q\{(.*?)\}\s*,?\s*$", re.S | re.M)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load_msf_source(root: pathlib.Path, con: sqlite3.Connection) -> int:
|
|
265
|
+
"""Parse module sources for metadata + CVE references (fixes cve_msf)."""
|
|
266
|
+
mods = root / "modules"
|
|
267
|
+
if not mods.exists():
|
|
268
|
+
return 0
|
|
269
|
+
rows, pairs = [], []
|
|
270
|
+
for p in mods.rglob("*.rb"):
|
|
271
|
+
rel = p.relative_to(root).as_posix()
|
|
272
|
+
parts = rel.split("/")
|
|
273
|
+
if len(parts) < 3:
|
|
274
|
+
continue
|
|
275
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
276
|
+
name_m = NAME_RE.search(text)
|
|
277
|
+
platform = ""
|
|
278
|
+
pm = PLAT_RE.search(text)
|
|
279
|
+
if pm:
|
|
280
|
+
platform = re.sub(r"[\s%w\[\]'\"]", "", pm.group(1))[:60]
|
|
281
|
+
dm = DESC_RE.search(text)
|
|
282
|
+
date_m = DATE_RE.search(text)
|
|
283
|
+
priv = PRIV_RE.search(text)
|
|
284
|
+
cves = sorted({c.upper() for c in CVE_RE.findall(text)})
|
|
285
|
+
rows.append((rel, parts[1],
|
|
286
|
+
name_m.group(1) if name_m else "/".join(parts[2:]).removesuffix(".rb"),
|
|
287
|
+
platform, 1 if (priv and priv.group(1) == "true") else 0,
|
|
288
|
+
date_m.group(1) if date_m else "",
|
|
289
|
+
_norm(dm.group(1) if dm else "", 1200), ",".join(cves)))
|
|
290
|
+
for c in cves:
|
|
291
|
+
pairs.append((c, rel))
|
|
292
|
+
con.execute("DELETE FROM msf")
|
|
293
|
+
con.executemany("INSERT OR REPLACE INTO msf(path,type,name,platform,privileged,"
|
|
294
|
+
"disclosure_date,description,cves) VALUES (?,?,?,?,?,?,?,?)", rows)
|
|
295
|
+
con.execute("DELETE FROM cve_msf")
|
|
296
|
+
con.executemany("INSERT OR IGNORE INTO cve_msf VALUES (?,?)", pairs)
|
|
297
|
+
con.execute("UPDATE cve SET has_msf=0 WHERE has_msf=1")
|
|
298
|
+
con.execute("UPDATE cve SET has_msf=1 WHERE id IN (SELECT cve_id FROM cve_msf)")
|
|
299
|
+
con.commit()
|
|
300
|
+
return len(rows)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------- Exploit-DB code
|
|
304
|
+
CODE_EXT = {".c", ".cc", ".cpp", ".py", ".rb", ".pl", ".sh", ".php", ".js", ".java",
|
|
305
|
+
".go", ".rs", ".txt", ".md", ".html", ".xml", ".ps1", ".asm", ".s"}
|
|
306
|
+
CODE_CAP = 60_000
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def load_exploit_code(root: pathlib.Path, con: sqlite3.Connection) -> int:
|
|
310
|
+
"""Full Exploit-DB sources -> FTS over the code itself."""
|
|
311
|
+
if not root.exists():
|
|
312
|
+
return 0
|
|
313
|
+
con.execute("DELETE FROM exploit_code_fts")
|
|
314
|
+
n = 0
|
|
315
|
+
batch = []
|
|
316
|
+
for p in root.rglob("*"):
|
|
317
|
+
if not p.is_file() or p.suffix.lower() not in CODE_EXT:
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
if p.stat().st_size > 400_000:
|
|
321
|
+
continue
|
|
322
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
323
|
+
except OSError:
|
|
324
|
+
continue
|
|
325
|
+
rel = p.relative_to(root).as_posix()
|
|
326
|
+
eid = p.stem if p.stem.isdigit() else ""
|
|
327
|
+
batch.append((eid, rel, text[:CODE_CAP]))
|
|
328
|
+
n += 1
|
|
329
|
+
if len(batch) >= 500:
|
|
330
|
+
con.executemany("INSERT INTO exploit_code_fts(exploit_id,path,content) VALUES (?,?,?)", batch)
|
|
331
|
+
batch = []
|
|
332
|
+
if batch:
|
|
333
|
+
con.executemany("INSERT INTO exploit_code_fts(exploit_id,path,content) VALUES (?,?,?)", batch)
|
|
334
|
+
con.commit()
|
|
335
|
+
return n
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------- toolkits
|
|
339
|
+
def load_toolkit(root: pathlib.Path, source: str, con: sqlite3.Connection) -> int:
|
|
340
|
+
if not root.exists():
|
|
341
|
+
return 0
|
|
342
|
+
if source == "gtfobins":
|
|
343
|
+
# Jekyll collection: files have no extension (e.g. _gtfobins/awk)
|
|
344
|
+
files = [p for p in (root / "_gtfobins").rglob("*") if p.is_file()]
|
|
345
|
+
else:
|
|
346
|
+
files = (list(root.rglob("*.md")) + list(root.rglob("*.yml"))
|
|
347
|
+
+ list(root.rglob("*.yaml")))
|
|
348
|
+
rows = []
|
|
349
|
+
for p in sorted(files)[:8000]:
|
|
350
|
+
if ".git" in p.parts:
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
354
|
+
except OSError:
|
|
355
|
+
continue
|
|
356
|
+
name = p.stem
|
|
357
|
+
rel = p.relative_to(root).as_posix()
|
|
358
|
+
# frontmatter: functions/tags block, else the first heading or description
|
|
359
|
+
tags = ""
|
|
360
|
+
m = re.search(r"^functions:\s*\n((?:[ \t]+\w+:.*\n?)+)", text, re.M)
|
|
361
|
+
if m:
|
|
362
|
+
tags = ",".join(sorted(set(re.findall(r"^[ \t]{2}(\w+):", m.group(1), re.M))))
|
|
363
|
+
if not tags:
|
|
364
|
+
m = re.search(r"^(?:functions|tags):\s*(.+)$", text, re.M)
|
|
365
|
+
if m:
|
|
366
|
+
tags = _norm(m.group(1), 200)
|
|
367
|
+
desc = ""
|
|
368
|
+
dm = re.search(r"^description:\s*(.+)$", text, re.M)
|
|
369
|
+
if dm:
|
|
370
|
+
desc = _norm(dm.group(1), 400)
|
|
371
|
+
body = re.sub(r"^---.*?---", "", text, flags=re.S)
|
|
372
|
+
if not desc:
|
|
373
|
+
heading = re.search(r"^#\s+(.+)$", body, re.M)
|
|
374
|
+
desc = _norm(heading.group(1) if heading else body, 400)
|
|
375
|
+
rows.append((source, name, rel, desc, tags))
|
|
376
|
+
con.execute("DELETE FROM toolkit WHERE source=?", (source,))
|
|
377
|
+
con.executemany("INSERT INTO toolkit(source,name,path,description,tags) VALUES (?,?,?,?,?)", rows)
|
|
378
|
+
con.execute("DELETE FROM toolkit_fts WHERE source=?", (source,))
|
|
379
|
+
con.executemany("INSERT INTO toolkit_fts(source,name,description,tags) VALUES (?,?,?,?)",
|
|
380
|
+
[(r[0], r[1], r[3], r[4]) for r in rows])
|
|
381
|
+
con.commit()
|
|
382
|
+
return len(rows)
|