From 49df8e71594b26673f9a546a4cd0f8b40fc2c977 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Nov 2026 21:39:03 +0100 Subject: [PATCH] Initial Oracle security mirror --- .gitea/workflows/update.yml | 59 +++++++++++++++++ .gitignore | 3 + README.md | 50 ++++++++++++++ scripts/sync.py | 129 ++++++++++++++++++++++++++++++++++++ scripts/validate.py | 23 +++++++ 5 files changed, 264 insertions(+) create mode 100644 .gitea/workflows/update.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 scripts/sync.py create mode 100644 scripts/validate.py diff --git a/.gitea/workflows/update.yml b/.gitea/workflows/update.yml new file mode 100644 index 0000000..b87c80b --- /dev/null +++ b/.gitea/workflows/update.yml @@ -0,0 +1,59 @@ +name: Oracle Security Mirror + +on: + workflow_dispatch: + schedule: + - cron: "11 */6 * * *" + +permissions: + contents: write + +jobs: + mirror: + runs-on: vuln-runner + timeout-minutes: 180 + steps: + - name: Synchronisieren im persistenten Git-Checkout + env: + GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }} + CACHE_ROOT: /var/lib/act_runner/cache/vuln-oracle + REPO_URL: https://git.apex-project.de/VULN/vuln-oracle.git + shell: bash + run: | + set -Eeuo pipefail + umask 077 + : "${GITEA_JOB_TOKEN:?GITEA_TOKEN fehlt oder ist leer}" + mkdir -p "$CACHE_ROOT" + exec 9>"$CACHE_ROOT/.sync.lock" + flock -w 300 9 || { echo 'Ein anderer Lauf ist noch aktiv' >&2; exit 1; } + ASKPASS_FILE=$(mktemp "$CACHE_ROOT/.askpass.XXXXXX") + trap 'rm -f "$ASKPASS_FILE"' EXIT + cat > "$ASKPASS_FILE" <<'SH' + #!/bin/sh + case "$1" in + *Username*) printf '%s\n' 'oauth2' ;; + *Password*) printf '%s\n' "$GITEA_JOB_TOKEN" ;; + *) exit 1 ;; + esac + SH + chmod 700 "$ASKPASS_FILE" + export GIT_ASKPASS="$ASKPASS_FILE" GIT_TERMINAL_PROMPT=0 + if [ ! -d "$CACHE_ROOT/repo/.git" ]; then + git clone --depth 1 --branch main "$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 + git clean -fd -- data state + 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 Datenänderungen' + exit 0 + fi + git commit -m "Update Oracle advisories $(date -u +%Y-%m-%dT%H:%M:%SZ)" + git push origin HEAD:main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6a221a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..28d7882 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# vuln-oracle – Oracle Security Advisory Mirror + +Öffentliche Oracle Security Advisories ab 2017 als **unveränderte HTML-Rohdokumente** plus **von Oracle verlinkte CSAF-JSON-Risikomatrizen**, wo vorhanden. + +## Datenquellen +- https://www.oracle.com/security-alerts/ +- https://www.oracle.com/security-alerts/cpuarchive.html + +Erfasst CPUs, CSPUs, Security Alerts und Solaris Third Party Bulletins, sofern in den beiden Übersichtsseiten mit Jahresangabe gelistet. Ältere Advisories haben nicht zwingend CSAF-JSON. My Oracle Support (Login/Patches) wird nicht abgerufen. + +## Einrichtung + +1. In Gitea unter `VULN` das leere Repository `vuln-oracle` anlegen. +2. Dateien hochladen (`git init`, `git branch -M main`, `git add .`, `git commit ...`, `git remote add origin ...`, `git push -u origin main`). +3. Auf der Gitea-VM als root: + +```bash +apt-get install -y python3 git util-linux +install -d -o act_runner -g act_runner -m 700 /var/lib/act_runner/cache/vuln-oracle +``` + +4. Gitea Actions für dieses Repository aktivieren und mit dem vorhandenen Host-Runner `vuln-runner` starten. Zeitplan: alle 6 Stunden um Minute 11 (UTC, soweit Gitea entsprechend konfiguriert). + +Der Workflow nutzt das automatisch bereitgestellte `secrets.GITEA_TOKEN`. Das Secret muss im Job vorhanden sein und `contents: write` erhalten; andernfalls die Variable in der YAML auf den eigenen Token-Namen ändern. + +## Layout + +```text +data/index/ # Originale Übersichtsseiten + data/advisories/YYYY/ # Originale Oracle HTML-Advisories + data/csaf/YYYY/ # Originale CSAF JSON (nur wo verlinkt) + state/catalog.json # Indizierte Advisory-Liste +``` + +## Verhalten + +- Alle 6 Stunden werden **alle gelisteten** Dokumente erneut per HTTP abgerufen und nur bei veränderten Bytes in Git geschrieben. +- Das ist ein inkrementeller **Git-Commit**, aber **kein HTTP-Delta-Download**. Der Oracle-Datenumfang ist deutlich kleiner als OSV/NVD. +- Link- oder Download-Fehler führen zum Abbruch ohne Commit. Fehlende historische Dokumente werden nicht automatisch gelöscht. +- Die Discovery prüft einen Mindestumfang; bei geändertem HTML-Layout wird kein vermeintlich erfolgreicher leerer Sync veröffentlicht. +- Enthält *keine* kommerziellen Patches oder interne MOS-Dokumente. Oracle-Lizenz-/Nutzungsbedingungen vor einer öffentlichen Weiterverteilung der Rohdaten prüfen. + +## Lokal testen + +```bash +python3 scripts/sync.py --since-year 2017 +python3 scripts/validate.py +``` + +Hinweis: Die URL-Strukturen und die HTML/CSAF-Verknüpfung sind geprüft. Vollständige Live-Läufe und Git-Pushes müssen auf der eigenen Gitea-VM getestet werden. diff --git a/scripts/sync.py b/scripts/sync.py new file mode 100644 index 0000000..0a71501 --- /dev/null +++ b/scripts/sync.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Mirror public Oracle security advisories (HTML and linked CSAF JSON).""" +import argparse +import hashlib +from html.parser import HTMLParser +import json +import os +from pathlib import Path +import re +import time +from urllib.parse import urljoin, urlparse +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +BASE = 'https://www.oracle.com' +INDEX = BASE + '/security-alerts/' +ARCHIVE = BASE + '/security-alerts/cpuarchive.html' +UA = 'vuln-oracle-mirror/1.0 (+security data mirror; contact site administrator)' +YEAR = re.compile(r'\b(20\d{2})\b') + +class Links(HTMLParser): + def __init__(self): + super().__init__() + self.links=[]; self.href=None; self.parts=[] + def handle_starttag(self, tag, attrs): + if tag=='a': + self.href=dict(attrs).get('href'); self.parts=[] + def handle_data(self, data): + if self.href is not None: self.parts.append(data) + def handle_endtag(self,tag): + if tag=='a' and self.href is not None: + self.links.append((self.href,' '.join(''.join(self.parts).split()))) + self.href=None; self.parts=[] + +def get(url, tries=4): + last=None + for i in range(tries): + try: + with urlopen(Request(url, headers={'User-Agent':UA,'Accept':'text/html,application/json,*/*'}), timeout=90) as response: + body=response.read(30_000_001) + if len(body)>30_000_000: raise ValueError('Document too large') + return body + except (HTTPError, URLError, TimeoutError) as exc: + last=exc + if isinstance(exc,HTTPError) and exc.code in (400,401,403,404): break + time.sleep(min(2**i,12)) + raise RuntimeError(f'Failed to fetch {url}: {last}') + +def parse_links(data,url): + p=Links(); p.feed(data.decode('utf-8','replace')) + return [(urljoin(url,href),label) for href,label in p.links] + +def allowed(url): + return urlparse(url).hostname in ('www.oracle.com','oracle.com') and url.startswith('https://') + +def discover(index_html, archive_html, since): + found={} + for page,src in ((index_html,INDEX),(archive_html,ARCHIVE)): + for url,label in parse_links(page,src): + if not allowed(url): continue + path=urlparse(url).path.lower() + if not path.startswith('/security-alerts/') or not path.endswith('.html'): continue + if path.endswith(('cpuarchive.html','index.html')): continue + title=label.lower() + if not any(x in title for x in ('critical patch update','critical security patch update','security alert','solaris third party bulletin')): continue + # Years are from the advisory title, not CVE numbers or page modification date. + years=[int(y) for y in YEAR.findall(label)] + if not years or max(years)= 25, 'Incomplete discovery' + assert len(catalog['advisories'])==catalog['advisory_count'] + for item in catalog['advisories']: + name=item['url'].split('/')[-1] + p=root/'data/advisories'/str(item['year'])/name + raw=p.read_bytes().lower() + assert b'1 else '.') + except Exception as exc: print(f'INVALID: {exc}',file=sys.stderr); sys.exit(1)