Initial Oracle security mirror
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.tmp
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user