Initial SUSE CSAF mirror
This commit is contained in:
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.tmp
|
||||||
|
.env
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user