Initial SUSE CSAF mirror

This commit is contained in:
root
2026-11-10 22:17:16 +01:00
commit 41e6676c2d
7 changed files with 267 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
name: SUSE CSAF Mirror
on:
schedule:
- cron: "31 */6 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: vuln-suse
cancel-in-progress: false
jobs:
mirror:
runs-on: vuln-runner
timeout-minutes: 240
steps:
- name: SUSE CSAF aktualisieren (persistenter Git-Checkout)
env:
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
CACHE_ROOT: /var/lib/act_runner/cache/vuln-suse
REPO_URL: https://git.apex-project.de/VULN/vuln-suse.git
run: |
set -Eeuo pipefail
# Only the first run needs to bootstrap the local checkout.
if [ ! -f "$CACHE_ROOT/repo/scripts/run-cached.sh" ]; then
mkdir -p "$CACHE_ROOT"
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=http.extraheader
export GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'oauth2:%s' "$GITEA_JOB_TOKEN" | base64 -w0)"
git clone --depth 1 --branch main "$REPO_URL" "$CACHE_ROOT/repo"
fi
bash "$CACHE_ROOT/repo/scripts/run-cached.sh"
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.tmp
.env
+33
View File
@@ -0,0 +1,33 @@
# vuln-suse – SUSE/openSUSE CSAF mirror
Spiegelt die offiziellen **CSAF 2.0 Advisory-Rohdaten** (SUSE und openSUSE) ab Advisory-Jahr 2017 direkt nach Git.
Quelle (GWDG-Mirror): https://ftp2.gwdg.de/pub/linux/suse/projects/security/csaf/
- `data/index.txt`: unveränderter offizieller Index.
- `data/csaf/<jahr>/<advisory>.json`: unveränderte CSAF-Dokumente.
- `state/sync.json`: Indexstand und Rotation für die Rückprüfung alter Dateien.
**Nicht enthalten:** Die separate SUSE CSAF-VEX-Sammlung nach CVE. Diese kann als eigenes Repository `vuln-suse-vex` folgen.
## Gitea-Installation
1. Leeres Repository `VULN/vuln-suse` erstellen.
2. Dateien initial nach `main` pushen.
3. Auf dem Gitea-Runner-Host:
`sudo install -d -o act_runner -g act_runner -m 700 /var/lib/act_runner/cache/vuln-suse`
4. `git`, `python3`, `flock` (`util-linux`) installieren.
5. Workflow `SUSE CSAF Mirror` manuell starten. Automatisch alle 6 Stunden um Minute 31 (UTC).
6. Gitea-Jobtoken `secrets.GITEA_TOKEN` benötigt Schreibrecht auf das Repository.
## Ablauf
- Erstimport: alle Advisory-JSONs ab 2017 aus `index.txt`.
- Weitere Läufe: neue/fehlende Dokumente und 300 ältere Dokumente zur Änderungserkennung.
- Vollabgleich: `python3 scripts/sync.py --full` im persistenten Checkout.
- Fehlgeschlagene Downloads verhindern Statusfortschreibung und Löschungen.
- Alte Dateien werden nur entfernt, wenn sie nach erfolgreichem Indexabruf nicht mehr gelistet sind.
**Grenze:** Änderungen an alten Dokumenten werden bei der rotierenden Kontrolle verzögert erkannt. `changes.csv` wird aktuell nicht ausgewertet. Der öffentliche GWDG-Mirror kann dem SUSE-Ursprung zeitlich hinterherhinken. Große Erstimports benötigen entsprechend Speicher und Git-Commit-Zeit. Für produktive Nutzung empfiehlt sich zusätzlich ein regelmäßiger `--full`-Abgleich.
Sicherheit: Ein `:host`-Runner führt Workflows direkt auf der Gitea-VM aus. Repository-Schreibrechte beschränken und nur vertrauenswürdige Branches ausführen.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
: "${CACHE_ROOT:=/var/lib/act_runner/cache/vuln-suse}"
: "${GITEA_JOB_TOKEN:?GITEA_JOB_TOKEN fehlt}"
: "${REPO_URL:=https://git.apex-project.de/VULN/vuln-suse.git}"
mkdir -p "$CACHE_ROOT"
exec 9>"$CACHE_ROOT/.sync.lock"
flock -w 300 9 || { echo 'Ein anderer SUSE-Lauf ist aktiv' >&2; exit 1; }
# Temporary, environment-only Git authentication. No token embedded in remote URL or git config file.
GIT_CONFIG_COUNT=1
GIT_CONFIG_KEY_0=http.extraheader
GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'oauth2:%s' "$GITEA_JOB_TOKEN" | base64 -w0)"
export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
if [[ ! -d "$CACHE_ROOT/repo/.git" ]]; then
git clone --branch main --single-branch --depth 1 "$REPO_URL" "$CACHE_ROOT/repo"
fi
cd "$CACHE_ROOT/repo"
git remote set-url origin "$REPO_URL"
git fetch --depth 1 origin main
git reset --hard FETCH_HEAD
# Recover tracked files that might have been created during a failed previous run.
git clean -fd -- scripts/ state/ || true
python3 scripts/sync.py --since-year 2017
python3 scripts/validate.py
git config user.name 'Vulnerability Bot'
git config user.email 'vulnerability-bot@users.noreply.local'
git add -A data/ state/
if git diff --cached --quiet; then
echo 'Keine Git-Änderungen'
exit 0
fi
git commit -m "Update SUSE CSAF $(date -u +%Y-%m-%dT%H:%M:%SZ)"
git push origin HEAD:main
+132
View File
@@ -0,0 +1,132 @@
#!/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)
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import json
import re
from pathlib import Path
from sys import exit
root=Path(__file__).resolve().parent.parent
state=root/'state'/'sync.json'
if not state.exists():raise SystemExit('Kein Sync-Status vorhanden')
s=json.loads(state.read_text())
assert s.get('documents')==len(s.get('index',[]))
files=list((root/'data'/'csaf').glob('*/*.json'))
if len(files)!=s['documents']:
raise SystemExit(f'Unvollständig: {len(files)} Dateien, {s["documents"]} erwartet')
for p in files:
obj=json.loads(p.read_bytes())
if not isinstance(obj.get('document'),dict):raise SystemExit(f'CSAF ungültig: {p}')
print(f'VALID: {len(files)} CSAF-Dokumente')
+16
View File
@@ -0,0 +1,16 @@
import importlib.util
from pathlib import Path
import unittest
spec=importlib.util.spec_from_file_location('sync',Path(__file__).resolve().parents[1]/'scripts/sync.py')
mod=importlib.util.module_from_spec(spec);spec.loader.exec_module(mod)
class TestIndex(unittest.TestCase):
def test_filter(self):
self.assertTrue(mod.eligible('opensuse-su-2017_0034-1.json',2017))
self.assertTrue(mod.eligible('suse-su-2026_9999-1.json',2017))
self.assertFalse(mod.eligible('opensuse-su-2016_2223-1.json',2017))
self.assertFalse(mod.eligible('../invalid-2026.json',2017))
self.assertEqual(len(mod.load_index(b'opensuse-su-2016_2223-1.json\nopensuse-su-2017_0034-1.json\n',2017)),1)
def test_csaf(self):
mod.valid_doc(b'{"document":{},"vulnerabilities":[]}')
with self.assertRaises(ValueError):mod.valid_doc(b'{"hello":"world"}')
if __name__=='__main__':unittest.main()