Initial Status

This commit is contained in:
root
2026-11-11 21:12:47 +01:00
commit 63f938a94e
6 changed files with 248 additions and 0 deletions
+30
View File
@@ -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
+7
View File
@@ -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)
+19
View File
@@ -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.
Binary file not shown.
+176
View File
@@ -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()
+16
View File
@@ -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"}
]
}