opencra-cli 0.1.0__tar.gz → 0.1.4__tar.gz

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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencra-cli
3
- Version: 0.1.0
3
+ Version: 0.1.4
4
4
  Summary: OpenCRA command-line scanner — Syft wrapper, OSV + CISA KEV matching.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.12
7
7
  Requires-Dist: httpx>=0.27
8
8
  Requires-Dist: jinja2>=3.1
9
- Requires-Dist: opencra-shared
9
+ Requires-Dist: opencra-shared>=0.1.4
10
10
  Requires-Dist: rich>=13.9
11
11
  Requires-Dist: typer>=0.15
12
12
  Provides-Extra: pdf
@@ -1,3 +1,3 @@
1
1
  """OpenCRA CLI — Syft wrapper, OSV + CISA KEV matching."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.4"
@@ -202,7 +202,11 @@ def doctor(
202
202
 
203
203
  console.print(f"OSV: {'reachable' if osv_ping() else 'unreachable (offline scans still work)'}")
204
204
  pdf_ok, pdf_reason = weasyprint_status()
205
- console.print(f"PDF engine: {'ok' if pdf_ok else 'fallback'} — {pdf_reason}")
205
+ # markup=False: Rich treats [pdf] in opencra-cli[pdf] as a style tag.
206
+ console.print(
207
+ f"PDF engine: {'ok' if pdf_ok else 'fallback'} — {pdf_reason}",
208
+ markup=False,
209
+ )
206
210
  console.print(
207
211
  "[dim]A KEV match is a CRA candidate. Clocks start only after human awareness.[/dim]"
208
212
  )
@@ -51,6 +51,11 @@ class Cache:
51
51
  fetched_at TEXT NOT NULL,
52
52
  vulns_json TEXT NOT NULL
53
53
  );
54
+ CREATE TABLE IF NOT EXISTS osv_vulns (
55
+ vuln_id TEXT PRIMARY KEY,
56
+ fetched_at TEXT NOT NULL,
57
+ vuln_json TEXT NOT NULL
58
+ );
54
59
  CREATE TABLE IF NOT EXISTS scans (
55
60
  id INTEGER PRIMARY KEY AUTOINCREMENT,
56
61
  target TEXT NOT NULL,
@@ -119,6 +124,32 @@ class Cache:
119
124
  return None
120
125
  return json.loads(row["vulns_json"])
121
126
 
127
+ def get_osv_vuln(self, vuln_id: str) -> dict[str, Any] | None:
128
+ row = self.conn.execute(
129
+ "SELECT fetched_at, vuln_json FROM osv_vulns WHERE vuln_id = ?",
130
+ (vuln_id,),
131
+ ).fetchone()
132
+ if not row:
133
+ return None
134
+ fetched = datetime.fromisoformat(row["fetched_at"])
135
+ if datetime.now(timezone.utc) - fetched > OSV_TTL:
136
+ return None
137
+ payload = json.loads(row["vuln_json"])
138
+ return payload if isinstance(payload, dict) else None
139
+
140
+ def save_osv_vuln(self, vuln_id: str, vuln: dict[str, Any]) -> None:
141
+ now = datetime.now(timezone.utc).isoformat()
142
+ self.conn.execute(
143
+ """
144
+ INSERT INTO osv_vulns (vuln_id, fetched_at, vuln_json)
145
+ VALUES (?, ?, ?)
146
+ ON CONFLICT(vuln_id) DO UPDATE SET fetched_at = excluded.fetched_at,
147
+ vuln_json = excluded.vuln_json
148
+ """,
149
+ (vuln_id, now, json.dumps(vuln)),
150
+ )
151
+ self.conn.commit()
152
+
122
153
  def save_osv(self, purl: str, vulns: list[dict[str, Any]]) -> None:
123
154
  now = datetime.now(timezone.utc).isoformat()
124
155
  self.conn.execute(
@@ -0,0 +1,153 @@
1
+ """OSV Query Batch client with 100-item chunks and SQLite cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from opencra_shared.kev import extract_cves
10
+
11
+ from opencra_cli.db import Cache
12
+ from opencra_cli.httputil import USER_AGENT, client
13
+
14
+ logger = logging.getLogger("opencra.osv")
15
+
16
+ OSV_QUERYBATCH = "https://api.osv.dev/v1/querybatch"
17
+ OSV_VULN = "https://api.osv.dev/v1/vulns"
18
+ BATCH_SIZE = 100
19
+ _HYDRATE_KEYS = ("aliases", "summary", "severity", "database_specific")
20
+
21
+
22
+ def _chunks(items: list[str], size: int) -> list[list[str]]:
23
+ return [items[i : i + size] for i in range(0, len(items), size)]
24
+
25
+
26
+ def _vuln_id(vuln: dict[str, Any]) -> str:
27
+ return str(vuln.get("id") or "")
28
+
29
+
30
+ def _has_cve(vuln: dict[str, Any]) -> bool:
31
+ aliases = [str(a) for a in (vuln.get("aliases") or [])]
32
+ return bool(extract_cves(aliases, _vuln_id(vuln)))
33
+
34
+
35
+ def _merge_detail(vuln: dict[str, Any], detail: dict[str, Any]) -> dict[str, Any]:
36
+ merged = dict(vuln)
37
+ for key in _HYDRATE_KEYS:
38
+ if key in detail and not merged.get(key):
39
+ merged[key] = detail[key]
40
+ return merged
41
+
42
+
43
+ def hydrate_vulns(
44
+ vulns: list[dict[str, Any]],
45
+ cache: Cache,
46
+ *,
47
+ offline: bool = False,
48
+ ) -> list[dict[str, Any]]:
49
+ """Fill aliases (and related fields) so KEV can match CVE-IDs.
50
+
51
+ OSV ``querybatch`` returns ``{id, modified}`` stubs. Full records come from
52
+ ``GET /v1/vulns/{id}``. Offline mode never opens HTTP.
53
+ """
54
+ if offline or not vulns:
55
+ return vulns
56
+
57
+ to_fetch: list[str] = []
58
+ for vuln in vulns:
59
+ if not isinstance(vuln, dict) or _has_cve(vuln):
60
+ continue
61
+ vid = _vuln_id(vuln)
62
+ if vid and cache.get_osv_vuln(vid) is None:
63
+ to_fetch.append(vid)
64
+
65
+ if to_fetch:
66
+ with client() as http:
67
+ for vid in dict.fromkeys(to_fetch):
68
+ try:
69
+ response = http.get(f"{OSV_VULN}/{vid}")
70
+ response.raise_for_status()
71
+ body = response.json()
72
+ except httpx.HTTPError as exc:
73
+ logger.warning("OSV vuln %s fetch failed: %s", vid, exc)
74
+ continue
75
+ if isinstance(body, dict):
76
+ cache.save_osv_vuln(vid, body)
77
+
78
+ hydrated: list[dict[str, Any]] = []
79
+ for vuln in vulns:
80
+ if not isinstance(vuln, dict):
81
+ continue
82
+ if _has_cve(vuln):
83
+ hydrated.append(vuln)
84
+ continue
85
+ vid = _vuln_id(vuln)
86
+ detail = cache.get_osv_vuln(vid) if vid else None
87
+ hydrated.append(_merge_detail(vuln, detail) if detail else vuln)
88
+ return hydrated
89
+
90
+
91
+ def query_batch(
92
+ purls: list[str],
93
+ cache: Cache,
94
+ *,
95
+ offline: bool = False,
96
+ ) -> dict[str, list[dict[str, Any]]]:
97
+ """Return {purl: [osv vuln dicts]} for validated PURLs only."""
98
+ results: dict[str, list[dict[str, Any]]] = {}
99
+ missing: list[str] = []
100
+ persist: set[str] = set()
101
+ for purl in purls:
102
+ cached = cache.get_osv(purl)
103
+ if cached is not None:
104
+ results[purl] = cached
105
+ persist.add(purl)
106
+ else:
107
+ missing.append(purl)
108
+
109
+ if offline:
110
+ if missing:
111
+ logger.warning("Offline mode: %s PURLs have no cached OSV data", len(missing))
112
+ for purl in missing:
113
+ results[purl] = []
114
+ return results
115
+
116
+ for chunk in _chunks(missing, BATCH_SIZE):
117
+ payload = {"queries": [{"package": {"purl": purl}} for purl in chunk]}
118
+ try:
119
+ with client() as http:
120
+ response = http.post(OSV_QUERYBATCH, json=payload)
121
+ response.raise_for_status()
122
+ body = response.json()
123
+ except httpx.HTTPError as exc:
124
+ logger.warning("OSV querybatch failed for %s items: %s", len(chunk), exc)
125
+ for purl in chunk:
126
+ results.setdefault(purl, [])
127
+ continue
128
+
129
+ results_list = body.get("results") or []
130
+ for purl, item in zip(chunk, results_list, strict=False):
131
+ vulns = item.get("vulns") or [] if isinstance(item, dict) else []
132
+ results[purl] = vulns if isinstance(vulns, list) else []
133
+ persist.add(purl)
134
+ if len(results_list) < len(chunk):
135
+ for purl in chunk[len(results_list) :]:
136
+ results[purl] = []
137
+ persist.add(purl)
138
+
139
+ for purl, vulns in list(results.items()):
140
+ hydrated = hydrate_vulns(vulns, cache, offline=False)
141
+ results[purl] = hydrated
142
+ if purl in persist:
143
+ cache.save_osv(purl, hydrated)
144
+ return results
145
+
146
+
147
+ def ping() -> bool:
148
+ try:
149
+ with client() as http:
150
+ response = http.get("https://api.osv.dev/v1/querybatch", headers={"User-Agent": USER_AGENT})
151
+ return response.status_code in {200, 405, 415}
152
+ except httpx.HTTPError:
153
+ return False
@@ -43,16 +43,44 @@ def resolve_syft(syft_bin: str | None = None) -> str:
43
43
  return found
