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.
@@ -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,3 @@
1
+ from .cli import entry
2
+
3
+ entry()
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devsecure = devsecure_intelligence.cli:entry
@@ -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.