Initial OSV mirror
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
name: vuln-osv Synchronisation (lokaler Git-Cache)
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "41 */6 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vuln-osv-sync
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: vuln-runner
|
||||||
|
timeout-minutes: 360
|
||||||
|
steps:
|
||||||
|
- name: Rohdaten aktualisieren
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
CACHE_ROOT: /var/lib/act_runner/cache/vuln-osv
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
: "${GITEA_JOB_TOKEN:?GITEA_TOKEN fehlt}"
|
||||||
|
mkdir -p "$CACHE_ROOT"
|
||||||
|
exec 9>"$CACHE_ROOT/.sync.lock"
|
||||||
|
flock -w 300 9 || { echo "Bereits ein Lauf aktiv" >&2; exit 1; }
|
||||||
|
|
||||||
|
ASKPASS="$(mktemp "$CACHE_ROOT/.askpass.XXXXXX")"
|
||||||
|
trap 'rm -f "$ASKPASS"' EXIT
|
||||||
|
cat > "$ASKPASS" <<'ASKPASS_SCRIPT'
|
||||||
|
#!/bin/sh
|
||||||
|
case "$1" in
|
||||||
|
*Username*|*username*) printf '%s\n' 'oauth2' ;;
|
||||||
|
*Password*|*password*) printf '%s\n' "$GITEA_JOB_TOKEN" ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
ASKPASS_SCRIPT
|
||||||
|
chmod 700 "$ASKPASS"
|
||||||
|
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0
|
||||||
|
REMOTE="https://git.apex-project.de/VULN/vuln-osv.git"
|
||||||
|
REPO="$CACHE_ROOT/repo"
|
||||||
|
if [ ! -d "$REPO/.git" ]; then
|
||||||
|
[ ! -e "$REPO" ] || { echo "Unvollstaendiger Cache: $REPO" >&2; exit 1; }
|
||||||
|
git -c credential.helper= clone --branch main --single-branch --depth 1 "$REMOTE" "$REPO"
|
||||||
|
else
|
||||||
|
# Never reset an unfinished local commit or changes after a failed push.
|
||||||
|
if [ -n "$(git -C "$REPO" status --porcelain)" ] || [ "$(git -C "$REPO" rev-list --count origin/main..HEAD)" != 0 ]; then
|
||||||
|
echo "Lokale Aenderungen/Commits vorhanden: manuell pruefen, nicht ueberschreiben" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git -C "$REPO" remote set-url origin "$REMOTE"
|
||||||
|
git -C "$REPO" -c credential.helper= fetch --no-tags --depth 1 origin main
|
||||||
|
git -C "$REPO" checkout -B main FETCH_HEAD
|
||||||
|
fi
|
||||||
|
cd "$REPO"
|
||||||
|
python3 scripts/sync.py
|
||||||
|
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 "Keine neuen Daten"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "vuln-osv update $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
git -c credential.helper= push origin HEAD:main
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.tmp
|
||||||
|
.sync.lock
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# vuln-osv – Gitea OSV raw-data mirror
|
||||||
|
|
||||||
|
Speichert Original-JSON aus OSV-Ökosystem-Snapshots direkt in Git unter `data/<ECOSYSTEM>/<ID>.json`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
1. Leeres Gitea-Repository `VULN/vuln-osv` erstellen, dieses Verzeichnis pushen.
|
||||||
|
2. Auf der Gitea-VM: `sudo install -d -o act_runner -g act_runner -m 700 /var/lib/act_runner/cache/vuln-osv`.
|
||||||
|
3. `vuln-runner:host` muss registriert sein. `python3`, `git` und `flock` müssen verfügbar sein.
|
||||||
|
4. Actions-Job-Token benötigt Schreibrechte. Workflow `.gitea/workflows/update.yml` starten.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
- Zeitplan: alle sechs Stunden (Minute 41).
|
||||||
|
- HTTP `If-None-Match` / `If-Modified-Since`: Bei unverändertem Export Antwort `304`, kein ZIP-Download.
|
||||||
|
- Wenn sich ein ZIP ändert, wird **das gesamte Ökosystem-ZIP erneut geladen**, nicht nur die Änderungen. Ein späterer Umbau auf `modified_id.csv` spart weitere Bandbreite.
|
||||||
|
- Nur geänderte JSON-Dateien werden geschrieben und committed.
|
||||||
|
- Standard-Ökosysteme: PyPI, npm, Go, Maven, NuGet, crates.io, Packagist, RubyGems, GitHub Actions, OSS-Fuzz.
|
||||||
|
- Für andere Ökosysteme `python3 scripts/sync.py --ecosystems 'PyPI,npm,Go'` ausführen, beziehungsweise Workflow-Aufruf anpassen.
|
||||||
|
- **Löschungen**: Standardmäßig werden keine Dateien entfernt. Erst nach belastbar bestätigtem Full-Snapshot mit `--allow-deletions` aktivieren. Ein fehlender Snapshot darf niemals als Leeren interpretiert werden.
|
||||||
|
- Sehr große Git-Repos: Speicherplatz, Inodes und Gitea-Push-Limits kontrollieren. Für npm/Go sind mehrere hunderttausend Dateien möglich.
|
||||||
|
|
||||||
|
Offizielle Quelle: https://google.github.io/osv.dev/data/
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mirror full raw OSV JSON files per ecosystem with conditional HTTP requests.
|
||||||
|
No third-party Python dependencies. All-or-nothing validation per ecosystem.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BASE = 'https://storage.googleapis.com/osv-vulnerabilities'
|
||||||
|
DEFAULT = 'PyPI,npm,Go,Maven,NuGet,crates.io,Packagist,RubyGems,GitHub Actions,OSS-Fuzz'
|
||||||
|
VALID_ECOSYSTEM = re.compile(r'^[A-Za-z0-9._ -]{1,60}$')
|
||||||
|
VALID_ID = re.compile(r'^[A-Za-z0-9._-]{2,150}$')
|
||||||
|
|
||||||
|
def atomic_write(path, data):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if path.exists() and path.read_bytes() == data:
|
||||||
|
return False
|
||||||
|
tmp = path.with_name(path.name + '.tmp')
|
||||||
|
try:
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def fetch(url, etag=None, last_modified=None):
|
||||||
|
headers = {'User-Agent': 'vuln-osv-gitea-mirror/1.0'}
|
||||||
|
if etag:
|
||||||
|
headers['If-None-Match'] = etag
|
||||||
|
elif last_modified:
|
||||||
|
headers['If-Modified-Since'] = last_modified
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=180) as response:
|
||||||
|
# Download into a temp file; don't hold huge archives in RAM.
|
||||||
|
with tempfile.TemporaryFile() as temp:
|
||||||
|
shutil.copyfileobj(response, temp)
|
||||||
|
temp.seek(0)
|
||||||
|
body = temp.read()
|
||||||
|
return body, response.headers.get('ETag'), response.headers.get('Last-Modified')
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 304:
|
||||||
|
return None, etag, last_modified
|
||||||
|
if exc.code not in (408, 429, 500, 502, 503, 504) or attempt == 4:
|
||||||
|
raise
|
||||||
|
except (urllib.error.URLError, TimeoutError):
|
||||||
|
if attempt == 4:
|
||||||
|
raise
|
||||||
|
time.sleep(min(120, 3 * 2**attempt))
|
||||||
|
raise RuntimeError('Unreachable')
|
||||||
|
|
||||||
|
def process(ecosystem, state, allow_deletions):
|
||||||
|
if not VALID_ECOSYSTEM.fullmatch(ecosystem):
|
||||||
|
raise ValueError(f'Invalid ecosystem name: {ecosystem!r}')
|
||||||
|
url = f'{BASE}/{urllib.parse.quote(ecosystem)}/all.zip'
|
||||||
|
previous = state.get('ecosystems', {}).get(ecosystem, {})
|
||||||
|
body, etag, modified = fetch(url, previous.get('etag'), previous.get('last_modified'))
|
||||||
|
if body is None:
|
||||||
|
print(f'{ecosystem}: unchanged (304)', flush=True)
|
||||||
|
return False
|
||||||
|
# Validate all records before changing the working tree.
|
||||||
|
records = {}
|
||||||
|
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
||||||
|
for info in archive.infolist():
|
||||||
|
if info.is_dir() or not info.filename.endswith('.json'):
|
||||||
|
continue
|
||||||
|
if info.file_size > 32*1024*1024:
|
||||||
|
raise ValueError(f'OSV JSON too large: {info.filename}')
|
||||||
|
identifier = Path(info.filename).stem
|
||||||
|
if not VALID_ID.fullmatch(identifier):
|
||||||
|
raise ValueError(f'Unsafe ID: {identifier!r}')
|
||||||
|
content = archive.read(info)
|
||||||
|
entry = json.loads(content)
|
||||||
|
if not isinstance(entry, dict) or entry.get('id') != identifier:
|
||||||
|
raise ValueError(f'Invalid OSV record: {info.filename}')
|
||||||
|
if identifier in records and records[identifier] != content:
|
||||||
|
raise ValueError(f'Duplicate OSV ID: {identifier}')
|
||||||
|
records[identifier] = content
|
||||||
|
if not records:
|
||||||
|
raise ValueError('Empty OSV archive: refusing to delete data')
|
||||||
|
directory = ROOT / 'data' / ecosystem
|
||||||
|
existing = {p.stem: p for p in directory.glob('*.json')}
|
||||||
|
written = sum(atomic_write(directory / f'{ident}.json', raw) for ident, raw in records.items())
|
||||||
|
deleted = 0
|
||||||
|
if allow_deletions:
|
||||||
|
for ident, path in existing.items():
|
||||||
|
if ident not in records:
|
||||||
|
path.unlink()
|
||||||
|
deleted += 1
|
||||||
|
state.setdefault('ecosystems', {})[ecosystem] = {
|
||||||
|
'etag': etag, 'last_modified': modified,
|
||||||
|
'records': len(records), 'last_success': datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
print(f'{ecosystem}: {len(records)} records, {written} written, {deleted} deleted', flush=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--ecosystems', default=os.getenv('OSV_ECOSYSTEMS', DEFAULT))
|
||||||
|
parser.add_argument('--allow-deletions', action='store_true', help='Remove records no longer in verified full snapshots')
|
||||||
|
args = parser.parse_args()
|
||||||
|
ecosystems = [s.strip() for s in args.ecosystems.split(',') if s.strip()]
|
||||||
|
if len(set(ecosystems)) != len(ecosystems):
|
||||||
|
raise ValueError('Duplicate ecosystem')
|
||||||
|
state_path = ROOT / 'state' / 'sync.json'
|
||||||
|
state = json.loads(state_path.read_text()) if state_path.exists() else {'version': 1, 'ecosystems': {}}
|
||||||
|
for ecosystem in ecosystems:
|
||||||
|
# Persist each successfully downloaded ecosystem. A retry resumes the rest.
|
||||||
|
if process(ecosystem, state, args.allow_deletions):
|
||||||
|
atomic_write(state_path, (json.dumps(state, ensure_ascii=False, sort_keys=True, indent=2) + '\n').encode())
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
state_file = root / 'state/sync.json'
|
||||||
|
if not state_file.exists():
|
||||||
|
sys.exit('No OSV sync state')
|
||||||
|
state = json.loads(state_file.read_text())
|
||||||
|
for ecosystem, metadata in state.get('ecosystems', {}).items():
|
||||||
|
files = list((root / 'data' / ecosystem).glob('*.json'))
|
||||||
|
if len(files) < 1:
|
||||||
|
sys.exit(f'Empty ecosystem: {ecosystem}')
|
||||||
|
# Files are checked on import; spot-check retained data here to avoid reparsing millions.
|
||||||
|
for path in files[:10]:
|
||||||
|
item = json.loads(path.read_text())
|
||||||
|
if item.get('id') != path.stem:
|
||||||
|
sys.exit(f'Invalid {path}')
|
||||||
|
print(ecosystem, len(files), 'records')
|
||||||
|
print('Validation OK')
|
||||||
Reference in New Issue
Block a user