44
44
 
45
45
 
46
- def parse_syft_version(text: str) -> tuple[int, int, int] | None:
47
- # Typical: "syft 1.18.1"
46
+ def _parse_version_token(text: str) -> tuple[int, int, int] | None:
48
47
  for token in text.replace(",", " ").split():
49
- parts = token.strip().split(".")
48
+ token = token.strip().lstrip("vV")
49
+ parts = token.split(".")
50
50
  if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
51
51
  patch = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
52
52
  return int(parts[0]), int(parts[1]), patch
53
53
  return None
54
54
 
55
55
 
56
+ def parse_syft_version(text: str) -> tuple[int, int, int] | None:
57
+ """Parse a Syft version from `syft version` or `syft --version` text.
58
+
59
+ Prefer the ``Version:`` line so SchemaVersion (e.g. 16.1.10) is not used.
60
+ """
61
+ for line in text.splitlines():
62
+ if line.strip().lower().startswith("version:"):
63
+ parsed = _parse_version_token(line.split(":", 1)[1])
64
+ if parsed:
65
+ return parsed
66
+ return _parse_version_token(text)
67
+
68
+
69
+ def format_syft_label(output: str, parsed: tuple[int, int, int] | None, fallback: str = "syft") -> str:
70
+ """Human-readable Syft version for `opencra doctor` (not the first metadata line)."""
71
+ if parsed:
72
+ return f"syft {parsed[0]}.{parsed[1]}.{parsed[2]}"
73
+ for line in output.splitlines():
74
+ if line.strip().lower().startswith("version:"):
75
+ value = line.split(":", 1)[1].strip()
76
+ if value:
77
+ return f"syft {value.lstrip('vV')}"
78
+ stripped = output.strip()
79
+ if stripped:
80
+ return stripped.splitlines()[0]
81
+ return fallback
82
+
83
+
56
84
  def syft_version(syft_bin: str | None = None) -> tuple[str, tuple[int, int, int] | None]:
