Initial Ubuntu security mirror
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.tmp
|
||||
.venv/
|
||||
@@ -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/<jahr>/`.
|
||||
- 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
|
||||
@@ -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
|
||||
+129
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user