133 lines
5.5 KiB
Python
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)
|