Initial Oracle security mirror

This commit is contained in:
root
2026-11-10 21:39:03 +01:00
commit 49df8e7159
5 changed files with 264 additions and 0 deletions
+59
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
*.tmp
+50
View File
@@ -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.
+129
View File
@@ -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)<since: continue
found[url]={'url':url,'title':label,'year':max(years)}
return sorted(found.values(),key=lambda v:v['url'])
def csaf_links(html,url):
result=[]
for link,label in parse_links(html,url):
if not allowed(link): continue
path=urlparse(link).path.lower()
if path.endswith('.json') and ('csaf' in label.lower() or 'csaf' in path):
result.append(link)
return sorted(set(result))
def write_changed(path,contents):
path.parent.mkdir(parents=True,exist_ok=True)
if path.exists() and path.read_bytes()==contents: return False
temp=path.with_name(path.name+'.tmp')
temp.write_bytes(contents)
os.replace(temp,path)
return True
def run(root,since,delay):
root=Path(root)
index=get(INDEX); archive=get(ARCHIVE)
items=discover(index,archive,since)
if len(items)<25:
raise RuntimeError(f'Only {len(items)} advisories discovered: page structure may have changed; refusing incomplete sync')
changed=0; errors=[]; csaf_count=0
changed+=write_changed(root/'data/index/security-alerts.html',index)
changed+=write_changed(root/'data/index/cpuarchive.html',archive)
for i,item in enumerate(items,1):
url=item['url']; filename=urlparse(url).path.rsplit('/',1)[-1]
target=root/'data/advisories'/str(item['year'])/filename
try:
html=get(url)
if b'<html' not in html.lower() and b'<!doctype html' not in html.lower():
raise ValueError('Unexpected non-HTML response')
links=csaf_links(html,url)
# Fetch JSON before committing HTML, fail full run if a linked CSAF is unreachable.
for csaf in links:
raw=get(csaf); doc=json.loads(raw)
if not isinstance(doc,dict): raise ValueError('CSAF must be object')
filename_json=urlparse(csaf).path.rsplit('/',1)[-1]
csaf_path=root/'data/csaf'/str(item['year'])/filename_json
changed+=write_changed(csaf_path,raw)
csaf_count+=1
changed+=write_changed(target,html)
print(f'[{i}/{len(items)}] {filename}: {len(links)} CSAF',flush=True)
except Exception as e:
errors.append(f'{url}: {e}')
print(f'ERROR {url}: {e}',flush=True)
if delay: time.sleep(delay)
if errors:
raise RuntimeError(f'{len(errors)} advisory errors; sync state not updated. First: {errors[0]}')
state={'source':INDEX,'since_year':since,'advisory_count':len(items),'csaf_links_found':csaf_count,
'advisories':items}
changed+=write_changed(root/'state/catalog.json',(json.dumps(state,ensure_ascii=False,indent=2)+'\n').encode())
print(f'Complete: {len(items)} advisories, {csaf_count} CSAF links, {changed} changed files')
if __name__=='__main__':
p=argparse.ArgumentParser(); p.add_argument('--root',default='.'); p.add_argument('--since-year',type=int,default=2017); p.add_argument('--delay',type=float,default=0.2)
a=p.parse_args(); run(a.root,a.since_year,a.delay)
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
import json
from pathlib import Path
import sys
def validate(root):
root=Path(root)
catalog=json.loads((root/'state/catalog.json').read_text())
assert catalog['advisory_count'] >= 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'<html' in raw or b'<!doctype html' in raw, str(p)
for p in (root/'data/csaf').rglob('*.json') if (root/'data/csaf').exists() else []:
doc=json.loads(p.read_bytes())
assert isinstance(doc,dict),str(p)
print(f"Validated {catalog['advisory_count']} Oracle advisories")
if __name__=='__main__':
try: validate(sys.argv[1] if len(sys.argv)>1 else '.')
except Exception as exc: print(f'INVALID: {exc}',file=sys.stderr); sys.exit(1)