primarysource 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.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,82 @@
1
+ """The two cursors of PLAN.md §5.11 as the plain collections use them.
2
+
3
+ - A **list** (search, outline, browse, grep, docs) takes a 0-based `start` and prints `next: start=<n>`
4
+ (`render.next_start`), exactly the value to pass back. `window` cuts the page and makes that value.
5
+ - A **read** prints `next: <ref>`, a ref string that `read` accepts back: the scope that was read with `¶<n>`
6
+ naming the first unit of the next page. So a `¶n` read starts at unit n and continues, within `max_tokens`,
7
+ to the end of the scope the ref names; one unit alone is what `cite` gives. `read_next` makes that ref.
8
+
9
+ A whole document is a scope too, and its units are counted the same way, so `doc¶n` continues a document read.
10
+ The core grammar refuses `¶` without a section (`refs.RefGrammar`), so `split_doc_para` takes it off first.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from collections.abc import Sequence
17
+
18
+ from primarysource.budget import Page
19
+ from primarysource.errors import LibError
20
+ from primarysource.render import next_start
21
+
22
+ _DOC_PARA = re.compile(r"(?P<head>[^§#¶]+?)\s*¶\s*(?P<para>\d+)\s*")
23
+ _TRAILING_PARA = re.compile(r"(?P<head>[^¶]+?)\s*¶\s*(?P<para>\d+)\s*")
24
+
25
+
26
+ def window[T](rows: Sequence[T], start: int, limit: int) -> tuple[Sequence[T], str]:
27
+ """Rows `start .. start+limit` (0-based) and the `next:` value: `start=<n>`, or "" on the last page."""
28
+ if start < 0:
29
+ raise LibError(f"start is a 0-based offset, not {start}", "pass the start= value of the last next: line")
30
+ if limit < 1:
31
+ raise LibError(f"limit must be 1 or more, not {limit}", "")
32
+ end = start + limit
33
+ return rows[start:end], next_start(end if end < len(rows) else None)
34
+
35
+
36
+ def split_doc_para(text: str) -> tuple[str, int | None]:
37
+ """`doc¶n` as (`doc`, n); any other text as (text, None), for the collection's own parser to read."""
38
+ if "§" in text or "::" in text or "#" in text:
39
+ return text, None
40
+ match = _DOC_PARA.fullmatch(text)
41
+ if match is None:
42
+ return text, None
43
+ para = int(match["para"])
44
+ if para < 1:
45
+ raise LibError(f"not a ref: {text!r}", "¶ is 1-based")
46
+ return match["head"].strip(), para
47
+
48
+
49
+ def split_para(text: str) -> tuple[str, int | None]:
50
+ """A trailing `¶n` as (the rest, n), for a collection whose own ref grammar has no `¶` (`usbids:1d6b¶40`,
51
+ `nist:cavp/...#[P-256]¶3`); any other text as (text, None)."""
52
+ match = _TRAILING_PARA.fullmatch(text)
53
+ if match is None:
54
+ return text, None
55
+ para = int(match["para"])
56
+ if para < 1:
57
+ raise LibError(f"not a ref: {text!r}", "¶ is 1-based")
58
+ return match["head"].strip(), para
59
+
60
+
61
+ def first_unit(para: int | None, total: int, what: str, ref: str) -> int:
62
+ """The 1-based unit a read starts at: `para`, or 1. A `para` past the end is an error naming the range."""
63
+ if para is None:
64
+ return 1
65
+ if para > total:
66
+ raise LibError(f"{ref}: the span has {total} {what}", f"use ¶1 to ¶{total}" if total else "")
67
+ return para
68
+
69
+
70
+ def read_next(scope: str, page: Page) -> str:
71
+ """The `next:` value of a read: `<scope>¶<n>` where the next page starts, or "" when the scope is done."""
72
+ return f"{scope}¶{page.next_start}" if page.next_start is not None else ""
73
+
74
+
75
+ def para_label(para: int | None, page: Page) -> int | str | None:
76
+ """What a read's citation names: None for a whole scope read from its start, `¶n` for one unit, `¶a-b` for a
77
+ page that starts inside the scope or holds several units of a `¶` read."""
78
+ if para is None and (page.first <= 1 or not page.units):
79
+ return None
80
+ if page.first == page.last:
81
+ return page.first
82
+ return f"{page.first}-{page.last}"
@@ -0,0 +1,5 @@
1
+ """The `cve` collection (moved in from cvelib): structured vulnerability data, local and citable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,144 @@
1
+ """`primarysource cve ...`: the same answers as the MCP tools, as the same text; `--json` prints `service`'s dict.
2
+
3
+ A failure prints the `error:`, `hint:` and `action:` lines of a `LibError` on stderr and exits 2 (PLAN.md §5.10)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ from collections.abc import Callable, Sequence
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from primarysource.errors import LibError
15
+
16
+ from . import __version__, config, service, views
17
+ from .sync import sync as run_sync
18
+
19
+
20
+ def _print_json(payload: object) -> None:
21
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="primarysource cve", description="Local vulnerability corpus and MCP server."
27
+ )
28
+ parser.add_argument("--version", action="version", version=f"primarysource cve {__version__}")
29
+ parser.add_argument("--json", action="store_true", help="print the raw JSON answer")
30
+ commands = parser.add_subparsers(dest="command", required=True)
31
+
32
+ commands.add_parser("path", help="show CVELIB_HOME and the index path")
33
+
34
+ info = commands.add_parser("info", help="corpus status, or a card for one CVE")
35
+ info.add_argument("ref", nargs="?", default="")
36
+
37
+ search = commands.add_parser("search", help="full-text search")
38
+ search.add_argument("query", nargs="+")
39
+ search.add_argument("--limit", type=int, default=8)
40
+ search.add_argument("--start", type=int, default=0)
41
+ search.add_argument("--kev", action="store_true")
42
+ search.add_argument("--kernel", action="store_true")
43
+ search.add_argument("--year", type=int, default=0)
44
+
45
+ read = commands.add_parser("read", help="one CVE, merged across sources (CVE-Y-N or CVE-Y-N§section)")
46
+ read.add_argument("ref")
47
+ read.add_argument("--max-tokens", type=int, default=2000)
48
+
49
+ kev = commands.add_parser("kev", help="CISA KEV status for a CVE, or the catalog newest first")
50
+ kev.add_argument("ref", nargs="?", default="")
51
+ kev.add_argument("--vendor", default="")
52
+ kev.add_argument("--since", default="")
53
+ kev.add_argument("--limit", type=int, default=20)
54
+ kev.add_argument("--start", type=int, default=0)
55
+
56
+ kernel = commands.add_parser("kernel", help="does this CVE affect this kernel version")
57
+ kernel.add_argument("ref")
58
+ kernel.add_argument("version")
59
+
60
+ kernel_list = commands.add_parser("kernel-list", help="every kernel CNA CVE that affects a version")
61
+ kernel_list.add_argument("version")
62
+ kernel_list.add_argument("--kev", action="store_true")
63
+ kernel_list.add_argument("--path", default="")
64
+ kernel_list.add_argument("--limit", type=int, default=25)
65
+ kernel_list.add_argument("--start", type=int, default=0)
66
+
67
+ sync = commands.add_parser("sync", help="fetch the sources and update the index")
68
+ sync.add_argument(
69
+ "--source", default=",".join(config.SOURCES), help="comma list of: " + ", ".join(config.SOURCES)
70
+ )
71
+ sync.add_argument("--kernel-tree", type=Path, help="ingest an existing vulns.git checkout instead of fetching")
72
+ sync.add_argument("--cvelist-zip", type=Path, help="ingest a local CVE List release zip instead of fetching")
73
+ sync.add_argument("--force", action="store_true", help="ignore the free-space check and any ETag")
74
+ sync.add_argument("--full", action="store_true", help="take the CVE List baseline even if a delta would do")
75
+
76
+ commands.add_parser("mcp", help="run the MCP server on stdio")
77
+ return parser
78
+
79
+
80
+ def _show(args: argparse.Namespace, payload: dict[str, Any], view: Callable[[dict[str, Any]], str]) -> None:
81
+ if args.json:
82
+ _print_json(payload)
83
+ else:
84
+ print(view(payload), end="")
85
+
86
+
87
+ def _dispatch(args: argparse.Namespace) -> int:
88
+ command = args.command
89
+ if command == "path":
90
+ if args.json:
91
+ _print_json({"home": str(config.home()), "index": str(config.index_path()), "env": "CVELIB_HOME"})
92
+ else:
93
+ print(views.path(str(config.home()), str(config.index_path())), end="")
94
+ elif command == "info":
95
+ _show(args, service.info(args.ref), views.info)
96
+ elif command == "search":
97
+ payload = service.search(" ".join(args.query), args.limit, args.start, args.kev, args.kernel, args.year)
98
+ _show(args, payload, views.search)
99
+ elif command == "read":
100
+ _show(args, service.read(args.ref, args.max_tokens), views.read)
101
+ elif command == "kev":
102
+ _show(args, service.kev(args.ref, args.vendor, args.since, args.limit, args.start), views.kev)
103
+ elif command == "kernel":
104
+ _show(args, service.kernel_check(args.ref, args.version), views.kernel_check)
105
+ elif command == "kernel-list":
106
+ payload = service.kernel_list(args.version, args.kev, args.path, args.limit, args.start)
107
+ _show(args, payload, views.kernel_list)
108
+ elif command == "sync":
109
+ results = run_sync(
110
+ [item.strip() for item in args.source.split(",") if item.strip()],
111
+ args.kernel_tree,
112
+ args.cvelist_zip,
113
+ args.force,
114
+ args.full,
115
+ )
116
+ for name, outcome in results.items():
117
+ print(f"{name}: {outcome}")
118
+ return 1 if any(value.startswith("FAILED") for value in results.values()) else 0
119
+ return 0
120
+
121
+
122
+ def main(argv: Sequence[str] | None = None) -> int:
123
+ # Record text holds characters a Windows console's code page has no room for; a command whose job is to print
124
+ # it must not die on one.
125
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
126
+ args = _parser().parse_args(argv)
127
+ if args.command == "mcp":
128
+ from .mcp import main as serve
129
+
130
+ try:
131
+ serve()
132
+ except ImportError as error:
133
+ print(error, file=sys.stderr)
134
+ return 2
135
+ return 0
136
+ try:
137
+ return _dispatch(args)
138
+ except LibError as error:
139
+ print(error.as_text(), file=sys.stderr)
140
+ return 2
141
+
142
+
143
+ if __name__ == "__main__":
144
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ """The CVE collection as primarysource sees it. Importing this needs nothing beyond the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.model import Unit
6
+ from primarysource.refs import RefGrammar
7
+ from primarysource.registry import Collection
8
+
9
+ # cvelib's refs: CVE-2024-14027, CVE-2024-14027§references, CVE-2024-14027§references¶21. The section is one of a
10
+ # fixed set of lower-case names (service.SECTIONS); ¶n is the nth item of a list section (reference, affected
11
+ # entry, kernel range), not a paragraph of prose. The tools already carry the cve_ prefix.
12
+ COLLECTION = Collection(
13
+ name="cve",
14
+ title="CVE records merged with the Linux kernel CNA, CISA KEV and FIRST EPSS, synced by the user",
15
+ prefix="cve_",
16
+ unit=Unit.BLOCK,
17
+ grammar=RefGrammar(r"CVE-\d{4}-\d{4,}", r"[a-z]+"),
18
+ server="primarysource.cve.mcp:build",
19
+ cli="primarysource.cve.cli:main",
20
+ )
@@ -0,0 +1,58 @@
1
+ """Where the corpus lives and where it comes from.
2
+
3
+ The corpus never lives in the working directory: MCP clients start servers from wherever they like, so a
4
+ cwd-relative home would point every session at a different, empty library.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from primarysource.home import collection_home
13
+
14
+ from . import __version__
15
+
16
+ USER_AGENT = f"primarysource-cve/{__version__} (local vulnerability corpus sync)"
17
+
18
+ KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
19
+ EPSS_URL = "https://epss.empiricalsecurity.com/epss_scores-current.csv.gz"
20
+ KERNEL_REPO_URL = "https://git.kernel.org/pub/scm/linux/security/vulns.git"
21
+ CVELIST_RELEASE_API = "https://api.github.com/repos/CVEProject/cvelistV5/releases/latest"
22
+ CVELIST_DELTA_LOG = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/deltaLog.json"
23
+
24
+ KERNEL_ORG_ID = "f4215fc3-5b6b-47ff-a258-f7189bd81038"
25
+ CISA_ADP_SHORT_NAME = "CISA-ADP"
26
+
27
+ SOURCES = ("cvelist", "kernel", "kev", "epss")
28
+
29
+ # Age, in days, past which `info` and every tool call a source stale. Chosen per source's own cadence.
30
+ STALE_DAYS = {"cvelist": 3, "kernel": 7, "kev": 7, "epss": 3}
31
+
32
+
33
+ def home() -> Path:
34
+ """Where the corpus lives (PLAN.md §5.8). The first that applies: `$CVELIB_HOME` (an explicit setting from
35
+ before the move keeps working), `$PRIMARYSOURCE_CVE_HOME`, `$PRIMARYSOURCE_HOME/cve`, an existing `~/.cvelib`,
36
+ then the per-user `primarysource/cve`. Point one at a large disk: the corpus is a few GB. Nothing is created."""
37
+ explicit = any(os.environ.get(name) for name in ("CVELIB_HOME", "PRIMARYSOURCE_CVE_HOME", "PRIMARYSOURCE_HOME"))
38
+ legacy = Path.home() / ".cvelib"
39
+ if not explicit and legacy.is_dir():
40
+ return legacy
41
+ return collection_home("cve", "cvelib").expanduser()
42
+
43
+
44
+ def index_path() -> Path:
45
+ return home() / "index.sqlite"
46
+
47
+
48
+ def raw_dir() -> Path:
49
+ return home() / "raw"
50
+
51
+
52
+ def kernel_repo_dir() -> Path:
53
+ return home() / "repos" / "kernel-vulns"
54
+
55
+
56
+ def min_free_bytes() -> int:
57
+ """Free space a sync must leave behind. CVELIB_MIN_FREE_GB overrides the 2 GB default."""
58
+ return int(float(os.environ.get("CVELIB_MIN_FREE_GB") or "2") * 1024**3)
@@ -0,0 +1,16 @@
1
+ """The one exception type for every state with a known remedy: a `LibError` (primarysource/errors.py).
2
+
3
+ A tool that answers "no results" when the real state is "the corpus was never synced" sends an agent to conclude a
4
+ CVE does not exist. Each such state raises `CvelibError` with the command that fixes it in `action` (what a human
5
+ must run, e.g. `primarysource cve sync --source kev`) and the call to make next in `hint`. The CLI prints its
6
+ `error:`, `hint:` and `action:` lines with exit status 2; the MCP server returns the same lines as an `isError`
7
+ result (PLAN.md §5.10).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from primarysource.errors import LibError
13
+
14
+
15
+ class CvelibError(LibError):
16
+ """A `LibError` raised by the cve collection; the name is kept so the code that raises it reads as it did."""
@@ -0,0 +1,232 @@
1
+ """Does a kernel CVE apply to one version? Read from the kernel CNA's `.dyad` files.
2
+
3
+ A `.dyad` line is `vulnerable:git:fixed:git`, one per place the bug was introduced and (maybe) fixed:
4
+
5
+ 6.6.51:c031...:6.6.133:9a3a... bug arrived in 6.6.51, fixed in 6.6.133 on that stable branch
6
+ 6.11:c3a5...:6.13:a718... bug arrived in mainline 6.11, fixed in mainline 6.13
7
+ 6.10.10:8d58...:0:0 arrived in 6.10.10, no fix on that branch
8
+
9
+ The trap is the last form. A `0` fix means unfixed *on that start version's own branch*, which is often a stable
10
+ branch that died long ago (`5.14.15:...:0:0`). Counting it as "unfixed everywhere" flags every later kernel. The rule
11
+ here is `kconfigscope/tools/cve_reach.py` plus one correction: a bug whose introduction on the version's own branch
12
+ comes *after* the last fix there (`6.18.51:...:0:0` beside a fix at 6.18.50) is still present.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from .errors import CvelibError
20
+
21
+ Version = tuple[int, ...]
22
+
23
+ ZERO: Version = (0,)
24
+ DEFAULT_RC_NOTE = "A release candidate is treated as the release it leads to."
25
+
26
+
27
+ def parse_version(text: str) -> Version:
28
+ """`6.18.62`, `v6.18`, `4.4-rc1`, `6.18.20-1-lts`: the numeric dotted head of a kernel version string."""
29
+ head = text.strip().lower().removeprefix("v").split("-", 1)[0].split("+", 1)[0]
30
+ parts = head.split(".")
31
+ if not head or not all(part.isdigit() for part in parts):
32
+ raise CvelibError(f"not a kernel version: {text!r}", "Give a dotted version such as 6.18.62 or 6.12.")
33
+ numbers = [int(part) for part in parts]
34
+ # 6.18.0 and 6.18 are the same release; keeping one spelling makes tuple comparison correct.
35
+ while len(numbers) > 1 and numbers[-1] == 0:
36
+ numbers.pop()
37
+ return tuple(numbers)
38
+
39
+
40
+ def version_caveats(text: str) -> list[str]:
41
+ """What the caller's version string implies that this data cannot speak to."""
42
+ notes: list[str] = []
43
+ suffix = text.strip().lower().removeprefix("v").split("-", 1)
44
+ if len(suffix) == 2 and suffix[1].startswith("rc"):
45
+ notes.append(DEFAULT_RC_NOTE)
46
+ elif len(suffix) == 2 and suffix[1]:
47
+ notes.append(
48
+ f"The suffix '-{suffix[1]}' looks like a distribution or local build. kernel.org data knows mainline "
49
+ "and stable releases, not distribution backports: check the distribution's own tracker too."
50
+ )
51
+ return notes
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Pair:
56
+ """One dyad line. `start` of `(0,)` means the introducing commit is unknown; `fix` of `(0,)` means no fix."""
57
+
58
+ start: Version
59
+ start_git: str
60
+ fix: Version
61
+ fix_git: str
62
+
63
+ @property
64
+ def text(self) -> str:
65
+ return f"{_show(self.start)} -> {_show(self.fix)}"
66
+
67
+
68
+ def _show(version: Version) -> str:
69
+ return "0" if version == ZERO else ".".join(str(part) for part in version)
70
+
71
+
72
+ def parse_dyad(text: str) -> list[Pair]:
73
+ """Every `vulnerable:git:fixed:git` line. Comments and malformed lines are skipped, not guessed at."""
74
+ pairs: list[Pair] = []
75
+ for line in text.splitlines():
76
+ line = line.strip()
77
+ if not line or line.startswith("#") or line.count(":") != 3:
78
+ continue
79
+ start, start_git, fix, fix_git = line.split(":")
80
+ try:
81
+ pairs.append(Pair(parse_version(start), start_git, parse_version(fix), fix_git))
82
+ except CvelibError:
83
+ continue
84
+ return pairs
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Verdict:
89
+ affected: bool
90
+ reason: str
91
+ detail: str
92
+ evidence: tuple[str, ...]
93
+
94
+
95
+ def _is_mainline(version: Version) -> bool:
96
+ return len(version) <= 2
97
+
98
+
99
+ def explain(pairs: list[Pair], version: Version) -> Verdict:
100
+ """Whether the bug is present in `version`, preferring the fix on that version's own stable branch."""
101
+ branch = version[:2]
102
+
103
+ # A range opened on this branch and still open here is decisive: it survives an earlier fix on the branch.
104
+ # A `0` entry is weaker than a stated fix: dyad emits one for each introducing commit it could not pair, so a fix
105
+ # on this branch at or after that entry's start closes it. Only a start *after* every such fix reopens the bug.
106
+ branch_fixes = [pair.fix for pair in pairs if pair.fix != ZERO and pair.fix[:2] == branch and pair.fix <= version]
107
+ for pair in pairs:
108
+ same_branch_start = pair.start != ZERO and pair.start[:2] == branch and not _is_mainline(pair.start)
109
+ if not same_branch_start or pair.start > version:
110
+ continue
111
+ if pair.fix == ZERO:
112
+ if not any(fix >= pair.start for fix in branch_fixes):
113
+ detail = f"no fix on {_show(branch)}.x since {_show(pair.start)}"
114
+ return Verdict(True, "unfixed_on_branch", detail, (pair.text,))
115
+ elif pair.fix[:2] == branch and pair.fix > version:
116
+ return Verdict(True, "open_range", f"fixed on this branch only in {_show(pair.fix)}", (pair.text,))
117
+
118
+ # Then the fix on this branch, which is what decides almost every longterm kernel.
119
+ fixes_here = [pair for pair in pairs if pair.fix != ZERO and pair.fix[:2] == branch and pair.fix <= version]
120
+ if fixes_here:
121
+ best = max(fixes_here, key=lambda pair: pair.fix)
122
+ return Verdict(False, "fixed_on_branch", f"fixed on this branch in {_show(best.fix)}", (best.text,))
123
+
124
+ dead_zero: list[Pair] = []
125
+ open_pairs: list[Pair] = []
126
+ for pair in pairs:
127
+ if pair.start == ZERO and pair.fix == ZERO:
128
+ continue # says nothing at all
129
+ if pair.start > version:
130
+ continue
131
+ if pair.fix == ZERO:
132
+ if _is_mainline(pair.start) or pair.start[:2] == branch:
133
+ open_pairs.append(pair)
134
+ else:
135
+ dead_zero.append(pair)
136
+ elif pair.fix > version:
137
+ open_pairs.append(pair)
138
+
139
+ if open_pairs:
140
+ pair = open_pairs[0]
141
+ if pair.fix == ZERO:
142
+ detail = f"unfixed from mainline {_show(pair.start)}"
143
+ elif pair.start == ZERO:
144
+ detail = f"introduction unknown; first fix past this version is {_show(pair.fix)}"
145
+ else:
146
+ detail = (
147
+ f"present since {_show(pair.start)}, no fix on {_show(branch)}.x, fixed later in {_show(pair.fix)}"
148
+ )
149
+ return Verdict(True, "open_range", detail, tuple(item.text for item in open_pairs[:4]))
150
+
151
+ if dead_zero:
152
+ return Verdict(
153
+ False,
154
+ "dead_branch_zero_ignored",
155
+ "the only open entries are '0' fixes on other stable branches, which say nothing about this one",
156
+ tuple(pair.text for pair in dead_zero[:4]),
157
+ )
158
+ if pairs and all(pair.start > version for pair in pairs if pair.start != ZERO):
159
+ return Verdict(False, "introduced_later", "every recorded introduction is newer than this version", ())
160
+ return Verdict(False, "fixed_before_version", "every recorded range closes at or before this version", ())
161
+
162
+
163
+ def affects(pairs: list[Pair], version: Version) -> bool:
164
+ return explain(pairs, version).affected
165
+
166
+
167
+ def fixed_in_by_branch(pairs: list[Pair]) -> dict[str, str]:
168
+ """`{"6.6": "6.6.133", "6.12": "6.12.77", "6.13": "6.13"}`, the earliest fix recorded on each branch."""
169
+ best: dict[Version, Version] = {}
170
+ for pair in pairs:
171
+ if pair.fix == ZERO:
172
+ continue
173
+ branch = pair.fix[:2]
174
+ if branch not in best or pair.fix < best[branch]:
175
+ best[branch] = pair.fix
176
+ return {_show(branch): _show(best[branch]) for branch in sorted(best)}
177
+
178
+
179
+ def unfixed_starts(pairs: list[Pair]) -> list[str]:
180
+ return sorted({_show(pair.start) for pair in pairs if pair.fix == ZERO and pair.start != ZERO})
181
+
182
+
183
+ WILDCARD_VERSIONS = {"", "*", "0", "n/a", "unspecified", "unknown", "-"}
184
+ KERNEL_PRODUCTS = {"linux", "kernel", "linux kernel", "linux_kernel"}
185
+
186
+
187
+ def is_kernel_product(entry: dict[str, object]) -> bool:
188
+ vendor = str(entry.get("vendor") or "").lower()
189
+ product = str(entry.get("product") or "").lower()
190
+ # Real spellings outside the kernel CNA include ("n/a", "Kernel") and ("Linux", "Linux Kernel").
191
+ anonymous = vendor in {"", "n/a"}
192
+ if product in {"linux kernel", "linux_kernel"}:
193
+ return True
194
+ if product == "kernel":
195
+ return "linux" in vendor or anonymous
196
+ return product == "linux" and "linux" in vendor
197
+
198
+
199
+ def fallback_ranges(affected: list[dict[str, object]], version: Version) -> tuple[list[str], int]:
200
+ """Evaluate a non-kernel-CNA record's ranges for `version`.
201
+
202
+ Returns `(matches, usable)`. A record with no upper bound and a wildcard start (the usual NVD and early MITRE
203
+ kernel record) carries no usable range: such a record is noise, and it contributes nothing here.
204
+ """
205
+ matches: list[str] = []
206
+ usable = 0
207
+ for entry in affected:
208
+ if not is_kernel_product(entry):
209
+ continue
210
+ for item in entry.get("versions") or []: # type: ignore[attr-defined]
211
+ if not isinstance(item, dict) or item.get("status") != "affected":
212
+ continue
213
+ start_text = str(item.get("version") or "").strip().lower()
214
+ upper = str(item.get("lessThan") or item.get("lessThanOrEqual") or "").strip()
215
+ inclusive = bool(item.get("lessThanOrEqual"))
216
+ if upper in {"", "*"} or upper.endswith(".*"):
217
+ upper = ""
218
+ if start_text in WILDCARD_VERSIONS and not upper:
219
+ continue
220
+ try:
221
+ start = parse_version(start_text) if start_text not in WILDCARD_VERSIONS else ZERO
222
+ end = parse_version(upper) if upper else None
223
+ except CvelibError:
224
+ continue
225
+ usable += 1
226
+ if end is None:
227
+ if start == version:
228
+ matches.append(f"{start_text}")
229
+ continue
230
+ if start <= version and (version < end or (inclusive and version == end)):
231
+ matches.append(f"{start_text or '0'} -> {upper}")
232
+ return matches, usable