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,214 @@
|
|
|
1
|
+
"""The local SQLite index: one row per CVE per source, so records merge when read, not when loaded.
|
|
2
|
+
|
|
3
|
+
Merging at read time means the order sources were synced in cannot change an answer, and a later source (NVD, OSV)
|
|
4
|
+
adds rows instead of rewriting them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import config
|
|
17
|
+
from .errors import CvelibError
|
|
18
|
+
from .kernel import Pair
|
|
19
|
+
from .parse import KevEntry, Record
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
|
|
23
|
+
SCHEMA = """
|
|
24
|
+
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS source(
|
|
26
|
+
name TEXT PRIMARY KEY, state TEXT NOT NULL, version TEXT, upstream_date TEXT, synced_at TEXT,
|
|
27
|
+
records INTEGER, detail TEXT, etag TEXT
|
|
28
|
+
);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS record(
|
|
30
|
+
rowid INTEGER PRIMARY KEY,
|
|
31
|
+
cve TEXT NOT NULL, source TEXT NOT NULL, state TEXT NOT NULL, assigner TEXT, assigner_org TEXT,
|
|
32
|
+
published TEXT, updated TEXT, title TEXT, description TEXT, products TEXT, files TEXT,
|
|
33
|
+
cwes TEXT, refs TEXT, affected TEXT, ssvc TEXT,
|
|
34
|
+
UNIQUE(cve, source)
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS record_cve ON record(cve);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS metric(
|
|
38
|
+
cve TEXT NOT NULL, source TEXT NOT NULL, label TEXT NOT NULL, version TEXT NOT NULL, vector TEXT NOT NULL,
|
|
39
|
+
score REAL, severity TEXT
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS metric_cve ON metric(cve, source);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS kev(
|
|
43
|
+
cve TEXT PRIMARY KEY, vendor TEXT, product TEXT, name TEXT, date_added TEXT, due_date TEXT, ransomware TEXT,
|
|
44
|
+
action TEXT, description TEXT, notes TEXT, cwes TEXT, forensic_triage TEXT
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS kev_added ON kev(date_added);
|
|
47
|
+
CREATE TABLE IF NOT EXISTS epss(cve TEXT PRIMARY KEY, score REAL NOT NULL, percentile REAL NOT NULL) WITHOUT ROWID;
|
|
48
|
+
CREATE TABLE IF NOT EXISTS kernel_pair(
|
|
49
|
+
cve TEXT NOT NULL, start TEXT NOT NULL, start_git TEXT, fix TEXT NOT NULL, fix_git TEXT
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS kernel_pair_cve ON kernel_pair(cve);
|
|
52
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
|
|
53
|
+
title, description, products, files, content='record', content_rowid='rowid', tokenize='porter unicode61'
|
|
54
|
+
);
|
|
55
|
+
CREATE TRIGGER IF NOT EXISTS record_ai AFTER INSERT ON record BEGIN
|
|
56
|
+
INSERT INTO fts(rowid, title, description, products, files)
|
|
57
|
+
VALUES (new.rowid, new.title, new.description, new.products, new.files);
|
|
58
|
+
END;
|
|
59
|
+
CREATE TRIGGER IF NOT EXISTS record_ad AFTER DELETE ON record BEGIN
|
|
60
|
+
INSERT INTO fts(fts, rowid, title, description, products, files)
|
|
61
|
+
VALUES ('delete', old.rowid, old.title, old.description, old.products, old.files);
|
|
62
|
+
END;
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def now_iso() -> str:
|
|
67
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _configure(conn: sqlite3.Connection) -> sqlite3.Connection:
|
|
71
|
+
conn.row_factory = sqlite3.Row
|
|
72
|
+
conn.execute("PRAGMA busy_timeout=15000")
|
|
73
|
+
return conn
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def open_write(path: Path | None = None) -> sqlite3.Connection:
|
|
77
|
+
target = path or config.index_path()
|
|
78
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
conn = _configure(sqlite3.connect(target))
|
|
80
|
+
# WAL lets an MCP server keep reading while a sync writes. If the filesystem cannot do it, the default holds.
|
|
81
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
82
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
83
|
+
conn.executescript(SCHEMA)
|
|
84
|
+
conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
|
|
85
|
+
conn.commit()
|
|
86
|
+
return conn
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def open_read(path: Path | None = None) -> sqlite3.Connection:
|
|
90
|
+
target = path or config.index_path()
|
|
91
|
+
if not target.is_file():
|
|
92
|
+
raise CvelibError(
|
|
93
|
+
f"no cve index at {target}",
|
|
94
|
+
"set CVELIB_HOME (or PRIMARYSOURCE_CVE_HOME) to a large disk first; `primarysource cve path` shows where",
|
|
95
|
+
"primarysource cve sync",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
conn = _configure(sqlite3.connect(f"{target.resolve().as_uri()}?mode=ro", uri=True))
|
|
99
|
+
conn.execute("SELECT 1 FROM source LIMIT 1")
|
|
100
|
+
except sqlite3.Error as error:
|
|
101
|
+
raise CvelibError(
|
|
102
|
+
f"the index at {target} is unusable: {error}",
|
|
103
|
+
"delete it and its -wal and -shm files; the sync then starts from the CVE List baseline",
|
|
104
|
+
"primarysource cve sync --full",
|
|
105
|
+
) from error
|
|
106
|
+
return conn
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def mark_source(
|
|
110
|
+
conn: sqlite3.Connection,
|
|
111
|
+
name: str,
|
|
112
|
+
state: str,
|
|
113
|
+
version: str | None = None,
|
|
114
|
+
upstream_date: str | None = None,
|
|
115
|
+
records: int | None = None,
|
|
116
|
+
detail: str | None = None,
|
|
117
|
+
etag: str | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
conn.execute(
|
|
120
|
+
"INSERT INTO source(name, state, version, upstream_date, synced_at, records, detail, etag) "
|
|
121
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET state=excluded.state, "
|
|
122
|
+
"version=COALESCE(excluded.version, source.version), "
|
|
123
|
+
"upstream_date=COALESCE(excluded.upstream_date, source.upstream_date), "
|
|
124
|
+
"synced_at=excluded.synced_at, records=COALESCE(excluded.records, source.records), detail=excluded.detail, "
|
|
125
|
+
"etag=COALESCE(excluded.etag, source.etag)",
|
|
126
|
+
(name, state, version, upstream_date, now_iso(), records, detail, etag),
|
|
127
|
+
)
|
|
128
|
+
conn.commit()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def source_row(conn: sqlite3.Connection, name: str) -> sqlite3.Row | None:
|
|
132
|
+
row: sqlite3.Row | None = conn.execute("SELECT * FROM source WHERE name=?", (name,)).fetchone()
|
|
133
|
+
return row
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def existing_updated(conn: sqlite3.Connection, source: str) -> dict[str, str]:
|
|
137
|
+
return {row[0]: row[1] or "" for row in conn.execute("SELECT cve, updated FROM record WHERE source=?", (source,))}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def put_record(conn: sqlite3.Connection, source: str, record: Record) -> None:
|
|
141
|
+
conn.execute("DELETE FROM record WHERE cve=? AND source=?", (record.cve, source))
|
|
142
|
+
conn.execute("DELETE FROM metric WHERE cve=? AND source=?", (record.cve, source))
|
|
143
|
+
products = " ".join(sorted({f"{a.get('vendor', '')} {a.get('product', '')}".strip() for a in record.affected}))
|
|
144
|
+
conn.execute(
|
|
145
|
+
"INSERT INTO record(cve, source, state, assigner, assigner_org, published, updated, title, description, "
|
|
146
|
+
"products, files, cwes, refs, affected, ssvc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
147
|
+
(
|
|
148
|
+
record.cve,
|
|
149
|
+
source,
|
|
150
|
+
record.state,
|
|
151
|
+
record.assigner,
|
|
152
|
+
record.assigner_org,
|
|
153
|
+
record.published,
|
|
154
|
+
record.updated,
|
|
155
|
+
record.title,
|
|
156
|
+
record.description,
|
|
157
|
+
products,
|
|
158
|
+
" ".join(record.program_files),
|
|
159
|
+
json.dumps(record.cwes),
|
|
160
|
+
json.dumps(record.references),
|
|
161
|
+
json.dumps(record.affected, separators=(",", ":")),
|
|
162
|
+
json.dumps(record.ssvc) if record.ssvc else None,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
conn.executemany(
|
|
166
|
+
"INSERT INTO metric(cve, source, label, version, vector, score, severity) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
167
|
+
[(record.cve, source, m.label, m.version, m.vector, m.score, m.severity) for m in record.metrics],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def replace_kev(conn: sqlite3.Connection, entries: list[KevEntry]) -> None:
|
|
172
|
+
conn.execute("DELETE FROM kev")
|
|
173
|
+
conn.executemany(
|
|
174
|
+
"INSERT OR REPLACE INTO kev VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
175
|
+
[
|
|
176
|
+
(
|
|
177
|
+
e.cve,
|
|
178
|
+
e.vendor,
|
|
179
|
+
e.product,
|
|
180
|
+
e.name,
|
|
181
|
+
e.date_added,
|
|
182
|
+
e.due_date,
|
|
183
|
+
e.ransomware,
|
|
184
|
+
e.action,
|
|
185
|
+
e.description,
|
|
186
|
+
e.notes,
|
|
187
|
+
json.dumps(e.cwes),
|
|
188
|
+
e.forensic_triage,
|
|
189
|
+
)
|
|
190
|
+
for e in entries
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def replace_epss(conn: sqlite3.Connection, rows: Iterable[tuple[str, float, float]]) -> None:
|
|
196
|
+
conn.execute("DELETE FROM epss")
|
|
197
|
+
conn.executemany("INSERT OR REPLACE INTO epss(cve, score, percentile) VALUES (?, ?, ?)", rows)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def clear_kernel(conn: sqlite3.Connection) -> None:
|
|
201
|
+
conn.execute("DELETE FROM kernel_pair")
|
|
202
|
+
conn.execute("DELETE FROM record WHERE source='kernel'")
|
|
203
|
+
conn.execute("DELETE FROM metric WHERE source='kernel'")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def put_pairs(conn: sqlite3.Connection, cve: str, pairs: list[Pair]) -> None:
|
|
207
|
+
conn.executemany(
|
|
208
|
+
"INSERT INTO kernel_pair(cve, start, start_git, fix, fix_git) VALUES (?, ?, ?, ?, ?)",
|
|
209
|
+
[(cve, ".".join(map(str, p.start)), p.start_git, ".".join(map(str, p.fix)), p.fix_git) for p in pairs],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def row_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
|
214
|
+
return dict(row) if row is not None else None
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Fetch each source and load it into the index. One source failing does not stop the others.
|
|
2
|
+
|
|
3
|
+
Each source is marked `syncing` before work and `ok` or `failed` after, so an interrupted run is reported as such by
|
|
4
|
+
every tool instead of looking like a complete corpus.
|
|
5
|
+
|
|
6
|
+
Network use is deliberately small: KEV uses an ETag, the CVE List is one daily baseline zip plus deltaLog-driven
|
|
7
|
+
single files, the kernel CNA is a shallow sparse git fetch, and EPSS is one 3 MB file. See docs/sources.md.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
import zipfile
|
|
20
|
+
from collections.abc import Callable, Iterator
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime, timedelta
|
|
23
|
+
from email.message import Message
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from primarysource.fsutil import replace
|
|
28
|
+
|
|
29
|
+
from . import config, store
|
|
30
|
+
from .errors import CvelibError
|
|
31
|
+
from .kernel import parse_dyad
|
|
32
|
+
from .parse import parse_cve_record, parse_epss, parse_kev
|
|
33
|
+
|
|
34
|
+
# What a baseline sync needs on the disk holding CVELIB_HOME: the outer zip, the inner zip and the index.
|
|
35
|
+
CVELIST_NEED_BYTES = int(3.5 * 1024**3)
|
|
36
|
+
KERNEL_NEED_BYTES = int(0.6 * 1024**3)
|
|
37
|
+
# Above this many changed records a baseline is cheaper and kinder than that many single-file requests.
|
|
38
|
+
DELTA_FILE_LIMIT = 3000
|
|
39
|
+
DELTA_REQUEST_DELAY = 0.05
|
|
40
|
+
BASELINE_MAX_AGE_DAYS = 25
|
|
41
|
+
BATCH = 2000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Response:
|
|
46
|
+
status: int
|
|
47
|
+
body: bytes
|
|
48
|
+
headers: Message
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
Fetch = Callable[[str, dict[str, str]], Response]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def http_fetch(url: str, headers: dict[str, str]) -> Response:
|
|
55
|
+
request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT, **headers})
|
|
56
|
+
try:
|
|
57
|
+
with urllib.request.urlopen(request, timeout=300) as reply:
|
|
58
|
+
return Response(reply.status, reply.read(), reply.headers)
|
|
59
|
+
except urllib.error.HTTPError as error:
|
|
60
|
+
if error.code == 304:
|
|
61
|
+
return Response(304, b"", error.headers)
|
|
62
|
+
raise CvelibError(
|
|
63
|
+
f"HTTP {error.code} fetching {url}", "Try again later; this is not a local fault."
|
|
64
|
+
) from error
|
|
65
|
+
except (urllib.error.URLError, TimeoutError) as error:
|
|
66
|
+
raise CvelibError(f"network error fetching {url}: {error}", "Check connectivity and retry.") from error
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def http_download(url: str, target: Path) -> None:
|
|
70
|
+
"""Stream a large file to `target` via a same-directory temporary, so a failure leaves nothing half-named."""
|
|
71
|
+
request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT})
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
partial = target.with_name(target.name + ".part")
|
|
74
|
+
try:
|
|
75
|
+
with urllib.request.urlopen(request, timeout=600) as reply, partial.open("wb") as out:
|
|
76
|
+
shutil.copyfileobj(reply, out, 1024 * 1024)
|
|
77
|
+
except (urllib.error.URLError, TimeoutError) as error:
|
|
78
|
+
partial.unlink(missing_ok=True)
|
|
79
|
+
raise CvelibError(f"download failed for {url}: {error}", "Check connectivity and retry.") from error
|
|
80
|
+
replace(partial, target)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_disk(path: Path, need: int, force: bool) -> None:
|
|
84
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
free = shutil.disk_usage(path).free
|
|
86
|
+
if not force and free - need < config.min_free_bytes():
|
|
87
|
+
raise CvelibError(
|
|
88
|
+
f"{path} has {free / 1024**3:.1f} GB free; this sync needs about {need / 1024**3:.1f} GB and would leave "
|
|
89
|
+
f"less than the {config.min_free_bytes() / 1024**3:.1f} GB reserve",
|
|
90
|
+
"set CVELIB_HOME to a larger disk, or lower CVELIB_MIN_FREE_GB, or pass --force",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _log(message: str) -> None:
|
|
95
|
+
# stderr: stdout is the protocol when this runs under the MCP server's process.
|
|
96
|
+
print(f"primarysource cve: {message}", file=sys.stderr, flush=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---- KEV -----------------------------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def sync_kev(conn: Any, fetch: Fetch = http_fetch, force: bool = False) -> str:
|
|
103
|
+
previous = store.source_row(conn, "kev")
|
|
104
|
+
headers: dict[str, str] = {}
|
|
105
|
+
if previous is not None and previous["etag"] and previous["state"] == "ok" and not force:
|
|
106
|
+
headers["If-None-Match"] = previous["etag"]
|
|
107
|
+
store.mark_source(conn, "kev", "syncing", detail="fetching")
|
|
108
|
+
reply = fetch(config.KEV_URL, headers)
|
|
109
|
+
if reply.status == 304:
|
|
110
|
+
store.mark_source(conn, "kev", "ok", detail="unchanged (ETag)")
|
|
111
|
+
return "unchanged"
|
|
112
|
+
version, released, entries = parse_kev(reply.body)
|
|
113
|
+
store.replace_kev(conn, entries)
|
|
114
|
+
store.mark_source(
|
|
115
|
+
conn,
|
|
116
|
+
"kev",
|
|
117
|
+
"ok",
|
|
118
|
+
version=version,
|
|
119
|
+
upstream_date=released,
|
|
120
|
+
records=len(entries),
|
|
121
|
+
detail="catalog replaced",
|
|
122
|
+
etag=reply.headers.get("ETag") or "",
|
|
123
|
+
)
|
|
124
|
+
return f"{len(entries)} entries, catalog {version}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---- EPSS ----------------------------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sync_epss(conn: Any, fetch: Fetch = http_fetch) -> str:
|
|
131
|
+
store.mark_source(conn, "epss", "syncing", detail="fetching")
|
|
132
|
+
reply = fetch(config.EPSS_URL, {})
|
|
133
|
+
model, score_date, rows = parse_epss(reply.body)
|
|
134
|
+
store.replace_epss(conn, rows)
|
|
135
|
+
store.mark_source(
|
|
136
|
+
conn, "epss", "ok", version=model, upstream_date=score_date, records=len(rows), detail="scores replaced"
|
|
137
|
+
)
|
|
138
|
+
return f"{len(rows)} scores, {score_date}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---- Kernel CNA ----------------------------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
KERNEL_SPARSE = (
|
|
144
|
+
"/cve/published/*/*.dyad",
|
|
145
|
+
"/cve/published/*/*.json",
|
|
146
|
+
"/cve/rejected/*/*.json",
|
|
147
|
+
"/LICENSES/cve-tou.txt",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _git(args: list[str], cwd: Path | None = None) -> str:
|
|
152
|
+
# safe.directory: on a non-NTFS or differently owned disk git refuses a repository this process just created.
|
|
153
|
+
repo = cwd.as_posix() if cwd is not None else "*"
|
|
154
|
+
command = ["git", "-c", f"safe.directory={repo}", *args]
|
|
155
|
+
done = subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False)
|
|
156
|
+
if done.returncode != 0:
|
|
157
|
+
raise CvelibError(
|
|
158
|
+
f"git {' '.join(args[:2])} failed: {done.stderr.strip()[:300]}", "Check connectivity and retry."
|
|
159
|
+
)
|
|
160
|
+
return done.stdout.strip()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def update_kernel_repo(repo: Path, force: bool) -> None:
|
|
164
|
+
if (repo / ".git").is_dir():
|
|
165
|
+
_git(["fetch", "--depth", "1", "origin", "HEAD"], repo)
|
|
166
|
+
_git(["reset", "--hard", "FETCH_HEAD"], repo)
|
|
167
|
+
return
|
|
168
|
+
check_disk(repo.parent, KERNEL_NEED_BYTES, force)
|
|
169
|
+
repo.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
_git(["clone", "--depth", "1", "--no-checkout", config.KERNEL_REPO_URL, str(repo)])
|
|
171
|
+
_git(["sparse-checkout", "init", "--no-cone"], repo)
|
|
172
|
+
_git(["sparse-checkout", "set", "--no-cone", *KERNEL_SPARSE], repo)
|
|
173
|
+
_git(["checkout", "-q", "HEAD"], repo)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ingest_kernel_tree(conn: Any, tree: Path, version: str, upstream_date: str) -> str:
|
|
177
|
+
"""Rebuild the kernel tables from a checkout. A CVE can move to `rejected/`, so nothing is kept from last time."""
|
|
178
|
+
root = tree / "cve"
|
|
179
|
+
if not root.is_dir():
|
|
180
|
+
raise CvelibError(
|
|
181
|
+
f"{tree} is not a checkout of the kernel CNA vulns repository",
|
|
182
|
+
"pass the root of a vulns.git checkout, or leave --kernel-tree out to fetch one",
|
|
183
|
+
"primarysource cve sync --source kernel",
|
|
184
|
+
)
|
|
185
|
+
store.clear_kernel(conn)
|
|
186
|
+
published = rejected = 0
|
|
187
|
+
for state, directory in (("PUBLISHED", root / "published"), ("REJECTED", root / "rejected")):
|
|
188
|
+
for path in sorted(directory.glob("*/*.json")):
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
191
|
+
except (ValueError, OSError):
|
|
192
|
+
_log(f"skipped unreadable {path.name}")
|
|
193
|
+
continue
|
|
194
|
+
record = parse_cve_record(data, state)
|
|
195
|
+
if record is None:
|
|
196
|
+
continue
|
|
197
|
+
record.state = state if state == "REJECTED" else record.state
|
|
198
|
+
store.put_record(conn, "kernel", record)
|
|
199
|
+
if state == "PUBLISHED":
|
|
200
|
+
dyad = path.with_suffix(".dyad")
|
|
201
|
+
if dyad.is_file():
|
|
202
|
+
store.put_pairs(conn, record.cve, parse_dyad(dyad.read_text(encoding="utf-8")))
|
|
203
|
+
published += 1
|
|
204
|
+
else:
|
|
205
|
+
rejected += 1
|
|
206
|
+
store.mark_source(
|
|
207
|
+
conn,
|
|
208
|
+
"kernel",
|
|
209
|
+
"ok",
|
|
210
|
+
version=version,
|
|
211
|
+
upstream_date=upstream_date,
|
|
212
|
+
records=published,
|
|
213
|
+
detail=f"{published} published, {rejected} rejected",
|
|
214
|
+
)
|
|
215
|
+
return f"{published} published, {rejected} rejected, commit {version[:12]}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def sync_kernel(conn: Any, local_tree: Path | None = None, force: bool = False) -> str:
|
|
219
|
+
store.mark_source(conn, "kernel", "syncing", detail="fetching")
|
|
220
|
+
if local_tree is not None:
|
|
221
|
+
return ingest_kernel_tree(conn, local_tree, "local-tree", "")
|
|
222
|
+
repo = config.kernel_repo_dir()
|
|
223
|
+
update_kernel_repo(repo, force)
|
|
224
|
+
version, date = _git(["log", "-1", "--format=%H %cI"], repo).split()
|
|
225
|
+
return ingest_kernel_tree(conn, repo, version, date)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---- CVE List ------------------------------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def iter_zip_records(path: Path) -> Iterator[tuple[str, bytes]]:
|
|
232
|
+
"""`(name, json bytes)` for every CVE file in a release zip, opening the nested `cves.zip` when there is one."""
|
|
233
|
+
with zipfile.ZipFile(path) as outer:
|
|
234
|
+
inner_name = next((n for n in outer.namelist() if n.endswith("cves.zip")), None)
|
|
235
|
+
if inner_name is None:
|
|
236
|
+
yield from _iter_cve_members(outer)
|
|
237
|
+
return
|
|
238
|
+
inner_path = path.with_name(path.name + ".inner.zip")
|
|
239
|
+
with outer.open(inner_name) as source, inner_path.open("wb") as out:
|
|
240
|
+
shutil.copyfileobj(source, out, 1024 * 1024)
|
|
241
|
+
try:
|
|
242
|
+
with zipfile.ZipFile(inner_path) as inner:
|
|
243
|
+
yield from _iter_cve_members(inner)
|
|
244
|
+
finally:
|
|
245
|
+
inner_path.unlink(missing_ok=True)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _iter_cve_members(archive: zipfile.ZipFile) -> Iterator[tuple[str, bytes]]:
|
|
249
|
+
for name in archive.namelist():
|
|
250
|
+
stem = name.rsplit("/", 1)[-1]
|
|
251
|
+
if stem.startswith("CVE-") and stem.endswith(".json"):
|
|
252
|
+
yield stem, archive.read(name)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def load_records(conn: Any, items: Iterator[tuple[str, bytes]], source: str = "cvelist") -> tuple[int, int]:
|
|
256
|
+
"""Upsert CVE JSON documents, skipping any whose dateUpdated is what the index already holds."""
|
|
257
|
+
known = store.existing_updated(conn, source)
|
|
258
|
+
written = seen = 0
|
|
259
|
+
for _name, body in items:
|
|
260
|
+
try:
|
|
261
|
+
data = json.loads(body)
|
|
262
|
+
except ValueError:
|
|
263
|
+
continue
|
|
264
|
+
record = parse_cve_record(data)
|
|
265
|
+
if record is None:
|
|
266
|
+
continue
|
|
267
|
+
seen += 1
|
|
268
|
+
if known.get(record.cve) == record.updated and record.updated:
|
|
269
|
+
continue
|
|
270
|
+
store.put_record(conn, source, record)
|
|
271
|
+
written += 1
|
|
272
|
+
if written % BATCH == 0:
|
|
273
|
+
conn.commit()
|
|
274
|
+
conn.commit()
|
|
275
|
+
return seen, written
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _latest_release(fetch: Fetch) -> dict[str, Any]:
|
|
279
|
+
data = json.loads(fetch(config.CVELIST_RELEASE_API, {"Accept": "application/vnd.github+json"}).body)
|
|
280
|
+
if not isinstance(data, dict):
|
|
281
|
+
raise CvelibError("the GitHub release API returned something unexpected", "Retry later.")
|
|
282
|
+
return data
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _asset(release: dict[str, Any], suffix: str) -> tuple[str, str] | None:
|
|
286
|
+
for asset in release.get("assets") or []:
|
|
287
|
+
name = str(asset.get("name") or "")
|
|
288
|
+
if suffix in name:
|
|
289
|
+
return name, str(asset.get("browser_download_url"))
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _parse_when(text: str) -> datetime | None:
|
|
294
|
+
try:
|
|
295
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
296
|
+
except ValueError:
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _delta_targets(log: Any, since: datetime) -> list[str]:
|
|
301
|
+
"""The raw URLs of every record changed after `since`, deduplicated. `deltaLog.json` is newest first."""
|
|
302
|
+
targets: dict[str, None] = {}
|
|
303
|
+
if not isinstance(log, list):
|
|
304
|
+
return []
|
|
305
|
+
for entry in log:
|
|
306
|
+
if not isinstance(entry, dict):
|
|
307
|
+
continue
|
|
308
|
+
when = _parse_when(str(entry.get("fetchTime") or ""))
|
|
309
|
+
if when is None or when <= since:
|
|
310
|
+
continue
|
|
311
|
+
for key in ("new", "updated"):
|
|
312
|
+
for item in entry.get(key) or []:
|
|
313
|
+
if isinstance(item, dict) and item.get("githubLink"):
|
|
314
|
+
targets[str(item["githubLink"])] = None
|
|
315
|
+
return list(targets)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def sync_cvelist(
|
|
319
|
+
conn: Any,
|
|
320
|
+
fetch: Fetch = http_fetch,
|
|
321
|
+
download: Callable[[str, Path], None] = http_download,
|
|
322
|
+
local_zip: Path | None = None,
|
|
323
|
+
force: bool = False,
|
|
324
|
+
full: bool = False,
|
|
325
|
+
) -> str:
|
|
326
|
+
store.mark_source(conn, "cvelist", "syncing", detail="fetching")
|
|
327
|
+
if local_zip is not None:
|
|
328
|
+
seen, written = load_records(conn, iter_zip_records(local_zip))
|
|
329
|
+
store.mark_source(conn, "cvelist", "ok", version=local_zip.name, records=_count(conn), detail="local zip")
|
|
330
|
+
return f"{seen} records read, {written} written from {local_zip.name}"
|
|
331
|
+
|
|
332
|
+
previous = store.source_row(conn, "cvelist")
|
|
333
|
+
baseline_at = (
|
|
334
|
+
_parse_when(str(previous["upstream_date"])) if previous is not None and previous["upstream_date"] else None
|
|
335
|
+
)
|
|
336
|
+
fresh_enough = (
|
|
337
|
+
baseline_at is not None
|
|
338
|
+
and datetime.now(UTC) - baseline_at < timedelta(days=BASELINE_MAX_AGE_DAYS)
|
|
339
|
+
and previous is not None
|
|
340
|
+
and previous["records"]
|
|
341
|
+
)
|
|
342
|
+
if not full and fresh_enough and baseline_at is not None:
|
|
343
|
+
log = json.loads(fetch(config.CVELIST_DELTA_LOG, {}).body)
|
|
344
|
+
targets = _delta_targets(log, baseline_at)
|
|
345
|
+
if len(targets) <= DELTA_FILE_LIMIT:
|
|
346
|
+
bodies: list[tuple[str, bytes]] = []
|
|
347
|
+
for url in targets:
|
|
348
|
+
bodies.append((url.rsplit("/", 1)[-1], fetch(url, {}).body))
|
|
349
|
+
time.sleep(DELTA_REQUEST_DELAY)
|
|
350
|
+
seen, written = load_records(conn, iter(bodies))
|
|
351
|
+
newest = max(
|
|
352
|
+
(w for e in log[:1] if (w := _parse_when(str(e.get("fetchTime") or ""))) is not None), default=None
|
|
353
|
+
)
|
|
354
|
+
when = newest.strftime("%Y-%m-%dT%H:%M:%SZ") if newest else None
|
|
355
|
+
store.mark_source(
|
|
356
|
+
conn,
|
|
357
|
+
"cvelist",
|
|
358
|
+
"ok",
|
|
359
|
+
upstream_date=when,
|
|
360
|
+
records=_count(conn),
|
|
361
|
+
detail=f"deltaLog: {len(targets)} changed files, {written} written",
|
|
362
|
+
)
|
|
363
|
+
return f"delta: {len(targets)} changed, {written} written"
|
|
364
|
+
_log(f"{len(targets)} changed files is past the delta limit; taking the baseline instead")
|
|
365
|
+
|
|
366
|
+
check_disk(config.raw_dir(), CVELIST_NEED_BYTES, force)
|
|
367
|
+
release = _latest_release(fetch)
|
|
368
|
+
baseline = _asset(release, "_all_CVEs_at_midnight")
|
|
369
|
+
delta = _asset(release, "_delta_CVEs_at_")
|
|
370
|
+
if baseline is None:
|
|
371
|
+
raise CvelibError("the latest CVE List release has no baseline asset", "See docs/sources.md and retry later.")
|
|
372
|
+
name, url = baseline
|
|
373
|
+
target = config.raw_dir() / "cvelist" / name
|
|
374
|
+
_log(f"downloading {name}")
|
|
375
|
+
download(url, target)
|
|
376
|
+
try:
|
|
377
|
+
seen, written = load_records(conn, iter_zip_records(target))
|
|
378
|
+
finally:
|
|
379
|
+
target.unlink(missing_ok=True)
|
|
380
|
+
when = str(release.get("published_at") or "")
|
|
381
|
+
if delta is not None:
|
|
382
|
+
delta_body = fetch(delta[1], {}).body
|
|
383
|
+
path = config.raw_dir() / "cvelist" / delta[0]
|
|
384
|
+
path.write_bytes(delta_body)
|
|
385
|
+
try:
|
|
386
|
+
load_records(conn, iter_zip_records(path))
|
|
387
|
+
finally:
|
|
388
|
+
path.unlink(missing_ok=True)
|
|
389
|
+
tag = str(release.get("tag_name") or "")
|
|
390
|
+
store.mark_source(
|
|
391
|
+
conn, "cvelist", "ok", version=tag, upstream_date=when, records=_count(conn), detail=f"baseline {name}"
|
|
392
|
+
)
|
|
393
|
+
return f"baseline {tag}: {seen} records read, {written} written"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _count(conn: Any) -> int:
|
|
397
|
+
row = conn.execute("SELECT count(*) FROM record WHERE source='cvelist'").fetchone()
|
|
398
|
+
return int(row[0])
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---- Orchestration -------------------------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def sync(
|
|
405
|
+
sources: list[str],
|
|
406
|
+
kernel_tree: Path | None = None,
|
|
407
|
+
cvelist_zip: Path | None = None,
|
|
408
|
+
force: bool = False,
|
|
409
|
+
full: bool = False,
|
|
410
|
+
) -> dict[str, str]:
|
|
411
|
+
"""Sync each named source. Returns `{source: outcome}`; a failed source is recorded and does not stop the rest."""
|
|
412
|
+
unknown = sorted(set(sources) - set(config.SOURCES))
|
|
413
|
+
if unknown:
|
|
414
|
+
raise CvelibError(f"unknown source(s): {', '.join(unknown)}", f"Sources are: {', '.join(config.SOURCES)}")
|
|
415
|
+
results: dict[str, str] = {}
|
|
416
|
+
conn = store.open_write()
|
|
417
|
+
try:
|
|
418
|
+
for name in sources:
|
|
419
|
+
started = time.monotonic()
|
|
420
|
+
_log(f"syncing {name}")
|
|
421
|
+
try:
|
|
422
|
+
if name == "kev":
|
|
423
|
+
outcome = sync_kev(conn, force=force)
|
|
424
|
+
elif name == "epss":
|
|
425
|
+
outcome = sync_epss(conn)
|
|
426
|
+
elif name == "kernel":
|
|
427
|
+
outcome = sync_kernel(conn, kernel_tree, force)
|
|
428
|
+
else:
|
|
429
|
+
outcome = sync_cvelist(conn, local_zip=cvelist_zip, force=force, full=full)
|
|
430
|
+
conn.commit()
|
|
431
|
+
results[name] = f"ok: {outcome} ({time.monotonic() - started:.0f}s)"
|
|
432
|
+
except CvelibError as error:
|
|
433
|
+
conn.rollback()
|
|
434
|
+
store.mark_source(conn, name, "failed", detail=error.message)
|
|
435
|
+
results[name] = f"FAILED: {error.message}"
|
|
436
|
+
finally:
|
|
437
|
+
conn.close()
|
|
438
|
+
return results
|