Initial Red Hat CSAF mirror
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
name: Red Hat CSAF Mirror
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '53 */4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: redhat-mirror
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: vuln-runner
|
||||||
|
timeout-minutes: 240
|
||||||
|
env:
|
||||||
|
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_REPO_URL: https://git.apex-project.de/VULN/vuln-redhat.git
|
||||||
|
VULN_CACHE_BASE: /var/lib/act_runner/cache/vuln-redhat
|
||||||
|
steps:
|
||||||
|
- name: Red Hat CSAF mirror
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
BASE="$VULN_CACHE_BASE"
|
||||||
|
mkdir -p "$BASE"
|
||||||
|
if [ ! -f "$BASE/repo/scripts/run-cached.sh" ]; then
|
||||||
|
AUTH="Authorization: Basic $(printf 'x-access-token:%s' "$GITEA_JOB_TOKEN" | base64 | tr -d '\n')"
|
||||||
|
git -c "http.extraHeader=$AUTH" clone --depth 1 --branch main "$GITEA_REPO_URL" "$BASE/repo"
|
||||||
|
fi
|
||||||
|
bash "$BASE/repo/scripts/run-cached.sh"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.tmp
|
||||||
|
.env
|
||||||
|
.cache/
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# vuln-redhat – Red Hat CSAF mirror
|
||||||
|
|
||||||
|
Mirrors the **original Red Hat CSAF JSON** from [the official advisory feed](https://security.access.redhat.com/data/csaf/v2/advisories/). The repository retains advisories by directory year starting in 2017. No transformation is applied to the JSON bytes. The authoritative `index.txt` is fetched every run; `changes.csv` is used as an update hint, and a rotating set of 250 entries is always rechecked as additional reconciliation.
|
||||||
|
|
||||||
|
## Server installation (Debian/Ubuntu, host-based Gitea runner)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3 git util-linux ca-certificates
|
||||||
|
sudo install -d -o act_runner -g act_runner -m 700 /var/lib/act_runner/cache/vuln-redhat
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `VULN/vuln-redhat` in Gitea and push the contents to `main`. The workflow runs on the already-registered **`vuln-runner`**. It uses the built-in `${{ secrets.GITEA_TOKEN }}`; no additional PAT is required if this already works for other repos. Allow job token repository write access.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Open `https://git.apex-project.de/VULN/vuln-redhat/actions`, select **Red Hat CSAF Mirror**, and run manually. Subsequent runs are scheduled every four hours at minute 53 (Gitea scheduler time zone).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Direct sync for development, without Git:
|
||||||
|
python3 scripts/sync.py --since-year 2017
|
||||||
|
python3 scripts/validate.py
|
||||||
|
|
||||||
|
# Only first 5 records, without advancing stored state:
|
||||||
|
python3 scripts/sync.py --limit 5 --rotate 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/csaf/2017/rhsa-2017_001.json
|
||||||
|
...
|
||||||
|
state/sync.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior and limitations
|
||||||
|
|
||||||
|
- The first sync can download tens of thousands of JSON documents and create a very large Git commit; leave enough disk space and allow several hours.
|
||||||
|
- New advisory files are discovered using `index.txt`. Changed entries are identified opportunistically via `changes.csv` plus a rotating audit. **This is not a guaranteed near-real-time change feed**: a historical advisory can take multiple runs to be revisited if it is not recognizable in the changes feed.
|
||||||
|
- Files missing from a successfully retrieved authoritative index are pruned only when the count is below an explicit mass-deletion safety threshold. A suspicious deletion count fails the job instead.
|
||||||
|
- Any HTTP/JSON error stops the run, and neither the state nor Git commit advances. The persistent checkout is retained for investigation on failures; after fixing, revert only unintended local edits before re-running.
|
||||||
|
- Run jobs on trusted branches only, because a host runner executes scripts as its local Linux account.
|
||||||
|
- License: Red Hat security data is CC BY 4.0. Preserve attribution and link to the original source if you redistribute it.
|
||||||
|
- Full live download is not part of the provided local tests.
|
||||||
|
|
||||||
|
Source: https://access.redhat.com/security/data
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
BASE="${VULN_CACHE_BASE:-/var/lib/act_runner/cache/vuln-redhat}"
|
||||||
|
REPO="$BASE/repo"
|
||||||
|
URL="${GITEA_REPO_URL:-https://git.apex-project.de/VULN/vuln-redhat.git}"
|
||||||
|
mkdir -p "$BASE"
|
||||||
|
exec 9>"$BASE/.sync.lock"
|
||||||
|
flock -w 120 9 || { echo 'Another run holds the Red Hat lock' >&2; exit 1; }
|
||||||
|
: "${GITEA_JOB_TOKEN:?Missing GITEA_JOB_TOKEN secret}"
|
||||||
|
AUTH="Authorization: Basic $(printf 'x-access-token:%s' "$GITEA_JOB_TOKEN" | base64 | tr -d '\n')"
|
||||||
|
git_auth() { git -c "http.extraHeader=$AUTH" "$@"; }
|
||||||
|
if [[ ! -d "$REPO/.git" ]]; then
|
||||||
|
git_auth clone --branch main --depth 1 "$URL" "$REPO"
|
||||||
|
fi
|
||||||
|
cd "$REPO"
|
||||||
|
git remote set-url origin "$URL"
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo 'Dirty cached checkout. Refusing to overwrite local data.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git_auth fetch --depth 1 origin main
|
||||||
|
git checkout main
|
||||||
|
git merge --ff-only FETCH_HEAD
|
||||||
|
python3 scripts/sync.py --since-year 2017 --rotate 250 --prune
|
||||||
|
python3 scripts/validate.py
|
||||||
|
git add -A data state
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo 'No changes'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git -c user.name='Vulnerability Bot' -c user.email='vulnerability-bot@users.noreply.local' commit -m "Red Hat CSAF update $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
git_auth push origin HEAD:main
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mirror official Red Hat CSAF advisories, retaining the original JSON bytes."""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
BASE = 'https://security.access.redhat.com/data/csaf/v2/advisories/'
|
||||||
|
PATH_RE = re.compile(r'(?<![0-9a-z])(?:20\d{2})/(?:rhsa|rhba|rhea)-20\d{2}_[0-9a-z_-]+\.json', re.I)
|
||||||
|
NAME_RE = re.compile(r'\b(?:RHSA|RHBA|RHEA)[-:]\s*(20\d{2})[-:]([0-9]{1,5})\b', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_paths(raw, year):
|
||||||
|
out = set()
|
||||||
|
for line in raw.decode('utf-8-sig').splitlines():
|
||||||
|
v = line.strip().lstrip('/')
|
||||||
|
if PATH_RE.fullmatch(v) and int(v[:4]) >= year:
|
||||||
|
out.add(v.lower())
|
||||||
|
if not out:
|
||||||
|
raise ValueError('Index empty or incompatible format: refusing to synchronize')
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_changed(raw, known):
|
||||||
|
"""Parse path or advisory ID from change/deletion feed without assuming CSV columns."""
|
||||||
|
data = raw.decode('utf-8-sig', errors='replace')
|
||||||
|
exact = set()
|
||||||
|
name_map = {}
|
||||||
|
for p in known:
|
||||||
|
name_map.setdefault(p.rsplit('/', 1)[-1], set()).add(p)
|
||||||
|
for row in csv.reader(io.StringIO(data)):
|
||||||
|
for value in row:
|
||||||
|
for match in PATH_RE.finditer(value):
|
||||||
|
p = match.group(0).lower()
|
||||||
|
if p in known:
|
||||||
|
exact.add(p)
|
||||||
|
for match in NAME_RE.finditer(value):
|
||||||
|
kind = match.group(0).split('-')[0].split(':')[0].lower()
|
||||||
|
filename = f'{kind}-{match.group(1)}_{int(match.group(2)):03d}.json'
|
||||||
|
exact.update(name_map.get(filename, ()))
|
||||||
|
return exact
|
||||||
|
|
||||||
|
|
||||||
|
def get(url, attempts=5, timeout=90):
|
||||||
|
for i in range(attempts):
|
||||||
|
req = Request(url, headers={'User-Agent': 'vuln-redhat-mirror/1.0', 'Accept': 'application/json, text/plain, text/csv, */*'})
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=timeout) as response:
|
||||||
|
return response.read()
|
||||||
|
except (HTTPError, URLError, TimeoutError) as exc:
|
||||||
|
if isinstance(exc, HTTPError) and exc.code not in (408, 429, 500, 502, 503, 504):
|
||||||
|
raise
|
||||||
|
if i == attempts - 1:
|
||||||
|
raise
|
||||||
|
delay = min(45, 2 ** i * 3)
|
||||||
|
print(f'Network error {url}: {exc}; retry in {delay}s', file=sys.stderr)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise RuntimeError('Unreachable')
|
||||||
|
|
||||||
|
|
||||||
|
def write_if_changed(dst, content):
|
||||||
|
if dst.exists() and dst.read_bytes() == content:
|
||||||
|
return False
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = dst.with_suffix('.tmp')
|
||||||
|
tmp.write_bytes(content)
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sync(root, year=2017, rotate=250, prune=False, limit=0):
|
||||||
|
root = Path(root)
|
||||||
|
data_dir = root / 'data' / 'csaf'
|
||||||
|
state_path = root / 'state' / 'sync.json'
|
||||||
|
old = json.loads(state_path.read_text()) if state_path.exists() else {}
|
||||||
|
index_raw = get(BASE + 'index.txt')
|
||||||
|
paths = valid_paths(index_raw, year)
|
||||||
|
known = set(paths)
|
||||||
|
changed = set()
|
||||||
|
feed_status = {}
|
||||||
|
for feed in ('changes.csv', 'deletions.csv'):
|
||||||
|
try:
|
||||||
|
raw = get(BASE + feed)
|
||||||
|
feed_status[feed] = hashlib.sha256(raw).hexdigest()
|
||||||
|
if feed == 'changes.csv':
|
||||||
|
changed |= extract_changed(raw, known)
|
||||||
|
else:
|
||||||
|
# Deletions are handled through the authoritative index below.
|
||||||
|
# Preserve hash to observe feed changes in state.
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'Warning: {feed} not available: {exc}', file=sys.stderr)
|
||||||
|
feed_status[feed] = None
|
||||||
|
missing = {p for p in paths if not (data_dir / p).exists()}
|
||||||
|
cursor = int(old.get('rotation_cursor', 0)) % len(paths)
|
||||||
|
rotating = {paths[(cursor + n) % len(paths)] for n in range(min(rotate, len(paths)))}
|
||||||
|
todo = sorted(missing | changed | rotating)
|
||||||
|
if limit:
|
||||||
|
todo = todo[:limit]
|
||||||
|
count_new = count_changed = count_same = 0
|
||||||
|
for index, rel in enumerate(todo, 1):
|
||||||
|
raw = get(BASE + quote(rel, safe='/'))
|
||||||
|
record = json.loads(raw)
|
||||||
|
if not isinstance(record, dict) or not isinstance(record.get('document'), dict):
|
||||||
|
raise ValueError(f'Invalid CSAF structure: {rel}')
|
||||||
|
out = data_dir / rel
|
||||||
|
was_present = out.exists()
|
||||||
|
if write_if_changed(out, raw):
|
||||||
|
if was_present:
|
||||||
|
count_changed += 1
|
||||||
|
else:
|
||||||
|
count_new += 1
|
||||||
|
else:
|
||||||
|
count_same += 1
|
||||||
|
if index % 250 == 0:
|
||||||
|
print(f'Processed {index}/{len(todo)}')
|
||||||
|
removed = 0
|
||||||
|
if prune and not limit:
|
||||||
|
existing = [p for p in data_dir.glob('*/*.json') if int(p.parent.name) >= year]
|
||||||
|
doomed = [p for p in existing if f'{p.parent.name}/{p.name}'.lower() not in known]
|
||||||
|
if len(doomed) > max(100, int(len(existing) * .05)):
|
||||||
|
raise RuntimeError(f'Refusing unexpected mass deletion: {len(doomed)} paths')
|
||||||
|
for p in doomed:
|
||||||
|
p.unlink()
|
||||||
|
removed = len(doomed)
|
||||||
|
state = {
|
||||||
|
'schema': 1, 'source': BASE, 'since_year': year,
|
||||||
|
'last_success_utc': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'index_sha256': hashlib.sha256(index_raw).hexdigest(),
|
||||||
|
'index_count': len(paths), 'rotation_cursor': (cursor + (0 if limit else rotate)) % len(paths),
|
||||||
|
'feed_sha256': feed_status,
|
||||||
|
'last_run': {'downloaded': len(todo), 'new': count_new, 'updated': count_changed,
|
||||||
|
'unchanged': count_same, 'removed': removed, 'limited': bool(limit)}
|
||||||
|
}
|
||||||
|
# A limited developer run must not advance the durable synchronization status.
|
||||||
|
if not limit:
|
||||||
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_if_changed(state_path, (json.dumps(state, indent=2, ensure_ascii=False) + '\n').encode())
|
||||||
|
print(json.dumps(state['last_run'], sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--root', default='.')
|
||||||
|
parser.add_argument('--since-year', type=int, default=2017)
|
||||||
|
parser.add_argument('--rotate', type=int, default=250, help='Number of older files to recheck per run')
|
||||||
|
parser.add_argument('--prune', action='store_true', help='Remove files missing from index after safety checks')
|
||||||
|
parser.add_argument('--limit', type=int, default=0, help='Developer test: do not advance state')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.rotate < 0 or args.limit < 0:
|
||||||
|
parser.error('rotate and limit must not be negative')
|
||||||
|
sync(args.root, args.since_year, args.rotate, args.prune, args.limit)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate the local Red Hat CSAF mirror."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def validate(root):
|
||||||
|
root = Path(root)
|
||||||
|
state = root / 'state/sync.json'
|
||||||
|
if not state.exists():
|
||||||
|
raise RuntimeError('Sync state missing')
|
||||||
|
obj = json.loads(state.read_text())
|
||||||
|
files = list((root / 'data/csaf').glob('*/*.json'))
|
||||||
|
if not files:
|
||||||
|
raise RuntimeError('No CSAF documents present')
|
||||||
|
for path in files:
|
||||||
|
data = json.loads(path.read_bytes())
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get('document'), dict):
|
||||||
|
raise RuntimeError(f'Invalid CSAF document: {path}')
|
||||||
|
print(f'Validated {len(files)} Red Hat CSAF documents; source index entries: {obj["index_count"]}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument('--root', default='.')
|
||||||
|
validate(ap.parse_args().root)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||||
|
import sync
|
||||||
|
|
||||||
|
class RedHatSyncTests(unittest.TestCase):
|
||||||
|
def test_index_year_filter(self):
|
||||||
|
data = b'2016/rhsa-2016_001.json\n2017/rhsa-2017_001.json\n2026/rhsa-2026_012.json\n'
|
||||||
|
self.assertEqual(sync.valid_paths(data, 2017), ['2017/rhsa-2017_001.json', '2026/rhsa-2026_012.json'])
|
||||||
|
|
||||||
|
def test_changes_csv(self):
|
||||||
|
items = {'2025/rhsa-2025_012.json'}
|
||||||
|
self.assertEqual(sync.extract_changed(b'url,info\nhttps://example.test/2025/rhsa-2025_012.json,updated\n', items), items)
|
||||||
|
|
||||||
|
def test_first_sync_and_second_no_change(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
idx = b'2026/rhsa-2026_001.json\n'
|
||||||
|
doc = b'{"document":{"title":"RHSA"}}'
|
||||||
|
def fetch(url):
|
||||||
|
if url.endswith('index.txt'): return idx
|
||||||
|
if url.endswith('.csv'): return b''
|
||||||
|
return doc
|
||||||
|
with patch.object(sync, 'get', side_effect=fetch):
|
||||||
|
sync.sync(d, 2017, rotate=1)
|
||||||
|
sync.sync(d, 2017, rotate=1)
|
||||||
|
self.assertEqual((Path(d)/'data/csaf/2026/rhsa-2026_001.json').read_bytes(), doc)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user