cve-finder-cli 1.0.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.
- cve_finder_cli-1.0.0/LICENSE +21 -0
- cve_finder_cli-1.0.0/PKG-INFO +119 -0
- cve_finder_cli-1.0.0/README.md +81 -0
- cve_finder_cli-1.0.0/cve_finder/__init__.py +1 -0
- cve_finder_cli-1.0.0/cve_finder/api.py +70 -0
- cve_finder_cli-1.0.0/cve_finder/cli.py +178 -0
- cve_finder_cli-1.0.0/cve_finder/models.py +17 -0
- cve_finder_cli-1.0.0/cve_finder/output.py +95 -0
- cve_finder_cli-1.0.0/cve_finder/utils.py +75 -0
- cve_finder_cli-1.0.0/cve_finder_cli.egg-info/PKG-INFO +119 -0
- cve_finder_cli-1.0.0/cve_finder_cli.egg-info/SOURCES.txt +25 -0
- cve_finder_cli-1.0.0/cve_finder_cli.egg-info/dependency_links.txt +1 -0
- cve_finder_cli-1.0.0/cve_finder_cli.egg-info/entry_points.txt +2 -0
- cve_finder_cli-1.0.0/cve_finder_cli.egg-info/requires.txt +1 -0
- cve_finder_cli-1.0.0/cve_finder_cli.egg-info/top_level.txt +3 -0
- cve_finder_cli-1.0.0/pyproject.toml +32 -0
- cve_finder_cli-1.0.0/setup.cfg +4 -0
- cve_finder_cli-1.0.0/tests/test_api.py +33 -0
- cve_finder_cli-1.0.0/tests/test_api_request.py +50 -0
- cve_finder_cli-1.0.0/tests/test_cli.py +64 -0
- cve_finder_cli-1.0.0/tests/test_cli_fetch.py +144 -0
- cve_finder_cli-1.0.0/tests/test_cli_main.py +24 -0
- cve_finder_cli-1.0.0/tests/test_cli_output_results.py +53 -0
- cve_finder_cli-1.0.0/tests/test_output.py +39 -0
- cve_finder_cli-1.0.0/tests/test_output_files.py +31 -0
- cve_finder_cli-1.0.0/tests/test_output_format.py +28 -0
- cve_finder_cli-1.0.0/tests/test_utils.py +65 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cve-finder-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fetch CVEs per application from NVD (CVE API 2.0)
|
|
5
|
+
Author-email: "Dr. Fatih Tekin" <fatih.tekin.de@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/DrFatihTekin/cve_finder
|
|
29
|
+
Project-URL: Repository, https://github.com/DrFatihTekin/cve_finder
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Operating System :: OS Independent
|
|
33
|
+
Requires-Python: >=3.7
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
License-File: LICENSE
|
|
36
|
+
Requires-Dist: requests>=2.25.0
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# CVE Finder
|
|
40
|
+
|
|
41
|
+
Fetch CVEs per application from NVD (National Vulnerability Database) API 2.0.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip3 install cve-finder-cli
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or install from source (editable):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or from the directory:
|
|
56
|
+
```bash
|
|
57
|
+
python -m pip install .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
After installation, use the `cve-finder` command:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Using CPE (exact match)
|
|
66
|
+
cve-finder --cpe "cpe:2.3:a:gitlab:gitlab:16.7:*:*:*:*:*:*:*" --json output.json
|
|
67
|
+
|
|
68
|
+
# Using keyword search
|
|
69
|
+
cve-finder --app "nginx" --version "1.24.0" --csv nginx.csv
|
|
70
|
+
|
|
71
|
+
# Filter by severity and date (case-insensitive)
|
|
72
|
+
cve-finder --app "openssl" --severity critical --since 2024-01-01 --max 50
|
|
73
|
+
|
|
74
|
+
# Multiple severities (repeat flag or comma-separated)
|
|
75
|
+
cve-finder --app "jira" --severity CRITICAL --severity MEDIUM
|
|
76
|
+
cve-finder --app "jira" --severity CRITICAL,MEDIUM
|
|
77
|
+
|
|
78
|
+
# Print to console (no file output)
|
|
79
|
+
cve-finder --app "tomcat" --version "9.0.0"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Direct script usage
|
|
83
|
+
|
|
84
|
+
You can also run the entry script directly:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Best (exact): use a CPE name
|
|
88
|
+
python main.py --cpe "cpe:2.3:a:nginx:nginx:1.24.0:*:*:*:*:*:*:*" --csv nginx_1.24.0.csv
|
|
89
|
+
|
|
90
|
+
# Keyword search (fuzzier)
|
|
91
|
+
python main.py --app "nginx" --version "1.24.0" --json out.json
|
|
92
|
+
|
|
93
|
+
# Keyword search, no version
|
|
94
|
+
python main.py --app "openssl" --since 2024-01-01 --max 200 --csv openssl.csv
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Options
|
|
98
|
+
|
|
99
|
+
- `--cpe` - Exact CPE name for precise matching
|
|
100
|
+
- `--app` - Application name for keyword search
|
|
101
|
+
- `--version` - Optional version (with --app)
|
|
102
|
+
- `--since` - Filter CVEs published since date (YYYY-MM-DD)
|
|
103
|
+
- `--until` - End date for published window
|
|
104
|
+
- `--severity` - Filter by severity (case-insensitive). Repeat flag or use comma-separated list (LOW, MEDIUM, HIGH, CRITICAL)
|
|
105
|
+
- `--max` - Maximum CVEs to fetch (default: 1000)
|
|
106
|
+
- `--page-size` - Results per page (max 200)
|
|
107
|
+
- `--timeout` - HTTP timeout seconds
|
|
108
|
+
- `--json` - Save results to JSON file
|
|
109
|
+
- `--csv` - Save results to CSV file
|
|
110
|
+
- `--format` - Output format to stdout: json or csv
|
|
111
|
+
|
|
112
|
+
## API Key
|
|
113
|
+
|
|
114
|
+
Set `NVD_API_KEY` environment variable to reduce rate limiting:
|
|
115
|
+
```bash
|
|
116
|
+
export NVD_API_KEY="your_key_here"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Get your API key at: https://nvd.nist.gov/developers/request-an-api-key
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# CVE Finder
|
|
2
|
+
|
|
3
|
+
Fetch CVEs per application from NVD (National Vulnerability Database) API 2.0.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip3 install cve-finder-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install from source (editable):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or from the directory:
|
|
18
|
+
```bash
|
|
19
|
+
python -m pip install .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
After installation, use the `cve-finder` command:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Using CPE (exact match)
|
|
28
|
+
cve-finder --cpe "cpe:2.3:a:gitlab:gitlab:16.7:*:*:*:*:*:*:*" --json output.json
|
|
29
|
+
|
|
30
|
+
# Using keyword search
|
|
31
|
+
cve-finder --app "nginx" --version "1.24.0" --csv nginx.csv
|
|
32
|
+
|
|
33
|
+
# Filter by severity and date (case-insensitive)
|
|
34
|
+
cve-finder --app "openssl" --severity critical --since 2024-01-01 --max 50
|
|
35
|
+
|
|
36
|
+
# Multiple severities (repeat flag or comma-separated)
|
|
37
|
+
cve-finder --app "jira" --severity CRITICAL --severity MEDIUM
|
|
38
|
+
cve-finder --app "jira" --severity CRITICAL,MEDIUM
|
|
39
|
+
|
|
40
|
+
# Print to console (no file output)
|
|
41
|
+
cve-finder --app "tomcat" --version "9.0.0"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Direct script usage
|
|
45
|
+
|
|
46
|
+
You can also run the entry script directly:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Best (exact): use a CPE name
|
|
50
|
+
python main.py --cpe "cpe:2.3:a:nginx:nginx:1.24.0:*:*:*:*:*:*:*" --csv nginx_1.24.0.csv
|
|
51
|
+
|
|
52
|
+
# Keyword search (fuzzier)
|
|
53
|
+
python main.py --app "nginx" --version "1.24.0" --json out.json
|
|
54
|
+
|
|
55
|
+
# Keyword search, no version
|
|
56
|
+
python main.py --app "openssl" --since 2024-01-01 --max 200 --csv openssl.csv
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
- `--cpe` - Exact CPE name for precise matching
|
|
62
|
+
- `--app` - Application name for keyword search
|
|
63
|
+
- `--version` - Optional version (with --app)
|
|
64
|
+
- `--since` - Filter CVEs published since date (YYYY-MM-DD)
|
|
65
|
+
- `--until` - End date for published window
|
|
66
|
+
- `--severity` - Filter by severity (case-insensitive). Repeat flag or use comma-separated list (LOW, MEDIUM, HIGH, CRITICAL)
|
|
67
|
+
- `--max` - Maximum CVEs to fetch (default: 1000)
|
|
68
|
+
- `--page-size` - Results per page (max 200)
|
|
69
|
+
- `--timeout` - HTTP timeout seconds
|
|
70
|
+
- `--json` - Save results to JSON file
|
|
71
|
+
- `--csv` - Save results to CSV file
|
|
72
|
+
- `--format` - Output format to stdout: json or csv
|
|
73
|
+
|
|
74
|
+
## API Key
|
|
75
|
+
|
|
76
|
+
Set `NVD_API_KEY` environment variable to reduce rate limiting:
|
|
77
|
+
```bash
|
|
78
|
+
export NVD_API_KEY="your_key_here"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Get your API key at: https://nvd.nist.gov/developers/request-an-api-key
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""cve_finder package."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .models import CVEItem
|
|
9
|
+
from .utils import parse_cvss, pick_english_description
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
NVD_ENDPOINT = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
|
13
|
+
DEFAULT_RESULTS_PER_PAGE = 200 # NVD allows up to 200 per request
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def request_page(
|
|
17
|
+
session: requests.Session,
|
|
18
|
+
params: Dict[str, Any],
|
|
19
|
+
api_key: Optional[str],
|
|
20
|
+
timeout: int,
|
|
21
|
+
) -> Dict[str, Any]:
|
|
22
|
+
headers = {"User-Agent": "cve-per-app-script/1.0"}
|
|
23
|
+
if api_key:
|
|
24
|
+
headers["apiKey"] = api_key
|
|
25
|
+
|
|
26
|
+
resp = session.get(NVD_ENDPOINT, params=params, headers=headers, timeout=timeout)
|
|
27
|
+
if resp.status_code == 429:
|
|
28
|
+
# Rate-limited. Back off and retry once.
|
|
29
|
+
retry_after = resp.headers.get("Retry-After")
|
|
30
|
+
sleep_s = int(retry_after) if retry_after and retry_after.isdigit() else 6
|
|
31
|
+
time.sleep(sleep_s)
|
|
32
|
+
resp = session.get(NVD_ENDPOINT, params=params, headers=headers, timeout=timeout)
|
|
33
|
+
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
return resp.json()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_items(nvd_json: Dict[str, Any]) -> List[CVEItem]:
|
|
39
|
+
out: List[CVEItem] = []
|
|
40
|
+
vulns = nvd_json.get("vulnerabilities") or []
|
|
41
|
+
for v in vulns:
|
|
42
|
+
c = (v.get("cve") or {})
|
|
43
|
+
cve_id = c.get("id") or ""
|
|
44
|
+
published = c.get("published")
|
|
45
|
+
last_modified = c.get("lastModified")
|
|
46
|
+
desc = pick_english_description(c.get("descriptions") or [])
|
|
47
|
+
|
|
48
|
+
metrics = c.get("metrics") or {}
|
|
49
|
+
v31, v30, v2, sev = parse_cvss(metrics)
|
|
50
|
+
|
|
51
|
+
refs = []
|
|
52
|
+
for r in (c.get("references") or []):
|
|
53
|
+
url = r.get("url")
|
|
54
|
+
if url:
|
|
55
|
+
refs.append(url)
|
|
56
|
+
|
|
57
|
+
out.append(
|
|
58
|
+
CVEItem(
|
|
59
|
+
cve_id=cve_id,
|
|
60
|
+
published=published,
|
|
61
|
+
last_modified=last_modified,
|
|
62
|
+
description=desc,
|
|
63
|
+
cvss_v31=v31,
|
|
64
|
+
cvss_v30=v30,
|
|
65
|
+
cvss_v2=v2,
|
|
66
|
+
severity=sev,
|
|
67
|
+
references=refs,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return out
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .api import DEFAULT_RESULTS_PER_PAGE, extract_items, request_page
|
|
12
|
+
from .models import CVEItem
|
|
13
|
+
from .output import format_csv, format_grouped, format_json, save_csv, save_json
|
|
14
|
+
from .utils import iso_date_to_nvd_range
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
p = argparse.ArgumentParser(description="Fetch CVEs per application from NVD.")
|
|
19
|
+
group = p.add_mutually_exclusive_group(required=True)
|
|
20
|
+
group.add_argument("--cpe", help="Exact CPE name, e.g. cpe:2.3:a:vendor:product:version:*:*:*:*:*:*:*")
|
|
21
|
+
group.add_argument("--app", help="Application name keyword, e.g. nginx, openssl, tomcat")
|
|
22
|
+
|
|
23
|
+
p.add_argument("--version", help="Optional version (used only with --app keyword search)")
|
|
24
|
+
p.add_argument("--since", help="Only CVEs published since date (YYYY-MM-DD or ISO8601)")
|
|
25
|
+
p.add_argument("--until", help="End date for published window (YYYY-MM-DD or ISO8601). Default: now")
|
|
26
|
+
|
|
27
|
+
p.add_argument(
|
|
28
|
+
"--severity",
|
|
29
|
+
action="append",
|
|
30
|
+
help="Filter by CVSS v3 severity. Use multiple --severity flags or a comma-separated list (e.g., CRITICAL,MEDIUM).",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument("--max", type=int, default=1000, help="Maximum CVEs to fetch (default 1000)")
|
|
33
|
+
p.add_argument("--page-size", type=int, default=DEFAULT_RESULTS_PER_PAGE, help="Results per page (max 200)")
|
|
34
|
+
p.add_argument("--timeout", type=int, default=30, help="HTTP timeout seconds")
|
|
35
|
+
|
|
36
|
+
out_group = p.add_mutually_exclusive_group(required=False)
|
|
37
|
+
out_group.add_argument("--json", dest="json_out", help="Write results to JSON file")
|
|
38
|
+
out_group.add_argument("--csv", dest="csv_out", help="Write results to CSV file")
|
|
39
|
+
|
|
40
|
+
p.add_argument("--format", choices=["json", "csv"], help="Output format to stdout (instead of grouped display)")
|
|
41
|
+
return p
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_params(args: argparse.Namespace) -> Dict[str, Any]:
|
|
45
|
+
params: Dict[str, Any] = {
|
|
46
|
+
"startIndex": 0,
|
|
47
|
+
"resultsPerPage": args.page_size,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Filter by application
|
|
51
|
+
if args.cpe:
|
|
52
|
+
params["cpeName"] = args.cpe
|
|
53
|
+
else:
|
|
54
|
+
# keywordSearch is broad; include version as additional keyword if provided
|
|
55
|
+
keyword = args.app.strip()
|
|
56
|
+
if args.version:
|
|
57
|
+
keyword = f"{keyword} {args.version.strip()}"
|
|
58
|
+
params["keywordSearch"] = keyword
|
|
59
|
+
|
|
60
|
+
# Optional time window
|
|
61
|
+
if args.since:
|
|
62
|
+
start, end = iso_date_to_nvd_range(args.since, args.until)
|
|
63
|
+
# Use "pubStartDate/pubEndDate" so you're filtering by published dates
|
|
64
|
+
params["pubStartDate"] = start
|
|
65
|
+
params["pubEndDate"] = end
|
|
66
|
+
|
|
67
|
+
# Optional severity filtering (NVD supports single cvssV3Severity)
|
|
68
|
+
severities = normalize_severities(args.severity)
|
|
69
|
+
if len(severities) == 1:
|
|
70
|
+
params["cvssV3Severity"] = severities[0]
|
|
71
|
+
|
|
72
|
+
return params
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_cves(args: argparse.Namespace) -> List[CVEItem]:
|
|
76
|
+
if args.page_size > 200 or args.page_size < 1:
|
|
77
|
+
raise ValueError("--page-size must be between 1 and 200 for NVD.")
|
|
78
|
+
|
|
79
|
+
api_key = os.getenv("NVD_API_KEY") # optional but recommended
|
|
80
|
+
params = build_params(args)
|
|
81
|
+
|
|
82
|
+
items: List[CVEItem] = []
|
|
83
|
+
session = requests.Session()
|
|
84
|
+
|
|
85
|
+
fetched = 0
|
|
86
|
+
total_results = None
|
|
87
|
+
|
|
88
|
+
while True:
|
|
89
|
+
page = request_page(session, params, api_key, args.timeout)
|
|
90
|
+
if total_results is None:
|
|
91
|
+
total_results = page.get("totalResults", 0)
|
|
92
|
+
|
|
93
|
+
page_items = extract_items(page)
|
|
94
|
+
items.extend(page_items)
|
|
95
|
+
fetched += len(page_items)
|
|
96
|
+
|
|
97
|
+
# Stop conditions
|
|
98
|
+
if fetched >= args.max:
|
|
99
|
+
items = items[: args.max]
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
start_index = params["startIndex"]
|
|
103
|
+
results_per_page = params["resultsPerPage"]
|
|
104
|
+
|
|
105
|
+
# If no more results
|
|
106
|
+
if len(page_items) == 0:
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
# Next page
|
|
110
|
+
next_index = start_index + results_per_page
|
|
111
|
+
if total_results is not None and next_index >= total_results:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
params["startIndex"] = next_index
|
|
115
|
+
|
|
116
|
+
# Gentle pacing if no API key (avoid 429)
|
|
117
|
+
if not api_key:
|
|
118
|
+
time.sleep(0.8)
|
|
119
|
+
|
|
120
|
+
# Local severity filter in case API doesn't enforce it reliably
|
|
121
|
+
severities = normalize_severities(args.severity)
|
|
122
|
+
if severities:
|
|
123
|
+
items = [it for it in items if (it.severity or "").upper() in severities]
|
|
124
|
+
|
|
125
|
+
# Sort by published date desc (if available)
|
|
126
|
+
items.sort(key=lambda it: it.published or "", reverse=True)
|
|
127
|
+
return items
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def normalize_severities(raw: List[str] | None) -> List[str]:
|
|
131
|
+
if not raw:
|
|
132
|
+
return []
|
|
133
|
+
allowed = {"LOW", "MEDIUM", "HIGH", "CRITICAL"}
|
|
134
|
+
values = []
|
|
135
|
+
for entry in raw:
|
|
136
|
+
parts = [p.strip().upper() for p in entry.split(",") if p.strip()]
|
|
137
|
+
values.extend(parts)
|
|
138
|
+
invalid = [v for v in values if v not in allowed]
|
|
139
|
+
if invalid:
|
|
140
|
+
raise ValueError(f"Invalid severity value(s): {', '.join(invalid)}")
|
|
141
|
+
# Preserve order and uniqueness
|
|
142
|
+
seen = set()
|
|
143
|
+
result = []
|
|
144
|
+
for v in values:
|
|
145
|
+
if v not in seen:
|
|
146
|
+
seen.add(v)
|
|
147
|
+
result.append(v)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def output_results(items: List[CVEItem], args: argparse.Namespace) -> int:
|
|
152
|
+
if args.json_out:
|
|
153
|
+
save_json(items, args.json_out)
|
|
154
|
+
print(f"Wrote {len(items)} CVEs to JSON: {args.json_out}")
|
|
155
|
+
elif args.csv_out:
|
|
156
|
+
save_csv(items, args.csv_out)
|
|
157
|
+
print(f"Wrote {len(items)} CVEs to CSV: {args.csv_out}")
|
|
158
|
+
elif args.format == "json":
|
|
159
|
+
print(format_json(items))
|
|
160
|
+
elif args.format == "csv":
|
|
161
|
+
print(format_csv(items))
|
|
162
|
+
else:
|
|
163
|
+
print(format_grouped(items))
|
|
164
|
+
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> int:
|
|
169
|
+
parser = build_parser()
|
|
170
|
+
args = parser.parse_args()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
items = fetch_cves(args)
|
|
174
|
+
except ValueError as e:
|
|
175
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
|
|
178
|
+
return output_results(items, args)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CVEItem:
|
|
9
|
+
cve_id: str
|
|
10
|
+
published: Optional[str]
|
|
11
|
+
last_modified: Optional[str]
|
|
12
|
+
description: str
|
|
13
|
+
cvss_v31: Optional[float]
|
|
14
|
+
cvss_v30: Optional[float]
|
|
15
|
+
cvss_v2: Optional[float]
|
|
16
|
+
severity: Optional[str]
|
|
17
|
+
references: List[str]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .models import CVEItem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save_json(items: List[CVEItem], path: str) -> None:
|
|
11
|
+
data = [item.__dict__ for item in items]
|
|
12
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
13
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def save_csv(items: List[CVEItem], path: str) -> None:
|
|
17
|
+
fieldnames = [
|
|
18
|
+
"cve_id",
|
|
19
|
+
"published",
|
|
20
|
+
"last_modified",
|
|
21
|
+
"severity",
|
|
22
|
+
"cvss_v31",
|
|
23
|
+
"cvss_v30",
|
|
24
|
+
"cvss_v2",
|
|
25
|
+
"description",
|
|
26
|
+
"references",
|
|
27
|
+
]
|
|
28
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
29
|
+
w = csv.DictWriter(f, fieldnames=fieldnames)
|
|
30
|
+
w.writeheader()
|
|
31
|
+
for item in items:
|
|
32
|
+
row = item.__dict__.copy()
|
|
33
|
+
row["references"] = " | ".join(item.references)
|
|
34
|
+
w.writerow(row)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_json(items: List[CVEItem]) -> str:
|
|
38
|
+
data = [item.__dict__ for item in items]
|
|
39
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_csv(items: List[CVEItem]) -> str:
|
|
43
|
+
import io
|
|
44
|
+
|
|
45
|
+
fieldnames = [
|
|
46
|
+
"cve_id",
|
|
47
|
+
"published",
|
|
48
|
+
"last_modified",
|
|
49
|
+
"severity",
|
|
50
|
+
"cvss_v31",
|
|
51
|
+
"cvss_v30",
|
|
52
|
+
"cvss_v2",
|
|
53
|
+
"description",
|
|
54
|
+
"references",
|
|
55
|
+
]
|
|
56
|
+
output = io.StringIO()
|
|
57
|
+
w = csv.DictWriter(output, fieldnames=fieldnames)
|
|
58
|
+
w.writeheader()
|
|
59
|
+
for item in items:
|
|
60
|
+
row = item.__dict__.copy()
|
|
61
|
+
row["references"] = " | ".join(item.references)
|
|
62
|
+
w.writerow(row)
|
|
63
|
+
return output.getvalue()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def format_grouped(items: List[CVEItem]) -> str:
|
|
67
|
+
from collections import defaultdict
|
|
68
|
+
|
|
69
|
+
by_severity = defaultdict(list)
|
|
70
|
+
for it in items:
|
|
71
|
+
sev = it.severity or "UNKNOWN"
|
|
72
|
+
by_severity[sev].append(it)
|
|
73
|
+
|
|
74
|
+
# Print grouped by severity (CRITICAL -> HIGH -> MEDIUM -> LOW -> UNKNOWN)
|
|
75
|
+
severity_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]
|
|
76
|
+
lines = []
|
|
77
|
+
total_printed = 0
|
|
78
|
+
header = "CVE_ID | PUBLISHED | SCORE | DESCRIPTION"
|
|
79
|
+
for sev in severity_order:
|
|
80
|
+
if sev not in by_severity:
|
|
81
|
+
continue
|
|
82
|
+
cves = by_severity[sev]
|
|
83
|
+
lines.append(f"\n{'='*80}")
|
|
84
|
+
lines.append(f"{sev} ({len(cves)} CVEs)")
|
|
85
|
+
lines.append(f"{'='*80}")
|
|
86
|
+
lines.append(header)
|
|
87
|
+
for it in cves[:50]: # Limit per severity group
|
|
88
|
+
score = it.cvss_v31 or it.cvss_v30 or it.cvss_v2
|
|
89
|
+
lines.append(f"{it.cve_id} | {it.published} | {score} | {it.description[:100]}")
|
|
90
|
+
total_printed += 1
|
|
91
|
+
if len(cves) > 50:
|
|
92
|
+
lines.append(f"... ({len(cves) - 50} more {sev} CVEs)")
|
|
93
|
+
|
|
94
|
+
lines.append(f"\nTotal CVEs fetched: {len(items)}")
|
|
95
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def iso_date_to_nvd_range(since: str, until: Optional[str]) -> Tuple[str, str]:
|
|
8
|
+
"""
|
|
9
|
+
Convert YYYY-MM-DD (or full ISO) into NVD required ISO-8601 with timezone.
|
|
10
|
+
NVD expects e.g. 2024-01-01T00:00:00.000Z
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def parse_date(d: str) -> datetime:
|
|
14
|
+
# Accept YYYY-MM-DD or full ISO
|
|
15
|
+
try:
|
|
16
|
+
if len(d) == 10:
|
|
17
|
+
return datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
18
|
+
# try full ISO
|
|
19
|
+
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
|
20
|
+
if dt.tzinfo is None:
|
|
21
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
22
|
+
return dt.astimezone(timezone.utc)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise ValueError(f"Invalid date format '{d}'. Use YYYY-MM-DD or ISO8601.") from e
|
|
25
|
+
|
|
26
|
+
start_dt = parse_date(since)
|
|
27
|
+
end_dt = parse_date(until) if until else datetime.now(timezone.utc)
|
|
28
|
+
|
|
29
|
+
# NVD wants milliseconds and Z
|
|
30
|
+
def fmt(dt: datetime) -> str:
|
|
31
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
32
|
+
|
|
33
|
+
return fmt(start_dt), fmt(end_dt)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pick_english_description(descriptions: List[Dict[str, Any]]) -> str:
|
|
37
|
+
for d in descriptions or []:
|
|
38
|
+
if d.get("lang") == "en" and d.get("value"):
|
|
39
|
+
return d["value"]
|
|
40
|
+
# fallback: first description if exists
|
|
41
|
+
if descriptions and descriptions[0].get("value"):
|
|
42
|
+
return descriptions[0]["value"]
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_cvss(metrics: Dict[str, Any]) -> Tuple[Optional[float], Optional[float], Optional[float], Optional[str]]:
|
|
47
|
+
"""
|
|
48
|
+
Extract best available CVSS score and severity from NVD metrics.
|
|
49
|
+
"""
|
|
50
|
+
v31 = v30 = v2 = None
|
|
51
|
+
sev = None
|
|
52
|
+
|
|
53
|
+
# CVSS v3.1
|
|
54
|
+
m31 = metrics.get("cvssMetricV31") or []
|
|
55
|
+
if m31:
|
|
56
|
+
data = (m31[0].get("cvssData") or {})
|
|
57
|
+
v31 = data.get("baseScore")
|
|
58
|
+
sev = data.get("baseSeverity") or sev
|
|
59
|
+
|
|
60
|
+
# CVSS v3.0
|
|
61
|
+
m30 = metrics.get("cvssMetricV30") or []
|
|
62
|
+
if m30:
|
|
63
|
+
data = (m30[0].get("cvssData") or {})
|
|
64
|
+
v30 = data.get("baseScore")
|
|
65
|
+
sev = data.get("baseSeverity") or sev
|
|
66
|
+
|
|
67
|
+
# CVSS v2
|
|
68
|
+
m2 = metrics.get("cvssMetricV2") or []
|
|
69
|
+
if m2:
|
|
70
|
+
data = (m2[0].get("cvssData") or {})
|
|
71
|
+
v2 = data.get("baseScore")
|
|
72
|
+
# v2 severity sometimes under "baseSeverity" elsewhere; keep sev if already set
|
|
73
|
+
sev = sev or m2[0].get("baseSeverity")
|
|
74
|
+
|
|
75
|
+
return v31, v30, v2, sev
|