Initial SUSE VEX mirror
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
name: SUSE VEX Mirror
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "49 */6 * * *"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vuln-suse-vex-sync
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: vuln-runner
|
||||||
|
timeout-minutes: 360
|
||||||
|
steps:
|
||||||
|
- name: Sync SUSE VEX using persistent Git checkout
|
||||||
|
env:
|
||||||
|
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
CACHE_ROOT: /var/lib/act_runner/cache/vuln-suse-vex
|
||||||
|
REPO_URL: https://git.apex-project.de/VULN/vuln-suse-vex.git
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
: "${GITEA_JOB_TOKEN:?Missing GITEA_TOKEN}"
|
||||||
|
mkdir -p "$CACHE_ROOT"
|
||||||
|
exec 9>"$CACHE_ROOT/.bootstrap.lock"
|
||||||
|
flock -w 300 9
|
||||||
|
AUTH_HEADER="Authorization: Basic $(printf 'oauth2:%s' "$GITEA_JOB_TOKEN" | base64 -w0)"
|
||||||
|
export GIT_CONFIG_COUNT=1
|
||||||
|
export GIT_CONFIG_KEY_0=http.extraheader
|
||||||
|
export GIT_CONFIG_VALUE_0="$AUTH_HEADER"
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
if [ ! -d "$CACHE_ROOT/repo/.git" ]; then
|
||||||
|
git clone --depth 1 --branch main "$REPO_URL" "$CACHE_ROOT/repo"
|
||||||
|
fi
|
||||||
|
unset AUTH_HEADER
|
||||||
|
bash "$CACHE_ROOT/repo/scripts/run-cached.sh"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
*.tmp
|
||||||
|
.download-*
|
||||||
|
.env
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# vuln-suse-vex
|
||||||
|
|
||||||
|
Eigener Gitea-Mirror der **SUSE CSAF 2.0 VEX**-Dokumente (je CVE) ab 2017.
|
||||||
|
Die bereits angelegte Gitea-Adresse ist `https://git.apex-project.de/VULN/vuln-suse-vex`.
|
||||||
|
|
||||||
|
## Quelle
|
||||||
|
|
||||||
|
- Offizielle SUSE-Quelle: `https://ftp.suse.com/pub/projects/security/csaf-vex.tar.bz2`
|
||||||
|
- Ausgabe: `data/vex/YYYY/CVE-YYYY-NNNN.json`
|
||||||
|
- Der Downloader verwendet `If-None-Match` und `If-Modified-Since` soweit die Quelle diese Header unterstützt.
|
||||||
|
- **Hinweis:** Bei einem geänderten Tar-Archiv wird das vollständige Archiv erneut geladen. Das ist kein echtes File-Delta. Das Repository selbst verwendet einen persistenten Git-Checkout.
|
||||||
|
- Kein Fallback auf möglicherweise veraltete Mirrors. Bei Fehlern bleibt der letzte Datenbestand unverändert.
|
||||||
|
|
||||||
|
## Erstinstallation
|
||||||
|
|
||||||
|
1. Leeres Gitea-Repository `VULN/vuln-suse-vex` anlegen.
|
||||||
|
2. Dateien committen und nach `main` pushen.
|
||||||
|
3. Auf der Gitea-VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y git python3 ca-certificates util-linux bzip2
|
||||||
|
sudo install -d -o act_runner -g act_runner -m 700 /var/lib/act_runner/cache/vuln-suse-vex
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Actions aktivieren. Runner-Label `vuln-runner` verwenden. `secrets.GITEA_TOKEN` muss Schreibrechte besitzen.
|
||||||
|
5. Workflow `SUSE VEX Mirror` manuell starten; danach alle 6 Stunden zur Minute 49 UTC.
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
- Ein unterbrochener Import lässt möglicherweise `?? data/` zurück. Das Skript bewahrt diese Dateien und synchronisiert sie bei gleichbleibendem Remote-Commit erneut.
|
||||||
|
- Wenn sich `main` geändert hat **und** der Cache uncommittete Dateien enthält, wird aus Sicherheitsgründen abgebrochen. Keinesfalls `git clean -fd` oder `git reset --hard` auf diese Daten anwenden, ohne sie vorher zu prüfen.
|
||||||
|
- Eine geänderte Quelldatei wird zunächst vollständig heruntergeladen und erst nach erfolgreicher Archiv- und JSON-Validierung verarbeitet.
|
||||||
|
- Nach einem unterbrochenen Commit/Push lokale Git-Historie prüfen.
|
||||||
|
- Lokaler Test: `python3 -m unittest discover -s tests -v`.
|
||||||
|
|
||||||
|
## Grenzen
|
||||||
|
|
||||||
|
- Keine Integration von separaten SUSE-Advisories; diese liegen in `vuln-suse`.
|
||||||
|
- Der Feed kann größer als mehrere hundert MB sein; ausreichend Platz für Archiv, JSON-Dateien und Git-Historie vorsehen.
|
||||||
|
- `state/sync.json` wird für einen erfolgreichen Snapshot aktualisiert. Die zusätzliche HTTP-Metadatei bleibt nur auf der VM.
|
||||||
|
- Vor produktiver Freigabe einen kompletten Live-Import testen.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
: "${GITEA_JOB_TOKEN:?GITEA_JOB_TOKEN missing}"
|
||||||
|
ROOT="${CACHE_ROOT:-/var/lib/act_runner/cache/vuln-suse-vex}"
|
||||||
|
REPO_URL="${REPO_URL:-https://git.apex-project.de/VULN/vuln-suse-vex.git}"
|
||||||
|
mkdir -p "$ROOT"
|
||||||
|
exec 9>"$ROOT/.sync.lock"
|
||||||
|
flock -w 300 9 || { echo 'Another sync is active' >&2; exit 1; }
|
||||||
|
cd "$ROOT"
|
||||||
|
# Credentials only provided to git commands in process environment; never saved to .git/config.
|
||||||
|
AUTH_HEADER="Authorization: Basic $(printf 'oauth2:%s' "$GITEA_JOB_TOKEN" | base64 -w0)"
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
GIT_CONFIG_COUNT=1
|
||||||
|
GIT_CONFIG_KEY_0=http.extraheader
|
||||||
|
GIT_CONFIG_VALUE_0="$AUTH_HEADER"
|
||||||
|
export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
|
||||||
|
if [[ ! -d repo/.git ]]; then
|
||||||
|
git clone --depth 1 --branch main "$REPO_URL" repo
|
||||||
|
fi
|
||||||
|
cd repo
|
||||||
|
# Allow only partial data/state from interrupted jobs. Never discard automatically.
|
||||||
|
DIRTY=$(git status --porcelain)
|
||||||
|
if [[ -n "$DIRTY" ]]; then
|
||||||
|
if git status --porcelain | grep -Ev '^.. (data/|state/)' >/dev/null; then
|
||||||
|
echo 'Unexpected dirty paths; manual review required' >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo 'Previous interrupted import data found; preserving it.'
|
||||||
|
fi
|
||||||
|
git fetch --depth 1 origin main
|
||||||
|
LOCAL=$(git rev-parse HEAD)
|
||||||
|
REMOTE=$(git rev-parse FETCH_HEAD)
|
||||||
|
if [[ "$LOCAL" != "$REMOTE" ]]; then
|
||||||
|
if [[ -n "$DIRTY" ]]; then
|
||||||
|
echo 'Upstream changed while data/state are dirty; manual recovery needed. No reset performed.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git reset --hard FETCH_HEAD
|
||||||
|
fi
|
||||||
|
python3 scripts/sync.py --since-year 2017
|
||||||
|
python3 scripts/validate.py
|
||||||
|
git config user.name 'Vulnerability Bot'
|
||||||
|
git config user.email 'vuln-bot@users.noreply.local'
|
||||||
|
git add -A -- data/ state/
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo 'No data changes'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "Sync SUSE VEX $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
# Concurrent push can't overwrite others. On failure keep local commit for inspection.
|
||||||
|
git push origin HEAD:main
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SUSE CSAF VEX mirror from official upstream tarball.
|
||||||
|
|
||||||
|
Atomic snapshot: download complete archive, validate every selected JSON before
|
||||||
|
changing the dataset, then reconcile data, including deletions. Resume: existing
|
||||||
|
uncommitted files are retained and compared against the next valid snapshot.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
DEFAULT_URL = 'https://ftp.suse.com/pub/projects/security/csaf-vex.tar.bz2'
|
||||||
|
CVE_RE = re.compile(r'(?i)cve[-_](\d{4})[-_](\d{4,})')
|
||||||
|
MAX_MEMBER = 32 * 1024 * 1024
|
||||||
|
UA = 'vuln-suse-vex-mirror/1.0 (+https://git.apex-project.de/VULN/vuln-suse-vex)'
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write(path, data):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.tmp-')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url, archive, meta_path, timeout, retries):
|
||||||
|
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
|
||||||
|
headers = {'User-Agent': UA}
|
||||||
|
if archive.exists() and archive.stat().st_size > 0 and meta.get('source') == url:
|
||||||
|
if meta.get('etag'): headers['If-None-Match'] = meta['etag']
|
||||||
|
if meta.get('last_modified'): headers['If-Modified-Since'] = meta['last_modified']
|
||||||
|
for n in range(retries):
|
||||||
|
try:
|
||||||
|
req = Request(url, headers=headers)
|
||||||
|
with urlopen(req, timeout=timeout) as response:
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(archive.parent), prefix='.download-')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'wb') as f:
|
||||||
|
shutil.copyfileobj(response, f, length=1024*1024)
|
||||||
|
f.flush(); os.fsync(f.fileno())
|
||||||
|
if os.stat(tmp).st_size < 1024:
|
||||||
|
raise ValueError('Archive unexpectedly small')
|
||||||
|
# Validate compressed tar before replacing persistent archive
|
||||||
|
with tarfile.open(tmp, 'r:bz2') as tf:
|
||||||
|
if tf.next() is None: raise ValueError('Empty upstream tar')
|
||||||
|
os.replace(tmp, archive)
|
||||||
|
new_meta = {'source':url, 'etag':response.headers.get('ETag'),
|
||||||
|
'last_modified':response.headers.get('Last-Modified')}
|
||||||
|
atomic_write(meta_path,json.dumps(new_meta, indent=2).encode())
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):os.unlink(tmp)
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code == 304 and archive.exists():return False
|
||||||
|
if e.code not in (429, 500, 502, 503, 504):raise
|
||||||
|
err=e
|
||||||
|
except (OSError, URLError, ValueError, tarfile.TarError) as e:
|
||||||
|
err=e
|
||||||
|
if n+1 < retries:
|
||||||
|
delay=min(2**n * 8, 120)
|
||||||
|
print(f'Download failed: {err}; retry in {delay}s',file=sys.stderr,flush=True)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise RuntimeError(f'Cannot fetch {url}: {err}')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_archive(path, since_year):
|
||||||
|
"""Read and validate ALL relevant raw VEX JSON before any output mutation."""
|
||||||
|
items={}
|
||||||
|
with tarfile.open(path, 'r|bz2') as tf:
|
||||||
|
for member in tf:
|
||||||
|
if not member.isfile() or not member.name.lower().endswith('.json'):
|
||||||
|
continue
|
||||||
|
name=Path(member.name).name
|
||||||
|
match=CVE_RE.search(name)
|
||||||
|
if not match or int(match.group(1)) < since_year:
|
||||||
|
continue
|
||||||
|
if member.size <= 0 or member.size > MAX_MEMBER:
|
||||||
|
raise ValueError(f'Invalid JSON member size: {member.name}')
|
||||||
|
f=tf.extractfile(member)
|
||||||
|
if not f:raise ValueError(f'Unreadable member: {member.name}')
|
||||||
|
raw=f.read(MAX_MEMBER+1)
|
||||||
|
if len(raw) != member.size or len(raw)>MAX_MEMBER:
|
||||||
|
raise ValueError(f'Incomplete JSON: {member.name}')
|
||||||
|
obj=json.loads(raw)
|
||||||
|
if not isinstance(obj,dict) or not isinstance(obj.get('document'),dict) or not isinstance(obj.get('vulnerabilities'),list):
|
||||||
|
raise ValueError(f'Invalid CSAF document: {member.name}')
|
||||||
|
year=match.group(1)
|
||||||
|
cve=f'CVE-{year}-{match.group(2)}'
|
||||||
|
# Duplicate names across archive: only exact duplicate bytes are acceptable
|
||||||
|
target=f'{year}/{cve}.json'
|
||||||
|
if target in items and items[target] != raw:
|
||||||
|
raise ValueError(f'Duplicate conflicting CVE document: {target}')
|
||||||
|
items[target]=raw
|
||||||
|
if not items:raise ValueError('Zero CSAF VEX documents; refusing snapshot')
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(items, root, state, source, since_year):
|
||||||
|
root.mkdir(parents=True,exist_ok=True)
|
||||||
|
existing={p.relative_to(root).as_posix():p for p in root.rglob('*.json')}
|
||||||
|
added=changed=removed=0
|
||||||
|
for rel,raw in items.items():
|
||||||
|
target=root/rel
|
||||||
|
if target.exists() and target.read_bytes()==raw:continue
|
||||||
|
added += not target.exists(); changed += target.exists()
|
||||||
|
atomic_write(target,raw)
|
||||||
|
for rel,p in existing.items():
|
||||||
|
# Files older than since_year never belong to this mirror
|
||||||
|
if rel not in items:
|
||||||
|
p.unlink();removed+=1
|
||||||
|
# This state describes a completely validated snapshot, not an incomplete run
|
||||||
|
payload={'source':source,'min_year':since_year,'record_count':len(items),
|
||||||
|
'snapshot_sha256':hashlib.sha256('\n'.join(sorted(items)).encode()).hexdigest(),
|
||||||
|
'completed_at':datetime.now(timezone.utc).isoformat(),
|
||||||
|
'added':added,'updated':changed,'deleted':removed}
|
||||||
|
atomic_write(state,json.dumps(payload,indent=2,sort_keys=True).encode())
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap=argparse.ArgumentParser()
|
||||||
|
ap.add_argument('--source',default=os.environ.get('SUSE_VEX_URL',DEFAULT_URL))
|
||||||
|
ap.add_argument('--since-year',type=int,default=2017)
|
||||||
|
ap.add_argument('--cache',type=Path,default=Path(os.environ.get('SUSE_VEX_DOWNLOAD_CACHE','/var/lib/act_runner/cache/vuln-suse-vex/download')))
|
||||||
|
ap.add_argument('--timeout',type=int,default=180)
|
||||||
|
ap.add_argument('--retries',type=int,default=4)
|
||||||
|
args=ap.parse_args()
|
||||||
|
if args.since_year<1999:ap.error('since-year must be >= 1999')
|
||||||
|
args.cache.mkdir(parents=True,exist_ok=True)
|
||||||
|
archive=args.cache/'csaf-vex.tar.bz2'
|
||||||
|
meta=args.cache/'http.json'
|
||||||
|
downloaded=fetch(args.source,archive,meta,args.timeout,args.retries)
|
||||||
|
# Don't rewrite the state, and don't regenerate a commit if HTTP returns 304.
|
||||||
|
if not downloaded and Path('state/sync.json').exists():
|
||||||
|
print('Upstream archive unchanged (HTTP 304)')
|
||||||
|
return
|
||||||
|
records=parse_archive(archive,args.since_year)
|
||||||
|
result=reconcile(records,Path('data/vex'),Path('state/sync.json'),args.source,args.since_year)
|
||||||
|
print(json.dumps(result,indent=2))
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail closed on invalid CSAF JSON and incomplete synchronization."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CVE=re.compile(r'^CVE-\d{4}-\d{4,}\.json$',re.I)
|
||||||
|
def validate(root=Path('data/vex'), state=Path('state/sync.json')):
|
||||||
|
if not state.exists():raise ValueError('Missing completed sync state')
|
||||||
|
metadata=json.loads(state.read_text())
|
||||||
|
files=sorted(root.rglob('*.json'))
|
||||||
|
if not files or len(files)!=metadata['record_count']:
|
||||||
|
raise ValueError(f'Count mismatch: files={len(files)} expected={metadata["record_count"]}')
|
||||||
|
for path in files:
|
||||||
|
if not CVE.match(path.name):raise ValueError(f'Invalid filename: {path}')
|
||||||
|
doc=json.loads(path.read_bytes())
|
||||||
|
if not isinstance(doc,dict) or not isinstance(doc.get('document'),dict) or not isinstance(doc.get('vulnerabilities'),list):
|
||||||
|
raise ValueError(f'Invalid CSAF VEX: {path}')
|
||||||
|
print(f'Validated {len(files)} CSAF VEX documents')
|
||||||
|
if __name__=='__main__':
|
||||||
|
try:validate()
|
||||||
|
except (ValueError,OSError,KeyError,json.JSONDecodeError) as ex:
|
||||||
|
print(f'VALIDATION FAILED: {ex}',file=sys.stderr);sys.exit(1)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import bz2
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
|
||||||
|
from sync import parse_archive,reconcile
|
||||||
|
from validate import validate
|
||||||
|
|
||||||
|
class SyncTests(unittest.TestCase):
|
||||||
|
def test_roundtrip_and_deletions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
p=Path(tmp);archive=p/'test.tar.bz2'
|
||||||
|
data=json.dumps({'document':{'category':'csaf_vex'},'vulnerabilities':[]}).encode()
|
||||||
|
with tarfile.open(archive,'w:bz2') as tf:
|
||||||
|
for name in ('csaf-vex/2025/cve-2025-12345.json','csaf-vex/2016/cve-2016-9999.json'):
|
||||||
|
info=tarfile.TarInfo(name);info.size=len(data);tf.addfile(info,io.BytesIO(data))
|
||||||
|
items=parse_archive(archive,2017)
|
||||||
|
self.assertEqual(list(items),['2025/CVE-2025-12345.json'])
|
||||||
|
import os
|
||||||
|
cwd=Path.cwd();os.chdir(p)
|
||||||
|
try:
|
||||||
|
result=reconcile(items,Path('data/vex'),Path('state/sync.json'),'test',2017)
|
||||||
|
self.assertEqual(result['record_count'],1)
|
||||||
|
validate()
|
||||||
|
(Path('data/vex/2025')/'CVE-2025-12345.json').unlink()
|
||||||
|
reconcile(items,Path('data/vex'),Path('state/sync.json'),'test',2017)
|
||||||
|
validate()
|
||||||
|
finally:os.chdir(cwd)
|
||||||
|
def test_invalid_csaf_fails_before_write(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
p=Path(tmp);archive=p/'bad.tar.bz2'
|
||||||
|
data=b'{bad'
|
||||||
|
with tarfile.open(archive,'w:bz2') as tf:
|
||||||
|
x=tarfile.TarInfo('cve-2026-1111.json');x.size=len(data);tf.addfile(x,io.BytesIO(data))
|
||||||
|
with self.assertRaises(json.JSONDecodeError):parse_archive(archive,2017)
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
Reference in New Issue
Block a user