cve-finder-cli 1.0.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.
- cve_finder/__init__.py +1 -0
- cve_finder/api.py +70 -0
- cve_finder/cli.py +178 -0
- cve_finder/models.py +17 -0
- cve_finder/output.py +95 -0
- cve_finder/utils.py +75 -0
- cve_finder_cli-1.0.0.dist-info/METADATA +119 -0
- cve_finder_cli-1.0.0.dist-info/RECORD +22 -0
- cve_finder_cli-1.0.0.dist-info/WHEEL +5 -0
- cve_finder_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cve_finder_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- cve_finder_cli-1.0.0.dist-info/top_level.txt +2 -0
- tests/test_api.py +33 -0
- tests/test_api_request.py +50 -0
- tests/test_cli.py +64 -0
- tests/test_cli_fetch.py +144 -0
- tests/test_cli_main.py +24 -0
- tests/test_cli_output_results.py +53 -0
- tests/test_output.py +39 -0
- tests/test_output_files.py +31 -0
- tests/test_output_format.py +28 -0
- tests/test_utils.py +65 -0
cve_finder/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""cve_finder package."""
|
cve_finder/api.py
ADDED
|
@@ -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
|
cve_finder/cli.py
ADDED
|
@@ -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)
|
cve_finder/models.py
ADDED
|
@@ -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]
|
cve_finder/output.py
ADDED
|
@@ -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)
|
cve_finder/utils.py
ADDED
|
@@ -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
|
|
@@ -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,22 @@
|
|
|
1
|
+
cve_finder/__init__.py,sha256=VmB67f2n9eeIs5n0d3qHai1p3Qbpqj2Sodh4gGR1qJs,26
|
|
2
|
+
cve_finder/api.py,sha256=fjKo_JHSXALvQli4GfI-p7r9ynRmoOMdP6dmzra-bFk,2060
|
|
3
|
+
cve_finder/cli.py,sha256=O8fwZA7-8HK_Hw2tdYP6tRh2UCEcfxiiNtnhxQExjvg,5937
|
|
4
|
+
cve_finder/models.py,sha256=SQZGXK-LoOOvd1SqA9cgSnV_KcY8oeeIoAXwy1k7Wb4,374
|
|
5
|
+
cve_finder/output.py,sha256=ayPw5u33ejhmYxFsS5ez8yzHpzRqukdYzemMWwe5GPM,2734
|
|
6
|
+
cve_finder/utils.py,sha256=-tSciUv-2928SaFBOXZ9rNVQohYbsZWoWcf70keJC6Q,2509
|
|
7
|
+
cve_finder_cli-1.0.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
|
|
8
|
+
tests/test_api.py,sha256=I90oG3KXy9kDahLypppzLTEixhWADnJJa3W3U8qv8xQ,1076
|
|
9
|
+
tests/test_api_request.py,sha256=fANDrLDr5Et3Gvy66ztzepy_BUd5XpZGepwEIxIHzgk,1396
|
|
10
|
+
tests/test_cli.py,sha256=3hwcaYFPlSX0UbRlUsd7lavehRz__drilmCqbwfLfQo,1804
|
|
11
|
+
tests/test_cli_fetch.py,sha256=bUlNSOoOCJjrkarfQJS1BfjA0PDsignnoUR3uzG54yc,3769
|
|
12
|
+
tests/test_cli_main.py,sha256=_IrUxL15UuBklVrL-TFtfZzy_VBH7bgzhpFBNL9Ax2Y,827
|
|
13
|
+
tests/test_cli_output_results.py,sha256=Y1BYa-l82VYB_tgM8aQ-4os9jU_RTvIQIBPvPuUc2bs,1659
|
|
14
|
+
tests/test_output.py,sha256=JG-TeLY6mnUDZssNsUOu4MoO_Y6YofbqMinTxTf1KGA,1086
|
|
15
|
+
tests/test_output_files.py,sha256=4cM_laNW_6zQvToASVB0VDE4iTMOM8214vAfOeCY9Ps,868
|
|
16
|
+
tests/test_output_format.py,sha256=d4vU5ErNlSK8XGYa3p303dkue9J0zCMUAJhkn7w5ezg,799
|
|
17
|
+
tests/test_utils.py,sha256=nQFtu0flFt1kRWZrLSk-lh6-NnEnkcdnt4stAhMv-ug,2256
|
|
18
|
+
cve_finder_cli-1.0.0.dist-info/METADATA,sha256=x7vxO2TvQu6VfXLgiYWd0tkPvcm8ZepIsJlWI62wg8k,3992
|
|
19
|
+
cve_finder_cli-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
20
|
+
cve_finder_cli-1.0.0.dist-info/entry_points.txt,sha256=YPjyg-kKbt7gWWIp9GmxAXTbaLRJlhhUWCxilpAcHU8,51
|
|
21
|
+
cve_finder_cli-1.0.0.dist-info/top_level.txt,sha256=zhlbmUAV_sturShGSsS5pJROYyv1pk4syTecS5lmWYs,17
|
|
22
|
+
cve_finder_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -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.
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from cve_finder.api import extract_items
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_extract_items_minimal():
|
|
5
|
+
sample = {
|
|
6
|
+
"vulnerabilities": [
|
|
7
|
+
{
|
|
8
|
+
"cve": {
|
|
9
|
+
"id": "CVE-2024-0001",
|
|
10
|
+
"published": "2024-01-01",
|
|
11
|
+
"lastModified": "2024-01-02",
|
|
12
|
+
"descriptions": [{"lang": "en", "value": "Test"}],
|
|
13
|
+
"metrics": {
|
|
14
|
+
"cvssMetricV31": [
|
|
15
|
+
{"cvssData": {"baseScore": 9.8, "baseSeverity": "CRITICAL"}}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"references": [{"url": "https://example.com"}],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
items = extract_items(sample)
|
|
25
|
+
assert len(items) == 1
|
|
26
|
+
item = items[0]
|
|
27
|
+
assert item.cve_id == "CVE-2024-0001"
|
|
28
|
+
assert item.published == "2024-01-01"
|
|
29
|
+
assert item.last_modified == "2024-01-02"
|
|
30
|
+
assert item.description == "Test"
|
|
31
|
+
assert item.cvss_v31 == 9.8
|
|
32
|
+
assert item.severity == "CRITICAL"
|
|
33
|
+
assert item.references == ["https://example.com"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from unittest.mock import Mock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from cve_finder.api import request_page
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DummyResponse:
|
|
9
|
+
def __init__(self, status_code, json_data=None, headers=None):
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
self._json_data = json_data or {}
|
|
12
|
+
self.headers = headers or {}
|
|
13
|
+
|
|
14
|
+
def raise_for_status(self):
|
|
15
|
+
if self.status_code >= 400:
|
|
16
|
+
raise RuntimeError("http error")
|
|
17
|
+
|
|
18
|
+
def json(self):
|
|
19
|
+
return self._json_data
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_request_page_retries_on_429(monkeypatch):
|
|
23
|
+
session = Mock()
|
|
24
|
+
session.get.side_effect = [
|
|
25
|
+
DummyResponse(429, headers={"Retry-After": "0"}),
|
|
26
|
+
DummyResponse(200, json_data={"ok": True}),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr("time.sleep", lambda *_: None)
|
|
30
|
+
|
|
31
|
+
result = request_page(session, {"a": 1}, api_key=None, timeout=1)
|
|
32
|
+
assert result == {"ok": True}
|
|
33
|
+
assert session.get.call_count == 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_request_page_includes_api_key_header():
|
|
37
|
+
session = Mock()
|
|
38
|
+
session.get.return_value = DummyResponse(200, json_data={"ok": True})
|
|
39
|
+
|
|
40
|
+
request_page(session, {"a": 1}, api_key="key", timeout=1)
|
|
41
|
+
_, kwargs = session.get.call_args
|
|
42
|
+
assert kwargs["headers"]["apiKey"] == "key"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_request_page_raises_on_error():
|
|
46
|
+
session = Mock()
|
|
47
|
+
session.get.return_value = DummyResponse(500)
|
|
48
|
+
|
|
49
|
+
with pytest.raises(RuntimeError):
|
|
50
|
+
request_page(session, {"a": 1}, api_key=None, timeout=1)
|
tests/test_cli.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from cve_finder.cli import build_params, build_parser, normalize_severities
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DummyArgs:
|
|
7
|
+
def __init__(self, **kwargs):
|
|
8
|
+
self.__dict__.update(kwargs)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_normalize_severities_single_case_insensitive():
|
|
12
|
+
assert normalize_severities(["critical"]) == ["CRITICAL"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_normalize_severities_comma_separated():
|
|
16
|
+
assert normalize_severities(["CRITICAl,medium"]) == ["CRITICAL", "MEDIUM"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_normalize_severities_multiple_flags_with_duplicates():
|
|
20
|
+
assert normalize_severities(["high", "HIGH", "medium"]) == ["HIGH", "MEDIUM"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_normalize_severities_invalid_value():
|
|
24
|
+
with pytest.raises(ValueError):
|
|
25
|
+
normalize_severities(["low,unknown"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_build_params_with_cpe_and_severity():
|
|
29
|
+
args = DummyArgs(
|
|
30
|
+
cpe="cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*",
|
|
31
|
+
app=None,
|
|
32
|
+
version=None,
|
|
33
|
+
since=None,
|
|
34
|
+
until=None,
|
|
35
|
+
severity=["critical"],
|
|
36
|
+
page_size=100,
|
|
37
|
+
)
|
|
38
|
+
params = build_params(args)
|
|
39
|
+
assert params["cpeName"].startswith("cpe:2.3:a:")
|
|
40
|
+
assert params["cvssV3Severity"] == "CRITICAL"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_build_params_with_app_and_version_and_dates():
|
|
44
|
+
args = DummyArgs(
|
|
45
|
+
cpe=None,
|
|
46
|
+
app="nginx",
|
|
47
|
+
version="1.24.0",
|
|
48
|
+
since="2024-01-01",
|
|
49
|
+
until="2024-01-02",
|
|
50
|
+
severity=None,
|
|
51
|
+
page_size=50,
|
|
52
|
+
)
|
|
53
|
+
params = build_params(args)
|
|
54
|
+
assert params["keywordSearch"] == "nginx 1.24.0"
|
|
55
|
+
assert params["pubStartDate"].endswith("Z")
|
|
56
|
+
assert params["pubEndDate"].endswith("Z")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_build_parser_parses_args():
|
|
60
|
+
parser = build_parser()
|
|
61
|
+
args = parser.parse_args(["--app", "nginx", "--severity", "low", "--format", "json"])
|
|
62
|
+
assert args.app == "nginx"
|
|
63
|
+
assert args.severity == ["low"]
|
|
64
|
+
assert args.format == "json"
|
tests/test_cli_fetch.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
from unittest.mock import Mock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from cve_finder import cli
|
|
7
|
+
from cve_finder.models import CVEItem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_item(sev: str, published: str = "2024-01-01") -> CVEItem:
|
|
11
|
+
return CVEItem(
|
|
12
|
+
cve_id="CVE-2024-0001",
|
|
13
|
+
published=published,
|
|
14
|
+
last_modified=None,
|
|
15
|
+
description="Test",
|
|
16
|
+
cvss_v31=9.8 if sev == "CRITICAL" else None,
|
|
17
|
+
cvss_v30=None,
|
|
18
|
+
cvss_v2=None,
|
|
19
|
+
severity=sev,
|
|
20
|
+
references=[],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_fetch_cves_paginates_and_filters(monkeypatch):
|
|
25
|
+
args = SimpleNamespace(
|
|
26
|
+
page_size=1,
|
|
27
|
+
max=2,
|
|
28
|
+
timeout=1,
|
|
29
|
+
cpe=None,
|
|
30
|
+
app="jira",
|
|
31
|
+
version=None,
|
|
32
|
+
since=None,
|
|
33
|
+
until=None,
|
|
34
|
+
severity=["critical"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
pages = [
|
|
38
|
+
{"totalResults": 2, "vulnerabilities": ["page1"]},
|
|
39
|
+
{"totalResults": 2, "vulnerabilities": ["page2"]},
|
|
40
|
+
]
|
|
41
|
+
req_mock = Mock(side_effect=pages)
|
|
42
|
+
monkeypatch.setattr(cli, "request_page", req_mock)
|
|
43
|
+
|
|
44
|
+
def extract_stub(page):
|
|
45
|
+
if page["vulnerabilities"] == ["page1"]:
|
|
46
|
+
return [make_item("CRITICAL", "2024-02-01")]
|
|
47
|
+
return [make_item("MEDIUM", "2024-01-01")]
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(cli, "extract_items", extract_stub)
|
|
50
|
+
|
|
51
|
+
items = cli.fetch_cves(args)
|
|
52
|
+
assert len(items) == 1
|
|
53
|
+
assert items[0].severity == "CRITICAL"
|
|
54
|
+
assert items[0].published == "2024-02-01"
|
|
55
|
+
assert req_mock.call_count == 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_fetch_cves_invalid_page_size():
|
|
59
|
+
args = SimpleNamespace(
|
|
60
|
+
page_size=500,
|
|
61
|
+
max=1,
|
|
62
|
+
timeout=1,
|
|
63
|
+
cpe="cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*",
|
|
64
|
+
app=None,
|
|
65
|
+
version=None,
|
|
66
|
+
since=None,
|
|
67
|
+
until=None,
|
|
68
|
+
severity=None,
|
|
69
|
+
)
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
cli.fetch_cves(args)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_fetch_cves_breaks_on_no_results(monkeypatch):
|
|
75
|
+
args = SimpleNamespace(
|
|
76
|
+
page_size=10,
|
|
77
|
+
max=100,
|
|
78
|
+
timeout=1,
|
|
79
|
+
cpe=None,
|
|
80
|
+
app="jira",
|
|
81
|
+
version=None,
|
|
82
|
+
since=None,
|
|
83
|
+
until=None,
|
|
84
|
+
severity=None,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
monkeypatch.setenv("NVD_API_KEY", "x")
|
|
88
|
+
monkeypatch.setattr(cli, "request_page", Mock(return_value={"totalResults": 0, "vulnerabilities": []}))
|
|
89
|
+
monkeypatch.setattr(cli, "extract_items", lambda _page: [])
|
|
90
|
+
|
|
91
|
+
items = cli.fetch_cves(args)
|
|
92
|
+
assert items == []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_fetch_cves_stops_when_total_reached(monkeypatch):
|
|
96
|
+
args = SimpleNamespace(
|
|
97
|
+
page_size=1,
|
|
98
|
+
max=100,
|
|
99
|
+
timeout=1,
|
|
100
|
+
cpe=None,
|
|
101
|
+
app="jira",
|
|
102
|
+
version=None,
|
|
103
|
+
since=None,
|
|
104
|
+
until=None,
|
|
105
|
+
severity=None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
monkeypatch.setenv("NVD_API_KEY", "x")
|
|
109
|
+
req_mock = Mock(return_value={"totalResults": 1, "vulnerabilities": ["page1"]})
|
|
110
|
+
monkeypatch.setattr(cli, "request_page", req_mock)
|
|
111
|
+
monkeypatch.setattr(cli, "extract_items", lambda _page: [make_item("HIGH")])
|
|
112
|
+
|
|
113
|
+
items = cli.fetch_cves(args)
|
|
114
|
+
assert len(items) == 1
|
|
115
|
+
assert req_mock.call_count == 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_fetch_cves_sleeps_without_api_key(monkeypatch):
|
|
119
|
+
args = SimpleNamespace(
|
|
120
|
+
page_size=1,
|
|
121
|
+
max=100,
|
|
122
|
+
timeout=1,
|
|
123
|
+
cpe=None,
|
|
124
|
+
app="jira",
|
|
125
|
+
version=None,
|
|
126
|
+
since=None,
|
|
127
|
+
until=None,
|
|
128
|
+
severity=None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
monkeypatch.delenv("NVD_API_KEY", raising=False)
|
|
132
|
+
pages = [
|
|
133
|
+
{"totalResults": 2, "vulnerabilities": ["page1"]},
|
|
134
|
+
{"totalResults": 2, "vulnerabilities": ["page2"]},
|
|
135
|
+
]
|
|
136
|
+
req_mock = Mock(side_effect=pages)
|
|
137
|
+
monkeypatch.setattr(cli, "request_page", req_mock)
|
|
138
|
+
monkeypatch.setattr(cli, "extract_items", lambda _page: [make_item("LOW")])
|
|
139
|
+
|
|
140
|
+
sleep_mock = Mock()
|
|
141
|
+
monkeypatch.setattr(cli.time, "sleep", sleep_mock)
|
|
142
|
+
|
|
143
|
+
cli.fetch_cves(args)
|
|
144
|
+
assert sleep_mock.call_count == 1
|
tests/test_cli_main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
from cve_finder import cli
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_main_success(monkeypatch, capsys):
|
|
7
|
+
args = SimpleNamespace()
|
|
8
|
+
monkeypatch.setattr(cli, "build_parser", lambda: SimpleNamespace(parse_args=lambda: args))
|
|
9
|
+
monkeypatch.setattr(cli, "fetch_cves", lambda _args: [])
|
|
10
|
+
monkeypatch.setattr(cli, "output_results", lambda items, _args: 0)
|
|
11
|
+
|
|
12
|
+
assert cli.main() == 0
|
|
13
|
+
captured = capsys.readouterr()
|
|
14
|
+
assert captured.err == ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_main_error(monkeypatch, capsys):
|
|
18
|
+
args = SimpleNamespace()
|
|
19
|
+
monkeypatch.setattr(cli, "build_parser", lambda: SimpleNamespace(parse_args=lambda: args))
|
|
20
|
+
monkeypatch.setattr(cli, "fetch_cves", lambda _args: (_ for _ in ()).throw(ValueError("bad")))
|
|
21
|
+
|
|
22
|
+
assert cli.main() == 2
|
|
23
|
+
captured = capsys.readouterr()
|
|
24
|
+
assert "Error: bad" in captured.err
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
from cve_finder.cli import output_results
|
|
4
|
+
from cve_finder.models import CVEItem
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_item() -> CVEItem:
|
|
8
|
+
return CVEItem(
|
|
9
|
+
cve_id="CVE-2024-1234",
|
|
10
|
+
published="2024-01-01",
|
|
11
|
+
last_modified=None,
|
|
12
|
+
description="Example",
|
|
13
|
+
cvss_v31=9.0,
|
|
14
|
+
cvss_v30=None,
|
|
15
|
+
cvss_v2=None,
|
|
16
|
+
severity="CRITICAL",
|
|
17
|
+
references=[],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_output_results_grouped(capsys):
|
|
22
|
+
args = SimpleNamespace(json_out=None, csv_out=None, format=None)
|
|
23
|
+
output_results([make_item()], args)
|
|
24
|
+
assert "Total CVEs fetched" in capsys.readouterr().out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_output_results_json_format(capsys):
|
|
28
|
+
args = SimpleNamespace(json_out=None, csv_out=None, format="json")
|
|
29
|
+
output_results([make_item()], args)
|
|
30
|
+
assert "CVE-2024-1234" in capsys.readouterr().out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_output_results_csv_format(capsys):
|
|
34
|
+
args = SimpleNamespace(json_out=None, csv_out=None, format="csv")
|
|
35
|
+
output_results([make_item()], args)
|
|
36
|
+
out = capsys.readouterr().out
|
|
37
|
+
assert out.startswith("cve_id,published,last_modified")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_output_results_json_file(tmp_path, capsys):
|
|
41
|
+
path = tmp_path / "out.json"
|
|
42
|
+
args = SimpleNamespace(json_out=str(path), csv_out=None, format=None)
|
|
43
|
+
output_results([make_item()], args)
|
|
44
|
+
assert path.exists()
|
|
45
|
+
assert "Wrote 1 CVEs to JSON" in capsys.readouterr().out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_output_results_csv_file(tmp_path, capsys):
|
|
49
|
+
path = tmp_path / "out.csv"
|
|
50
|
+
args = SimpleNamespace(json_out=None, csv_out=str(path), format=None)
|
|
51
|
+
output_results([make_item()], args)
|
|
52
|
+
assert path.exists()
|
|
53
|
+
assert "Wrote 1 CVEs to CSV" in capsys.readouterr().out
|
tests/test_output.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from cve_finder.models import CVEItem
|
|
2
|
+
from cve_finder.output import format_grouped
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_grouped_output_includes_header():
|
|
6
|
+
items = [
|
|
7
|
+
CVEItem(
|
|
8
|
+
cve_id="CVE-2024-0001",
|
|
9
|
+
published="2024-01-01",
|
|
10
|
+
last_modified=None,
|
|
11
|
+
description="Test description",
|
|
12
|
+
cvss_v31=9.8,
|
|
13
|
+
cvss_v30=None,
|
|
14
|
+
cvss_v2=None,
|
|
15
|
+
severity="CRITICAL",
|
|
16
|
+
references=["https://example.com"],
|
|
17
|
+
)
|
|
18
|
+
]
|
|
19
|
+
output = format_grouped(items)
|
|
20
|
+
assert "CVE_ID | PUBLISHED | SCORE | DESCRIPTION" in output
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_grouped_output_truncates_and_shows_more():
|
|
24
|
+
items = [
|
|
25
|
+
CVEItem(
|
|
26
|
+
cve_id=f"CVE-2024-{i:04d}",
|
|
27
|
+
published="2024-01-01",
|
|
28
|
+
last_modified=None,
|
|
29
|
+
description="Test description",
|
|
30
|
+
cvss_v31=9.8,
|
|
31
|
+
cvss_v30=None,
|
|
32
|
+
cvss_v2=None,
|
|
33
|
+
severity="CRITICAL",
|
|
34
|
+
references=[],
|
|
35
|
+
)
|
|
36
|
+
for i in range(51)
|
|
37
|
+
]
|
|
38
|
+
output = format_grouped(items)
|
|
39
|
+
assert "... (1 more CRITICAL CVEs)" in output
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from cve_finder.models import CVEItem
|
|
2
|
+
from cve_finder.output import save_csv, save_json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def make_item() -> CVEItem:
|
|
6
|
+
return CVEItem(
|
|
7
|
+
cve_id="CVE-2024-9999",
|
|
8
|
+
published="2024-01-01",
|
|
9
|
+
last_modified="2024-01-02",
|
|
10
|
+
description="Example",
|
|
11
|
+
cvss_v31=7.5,
|
|
12
|
+
cvss_v30=None,
|
|
13
|
+
cvss_v2=None,
|
|
14
|
+
severity="HIGH",
|
|
15
|
+
references=["https://example.com"],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_save_json(tmp_path):
|
|
20
|
+
path = tmp_path / "out.json"
|
|
21
|
+
save_json([make_item()], str(path))
|
|
22
|
+
content = path.read_text(encoding="utf-8")
|
|
23
|
+
assert "CVE-2024-9999" in content
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_save_csv(tmp_path):
|
|
27
|
+
path = tmp_path / "out.csv"
|
|
28
|
+
save_csv([make_item()], str(path))
|
|
29
|
+
content = path.read_text(encoding="utf-8")
|
|
30
|
+
assert content.splitlines()[0].startswith("cve_id,published,last_modified")
|
|
31
|
+
assert "CVE-2024-9999" in content
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from cve_finder.models import CVEItem
|
|
2
|
+
from cve_finder.output import format_csv, format_json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def make_item() -> CVEItem:
|
|
6
|
+
return CVEItem(
|
|
7
|
+
cve_id="CVE-2024-0002",
|
|
8
|
+
published="2024-01-03",
|
|
9
|
+
last_modified=None,
|
|
10
|
+
description="Example",
|
|
11
|
+
cvss_v31=None,
|
|
12
|
+
cvss_v30=7.2,
|
|
13
|
+
cvss_v2=None,
|
|
14
|
+
severity="HIGH",
|
|
15
|
+
references=["https://example.com", "https://example.org"],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_format_json_contains_id():
|
|
20
|
+
output = format_json([make_item()])
|
|
21
|
+
assert "CVE-2024-0002" in output
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_format_csv_has_header_and_refs():
|
|
25
|
+
output = format_csv([make_item()])
|
|
26
|
+
lines = output.strip().splitlines()
|
|
27
|
+
assert lines[0].startswith("cve_id,published,last_modified")
|
|
28
|
+
assert "https://example.com | https://example.org" in output
|
tests/test_utils.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from cve_finder.utils import iso_date_to_nvd_range, parse_cvss, pick_english_description
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_iso_date_to_nvd_range_date_only():
|
|
7
|
+
start, end = iso_date_to_nvd_range("2024-01-01", "2024-01-02")
|
|
8
|
+
assert start == "2024-01-01T00:00:00.000Z"
|
|
9
|
+
assert end == "2024-01-02T00:00:00.000Z"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_iso_date_to_nvd_range_iso_with_timezone():
|
|
13
|
+
start, end = iso_date_to_nvd_range("2024-01-01T12:30:00+00:00", None)
|
|
14
|
+
assert start == "2024-01-01T12:30:00.000Z"
|
|
15
|
+
# end is current time; just validate format
|
|
16
|
+
assert end.endswith("Z") and "T" in end
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_iso_date_to_nvd_range_iso_without_timezone():
|
|
20
|
+
start, _ = iso_date_to_nvd_range("2024-01-01T12:30:00", "2024-01-01T13:00:00")
|
|
21
|
+
assert start == "2024-01-01T12:30:00.000Z"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_iso_date_to_nvd_range_invalid():
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
iso_date_to_nvd_range("2024-13-01", None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_pick_english_description():
|
|
30
|
+
descriptions = [
|
|
31
|
+
{"lang": "es", "value": "hola"},
|
|
32
|
+
{"lang": "en", "value": "hello"},
|
|
33
|
+
]
|
|
34
|
+
assert pick_english_description(descriptions) == "hello"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_pick_english_description_fallback_first():
|
|
38
|
+
descriptions = [{"lang": "fr", "value": "salut"}]
|
|
39
|
+
assert pick_english_description(descriptions) == "salut"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_pick_english_description_empty():
|
|
43
|
+
assert pick_english_description([]) == ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_parse_cvss_prefers_v31():
|
|
47
|
+
metrics = {
|
|
48
|
+
"cvssMetricV31": [{"cvssData": {"baseScore": 9.8, "baseSeverity": "CRITICAL"}}],
|
|
49
|
+
"cvssMetricV30": [{"cvssData": {"baseScore": 9.0, "baseSeverity": "CRITICAL"}}],
|
|
50
|
+
"cvssMetricV2": [{"cvssData": {"baseScore": 7.5}, "baseSeverity": "HIGH"}],
|
|
51
|
+
}
|
|
52
|
+
v31, v30, v2, sev = parse_cvss(metrics)
|
|
53
|
+
assert (v31, v30, v2, sev) == (9.8, 9.0, 7.5, "CRITICAL")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_parse_cvss_v2_only():
|
|
57
|
+
metrics = {"cvssMetricV2": [{"cvssData": {"baseScore": 5.0}, "baseSeverity": "MEDIUM"}]}
|
|
58
|
+
v31, v30, v2, sev = parse_cvss(metrics)
|
|
59
|
+
assert (v31, v30, v2, sev) == (None, None, 5.0, "MEDIUM")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_parse_cvss_v30_only():
|
|
63
|
+
metrics = {"cvssMetricV30": [{"cvssData": {"baseScore": 6.1, "baseSeverity": "MEDIUM"}}]}
|
|
64
|
+
v31, v30, v2, sev = parse_cvss(metrics)
|
|
65
|
+
assert (v31, v30, v2, sev) == (None, 6.1, None, "MEDIUM")
|