cveye 3.2.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.
cveye/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CVEye - Check latest CVEs and exploit availability without API keys."""
2
+
3
+ __version__ = "3.2.0"
cveye/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow `python3 -m cveye` invocation."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
cveye/batch.py ADDED
@@ -0,0 +1,204 @@
1
+ """
2
+ Stdin batch engine: reads CVE IDs (or nuclei/httpx JSONL) from stdin,
3
+ warms the cache via one OSV batch request, then processes each CVE
4
+ concurrently with per-thread stdout capture so output blocks never
5
+ interleave.
6
+ """
7
+
8
+ import argparse
9
+ import io
10
+ import json
11
+ import re
12
+ import sys
13
+ import threading
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+
16
+ from .colors import set_silent, tag_error, tag_success
17
+ from .output import elapsed
18
+ from .utils import normalize_cve_id, dedup
19
+
20
+ _STDIN_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}", re.I)
21
+
22
+
23
+ def extract_cves_from_line(line):
24
+ """
25
+ Extract CVE IDs from a stdin line.
26
+
27
+ Accepts plain CVE IDs ('CVE-2021-44228', '2021-44228') as well as
28
+ structured output from other tools - any JSON blob such as nuclei or
29
+ httpx result lines is scanned for CVE identifiers.
30
+ Returns a list of normalized IDs (may be empty).
31
+ """
32
+ stripped = line.strip()
33
+ if stripped.startswith("{") or stripped.startswith("["):
34
+ found = _STDIN_CVE_RE.findall(stripped)
35
+ return dedup([normalize_cve_id(c) for c in found if normalize_cve_id(c)])
36
+ normalized = normalize_cve_id(stripped)
37
+ return [normalized] if normalized else []
38
+
39
+
40
+ def read_cve_ids_from_stdin():
41
+ """
42
+ Read CVE IDs from stdin (one per line). JSON tool output (nuclei,
43
+ httpx, ...) is auto-detected per line and scanned for CVE IDs.
44
+ Returns (valid_ids, skipped_lines).
45
+ """
46
+ if sys.stdin.isatty():
47
+ return [], []
48
+ valid, skipped = [], []
49
+ seen = set()
50
+ for line in sys.stdin:
51
+ line = line.rstrip("\n")
52
+ if not line.strip():
53
+ continue
54
+ ids = extract_cves_from_line(line)
55
+ if ids:
56
+ for cid in ids:
57
+ if cid not in seen:
58
+ seen.add(cid)
59
+ valid.append(cid)
60
+ else:
61
+ skipped.append(line.strip())
62
+ return valid, skipped
63
+
64
+
65
+ class BatchStdoutProxy:
66
+ """
67
+ stdout proxy used during concurrent batch processing.
68
+
69
+ Each worker thread captures its own writes into a private buffer so
70
+ result blocks never interleave; the main thread passes through to the
71
+ real stdout. Installed once for the whole batch.
72
+ """
73
+
74
+ def __init__(self, real):
75
+ self._real = real
76
+ self._local = threading.local()
77
+
78
+ def write(self, s):
79
+ buf = getattr(self._local, "buffer", None)
80
+ if buf is None:
81
+ return self._real.write(s)
82
+ return buf.write(s)
83
+
84
+ def flush(self):
85
+ buf = getattr(self._local, "buffer", None)
86
+ if buf is None:
87
+ self._real.flush()
88
+ else:
89
+ buf.flush()
90
+
91
+ def start_capture(self):
92
+ self._local.buffer = io.StringIO()
93
+
94
+ def stop_capture(self):
95
+ buf = getattr(self._local, "buffer", None)
96
+ self._local.buffer = None
97
+ return buf.getvalue() if buf else ""
98
+
99
+ def __getattr__(self, name):
100
+ return getattr(self._real, name)
101
+
102
+
103
+ def handle_stdin_cves(args, func):
104
+ """
105
+ Handle stdin input for CVE IDs ('-' as cve_id).
106
+
107
+ Supports batch processing of multiple CVEs, executed concurrently
108
+ (-c/-concurrency, default 5). Result blocks are kept intact; JSON batch
109
+ mode emits a single JSON array in input order, with failures reported
110
+ as {"cve_id": ..., "error": ...} objects instead of being dropped.
111
+ """
112
+ cve_ids, skipped = read_cve_ids_from_stdin()
113
+
114
+ for line in skipped[:3]:
115
+ from .colors import tag_warning
116
+ tag_warning(f"Skipping invalid CVE ID: {line}")
117
+ if len(skipped) > 3:
118
+ from .colors import tag_warning
119
+ tag_warning(f"... and {len(skipped) - 3} more invalid lines skipped")
120
+
121
+ if not cve_ids:
122
+ tag_error("No valid CVE IDs found on stdin.")
123
+ return 1
124
+
125
+ concurrency = getattr(args, "concurrency", None) or 5
126
+ concurrency = max(1, min(int(concurrency), len(cve_ids)))
127
+
128
+ was_silent = getattr(args, "silent", False)
129
+ if args.json and not was_silent:
130
+ set_silent(True)
131
+
132
+ # Warm the cache: one OSV querybatch round-trip resolves aliases and
133
+ # fills the cache so the per-CVE phase is mostly cache hits.
134
+ try:
135
+ from .sources import fetch_cve_details_batch
136
+ fetch_cve_details_batch(cve_ids, max_workers=concurrency)
137
+ except Exception:
138
+ pass
139
+
140
+ real_stdout = sys.stdout
141
+ proxy = BatchStdoutProxy(real_stdout)
142
+ sys.stdout = proxy
143
+
144
+ exit_code = 0
145
+ print_lock = threading.Lock()
146
+
147
+ def _work(cid):
148
+ new_args = argparse.Namespace(**vars(args))
149
+ new_args.cve_id = cid
150
+ proxy.start_capture()
151
+ try:
152
+ code = func(new_args)
153
+ out = proxy.stop_capture()
154
+ return cid, code, out, None
155
+ except Exception as e: # keep one bad CVE from killing the batch
156
+ proxy.stop_capture()
157
+ return cid, 1, "", e
158
+
159
+ try:
160
+ if concurrency == 1 or len(cve_ids) == 1:
161
+ outcomes = [_work(cid) for cid in cve_ids]
162
+ else:
163
+ outcomes = []
164
+ with ThreadPoolExecutor(max_workers=concurrency) as ex:
165
+ futures = [ex.submit(_work, cid) for cid in cve_ids]
166
+ for fut in as_completed(futures):
167
+ outcomes.append(fut.result())
168
+
169
+ if args.json:
170
+ by_id = {}
171
+ for cid, code, out, err in outcomes:
172
+ if err is not None or code != 0:
173
+ by_id[cid] = {"cve_id": cid, "error": "lookup failed"}
174
+ exit_code = code or 1
175
+ continue
176
+ try:
177
+ by_id[cid] = json.loads(out.strip())
178
+ except json.JSONDecodeError:
179
+ by_id[cid] = {"cve_id": cid,
180
+ "error": "invalid JSON output"}
181
+ exit_code = 1
182
+ # Preserve input order in the emitted array.
183
+ batch = [by_id.get(cid) for cid in cve_ids]
184
+ with print_lock:
185
+ print(json.dumps(batch, indent=2))
186
+ else:
187
+ for cid, code, out, err in outcomes:
188
+ with print_lock:
189
+ if out:
190
+ print(out, end="" if out.endswith("\n") else "\n")
191
+ if err is not None:
192
+ with print_lock:
193
+ tag_error(f"Error processing {cid}: {err}")
194
+ exit_code = 1
195
+ elif code != 0:
196
+ exit_code = code
197
+ finally:
198
+ sys.stdout = real_stdout
199
+ if args.json and not was_silent:
200
+ set_silent(False)
201
+
202
+ tag_success(f"Processed {len(cve_ids)} CVE(s) in {elapsed()} "
203
+ f"(concurrency {concurrency})")
204
+ return exit_code
cveye/cache.py ADDED
@@ -0,0 +1,235 @@
1
+ """
2
+ Persistent TTL cache for CVEye.
3
+
4
+ Stores API responses (CVE details, EPSS scores, KEV index, ExploitDB CSV)
5
+ under the user cache directory so repeated runs are instant and
6
+ rate-limit friendly. Thread-safe; disabled at runtime via -no-cache or
7
+ CVEYE_NO_CACHE=1.
8
+
9
+ Backends:
10
+ - json (default): two JSON files (cache.json + big-cache.json)
11
+ - sqlite: a single SQLite database; better for very large batches.
12
+ Selected with CVEYE_CACHE_BACKEND=sqlite (stdlib sqlite3 only).
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import tempfile
18
+ import threading
19
+ import time
20
+
21
+ DEFAULT_TTL = 3600
22
+
23
+ _enabled = True
24
+
25
+
26
+ def set_enabled(enabled):
27
+ """Enable/disable caching globally (disabled caches are no-ops)."""
28
+ global _enabled
29
+ _enabled = bool(enabled)
30
+
31
+
32
+ def is_enabled():
33
+ return _enabled
34
+
35
+
36
+ def default_cache_dir():
37
+ """Platform-appropriate cache directory for CVEye."""
38
+ if os.name == "nt":
39
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
40
+ return os.path.join(base, "CVEye", "cache")
41
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(
42
+ os.path.expanduser("~"), ".cache")
43
+ return os.path.join(base, "cveye")
44
+
45
+
46
+ def user_cache_path(filename="cache.json"):
47
+ return os.path.join(default_cache_dir(), filename)
48
+
49
+
50
+ class TTLCache:
51
+ """A JSON-file-backed key/value store with per-entry TTLs."""
52
+
53
+ def __init__(self, path):
54
+ self.path = path
55
+ self.ttl = DEFAULT_TTL
56
+ self._lock = threading.Lock()
57
+ self._data = {}
58
+ self._load()
59
+
60
+ # -- persistence ------------------------------------------------------
61
+
62
+ def _load(self):
63
+ try:
64
+ with open(self.path, "r", encoding="utf-8") as f:
65
+ raw = json.load(f)
66
+ if isinstance(raw, dict):
67
+ self._data = raw.get("entries", {})
68
+ except (OSError, ValueError):
69
+ self._data = {}
70
+
71
+ def save(self):
72
+ """Atomically persist non-expired entries."""
73
+ with self._lock:
74
+ entries = {k: v for k, v in self._data.items()
75
+ if v.get("e", 0) > time.time()}
76
+ self._data = entries
77
+ try:
78
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
79
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path),
80
+ prefix=".cveye-cache-")
81
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
82
+ json.dump({"version": 1, "entries": entries}, f)
83
+ os.replace(tmp, self.path)
84
+ except OSError:
85
+ pass
86
+
87
+ # -- api ----------------------------------------------------------------
88
+
89
+ def get(self, key):
90
+ """Return cached value or None if missing/expired/disabled."""
91
+ if not _enabled:
92
+ return None
93
+ with self._lock:
94
+ entry = self._data.get(key)
95
+ if not entry:
96
+ return None
97
+ if entry.get("e", 0) <= time.time():
98
+ del self._data[key]
99
+ return None
100
+ return entry.get("v")
101
+
102
+ def set(self, key, value, ttl=None):
103
+ """Store value with TTL (seconds) and persist."""
104
+ if not _enabled:
105
+ return
106
+ with self._lock:
107
+ self._data[key] = {
108
+ "v": value,
109
+ "e": time.time() + (ttl if ttl is not None else self.ttl),
110
+ }
111
+ self.save()
112
+
113
+ def clear(self):
114
+ with self._lock:
115
+ self._data = {}
116
+ try:
117
+ os.remove(self.path)
118
+ except OSError:
119
+ pass
120
+
121
+
122
+ class SQLiteCache:
123
+ """
124
+ SQLite-backed key/value store with per-entry TTLs.
125
+
126
+ Same interface as TTLCache. Values are stored as JSON text; reads hit
127
+ the database directly so huge batches don't hold everything in memory.
128
+ """
129
+
130
+ def __init__(self, path):
131
+ import sqlite3
132
+ self.path = path
133
+ self.ttl = DEFAULT_TTL
134
+ self._lock = threading.Lock()
135
+ try:
136
+ os.makedirs(os.path.dirname(path), exist_ok=True)
137
+ self._conn = sqlite3.connect(path, check_same_thread=False)
138
+ self._conn.execute(
139
+ "CREATE TABLE IF NOT EXISTS cache ("
140
+ " key TEXT PRIMARY KEY, value TEXT NOT NULL,"
141
+ " expires REAL NOT NULL)")
142
+ self._conn.commit()
143
+ except OSError:
144
+ # Fall back to a disabled in-memory instance on failure.
145
+ self._conn = sqlite3.connect(":memory:")
146
+ self._conn.execute(
147
+ "CREATE TABLE IF NOT EXISTS cache ("
148
+ " key TEXT PRIMARY KEY, value TEXT NOT NULL,"
149
+ " expires REAL NOT NULL)")
150
+
151
+ def get(self, key):
152
+ if not _enabled:
153
+ return None
154
+ row = self._conn.execute(
155
+ "SELECT value, expires FROM cache WHERE key = ?",
156
+ (key,)).fetchone()
157
+ if row is None:
158
+ return None
159
+ if row[1] <= time.time():
160
+ with self._lock:
161
+ self._conn.execute("DELETE FROM cache WHERE key = ?", (key,))
162
+ self._conn.commit()
163
+ return None
164
+ try:
165
+ return json.loads(row[0])
166
+ except ValueError:
167
+ return None
168
+
169
+ def set(self, key, value, ttl=None):
170
+ if not _enabled:
171
+ return
172
+ expires = time.time() + (ttl if ttl is not None else self.ttl)
173
+ with self._lock:
174
+ self._conn.execute(
175
+ "INSERT OR REPLACE INTO cache (key, value, expires)"
176
+ " VALUES (?, ?, ?)", (key, json.dumps(value), expires))
177
+ self._conn.commit()
178
+
179
+ def clear(self):
180
+ with self._lock:
181
+ self._conn.execute("DELETE FROM cache")
182
+ self._conn.commit()
183
+ try:
184
+ os.remove(self.path)
185
+ except OSError:
186
+ pass
187
+
188
+ def save(self):
189
+ pass
190
+
191
+
192
+ def _make_cache(filename):
193
+ backend = os.environ.get("CVEYE_CACHE_BACKEND", "json").lower()
194
+ if backend == "sqlite":
195
+ return SQLiteCache(user_cache_path("cache.db"))
196
+ return TTLCache(user_cache_path(filename))
197
+
198
+
199
+ # Shared process-wide cache instances. The 'big' variant holds bulky
200
+ # payloads (KEV index, ExploitDB CSV index) so frequent small writes never
201
+ # rewrite megabyte-sized files.
202
+ _instance = _make_cache("cache.json")
203
+ if isinstance(_instance, SQLiteCache):
204
+ _instance_big = _instance # single DB serves both sizes
205
+ else:
206
+ _instance_big = TTLCache(user_cache_path("big-cache.json"))
207
+
208
+
209
+ # Module-level accessors so callers can simply `from . import cache`
210
+ # and use cache.get(...) / cache.set(...).
211
+
212
+ def get(key):
213
+ return _instance.get(key)
214
+
215
+
216
+ def set(key, value, ttl=None):
217
+ _instance.set(key, value, ttl)
218
+
219
+
220
+ def get_big(key):
221
+ return _instance_big.get(key)
222
+
223
+
224
+ def set_big(key, value, ttl=None):
225
+ _instance_big.set(key, value, ttl)
226
+
227
+
228
+ def save():
229
+ _instance.save()
230
+ _instance_big.save()
231
+
232
+
233
+ def clear():
234
+ _instance.clear()
235
+ _instance_big.clear()
cveye/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CVEye CLI entry point - Check latest CVEs and exploit availability
4
+ without API keys.
5
+
6
+ The implementation is split across focused modules:
7
+ parser.py argument parsing and help formatting
8
+ output.py presentation helpers (banner, CVE printers)
9
+ batch.py stdin batch engine
10
+ commands/ one module per command group
11
+
12
+ This module wires them together: global-flag application (config file,
13
+ env vars), stream redirection, and main().
14
+ """
15
+
16
+ import sys
17
+
18
+ if sys.version_info < (3, 7):
19
+ sys.stderr.write("CVEye requires Python 3.7 or newer.\n")
20
+ sys.exit(1)
21
+
22
+ from . import cache
23
+ from .config import VERSION, load_user_config
24
+ from .colors import (
25
+ set_color,
26
+ set_silent,
27
+ refresh_streams,
28
+ tag_error,
29
+ )
30
+
31
+ # Re-exported for backward compatibility (tests and external callers).
32
+ from .parser import build_parser, CVeyeParser, CVeyeHelpFormatter # noqa: F401
33
+ from .output import print_startup as _print_startup # noqa: F401
34
+ from .batch import ( # noqa: F401
35
+ handle_stdin_cves as handle_stdin_cve,
36
+ read_cve_ids_from_stdin as _read_cve_ids_from_stdin,
37
+ )
38
+
39
+
40
+ def apply_global_args(args):
41
+ """Apply global flags, filling unset values from the user config file.
42
+
43
+ Precedence: CLI flag > config.json > environment/built-in default.
44
+ """
45
+ import os as _os
46
+
47
+ from . import config as _config
48
+
49
+ cfg = load_user_config()
50
+
51
+ if getattr(args, "silent", None) is None:
52
+ args.silent = bool(cfg.get("silent", False))
53
+ if getattr(args, "no_color", None) is None:
54
+ args.no_color = bool(cfg.get("no_color", False))
55
+ if getattr(args, "no_cache", None) is None:
56
+ args.no_cache = bool(cfg.get("no_cache", False)) or \
57
+ bool(_os.environ.get("CVEYE_NO_CACHE"))
58
+ if getattr(args, "concurrency", None) is None:
59
+ try:
60
+ args.concurrency = max(1, int(cfg.get("concurrency", 5)))
61
+ except (TypeError, ValueError):
62
+ args.concurrency = 5
63
+ if getattr(args, "download_dir", None) is None:
64
+ args.download_dir = cfg.get("download_dir", "exploits")
65
+
66
+ # GitHub/NVD tokens: env var wins over config file.
67
+ if not _os.environ.get("GITHUB_TOKEN") and cfg.get("github_token"):
68
+ _config.GITHUB_TOKEN = cfg["github_token"]
69
+ if not _os.environ.get("NVD_API_KEY") and cfg.get("nvd_api_key"):
70
+ _config.NVD_API_KEY = cfg["nvd_api_key"]
71
+
72
+ cache.set_enabled(not args.no_cache)
73
+
74
+ if getattr(args, "silent", False):
75
+ set_silent(True)
76
+ if getattr(args, "no_color", False):
77
+ set_color(False)
78
+ if getattr(args, "output", None):
79
+ try:
80
+ sys.stdout = open(args.output, "w", encoding="utf-8")
81
+ except OSError as e:
82
+ tag_error(f"Cannot open output file '{args.output}': {e}")
83
+ raise SystemExit(2)
84
+ # Re-evaluate color support now that stdout points at a file.
85
+ refresh_streams()
86
+
87
+
88
+ def main(argv=None):
89
+ """Main entry point."""
90
+ from .output import reset_timer, print_startup
91
+
92
+ reset_timer()
93
+ parser = build_parser()
94
+ args = parser.parse_args(argv)
95
+
96
+ if not args.command:
97
+ # Show the banner on bare invocation too.
98
+ if not getattr(args, "silent", False):
99
+ print_startup()
100
+ parser.print_help()
101
+ return 1
102
+
103
+ apply_global_args(args)
104
+
105
+ # Show banner and warnings unless silent or JSON mode.
106
+ if (not getattr(args, "silent", False)
107
+ and not getattr(args, "json", False)):
108
+ print_startup()
109
+
110
+ try:
111
+ if (args.command in ("check", "scan", "search", "affected")
112
+ and getattr(args, "cve_id", "") == "-"):
113
+ from .batch import handle_stdin_cves
114
+ code = handle_stdin_cves(args, args.func)
115
+ else:
116
+ code = args.func(args)
117
+ finally:
118
+ if getattr(args, "output", None):
119
+ try:
120
+ sys.stdout.close()
121
+ except OSError:
122
+ pass
123
+ sys.stdout = sys.__stdout__
124
+
125
+ return code
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())