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.
- devsecure_intelligence-0.1.0/.gitignore +100 -0
- devsecure_intelligence-0.1.0/CHANGELOG.md +6 -0
- devsecure_intelligence-0.1.0/LICENSE +21 -0
- devsecure_intelligence-0.1.0/PKG-INFO +130 -0
- devsecure_intelligence-0.1.0/README.md +111 -0
- devsecure_intelligence-0.1.0/pyproject.toml +42 -0
- devsecure_intelligence-0.1.0/src/devsecure_intelligence/__init__.py +29 -0
- devsecure_intelligence-0.1.0/src/devsecure_intelligence/__main__.py +3 -0
- devsecure_intelligence-0.1.0/src/devsecure_intelligence/cli.py +233 -0
- devsecure_intelligence-0.1.0/src/devsecure_intelligence/client.py +366 -0
- devsecure_intelligence-0.1.0/src/devsecure_intelligence/errors.py +71 -0
- devsecure_intelligence-0.1.0/src/devsecure_intelligence/models.py +64 -0
- devsecure_intelligence-0.1.0/src/devsecure_intelligence/py.typed +0 -0
- devsecure_intelligence-0.1.0/tests/conftest.py +4 -0
- devsecure_intelligence-0.1.0/tests/test_cli.py +131 -0
- devsecure_intelligence-0.1.0/tests/test_client.py +229 -0
- devsecure_intelligence-0.1.0/tests/test_live.py +35 -0
|
@@ -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,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"]
|