From e0be5fda89c922fa9befe6c36c858f5832a38b59 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Nov 2026 21:58:22 +0100 Subject: [PATCH] Initial Ubuntu security mirror --- .gitea/workflows/update.yml | 26 ++++++++ .gitignore | 4 ++ README.md | 43 ++++++++++++ scripts/run-cached.sh | 32 +++++++++ scripts/sync.py | 129 ++++++++++++++++++++++++++++++++++++ scripts/validate.py | 15 +++++ tests/test_sync.py | 26 ++++++++ 7 files changed, 275 insertions(+) create mode 100644 .gitea/workflows/update.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 scripts/run-cached.sh create mode 100644 scripts/sync.py create mode 100644 scripts/validate.py create mode 100644 tests/test_sync.py diff --git a/.gitea/workflows/update.yml b/.gitea/workflows/update.yml new file mode 100644 index 00000000..67bf2fc3 --- /dev/null +++ b/.gitea/workflows/update.yml @@ -0,0 +1,26 @@ +name: Ubuntu OSV Mirror + +on: + workflow_dispatch: + schedule: + - cron: "13 */6 * * *" + +permissions: + contents: write + +concurrency: + group: vuln-ubuntu-update + cancel-in-progress: false + +jobs: + mirror: + runs-on: vuln-runner + timeout-minutes: 240 + steps: + - name: Sync Ubuntu data with persistent Git cache + env: + GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }} + CACHE_ROOT: /var/lib/act_runner/cache/vuln-ubuntu + REPO_URL: https://git.apex-project.de/VULN/vuln-ubuntu.git + run: | + bash /var/lib/act_runner/cache/vuln-ubuntu/run-cached.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a110ce53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.tmp +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..ee2da420 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# vuln-ubuntu + +Mirror der **offiziellen Ubuntu-OSV-JSON-Rohdaten** aus +`https://github.com/canonical/ubuntu-security-notices`. + +- CVEs ab 2017 unter `data/osv/cve//`. +- Alle verfügbaren Ubuntu Security Notices und Livepatch Security Notices unter `data/osv/usn/` und `data/osv/lsn/`. +- Kein Reformatieren der JSON-Dateien, unveränderte Bytes des Upstreams. +- Inkrementeller `git fetch` in einem persistenten Upstream-Checkout. +- Git-Folgeupdates des eigenen Repositories ebenfalls aus einem persistenten Checkout. +- Validierung vor Löschungen; entfernte Upstream-Dateien werden mit Git versioniert gelöscht. +- Alle sechs Stunden um Minute 13 UTC. + +**Hinweis:** Dies ist der OSV-Mirror, nicht eine vollständige Kopie aller Canonical-Formate (beispielsweise VEX und OVAL). CVEs vor 2017 werden bewusst ausgefiltert. Die offiziellen OSV-Daten sind für unterstützte Ubuntu-Releases verfügbar; historischer Abdeckungsgrad hängt vom Upstream ab. + +## Installation auf der Gitea-VM (Host-Runner ohne Docker) + +1. Leeres Repository `VULN/vuln-ubuntu` erstellen und diesen Ordner als `main` pushen. +2. `sudo apt install -y git python3 util-linux ca-certificates` +3. Runner-Cache anlegen: + + ```bash + sudo install -d -o act_runner -g act_runner -m 700 /var/lib/act_runner/cache/vuln-ubuntu + ``` + +4. **Bootstrap-Skript einmalig auf der Gitea-VM installieren** (wird außerhalb des Repositories ausgeführt, weil wir absichtlich keine Checkout-Action verwenden): + + ```bash + sudo install -o act_runner -g act_runner -m 700 scripts/run-cached.sh /var/lib/act_runner/cache/vuln-ubuntu/run-cached.sh + ``` + +5. Der vorhandene `vuln-runner` muss das Label `vuln-runner` tragen und `secrets.GITEA_TOKEN` muss Schreibrechte besitzen. +6. In Gitea `Actions → Ubuntu OSV Mirror → Run workflow` starten. + +## Sicherheits- und Betriebshinweise + +Host-Runner führen Code direkt auf der Gitea-VM aus: nur vertrauenswürdige Commits zulassen. +Für große Anfangsimporte viel freien Speicherplatz einplanen. Die ungefilterte Quelle kann sehr groß sein. Der erste Clone ist trotzdem erforderlich; danach nur inkrementelle Fetches. Gitea selbst speichert dieselben Rohdaten zusätzlich in Git. + +Vorsicht: Wenn GitHub die Filterfähigkeit nicht unterstützt, kann der anfängliche Git-Clone mehr Daten herunterladen als erwartet. +Der verwendete Job-Token wird nur im Prozess weitergegeben, nicht in die Remote-URL geschrieben. Die Daten werden vor Commit geprüft, aber ein Produktionslauf gegen den gesamten Upstream wurde noch nicht durchgeführt. + +Quelle: https://documentation.ubuntu.com/security/security-updates/osv/ und https://github.com/canonical/ubuntu-security-notices diff --git a/scripts/run-cached.sh b/scripts/run-cached.sh new file mode 100644 index 00000000..19a7bab3 --- /dev/null +++ b/scripts/run-cached.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +: "${GITEA_JOB_TOKEN:?GITEA_JOB_TOKEN fehlt}" +CACHE_ROOT="${CACHE_ROOT:-/var/lib/act_runner/cache/vuln-ubuntu}" +REPO_URL="${REPO_URL:-https://git.apex-project.de/VULN/vuln-ubuntu.git}" +mkdir -p "$CACHE_ROOT" +exec 9>"$CACHE_ROOT/.sync.lock" +flock -w 300 9 || { echo 'Schon ein anderer Lauf aktiv' >&2; exit 1; } +cd "$CACHE_ROOT" +if [[ ! -d repo/.git ]]; then + [[ ! -e repo ]] || { echo 'repo existiert ohne gültiges Git-Verzeichnis' >&2; exit 1; } + git -c "http.extraHeader=$AUTH_HEADER" clone --depth 1 "$REPO_URL" repo +fi +cd repo +# No credentials written to disk. Authentication is supplied on individual +# Git requests through an HTTP header. +AUTH_HEADER="Authorization: Basic $(printf 'oauth2:%s' "$GITEA_JOB_TOKEN" | base64 -w0)" +git -c "http.extraHeader=$AUTH_HEADER" fetch --depth 1 origin main +git checkout -B main FETCH_HEAD +git reset --hard FETCH_HEAD +# Keep the large upstream checkout outside the Gitea repository. +python3 scripts/sync.py --upstream-cache "$CACHE_ROOT/upstream" --since-year 2017 +python3 scripts/validate.py +git add -A data state +if git diff --cached --quiet; then + echo 'Keine Rohdaten-Änderungen' + exit 0 +fi +git -c user.name='Vulnerability Bot' -c user.email='vulnerability-bot@localhost.invalid' \ + commit -m "Update Ubuntu OSV $(date -u +%Y-%m-%dT%H:%M:%SZ)" +git -c "http.extraHeader=$AUTH_HEADER" push origin HEAD:main diff --git a/scripts/sync.py b/scripts/sync.py new file mode 100644 index 00000000..426ef9e0 --- /dev/null +++ b/scripts/sync.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Mirror Canonical's OSV JSON dataset to the local Gitea repository. + +The upstream Git checkout lives outside the repository in a persistent cache. +The initial clone is large; subsequent runs fetch only new Git objects. +""" +import argparse +import datetime as dt +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +UPSTREAM = 'https://github.com/canonical/ubuntu-security-notices.git' +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args, cwd=None): + print('+', ' '.join(map(str,args)), flush=True) + subprocess.run(args, cwd=cwd, check=True) + + +def upstream_update(path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + if not (path / '.git').is_dir(): + if path.exists(): + if any(path.iterdir()): + raise RuntimeError(f'Upstream-Cache ist kein Git-Repository: {path}') + path.rmdir() + run('git', 'clone', '--depth', '1', '--filter=blob:none', '--sparse', UPSTREAM, str(path)) + run('git', 'sparse-checkout', 'set', 'osv', cwd=path) + else: + run('git', 'fetch', '--depth', '1', 'origin', 'main', cwd=path) + run('git', 'reset', '--hard', 'FETCH_HEAD', cwd=path) + run('git', 'sparse-checkout', 'set', 'osv', cwd=path) + sha = subprocess.check_output(['git','rev-parse','HEAD'], cwd=path, text=True).strip() + return sha + + +def collect(src: Path, since_year: int): + """Select OSV CVEs from year >= since_year, plus all USNs/LSNs.""" + if not (src/'cve').is_dir(): + raise RuntimeError('Upstream osv/cve/ nicht vorhanden: Abbruch ohne Löschung') + count = 0 + for p in sorted(src.rglob('*.json')): + rel = p.relative_to(src) + if rel.parts[0] == 'cve': + if len(rel.parts) < 3 or not rel.parts[1].isdigit(): + continue + if int(rel.parts[1]) < since_year: + continue + elif rel.parts[0] not in ('usn', 'lsn'): + continue + count += 1 + yield p, rel + if count == 0: + raise RuntimeError('Keine OSV-JSON-Dateien gefunden') + + +def is_valid(payload, name): + if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']: + raise ValueError(f'Ungültiges OSV-Dokument: {name}') + + +def sync(src: Path, dst: Path, since_year: int): + dst.mkdir(parents=True, exist_ok=True) + present = set() + updated = 0 + unchanged = 0 + for p, rel in collect(src, since_year): + data = p.read_bytes() + is_valid(json.loads(data), p) + target = dst / rel + present.add(rel.as_posix()) + if target.exists() and target.read_bytes() == data: + unchanged += 1 + continue + target.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as f: + tmp = Path(f.name) + f.write(data) + os.replace(tmp, target) + updated += 1 + # Only delete after fully successful fetch and validation of all inputs. + removed = 0 + for p in dst.rglob('*.json'): + if p.relative_to(dst).as_posix() not in present: + p.unlink() + removed += 1 + for folder in sorted((p for p in dst.rglob('*') if p.is_dir()), key=lambda x:len(x.parts), reverse=True): + if not any(folder.iterdir()): + folder.rmdir() + return {'files':len(present), 'updated':updated,'unchanged':unchanged,'removed':removed} + + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument('--upstream-cache', default=os.environ.get('UPSTREAM_CACHE','/var/lib/act_runner/cache/vuln-ubuntu/upstream')) + ap.add_argument('--since-year', type=int, default=2017) + args=ap.parse_args() + if args.since_year < 1999 or args.since_year > dt.datetime.now(dt.timezone.utc).year: + ap.error('Ungültiges Jahr') + sha = upstream_update(Path(args.upstream_cache)) + stats = sync(Path(args.upstream_cache)/'osv', ROOT/'data'/'osv', args.since_year) + state={'source':UPSTREAM,'upstream_commit':sha,'since_year':args.since_year, + 'last_success_utc':dt.datetime.now(dt.timezone.utc).isoformat(), **stats} + (ROOT/'state').mkdir(exist_ok=True) + state_path = ROOT/'state'/'sync.json' + # Do not produce a Git commit if the upstream commit and dataset are unchanged. + previous = {} + if state_path.exists(): + try: + previous = json.loads(state_path.read_text()) + except (OSError, ValueError): + pass + if previous.get('upstream_commit') != sha or stats['updated'] or stats['removed']: + state_path.write_text(json.dumps(state,indent=2,ensure_ascii=False)+'\n') + print(json.dumps(state, indent=2)) + +if __name__=='__main__': + try: + main() + except (RuntimeError, subprocess.CalledProcessError, OSError, ValueError, json.JSONDecodeError) as e: + print(f'Synchronisierung fehlgeschlagen: {e}',file=sys.stderr) + sys.exit(1) diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100644 index 00000000..03bd972f --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +import json +from pathlib import Path +import sys +root=Path(__file__).resolve().parents[1] +files=list((root/'data'/'osv').rglob('*.json')) +if not files: + sys.exit('Fehler: Keine OSV-Dateien gefunden') +for p in files: + try: + v=json.loads(p.read_bytes()) + assert isinstance(v,dict) and isinstance(v.get('id'),str) and v['id'] + except (ValueError, AssertionError) as e: + sys.exit(f'Ungültig: {p}: {e}') +print(f'{len(files)} Ubuntu OSV-Datensätze gültig') diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 00000000..752fb775 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,26 @@ +import json +from pathlib import Path +import tempfile +import unittest +import sys +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts')) +from sync import sync + +class SyncTest(unittest.TestCase): + def test_filter_update_delete_and_invalid_input(self): + with tempfile.TemporaryDirectory() as d: + base=Path(d) + src=base/'source'; dst=base/'output' + for rel in ['cve/2016/old.json','cve/2025/current.json','usn/USN-1.json']: + p=src/rel;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(json.dumps({'id':rel})) + st=sync(src,dst,2017) + self.assertEqual(st['files'],2) + self.assertFalse((dst/'cve/2016/old.json').exists()) + self.assertEqual(sync(src,dst,2017)['updated'],0) + (src/'cve/2025/current.json').unlink() + st=sync(src,dst,2017) + self.assertEqual(st['removed'],1) + bad=src/'cve/2026/bad.json';bad.parent.mkdir(parents=True,exist_ok=True);bad.write_text('not-json') + with self.assertRaises(ValueError):sync(src,dst,2017) + self.assertTrue((dst/'usn/USN-1.json').exists()) +if __name__=='__main__':unittest.main()