Files
vuln-suse/scripts/sync.py
T
2026-11-10 22:17:16 +01:00

133 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Mirror SUSE/openSUSE CSAF advisory documents using the official index."""
import argparse
import concurrent.futures
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import time
from datetime import datetime, timezone
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
BASE = os.getenv('SUSE_CSAF_URL', 'https://ftp2.gwdg.de/pub/linux/suse/projects/security/csaf/').rstrip('/') + '/'
NAME = re.compile(r'^(?:opensuse|suse)-[a-z0-9_-]*?(20\d\d)[_-][a-z0-9_.-]+\.json$', re.I)
# Handles 'suse-su-2026_...' etc; do not interpret CVE year as advisory year.
USER_AGENT='apex-project-vuln-suse-mirror/1.0'
def fetch(url, tries=5):
for i in range(tries):
try:
with urlopen(Request(url, headers={'User-Agent': USER_AGENT, 'Accept':'application/json,text/plain,*/*'}), timeout=90) as r:
return r.read()
except (HTTPError, URLError, TimeoutError) as e:
if i == tries-1 or (isinstance(e, HTTPError) and e.code not in (429,500,502,503,504)):
raise
time.sleep(min(60, 2**(i+1)))
def eligible(name, year):
if '/' in name or '\\' in name or '..' in name or not name.endswith('.json'):
return False
m = NAME.match(name)
return bool(m and int(m.group(1)) >= year)
def load_index(data, year):
text=data.decode('utf-8-sig')
names=set()
for line in text.splitlines():
name=line.strip()
if eligible(name,year): names.add(name)
if not names:
raise ValueError('Index liefert keine passenden CSAF-Dateien; kein Abgleich durchgeführt')
return names
def valid_doc(blob):
doc=json.loads(blob)
if not isinstance(doc,dict) or not isinstance(doc.get('document'),dict):
raise ValueError('Ungültiges CSAF-Dokument (document fehlt)')
if not isinstance(doc.get('product_tree'),dict) and not isinstance(doc.get('vulnerabilities'),list):
raise ValueError('CSAF enthält weder product_tree noch vulnerabilities')
return doc
def atomic_write(path,blob):
path.parent.mkdir(parents=True,exist_ok=True)
tmp=path.with_name(path.name+'.tmp')
try:
tmp.write_bytes(blob)
tmp.replace(path)
finally:
tmp.unlink(missing_ok=True)
def main(argv=None):
ap=argparse.ArgumentParser()
ap.add_argument('--since-year',type=int,default=2017)
ap.add_argument('--workers',type=int,default=3)
ap.add_argument('--review',type=int,default=300,help='Anzahl älterer Advisories pro Lauf erneut herunterladen')
ap.add_argument('--full',action='store_true',help='Alle Dokumente erneut abgleichen')
ap.add_argument('--max-files',type=int,default=0,help='Nur für Tests, keine Löschungen')
args=ap.parse_args(argv)
root=Path(__file__).resolve().parent.parent
out=root/'data'/'csaf'
state_file=root/'state'/'sync.json'
state=json.loads(state_file.read_text()) if state_file.exists() else {}
index=fetch(BASE+'index.txt')
names=load_index(index,args.since_year)
previous=set(state.get('index',[]))
known={p.name for p in out.glob('*/*.json')}
missing=names-known
remaining=sorted(names-missing)
pos=int(state.get('review_cursor',0))
review=[]
if args.full:
targets=sorted(names)
else:
if remaining:
count=min(args.review,len(remaining))
review=[remaining[(pos+i)%len(remaining)] for i in range(count)]
targets=sorted(missing|set(review))
if args.max_files:
targets=targets[:args.max_files]
print(f'Index={len(names)} vorhanden={len(known)} neu={len(missing)} Abrufe={len(targets)}',flush=True)
errors=[]; changed=0
def worker(name):
m=NAME.match(name)
path=out/m.group(1)/name
blob=fetch(BASE+quote(name))
valid_doc(blob)
if path.exists() and hashlib.sha256(path.read_bytes()).digest()==hashlib.sha256(blob).digest():
return False
atomic_write(path,blob)
return True
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1,min(8,args.workers))) as pool:
futures={pool.submit(worker,n):n for n in targets}
for fut in concurrent.futures.as_completed(futures):
try: changed+=int(fut.result())
except Exception as e: errors.append(f'{futures[fut]}: {e}')
if errors:
for msg in errors[:30]:print('ERROR',msg,file=sys.stderr)
raise RuntimeError(f'{len(errors)} Downloads fehlgeschlagen. Keine Löschungen und kein Status-Commit.')
if not args.max_files:
deleted=0
# The public index is authoritative only after a successful complete fetch of the index and all requested documents.
for old in sorted(known-names):
for path in out.glob('*/'+old):
path.unlink(); deleted+=1
atomic_write(root/'data'/'index.txt',index)
new_state={'source':BASE,'since_year':args.since_year,'updated_at':datetime.now(timezone.utc).isoformat(),
'index':sorted(names),'review_cursor':(pos+len(review)) % max(1,len(remaining)),
'documents':len(names)}
atomic_write(state_file,(json.dumps(new_state,ensure_ascii=False,indent=2)+'\n').encode())
print(f'Änderungen={changed}, Löschungen={deleted}')
else:
print('TESTMODUS: Status und Löschungen unverändert')
return 0
if __name__=='__main__':
try:sys.exit(main())
except Exception as exc:
print(f'FEHLER: {exc}',file=sys.stderr); sys.exit(1)