From daf89ff8b9f756203e056983701872a795acde09 Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Tue, 9 Jun 2026 15:38:53 +0200 Subject: [PATCH] =?UTF-8?q?ci-ai:=20KI-Schicht=20f=C3=BCr=20die=20Gitea-CI?= =?UTF-8?q?=20(PR-Review,=20Security-Erkl=C3=A4rung,=20CI-Failure-Analyse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider-agnostische LLM-Anbindung (Anthropic jetzt, Jetson/Ollama später per Env-Var umschaltbar). Stdlib-only Skripte + Beispiel-Workflows. GitLab-Duo-Parität für self-hosted Gitea + act_runner. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + README.md | 71 ++++++++++++++++++++++ scripts/ai_explain.py | 71 ++++++++++++++++++++++ scripts/ai_review.py | 101 +++++++++++++++++++++++++++++++ scripts/context.py | 43 +++++++++++++ scripts/gitea_api.py | 70 +++++++++++++++++++++ scripts/llm.py | 92 ++++++++++++++++++++++++++++ workflows/ai-review.example.yml | 40 ++++++++++++ workflows/ci-failure.example.yml | 41 +++++++++++++ workflows/security.example.yml | 60 ++++++++++++++++++ 10 files changed, 592 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 scripts/ai_explain.py create mode 100644 scripts/ai_review.py create mode 100644 scripts/context.py create mode 100644 scripts/gitea_api.py create mode 100644 scripts/llm.py create mode 100644 workflows/ai-review.example.yml create mode 100644 workflows/ci-failure.example.yml create mode 100644 workflows/security.example.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..8af93d5 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# ci-ai — KI-Schicht für die Gitea-CI + +Self-hosted Äquivalent zu **GitLab Duo** für Gitea + act_runner: automatische +PR-Reviews, PR-Summaries, KI-erklärte Security-Funde und CI-Failure-Analyse. +Läuft über den vorhandenen `gitea-runner` auf dem Pi. + +## Bausteine + +| Feature | Skript | Trigger | +|---|---|---| +| A — PR-Review + Summary | `scripts/ai_review.py` | `pull_request` | +| B — Security-Scan + KI-Erklärung (hart blockierend) | `security.yml` + `scripts/ai_explain.py` | `pull_request`, `push` | +| C — CI-Failure-Analyse | `if:failure()`-Job + `scripts/ai_explain.py` | Job-Fehler | + +`scripts/llm.py` ist die **provider-agnostische** LLM-Schicht — heute Claude +(Anthropic), später der lokale Jetson (Ollama), umschaltbar per Env-Var. + +## Aufbau + +``` +scripts/ + llm.py Provider-Abstraktion (anthropic | ollama), stdlib-only + gitea_api.py PR-Diff holen + idempotente Marker-Kommentare + context.py Gitea-Actions-Event-Kontext (GITHUB_*-Vars) + ai_review.py Feature A + ai_explain.py Features B & C +workflows/ Beispiel-Workflows zum Kopieren in Ziel-Repos +``` + +Keine Abhängigkeiten — reine Python-Standardbibliothek, kein `pip install`. + +## In ein Repo einbauen + +1. Beispiel aus `workflows/` nach `.gitea/workflows/` im Ziel-Repo kopieren. +2. Im Repo (oder org-weit) die **Actions-Unit** aktivieren. +3. **Actions-Secret** `ANTHROPIC_API_KEY` setzen. Kommentiert wird per Auto-Token + (`${{ github.token }}`); reichen dessen Rechte nicht, ein `GITEA_BOT_TOKEN` + (PAT mit `write:issue`) als Secret hinterlegen. +4. Für „hart blockieren": in der Branch-Protection den `Security`-Check als + *required* markieren. + +## Umstieg auf den Jetson (später) + +Sobald das lokale Ollama auf dem Jetson läuft — nur die Workflow-Env ändern, +**kein Code-Change**: + +```yaml +AI_PROVIDER: ollama +AI_MODEL: qwen-light +AI_BASE_URL: http://192.168.178.20:11434/v1 +JETSON_API_KEY: ${{ secrets.JETSON_API_KEY }} +``` + +## Konfiguration (Env-Vars) + +| Var | Default | Zweck | +|---|---|---| +| `AI_PROVIDER` | `anthropic` | `anthropic` oder `ollama` | +| `AI_MODEL` | `claude-3-5-haiku-latest` / `qwen-light` | Modell | +| `ANTHROPIC_API_KEY` | — | Pflicht bei provider=anthropic | +| `AI_BASE_URL` | `http://192.168.178.20:11434/v1` | Ollama-Endpoint | +| `JETSON_API_KEY` | — | Bearer für Ollama (optional) | +| `GITEA_API` | `https://git.eulernest.eu/api/v1` | Gitea-API-Basis | +| `GITEA_TOKEN` / `GITEA_BOT_TOKEN` | — | Token fürs Kommentieren | + +## Constraints + +- Runner hat **capacity 1** → Jobs laufen seriell. +- Docker-Executor abgesichert (kein privileged, keine Host-Mounts) → Trivy + `fs`/`config` + Gitleaks laufen; Scans *laufender* Container nicht (unnötig). +- Kosten (Anthropic, Haiku-Klasse, gedeckelter Diff): Bruchteile eines Cent/PR. diff --git a/scripts/ai_explain.py b/scripts/ai_explain.py new file mode 100644 index 0000000..23b9390 --- /dev/null +++ b/scripts/ai_explain.py @@ -0,0 +1,71 @@ +"""Features B & C — Klartext-Erklärung von Scan-Funden / CI-Fehlern. + +Aufruf: + python ai_explain.py + + kind = "security" -> erklärt Trivy/Gitleaks-Funde (GitLab-Duo-Parität) + kind = "ci-failure" -> erklärt ein fehlgeschlagenes Job-Log + +Postet das Ergebnis als idempotenten PR-Kommentar (eigener Marker je kind). +Ohne PR-Kontext (z.B. push ohne PR) wird nur ins Job-Log geschrieben. +""" +import sys + +import context +import gitea_api +import llm + +KINDS = { + "security": { + "marker": "", + "heading": "## 🛡️ Security-Scan — Erklärung der Funde", + "system": ( + "Du bist ein Security-Engineer. Erkläre die folgenden Scanner-Funde " + "(Trivy/Gitleaks) auf Deutsch in Klartext: was wurde gefunden, warum " + "ist es ein Risiko, und wie behebt man es konkret. Priorisiere nach " + "Schweregrad. Keine Funde erfinden." + ), + "intro": "Der Security-Scan ist fehlgeschlagen. Hier die Funde im Klartext:", + }, + "ci-failure": { + "marker": "", + "heading": "## 🔧 CI-Fehler — Analyse", + "system": ( + "Du bist ein erfahrener CI/CD-Engineer. Analysiere das folgende Log " + "eines fehlgeschlagenen CI-Jobs auf Deutsch: nenne die wahrscheinliche " + "Ursache und einen konkreten Fix. Knapp und konkret." + ), + "intro": "Ein CI-Job ist fehlgeschlagen. Wahrscheinliche Ursache:", + }, +} + +LOG_CAP = 16000 # Zeichen aus dem Log (Ende ist meist am aussagekräftigsten) + + +def main() -> int: + if len(sys.argv) != 3 or sys.argv[1] not in KINDS: + print("Usage: ai_explain.py ", file=sys.stderr) + return 2 + + kind = KINDS[sys.argv[1]] + with open(sys.argv[2], encoding="utf-8", errors="replace") as fh: + text = fh.read() + if len(text) > LOG_CAP: + text = "[... Anfang gekürzt ...]\n" + text[-LOG_CAP:] + + answer = llm.complete(kind["system"], f"{kind['intro']}\n\n```\n{text}\n```", max_tokens=1200) + body = f"{kind['heading']}\n\n{answer}" + + owner, repo = context.repo_slug() + pr = context.pull_request() + if pr and pr["index"]: + cid = gitea_api.upsert_comment(owner, repo, pr["index"], kind["marker"], body) + print(f"[ai_explain] Kommentar gepostet/aktualisiert (id={cid}).") + else: + print("[ai_explain] Kein PR-Kontext — Ausgabe nur im Log:\n") + print(body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ai_review.py b/scripts/ai_review.py new file mode 100644 index 0000000..da2c458 --- /dev/null +++ b/scripts/ai_review.py @@ -0,0 +1,101 @@ +"""Feature A — KI-PR-Review + Summary. + +Holt den PR-Diff über die Gitea-API, lässt ihn vom konfigurierten LLM +zusammenfassen + reviewen und postet das Ergebnis als EINEN idempotenten +Kommentar (Marker -> editiert sich bei jedem Push, statt zu spammen). + +Erwartete Env-Vars: siehe llm.py + gitea_api.py, plus die Actions-Kontext- +Variablen (GITHUB_REPOSITORY, GITHUB_EVENT_PATH). +""" +import sys + +import context +import gitea_api +import llm + +MARKER = "" +DIFF_CAP = 40000 # Zeichen — darüber wird der Diff gekürzt (Token-/Kostenschutz) + +# Dateien, die für ein Review wenig Sinn ergeben (Lockfiles, Binär, generiert). +SKIP_TOKENS = ( + ".lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", + ".min.js", ".min.css", ".map", + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".woff", ".woff2", ".ttf", + "/vendor/", "/node_modules/", "/dist/", "/build/", +) + +SYSTEM = ( + "Du bist ein erfahrener, pragmatischer Code-Reviewer. Antworte auf Deutsch, " + "knapp und konkret. Erfinde nichts, was nicht im Diff steht." +) + +PROMPT = """\ +Reviewe den folgenden Pull-Request. + +PR-Titel: {title} +PR-Beschreibung: {body} + +Gib genau diese zwei Abschnitte aus (Markdown): + +### Zusammenfassung +2–4 Sätze: Was ändert dieser PR und warum. + +### Review +Stichpunkte zu: möglichen Bugs, Sicherheits-/Edge-Cases, fehlenden Tests, +Stil/Lesbarkeit. Nur echte, im Diff belegbare Punkte — wenn alles gut aussieht, +sag das ehrlich. Markiere ernste Probleme mit ⚠️. + +--- DIFF --- +{diff} +""" + + +def filter_diff(diff: str) -> str: + """Entfernt uninteressante Dateien (per `diff --git`-Sektion).""" + if not diff: + return "" + sections = diff.split("diff --git ") + kept = [] + for sec in sections[1:]: # sections[0] ist der Vorspann (leer) + header = sec.split("\n", 1)[0].lower() + if any(tok in header for tok in SKIP_TOKENS): + continue + kept.append("diff --git " + sec) + return "".join(kept).strip() + + +def main() -> int: + owner, repo = context.repo_slug() + pr = context.pull_request() + if not pr or not pr["index"]: + print("[ai_review] Kein Pull-Request im Event — übersprungen.") + return 0 + if pr["author"].lower().endswith("-bot") or pr["author"].lower() == "ci-ai": + print("[ai_review] PR vom Bot selbst — übersprungen.") + return 0 + + diff = filter_diff(gitea_api.get_pr_diff(owner, repo, pr["index"])) + if not diff: + print("[ai_review] Leerer/gefilterter Diff — nichts zu reviewen.") + return 0 + + truncated = False + if len(diff) > DIFF_CAP: + diff = diff[:DIFF_CAP] + truncated = True + + answer = llm.complete( + SYSTEM, + PROMPT.format(title=pr["title"], body=pr["body"][:1000], diff=diff), + max_tokens=1800, + ) + + note = "\n\n_⚠️ Diff war groß und wurde gekürzt — Review nur über den Anfang._" if truncated else "" + body = f"## 🤖 KI-Review\n\n{answer}{note}" + cid = gitea_api.upsert_comment(owner, repo, pr["index"], MARKER, body) + print(f"[ai_review] Kommentar gepostet/aktualisiert (id={cid}).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/context.py b/scripts/context.py new file mode 100644 index 0000000..bccbd67 --- /dev/null +++ b/scripts/context.py @@ -0,0 +1,43 @@ +"""Liest den Gitea-Actions-Event-Kontext (GitHub-kompatible Env-Vars). + +Gitea spiegelt die GITHUB_*-Variablen: GITHUB_REPOSITORY (owner/repo), +GITHUB_EVENT_PATH (JSON des auslösenden Events), GITHUB_EVENT_NAME. +""" +import json +import os + + +def repo_slug(): + """(owner, repo) aus GITHUB_REPOSITORY.""" + full = os.environ.get("GITHUB_REPOSITORY", "") + if "/" not in full: + raise SystemExit("[context] GITHUB_REPOSITORY fehlt oder ist ungültig.") + owner, repo = full.split("/", 1) + return owner, repo + + +def event(): + """Geparstes Event-JSON (oder {} wenn nicht vorhanden).""" + path = os.environ.get("GITHUB_EVENT_PATH", "") + if path and os.path.exists(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh) + return {} + + +def pull_request(): + """PR-Infos aus dem Event oder None, wenn das Event kein PR ist. + + Liefert dict: index, title, body, author, base_ref, head_sha. + """ + pr = event().get("pull_request") + if not pr: + return None + return { + "index": pr.get("number"), + "title": pr.get("title", ""), + "body": pr.get("body", "") or "", + "author": (pr.get("user") or {}).get("login", ""), + "base_ref": (pr.get("base") or {}).get("ref", ""), + "head_sha": (pr.get("head") or {}).get("sha", ""), + } diff --git a/scripts/gitea_api.py b/scripts/gitea_api.py new file mode 100644 index 0000000..03873fd --- /dev/null +++ b/scripts/gitea_api.py @@ -0,0 +1,70 @@ +"""Schlanke Gitea-API-Helfer (stdlib only). + +- PR-Diff holen +- Kommentare IDEMPOTENT posten: ein versteckter HTML-Marker identifiziert den + Bot-Kommentar; existiert er schon, wird er editiert statt neu angelegt + (kein Zuspammen bei jedem Push). + +Env-Vars: + GITEA_API Basis-URL der API, Default https://git.eulernest.eu/api/v1 + GITEA_TOKEN Token fürs Kommentieren (Actions-Auto-Token ODER PAT). + Fallback: GITEA_BOT_TOKEN. +""" +import json +import os +import urllib.error +import urllib.request + +_BASE = os.environ.get("GITEA_API", "https://git.eulernest.eu/api/v1").rstrip("/") +_TOKEN = os.environ.get("GITEA_TOKEN") or os.environ.get("GITEA_BOT_TOKEN", "") + + +def _request(method: str, path: str, *, data=None, raw: bool = False): + url = f"{_BASE}{path}" + headers = {} + if _TOKEN: + headers["Authorization"] = f"token {_TOKEN}" + body = None + if data is not None: + body = json.dumps(data).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + content = resp.read().decode("utf-8", "replace") + if raw: + return content + return json.loads(content) if content.strip() else None + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + raise SystemExit(f"[gitea] {method} {path} -> HTTP {exc.code}: {detail[:400]}") + except urllib.error.URLError as exc: + raise SystemExit(f"[gitea] {method} {path} fehlgeschlagen: {exc.reason}") + + +def get_pr_diff(owner: str, repo: str, index: int) -> str: + """Unified Diff eines PRs (Gitea unterstützt den .diff-Suffix).""" + return _request("GET", f"/repos/{owner}/{repo}/pulls/{index}.diff", raw=True) + + +def upsert_comment(owner: str, repo: str, index: int, marker: str, body: str) -> int: + """Legt einen Kommentar an ODER editiert den vorhandenen mit gleichem Marker. + + PR-Kommentare sind in Gitea Issue-Kommentare (gleicher Endpunkt). + """ + full = f"{body}\n\n{marker}" + existing = _request("GET", f"/repos/{owner}/{repo}/issues/{index}/comments") or [] + for comment in existing: + if marker in (comment.get("body") or ""): + _request( + "PATCH", + f"/repos/{owner}/{repo}/issues/comments/{comment['id']}", + data={"body": full}, + ) + return comment["id"] + created = _request( + "POST", + f"/repos/{owner}/{repo}/issues/{index}/comments", + data={"body": full}, + ) + return created["id"] diff --git a/scripts/llm.py b/scripts/llm.py new file mode 100644 index 0000000..d58dc20 --- /dev/null +++ b/scripts/llm.py @@ -0,0 +1,92 @@ +"""Provider-agnostischer LLM-Client für die KI-CI. + +Bewusst NUR stdlib (urllib) — so braucht der CI-Job kein `pip install` und +läuft in jedem schlanken Python-Image sofort. + +Umschalten zwischen Backends über Env-Vars: + AI_PROVIDER=anthropic -> api.anthropic.com/v1/messages (Claude, jetzt) + AI_PROVIDER=ollama -> /chat/completions (Jetson, später) + +Weitere Env-Vars: + AI_MODEL Modellname (Default je nach Provider) + ANTHROPIC_API_KEY (für anthropic) + AI_BASE_URL OpenAI-kompatible Basis-URL (für ollama), + Default http://192.168.178.20:11434/v1 + JETSON_API_KEY Bearer-Token (für ollama, optional) + +Der Wechsel auf den Jetson ist damit ein reiner Env-Change in der Workflow- +Datei — KEINE Code-Änderung. +""" +import json +import os +import urllib.error +import urllib.request + + +def complete(system: str, user: str, *, max_tokens: int = 1500) -> str: + """Schickt (system, user) ans konfigurierte Backend, gibt reinen Text zurück.""" + provider = os.environ.get("AI_PROVIDER", "anthropic").lower() + if provider == "anthropic": + model = os.environ.get("AI_MODEL", "claude-3-5-haiku-latest") + return _anthropic(system, user, model, max_tokens) + if provider == "ollama": + model = os.environ.get("AI_MODEL", "qwen-light") + return _ollama_openai(system, user, model, max_tokens) + raise SystemExit(f"[llm] Unbekannter AI_PROVIDER: {provider!r}") + + +def _post(url: str, headers: dict, payload: dict) -> dict: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=180) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + raise SystemExit(f"[llm] HTTP {exc.code} von {url}: {body[:600]}") + except urllib.error.URLError as exc: + raise SystemExit(f"[llm] Verbindung zu {url} fehlgeschlagen: {exc.reason}") + + +def _anthropic(system: str, user: str, model: str, max_tokens: int) -> str: + try: + key = os.environ["ANTHROPIC_API_KEY"] + except KeyError: + raise SystemExit("[llm] ANTHROPIC_API_KEY ist nicht gesetzt.") + resp = _post( + "https://api.anthropic.com/v1/messages", + { + "x-api-key": key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + { + "model": model, + "max_tokens": max_tokens, + "system": system, + "messages": [{"role": "user", "content": user}], + }, + ) + parts = [b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"] + return "".join(parts).strip() + + +def _ollama_openai(system: str, user: str, model: str, max_tokens: int) -> str: + base = os.environ.get("AI_BASE_URL", "http://192.168.178.20:11434/v1").rstrip("/") + headers = {"content-type": "application/json"} + key = os.environ.get("JETSON_API_KEY", "") + if key: + headers["Authorization"] = f"Bearer {key}" + resp = _post( + f"{base}/chat/completions", + headers, + { + "model": model, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + }, + ) + return resp["choices"][0]["message"]["content"].strip() diff --git a/workflows/ai-review.example.yml b/workflows/ai-review.example.yml new file mode 100644 index 0000000..8833181 --- /dev/null +++ b/workflows/ai-review.example.yml @@ -0,0 +1,40 @@ +# Feature A — KI-PR-Review + Summary. +# In ein Ziel-Repo kopieren nach: .gitea/workflows/ai-review.yml +# +# Nutzt KEINE JS-Actions (kein node nötig): schlankes python-Image, ci-ai wird +# manuell geklont. GITHUB_REPOSITORY / GITHUB_EVENT_PATH setzt Gitea automatisch. +name: AI PR Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + review: + runs-on: eulernest + container: python:3.12-slim + steps: + - name: Tooling (git) + run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates + + - name: ci-ai-Skripte holen + run: git clone --depth 1 https://git.eulernest.eu/user2595/ci-ai.git /opt/ci-ai + + - name: KI-Review + env: + AI_PROVIDER: anthropic + AI_MODEL: claude-3-5-haiku-latest + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # Kommentieren: Actions-Auto-Token. Reicht dessen Schreibrecht nicht, + # stattdessen GITEA_BOT_TOKEN (PAT) als Repo-/Org-Secret setzen. + GITEA_TOKEN: ${{ github.token }} + GITEA_API: https://git.eulernest.eu/api/v1 + run: python /opt/ci-ai/scripts/ai_review.py + +# ───────────────────────────────────────────────────────────────────────────── +# Späterer Umstieg auf den Jetson (lokales Ollama) — NUR diese env-Werte ändern, +# kein Code-Change: +# AI_PROVIDER: ollama +# AI_MODEL: qwen-light +# AI_BASE_URL: http://192.168.178.20:11434/v1 +# JETSON_API_KEY: ${{ secrets.JETSON_API_KEY }} diff --git a/workflows/ci-failure.example.yml b/workflows/ci-failure.example.yml new file mode 100644 index 0000000..5fed8f0 --- /dev/null +++ b/workflows/ci-failure.example.yml @@ -0,0 +1,41 @@ +# Feature C — KI-Analyse fehlgeschlagener CI-Jobs. +# KEIN eigenständiger Workflow, sondern ein MUSTER: diesen if:failure()-Job ans +# Ende eines echten Build/Test-Workflows hängen (needs: auf die CI-Jobs setzen). +# Er nimmt das geteilte Job-Log und lässt die KI die Ursache erklären. +# +# Beispiel-Einbettung in einen vorhandenen Workflow: +# +# jobs: +# build: +# runs-on: eulernest +# container: ghcr.io/catthehacker/ubuntu:act-latest +# steps: +# - uses: actions/checkout@v4 +# - name: Build +# run: | +# set -o pipefail +# make 2>&1 | tee /tmp/ci.log # <-- Log mitschneiden +# +# explain-on-failure: +# needs: [build] +# if: failure() +# runs-on: eulernest +# container: python:3.12-slim +# steps: +# - name: Tooling +# run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates +# - name: KI-Analyse +# env: +# AI_PROVIDER: anthropic +# AI_MODEL: claude-3-5-haiku-latest +# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} +# GITEA_TOKEN: ${{ github.token }} +# GITEA_API: https://git.eulernest.eu/api/v1 +# run: | +# git clone --depth 1 https://git.eulernest.eu/user2595/ci-ai.git /opt/ci-ai +# python /opt/ci-ai/scripts/ai_explain.py ci-failure /tmp/ci.log +# +# Hinweis: /tmp wird zwischen Jobs NICHT automatisch geteilt. Für echtes Cross- +# Job-Log-Sharing das Log als actions/upload-artifact hochladen und im Folge-Job +# wieder herunterladen — ODER (einfacher) den explain-Schritt als if:failure() +# direkt in DENSELBEN Job hängen, wie in security.example.yml gezeigt. diff --git a/workflows/security.example.yml b/workflows/security.example.yml new file mode 100644 index 0000000..2f866b4 --- /dev/null +++ b/workflows/security.example.yml @@ -0,0 +1,60 @@ +# Feature B (+C) — Security-Scan, HART blockierend, mit KI-Klartext-Erklärung. +# In ein Ziel-Repo kopieren nach: .gitea/workflows/security.yml +# +# Ablauf: scannen (ohne sofort abzubrechen) -> Gate (failt hart bei Funden) -> +# bei Fehler erklärt die KI die Funde im PR. So failt der Check verlässlich UND +# der Erklär-Schritt sieht beide Reports. +name: Security + +on: + pull_request: + push: + branches: [main, master] + +jobs: + scan: + runs-on: eulernest + container: ghcr.io/catthehacker/ubuntu:act-latest + steps: + - name: Checkout (volle Historie für Gitleaks) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Gitleaks (Secret-Scan) + id: gitleaks + continue-on-error: true + run: | + curl -sSL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz \ + | tar -xz -C /usr/local/bin gitleaks + gitleaks detect --source . --report-format json --report-path /tmp/gitleaks.json --no-banner \ + | tee /tmp/gitleaks.txt; echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Trivy (fs + IaC, HIGH/CRITICAL) + id: trivy + continue-on-error: true + run: | + curl -sSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin + trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \ + --exit-code 1 --no-progress . | tee /tmp/trivy.txt; echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Gate — hart blockieren bei Funden + run: | + cat /tmp/gitleaks.txt /tmp/trivy.txt > /tmp/scan.log 2>/dev/null || true + if [ "${{ steps.gitleaks.outputs.code }}" != "0" ] || [ "${{ steps.trivy.outputs.code }}" != "0" ]; then + echo "Security-Funde — Check schlägt fehl (hart blockierend)."; exit 1 + fi + echo "Keine HIGH/CRITICAL-Funde." + + - name: KI erklärt die Funde (nur bei Fehler) + if: failure() + env: + AI_PROVIDER: anthropic + AI_MODEL: claude-3-5-haiku-latest + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITEA_TOKEN: ${{ github.token }} + GITEA_API: https://git.eulernest.eu/api/v1 + run: | + git clone --depth 1 https://git.eulernest.eu/user2595/ci-ai.git /opt/ci-ai + python3 /opt/ci-ai/scripts/ai_explain.py security /tmp/scan.log