devsecure-intelligence 0.1.0__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.
@@ -0,0 +1,100 @@
1
+ # Local environment / secrets
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ .dev.vars
6
+ .dev.vars.*
7
+ !.dev.vars.example
8
+
9
+
10
+ # Secret/config dumps
11
+ secrets.json
12
+ credentials.json
13
+ service-account*.json
14
+ *secret*
15
+ *secrets*
16
+
17
+ # Cloudflare local state / generated files
18
+ .wrangler/
19
+ .wrangler-tmp/
20
+
21
+ # Claude / local AI agent state (keep automation files versioned)
22
+ .claude/*
23
+ !.claude/skills/
24
+ !.claude/agents/
25
+ !.claude/hooks/
26
+ !.claude/settings.json
27
+ .claude.local/
28
+ *.local.md
29
+
30
+ # VS / IDE local files
31
+ .vs/
32
+ .vscode/*
33
+ !.vscode/extensions.json
34
+ !.vscode/settings.example.json
35
+
36
+ # Test/build output
37
+ dist/
38
+ coverage/
39
+ test-results/
40
+ playwright-report/
41
+
42
+ # Logs
43
+ *.log
44
+ logs/
45
+
46
+ # OS junk
47
+ .DS_Store
48
+ Thumbs.db
49
+
50
+ # IDE
51
+ .vs/
52
+ .vscode/
53
+
54
+ # OS
55
+ .DS_Store
56
+ Thumbs.db
57
+
58
+ # Node
59
+ node_modules/
60
+
61
+ # Prompts
62
+ docs/prompts/
63
+ docs/temp/
64
+ .prompt-local*.md
65
+ .prompt-scratch*.md
66
+ .prompt*.md
67
+ .*report.md
68
+ .UPGRADE_PLAN.md
69
+ .strategy.md
70
+ .test-report01.md
71
+ .deepseek.md
72
+ .AGENTS.md
73
+
74
+ # Session handoff / AI conversation artifacts (never ship to master)
75
+ docs/SESSION_HANDOFF.md
76
+
77
+ # Private keys and certificates
78
+ *.pem
79
+ *.key
80
+ *.p12
81
+ *.pfx
82
+ *.crt
83
+ *.csr
84
+
85
+
86
+ # Python
87
+ __pycache__/
88
+
89
+
90
+
91
+ # Misc
92
+ INDEX.md
93
+
94
+ ===============
95
+
96
+ t e s t . p s 1
97
+
98
+
99
+ # Local PowerShell test scripts
100
+ /test.ps1
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - First release: `Client` for the v1 API (lookups, Prioritize with scanner-text input and plan-aware chunking, movers, recent, CWE search, enrichment, public endpoints), typed responses, retries with `Retry-After`, quota headers.
6
+ - `devsecure` command: `cve`, `prioritize` (files or stdin, `--fail-on critical|high|medium|kev`), `movers`, `kev`; `--json`.
@@ -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.
@@ -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,111 @@
1
+ # DevSecure Intelligence for Python
2
+
3
+ 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.
4
+
5
+ - Standard library only: no dependencies.
6
+ - Typed (`py.typed`), Python 3.10+.
7
+ - Retries network errors, 5xx and `429 rate_limited`, honouring `Retry-After`. It never retries `quota_exceeded`.
8
+ - Accepts a list of IDs or a whole scanner report, splits long lists to fit your plan, and merges the results into one ranking.
9
+
10
+ ```bash
11
+ pip install devsecure-intelligence
12
+ export DEVSECURE_API_KEY=dsec_live_... # free key: https://intelligence.devsecure.io/signup
13
+ ```
14
+
15
+ ## Rank a scanner's findings
16
+
17
+ ```python
18
+ from pathlib import Path
19
+ from devsecure_intelligence import Client
20
+
21
+ client = Client() # reads DEVSECURE_API_KEY
22
+ result = client.prioritize(Path("trivy.json").read_text()) # any text that mentions CVE IDs
23
+
24
+ for cve in result["ranked"]:
25
+ print(cve["rank_in_request"], cve["cve_id"], cve["priority_band"], cve["rps_score"], cve["reason"])
26
+ ```
27
+
28
+ `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`.
29
+
30
+ 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.
31
+
32
+ ## Fail a CI step on High, Critical or KEV
33
+
34
+ ```python
35
+ import sys
36
+ blocking = [c for c in result["ranked"]
37
+ if c["priority_band"] in ("critical_priority", "high_priority") or (c.get("evidence") or {}).get("kev", {}).get("listed")]
38
+ for c in blocking:
39
+ print(f"{c['cve_id']}\t{c['priority_band']}\t{c['reason']}")
40
+ sys.exit(1 if blocking else 0)
41
+ ```
42
+
43
+ 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).
44
+
45
+ ## Other calls
46
+
47
+ | Method | Endpoint | Plan |
48
+ |---|---|---|
49
+ | `cve(id)`, `rps(id)` | `GET /api/v1/cve/{id}`, `/rps/{id}` | Any key |
50
+ | `cve_batch(ids)` | `POST /api/v1/cve/batch` | Any key (Free 100 IDs, Pro+ 1,000) |
51
+ | `prioritize(ids_or_text, chunk_size=None)` | `POST /api/v1/prioritize` | Any key |
52
+ | `movers(window="7d", types=[...])`, `iter_movers(...)` | `GET /api/v1/movers` | Any key |
53
+ | `recent(since=...)` | `GET /api/v1/cve/recent` | Any key |
54
+ | `cwe_cves(79, is_kev=True)` | `GET /api/v1/cwe/{cwe}/cves` | Any key |
55
+ | `intelligence(id)` | `GET /api/v1/cve/{id}/intelligence` | Pro and above |
56
+ | `patches(id)` | `GET /api/v1/patches/{id}` | Business and Enterprise |
57
+ | `public_cve(id)`, `kev()`, `stats()`, `status()`, `health()` | public endpoints | No key needed |
58
+ | `request(method, path, params=..., json_body=...)` | anything else | |
59
+
60
+ What changed in the last 24 hours:
61
+
62
+ ```python
63
+ for m in client.iter_movers(window="24h", types=["kev_added", "priority_change"]):
64
+ print(m["occurred_on"], m["cve_id"], m["type"], m.get("priority_now"), m["reason"])
65
+ ```
66
+
67
+ ## Command line: `devsecure`
68
+
69
+ Installing the package also installs a `devsecure` command:
70
+
71
+ ```bash
72
+ devsecure cve CVE-2024-3400 # no key needed: falls back to the public record
73
+ devsecure prioritize trivy.json grype.json # ranked table of every CVE found in the files
74
+ trivy image -f json app:1.2 | devsecure prioritize - --fail-on high,kev
75
+ devsecure movers --window 24h --type kev_added,priority_change
76
+ devsecure kev --limit 10 # CISA KEV ranked by RPS, no key needed
77
+ devsecure --json prioritize sbom.cdx.json # the API's JSON instead of a table
78
+ ```
79
+
80
+ `--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.
81
+
82
+ ## Errors, limits and quota
83
+
84
+ Every error raises a `DevSecureError` carrying `status`, `code`, `message`, `docs_url`, `request_id` and `retry_after`. These subclasses let you catch the common cases:
85
+
86
+ | Class | When |
87
+ |---|---|
88
+ | `AuthenticationError` | 401: missing, invalid or revoked key |
89
+ | `PlanError` | 403 `forbidden_plan`: your plan doesn't include the endpoint |
90
+ | `NotFoundError` | 404: the CVE isn't in the corpus |
91
+ | `RateLimitError` | 429 `rate_limited`, after the retries are used up |
92
+ | `QuotaExceededError` | 429 `quota_exceeded`: the month's allowance is used (resets on the 1st, UTC) |
93
+
94
+ `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.
95
+
96
+ ```python
97
+ Client(api_key="...", base_url="https://api.intelligence.devsecure.io", timeout=30, max_retries=3)
98
+ ```
99
+
100
+ 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.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ cd sdk/python
106
+ pip install pytest
107
+ PYTHONPATH=src pytest # unit tests, no network
108
+ PYTHONPATH=src pytest -m live # a few public calls to the production API, no key
109
+ ```
110
+
111
+ Licence: MIT. API docs: <https://intelligence.devsecure.io/docs>. Support: support@devsecure.io.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.27.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "devsecure-intelligence"
7
+ dynamic = ["version"]
8
+ description = "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."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ authors = [{ name = "DevSecure Limited" }]
15
+ keywords = ["cve", "vulnerability", "epss", "cisa-kev", "cvss", "prioritisation", "security", "sbom"]
16
+ classifiers = [
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: Information Technology",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Security",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.scripts]
26
+ devsecure = "devsecure_intelligence.cli:entry"
27
+
28
+ [project.urls]
29
+ Homepage = "https://intelligence.devsecure.io"
30
+ # The Intelligence repository is private: no source/changelog links until a public repo exists.
31
+ Documentation = "https://intelligence.devsecure.io/docs/guides/python"
32
+
33
+ [tool.hatch.version]
34
+ path = "src/devsecure_intelligence/__init__.py"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/devsecure_intelligence"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ markers = ["live: calls the production API (no key needed); run with -m live"]
42
+ addopts = "-m 'not live'"
@@ -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"]