From 63f938a94ea624d8ceb225dea9d5c030fe21b0ad Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Nov 2026 21:12:47 +0100 Subject: [PATCH] Initial Status --- .gitea/workflows/update-status.yml | 30 +++ README.md | 7 + SETUP.md | 19 ++ .../__pycache__/update_status.cpython-313.pyc | Bin 0 -> 13328 bytes scripts/update_status.py | 176 ++++++++++++++++++ sources.json | 16 ++ 6 files changed, 248 insertions(+) create mode 100644 .gitea/workflows/update-status.yml create mode 100644 README.md create mode 100644 SETUP.md create mode 100644 scripts/__pycache__/update_status.cpython-313.pyc create mode 100644 scripts/update_status.py create mode 100644 sources.json diff --git a/.gitea/workflows/update-status.yml b/.gitea/workflows/update-status.yml new file mode 100644 index 0000000..c04cb87 --- /dev/null +++ b/.gitea/workflows/update-status.yml @@ -0,0 +1,30 @@ +name: Vulnerability Status Dashboard + +on: + workflow_dispatch: + schedule: + - cron: '*/15 * * * *' + +jobs: + update: + runs-on: status + steps: + - name: Dashboard aktualisieren + env: + GITEA_URL: ${{ gitea.server_url }} + GITEA_API_TOKEN: ${{ secrets.STATUS_API_TOKEN }} + STATUS_REPOSITORY: status + STATUS_OWNER: VULN + run: | + python --version + python - <<'PY' + import base64, json, os, urllib.request + # Read script via authenticated Gitea raw-content API if the repository is private. + # Use contents API instead for consistent authentication across versions. + api = os.environ['GITEA_URL'].rstrip('/') + '/api/v1/repos/' + os.environ['STATUS_OWNER'] + '/' + os.environ['STATUS_REPOSITORY'] + '/contents/' + req = urllib.request.Request(api + 'scripts/update_status.py?ref=main',headers={'Authorization':'token '+os.environ['GITEA_API_TOKEN']}) + with urllib.request.urlopen(req,timeout=30) as resp: + payload=json.load(resp) + code=base64.b64decode(payload['content']).decode('utf-8') + exec(compile(code,'update_status.py','exec'),{'__name__':'__main__'}) + PY diff --git a/README.md b/README.md new file mode 100644 index 0000000..591552c --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Vulnerability Database – Sync Status + +Das Dashboard wird nach dem ersten Start des Workflows automatisch befüllt. + +**Einrichtung:** Siehe `SETUP.md`. Die Quellen stehen in `sources.json`. + +[Schwachstellendatenbank](https://vulnerability.apex-project.de) diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..563ff45 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,19 @@ +# Einrichtung in Gitea + +1. Repository `VULN/status` mit Branch `main` erstellen und alle Dateien dieses Pakets (einschließlich `.gitea/workflows/`) hochladen. +2. `sources.json` bearbeiten: Repository-Namen und Workflow-Dateinamen (z. B. `sync.yml`) müssen wirklich existieren. Nicht vorhandene Quellen löschen. `max_age_hours` pro Quelle anpassbar. +3. Einen Gitea Personal Access Token mit **Lesezugriff auf Actions aller überwachten Repositories** sowie **Schreibzugriff auf Code im Repository `VULN/status`** erstellen. Bei privaten Repositories müssen passende Zugriffsrechte bestehen. In `VULN/status` unter **Settings → Actions → Secrets** als `STATUS_API_TOKEN` speichern. +4. Dem gewünschten Docker-Runner Label `status:docker://python:3.12-bookworm` zuweisen (z. B. `GITEA_RUNNER_LABELS` um `,status:docker://python:3.12-bookworm` erweitern) und Runner neu starten. Damit laufen die Status-Jobs im Python-Container. +5. Actions für `VULN/status` aktivieren, Workflow `Vulnerability Status Dashboard` manuell starten. Danach alle 15 Minuten automatischer Lauf. +6. README unter `VULN/status` anschauen. + +## Wichtige Hinweise + +- Der API-Endpunkt ist `/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/runs`. +- Der Job lädt das Skript über die Gitea-Contents-API; `actions/checkout` ist nicht nötig. +- Der Token ist ein Secret, niemals in die Repository-Dateien schreiben. +- Lege `STATUS_API_TOKEN` mit minimal nötigen Rechten an. Die Schreibrechte auf `VULN/status` sind für den README-Commit nötig. +- Der Workflow verwendet `gitea.server_url` als Gitea-Basis-URL. Bei Reverse-Proxy-Installationen auf korrekte `ROOT_URL` achten. +- Der Status prüft den letzten Workflow pro Quelle und den letzten erfolgreichen Lauf. Ein alter Erfolg gilt als überfällig. +- Bei API-Fehlern wird eine Fehlermeldung im Dashboard angezeigt und der Aktualisierungsjob schlägt fehl. +- Die Ausführung muss Zugang zur Gitea-API haben und DNS/TLS korrekt auflösen. diff --git a/scripts/__pycache__/update_status.cpython-313.pyc b/scripts/__pycache__/update_status.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75082da6cb30866c9a46d81b704a846f3973b19d GIT binary patch literal 13328 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 literal 0 HcmV?d00001 diff --git a/scripts/update_status.py b/scripts/update_status.py new file mode 100644 index 0000000..ac7e948 --- /dev/null +++ b/scripts/update_status.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Gitea Actions status dashboard. Standard library only; reads config from same repo.""" +import base64 +import datetime as dt +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from zoneinfo import ZoneInfo + +BASE = os.environ['GITEA_URL'].rstrip('/') +API = BASE + '/api/v1' +TOKEN = os.environ['GITEA_API_TOKEN'] +OWNER = os.environ.get('STATUS_OWNER', 'VULN') +STATUS_REPO = os.environ.get('STATUS_REPOSITORY', 'status') +BRANCH = os.environ.get('STATUS_BRANCH', 'main') +NOW = dt.datetime.now(dt.timezone.utc) + + +def req(path, method='GET', data=None): + body = json.dumps(data).encode() if data is not None else None + request = urllib.request.Request( + API + path, data=body, method=method, + headers={'Authorization': 'token ' + TOKEN, 'Accept': 'application/json', + 'Content-Type': 'application/json', 'User-Agent': 'vuln-status/1.0'}) + try: + with urllib.request.urlopen(request, timeout=35) as r: + return json.load(r) + except urllib.error.HTTPError as e: + if e.code == 404: + raise FileNotFoundError(f'HTTP 404: {path}') from e + err = e.read(500).decode(errors='replace') + raise RuntimeError(f'HTTP {e.code} {path}: {err}') from e + + +def content_path(filename): + return '/repos/{}/{}/contents/{}?ref={}'.format( + urllib.parse.quote(OWNER, safe=''), + urllib.parse.quote(STATUS_REPO, safe=''), + urllib.parse.quote(filename, safe='/'), + urllib.parse.quote(BRANCH, safe='')) + + +def get_content(filename): + obj = req(content_path(filename)) + return base64.b64decode(obj['content']).decode('utf-8'), obj['sha'] + + +def parse_time(raw): + if not raw or raw.startswith('0001-'): + return None + try: + return dt.datetime.fromisoformat(raw.replace('Z', '+00:00')).astimezone(dt.timezone.utc) + except (ValueError, AttributeError): + return None + + +def fmt(value): + if value is None: + return '–' + local = value.astimezone(ZoneInfo('Europe/Berlin')) + return local.strftime('%d.%m.%Y %H:%M %Z') + + +def elapsed(start, end): + if not start or not end or end < start: + return '–' + sec = int((end - start).total_seconds()) + return f'{sec // 3600}h {sec % 3600 // 60}m' if sec >= 3600 else f'{sec // 60}m {sec % 60:02d}s' + + +def safe_cell(text): + return str(text).replace('|', '\\|').replace('\n', ' ') + + +def api_runs(owner, repo, workflow, page, limit=50): + # Workflow-scoped API: avoids misreading another workflow in the repository. + path = '/repos/{}/{}/actions/workflows/{}/runs?branch={}&page={}&limit={}'.format( + *(urllib.parse.quote(x, safe='') for x in (owner, repo, workflow)), + urllib.parse.quote(BRANCH, safe=''), page, limit) + return req(path).get('workflow_runs', []) + + +def source_status(cfg, source): + owner = source.get('owner', cfg['owner']) + repo = source['repo'] + workflow = source['workflow'] + limit_age = dt.timedelta(hours=source.get('max_age_hours', cfg.get('max_age_hours', 26))) + all_runs = [] + # Up to 250 runs; sufficient for typical daily sources, can be adjusted. + for page in range(1, 6): + chunk = api_runs(owner, repo, workflow, page) + all_runs.extend(chunk) + if len(chunk) < 50: + break + all_runs.sort(key=lambda r: (parse_time(r.get('created_at')) or dt.datetime.min.replace(tzinfo=dt.timezone.utc), r.get('id') or 0), reverse=True) + latest = all_runs[0] if all_runs else None + success = next((r for r in all_runs if (r.get('conclusion') or '').lower() == 'success'), None) + latest_success = parse_time(success.get('completed_at')) or parse_time(success.get('created_at')) if success else None + if not latest: + state = '⚪ Kein Lauf' + elif (latest.get('status') or '').lower() in ('in_progress', 'running'): + state = '🔵 Läuft' + elif (latest.get('status') or '').lower() in ('queued', 'pending', 'requested', 'waiting'): + state = '🟡 Wartet' + elif (latest.get('conclusion') or '').lower() == 'success': + state = '🟢 Erfolgreich' + elif (latest.get('conclusion') or '').lower() in ('failure', 'timed_out', 'startup_failure'): + state = '🔴 Fehlgeschlagen' + elif (latest.get('conclusion') or '').lower() in ('cancelled', 'skipped'): + state = '⚪ ' + latest['conclusion'] + else: + state = '🟡 ' + str(latest.get('status') or 'Unbekannt') + if latest_success is None or NOW - latest_success > limit_age: + state += ' / ⚠️ Überfällig' + started = parse_time(latest.get('started_at')) if latest else None + ended = parse_time(latest.get('completed_at')) if latest else None + latest_url = latest.get('html_url') if latest else None + link = f'[#{latest["id"]}]({latest_url})' if latest_url and latest else '–' + return state, fmt(latest_success), elapsed(started, ended), link + + +def main(): + config_text, _ = get_content('sources.json') + cfg = json.loads(config_text) + rows = [] + errors = [] + for src in cfg['sources']: + owner = src.get('owner', cfg['owner']) + repo = src['repo'] + workflow = src['workflow'] + name = src['name'] + url = BASE + '/' + urllib.parse.quote(owner) + '/' + urllib.parse.quote(repo) + '/actions' + try: + state, last_success, duration, link = source_status(cfg, src) + except Exception as exc: + state, last_success, duration, link = '❌ API-Fehler', '–', '–', '–' + errors.append(f'{repo}: {exc}') + rows.append(f'| [{safe_cell(name)}]({url}) | {state} | {last_success} | {duration} | {link} |') + print(f'{repo}: {state}', flush=True) + content = ('# Vulnerability Database – Sync Status\n\n' + 'Automatisch generiertes Dashboard für Gitea Actions.\n\n' + f'**Letzte Prüfung:** {NOW.strftime("%d.%m.%Y %H:%M UTC")} \n' + f'**Überfällig:** Kein erfolgreicher Lauf innerhalb von {cfg.get("max_age_hours", 26)} Stunden ' + '(pro Quelle anpassbar).\n\n' + '| Datenquelle | Aktueller Status | Letzter Erfolg | Dauer letzter Lauf | Letzter Job |\n' + '|---|---|---|---|---|\n' + '\n'.join(rows) + '\n\n' + '## Hinweise\n\n' + '- 🔴 Fehlgeschlagen: Der neueste Workflow ist gescheitert.\n' + '- ⚠️ Überfällig: Es gibt keinen ausreichend aktuellen erfolgreichen Lauf, auch wenn der letzte alte Lauf grün war.\n' + '- 🔵 Läuft / 🟡 Wartet: Der neueste Lauf ist noch nicht abgeschlossen.\n' + '- ❌ API-Fehler: Quelle, Workflowname, Tokenrechte oder API prüfen.\n' + '- Die Dauer bezieht sich auf den zuletzt gestarteten Lauf, soweit Start- und Endzeit vorliegen.\n' + '- Status und Zeitangaben werden alle 15 Minuten oder manuell aktualisiert.\n\n' + '## Links\n\n' + '- [Vulnerability Database](https://vulnerability.apex-project.de)\n' + '- [Projektwebsite](https://www.apex-project.de)\n') + existing, sha = get_content('README.md') + if existing == content: + print('README unverändert.') + else: + path = '/repos/{}/{}/contents/README.md'.format(urllib.parse.quote(OWNER), urllib.parse.quote(STATUS_REPO)) + req(path, 'PUT', {'branch': BRANCH, 'sha': sha, + 'content': base64.b64encode(content.encode()).decode(), + 'message': 'docs: update vulnerability sync status [skip ci]'}) + print('README aktualisiert.') + if errors: + for error in errors: + print('ERROR: ' + error, file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/sources.json b/sources.json new file mode 100644 index 0000000..3bb5a24 --- /dev/null +++ b/sources.json @@ -0,0 +1,16 @@ +{ + "owner": "VULN", + "branch": "main", + "max_age_hours": 26, + "sources": [ + {"name": "CVE Project", "repo": "vuln-cve", "workflow": "sync.yml"}, + {"name": "NVD", "repo": "vuln-nvd", "workflow": "sync.yml"}, + {"name": "OSV", "repo": "vuln-osv", "workflow": "sync.yml"}, + {"name": "AlmaLinux", "repo": "vuln-almalinux", "workflow": "sync.yml"}, + {"name": "Rocky Linux", "repo": "vuln-rocky", "workflow": "sync.yml"}, + {"name": "Red Hat", "repo": "vuln-redhat", "workflow": "sync.yml"}, + {"name": "Microsoft", "repo": "vuln-microsoft", "workflow": "sync.yml"}, + {"name": "CISA KEV", "repo": "vuln-cisa-kev", "workflow": "sync.yml"}, + {"name": "BSI", "repo": "vuln-bsi", "workflow": "sync.yml"} + ] +}