devsecure-intelligence 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.
- devsecure_intelligence/__init__.py +29 -0
- devsecure_intelligence/__main__.py +3 -0
- devsecure_intelligence/cli.py +233 -0
- devsecure_intelligence/client.py +366 -0
- devsecure_intelligence/errors.py +71 -0
- devsecure_intelligence/models.py +64 -0
- devsecure_intelligence/py.typed +0 -0
- devsecure_intelligence-0.1.0.dist-info/METADATA +130 -0
- devsecure_intelligence-0.1.0.dist-info/RECORD +12 -0
- devsecure_intelligence-0.1.0.dist-info/WHEEL +4 -0
- devsecure_intelligence-0.1.0.dist-info/entry_points.txt +2 -0
- devsecure_intelligence-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Python client for the DevSecure Intelligence API: rank CVEs by real-world risk (RPS from CVSS,
|
|
2
|
+
EPSS and CISA KEV), with the reason for every rank. Docs: https://intelligence.devsecure.io/docs"""
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
|
|
6
|
+
from .client import DEFAULT_BASE_URL, Client, extract_cve_ids, normalise_cve_id, rank_key # noqa: E402
|
|
7
|
+
from .errors import ( # noqa: E402
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
DevSecureError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
PlanError,
|
|
12
|
+
QuotaExceededError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"DEFAULT_BASE_URL",
|
|
19
|
+
"Client",
|
|
20
|
+
"extract_cve_ids",
|
|
21
|
+
"normalise_cve_id",
|
|
22
|
+
"rank_key",
|
|
23
|
+
"DevSecureError",
|
|
24
|
+
"AuthenticationError",
|
|
25
|
+
"PlanError",
|
|
26
|
+
"NotFoundError",
|
|
27
|
+
"RateLimitError",
|
|
28
|
+
"QuotaExceededError",
|
|
29
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""``devsecure``: the DevSecure Intelligence API from the terminal.
|
|
2
|
+
|
|
3
|
+
devsecure cve CVE-2024-3400
|
|
4
|
+
devsecure prioritize trivy.json grype.json --fail-on high
|
|
5
|
+
trivy image -f json app:1.2 | devsecure prioritize - --json
|
|
6
|
+
devsecure movers --window 24h --type kev_added,priority_change
|
|
7
|
+
devsecure kev --limit 10
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 done, 1 ``--fail-on`` matched, 2 usage or API error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any, List, Mapping, Optional, Sequence, TextIO
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .client import Client, extract_cve_ids
|
|
21
|
+
from .errors import DevSecureError
|
|
22
|
+
|
|
23
|
+
BAND_LABEL = {
|
|
24
|
+
"critical_priority": "Critical",
|
|
25
|
+
"high_priority": "High",
|
|
26
|
+
"medium_priority": "Medium",
|
|
27
|
+
"low_priority": "Low",
|
|
28
|
+
"informational": "Info",
|
|
29
|
+
"unknown": "Unknown",
|
|
30
|
+
}
|
|
31
|
+
# --fail-on: this band or more urgent fails; "kev" fails on any CISA KEV CVE whatever its band.
|
|
32
|
+
FAIL_BANDS = {
|
|
33
|
+
"critical": {"critical_priority"},
|
|
34
|
+
"high": {"critical_priority", "high_priority"},
|
|
35
|
+
"medium": {"critical_priority", "high_priority", "medium_priority"},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _table(rows: Sequence[Sequence[Any]], header: Sequence[str], out: TextIO) -> None:
|
|
40
|
+
cells = [[str(c) for c in header]] + [["" if c is None else str(c) for c in r] for r in rows]
|
|
41
|
+
widths = [max(len(r[i]) for r in cells) for i in range(len(header))]
|
|
42
|
+
for r in cells:
|
|
43
|
+
# The last column (a reason or name) isn't padded, so long text doesn't push trailing spaces.
|
|
44
|
+
out.write(" ".join(c.ljust(w) for c, w in zip(r[:-1], widths)) + (" " if len(r) > 1 else "") + r[-1] + "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _kev_listed(item: Mapping[str, Any]) -> bool:
|
|
48
|
+
kev = (item.get("evidence") or {}).get("kev") or {}
|
|
49
|
+
return bool(kev.get("listed"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def failing(ranked: Sequence[Mapping[str, Any]], fail_on: Sequence[str]) -> List[Mapping[str, Any]]:
|
|
53
|
+
"""The CVEs that trip any of the --fail-on rules."""
|
|
54
|
+
bands = set().union(*(FAIL_BANDS[f] for f in fail_on if f in FAIL_BANDS)) if fail_on else set()
|
|
55
|
+
on_kev = "kev" in fail_on
|
|
56
|
+
return [r for r in ranked if r.get("priority_band") in bands or (on_kev and _kev_listed(r))]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_inputs(paths: Sequence[str], stdin: TextIO) -> str:
|
|
60
|
+
parts = []
|
|
61
|
+
for p in paths:
|
|
62
|
+
if p == "-":
|
|
63
|
+
parts.append(stdin.read())
|
|
64
|
+
else:
|
|
65
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
66
|
+
parts.append(f.read())
|
|
67
|
+
return "\n".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_cve(client: Client, args: argparse.Namespace, out: TextIO) -> int:
|
|
71
|
+
# No key: the public record (limited per IP) still answers.
|
|
72
|
+
data = client.cve(args.cve_id) if client.api_key else client.public_cve(args.cve_id)
|
|
73
|
+
if args.json:
|
|
74
|
+
json.dump(data, out, indent=2)
|
|
75
|
+
out.write("\n")
|
|
76
|
+
return 0
|
|
77
|
+
d = data.get("data", data)
|
|
78
|
+
rps = d.get("rps") or {}
|
|
79
|
+
kev = d.get("kev") or (d.get("signals") or {}).get("kev") or {}
|
|
80
|
+
expl = d.get("explanation") or {}
|
|
81
|
+
out.write(f"{d.get('cve_id')} {BAND_LABEL.get(str(rps.get('band')), rps.get('band'))} RPS {rps.get('score')}")
|
|
82
|
+
out.write(f" (set by {rps.get('band_basis')})\n" if rps.get("band_basis") else "\n")
|
|
83
|
+
out.write(f"CISA KEV: {'yes' if kev.get('in_catalog') else 'no'}\n")
|
|
84
|
+
if expl.get("summary"):
|
|
85
|
+
out.write(f"{expl['summary']}\n")
|
|
86
|
+
as_of = data.get("data_as_of") or d.get("data_as_of")
|
|
87
|
+
if as_of:
|
|
88
|
+
out.write(f"Data as of {as_of}\n")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_prioritize(client: Client, args: argparse.Namespace, out: TextIO, stdin: TextIO) -> int:
|
|
93
|
+
text = _read_inputs(args.inputs or ["-"], stdin)
|
|
94
|
+
ids = extract_cve_ids(text)
|
|
95
|
+
if not ids:
|
|
96
|
+
sys.stderr.write("No CVE IDs found in the input.\n")
|
|
97
|
+
return 0
|
|
98
|
+
result = client.prioritize(ids, chunk_size=args.chunk_size)
|
|
99
|
+
ranked = result.get("ranked") or []
|
|
100
|
+
fail_on = [f.strip().lower() for f in (args.fail_on or "").split(",") if f.strip()]
|
|
101
|
+
tripped = failing(ranked, fail_on)
|
|
102
|
+
if args.json:
|
|
103
|
+
json.dump(result, out, indent=2)
|
|
104
|
+
out.write("\n")
|
|
105
|
+
else:
|
|
106
|
+
rows = [
|
|
107
|
+
(
|
|
108
|
+
r.get("rank_in_request"),
|
|
109
|
+
r.get("cve_id"),
|
|
110
|
+
BAND_LABEL.get(r.get("priority_band", ""), r.get("priority_band")),
|
|
111
|
+
r.get("rps_score"),
|
|
112
|
+
"yes" if _kev_listed(r) else "",
|
|
113
|
+
r.get("reason", ""),
|
|
114
|
+
)
|
|
115
|
+
for r in ranked
|
|
116
|
+
]
|
|
117
|
+
_table(rows, ["#", "CVE", "PRIORITY", "RPS", "KEV", "REASON"], out)
|
|
118
|
+
out.write(f"\n{result.get('found', 0)} of {result.get('total', len(ids))} found")
|
|
119
|
+
out.write(f"; data as of {result['data_as_of']}\n" if result.get("data_as_of") else "\n")
|
|
120
|
+
if tripped:
|
|
121
|
+
sys.stderr.write(
|
|
122
|
+
f"{len(tripped)} CVE(s) match --fail-on {','.join(fail_on)}: "
|
|
123
|
+
+ ", ".join(str(r.get("cve_id")) for r in tripped)
|
|
124
|
+
+ "\n"
|
|
125
|
+
)
|
|
126
|
+
return 1
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_movers(client: Client, args: argparse.Namespace, out: TextIO) -> int:
|
|
131
|
+
types = [t.strip() for t in args.type.split(",")] if args.type else None
|
|
132
|
+
res = client.movers(window=args.window, scope=args.scope, types=types, limit=args.limit)
|
|
133
|
+
if args.json:
|
|
134
|
+
json.dump(res, out, indent=2)
|
|
135
|
+
out.write("\n")
|
|
136
|
+
return 0
|
|
137
|
+
items = (res.get("data") or {}).get("items") or []
|
|
138
|
+
rows = [
|
|
139
|
+
(
|
|
140
|
+
m.get("occurred_on"),
|
|
141
|
+
m.get("cve_id"),
|
|
142
|
+
m.get("type"),
|
|
143
|
+
BAND_LABEL.get(m.get("priority_now") or "", m.get("priority_now") or ""),
|
|
144
|
+
m.get("reason", ""),
|
|
145
|
+
)
|
|
146
|
+
for m in items
|
|
147
|
+
]
|
|
148
|
+
_table(rows, ["DATE", "CVE", "CHANGE", "PRIORITY NOW", "REASON"], out)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_kev(client: Client, args: argparse.Namespace, out: TextIO) -> int:
|
|
153
|
+
res = client.kev(limit=args.limit)
|
|
154
|
+
if args.json:
|
|
155
|
+
json.dump(res, out, indent=2)
|
|
156
|
+
out.write("\n")
|
|
157
|
+
return 0
|
|
158
|
+
rows = [
|
|
159
|
+
(r.get("rps_score"), r.get("cve_id"), (r.get("kev") or {}).get("vendor_project"), (r.get("kev") or {}).get("product"))
|
|
160
|
+
for r in res.get("results") or []
|
|
161
|
+
]
|
|
162
|
+
_table(rows, ["RPS", "CVE", "VENDOR", "PRODUCT"], out)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
167
|
+
p = argparse.ArgumentParser(
|
|
168
|
+
prog="devsecure", description="DevSecure Intelligence from the terminal. Reads DEVSECURE_API_KEY."
|
|
169
|
+
)
|
|
170
|
+
p.add_argument("--version", action="version", version=f"devsecure {__version__}")
|
|
171
|
+
p.add_argument("--json", action="store_true", help="print the API's JSON instead of a table")
|
|
172
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
173
|
+
|
|
174
|
+
c = sub.add_parser("cve", help="one CVE: Final Priority, RPS, KEV and the reason (no key: the public record)")
|
|
175
|
+
c.add_argument("cve_id")
|
|
176
|
+
|
|
177
|
+
pr = sub.add_parser("prioritize", help="rank the CVEs found in scanner output, SBOMs or any text files ('-' = stdin)")
|
|
178
|
+
pr.add_argument("inputs", nargs="*", metavar="FILE")
|
|
179
|
+
pr.add_argument(
|
|
180
|
+
"--fail-on", metavar="RULES", help="exit 1 if any CVE matches: critical, high, medium and/or kev (comma-separated)"
|
|
181
|
+
)
|
|
182
|
+
pr.add_argument("--chunk-size", type=int, default=None, help="IDs per call (default: your plan's limit)")
|
|
183
|
+
|
|
184
|
+
m = sub.add_parser("movers", help="what changed: KEV additions, priority changes, new exploits, fixes, EPSS jumps")
|
|
185
|
+
m.add_argument("--window", choices=["24h", "7d"], default="7d")
|
|
186
|
+
m.add_argument("--scope", choices=["actionable", "all"], default=None)
|
|
187
|
+
m.add_argument(
|
|
188
|
+
"--type", help="comma-separated: priority_change, kev_added, metasploit_module, exploitdb_added, fix_found, epss_jump"
|
|
189
|
+
)
|
|
190
|
+
m.add_argument("--limit", type=int, default=None)
|
|
191
|
+
|
|
192
|
+
k = sub.add_parser("kev", help="the CISA KEV catalogue ranked by RPS (no key needed)")
|
|
193
|
+
k.add_argument("--limit", type=int, default=20)
|
|
194
|
+
return p
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main(
|
|
198
|
+
argv: Optional[Sequence[str]] = None, *, client: Optional[Client] = None, out: TextIO = sys.stdout, stdin: TextIO = sys.stdin
|
|
199
|
+
) -> int:
|
|
200
|
+
parser = build_parser()
|
|
201
|
+
args = parser.parse_args(argv)
|
|
202
|
+
if args.command == "prioritize" and args.fail_on:
|
|
203
|
+
bad = [f for f in args.fail_on.split(",") if f.strip().lower() not in (*FAIL_BANDS, "kev")]
|
|
204
|
+
if bad:
|
|
205
|
+
parser.error(f"--fail-on: unknown rule(s) {', '.join(bad)}; use critical, high, medium, kev")
|
|
206
|
+
try:
|
|
207
|
+
client = client or Client()
|
|
208
|
+
if args.command == "cve":
|
|
209
|
+
return cmd_cve(client, args, out)
|
|
210
|
+
if args.command == "prioritize":
|
|
211
|
+
return cmd_prioritize(client, args, out, stdin)
|
|
212
|
+
if args.command == "movers":
|
|
213
|
+
return cmd_movers(client, args, out)
|
|
214
|
+
return cmd_kev(client, args, out)
|
|
215
|
+
except DevSecureError as e:
|
|
216
|
+
sys.stderr.write(f"devsecure: {e}\n")
|
|
217
|
+
if e.docs_url:
|
|
218
|
+
sys.stderr.write(f" {e.docs_url}\n")
|
|
219
|
+
return 2
|
|
220
|
+
except (OSError, ValueError) as e:
|
|
221
|
+
sys.stderr.write(f"devsecure: {e}\n")
|
|
222
|
+
return 2
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def entry() -> None: # console script
|
|
226
|
+
sys.exit(main())
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__": # pragma: no cover
|
|
230
|
+
entry()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
__all__: List[str] = ["main", "failing", "build_parser"]
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Client for the DevSecure Intelligence API (https://api.intelligence.devsecure.io).
|
|
2
|
+
|
|
3
|
+
Standard library only. Every call goes to the same endpoints and limits as the HTTP API, with your
|
|
4
|
+
own key. Retries: network errors, timeouts, 5xx and ``429 rate_limited`` (honouring ``Retry-After``),
|
|
5
|
+
with exponential backoff; never ``quota_exceeded`` or other 4xx.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union, cast
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .errors import DevSecureError, error_for
|
|
22
|
+
from .models import BAND_ORDER, Mover, PrioritizeResult, Quota, RankedCve
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.intelligence.devsecure.io"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0 # the API's own request timeout, in seconds
|
|
26
|
+
MAX_IDS = 1000 # the largest per-call list on any plan; the API enforces your plan's own (Free: 25)
|
|
27
|
+
MAX_RETRY_WAIT = 60.0 # never sleep longer than this for one retry, whatever Retry-After says
|
|
28
|
+
|
|
29
|
+
CVE_ID = re.compile(r"^CVE-\d{4}-\d{4,}$")
|
|
30
|
+
CVE_IN_TEXT = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)
|
|
31
|
+
SIGNUP_URL = "https://intelligence.devsecure.io/signup"
|
|
32
|
+
STATUS_URL = "https://intelligence.devsecure.io/status"
|
|
33
|
+
|
|
34
|
+
# (method, url, headers, body, timeout) -> (status, headers, body bytes). Injectable for tests.
|
|
35
|
+
Transport = Callable[[str, str, Mapping[str, str], Optional[bytes], float], Tuple[int, Mapping[str, str], bytes]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normalise_cve_id(value: str) -> str:
|
|
39
|
+
"""Upper-case and trim; raise for anything that isn't a CVE ID (the API would 400 anyway)."""
|
|
40
|
+
cve_id = str(value or "").strip().upper()
|
|
41
|
+
if not CVE_ID.match(cve_id):
|
|
42
|
+
raise DevSecureError(400, "invalid_cve_id", f'"{value}" is not a CVE ID (they look like CVE-2021-44228).')
|
|
43
|
+
return cve_id
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_cve_ids(text: str) -> List[str]:
|
|
47
|
+
"""Every distinct CVE ID in a scanner report, SBOM or any text, in first-seen order."""
|
|
48
|
+
seen: Dict[str, None] = {}
|
|
49
|
+
for match in CVE_IN_TEXT.findall(text or ""):
|
|
50
|
+
seen.setdefault(match.upper(), None)
|
|
51
|
+
return list(seen)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rank_key(item: Mapping[str, Any]) -> Tuple[int, float]:
|
|
55
|
+
"""The API's ranking: band first (a KEV CVE floored at High never ranks under a Medium), then RPS."""
|
|
56
|
+
band = item.get("priority_band")
|
|
57
|
+
band_rank = BAND_ORDER.index(band) if band in BAND_ORDER else len(BAND_ORDER)
|
|
58
|
+
return band_rank, -float(item.get("rps_score") or 0)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _urllib_transport(
|
|
62
|
+
method: str, url: str, headers: Mapping[str, str], body: Optional[bytes], timeout: float
|
|
63
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
64
|
+
req = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
|
|
65
|
+
try:
|
|
66
|
+
# B310: Client accepts only https:// or http://localhost base URLs, never file: or custom schemes.
|
|
67
|
+
with urllib.request.urlopen(req, timeout=timeout) as res: # nosec B310
|
|
68
|
+
return res.status, dict(res.headers.items()), res.read()
|
|
69
|
+
except urllib.error.HTTPError as e:
|
|
70
|
+
return e.code, dict(e.headers.items()) if e.headers else {}, e.read() or b""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
74
|
+
lname = name.lower()
|
|
75
|
+
for k, v in headers.items():
|
|
76
|
+
if k.lower() == lname:
|
|
77
|
+
return v
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
82
|
+
value = _header(headers, "Retry-After")
|
|
83
|
+
try:
|
|
84
|
+
return max(0.0, float(value)) if value is not None else None
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Client:
|
|
90
|
+
"""DevSecure Intelligence API client.
|
|
91
|
+
|
|
92
|
+
>>> from devsecure_intelligence import Client
|
|
93
|
+
>>> client = Client() # reads DEVSECURE_API_KEY
|
|
94
|
+
>>> client.prioritize(["CVE-2021-44228", "CVE-2024-3400"])["ranked"][0]["priority_band"]
|
|
95
|
+
'critical_priority'
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
api_key: Optional[str] = None,
|
|
101
|
+
*,
|
|
102
|
+
base_url: Optional[str] = None,
|
|
103
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
104
|
+
max_retries: int = 3,
|
|
105
|
+
user_agent: Optional[str] = None,
|
|
106
|
+
transport: Optional[Transport] = None,
|
|
107
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
108
|
+
) -> None:
|
|
109
|
+
self.api_key = api_key if api_key is not None else os.environ.get("DEVSECURE_API_KEY") or None
|
|
110
|
+
self.base_url = (base_url or os.environ.get("DEVSECURE_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
|
|
111
|
+
if not self.base_url.startswith("https://") and not re.match(r"^http://(localhost|127\.0\.0\.1)(:|/|$)", self.base_url):
|
|
112
|
+
# The key travels in a header: refuse to send it in clear text anywhere but localhost.
|
|
113
|
+
raise ValueError(f"base_url must be https:// (got {self.base_url!r}).")
|
|
114
|
+
self.timeout = timeout
|
|
115
|
+
self.max_retries = max(0, int(max_retries))
|
|
116
|
+
self.user_agent = user_agent or f"devsecure-intelligence-python/{__version__}"
|
|
117
|
+
self._transport = transport or _urllib_transport
|
|
118
|
+
self._sleep = sleep
|
|
119
|
+
#: Rate-limit and quota headers from the most recent response.
|
|
120
|
+
self.last_quota: Quota = {}
|
|
121
|
+
#: ``X-Request-Id`` of the most recent response; quote it to support.
|
|
122
|
+
self.last_request_id: Optional[str] = None
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str: # never print the key
|
|
125
|
+
return f"Client(base_url={self.base_url!r}, api_key={'set' if self.api_key else 'not set'})"
|
|
126
|
+
|
|
127
|
+
# -- transport -------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def request(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
*,
|
|
134
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
135
|
+
json_body: Any = None,
|
|
136
|
+
auth: bool = True,
|
|
137
|
+
) -> Any:
|
|
138
|
+
"""Call any endpoint and return the parsed JSON. Raises :class:`DevSecureError` subclasses."""
|
|
139
|
+
if auth and not self.api_key:
|
|
140
|
+
raise error_for(401, "unauthorized")(
|
|
141
|
+
401, "unauthorized", f"Set DEVSECURE_API_KEY or pass api_key= (get a free key at {SIGNUP_URL})."
|
|
142
|
+
)
|
|
143
|
+
query = {k: ("true" if v is True else "false" if v is False else v) for k, v in (params or {}).items() if v is not None}
|
|
144
|
+
url = self.base_url + path + (("?" + urllib.parse.urlencode(query, doseq=True)) if query else "")
|
|
145
|
+
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
|
|
146
|
+
if auth:
|
|
147
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
148
|
+
body = None
|
|
149
|
+
if json_body is not None:
|
|
150
|
+
headers["Content-Type"] = "application/json"
|
|
151
|
+
body = json.dumps(json_body).encode("utf-8")
|
|
152
|
+
|
|
153
|
+
attempt = 0
|
|
154
|
+
while True:
|
|
155
|
+
try:
|
|
156
|
+
status, res_headers, raw = self._transport(method, url, headers, body, self.timeout)
|
|
157
|
+
except Exception as exc: # timeouts and connection errors from the transport
|
|
158
|
+
timed_out = "timed out" in str(exc).lower() or isinstance(exc, TimeoutError)
|
|
159
|
+
err = DevSecureError(
|
|
160
|
+
504 if timed_out else 503,
|
|
161
|
+
"timeout" if timed_out else "network_error",
|
|
162
|
+
(
|
|
163
|
+
f"The API did not answer within {self.timeout:g} s. Status: {STATUS_URL}"
|
|
164
|
+
if timed_out
|
|
165
|
+
else f"Could not reach {self.base_url} ({exc}). Check the network or DEVSECURE_API_BASE."
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
if attempt < self.max_retries:
|
|
169
|
+
attempt += 1
|
|
170
|
+
self._sleep(self._backoff(attempt, None))
|
|
171
|
+
continue
|
|
172
|
+
raise err from exc
|
|
173
|
+
|
|
174
|
+
self._record(res_headers)
|
|
175
|
+
payload = None
|
|
176
|
+
if raw:
|
|
177
|
+
try:
|
|
178
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
179
|
+
except ValueError:
|
|
180
|
+
payload = None
|
|
181
|
+
if 200 <= status < 300:
|
|
182
|
+
return payload
|
|
183
|
+
|
|
184
|
+
e = payload.get("error", {}) if isinstance(payload, dict) and isinstance(payload.get("error"), dict) else {}
|
|
185
|
+
code = e.get("code") or ("rate_limited" if status == 429 else "http_error")
|
|
186
|
+
retry_after = _retry_after(res_headers)
|
|
187
|
+
retryable = status >= 500 or (status == 429 and code != "quota_exceeded")
|
|
188
|
+
if retryable and attempt < self.max_retries:
|
|
189
|
+
attempt += 1
|
|
190
|
+
self._sleep(self._backoff(attempt, retry_after))
|
|
191
|
+
continue
|
|
192
|
+
raise error_for(status, code)(
|
|
193
|
+
status,
|
|
194
|
+
code,
|
|
195
|
+
e.get("message") or f"HTTP {status}",
|
|
196
|
+
docs_url=e.get("docs_url"),
|
|
197
|
+
request_id=e.get("request_id") or self.last_request_id,
|
|
198
|
+
retry_after=retry_after,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _call(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
|
202
|
+
return cast(Dict[str, Any], self.request(method, path, **kwargs))
|
|
203
|
+
|
|
204
|
+
def _backoff(self, attempt: int, retry_after: Optional[float]) -> float:
|
|
205
|
+
if retry_after is not None:
|
|
206
|
+
return min(retry_after, MAX_RETRY_WAIT)
|
|
207
|
+
# B311: retry jitter, not a secret.
|
|
208
|
+
return float(min(0.5 * (2 ** (attempt - 1)) + random.uniform(0, 0.25), MAX_RETRY_WAIT)) # nosec B311
|
|
209
|
+
|
|
210
|
+
def _record(self, headers: Mapping[str, str]) -> None:
|
|
211
|
+
quota: Quota = {}
|
|
212
|
+
for key, name in (
|
|
213
|
+
("rate_limit", "X-RateLimit-Limit"),
|
|
214
|
+
("rate_limit_remaining", "X-RateLimit-Remaining"),
|
|
215
|
+
("rate_limit_reset", "X-RateLimit-Reset"),
|
|
216
|
+
("quota_limit", "X-Quota-Limit"),
|
|
217
|
+
("quota_remaining", "X-Quota-Remaining"),
|
|
218
|
+
):
|
|
219
|
+
value = _header(headers, name)
|
|
220
|
+
if value is not None and value.strip().lstrip("-").isdigit():
|
|
221
|
+
quota[key] = int(value) # type: ignore[literal-required]
|
|
222
|
+
self.last_quota = quota
|
|
223
|
+
self.last_request_id = _header(headers, "X-Request-Id")
|
|
224
|
+
|
|
225
|
+
# -- lookups (any plan) ----------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def cve(self, cve_id: str) -> Dict[str, Any]:
|
|
228
|
+
"""``GET /api/v1/cve/{id}``: RPS score, signals, evidence and a plain-English explanation."""
|
|
229
|
+
return self._call("GET", f"/api/v1/cve/{normalise_cve_id(cve_id)}")
|
|
230
|
+
|
|
231
|
+
def rps(self, cve_id: str) -> Dict[str, Any]:
|
|
232
|
+
"""``GET /api/v1/rps/{id}``: the RPS score and Final Priority only."""
|
|
233
|
+
return self._call("GET", f"/api/v1/rps/{normalise_cve_id(cve_id)}")
|
|
234
|
+
|
|
235
|
+
def cve_batch(self, cve_ids: Iterable[str]) -> Dict[str, Any]:
|
|
236
|
+
"""``POST /api/v1/cve/batch``: full records for up to your plan's limit (Free 100, Pro+ 1,000)."""
|
|
237
|
+
ids = [normalise_cve_id(c) for c in cve_ids]
|
|
238
|
+
return self._call("POST", "/api/v1/cve/batch", json_body={"cve_ids": ids})
|
|
239
|
+
|
|
240
|
+
def prioritize(self, cve_ids: Union[str, Iterable[str]], *, chunk_size: Optional[int] = None) -> PrioritizeResult:
|
|
241
|
+
"""``POST /api/v1/prioritize``: rank CVEs into a fix order, with the reason for each.
|
|
242
|
+
|
|
243
|
+
``cve_ids`` may be a list of IDs or any text (a scanner report, SBOM); IDs are extracted and
|
|
244
|
+
de-duplicated. Lists longer than ``chunk_size`` are sent in several calls and merged into
|
|
245
|
+
one ranking, in the API's own order. Without ``chunk_size``, a list over your plan's per-call
|
|
246
|
+
limit (Free: 25) is retried in chunks of that limit, read from the API's ``list_too_long`` error.
|
|
247
|
+
"""
|
|
248
|
+
ids = extract_cve_ids(cve_ids) if isinstance(cve_ids, str) else list(dict.fromkeys(normalise_cve_id(c) for c in cve_ids))
|
|
249
|
+
if not ids:
|
|
250
|
+
raise DevSecureError(400, "bad_request", "No CVE IDs to prioritize.")
|
|
251
|
+
size = min(chunk_size or MAX_IDS, MAX_IDS)
|
|
252
|
+
if size < 1:
|
|
253
|
+
raise ValueError("chunk_size must be at least 1.")
|
|
254
|
+
if len(ids) <= size:
|
|
255
|
+
try:
|
|
256
|
+
return cast(PrioritizeResult, self._call("POST", "/api/v1/prioritize", json_body={"cve_ids": ids}))
|
|
257
|
+
except DevSecureError as e:
|
|
258
|
+
limit = _plan_limit(e)
|
|
259
|
+
if chunk_size is not None or limit is None or limit >= len(ids):
|
|
260
|
+
raise
|
|
261
|
+
size = limit
|
|
262
|
+
return _merge_prioritize(
|
|
263
|
+
[
|
|
264
|
+
cast(PrioritizeResult, self._call("POST", "/api/v1/prioritize", json_body={"cve_ids": ids[i : i + size]}))
|
|
265
|
+
for i in range(0, len(ids), size)
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def cwe_cves(self, cwe_id: Union[str, int], **filters: Any) -> Dict[str, Any]:
|
|
270
|
+
"""``GET /api/v1/cwe/{cwe}/cves``: CVEs for a weakness. Filters: ``severity``, ``has_patch``,
|
|
271
|
+
``is_kev``, ``epss_min``, ``limit``, ``offset``, ``sort``."""
|
|
272
|
+
cwe = str(cwe_id).upper()
|
|
273
|
+
cwe = cwe if cwe.startswith("CWE-") else f"CWE-{cwe}"
|
|
274
|
+
return self._call("GET", f"/api/v1/cwe/{urllib.parse.quote(cwe)}/cves", params=filters)
|
|
275
|
+
|
|
276
|
+
def recent(self, *, since: Optional[str] = None, sort: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
277
|
+
"""``GET /api/v1/cve/recent``: CVEs changed since a time (ISO 8601), to keep a local copy in sync."""
|
|
278
|
+
return self._call("GET", "/api/v1/cve/recent", params={"since": since, "sort": sort, "limit": limit})
|
|
279
|
+
|
|
280
|
+
def movers(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
window: str = "7d",
|
|
284
|
+
scope: Optional[str] = None,
|
|
285
|
+
types: Optional[Sequence[str]] = None,
|
|
286
|
+
limit: Optional[int] = None,
|
|
287
|
+
) -> Dict[str, Any]:
|
|
288
|
+
"""``GET /api/v1/movers``: what changed (KEV additions, priority changes, new exploits, fixes,
|
|
289
|
+
EPSS jumps) in the last ``24h`` or ``7d``. Items are in ``["data"]["items"]``."""
|
|
290
|
+
return self._call(
|
|
291
|
+
"GET",
|
|
292
|
+
"/api/v1/movers",
|
|
293
|
+
params={"window": window, "scope": scope, "type": ",".join(types) if types else None, "limit": limit},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def iter_movers(self, **kwargs: Any) -> Iterable[Mover]:
|
|
297
|
+
"""The movers items themselves."""
|
|
298
|
+
return iter((self.movers(**kwargs).get("data") or {}).get("items") or [])
|
|
299
|
+
|
|
300
|
+
# -- enrichment (Pro and above; patches on Business and Enterprise) --------------------
|
|
301
|
+
|
|
302
|
+
def intelligence(self, cve_id: str) -> Dict[str, Any]:
|
|
303
|
+
"""``GET /api/v1/cve/{id}/intelligence``: affected products, CWE mappings, remediation (Pro+)."""
|
|
304
|
+
return self._call("GET", f"/api/v1/cve/{normalise_cve_id(cve_id)}/intelligence")
|
|
305
|
+
|
|
306
|
+
def patches(self, cve_id: str) -> Dict[str, Any]:
|
|
307
|
+
"""``GET /api/v1/patches/{id}``: fix commits and patched files (Business and Enterprise)."""
|
|
308
|
+
return self._call("GET", f"/api/v1/patches/{normalise_cve_id(cve_id)}")
|
|
309
|
+
|
|
310
|
+
# -- no key needed (limited per IP) ----------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def public_cve(self, cve_id: str) -> Dict[str, Any]:
|
|
313
|
+
"""``GET /api/v1/public/cve/{id}``: one public CVE record, no key needed."""
|
|
314
|
+
return self._call("GET", f"/api/v1/public/cve/{normalise_cve_id(cve_id)}", auth=False)
|
|
315
|
+
|
|
316
|
+
def kev(self, *, limit: Optional[int] = None, offset: Optional[int] = None) -> Dict[str, Any]:
|
|
317
|
+
"""``GET /api/v1/kev``: the CISA KEV catalogue ranked by RPS. Cached an hour; fetch at most hourly."""
|
|
318
|
+
return self._call("GET", "/api/v1/kev", params={"limit": limit, "offset": offset}, auth=False)
|
|
319
|
+
|
|
320
|
+
def stats(self) -> Dict[str, Any]:
|
|
321
|
+
"""``GET /api/v1/stats``: corpus totals."""
|
|
322
|
+
return self._call("GET", "/api/v1/stats", auth=False)
|
|
323
|
+
|
|
324
|
+
def status(self) -> Dict[str, Any]:
|
|
325
|
+
"""``GET /api/v1/status``: data freshness (``data_freshness.last_data_refresh_utc``)."""
|
|
326
|
+
return self._call("GET", "/api/v1/status", auth=False)
|
|
327
|
+
|
|
328
|
+
def health(self) -> Dict[str, Any]:
|
|
329
|
+
"""``GET /api/v1/health``: liveness and the API release."""
|
|
330
|
+
return self._call("GET", "/api/v1/health", auth=False)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _plan_limit(e: DevSecureError) -> Optional[int]:
|
|
334
|
+
"""The per-call maximum from a ``list_too_long`` error ("Maximum 25 CVEs per request ...")."""
|
|
335
|
+
if e.code != "list_too_long":
|
|
336
|
+
return None
|
|
337
|
+
m = re.search(r"Maximum (\d+)", e.message)
|
|
338
|
+
return int(m.group(1)) if m and int(m.group(1)) > 0 else None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _merge_prioritize(parts: List[PrioritizeResult]) -> PrioritizeResult:
|
|
342
|
+
"""One Prioritize result from several calls: found CVEs re-ranked in the API's order, then the
|
|
343
|
+
not-found ones (band ``unknown``), with ``rank_in_request`` renumbered across the whole list."""
|
|
344
|
+
found: List[RankedCve] = []
|
|
345
|
+
unknown: List[RankedCve] = []
|
|
346
|
+
not_found: List[str] = []
|
|
347
|
+
as_of: List[str] = []
|
|
348
|
+
for part in parts:
|
|
349
|
+
for item in part.get("ranked") or []:
|
|
350
|
+
(unknown if item.get("priority_band") == "unknown" else found).append(dict(item)) # type: ignore[arg-type]
|
|
351
|
+
not_found.extend(part.get("not_found") or [])
|
|
352
|
+
if part.get("data_as_of"):
|
|
353
|
+
as_of.append(str(part["data_as_of"]))
|
|
354
|
+
found.sort(key=rank_key)
|
|
355
|
+
ranked = found + unknown
|
|
356
|
+
for i, item in enumerate(ranked, 1):
|
|
357
|
+
item["rank_in_request"] = i
|
|
358
|
+
return {
|
|
359
|
+
"total": sum(int(p.get("total") or 0) for p in parts),
|
|
360
|
+
"found": sum(int(p.get("found") or 0) for p in parts),
|
|
361
|
+
"not_found": not_found,
|
|
362
|
+
"ranked": ranked,
|
|
363
|
+
# The oldest build any part came from: the merged answer is only as current as that.
|
|
364
|
+
"data_as_of": min(as_of) if as_of else None,
|
|
365
|
+
"status": "success",
|
|
366
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Errors raised by the client. Every API error has one shape:
|
|
2
|
+
``{"error": {"code", "message", "status", "request_id", "docs_url"}}``; branch on ``code``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DevSecureError(Exception):
|
|
10
|
+
"""An error from the API, or a network failure reaching it.
|
|
11
|
+
|
|
12
|
+
``code`` is the API's machine-readable code (``unauthorized``, ``forbidden_plan``,
|
|
13
|
+
``not_found``, ``rate_limited``, ``quota_exceeded``, ...), or ``timeout`` /
|
|
14
|
+
``network_error`` when no response arrived.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
status: int,
|
|
20
|
+
code: str,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
docs_url: Optional[str] = None,
|
|
24
|
+
request_id: Optional[str] = None,
|
|
25
|
+
retry_after: Optional[float] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status = status
|
|
29
|
+
self.code = code
|
|
30
|
+
self.message = message
|
|
31
|
+
self.docs_url = docs_url
|
|
32
|
+
self.request_id = request_id
|
|
33
|
+
self.retry_after = retry_after
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
extra = f" (request_id {self.request_id})" if self.request_id else ""
|
|
37
|
+
return f"{self.status} {self.code}: {self.message}{extra}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthenticationError(DevSecureError):
|
|
41
|
+
"""401: missing, invalid or revoked API key."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PlanError(DevSecureError):
|
|
45
|
+
"""403 ``forbidden_plan``: the key's plan doesn't include this endpoint."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NotFoundError(DevSecureError):
|
|
49
|
+
"""404: the CVE (or other resource) isn't in the corpus."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimitError(DevSecureError):
|
|
53
|
+
"""429 ``rate_limited``: too many requests this minute. ``retry_after`` is in seconds."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class QuotaExceededError(DevSecureError):
|
|
57
|
+
"""429 ``quota_exceeded``: the month's allowance is used. Retrying won't help until the 1st (UTC)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def error_for(status: int, code: str) -> type:
|
|
61
|
+
if status == 401:
|
|
62
|
+
return AuthenticationError
|
|
63
|
+
if code == "forbidden_plan":
|
|
64
|
+
return PlanError
|
|
65
|
+
if status == 404:
|
|
66
|
+
return NotFoundError
|
|
67
|
+
if code == "quota_exceeded":
|
|
68
|
+
return QuotaExceededError
|
|
69
|
+
if status == 429:
|
|
70
|
+
return RateLimitError
|
|
71
|
+
return DevSecureError
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Typed shapes for the most used responses (from ``/docs/openapi.yaml``). The API adds fields
|
|
2
|
+
within v1 without notice, so these are ``total=False`` and responses keep any extra keys."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
7
|
+
|
|
8
|
+
PriorityBand = Literal["critical_priority", "high_priority", "medium_priority", "low_priority", "informational", "unknown"]
|
|
9
|
+
BandBasis = Literal["rps", "kev_floor", "epss_gate"]
|
|
10
|
+
MoverType = Literal["priority_change", "kev_added", "metasploit_module", "exploitdb_added", "fix_found", "epss_jump"]
|
|
11
|
+
|
|
12
|
+
#: Most urgent first; the order the API ranks by (band, then RPS score within a band).
|
|
13
|
+
BAND_ORDER: List[str] = ["critical_priority", "high_priority", "medium_priority", "low_priority", "informational"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RankedCve(TypedDict, total=False):
|
|
17
|
+
"""One CVE in a Prioritize response. ``priority_band`` is the Final Priority (after the
|
|
18
|
+
CISA KEV floor and the EPSS gate); ``band_basis`` says which rule set it."""
|
|
19
|
+
|
|
20
|
+
cve_id: str
|
|
21
|
+
rps_score: float
|
|
22
|
+
priority_band: PriorityBand
|
|
23
|
+
band_basis: BandBasis
|
|
24
|
+
rank_in_request: int
|
|
25
|
+
confidence: str
|
|
26
|
+
evidence: Dict[str, Any]
|
|
27
|
+
data_quality: Dict[str, Any]
|
|
28
|
+
reason: str
|
|
29
|
+
sla_guidance: str
|
|
30
|
+
recommended_next_step: str
|
|
31
|
+
scored_at: Optional[str]
|
|
32
|
+
last_modified: Optional[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PrioritizeResult(TypedDict, total=False):
|
|
36
|
+
total: int
|
|
37
|
+
found: int
|
|
38
|
+
not_found: List[str]
|
|
39
|
+
ranked: List[RankedCve]
|
|
40
|
+
data_as_of: Optional[str]
|
|
41
|
+
status: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Mover(TypedDict, total=False):
|
|
45
|
+
cve_id: str
|
|
46
|
+
type: MoverType
|
|
47
|
+
occurred_on: str
|
|
48
|
+
priority_before: str
|
|
49
|
+
priority_after: str
|
|
50
|
+
priority_now: Optional[str]
|
|
51
|
+
direction: Literal["up", "down"]
|
|
52
|
+
reason: str
|
|
53
|
+
epss_percentile: Dict[str, float]
|
|
54
|
+
actionable: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Quota(TypedDict, total=False):
|
|
58
|
+
"""From the last response's headers; a key absent from the response is absent here."""
|
|
59
|
+
|
|
60
|
+
rate_limit: int
|
|
61
|
+
rate_limit_remaining: int
|
|
62
|
+
rate_limit_reset: int
|
|
63
|
+
quota_limit: int
|
|
64
|
+
quota_remaining: int
|
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devsecure-intelligence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the DevSecure Intelligence API: rank CVEs by real-world risk (RPS from CVSS, EPSS and CISA KEV), with the reason for every rank.
|
|
5
|
+
Project-URL: Homepage, https://intelligence.devsecure.io
|
|
6
|
+
Project-URL: Documentation, https://intelligence.devsecure.io/docs/guides/python
|
|
7
|
+
Author: DevSecure Limited
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cisa-kev,cve,cvss,epss,prioritisation,sbom,security,vulnerability
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# DevSecure Intelligence for Python
|
|
21
|
+
|
|
22
|
+
Rank CVEs by real-world risk from Python. Each CVE gets an RPS score (from CVSS, EPSS and CISA KEV), a Final Priority, and the reason for its rank. Exploit and fix evidence come with it.
|
|
23
|
+
|
|
24
|
+
- Standard library only: no dependencies.
|
|
25
|
+
- Typed (`py.typed`), Python 3.10+.
|
|
26
|
+
- Retries network errors, 5xx and `429 rate_limited`, honouring `Retry-After`. It never retries `quota_exceeded`.
|
|
27
|
+
- Accepts a list of IDs or a whole scanner report, splits long lists to fit your plan, and merges the results into one ranking.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install devsecure-intelligence
|
|
31
|
+
export DEVSECURE_API_KEY=dsec_live_... # free key: https://intelligence.devsecure.io/signup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Rank a scanner's findings
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from devsecure_intelligence import Client
|
|
39
|
+
|
|
40
|
+
client = Client() # reads DEVSECURE_API_KEY
|
|
41
|
+
result = client.prioritize(Path("trivy.json").read_text()) # any text that mentions CVE IDs
|
|
42
|
+
|
|
43
|
+
for cve in result["ranked"]:
|
|
44
|
+
print(cve["rank_in_request"], cve["cve_id"], cve["priority_band"], cve["rps_score"], cve["reason"])
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`priority_band` is the **Final Priority**: the RPS band after the CISA KEV floor and the EPSS gate. `band_basis` says which rule set it (`rps`, `kev_floor` or `epss_gate`). CVEs not in the corpus come last with band `unknown` and are listed in `not_found`.
|
|
48
|
+
|
|
49
|
+
Free keys take 25 IDs per call and Pro and above take 1,000. You can pass a longer list anyway: if the API rejects it as `list_too_long`, the client re-sends it in chunks of your plan's limit and merges them. The merged list uses the API's own order: band first, then RPS within a band. Pass `chunk_size=` to choose the size yourself.
|
|
50
|
+
|
|
51
|
+
## Fail a CI step on High, Critical or KEV
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import sys
|
|
55
|
+
blocking = [c for c in result["ranked"]
|
|
56
|
+
if c["priority_band"] in ("critical_priority", "high_priority") or (c.get("evidence") or {}).get("kev", {}).get("listed")]
|
|
57
|
+
for c in blocking:
|
|
58
|
+
print(f"{c['cve_id']}\t{c['priority_band']}\t{c['reason']}")
|
|
59
|
+
sys.exit(1 if blocking else 0)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Your organisation decides what blocks a build. For a ready-made GitHub Action with allowlists, VEX and a job summary, see the [CI gate](https://intelligence.devsecure.io/docs/guides/ci-gate).
|
|
63
|
+
|
|
64
|
+
## Other calls
|
|
65
|
+
|
|
66
|
+
| Method | Endpoint | Plan |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `cve(id)`, `rps(id)` | `GET /api/v1/cve/{id}`, `/rps/{id}` | Any key |
|
|
69
|
+
| `cve_batch(ids)` | `POST /api/v1/cve/batch` | Any key (Free 100 IDs, Pro+ 1,000) |
|
|
70
|
+
| `prioritize(ids_or_text, chunk_size=None)` | `POST /api/v1/prioritize` | Any key |
|
|
71
|
+
| `movers(window="7d", types=[...])`, `iter_movers(...)` | `GET /api/v1/movers` | Any key |
|
|
72
|
+
| `recent(since=...)` | `GET /api/v1/cve/recent` | Any key |
|
|
73
|
+
| `cwe_cves(79, is_kev=True)` | `GET /api/v1/cwe/{cwe}/cves` | Any key |
|
|
74
|
+
| `intelligence(id)` | `GET /api/v1/cve/{id}/intelligence` | Pro and above |
|
|
75
|
+
| `patches(id)` | `GET /api/v1/patches/{id}` | Business and Enterprise |
|
|
76
|
+
| `public_cve(id)`, `kev()`, `stats()`, `status()`, `health()` | public endpoints | No key needed |
|
|
77
|
+
| `request(method, path, params=..., json_body=...)` | anything else | |
|
|
78
|
+
|
|
79
|
+
What changed in the last 24 hours:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
for m in client.iter_movers(window="24h", types=["kev_added", "priority_change"]):
|
|
83
|
+
print(m["occurred_on"], m["cve_id"], m["type"], m.get("priority_now"), m["reason"])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Command line: `devsecure`
|
|
87
|
+
|
|
88
|
+
Installing the package also installs a `devsecure` command:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
devsecure cve CVE-2024-3400 # no key needed: falls back to the public record
|
|
92
|
+
devsecure prioritize trivy.json grype.json # ranked table of every CVE found in the files
|
|
93
|
+
trivy image -f json app:1.2 | devsecure prioritize - --fail-on high,kev
|
|
94
|
+
devsecure movers --window 24h --type kev_added,priority_change
|
|
95
|
+
devsecure kev --limit 10 # CISA KEV ranked by RPS, no key needed
|
|
96
|
+
devsecure --json prioritize sbom.cdx.json # the API's JSON instead of a table
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`--fail-on` takes `critical`, `high`, `medium` and/or `kev` (comma-separated). Exit codes: `0` done, `1` a CVE matched `--fail-on`, `2` a usage or API error, with the error's docs link printed. `python -m devsecure_intelligence` works too.
|
|
100
|
+
|
|
101
|
+
## Errors, limits and quota
|
|
102
|
+
|
|
103
|
+
Every error raises a `DevSecureError` carrying `status`, `code`, `message`, `docs_url`, `request_id` and `retry_after`. These subclasses let you catch the common cases:
|
|
104
|
+
|
|
105
|
+
| Class | When |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `AuthenticationError` | 401: missing, invalid or revoked key |
|
|
108
|
+
| `PlanError` | 403 `forbidden_plan`: your plan doesn't include the endpoint |
|
|
109
|
+
| `NotFoundError` | 404: the CVE isn't in the corpus |
|
|
110
|
+
| `RateLimitError` | 429 `rate_limited`, after the retries are used up |
|
|
111
|
+
| `QuotaExceededError` | 429 `quota_exceeded`: the month's allowance is used (resets on the 1st, UTC) |
|
|
112
|
+
|
|
113
|
+
`client.last_quota` holds the rate-limit and quota headers from the last response, for example `{"quota_limit": 500, "quota_remaining": 412, "rate_limit_remaining": 19}`. `client.last_request_id` is the ID to quote to support.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
Client(api_key="...", base_url="https://api.intelligence.devsecure.io", timeout=30, max_retries=3)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The key is sent only in the `Authorization` header, and never appears in URLs or in `repr(client)`. `base_url` must be `https://`, except `http://localhost` for testing.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
cd sdk/python
|
|
125
|
+
pip install pytest
|
|
126
|
+
PYTHONPATH=src pytest # unit tests, no network
|
|
127
|
+
PYTHONPATH=src pytest -m live # a few public calls to the production API, no key
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Licence: MIT. API docs: <https://intelligence.devsecure.io/docs>. Support: support@devsecure.io.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
devsecure_intelligence/__init__.py,sha256=JWVe_jFc6B4eUXPtl9pZw_FmPZ4TOjK0QtPZ3ORf60w,757
|
|
2
|
+
devsecure_intelligence/__main__.py,sha256=iqL5sqaJteojUO-yluSE4moQ3YYJvhp0lJWkXincvCQ,32
|
|
3
|
+
devsecure_intelligence/cli.py,sha256=IO9kuRqRF0pcauBgcfA8YdqQRiqB_Bv8rZSEfQCzzZw,9081
|
|
4
|
+
devsecure_intelligence/client.py,sha256=wPydHjhN8IawWDABxjwK177QUi6WBkVTu9cN2hs1iE0,17260
|
|
5
|
+
devsecure_intelligence/errors.py,sha256=11_SWmXgjUTPEg84Z6XbOVIe8up-max3g_1Ic2YANhM,2157
|
|
6
|
+
devsecure_intelligence/models.py,sha256=XTpPinP5fqNU0ZtE_y7Juv9i6VoQG5LCUXr_K9fz5hE,2060
|
|
7
|
+
devsecure_intelligence/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
devsecure_intelligence-0.1.0.dist-info/METADATA,sha256=uJ9HlbToGHThplube3QcoM96Qg1gxG0_S2hnTqgvS2o,6394
|
|
9
|
+
devsecure_intelligence-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
devsecure_intelligence-0.1.0.dist-info/entry_points.txt,sha256=1h7b2-m9Gz1NfoDHsSyQyEw0tt6Bjmz6nYKQ_O2dTHI,63
|
|
11
|
+
devsecure_intelligence-0.1.0.dist-info/licenses/LICENSE,sha256=8mzQNvzrASI4xZEye2tmPKYpqYepAF9V-6gCmdHLbGs,1074
|
|
12
|
+
devsecure_intelligence-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevSecure Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|