Initial KEV and EPSS mirror
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Store raw CISA KEV JSON and FIRST EPSS daily compressed CSV in Git."""
|
||||
import csv
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCES = {
|
||||
'kev': 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json',
|
||||
'epss': 'https://epss.empiricalsecurity.com/epss_scores-current.csv.gz',
|
||||
}
|
||||
|
||||
def download(url):
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'vuln-exploits-mirror/1.0'})
|
||||
for retry in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
return resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in (408, 429, 500, 502, 503, 504) or retry == 4:
|
||||
raise
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
if retry == 4:
|
||||
raise
|
||||
time.sleep(3 * 2**retry)
|
||||
|
||||
def atomic_write(path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists() and path.read_bytes() == content:
|
||||
return False
|
||||
temp = path.with_name(path.name + '.tmp')
|
||||
try:
|
||||
temp.write_bytes(content)
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
def main():
|
||||
kev = download(SOURCES['kev'])
|
||||
data = json.loads(kev)
|
||||
records = data.get('vulnerabilities')
|
||||
if not isinstance(records, list) or len(records) < 1000 or data.get('count') != len(records):
|
||||
raise ValueError('Unexpected or truncated CISA KEV JSON')
|
||||
if not all(re.fullmatch(r'CVE-\d{4}-\d{4,}', r.get('cveID','')) for r in records):
|
||||
raise ValueError('CISA KEV contains invalid CVE IDs')
|
||||
epss = download(SOURCES['epss'])
|
||||
with gzip.GzipFile(fileobj=io.BytesIO(epss)) as zipped:
|
||||
csv_text = io.TextIOWrapper(zipped, encoding='utf-8-sig', newline='')
|
||||
reader = csv.reader(line for line in csv_text if not line.startswith('#'))
|
||||
header = next(reader)
|
||||
if header != ['cve', 'epss', 'percentile']:
|
||||
raise ValueError(f'Unexpected EPSS CSV header: {header}')
|
||||
rows = sum(1 for row in reader if len(row) == 3 and re.fullmatch(r'CVE-\d{4}-\d{4,}', row[0]))
|
||||
if rows < 10000:
|
||||
raise ValueError(f'EPSS snapshot too small: {rows}')
|
||||
# Only update files after both datasets validated.
|
||||
date = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
output = ROOT/'data'
|
||||
changed_kev = atomic_write(output/'cisa-kev'/'catalog.json', kev)
|
||||
changed_epss = atomic_write(output/'epss'/'current.csv.gz', epss)
|
||||
# Daily snapshots are optional: keeping all of them significantly grows Git.
|
||||
if os.getenv('EPSS_DAILY_HISTORY', '0') == '1':
|
||||
atomic_write(output/'epss'/'history'/f'{date}.csv.gz', epss)
|
||||
state = {'version': 1, 'last_success': datetime.now(timezone.utc).isoformat(), 'kev_count': len(records), 'epss_count': rows,
|
||||
'epss_history_enabled': os.getenv('EPSS_DAILY_HISTORY', '0') == '1'}
|
||||
atomic_write(ROOT/'state'/'sync.json', (json.dumps(state, indent=2, sort_keys=True)+'\n').encode())
|
||||
print(f'KEV={len(records)} changed={changed_kev}, EPSS={rows} changed={changed_epss}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user