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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cve-finder = cve_finder.cli:main
@@ -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,2 @@
1
+ cve_finder
2
+ tests
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"
@@ -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")