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.
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/PKG-INFO +2 -2
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/__init__.py +1 -1
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/app.py +5 -1
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/db.py +31 -0
- opencra_cli-0.1.4/opencra_cli/osv.py +153 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/syft.py +32 -4
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/pyproject.toml +2 -2
- opencra_cli-0.1.0/opencra_cli/osv.py +0 -79
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/.gitignore +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/httputil.py +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/kev.py +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/match.py +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/nvd.py +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/pdf.py +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/py.typed +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/render.py +0 -0
- {opencra_cli-0.1.0 → opencra_cli-0.1.4}/opencra_cli/sync.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opencra-cli
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|