#!/usr/bin/env python3 """Mirror official Red Hat CSAF advisories, retaining the original JSON bytes.""" import argparse import csv import hashlib import io import json import os import re import sys import time from datetime import datetime, timezone from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen BASE = 'https://security.access.redhat.com/data/csaf/v2/advisories/' PATH_RE = re.compile(r'(?= year: out.add(v.lower()) if not out: raise ValueError('Index empty or incompatible format: refusing to synchronize') return sorted(out) def extract_changed(raw, known): """Parse path or advisory ID from change/deletion feed without assuming CSV columns.""" data = raw.decode('utf-8-sig', errors='replace') exact = set() name_map = {} for p in known: name_map.setdefault(p.rsplit('/', 1)[-1], set()).add(p) for row in csv.reader(io.StringIO(data)): for value in row: for match in PATH_RE.finditer(value): p = match.group(0).lower() if p in known: exact.add(p) for match in NAME_RE.finditer(value): kind = match.group(0).split('-')[0].split(':')[0].lower() filename = f'{kind}-{match.group(1)}_{int(match.group(2)):03d}.json' exact.update(name_map.get(filename, ())) return exact def get(url, attempts=5, timeout=90): for i in range(attempts): req = Request(url, headers={'User-Agent': 'vuln-redhat-mirror/1.0', 'Accept': 'application/json, text/plain, text/csv, */*'}) try: with urlopen(req, timeout=timeout) as response: return response.read() except (HTTPError, URLError, TimeoutError) as exc: if isinstance(exc, HTTPError) and exc.code not in (408, 429, 500, 502, 503, 504): raise if i == attempts - 1: raise delay = min(45, 2 ** i * 3) print(f'Network error {url}: {exc}; retry in {delay}s', file=sys.stderr) time.sleep(delay) raise RuntimeError('Unreachable') def write_if_changed(dst, content): if dst.exists() and dst.read_bytes() == content: return False dst.parent.mkdir(parents=True, exist_ok=True) tmp = dst.with_suffix('.tmp') tmp.write_bytes(content) os.replace(tmp, dst) return True def sync(root, year=2017, rotate=250, prune=False, limit=0): root = Path(root) data_dir = root / 'data' / 'csaf' state_path = root / 'state' / 'sync.json' old = json.loads(state_path.read_text()) if state_path.exists() else {} index_raw = get(BASE + 'index.txt') paths = valid_paths(index_raw, year) known = set(paths) changed = set() feed_status = {} for feed in ('changes.csv', 'deletions.csv'): try: raw = get(BASE + feed) feed_status[feed] = hashlib.sha256(raw).hexdigest() if feed == 'changes.csv': changed |= extract_changed(raw, known) else: # Deletions are handled through the authoritative index below. # Preserve hash to observe feed changes in state. pass except Exception as exc: print(f'Warning: {feed} not available: {exc}', file=sys.stderr) feed_status[feed] = None missing = {p for p in paths if not (data_dir / p).exists()} cursor = int(old.get('rotation_cursor', 0)) % len(paths) rotating = {paths[(cursor + n) % len(paths)] for n in range(min(rotate, len(paths)))} todo = sorted(missing | changed | rotating) if limit: todo = todo[:limit] count_new = count_changed = count_same = 0 for index, rel in enumerate(todo, 1): raw = get(BASE + quote(rel, safe='/')) record = json.loads(raw) if not isinstance(record, dict) or not isinstance(record.get('document'), dict): raise ValueError(f'Invalid CSAF structure: {rel}') out = data_dir / rel was_present = out.exists() if write_if_changed(out, raw): if was_present: count_changed += 1 else: count_new += 1 else: count_same += 1 if index % 250 == 0: print(f'Processed {index}/{len(todo)}') removed = 0 if prune and not limit: existing = [p for p in data_dir.glob('*/*.json') if int(p.parent.name) >= year] doomed = [p for p in existing if f'{p.parent.name}/{p.name}'.lower() not in known] if len(doomed) > max(100, int(len(existing) * .05)): raise RuntimeError(f'Refusing unexpected mass deletion: {len(doomed)} paths') for p in doomed: p.unlink() removed = len(doomed) state = { 'schema': 1, 'source': BASE, 'since_year': year, 'last_success_utc': datetime.now(timezone.utc).isoformat(), 'index_sha256': hashlib.sha256(index_raw).hexdigest(), 'index_count': len(paths), 'rotation_cursor': (cursor + (0 if limit else rotate)) % len(paths), 'feed_sha256': feed_status, 'last_run': {'downloaded': len(todo), 'new': count_new, 'updated': count_changed, 'unchanged': count_same, 'removed': removed, 'limited': bool(limit)} } # A limited developer run must not advance the durable synchronization status. if not limit: state_path.parent.mkdir(parents=True, exist_ok=True) write_if_changed(state_path, (json.dumps(state, indent=2, ensure_ascii=False) + '\n').encode()) print(json.dumps(state['last_run'], sort_keys=True)) def main(): parser = argparse.ArgumentParser() parser.add_argument('--root', default='.') parser.add_argument('--since-year', type=int, default=2017) parser.add_argument('--rotate', type=int, default=250, help='Number of older files to recheck per run') parser.add_argument('--prune', action='store_true', help='Remove files missing from index after safety checks') parser.add_argument('--limit', type=int, default=0, help='Developer test: do not advance state') args = parser.parse_args() if args.rotate < 0 or args.limit < 0: parser.error('rotate and limit must not be negative') sync(args.root, args.since_year, args.rotate, args.prune, args.limit) if __name__ == '__main__': main()