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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""The query layer behind both the CLI and the MCP server. Everything returns plain JSON-able dicts.
|
|
2
|
+
|
|
3
|
+
The merge rules live in `read`: description and affected data from the kernel CNA for kernel CVEs and from the CVE
|
|
4
|
+
List otherwise; CVSS listed per source with one preferred; KEV only from CISA's catalog; EPSS only from FIRST. Every
|
|
5
|
+
answer names its sources, and states what could not be consulted, because "no KEV entry" with an unsynced catalog is
|
|
6
|
+
a different fact from "not in KEV".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import config, kernel, store
|
|
19
|
+
from .errors import CvelibError
|
|
20
|
+
from .kernel import Pair
|
|
21
|
+
from .parse import normalize_cve
|
|
22
|
+
|
|
23
|
+
SECTIONS = ("summary", "references", "affected", "kernel", "cvss", "sources")
|
|
24
|
+
LIMIT_MAX = 200
|
|
25
|
+
TOKENS_MAX = 8000
|
|
26
|
+
_REF = re.compile(r"^(CVE-\d{4}-\d{4,})(?:§([a-z]+)(?:¶(\d+))?)?$", re.IGNORECASE)
|
|
27
|
+
VERSION_ORDER = {"4.0": 4, "3.1": 3, "3.0": 2, "2.0": 1}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _tokens(payload: object) -> int:
|
|
31
|
+
return len(json.dumps(payload, ensure_ascii=False)) // 4
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clamp(value: int, low: int, high: int) -> int:
|
|
35
|
+
return max(low, min(value, high))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _age_days(when: str | None) -> float | None:
|
|
39
|
+
if not when:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
parsed = datetime.fromisoformat(when.replace("Z", "+00:00"))
|
|
43
|
+
except ValueError:
|
|
44
|
+
return None
|
|
45
|
+
if parsed.tzinfo is None:
|
|
46
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
47
|
+
return (datetime.now(UTC) - parsed).total_seconds() / 86400
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sources(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
|
51
|
+
return {row["name"]: dict(row) for row in conn.execute("SELECT * FROM source")}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _coverage(sources: Mapping[str, dict[str, Any]]) -> list[str]:
|
|
55
|
+
"""One line per source that is not fully usable. Empty means every source is synced and current."""
|
|
56
|
+
gaps: list[str] = []
|
|
57
|
+
for name in config.SOURCES:
|
|
58
|
+
row = sources.get(name)
|
|
59
|
+
if row is None:
|
|
60
|
+
gaps.append(f"{name}: not synced (run: primarysource cve sync --source {name})")
|
|
61
|
+
elif row["state"] != "ok":
|
|
62
|
+
gaps.append(
|
|
63
|
+
f"{name}: {row['state']} at {row['synced_at']}: {row['detail']} "
|
|
64
|
+
f"(run: primarysource cve sync --source {name})"
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
age = _age_days(row["synced_at"])
|
|
68
|
+
if age is not None and age > config.STALE_DAYS[name]:
|
|
69
|
+
gaps.append(
|
|
70
|
+
f"{name}: stale, last synced {age:.0f} days ago (run: primarysource cve sync --source {name})"
|
|
71
|
+
)
|
|
72
|
+
return gaps
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _label(name: str, row: Mapping[str, Any] | None) -> str:
|
|
76
|
+
if row is None:
|
|
77
|
+
return name
|
|
78
|
+
version = str(row["version"] or "")
|
|
79
|
+
if name == "kernel" and version:
|
|
80
|
+
return f"kernel CNA vulns.git {version[:12]}"
|
|
81
|
+
if name == "cvelist":
|
|
82
|
+
return f"CVE List {version}".strip()
|
|
83
|
+
if name == "kev":
|
|
84
|
+
return f"CISA KEV {version}".strip()
|
|
85
|
+
if name == "epss":
|
|
86
|
+
return f"FIRST EPSS {str(row['upstream_date'] or '')[:10]}".strip()
|
|
87
|
+
return name
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _with_gaps(payload: dict[str, Any], sources: Mapping[str, dict[str, Any]]) -> dict[str, Any]:
|
|
91
|
+
gaps = _coverage(sources)
|
|
92
|
+
if gaps:
|
|
93
|
+
payload["partial"] = gaps
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _records(conn: sqlite3.Connection, cve: str) -> dict[str, dict[str, Any]]:
|
|
98
|
+
return {row["source"]: dict(row) for row in conn.execute("SELECT * FROM record WHERE cve=?", (cve,))}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _pairs(conn: sqlite3.Connection, cve: str) -> list[Pair]:
|
|
102
|
+
rows = conn.execute("SELECT start, start_git, fix, fix_git FROM kernel_pair WHERE cve=?", (cve,)).fetchall()
|
|
103
|
+
return [
|
|
104
|
+
Pair(kernel.parse_version(row["start"]), row["start_git"], kernel.parse_version(row["fix"]), row["fix_git"])
|
|
105
|
+
for row in rows
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _not_found(cve: str, sources: Mapping[str, dict[str, Any]]) -> CvelibError:
|
|
110
|
+
gaps = _coverage(sources)
|
|
111
|
+
hint = (
|
|
112
|
+
"It may be reserved or newer than the last sync."
|
|
113
|
+
if not gaps
|
|
114
|
+
else "This corpus is incomplete: " + "; ".join(gaps)
|
|
115
|
+
)
|
|
116
|
+
return CvelibError(f"{cve} is in none of the synced sources", hint)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _preferred_metric(metrics: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
120
|
+
"""A CNA's own score first, then CISA's ADP, then anything else; within those, the newest CVSS version."""
|
|
121
|
+
|
|
122
|
+
def weight(metric: dict[str, Any]) -> tuple[int, int]:
|
|
123
|
+
label = str(metric["label"])
|
|
124
|
+
origin = 2 if label.startswith("CNA") else 1 if "CISA" in label else 0
|
|
125
|
+
return origin, VERSION_ORDER.get(str(metric["version"]), 0)
|
|
126
|
+
|
|
127
|
+
scored = [m for m in metrics if m["score"] is not None]
|
|
128
|
+
return max(scored, key=weight) if scored else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _metrics(conn: sqlite3.Connection, cve: str) -> list[dict[str, Any]]:
|
|
132
|
+
rows = conn.execute("SELECT * FROM metric WHERE cve=? ORDER BY source", (cve,)).fetchall()
|
|
133
|
+
seen: set[tuple[str, str]] = set()
|
|
134
|
+
unique: list[dict[str, Any]] = []
|
|
135
|
+
for row in rows:
|
|
136
|
+
key = (row["label"], row["vector"])
|
|
137
|
+
if key in seen:
|
|
138
|
+
continue
|
|
139
|
+
seen.add(key)
|
|
140
|
+
unique.append({k: row[k] for k in ("label", "version", "vector", "score", "severity")})
|
|
141
|
+
return unique
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _kev_block(conn: sqlite3.Connection, cve: str, sources: Mapping[str, dict[str, Any]]) -> dict[str, Any]:
|
|
145
|
+
row = sources.get("kev")
|
|
146
|
+
if row is None or row["state"] not in ("ok", "syncing"):
|
|
147
|
+
return {"status": "unknown", "why": "the CISA KEV catalog is not synced"}
|
|
148
|
+
entry = conn.execute("SELECT * FROM kev WHERE cve=?", (cve,)).fetchone()
|
|
149
|
+
label = _label("kev", row)
|
|
150
|
+
if entry is None:
|
|
151
|
+
return {"status": "not_listed", "source": label}
|
|
152
|
+
return {
|
|
153
|
+
"status": "listed",
|
|
154
|
+
"date_added": entry["date_added"],
|
|
155
|
+
"due_date": entry["due_date"],
|
|
156
|
+
"ransomware": entry["ransomware"],
|
|
157
|
+
"action": entry["action"],
|
|
158
|
+
"forensic_triage": entry["forensic_triage"],
|
|
159
|
+
"source": label,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _epss_block(conn: sqlite3.Connection, cve: str, sources: Mapping[str, dict[str, Any]]) -> dict[str, Any] | None:
|
|
164
|
+
row = sources.get("epss")
|
|
165
|
+
if row is None:
|
|
166
|
+
return None
|
|
167
|
+
entry = conn.execute("SELECT score, percentile FROM epss WHERE cve=?", (cve,)).fetchone()
|
|
168
|
+
if entry is None:
|
|
169
|
+
return None
|
|
170
|
+
return {"score": entry["score"], "percentile": entry["percentile"], "source": _label("epss", row)}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _kernel_block(conn: sqlite3.Connection, cve: str, sources: Mapping[str, dict[str, Any]]) -> dict[str, Any] | None:
|
|
174
|
+
pairs = _pairs(conn, cve)
|
|
175
|
+
if not pairs:
|
|
176
|
+
return None
|
|
177
|
+
return {
|
|
178
|
+
"source": _label("kernel", sources.get("kernel")),
|
|
179
|
+
"fixed_in": kernel.fixed_in_by_branch(pairs),
|
|
180
|
+
"unfixed_on_start": kernel.unfixed_starts(pairs),
|
|
181
|
+
"ranges": [pair.text for pair in pairs],
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_ref(ref: str) -> tuple[str, str, int]:
|
|
186
|
+
match = _REF.match(ref.strip())
|
|
187
|
+
if match is None:
|
|
188
|
+
normalize_cve(ref) # raises the usual error
|
|
189
|
+
return ref.strip().upper(), "summary", 1
|
|
190
|
+
section = (match.group(2) or "summary").lower()
|
|
191
|
+
if section not in SECTIONS:
|
|
192
|
+
raise CvelibError(f"unknown section §{section}", f"Sections are: {', '.join(SECTIONS)}")
|
|
193
|
+
return match.group(1).upper(), section, int(match.group(3) or 1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _cite(cve: str, used: dict[str, str]) -> str:
|
|
197
|
+
return f"{cve} [{'; '.join(dict.fromkeys(used.values()))}]"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load(conn: sqlite3.Connection, record: dict[str, Any], key: str) -> Any:
|
|
201
|
+
value = record.get(key)
|
|
202
|
+
return json.loads(value) if value else []
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _page(
|
|
206
|
+
items: list[Any], start: int, budget: int, ref: str, section: str, gather: dict[str, Any]
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
"""A token-budgeted slice of `items` with the ref that continues it."""
|
|
209
|
+
picked: list[Any] = []
|
|
210
|
+
used = 0
|
|
211
|
+
index = max(start, 1) - 1
|
|
212
|
+
for item in items[index:]:
|
|
213
|
+
cost = _tokens(item)
|
|
214
|
+
if picked and used + cost > budget:
|
|
215
|
+
break
|
|
216
|
+
picked.append(item)
|
|
217
|
+
used += cost
|
|
218
|
+
end = index + len(picked)
|
|
219
|
+
gather["total"] = len(items)
|
|
220
|
+
gather["items"] = picked
|
|
221
|
+
gather["start"] = index + 1
|
|
222
|
+
if end < len(items):
|
|
223
|
+
gather["next"] = f"{ref}§{section}¶{end + 1}"
|
|
224
|
+
return gather
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def read(ref: str, max_tokens: int = 2000) -> dict[str, Any]:
|
|
228
|
+
cve, section, start = _parse_ref(ref)
|
|
229
|
+
budget = _clamp(max_tokens, 200, TOKENS_MAX)
|
|
230
|
+
conn = store.open_read()
|
|
231
|
+
try:
|
|
232
|
+
sources = _sources(conn)
|
|
233
|
+
records = _records(conn, cve)
|
|
234
|
+
if not records:
|
|
235
|
+
kev = _kev_block(conn, cve, sources)
|
|
236
|
+
if kev.get("status") == "listed":
|
|
237
|
+
return _with_gaps(
|
|
238
|
+
{
|
|
239
|
+
"ref": cve,
|
|
240
|
+
"state": "UNKNOWN_TO_CVE_SOURCES",
|
|
241
|
+
"kev": kev,
|
|
242
|
+
"epss": _epss_block(conn, cve, sources),
|
|
243
|
+
"note": "only the KEV catalog has this id",
|
|
244
|
+
},
|
|
245
|
+
sources,
|
|
246
|
+
)
|
|
247
|
+
raise _not_found(cve, sources)
|
|
248
|
+
# The kernel repository is the kernel CNA's own copy: it decides kernel CVEs even when the list copy differs.
|
|
249
|
+
primary = records.get("kernel") or records["cvelist"]
|
|
250
|
+
primary_source = "kernel" if "kernel" in records else "cvelist"
|
|
251
|
+
attribution: dict[str, str] = {"record": _label(primary_source, sources.get(primary_source))}
|
|
252
|
+
if "cvelist" in records:
|
|
253
|
+
attribution["registry"] = _label("cvelist", sources.get("cvelist"))
|
|
254
|
+
cvelist = records.get("cvelist")
|
|
255
|
+
cite = _cite(cve, attribution)
|
|
256
|
+
# Registry status wins for lifecycle: a CVE rejected upstream is rejected even if a mirror lags.
|
|
257
|
+
state = (cvelist or primary)["state"]
|
|
258
|
+
if "kernel" in records and records["kernel"]["state"] != state:
|
|
259
|
+
state = "REJECTED" if "REJECTED" in {state, records["kernel"]["state"]} else state
|
|
260
|
+
|
|
261
|
+
refs = list(dict.fromkeys(_load(conn, primary, "refs") + (_load(conn, cvelist, "refs") if cvelist else [])))
|
|
262
|
+
affected = (
|
|
263
|
+
_load(conn, (cvelist or primary), "affected")
|
|
264
|
+
if primary_source == "cvelist"
|
|
265
|
+
else _load(conn, primary, "affected")
|
|
266
|
+
)
|
|
267
|
+
metrics = _metrics(conn, cve)
|
|
268
|
+
kernel_block = _kernel_block(conn, cve, sources)
|
|
269
|
+
|
|
270
|
+
if section == "references":
|
|
271
|
+
return _with_gaps(
|
|
272
|
+
_page(refs, start, budget, cve, section, {"ref": f"{cve}§references", "cite": cite}), sources
|
|
273
|
+
)
|
|
274
|
+
if section == "affected":
|
|
275
|
+
return _with_gaps(
|
|
276
|
+
_page(
|
|
277
|
+
affected,
|
|
278
|
+
start,
|
|
279
|
+
budget,
|
|
280
|
+
cve,
|
|
281
|
+
section,
|
|
282
|
+
{"ref": f"{cve}§affected", "cite": cite, "source": attribution["record"]},
|
|
283
|
+
),
|
|
284
|
+
sources,
|
|
285
|
+
)
|
|
286
|
+
if section == "cvss":
|
|
287
|
+
return _with_gaps(
|
|
288
|
+
{"ref": f"{cve}§cvss", "cite": cite, "items": metrics, "preferred": _preferred_metric(metrics)},
|
|
289
|
+
sources,
|
|
290
|
+
)
|
|
291
|
+
if section == "kernel":
|
|
292
|
+
if kernel_block is None:
|
|
293
|
+
return _with_gaps(
|
|
294
|
+
{"ref": f"{cve}§kernel", "cite": cite, "kernel": None, "note": "no kernel CNA data for this CVE"},
|
|
295
|
+
sources,
|
|
296
|
+
)
|
|
297
|
+
ranges = kernel_block.pop("ranges")
|
|
298
|
+
return _with_gaps(
|
|
299
|
+
_page(ranges, start, budget, cve, section, {"ref": f"{cve}§kernel", "cite": cite, **kernel_block}),
|
|
300
|
+
sources,
|
|
301
|
+
)
|
|
302
|
+
if section == "sources":
|
|
303
|
+
return _with_gaps(
|
|
304
|
+
{
|
|
305
|
+
"ref": f"{cve}§sources",
|
|
306
|
+
"cite": cite,
|
|
307
|
+
"sources": {
|
|
308
|
+
name: {
|
|
309
|
+
"version": row["version"],
|
|
310
|
+
"upstream_date": row["upstream_date"],
|
|
311
|
+
"synced_at": row["synced_at"],
|
|
312
|
+
"state": row["state"],
|
|
313
|
+
}
|
|
314
|
+
for name, row in sources.items()
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
sources,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
epss_row = _epss_block(conn, cve, sources)
|
|
321
|
+
kev_row = _kev_block(conn, cve, sources)
|
|
322
|
+
if kev_row.get("status") != "unknown":
|
|
323
|
+
attribution["kev"] = str(kev_row.get("source"))
|
|
324
|
+
if epss_row:
|
|
325
|
+
attribution["epss"] = str(epss_row.get("source"))
|
|
326
|
+
files = str(primary.get("files") or "").split()
|
|
327
|
+
result: dict[str, Any] = {
|
|
328
|
+
"ref": cve,
|
|
329
|
+
"cite": _cite(cve, attribution),
|
|
330
|
+
"state": state,
|
|
331
|
+
"assigner": primary["assigner"] or (cvelist or primary)["assigner"],
|
|
332
|
+
"published": (cvelist or primary)["published"],
|
|
333
|
+
"updated": (cvelist or primary)["updated"],
|
|
334
|
+
"title": primary["title"],
|
|
335
|
+
"description": primary["description"],
|
|
336
|
+
"description_source": attribution["record"],
|
|
337
|
+
"cwe": _load(conn, primary, "cwes") or (_load(conn, cvelist, "cwes") if cvelist else []),
|
|
338
|
+
"cvss": {"preferred": _preferred_metric(metrics), "count": len(metrics)},
|
|
339
|
+
"kev": kev_row,
|
|
340
|
+
"epss": epss_row,
|
|
341
|
+
"ssvc": _load(conn, cvelist or primary, "ssvc") or None,
|
|
342
|
+
"kernel": None,
|
|
343
|
+
"sources_used": attribution,
|
|
344
|
+
"references": refs[:8],
|
|
345
|
+
"references_total": len(refs),
|
|
346
|
+
}
|
|
347
|
+
if kernel_block is not None:
|
|
348
|
+
result["kernel"] = {
|
|
349
|
+
"source": kernel_block["source"],
|
|
350
|
+
"fixed_in": kernel_block["fixed_in"],
|
|
351
|
+
"unfixed_on_start": kernel_block["unfixed_on_start"],
|
|
352
|
+
"files": files[:12],
|
|
353
|
+
"files_total": len(files),
|
|
354
|
+
"use": f"cve_kernel('{cve}', '<version>')",
|
|
355
|
+
}
|
|
356
|
+
if len(refs) > 8:
|
|
357
|
+
result["next"] = f"{cve}§references¶1"
|
|
358
|
+
if _tokens(result) > budget:
|
|
359
|
+
result["description"] = result["description"][: max(budget * 2, 400)]
|
|
360
|
+
result["references"] = result["references"][:3]
|
|
361
|
+
result["truncated"] = True
|
|
362
|
+
result["sections"] = [f"{cve}§{name}" for name in SECTIONS[1:]]
|
|
363
|
+
return _with_gaps(result, sources)
|
|
364
|
+
finally:
|
|
365
|
+
conn.close()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def search(
|
|
369
|
+
query: str,
|
|
370
|
+
limit: int = 8,
|
|
371
|
+
start: int = 0,
|
|
372
|
+
kev_only: bool = False,
|
|
373
|
+
kernel_only: bool = False,
|
|
374
|
+
year: int = 0,
|
|
375
|
+
max_tokens: int = 1500,
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
text = query.strip()
|
|
378
|
+
if not text:
|
|
379
|
+
raise CvelibError("an empty query matches nothing", "Give search terms, a file path or a CVE id.")
|
|
380
|
+
limit = _clamp(limit, 1, LIMIT_MAX)
|
|
381
|
+
budget = _clamp(max_tokens, 200, TOKENS_MAX)
|
|
382
|
+
conn = store.open_read()
|
|
383
|
+
try:
|
|
384
|
+
sources = _sources(conn)
|
|
385
|
+
if not (sources.get("cvelist") or sources.get("kernel")):
|
|
386
|
+
raise CvelibError(
|
|
387
|
+
"no CVE records have been synced",
|
|
388
|
+
action="primarysource cve sync --source cvelist,kernel",
|
|
389
|
+
)
|
|
390
|
+
if re.fullmatch(r"(?i)CVE-\d{4}-\d{4,}", text):
|
|
391
|
+
cve = text.upper()
|
|
392
|
+
if conn.execute("SELECT 1 FROM record WHERE cve=? LIMIT 1", (cve,)).fetchone():
|
|
393
|
+
return _with_gaps(
|
|
394
|
+
{"query": text, "hits": [_hit(conn, cve, "", sources, None)], "exact": True}, sources
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
clauses = ["record.state != 'REJECTED'"]
|
|
398
|
+
args: list[Any] = []
|
|
399
|
+
if kev_only:
|
|
400
|
+
clauses.append("record.cve IN (SELECT cve FROM kev)")
|
|
401
|
+
if kernel_only:
|
|
402
|
+
clauses.append("record.cve IN (SELECT cve FROM kernel_pair)")
|
|
403
|
+
if year:
|
|
404
|
+
clauses.append("record.cve LIKE ?")
|
|
405
|
+
args.append(f"CVE-{year}-%")
|
|
406
|
+
|
|
407
|
+
terms = [term for term in re.findall(r'"[^"]+"|\S+', text)]
|
|
408
|
+
phrases = [f'"{term.strip(chr(34)).replace(chr(34), "")}"' for term in terms]
|
|
409
|
+
strict = " ".join(phrases)
|
|
410
|
+
loose = " OR ".join(phrases)
|
|
411
|
+
rows, relaxed = [], False
|
|
412
|
+
for expression, was_relaxed in ((strict, False), (loose, True)):
|
|
413
|
+
rows = _fts(conn, expression, clauses, args, start + limit * 6 + 50)
|
|
414
|
+
if rows:
|
|
415
|
+
relaxed = was_relaxed
|
|
416
|
+
break
|
|
417
|
+
if len(phrases) == 1:
|
|
418
|
+
break
|
|
419
|
+
|
|
420
|
+
seen: dict[str, sqlite3.Row] = {}
|
|
421
|
+
for row in rows:
|
|
422
|
+
seen.setdefault(row["cve"], row)
|
|
423
|
+
ordered = list(seen.values())
|
|
424
|
+
chosen = ordered[start : start + limit]
|
|
425
|
+
hits: list[dict[str, Any]] = []
|
|
426
|
+
used = 0
|
|
427
|
+
for row in chosen:
|
|
428
|
+
hit = _hit(conn, row["cve"], row["snip"], sources, row["source"])
|
|
429
|
+
cost = _tokens(hit)
|
|
430
|
+
if hits and used + cost > budget:
|
|
431
|
+
break
|
|
432
|
+
hits.append(hit)
|
|
433
|
+
used += cost
|
|
434
|
+
payload: dict[str, Any] = {"query": text, "hits": hits}
|
|
435
|
+
if relaxed:
|
|
436
|
+
payload["relaxed"] = "only some terms matched"
|
|
437
|
+
if start + len(hits) < len(ordered):
|
|
438
|
+
payload["next"] = {"start": start + len(hits)}
|
|
439
|
+
return _with_gaps(payload, sources)
|
|
440
|
+
finally:
|
|
441
|
+
conn.close()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _fts(
|
|
445
|
+
conn: sqlite3.Connection, expression: str, clauses: list[str], args: list[Any], fetch: int
|
|
446
|
+
) -> list[sqlite3.Row]:
|
|
447
|
+
sql = (
|
|
448
|
+
"SELECT record.cve AS cve, record.source AS source, "
|
|
449
|
+
"snippet(fts, 1, '', '', '...', 24) AS snip, bm25(fts, 8.0, 1.0, 4.0, 4.0) AS rank "
|
|
450
|
+
"FROM fts JOIN record ON record.rowid = fts.rowid WHERE fts MATCH ? AND "
|
|
451
|
+
+ " AND ".join(clauses)
|
|
452
|
+
+ " ORDER BY rank LIMIT ?"
|
|
453
|
+
)
|
|
454
|
+
try:
|
|
455
|
+
return conn.execute(sql, [expression, *args, fetch]).fetchall()
|
|
456
|
+
except sqlite3.OperationalError as error:
|
|
457
|
+
raise CvelibError(f"could not run that search: {error}", "Use plain words or quoted phrases.") from error
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _hit(
|
|
461
|
+
conn: sqlite3.Connection, cve: str, snippet: str, sources: Mapping[str, dict[str, Any]], origin: str | None
|
|
462
|
+
) -> dict[str, Any]:
|
|
463
|
+
records = _records(conn, cve)
|
|
464
|
+
best = records.get("kernel") or records.get("cvelist") or next(iter(records.values()))
|
|
465
|
+
metrics = _metrics(conn, cve)
|
|
466
|
+
preferred = _preferred_metric(metrics)
|
|
467
|
+
hit: dict[str, Any] = {
|
|
468
|
+
"ref": cve,
|
|
469
|
+
"title": best["title"] or best["description"][:90],
|
|
470
|
+
"source": "kernel" if "kernel" in records else (origin or "cvelist"),
|
|
471
|
+
}
|
|
472
|
+
if snippet:
|
|
473
|
+
hit["snippet"] = snippet
|
|
474
|
+
if preferred:
|
|
475
|
+
hit["cvss"] = preferred["score"]
|
|
476
|
+
if conn.execute("SELECT 1 FROM kev WHERE cve=?", (cve,)).fetchone():
|
|
477
|
+
hit["kev"] = True
|
|
478
|
+
epss = conn.execute("SELECT score FROM epss WHERE cve=?", (cve,)).fetchone()
|
|
479
|
+
if epss:
|
|
480
|
+
hit["epss"] = epss["score"]
|
|
481
|
+
return hit
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def kev(ref: str = "", vendor: str = "", since: str = "", limit: int = 20, start: int = 0) -> dict[str, Any]:
|
|
485
|
+
limit = _clamp(limit, 1, LIMIT_MAX)
|
|
486
|
+
conn = store.open_read()
|
|
487
|
+
try:
|
|
488
|
+
sources = _sources(conn)
|
|
489
|
+
row = sources.get("kev")
|
|
490
|
+
if row is None or row["state"] == "failed":
|
|
491
|
+
raise CvelibError(
|
|
492
|
+
"the CISA KEV catalog is not synced, so KEV status cannot be determined",
|
|
493
|
+
action="primarysource cve sync --source kev",
|
|
494
|
+
)
|
|
495
|
+
label = _label("kev", row)
|
|
496
|
+
if ref:
|
|
497
|
+
cve = normalize_cve(ref)
|
|
498
|
+
entry = conn.execute("SELECT * FROM kev WHERE cve=?", (cve,)).fetchone()
|
|
499
|
+
if entry is None:
|
|
500
|
+
return _with_gaps({"ref": cve, "status": "not_listed", "source": label}, sources)
|
|
501
|
+
data = dict(entry)
|
|
502
|
+
data["cwes"] = json.loads(data["cwes"] or "[]")
|
|
503
|
+
return _with_gaps({"ref": cve, "status": "listed", "source": label, **data}, sources)
|
|
504
|
+
clauses: list[str] = []
|
|
505
|
+
args: list[Any] = []
|
|
506
|
+
if vendor:
|
|
507
|
+
clauses.append("(vendor LIKE ? OR product LIKE ?)")
|
|
508
|
+
args += [f"%{vendor}%", f"%{vendor}%"]
|
|
509
|
+
if since:
|
|
510
|
+
clauses.append("date_added >= ?")
|
|
511
|
+
args.append(since)
|
|
512
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
513
|
+
total = conn.execute(f"SELECT count(*) FROM kev {where}", args).fetchone()[0] # noqa: S608
|
|
514
|
+
rows = conn.execute(
|
|
515
|
+
f"SELECT cve, vendor, product, name, date_added, due_date, ransomware FROM kev {where} " # noqa: S608
|
|
516
|
+
"ORDER BY date_added DESC, cve DESC LIMIT ? OFFSET ?",
|
|
517
|
+
[*args, limit, start],
|
|
518
|
+
).fetchall()
|
|
519
|
+
payload: dict[str, Any] = {"source": label, "total": total, "items": [dict(item) for item in rows]}
|
|
520
|
+
if start + len(rows) < total:
|
|
521
|
+
payload["next"] = {"start": start + len(rows)}
|
|
522
|
+
return _with_gaps(payload, sources)
|
|
523
|
+
finally:
|
|
524
|
+
conn.close()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def kernel_check(ref: str, version: str) -> dict[str, Any]:
|
|
528
|
+
"""Does this CVE affect this kernel version? Kernel CNA data when it exists, otherwise a clearly hedged answer."""
|
|
529
|
+
cve = normalize_cve(ref)
|
|
530
|
+
parsed = kernel.parse_version(version)
|
|
531
|
+
caveats = kernel.version_caveats(version)
|
|
532
|
+
conn = store.open_read()
|
|
533
|
+
try:
|
|
534
|
+
sources = _sources(conn)
|
|
535
|
+
records = _records(conn, cve)
|
|
536
|
+
pairs = _pairs(conn, cve)
|
|
537
|
+
answer: dict[str, Any] = {"ref": cve, "version": version}
|
|
538
|
+
if caveats:
|
|
539
|
+
answer["caveats"] = caveats
|
|
540
|
+
kernel_row = sources.get("kernel")
|
|
541
|
+
if pairs:
|
|
542
|
+
verdict = kernel.explain(pairs, parsed)
|
|
543
|
+
answer.update(
|
|
544
|
+
{
|
|
545
|
+
"verdict": "affected" if verdict.affected else "not_affected",
|
|
546
|
+
"reason": verdict.reason,
|
|
547
|
+
"detail": verdict.detail,
|
|
548
|
+
"evidence": list(verdict.evidence),
|
|
549
|
+
"fixed_in": kernel.fixed_in_by_branch(pairs),
|
|
550
|
+
"source": _label("kernel", kernel_row),
|
|
551
|
+
"confidence": "high",
|
|
552
|
+
"note": "Per-branch data from the kernel CNA, including stable backports.",
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
return _with_gaps(answer, sources)
|
|
556
|
+
|
|
557
|
+
record = records.get("cvelist") or records.get("kernel")
|
|
558
|
+
if record is None:
|
|
559
|
+
raise _not_found(cve, sources)
|
|
560
|
+
if "kernel" in records and record is records["kernel"] and records["kernel"]["state"] == "REJECTED":
|
|
561
|
+
answer.update(
|
|
562
|
+
{"verdict": "rejected", "reason": "rejected_by_kernel_cna", "source": _label("kernel", kernel_row)}
|
|
563
|
+
)
|
|
564
|
+
return _with_gaps(answer, sources)
|
|
565
|
+
affected = _load(conn, record, "affected")
|
|
566
|
+
kernel_entries = [entry for entry in affected if kernel.is_kernel_product(entry)]
|
|
567
|
+
if not kernel_entries:
|
|
568
|
+
answer.update(
|
|
569
|
+
{
|
|
570
|
+
"verdict": "unknown",
|
|
571
|
+
"reason": "not_a_kernel_record",
|
|
572
|
+
"detail": "no kernel CNA data, and the CVE List record does not name the Linux kernel",
|
|
573
|
+
"source": _label("cvelist", sources.get("cvelist")),
|
|
574
|
+
"confidence": "none",
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
return _with_gaps(answer, sources)
|
|
578
|
+
matches, usable = kernel.fallback_ranges(kernel_entries, parsed)
|
|
579
|
+
source = _label("cvelist", sources.get("cvelist"))
|
|
580
|
+
if usable == 0:
|
|
581
|
+
answer.update(
|
|
582
|
+
{
|
|
583
|
+
"verdict": "unknown",
|
|
584
|
+
"reason": "wildcard_record",
|
|
585
|
+
"detail": "the record names the kernel but gives no version range; it is not evidence either way",
|
|
586
|
+
"source": source,
|
|
587
|
+
"confidence": "none",
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
elif matches:
|
|
591
|
+
answer.update(
|
|
592
|
+
{
|
|
593
|
+
"verdict": "possibly_affected",
|
|
594
|
+
"reason": "cna_range_match",
|
|
595
|
+
"detail": "version falls in a range from the assigning CNA, which rarely tracks stable backports",
|
|
596
|
+
"evidence": matches[:6],
|
|
597
|
+
"source": source,
|
|
598
|
+
"confidence": "low",
|
|
599
|
+
"note": "Not the kernel CNA: a longterm kernel may already carry the fix. Check the changelog.",
|
|
600
|
+
}
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
answer.update(
|
|
604
|
+
{
|
|
605
|
+
"verdict": "unknown",
|
|
606
|
+
"reason": "outside_cna_ranges",
|
|
607
|
+
"detail": "version is outside the assigning CNA's ranges, which does not prove it is unaffected",
|
|
608
|
+
"source": source,
|
|
609
|
+
"confidence": "low",
|
|
610
|
+
}
|
|
611
|
+
)
|
|
612
|
+
return _with_gaps(answer, sources)
|
|
613
|
+
finally:
|
|
614
|
+
conn.close()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def kernel_list(
|
|
618
|
+
version: str, kev_only: bool = False, path: str = "", limit: int = 25, start: int = 0, max_tokens: int = 2000
|
|
619
|
+
) -> dict[str, Any]:
|
|
620
|
+
"""Every kernel CNA CVE that affects `version`, known-exploited first, then by EPSS."""
|
|
621
|
+
parsed = kernel.parse_version(version)
|
|
622
|
+
limit = _clamp(limit, 1, LIMIT_MAX)
|
|
623
|
+
budget = _clamp(max_tokens, 200, TOKENS_MAX)
|
|
624
|
+
conn = store.open_read()
|
|
625
|
+
try:
|
|
626
|
+
sources = _sources(conn)
|
|
627
|
+
if not (sources.get("kernel") and sources["kernel"]["state"] == "ok"):
|
|
628
|
+
raise CvelibError(
|
|
629
|
+
"kernel CNA data is not synced, so a version cannot be assessed",
|
|
630
|
+
action="primarysource cve sync --source kernel",
|
|
631
|
+
)
|
|
632
|
+
grouped: dict[str, list[Pair]] = {}
|
|
633
|
+
for row in conn.execute("SELECT cve, start, start_git, fix, fix_git FROM kernel_pair"):
|
|
634
|
+
grouped.setdefault(row["cve"], []).append(
|
|
635
|
+
Pair(
|
|
636
|
+
kernel.parse_version(row["start"]),
|
|
637
|
+
row["start_git"],
|
|
638
|
+
kernel.parse_version(row["fix"]),
|
|
639
|
+
row["fix_git"],
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
affected: dict[str, kernel.Verdict] = {}
|
|
643
|
+
for cve, pairs in grouped.items():
|
|
644
|
+
verdict = kernel.explain(pairs, parsed)
|
|
645
|
+
if verdict.affected:
|
|
646
|
+
affected[cve] = verdict
|
|
647
|
+
kev_ids = {row[0] for row in conn.execute("SELECT cve FROM kev")}
|
|
648
|
+
epss = {row[0]: row[1] for row in conn.execute("SELECT cve, score FROM epss")}
|
|
649
|
+
files_filter = path.strip()
|
|
650
|
+
titles = {
|
|
651
|
+
row["cve"]: (row["title"], str(row["files"] or ""))
|
|
652
|
+
for row in conn.execute("SELECT cve, title, files FROM record WHERE source='kernel'")
|
|
653
|
+
}
|
|
654
|
+
order = sorted(affected, key=lambda c: (c not in kev_ids, -epss.get(c, 0.0), c))
|
|
655
|
+
if kev_only:
|
|
656
|
+
order = [c for c in order if c in kev_ids]
|
|
657
|
+
if files_filter:
|
|
658
|
+
order = [c for c in order if files_filter in titles.get(c, ("", ""))[1]]
|
|
659
|
+
items: list[dict[str, Any]] = []
|
|
660
|
+
used = 0
|
|
661
|
+
for cve in order[start : start + limit]:
|
|
662
|
+
title, files = titles.get(cve, ("", ""))
|
|
663
|
+
item: dict[str, Any] = {"ref": cve, "title": title, "why": affected[cve].detail}
|
|
664
|
+
if cve in kev_ids:
|
|
665
|
+
item["kev"] = True
|
|
666
|
+
if cve in epss:
|
|
667
|
+
item["epss"] = epss[cve]
|
|
668
|
+
if files:
|
|
669
|
+
item["files"] = files.split()[:3]
|
|
670
|
+
cost = _tokens(item)
|
|
671
|
+
if items and used + cost > budget:
|
|
672
|
+
break
|
|
673
|
+
items.append(item)
|
|
674
|
+
used += cost
|
|
675
|
+
payload: dict[str, Any] = {
|
|
676
|
+
"version": version,
|
|
677
|
+
"source": _label("kernel", sources.get("kernel")),
|
|
678
|
+
"affected_total": len(order),
|
|
679
|
+
"assessed": len(grouped),
|
|
680
|
+
"items": items,
|
|
681
|
+
}
|
|
682
|
+
caveats = kernel.version_caveats(version)
|
|
683
|
+
if caveats:
|
|
684
|
+
payload["caveats"] = caveats
|
|
685
|
+
if start + len(items) < len(order):
|
|
686
|
+
payload["next"] = {"start": start + len(items)}
|
|
687
|
+
payload["note"] = (
|
|
688
|
+
"Covers CVEs assigned by the kernel CNA only. Compiled-in reachability depends on the build's config."
|
|
689
|
+
)
|
|
690
|
+
return _with_gaps(payload, sources)
|
|
691
|
+
finally:
|
|
692
|
+
conn.close()
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def info(ref: str = "") -> dict[str, Any]:
|
|
696
|
+
"""Corpus status; with `ref`, a one-screen card for that CVE."""
|
|
697
|
+
home = config.home()
|
|
698
|
+
index = config.index_path()
|
|
699
|
+
if not index.is_file():
|
|
700
|
+
return {
|
|
701
|
+
"home": str(home),
|
|
702
|
+
"status": "not_synced",
|
|
703
|
+
"error": "no index yet",
|
|
704
|
+
"action": "primarysource cve sync",
|
|
705
|
+
"hint": "set CVELIB_HOME (or PRIMARYSOURCE_CVE_HOME) to a large disk first",
|
|
706
|
+
}
|
|
707
|
+
conn = store.open_read()
|
|
708
|
+
try:
|
|
709
|
+
sources = _sources(conn)
|
|
710
|
+
if ref:
|
|
711
|
+
cve = normalize_cve(ref)
|
|
712
|
+
records = _records(conn, cve)
|
|
713
|
+
if not records:
|
|
714
|
+
raise _not_found(cve, sources)
|
|
715
|
+
best = records.get("kernel") or records.get("cvelist") or next(iter(records.values()))
|
|
716
|
+
metrics = _metrics(conn, cve)
|
|
717
|
+
preferred = _preferred_metric(metrics)
|
|
718
|
+
kev_row = _kev_block(conn, cve, sources)
|
|
719
|
+
return _with_gaps(
|
|
720
|
+
{
|
|
721
|
+
"ref": cve,
|
|
722
|
+
"state": best["state"],
|
|
723
|
+
"title": best["title"] or best["description"][:120],
|
|
724
|
+
"cvss": preferred,
|
|
725
|
+
"kev": kev_row.get("status"),
|
|
726
|
+
"epss": _epss_block(conn, cve, sources),
|
|
727
|
+
"kernel_cna": cve
|
|
728
|
+
in {row[0] for row in conn.execute("SELECT DISTINCT cve FROM kernel_pair WHERE cve=?", (cve,))},
|
|
729
|
+
"next": cve,
|
|
730
|
+
},
|
|
731
|
+
sources,
|
|
732
|
+
)
|
|
733
|
+
counts = {
|
|
734
|
+
"cve_list_records": conn.execute("SELECT count(*) FROM record WHERE source='cvelist'").fetchone()[0],
|
|
735
|
+
"kernel_records": conn.execute("SELECT count(*) FROM record WHERE source='kernel'").fetchone()[0],
|
|
736
|
+
"kernel_ranges": conn.execute("SELECT count(*) FROM kernel_pair").fetchone()[0],
|
|
737
|
+
"kev_entries": conn.execute("SELECT count(*) FROM kev").fetchone()[0],
|
|
738
|
+
"epss_scores": conn.execute("SELECT count(*) FROM epss").fetchone()[0],
|
|
739
|
+
}
|
|
740
|
+
size = index.stat().st_size
|
|
741
|
+
payload: dict[str, Any] = {
|
|
742
|
+
"home": str(home),
|
|
743
|
+
"index": str(index),
|
|
744
|
+
"index_mb": round(size / 1024 / 1024, 1),
|
|
745
|
+
"counts": counts,
|
|
746
|
+
"sources": {
|
|
747
|
+
name: {
|
|
748
|
+
"state": row["state"],
|
|
749
|
+
"version": row["version"],
|
|
750
|
+
"upstream_date": row["upstream_date"],
|
|
751
|
+
"synced_at": row["synced_at"],
|
|
752
|
+
"records": row["records"],
|
|
753
|
+
"detail": row["detail"],
|
|
754
|
+
}
|
|
755
|
+
for name, row in sources.items()
|
|
756
|
+
},
|
|
757
|
+
}
|
|
758
|
+
return _with_gaps(payload, sources)
|
|
759
|
+
finally:
|
|
760
|
+
conn.close()
|