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