xpl-intel 1.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.
- ingestion/__init__.py +2 -0
- ingestion/cli.py +162 -0
- ingestion/dedup.py +49 -0
- ingestion/output/__init__.py +9 -0
- ingestion/output/csv_writer.py +36 -0
- ingestion/output/json_writer.py +17 -0
- ingestion/output/sqlite_writer.py +116 -0
- ingestion/sources/__init__.py +0 -0
- ingestion/sources/cisa_kev.py +44 -0
- ingestion/sources/epss_api.py +49 -0
- ingestion/sources/exploitdb.py +50 -0
- ingestion/sources/github_advisory.py +113 -0
- ingestion/sources/nuclei_templates.py +62 -0
- ingestion/sources/nvd_api.py +99 -0
- ingestion/sources/packetstorm_rss.py +47 -0
- ingestion/sources/vulncheck_kev.py +67 -0
- ingestion/sources/watchtowr_rss.py +48 -0
- schemas/__init__.py +0 -0
- schemas/advisory_v1.py +73 -0
- schemas/py.typed +0 -0
- xpl_intel-1.2.0.dist-info/METADATA +24 -0
- xpl_intel-1.2.0.dist-info/RECORD +26 -0
- xpl_intel-1.2.0.dist-info/WHEEL +5 -0
- xpl_intel-1.2.0.dist-info/entry_points.txt +3 -0
- xpl_intel-1.2.0.dist-info/licenses/LICENSE +123 -0
- xpl_intel-1.2.0.dist-info/top_level.txt +2 -0
ingestion/__init__.py
ADDED
ingestion/cli.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""XPL-Intel CLI — CVE ingestion pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import argcomplete
|
|
11
|
+
|
|
12
|
+
from ingestion import PRODUCT, __version__
|
|
13
|
+
from schemas.advisory_v1 import Advisory
|
|
14
|
+
|
|
15
|
+
ALL_SOURCES = ["kev", "nvd", "watchtowr", "nuclei", "exploitdb", "vulncheck", "ghsa"]
|
|
16
|
+
OUTPUT_FORMATS = ["json", "sqlite", "csv"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="xpl-intel",
|
|
22
|
+
description=f"{PRODUCT} v{__version__} — CVE ingestion pipeline",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("--version", action="version", version=f"{PRODUCT} {__version__}")
|
|
25
|
+
|
|
26
|
+
sub = parser.add_subparsers(dest="command")
|
|
27
|
+
|
|
28
|
+
ingest_p = sub.add_parser("ingest", help="Run ingestion from sources")
|
|
29
|
+
ingest_p.add_argument("--sources", "-s", nargs="+",
|
|
30
|
+
choices=[*ALL_SOURCES, "all"],
|
|
31
|
+
default=["all"])
|
|
32
|
+
ingest_p.add_argument("--nvd-key", help="NVD API key (optional)")
|
|
33
|
+
ingest_p.add_argument("--nvd-days", type=int, default=7, help="NVD lookback days")
|
|
34
|
+
ingest_p.add_argument("--nuclei-dir", default="nuclei-templates", help="Nuclei templates dir")
|
|
35
|
+
ingest_p.add_argument("--exploitdb-csv", help="ExploitDB CSV path")
|
|
36
|
+
ingest_p.add_argument("--vulncheck-token", help="VulnCheck API token")
|
|
37
|
+
ingest_p.add_argument("--github-token", help="GitHub token for GHSA")
|
|
38
|
+
ingest_p.add_argument("--ghsa-severity", default="critical,high",
|
|
39
|
+
help="GHSA severity filter (default: critical,high)")
|
|
40
|
+
ingest_p.add_argument("--ghsa-days", type=int, default=7, help="GHSA lookback days")
|
|
41
|
+
ingest_p.add_argument("--output", "-o", default="output", help="Output directory")
|
|
42
|
+
ingest_p.add_argument("--format", "-f", nargs="+",
|
|
43
|
+
choices=OUTPUT_FORMATS, default=["json"],
|
|
44
|
+
help="Output formats (default: json)")
|
|
45
|
+
|
|
46
|
+
sub.add_parser("sources", help="List available sources")
|
|
47
|
+
|
|
48
|
+
argcomplete.autocomplete(parser)
|
|
49
|
+
args = parser.parse_args(argv)
|
|
50
|
+
if not args.command:
|
|
51
|
+
parser.print_help()
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
if args.command == "sources":
|
|
55
|
+
_print_sources()
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
return _run_ingest(args)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _print_sources() -> None:
|
|
62
|
+
sources = [
|
|
63
|
+
("kev", "CISA KEV catalog (CSV daily)", "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"),
|
|
64
|
+
("nvd", "NVD API v2 (delta weekly)", "https://nvd.nist.gov/"),
|
|
65
|
+
("watchtowr", "watchTowr Labs RSS", "https://labs.watchtowr.com/"),
|
|
66
|
+
("nuclei", "Nuclei templates (local)", "https://github.com/projectdiscovery/nuclei-templates"),
|
|
67
|
+
("exploitdb", "ExploitDB CSV (searchsploit)", "https://www.exploit-db.com/"),
|
|
68
|
+
("vulncheck", "VulnCheck KEV enriched", "https://vulncheck.com/"),
|
|
69
|
+
("ghsa", "GitHub Advisory Database", "https://github.com/advisories"),
|
|
70
|
+
]
|
|
71
|
+
print(f"{PRODUCT} v{__version__} — Available sources:\n")
|
|
72
|
+
for name, desc, url in sources:
|
|
73
|
+
print(f" {name:12s} {desc}")
|
|
74
|
+
print(f" {'':12s} {url}")
|
|
75
|
+
print()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run_ingest(args: argparse.Namespace) -> int:
|
|
79
|
+
from ingestion.dedup import deduplicate
|
|
80
|
+
|
|
81
|
+
sources_to_run: list[str] = args.sources
|
|
82
|
+
if "all" in sources_to_run:
|
|
83
|
+
sources_to_run = list(ALL_SOURCES)
|
|
84
|
+
|
|
85
|
+
all_advisories: list[Advisory] = []
|
|
86
|
+
|
|
87
|
+
for source in sources_to_run:
|
|
88
|
+
try:
|
|
89
|
+
advisories = _fetch_source(source, args)
|
|
90
|
+
print(f"[+] {source}: {len(advisories)} advisories")
|
|
91
|
+
all_advisories.extend(advisories)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
print(f"[-] {source}: {exc}", file=sys.stderr)
|
|
94
|
+
|
|
95
|
+
if not all_advisories:
|
|
96
|
+
print("[*] No advisories ingested")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
deduped = deduplicate(all_advisories)
|
|
100
|
+
print(f"[+] Deduplicated: {len(all_advisories)} → {len(deduped)}")
|
|
101
|
+
|
|
102
|
+
os.makedirs(args.output, exist_ok=True)
|
|
103
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
104
|
+
|
|
105
|
+
formats: list[str] = args.format
|
|
106
|
+
for fmt in formats:
|
|
107
|
+
_write_output(deduped, args.output, ts, fmt)
|
|
108
|
+
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_output(advisories: list[Advisory], output_dir: str, ts: str, fmt: str) -> None:
|
|
113
|
+
if fmt == "json":
|
|
114
|
+
from ingestion.output.json_writer import write_json
|
|
115
|
+
path = write_json(advisories, os.path.join(output_dir, f"advisories-{ts}.json"))
|
|
116
|
+
elif fmt == "sqlite":
|
|
117
|
+
from ingestion.output.sqlite_writer import write_sqlite
|
|
118
|
+
path = write_sqlite(advisories, os.path.join(output_dir, f"advisories-{ts}.db"))
|
|
119
|
+
elif fmt == "csv":
|
|
120
|
+
from ingestion.output.csv_writer import write_csv
|
|
121
|
+
path = write_csv(advisories, os.path.join(output_dir, f"advisories-{ts}.csv"))
|
|
122
|
+
else:
|
|
123
|
+
print(f"[-] Unknown format: {fmt}", file=sys.stderr)
|
|
124
|
+
return
|
|
125
|
+
print(f"[+] Output ({fmt}): {path} ({len(advisories)} advisories)")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _fetch_source(source: str, args: argparse.Namespace) -> list[Advisory]:
|
|
129
|
+
if source == "kev":
|
|
130
|
+
from ingestion.sources.cisa_kev import fetch as fetch_kev
|
|
131
|
+
return fetch_kev()
|
|
132
|
+
if source == "nvd":
|
|
133
|
+
from ingestion.sources.nvd_api import fetch as fetch_nvd
|
|
134
|
+
return fetch_nvd(days_back=args.nvd_days, api_key=args.nvd_key)
|
|
135
|
+
if source == "watchtowr":
|
|
136
|
+
from ingestion.sources.watchtowr_rss import fetch as fetch_watchtowr
|
|
137
|
+
return fetch_watchtowr()
|
|
138
|
+
if source == "nuclei":
|
|
139
|
+
from ingestion.sources.nuclei_templates import fetch as fetch_nuclei
|
|
140
|
+
return fetch_nuclei(templates_dir=args.nuclei_dir)
|
|
141
|
+
if source == "exploitdb":
|
|
142
|
+
from ingestion.sources.exploitdb import fetch as fetch_exploitdb
|
|
143
|
+
return fetch_exploitdb(csv_path=args.exploitdb_csv)
|
|
144
|
+
if source == "vulncheck":
|
|
145
|
+
from ingestion.sources.vulncheck_kev import fetch as fetch_vulncheck
|
|
146
|
+
token = args.vulncheck_token or os.environ.get("VULNCHECK_TOKEN", "")
|
|
147
|
+
if not token:
|
|
148
|
+
raise ValueError("VulnCheck API token required (--vulncheck-token or VULNCHECK_TOKEN env)")
|
|
149
|
+
return fetch_vulncheck(api_token=token)
|
|
150
|
+
if source == "ghsa":
|
|
151
|
+
from ingestion.sources.github_advisory import fetch as fetch_ghsa
|
|
152
|
+
token = args.github_token or os.environ.get("GITHUB_TOKEN")
|
|
153
|
+
return fetch_ghsa(
|
|
154
|
+
days_back=args.ghsa_days,
|
|
155
|
+
github_token=token,
|
|
156
|
+
severity=args.ghsa_severity,
|
|
157
|
+
)
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
sys.exit(main())
|
ingestion/dedup.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Deduplication engine — merge advisories by CVE+CNA, keep richest data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from schemas.advisory_v1 import Advisory
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def deduplicate(advisories: list[Advisory]) -> list[Advisory]:
|
|
9
|
+
seen: dict[str, Advisory] = {}
|
|
10
|
+
|
|
11
|
+
for adv in advisories:
|
|
12
|
+
key = adv.cve_id
|
|
13
|
+
if key in seen:
|
|
14
|
+
seen[key] = _merge(seen[key], adv)
|
|
15
|
+
else:
|
|
16
|
+
seen[key] = adv
|
|
17
|
+
|
|
18
|
+
return sorted(seen.values(), key=lambda a: a.cve_id)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _merge(existing: Advisory, new: Advisory) -> Advisory:
|
|
22
|
+
if not existing.description and new.description:
|
|
23
|
+
existing.description = new.description
|
|
24
|
+
if new.cvss_score is not None and (existing.cvss_score is None or new.cvss_score > existing.cvss_score):
|
|
25
|
+
existing.cvss_score = new.cvss_score
|
|
26
|
+
existing.cvss_vector = new.cvss_vector or existing.cvss_vector
|
|
27
|
+
if new.epss_score is not None:
|
|
28
|
+
existing.epss_score = new.epss_score
|
|
29
|
+
existing.epss_percentile = new.epss_percentile
|
|
30
|
+
if new.kev:
|
|
31
|
+
existing.kev = True
|
|
32
|
+
existing.kev_date_added = new.kev_date_added or existing.kev_date_added
|
|
33
|
+
existing.kev_due_date = new.kev_due_date or existing.kev_due_date
|
|
34
|
+
if not existing.vendor and new.vendor:
|
|
35
|
+
existing.vendor = new.vendor
|
|
36
|
+
if not existing.product and new.product:
|
|
37
|
+
existing.product = new.product
|
|
38
|
+
if new.cwe and not existing.cwe:
|
|
39
|
+
existing.cwe = new.cwe
|
|
40
|
+
|
|
41
|
+
existing.references = list(dict.fromkeys(existing.references + new.references))[:20]
|
|
42
|
+
existing.poc_urls = list(dict.fromkeys(existing.poc_urls + new.poc_urls))[:10]
|
|
43
|
+
existing.tags = list(dict.fromkeys(existing.tags + new.tags))[:20]
|
|
44
|
+
existing.mitre_techniques = list(dict.fromkeys(existing.mitre_techniques + new.mitre_techniques))
|
|
45
|
+
|
|
46
|
+
sources = f"{existing.source},{new.source}"
|
|
47
|
+
existing.source = ",".join(dict.fromkeys(sources.split(",")))
|
|
48
|
+
|
|
49
|
+
return existing
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Output writers for xpl-intel advisories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ingestion.output.csv_writer import write_csv
|
|
6
|
+
from ingestion.output.json_writer import write_json
|
|
7
|
+
from ingestion.output.sqlite_writer import write_sqlite
|
|
8
|
+
|
|
9
|
+
__all__ = ["write_csv", "write_json", "write_sqlite"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CSV output writer for xpl-intel advisories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from schemas.advisory_v1 import Advisory
|
|
10
|
+
|
|
11
|
+
CSV_FIELDS = [
|
|
12
|
+
"cve_id", "title", "description", "cvss_score", "cvss_vector",
|
|
13
|
+
"epss_score", "epss_percentile", "kev", "kev_date_added", "kev_due_date",
|
|
14
|
+
"vendor", "product", "affected_versions", "fixed_versions", "patch_url",
|
|
15
|
+
"poc_urls", "references", "cwe", "source", "source_url",
|
|
16
|
+
"status", "ingested_at", "mitre_techniques", "tags",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_csv(advisories: list[Advisory], path: str | Path) -> Path:
|
|
21
|
+
out = Path(path)
|
|
22
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
with open(out, "w", newline="", encoding="utf-8") as fh:
|
|
25
|
+
writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
|
|
26
|
+
writer.writeheader()
|
|
27
|
+
for adv in advisories:
|
|
28
|
+
row = adv.to_dict()
|
|
29
|
+
row.pop("schema", None)
|
|
30
|
+
for key in ("affected_versions", "fixed_versions", "poc_urls",
|
|
31
|
+
"references", "mitre_techniques", "tags"):
|
|
32
|
+
if isinstance(row.get(key), list):
|
|
33
|
+
row[key] = json.dumps(row[key])
|
|
34
|
+
writer.writerow({k: row.get(k, "") for k in CSV_FIELDS})
|
|
35
|
+
|
|
36
|
+
return out
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""JSON output writer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from schemas.advisory_v1 import Advisory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_json(advisories: list[Advisory], path: str | Path) -> Path:
|
|
13
|
+
out = Path(path)
|
|
14
|
+
data: list[dict[str, Any]] = [a.to_dict() for a in advisories]
|
|
15
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
out.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
17
|
+
return out
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""SQLite output writer for xpl-intel advisories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from schemas.advisory_v1 import Advisory
|
|
10
|
+
|
|
11
|
+
SCHEMA_SQL = """
|
|
12
|
+
CREATE TABLE IF NOT EXISTS advisories (
|
|
13
|
+
cve_id TEXT PRIMARY KEY,
|
|
14
|
+
title TEXT NOT NULL,
|
|
15
|
+
description TEXT DEFAULT '',
|
|
16
|
+
cvss_score REAL,
|
|
17
|
+
cvss_vector TEXT DEFAULT '',
|
|
18
|
+
epss_score REAL,
|
|
19
|
+
epss_percentile REAL,
|
|
20
|
+
kev INTEGER DEFAULT 0,
|
|
21
|
+
kev_date_added TEXT,
|
|
22
|
+
kev_due_date TEXT,
|
|
23
|
+
vendor TEXT DEFAULT '',
|
|
24
|
+
product TEXT DEFAULT '',
|
|
25
|
+
affected_versions TEXT DEFAULT '[]',
|
|
26
|
+
fixed_versions TEXT DEFAULT '[]',
|
|
27
|
+
patch_url TEXT DEFAULT '',
|
|
28
|
+
poc_urls TEXT DEFAULT '[]',
|
|
29
|
+
"references" TEXT DEFAULT '[]',
|
|
30
|
+
cwe TEXT DEFAULT '',
|
|
31
|
+
source TEXT DEFAULT '',
|
|
32
|
+
source_url TEXT DEFAULT '',
|
|
33
|
+
status TEXT DEFAULT 'unverified',
|
|
34
|
+
ingested_at TEXT DEFAULT '',
|
|
35
|
+
mitre_techniques TEXT DEFAULT '[]',
|
|
36
|
+
tags TEXT DEFAULT '[]'
|
|
37
|
+
);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_advisories_vendor ON advisories(vendor);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_advisories_cvss ON advisories(cvss_score);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_advisories_kev ON advisories(kev);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_advisories_source ON advisories(source);
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
UPSERT_SQL = """
|
|
45
|
+
INSERT INTO advisories (
|
|
46
|
+
cve_id, title, description, cvss_score, cvss_vector,
|
|
47
|
+
epss_score, epss_percentile, kev, kev_date_added, kev_due_date,
|
|
48
|
+
vendor, product, affected_versions, fixed_versions, patch_url,
|
|
49
|
+
poc_urls, "references", cwe, source, source_url,
|
|
50
|
+
status, ingested_at, mitre_techniques, tags
|
|
51
|
+
) VALUES (
|
|
52
|
+
?, ?, ?, ?, ?,
|
|
53
|
+
?, ?, ?, ?, ?,
|
|
54
|
+
?, ?, ?, ?, ?,
|
|
55
|
+
?, ?, ?, ?, ?,
|
|
56
|
+
?, ?, ?, ?
|
|
57
|
+
) ON CONFLICT(cve_id) DO UPDATE SET
|
|
58
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE advisories.title END,
|
|
59
|
+
description = CASE WHEN excluded.description != '' THEN excluded.description ELSE advisories.description END,
|
|
60
|
+
cvss_score = CASE WHEN excluded.cvss_score IS NOT NULL THEN excluded.cvss_score ELSE advisories.cvss_score END,
|
|
61
|
+
cvss_vector = CASE WHEN excluded.cvss_vector != '' THEN excluded.cvss_vector ELSE advisories.cvss_vector END,
|
|
62
|
+
kev = MAX(advisories.kev, excluded.kev),
|
|
63
|
+
source = advisories.source || ',' || excluded.source,
|
|
64
|
+
ingested_at = excluded.ingested_at
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_sqlite(advisories: list[Advisory], path: str | Path) -> Path:
|
|
69
|
+
out = Path(path)
|
|
70
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
conn = sqlite3.connect(str(out))
|
|
73
|
+
try:
|
|
74
|
+
conn.executescript(SCHEMA_SQL)
|
|
75
|
+
rows = [_advisory_to_row(a) for a in advisories]
|
|
76
|
+
conn.executemany(UPSERT_SQL, rows)
|
|
77
|
+
conn.commit()
|
|
78
|
+
finally:
|
|
79
|
+
conn.close()
|
|
80
|
+
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _advisory_to_row(adv: Advisory) -> tuple[
|
|
85
|
+
str, str, str, float | None, str,
|
|
86
|
+
float | None, float | None, int, str | None, str | None,
|
|
87
|
+
str, str, str, str, str,
|
|
88
|
+
str, str, str, str, str,
|
|
89
|
+
str, str, str, str,
|
|
90
|
+
]:
|
|
91
|
+
return (
|
|
92
|
+
adv.cve_id,
|
|
93
|
+
adv.title,
|
|
94
|
+
adv.description,
|
|
95
|
+
adv.cvss_score,
|
|
96
|
+
adv.cvss_vector,
|
|
97
|
+
adv.epss_score,
|
|
98
|
+
adv.epss_percentile,
|
|
99
|
+
1 if adv.kev else 0,
|
|
100
|
+
adv.kev_date_added,
|
|
101
|
+
adv.kev_due_date,
|
|
102
|
+
adv.vendor,
|
|
103
|
+
adv.product,
|
|
104
|
+
json.dumps(adv.affected_versions),
|
|
105
|
+
json.dumps(adv.fixed_versions),
|
|
106
|
+
adv.patch_url,
|
|
107
|
+
json.dumps(adv.poc_urls),
|
|
108
|
+
json.dumps(adv.references),
|
|
109
|
+
adv.cwe,
|
|
110
|
+
adv.source,
|
|
111
|
+
adv.source_url,
|
|
112
|
+
adv.status,
|
|
113
|
+
adv.ingested_at,
|
|
114
|
+
json.dumps(adv.mitre_techniques),
|
|
115
|
+
json.dumps(adv.tags),
|
|
116
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CISA Known Exploited Vulnerabilities (KEV) catalog ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from schemas.advisory_v1 import Advisory
|
|
12
|
+
|
|
13
|
+
KEV_URL = "https://www.cisa.gov/sites/default/files/csv/known_exploited_vulnerabilities.csv"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch(timeout: int = 30) -> list[Advisory]:
|
|
17
|
+
resp = requests.get(KEV_URL, timeout=timeout)
|
|
18
|
+
resp.raise_for_status()
|
|
19
|
+
|
|
20
|
+
reader = csv.DictReader(io.StringIO(resp.text))
|
|
21
|
+
advisories = []
|
|
22
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
for row in reader:
|
|
25
|
+
adv = Advisory(
|
|
26
|
+
cve_id=row.get("cveID", ""),
|
|
27
|
+
title=row.get("vulnerabilityName", ""),
|
|
28
|
+
description=row.get("shortDescription", ""),
|
|
29
|
+
vendor=row.get("vendorProject", ""),
|
|
30
|
+
product=row.get("product", ""),
|
|
31
|
+
kev=True,
|
|
32
|
+
kev_date_added=row.get("dateAdded", ""),
|
|
33
|
+
kev_due_date=row.get("dueDate", ""),
|
|
34
|
+
references=[row["notes"]] if row.get("notes") else [],
|
|
35
|
+
source="cisa_kev",
|
|
36
|
+
source_url=KEV_URL,
|
|
37
|
+
status="unverified",
|
|
38
|
+
ingested_at=now,
|
|
39
|
+
tags=["kev", "actively-exploited"],
|
|
40
|
+
)
|
|
41
|
+
if adv.cve_id:
|
|
42
|
+
advisories.append(adv)
|
|
43
|
+
|
|
44
|
+
return advisories
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""FIRST EPSS (Exploit Prediction Scoring System) API enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
EPSS_API_URL = "https://api.first.org/data/v1/epss"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class EPSSScore:
|
|
14
|
+
cve: str
|
|
15
|
+
epss: float
|
|
16
|
+
percentile: float
|
|
17
|
+
date: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fetch_scores(cve_ids: list[str], timeout: int = 30) -> list[EPSSScore]:
|
|
21
|
+
if not cve_ids:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
batch_size = 100
|
|
25
|
+
results: list[EPSSScore] = []
|
|
26
|
+
|
|
27
|
+
for i in range(0, len(cve_ids), batch_size):
|
|
28
|
+
batch = cve_ids[i : i + batch_size]
|
|
29
|
+
params = {"cve": ",".join(batch)}
|
|
30
|
+
resp = requests.get(EPSS_API_URL, params=params, timeout=timeout)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
data = resp.json()
|
|
33
|
+
|
|
34
|
+
for entry in data.get("data", []):
|
|
35
|
+
results.append(
|
|
36
|
+
EPSSScore(
|
|
37
|
+
cve=entry.get("cve", ""),
|
|
38
|
+
epss=float(entry.get("epss", 0.0)),
|
|
39
|
+
percentile=float(entry.get("percentile", 0.0)),
|
|
40
|
+
date=entry.get("date", ""),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return results
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def enrich_single(cve_id: str, timeout: int = 30) -> EPSSScore | None:
|
|
48
|
+
scores = fetch_scores([cve_id], timeout=timeout)
|
|
49
|
+
return scores[0] if scores else None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""ExploitDB CSV ingestion (files_exploits.csv from searchsploit clone)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from schemas.advisory_v1 import Advisory
|
|
11
|
+
|
|
12
|
+
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
|
|
13
|
+
DEFAULT_CSV = "/usr/share/exploitdb/files_exploits.csv"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch(csv_path: str | None = None) -> list[Advisory]:
|
|
17
|
+
path = Path(csv_path or DEFAULT_CSV)
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
advisories = []
|
|
22
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
with open(path, errors="ignore") as fh:
|
|
25
|
+
reader = csv.DictReader(fh)
|
|
26
|
+
for row in reader:
|
|
27
|
+
codes = row.get("codes", "")
|
|
28
|
+
cves = CVE_RE.findall(codes)
|
|
29
|
+
if not cves:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
desc = row.get("description", "")
|
|
33
|
+
edb_id = row.get("id", "")
|
|
34
|
+
platform = row.get("platform", "")
|
|
35
|
+
|
|
36
|
+
for cve in set(cves):
|
|
37
|
+
adv = Advisory(
|
|
38
|
+
cve_id=cve,
|
|
39
|
+
title=desc[:200],
|
|
40
|
+
description=desc,
|
|
41
|
+
poc_urls=[f"https://www.exploit-db.com/exploits/{edb_id}"] if edb_id else [],
|
|
42
|
+
source="exploitdb",
|
|
43
|
+
source_url=f"https://www.exploit-db.com/exploits/{edb_id}",
|
|
44
|
+
status="unverified",
|
|
45
|
+
ingested_at=now,
|
|
46
|
+
tags=["exploitdb", platform] if platform else ["exploitdb"],
|
|
47
|
+
)
|
|
48
|
+
advisories.append(adv)
|
|
49
|
+
|
|
50
|
+
return advisories
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""GitHub Advisory Database (GHSA) ingestion via REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from schemas.advisory_v1 import Advisory
|
|
12
|
+
|
|
13
|
+
GHSA_API = "https://api.github.com/advisories"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch(
|
|
17
|
+
days_back: int = 7,
|
|
18
|
+
github_token: str | None = None,
|
|
19
|
+
severity: str = "critical,high",
|
|
20
|
+
timeout: int = 30,
|
|
21
|
+
) -> list[Advisory]:
|
|
22
|
+
now = datetime.now(timezone.utc)
|
|
23
|
+
since = (now - timedelta(days=days_back)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
24
|
+
|
|
25
|
+
headers: dict[str, str] = {"Accept": "application/vnd.github+json"}
|
|
26
|
+
if github_token:
|
|
27
|
+
headers["Authorization"] = f"Bearer {github_token}"
|
|
28
|
+
|
|
29
|
+
advisories: list[Advisory] = []
|
|
30
|
+
page = 1
|
|
31
|
+
|
|
32
|
+
while True:
|
|
33
|
+
params: dict[str, str] = {
|
|
34
|
+
"type": "reviewed",
|
|
35
|
+
"severity": severity,
|
|
36
|
+
"updated": f">={since}",
|
|
37
|
+
"per_page": "100",
|
|
38
|
+
"page": str(page),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
resp = requests.get(GHSA_API, params=params, headers=headers, timeout=timeout)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
items: list[dict[str, Any]] = resp.json()
|
|
44
|
+
|
|
45
|
+
if not items:
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
ingested_at = datetime.now(timezone.utc).isoformat()
|
|
49
|
+
|
|
50
|
+
for item in items:
|
|
51
|
+
cve_id = item.get("cve_id") or ""
|
|
52
|
+
ghsa_id: str = item.get("ghsa_id", "")
|
|
53
|
+
if not cve_id and not ghsa_id:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
cvss_score: float | None = None
|
|
57
|
+
cvss_vector = ""
|
|
58
|
+
cvss_data: dict[str, Any] | None = item.get("cvss")
|
|
59
|
+
if cvss_data:
|
|
60
|
+
cvss_score = cvss_data.get("score")
|
|
61
|
+
cvss_vector = cvss_data.get("vector_string", "")
|
|
62
|
+
|
|
63
|
+
cwe_list: list[dict[str, str]] = item.get("cwes", [])
|
|
64
|
+
cwe = cwe_list[0].get("cwe_id", "") if cwe_list else ""
|
|
65
|
+
|
|
66
|
+
refs: list[str] = [
|
|
67
|
+
r.get("url", "") for r in item.get("references", []) if r.get("url")
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
vendor = ""
|
|
71
|
+
product = ""
|
|
72
|
+
affected: list[str] = []
|
|
73
|
+
fixed: list[str] = []
|
|
74
|
+
vuln_list: list[dict[str, Any]] = item.get("vulnerabilities", [])
|
|
75
|
+
for vuln in vuln_list:
|
|
76
|
+
pkg: dict[str, Any] = vuln.get("package", {})
|
|
77
|
+
if not vendor and pkg.get("ecosystem"):
|
|
78
|
+
vendor = pkg["ecosystem"]
|
|
79
|
+
if not product and pkg.get("name"):
|
|
80
|
+
product = pkg["name"]
|
|
81
|
+
vr: str = vuln.get("vulnerable_version_range", "")
|
|
82
|
+
if vr:
|
|
83
|
+
affected.append(f"{pkg.get('name', '')}:{vr}")
|
|
84
|
+
fv: str | None = vuln.get("first_patched_version")
|
|
85
|
+
if fv:
|
|
86
|
+
fixed.append(f"{pkg.get('name', '')}:{fv}")
|
|
87
|
+
|
|
88
|
+
adv = Advisory(
|
|
89
|
+
cve_id=cve_id or ghsa_id,
|
|
90
|
+
title=item.get("summary", "") or cve_id or ghsa_id,
|
|
91
|
+
description=(item.get("description", "") or "")[:500],
|
|
92
|
+
cvss_score=cvss_score,
|
|
93
|
+
cvss_vector=cvss_vector,
|
|
94
|
+
cwe=cwe,
|
|
95
|
+
vendor=vendor,
|
|
96
|
+
product=product,
|
|
97
|
+
affected_versions=affected[:10],
|
|
98
|
+
fixed_versions=fixed[:10],
|
|
99
|
+
references=refs[:10],
|
|
100
|
+
source="github_advisory",
|
|
101
|
+
source_url=item.get("html_url", GHSA_API),
|
|
102
|
+
status="unverified",
|
|
103
|
+
ingested_at=ingested_at,
|
|
104
|
+
tags=["ghsa", item.get("severity", "unknown")],
|
|
105
|
+
)
|
|
106
|
+
advisories.append(adv)
|
|
107
|
+
|
|
108
|
+
if len(items) < 100:
|
|
109
|
+
break
|
|
110
|
+
page += 1
|
|
111
|
+
time.sleep(1.0 if not github_token else 0.3)
|
|
112
|
+
|
|
113
|
+
return advisories
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Nuclei templates CVE extraction (local clone)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from schemas.advisory_v1 import Advisory
|
|
10
|
+
|
|
11
|
+
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
|
|
12
|
+
SEVERITY_MAP = {"critical": 9.8, "high": 8.0, "medium": 5.5, "low": 3.0, "info": 0.0}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch(templates_dir: str = "nuclei-templates") -> list[Advisory]:
|
|
16
|
+
base = Path(templates_dir)
|
|
17
|
+
if not base.exists():
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
advisories = []
|
|
21
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
22
|
+
|
|
23
|
+
for yaml_file in base.rglob("*.yaml"):
|
|
24
|
+
content = yaml_file.read_text(errors="ignore")
|
|
25
|
+
|
|
26
|
+
cves = CVE_RE.findall(content)
|
|
27
|
+
if not cves:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
name = ""
|
|
31
|
+
severity = ""
|
|
32
|
+
desc = ""
|
|
33
|
+
refs = []
|
|
34
|
+
|
|
35
|
+
for line in content.split("\n"):
|
|
36
|
+
stripped = line.strip()
|
|
37
|
+
if stripped.startswith("name:"):
|
|
38
|
+
name = stripped.split(":", 1)[1].strip().strip("\"'")
|
|
39
|
+
elif stripped.startswith("severity:"):
|
|
40
|
+
severity = stripped.split(":", 1)[1].strip().lower()
|
|
41
|
+
elif stripped.startswith("description:"):
|
|
42
|
+
desc = stripped.split(":", 1)[1].strip().strip("\"'")
|
|
43
|
+
elif stripped.startswith("- http"):
|
|
44
|
+
refs.append(stripped.lstrip("- ").strip())
|
|
45
|
+
|
|
46
|
+
for cve in set(cves):
|
|
47
|
+
adv = Advisory(
|
|
48
|
+
cve_id=cve,
|
|
49
|
+
title=name or cve,
|
|
50
|
+
description=desc[:500],
|
|
51
|
+
cvss_score=SEVERITY_MAP.get(severity),
|
|
52
|
+
references=refs[:5],
|
|
53
|
+
poc_urls=[f"nuclei:{yaml_file.relative_to(base)}"],
|
|
54
|
+
source="nuclei",
|
|
55
|
+
source_url=str(yaml_file),
|
|
56
|
+
status="unverified",
|
|
57
|
+
ingested_at=now,
|
|
58
|
+
tags=["nuclei", severity] if severity else ["nuclei"],
|
|
59
|
+
)
|
|
60
|
+
advisories.append(adv)
|
|
61
|
+
|
|
62
|
+
return advisories
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""NVD API v2 delta ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from schemas.advisory_v1 import Advisory
|
|
12
|
+
|
|
13
|
+
NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch(days_back: int = 7, api_key: str | None = None, timeout: int = 30) -> list[Advisory]:
|
|
17
|
+
now = datetime.now(timezone.utc)
|
|
18
|
+
start = now - timedelta(days=days_back)
|
|
19
|
+
|
|
20
|
+
pub_start = start.strftime("%Y-%m-%dT%H:%M:%S.000")
|
|
21
|
+
pub_end = now.strftime("%Y-%m-%dT%H:%M:%S.000")
|
|
22
|
+
start_index = 0
|
|
23
|
+
headers: dict[str, str] = {}
|
|
24
|
+
if api_key:
|
|
25
|
+
headers["apiKey"] = api_key
|
|
26
|
+
|
|
27
|
+
advisories: list[Advisory] = []
|
|
28
|
+
total: int | None = None
|
|
29
|
+
|
|
30
|
+
while True:
|
|
31
|
+
params: dict[str, str] = {
|
|
32
|
+
"pubStartDate": pub_start,
|
|
33
|
+
"pubEndDate": pub_end,
|
|
34
|
+
"resultsPerPage": "200",
|
|
35
|
+
"startIndex": str(start_index),
|
|
36
|
+
}
|
|
37
|
+
resp = requests.get(NVD_API, params=params, headers=headers, timeout=timeout)
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
data: dict[str, Any] = resp.json()
|
|
40
|
+
|
|
41
|
+
if total is None:
|
|
42
|
+
total = int(data.get("totalResults", 0))
|
|
43
|
+
|
|
44
|
+
vulns: list[dict[str, Any]] = data.get("vulnerabilities", [])
|
|
45
|
+
for vuln in vulns:
|
|
46
|
+
cve_data: dict[str, Any] = vuln.get("cve", {})
|
|
47
|
+
cve_id: str = cve_data.get("id", "")
|
|
48
|
+
|
|
49
|
+
descriptions: list[dict[str, str]] = cve_data.get("descriptions", [])
|
|
50
|
+
desc = next((d["value"] for d in descriptions if d.get("lang") == "en"), "")
|
|
51
|
+
|
|
52
|
+
metrics: dict[str, Any] = cve_data.get("metrics", {})
|
|
53
|
+
cvss_score: float | None = None
|
|
54
|
+
cvss_vector = ""
|
|
55
|
+
for key in ["cvssMetricV31", "cvssMetricV30", "cvssMetricV2"]:
|
|
56
|
+
if metrics.get(key):
|
|
57
|
+
cvss_data: dict[str, Any] = metrics[key][0].get("cvssData", {})
|
|
58
|
+
cvss_score = cvss_data.get("baseScore")
|
|
59
|
+
cvss_vector = cvss_data.get("vectorString", "")
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
refs = [
|
|
63
|
+
r.get("url", "")
|
|
64
|
+
for r in cve_data.get("references", [])
|
|
65
|
+
if r.get("url")
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
weaknesses: list[dict[str, Any]] = cve_data.get("weaknesses", [])
|
|
69
|
+
cwe = ""
|
|
70
|
+
for w in weaknesses:
|
|
71
|
+
for desc_item in w.get("description", []):
|
|
72
|
+
if desc_item.get("value", "").startswith("CWE-"):
|
|
73
|
+
cwe = desc_item["value"]
|
|
74
|
+
break
|
|
75
|
+
if cwe:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
adv = Advisory(
|
|
79
|
+
cve_id=cve_id,
|
|
80
|
+
title=cve_id,
|
|
81
|
+
description=desc[:500],
|
|
82
|
+
cvss_score=cvss_score,
|
|
83
|
+
cvss_vector=cvss_vector,
|
|
84
|
+
cwe=cwe,
|
|
85
|
+
references=refs[:10],
|
|
86
|
+
source="nvd",
|
|
87
|
+
source_url=f"https://nvd.nist.gov/vuln/detail/{cve_id}",
|
|
88
|
+
status="unverified",
|
|
89
|
+
ingested_at=datetime.now(timezone.utc).isoformat(),
|
|
90
|
+
)
|
|
91
|
+
advisories.append(adv)
|
|
92
|
+
|
|
93
|
+
start_index += len(vulns)
|
|
94
|
+
if start_index >= total:
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
time.sleep(0.6 if api_key else 6)
|
|
98
|
+
|
|
99
|
+
return advisories
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""PacketStorm Security RSS feed ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
import defusedxml.ElementTree as ET
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from schemas.advisory_v1 import Advisory
|
|
11
|
+
|
|
12
|
+
PACKETSTORM_RSS_URL = "https://rss.packetstormsecurity.com/files/tags/exploit/"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch(timeout: int = 30) -> list[Advisory]:
|
|
16
|
+
resp = requests.get(PACKETSTORM_RSS_URL, timeout=timeout)
|
|
17
|
+
resp.raise_for_status()
|
|
18
|
+
|
|
19
|
+
root = ET.fromstring(resp.text)
|
|
20
|
+
advisories: list[Advisory] = []
|
|
21
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
22
|
+
|
|
23
|
+
for item in root.iter("item"):
|
|
24
|
+
title = item.findtext("title", "")
|
|
25
|
+
link = item.findtext("link", "")
|
|
26
|
+
description = item.findtext("description", "")
|
|
27
|
+
pub_date = item.findtext("pubDate", "")
|
|
28
|
+
|
|
29
|
+
adv = Advisory(
|
|
30
|
+
cve_id=_extract_cve(title + " " + description),
|
|
31
|
+
title=title,
|
|
32
|
+
description=description,
|
|
33
|
+
source="packetstorm",
|
|
34
|
+
source_url=link,
|
|
35
|
+
ingested_at=now,
|
|
36
|
+
extra={"pubDate": pub_date} if pub_date else {},
|
|
37
|
+
)
|
|
38
|
+
advisories.append(adv)
|
|
39
|
+
|
|
40
|
+
return advisories
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_cve(text: str) -> str:
|
|
44
|
+
import re
|
|
45
|
+
|
|
46
|
+
match = re.search(r"CVE-\d{4}-\d{4,}", text)
|
|
47
|
+
return match.group(0) if match else ""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""VulnCheck KEV enriched catalog ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from schemas.advisory_v1 import Advisory
|
|
12
|
+
|
|
13
|
+
VULNCHECK_API = "https://api.vulncheck.com/v3/index/vulncheck-kev"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch(api_token: str, timeout: int = 30) -> list[Advisory]:
|
|
17
|
+
headers = {
|
|
18
|
+
"Accept": "application/json",
|
|
19
|
+
"Authorization": f"Bearer {api_token}",
|
|
20
|
+
}
|
|
21
|
+
advisories: list[Advisory] = []
|
|
22
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
resp = requests.get(VULNCHECK_API, headers=headers, timeout=timeout)
|
|
25
|
+
resp.raise_for_status()
|
|
26
|
+
data: dict[str, Any] = resp.json()
|
|
27
|
+
|
|
28
|
+
entries: list[dict[str, Any]] = data.get("data", [])
|
|
29
|
+
|
|
30
|
+
for entry in entries:
|
|
31
|
+
cve_id: str = entry.get("cve_id", "") or entry.get("cveID", "")
|
|
32
|
+
if not cve_id:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
cvss_score: float | None = None
|
|
36
|
+
raw_cvss = entry.get("cvss_score") or entry.get("baseScore")
|
|
37
|
+
if raw_cvss is not None:
|
|
38
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
39
|
+
cvss_score = float(raw_cvss)
|
|
40
|
+
|
|
41
|
+
refs: list[str] = []
|
|
42
|
+
for r in entry.get("references", []):
|
|
43
|
+
url = r if isinstance(r, str) else r.get("url", "")
|
|
44
|
+
if url:
|
|
45
|
+
refs.append(url)
|
|
46
|
+
|
|
47
|
+
adv = Advisory(
|
|
48
|
+
cve_id=cve_id,
|
|
49
|
+
title=entry.get("vulnerabilityName", "") or entry.get("name", "") or cve_id,
|
|
50
|
+
description=entry.get("shortDescription", "") or entry.get("description", ""),
|
|
51
|
+
cvss_score=cvss_score,
|
|
52
|
+
cvss_vector=entry.get("cvss_vector", "") or entry.get("vectorString", ""),
|
|
53
|
+
vendor=entry.get("vendorProject", "") or entry.get("vendor", ""),
|
|
54
|
+
product=entry.get("product", ""),
|
|
55
|
+
kev=True,
|
|
56
|
+
kev_date_added=entry.get("dateAdded", ""),
|
|
57
|
+
kev_due_date=entry.get("dueDate", ""),
|
|
58
|
+
references=refs[:10],
|
|
59
|
+
source="vulncheck_kev",
|
|
60
|
+
source_url=VULNCHECK_API,
|
|
61
|
+
status="unverified",
|
|
62
|
+
ingested_at=now,
|
|
63
|
+
tags=["kev", "vulncheck", "actively-exploited"],
|
|
64
|
+
)
|
|
65
|
+
advisories.append(adv)
|
|
66
|
+
|
|
67
|
+
return advisories
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""watchTowr Labs RSS/blog advisory ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from defusedxml import ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from schemas.advisory_v1 import Advisory
|
|
12
|
+
|
|
13
|
+
FEED_URL = "https://labs.watchtowr.com/rss/"
|
|
14
|
+
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch(timeout: int = 30) -> list[Advisory]:
|
|
18
|
+
resp = requests.get(FEED_URL, timeout=timeout)
|
|
19
|
+
resp.raise_for_status()
|
|
20
|
+
|
|
21
|
+
root = ET.fromstring(resp.text)
|
|
22
|
+
advisories = []
|
|
23
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
24
|
+
|
|
25
|
+
for item in root.iter("item"):
|
|
26
|
+
title = item.findtext("title", "")
|
|
27
|
+
link = item.findtext("link", "")
|
|
28
|
+
desc = item.findtext("description", "")
|
|
29
|
+
cves = CVE_RE.findall(f"{title} {desc}")
|
|
30
|
+
|
|
31
|
+
if not cves:
|
|
32
|
+
cves = ["WATCHTOWR-NOID"]
|
|
33
|
+
|
|
34
|
+
for cve in set(cves):
|
|
35
|
+
adv = Advisory(
|
|
36
|
+
cve_id=cve,
|
|
37
|
+
title=title[:200],
|
|
38
|
+
description=desc[:500],
|
|
39
|
+
references=[link] if link else [],
|
|
40
|
+
source="watchtowr",
|
|
41
|
+
source_url=link,
|
|
42
|
+
status="unverified",
|
|
43
|
+
ingested_at=now,
|
|
44
|
+
tags=["watchtowr"],
|
|
45
|
+
)
|
|
46
|
+
advisories.append(adv)
|
|
47
|
+
|
|
48
|
+
return advisories
|
schemas/__init__.py
ADDED
|
File without changes
|
schemas/advisory_v1.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""xpl.advisory.v1 schema definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Advisory:
|
|
11
|
+
cve_id: str
|
|
12
|
+
title: str
|
|
13
|
+
description: str = ""
|
|
14
|
+
cvss_score: float | None = None
|
|
15
|
+
cvss_vector: str = ""
|
|
16
|
+
epss_score: float | None = None
|
|
17
|
+
epss_percentile: float | None = None
|
|
18
|
+
kev: bool = False
|
|
19
|
+
kev_date_added: str | None = None
|
|
20
|
+
kev_due_date: str | None = None
|
|
21
|
+
vendor: str = ""
|
|
22
|
+
product: str = ""
|
|
23
|
+
affected_versions: list[str] = field(default_factory=list)
|
|
24
|
+
fixed_versions: list[str] = field(default_factory=list)
|
|
25
|
+
patch_url: str = ""
|
|
26
|
+
poc_urls: list[str] = field(default_factory=list)
|
|
27
|
+
references: list[str] = field(default_factory=list)
|
|
28
|
+
cwe: str = ""
|
|
29
|
+
source: str = ""
|
|
30
|
+
source_url: str = ""
|
|
31
|
+
status: str = "unverified"
|
|
32
|
+
ingested_at: str = ""
|
|
33
|
+
mitre_techniques: list[str] = field(default_factory=list)
|
|
34
|
+
tags: list[str] = field(default_factory=list)
|
|
35
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"schema": "xpl.advisory.v1",
|
|
40
|
+
"cve_id": self.cve_id,
|
|
41
|
+
"title": self.title,
|
|
42
|
+
"description": self.description,
|
|
43
|
+
"cvss_score": self.cvss_score,
|
|
44
|
+
"cvss_vector": self.cvss_vector,
|
|
45
|
+
"epss_score": self.epss_score,
|
|
46
|
+
"epss_percentile": self.epss_percentile,
|
|
47
|
+
"kev": self.kev,
|
|
48
|
+
"kev_date_added": self.kev_date_added,
|
|
49
|
+
"kev_due_date": self.kev_due_date,
|
|
50
|
+
"vendor": self.vendor,
|
|
51
|
+
"product": self.product,
|
|
52
|
+
"affected_versions": self.affected_versions,
|
|
53
|
+
"fixed_versions": self.fixed_versions,
|
|
54
|
+
"patch_url": self.patch_url,
|
|
55
|
+
"poc_urls": self.poc_urls,
|
|
56
|
+
"references": self.references,
|
|
57
|
+
"cwe": self.cwe,
|
|
58
|
+
"source": self.source,
|
|
59
|
+
"source_url": self.source_url,
|
|
60
|
+
"status": self.status,
|
|
61
|
+
"ingested_at": self.ingested_at,
|
|
62
|
+
"mitre_techniques": self.mitre_techniques,
|
|
63
|
+
"tags": self.tags,
|
|
64
|
+
**self.extra,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, data: dict[str, Any]) -> Advisory:
|
|
69
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
70
|
+
extra = {k: v for k, v in data.items() if k not in known and k != "schema"}
|
|
71
|
+
kwargs = {k: v for k, v in data.items() if k in known}
|
|
72
|
+
kwargs["extra"] = extra
|
|
73
|
+
return cls(**kwargs)
|
schemas/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xpl-intel
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: XPL-Intel — CVE ingestion pipeline for XPL-Suite. CISA KEV, NVD, watchTowr, Nuclei, ExploitDB.
|
|
5
|
+
Author-email: André Henrique <henrique.santos@uniaogeek.com.br>
|
|
6
|
+
License: XSRL-1.0
|
|
7
|
+
Keywords: cve,vulnerability,intel,kev,nvd,nuclei,xpl-suite
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: requests<3.0,>=2.31.0
|
|
15
|
+
Requires-Dist: rich>=13.0
|
|
16
|
+
Requires-Dist: defusedxml>=0.7.1
|
|
17
|
+
Requires-Dist: argcomplete>=3.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
23
|
+
Requires-Dist: types-requests>=2.31; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
ingestion/__init__.py,sha256=YsbpQonm9QDnmXZjz_s_OjgqPUD8q4nbF42oCVqbzjo,44
|
|
2
|
+
ingestion/cli.py,sha256=3-F5db2tyrsPNzF21sHxOa9DtHxoo1W5ZcdbhsZbplk,6442
|
|
3
|
+
ingestion/dedup.py,sha256=jMHRRVrdmJ19Td_wuOwDLpH9FoYWJY4dHPjBET8aDuI,1914
|
|
4
|
+
ingestion/output/__init__.py,sha256=RL91dQSVpTk9XqelsmtduBu7v6K48HvGFVe6z6prYIQ,297
|
|
5
|
+
ingestion/output/csv_writer.py,sha256=EZ2YCoX9H6Wa06fjs5pjk94L6Zj1n6a9Brt_9_8_ftw,1256
|
|
6
|
+
ingestion/output/json_writer.py,sha256=YY2Lvj8SfgPBepcqX5bi-clpZRPHIFS8LHQw8qFLHag,468
|
|
7
|
+
ingestion/output/sqlite_writer.py,sha256=zWa_mVl2tBvDUEDFoBmzc4DReG9bZck3MpfTp3knA5U,3606
|
|
8
|
+
ingestion/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
ingestion/sources/cisa_kev.py,sha256=NWJR_jbiJZKzfEaIopsJR-KPwpBc0Ke_ZyjjnUlMcfA,1315
|
|
10
|
+
ingestion/sources/epss_api.py,sha256=Hg1xfDVTXSn1tZgu10bHoNM7lvfvHsyDFgp-V-JwIOI,1282
|
|
11
|
+
ingestion/sources/exploitdb.py,sha256=wRflqJrFmDgjPsyI5aZ37PxKTgJyJypBPICZ4GUPMus,1560
|
|
12
|
+
ingestion/sources/github_advisory.py,sha256=wMDr5KFSB8GVGPyqrX-1av3KWw5hGvY07Dwo75rdBIg,3752
|
|
13
|
+
ingestion/sources/nuclei_templates.py,sha256=cU6019zOdeo5clMHF9Nnnpat4jNfGtXQvutg9KxNDUk,1966
|
|
14
|
+
ingestion/sources/nvd_api.py,sha256=2nc11cnHUwpx2YPeSdhJDJxJD2FszfzYVcS6IYEuAIU,3293
|
|
15
|
+
ingestion/sources/packetstorm_rss.py,sha256=VPXbegeEUxRTwWECv8lxXSabk29nqOHWI3vQ_iOBrsM,1294
|
|
16
|
+
ingestion/sources/vulncheck_kev.py,sha256=K_C63VkwgP8Wc5ksow_prHCP_oOl1bfSQzSf3XtRcKA,2237
|
|
17
|
+
ingestion/sources/watchtowr_rss.py,sha256=J0fKZN7X2bOQEpstSfpOlb36h-vL0Lc0S70J5SkXtig,1297
|
|
18
|
+
schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
schemas/advisory_v1.py,sha256=KZvealmzrMxYJYUqxQj08I_zRR-hmjmSnWmCf17th1s,2540
|
|
20
|
+
schemas/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
xpl_intel-1.2.0.dist-info/licenses/LICENSE,sha256=hQZm7l5K5ztlml-M_W-xQ8Z6Y8n9fNQvqRrH6pc6big,5710
|
|
22
|
+
xpl_intel-1.2.0.dist-info/METADATA,sha256=uoMI_VTJQ89_lSR4nH6IkDNo8EDkim38m9E9HPxnCZw,898
|
|
23
|
+
xpl_intel-1.2.0.dist-info/WHEEL,sha256=16fc_9Rp854SLcdhaDEjr0u65p0e3FhZxihNgcnCE98,91
|
|
24
|
+
xpl_intel-1.2.0.dist-info/entry_points.txt,sha256=Q6NAOCbbd4xr5OiqIwvEV0N15qAX1yOawlk1DsbDGcE,74
|
|
25
|
+
xpl_intel-1.2.0.dist-info/top_level.txt,sha256=m9wK5ToZT6pOo2B-PFgJazrH-7oDoiNezLjqW9cKScI,18
|
|
26
|
+
xpl_intel-1.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# XPL-Suite Security Research License (XSRL) v1.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Andre Henrike de Oliveira (@mrhenrike) / Uniao Geek
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
## 1. Definitions
|
|
7
|
+
|
|
8
|
+
- "Software" means the source code, documentation, binaries, scripts, configuration
|
|
9
|
+
files, and any other materials included in this repository.
|
|
10
|
+
- "Author" means the original copyright holder: Andre Henrike de Oliveira (@mrhenrike).
|
|
11
|
+
- "Original Repository" means the source repository hosted at
|
|
12
|
+
https://github.com/mrhenrike/xpl-intel from which the Software originates.
|
|
13
|
+
- "Upstream Credits" means the authors and licenses of third-party components,
|
|
14
|
+
libraries, or code referenced in the Software, as documented in source file headers,
|
|
15
|
+
CREDITS files, or README attribution sections.
|
|
16
|
+
- "Authorized Security Testing" means penetration testing, vulnerability assessment,
|
|
17
|
+
red team exercises, purple team exercises, security auditing, or similar activities
|
|
18
|
+
conducted with explicit written permission from the owner of the target system(s).
|
|
19
|
+
|
|
20
|
+
## 2. Grant of Rights
|
|
21
|
+
|
|
22
|
+
Subject to the terms and conditions of this License, the Author grants you a
|
|
23
|
+
non-exclusive, worldwide, royalty-free license to:
|
|
24
|
+
|
|
25
|
+
a) **Use** the Software for educational purposes, academic research, security studies,
|
|
26
|
+
and Authorized Security Testing;
|
|
27
|
+
b) **Copy and reproduce** the Software;
|
|
28
|
+
c) **Modify** the Software and create derivative works;
|
|
29
|
+
d) **Fork** the Software and maintain independent copies;
|
|
30
|
+
e) **Distribute** the Software and derivative works, provided all conditions in
|
|
31
|
+
Section 3 are met.
|
|
32
|
+
|
|
33
|
+
## 3. Conditions
|
|
34
|
+
|
|
35
|
+
Any use, copy, modification, fork, or distribution of the Software must comply with
|
|
36
|
+
ALL of the following conditions:
|
|
37
|
+
|
|
38
|
+
### 3.1 Attribution
|
|
39
|
+
|
|
40
|
+
a) You must retain the Author's original copyright notice and attribution in all
|
|
41
|
+
copies and derivative works.
|
|
42
|
+
b) You must retain all Upstream Credits, including but not limited to: original author
|
|
43
|
+
names, license notices, and attribution statements of third-party code used as a
|
|
44
|
+
base or reference for the Software.
|
|
45
|
+
c) Modified files must carry a prominent notice stating that you changed the files and
|
|
46
|
+
the date of any change.
|
|
47
|
+
d) Derivative works must include a clear statement identifying the original Software
|
|
48
|
+
and its Author.
|
|
49
|
+
e) Any copy, fork, or derivative work must include a visible reference to the Original
|
|
50
|
+
Repository URL (https://github.com/mrhenrike/xpl-intel) in the project's
|
|
51
|
+
README or equivalent documentation, so that users can trace the Software back to
|
|
52
|
+
its source.
|
|
53
|
+
f) The Author's name and credits must not be removed, obscured, or replaced in any
|
|
54
|
+
copy, fork, or derivative work. The Author must remain credited as the original
|
|
55
|
+
creator in all circumstances.
|
|
56
|
+
|
|
57
|
+
### 3.2 Same License
|
|
58
|
+
|
|
59
|
+
a) Any distribution of the Software or derivative works must be under this same
|
|
60
|
+
License (XSRL v1.0).
|
|
61
|
+
b) You may not apply any additional restrictions or alternative license terms to the
|
|
62
|
+
Software or derivative works.
|
|
63
|
+
c) You may not sublicense the Software under different terms.
|
|
64
|
+
|
|
65
|
+
### 3.3 Non-Commercial Use Only
|
|
66
|
+
|
|
67
|
+
a) The Software may not be used, in whole or in part, for commercial purposes.
|
|
68
|
+
b) "Commercial purposes" includes but is not limited to: selling the Software or
|
|
69
|
+
derivative works, incorporating the Software into commercial products or services,
|
|
70
|
+
using the Software to generate revenue directly or indirectly, and offering the
|
|
71
|
+
Software as part of a paid service.
|
|
72
|
+
c) Use in non-profit organizations, academic institutions, and personal projects is
|
|
73
|
+
permitted, provided no commercial gain is derived.
|
|
74
|
+
|
|
75
|
+
### 3.4 Ethical Use Only
|
|
76
|
+
|
|
77
|
+
a) The Software may only be used for:
|
|
78
|
+
- Educational purposes and academic research;
|
|
79
|
+
- Security studies, training, and skill development;
|
|
80
|
+
- Authorized Security Testing (as defined in Section 1);
|
|
81
|
+
- Capture The Flag (CTF) competitions and security labs;
|
|
82
|
+
- Defensive security, incident response, and threat analysis.
|
|
83
|
+
|
|
84
|
+
b) The Software may NOT be used for:
|
|
85
|
+
- Unauthorized access to systems, networks, or data;
|
|
86
|
+
- Any activity that violates applicable laws or regulations;
|
|
87
|
+
- Malicious purposes, including but not limited to: deploying malware against
|
|
88
|
+
unauthorized targets, conducting denial-of-service attacks, unauthorized
|
|
89
|
+
surveillance, data theft, or any form of cybercrime;
|
|
90
|
+
- Targeting individuals, organizations, or infrastructure without explicit
|
|
91
|
+
authorization;
|
|
92
|
+
- Any purpose that causes harm to individuals or organizations.
|
|
93
|
+
|
|
94
|
+
## 4. No Warranty
|
|
95
|
+
|
|
96
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
97
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
98
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
99
|
+
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
100
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
101
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
102
|
+
|
|
103
|
+
## 5. Limitation of Liability
|
|
104
|
+
|
|
105
|
+
The Author shall not be held responsible for any misuse of the Software. Users are
|
|
106
|
+
solely responsible for ensuring their use of the Software complies with all applicable
|
|
107
|
+
laws, regulations, and ethical standards.
|
|
108
|
+
|
|
109
|
+
## 6. Termination
|
|
110
|
+
|
|
111
|
+
This License and the rights granted hereunder will terminate automatically if you fail
|
|
112
|
+
to comply with any of the terms and conditions of this License. Upon termination, you
|
|
113
|
+
must cease all use and destroy all copies of the Software in your possession.
|
|
114
|
+
|
|
115
|
+
## 7. Severability
|
|
116
|
+
|
|
117
|
+
If any provision of this License is held to be unenforceable, such provision shall be
|
|
118
|
+
reformed only to the extent necessary to make it enforceable, and the remaining
|
|
119
|
+
provisions shall continue in full force and effect.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
SPDX-License-Identifier: LicenseRef-XSRL-1.0
|