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
primarysource/cursor.py
ADDED
|
@@ -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}"
|
primarysource/cve/cli.py
ADDED
|
@@ -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
|