57
85
  binary = resolve_syft(syft_bin)
58
86
  result = subprocess.run(
@@ -72,7 +100,7 @@ def syft_version(syft_bin: str | None = None) -> tuple[str, tuple[int, int, int]
72
100
  )
73
101
  output = (result.stdout or "") + (result.stderr or "")
74
102
  parsed = parse_syft_version(output)
75
- return output.strip().splitlines()[0] if output.strip() else binary, parsed
103
+ return format_syft_label(output, parsed, fallback=binary), parsed
76
104
 
77
105
 
78
106
  def version_ok(version: tuple[int, int, int] | None) -> bool:
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "opencra-cli"
3
- version = "0.1.0"
3
+ version = "0.1.4"
4
4
  description = "OpenCRA command-line scanner — Syft wrapper, OSV + CISA KEV matching."
5
5
  requires-python = ">=3.12"
6
6
  license = { text = "Apache-2.0" }
7
7
  dependencies = [
8
- "opencra-shared",
8
+ "opencra-shared>=0.1.4",
9
9
  "typer>=0.15",
10
10
  "rich>=13.9",
11
11
  "httpx>=0.27",
@@ -1,79 +0,0 @@
1
- """OSV Query Batch client with 100-item chunks and SQLite cache."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
- from typing import Any
7
-
8
- import httpx
9
-
10
- from opencra_cli.db import Cache
11
- from opencra_cli.httputil import USER_AGENT, client
12
-
13
- logger = logging.getLogger("opencra.osv")
14
-
15
- OSV_QUERYBATCH = "https://api.osv.dev/v1/querybatch"
16
- BATCH_SIZE = 100
17
-
18
-
19
- def _chunks(items: list[str], size: int) -> list[list[str]]:
20
- return [items[i : i + size] for i in range(0, len(items), size)]
21
-
22
-
23
- def query_batch(
24
- purls: list[str],
25
- cache: Cache,
26
- *,
27
- offline: bool = False,
28
- ) -> dict[str, list[dict[str, Any]]]:
29
- """Return {purl: [osv vuln dicts]} for validated PURLs only."""
30
- results: dict[str, list[dict[str, Any]]] = {}
31
- missing: list[str] = []
32
- for purl in purls:
33
- cached = cache.get_osv(purl)
34
- if cached is not None:
35
- results[purl] = cached
36
- else:
37
- missing.append(purl)
38
-
39
- if not missing:
40
- return results
41
- if offline:
42
- logger.warning("Offline mode: %s PURLs have no cached OSV data", len(missing))
43
- for purl in missing:
44
- results[purl] = []
45
- return results
46
-
47
- for chunk in _chunks(missing, BATCH_SIZE):
48
- payload = {"queries": [{"package": {"purl": purl}} for purl in chunk]}
49
- try:
50
- with client() as http:
51
- response = http.post(OSV_QUERYBATCH, json=payload)
52
- response.raise_for_status()
53
- body = response.json()
54
- except httpx.HTTPError as exc:
55
- logger.warning("OSV querybatch failed for %s items: %s", len(chunk), exc)
56
- for purl in chunk:
57
- results.setdefault(purl, [])
58
- continue
59
-
60
- results_list = body.get("results") or []
61
- for purl, item in zip(chunk, results_list, strict=False):
62
- vulns = item.get("vulns") or [] if isinstance(item, dict) else []
63
- cache.save_osv(purl, vulns)
64
- results[purl] = vulns
65
- # If OSV returned fewer results than queries, mark leftovers empty.
66
- if len(results_list) < len(chunk):
67
- for purl in chunk[len(results_list) :]:
68
- cache.save_osv(purl, [])
69
- results[purl] = []
70
- return results
71
-
72
-
73
- def ping() -> bool:
74
- try:
75
- with client() as http:
76
- response = http.get("https://api.osv.dev/v1/querybatch", headers={"User-Agent": USER_AGENT})
77
- return response.status_code in {200, 405, 415}
78
- except httpx.HTTPError:
79
- return False
File without changes