140 lines
5.9 KiB
Python
140 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Mirror public BSI WID CSAF WHITE advisories using authoritative index.txt."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import random
|
|
import re
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
BASE = 'https://wid.cert-bund.de/.well-known/csaf/white/'
|
|
PATH_RE = re.compile(r'^([12][0-9]{3})/([a-zA-Z0-9_.-]+\.json)$')
|
|
LOG = logging.getLogger('bsi')
|
|
|
|
|
|
def fetch(url, timeout=90, retries=5):
|
|
for attempt in range(retries):
|
|
try:
|
|
req = Request(url, headers={'User-Agent': 'vuln-bsi-mirror/1.0 (+https://git.apex-project.de/VULN/vuln-bsi)', 'Accept': 'application/json,text/plain,*/*'})
|
|
with urlopen(req, timeout=timeout) as response:
|
|
return response.read()
|
|
except (HTTPError, URLError, TimeoutError, OSError) as exc:
|
|
if attempt + 1 == retries:
|
|
raise RuntimeError(f'Failed to download {url}: {exc}') from exc
|
|
delay = min(90, 2 ** attempt * 3 + random.random() * 2)
|
|
LOG.warning('Download failed (%s), retry in %.1fs: %s', exc, delay, url)
|
|
time.sleep(delay)
|
|
|
|
|
|
def parse_index(raw, min_year):
|
|
try:
|
|
lines = raw.decode('utf-8-sig').splitlines()
|
|
except UnicodeError as exc:
|
|
raise ValueError('index.txt is not valid UTF-8') from exc
|
|
all_entries = [x.strip() for x in lines if x.strip()]
|
|
if not all_entries:
|
|
raise ValueError('Empty index; refusing to remove documents')
|
|
if any(not PATH_RE.fullmatch(x) for x in all_entries):
|
|
bad = next(x for x in all_entries if not PATH_RE.fullmatch(x))
|
|
raise ValueError(f'Unexpected index entry: {bad[:140]!r}')
|
|
if len(all_entries) != len(set(all_entries)):
|
|
raise ValueError('Duplicate entries in index')
|
|
selected = sorted(x for x in all_entries if int(x.split('/', 1)[0]) >= min_year)
|
|
if not selected:
|
|
raise ValueError('No entries in requested years')
|
|
return selected
|
|
|
|
|
|
def validate_document(raw, path):
|
|
try:
|
|
obj = json.loads(raw)
|
|
except (ValueError, UnicodeError) as exc:
|
|
raise ValueError(f'Invalid JSON in {path}') from exc
|
|
if not isinstance(obj, dict) or not isinstance(obj.get('document'), dict):
|
|
raise ValueError(f'Not a CSAF document: {path}')
|
|
doc = obj['document']
|
|
tracking = doc.get('tracking')
|
|
if not isinstance(tracking, dict) or not tracking.get('id') or not tracking.get('current_release_date'):
|
|
raise ValueError(f'Missing CSAF tracking fields: {path}')
|
|
return obj
|
|
|
|
|
|
def atomic_bytes(path, raw):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temp = path.with_name(path.name + '.tmp')
|
|
try:
|
|
temp.write_bytes(raw)
|
|
os.replace(temp, path)
|
|
finally:
|
|
temp.unlink(missing_ok=True)
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument('--since-year', type=int, default=2017)
|
|
p.add_argument('--refresh-old', type=int, default=300, help='Rotate old documents to check changed advisories each run')
|
|
p.add_argument('--full', action='store_true', help='Re-fetch every indexed document')
|
|
p.add_argument('--delay', type=float, default=0.15)
|
|
p.add_argument('--root', default='.')
|
|
args = p.parse_args()
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
|
root = Path(args.root).resolve()
|
|
statefile = root / 'state' / 'sync.json'
|
|
state = json.loads(statefile.read_text()) if statefile.exists() else {}
|
|
index_raw = fetch(BASE + 'index.txt')
|
|
entries = parse_index(index_raw, args.since_year)
|
|
if state.get('count', 0) and len(entries) < max(1, int(state['count'] * .70)):
|
|
raise RuntimeError('Index unexpectedly shrunk by over 30%; refusing unsafe sync')
|
|
current = set(entries)
|
|
stored = set(state.get('entries', []))
|
|
pending = sorted(current - stored)
|
|
refresh_n = max(0, args.refresh_old)
|
|
cursor = int(state.get('cursor', 0))
|
|
old = sorted(current & stored)
|
|
revisit = []
|
|
if old and refresh_n:
|
|
revisit = [old[(cursor + n) % len(old)] for n in range(min(refresh_n, len(old)))]
|
|
missing = [item for item in entries if not (root / 'data' / 'csaf' / item).is_file()]
|
|
targets = entries if args.full else sorted(set(pending) | set(missing) | set(revisit))
|
|
LOG.info('Index: %s entries, new=%s missing=%s refreshing=%s total downloads=%s', len(entries), len(pending), len(missing), len(revisit), len(targets))
|
|
updates = 0
|
|
for i, item in enumerate(targets, 1):
|
|
dest = root / 'data' / 'csaf' / item
|
|
raw = fetch(BASE + item)
|
|
validate_document(raw, item)
|
|
if not dest.is_file() or hashlib.sha256(raw).digest() != hashlib.sha256(dest.read_bytes()).digest():
|
|
atomic_bytes(dest, raw)
|
|
updates += 1
|
|
if i % 100 == 0:
|
|
LOG.info('Processed %s/%s (new/updated %s)', i, len(targets), updates)
|
|
if args.delay and i < len(targets):
|
|
time.sleep(args.delay)
|
|
# Apply removals only after ALL downloads succeeded. No state changes on partial failures.
|
|
removed = stored - current
|
|
for item in removed:
|
|
(root / 'data' / 'csaf' / item).unlink(missing_ok=True)
|
|
atomic_bytes(root / 'data' / 'index.txt', index_raw)
|
|
new_state = {
|
|
'source': BASE, 'since_year': args.since_year,
|
|
'last_success_utc': datetime.now(timezone.utc).isoformat(),
|
|
'index_sha256': hashlib.sha256(index_raw).hexdigest(),
|
|
'count': len(entries), 'cursor': (cursor + len(revisit)) % max(len(old), 1),
|
|
'entries': entries,
|
|
}
|
|
atomic_bytes(statefile, (json.dumps(new_state, ensure_ascii=False, sort_keys=True, indent=2) + '\n').encode())
|
|
LOG.info('Done: %s downloaded, %s changed, %s removed', len(targets), updates, len(removed))
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except Exception as exc:
|
|
LOG.exception('Sync failed: %s', exc)
|
|
sys.exit(1)
|