Feat: Gitea-Action für Security-Scans mit Trivy & OSV-Scanner hinzugefügt

- Trivy (fs-Scan: vuln, secret, misconfig) und OSV-Scanner (Cargo.lock)
  laufen als FOSS-Tools ohne Anmeldedaten
- Bei Funden wird automatisch ein Gitea-Issue (Label security-scan)
  erstellt bzw. bei jedem weiteren Lauf per Kommentar aktualisiert,
  solange es offen ist; nach Funden wird nicht automatisch geschlossen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKMjR9pBxshZYZdhKyT5Ky
This commit is contained in:
2026-09-09 22:24:14 +02:00
co-authored by Claude Sonnet 5
parent 9a59fff032
commit f56a5c71ab
2 changed files with 258 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Security Scans
on:
push:
branches:
- main
- testing
- dev
pull_request:
schedule:
- cron: "0 5 * * 1"
workflow_dispatch:
jobs:
security-scan:
name: Trivy & OSV-Scanner
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Run Trivy Scanner
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
severity: CRITICAL,HIGH
format: json
output: trivy-results.json
exit-code: "0"
- name: Install OSV-Scanner
run: |
curl -fsSL -o /usr/local/bin/osv-scanner \
"https://github.com/google/osv-scanner/releases/download/v2.5.1/osv-scanner_linux_amd64"
chmod +x /usr/local/bin/osv-scanner
- name: Run OSV-Scanner
run: |
set +e
osv-scanner scan source --recursive --format json --output-file osv-results.json .
echo "OSV_EXIT=$?" >> "$GITHUB_ENV"
- name: Ergebnisse & Gitea-Issue erstellen/aktualisieren
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
TOKEN: ${{ secrets.SECURITY_TOKEN }}
RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }}
run: |
python3 scripts/report-security-issue.py trivy-results.json osv-results.json
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Erstellt oder kommentiert ein Gitea-Issue mit den Ergebnissen der Security-Scans.
Sucht ein offenes Issue mit dem Label "security-scan". Existiert eines, wird
der aktuelle Scan-Stand als neuer Kommentar angehängt (die Historie bleibt
erhalten). Existiert keines (z.B. weil das letzte geschlossen wurde), wird ein
neues Issue erstellt. Gibt es keine Funde mehr, wird ein offenes Issue nur
kommentiert, nicht geschlossen.
"""
import json
import os
import sys
import urllib.error
import urllib.request
LABEL_NAME = "security-scan"
LABEL_COLOR = "#b60205"
ISSUE_TITLE = "Security-Scan: Offene Schwachstellen"
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4, "-": 5}
def api(method, path, token, gitea_url, data=None):
url = f"{gitea_url}/api/v1{path}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
print(f"Gitea API Fehler ({method} {path}): {e.code} {e.read().decode()}", file=sys.stderr)
raise
def load_trivy(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
data = json.load(f)
for result in data.get("Results", []) or []:
target = result.get("Target", "?")
for vuln in result.get("Vulnerabilities", []) or []:
findings.append({
"source": "Trivy",
"id": vuln.get("VulnerabilityID", "?"),
"severity": vuln.get("Severity", "UNKNOWN"),
"package": vuln.get("PkgName", "?"),
"installed": vuln.get("InstalledVersion", "?"),
"fixed": vuln.get("FixedVersion") or "-",
"target": target,
})
for misc in result.get("Misconfigurations", []) or []:
findings.append({
"source": "Trivy (Misconfig)",
"id": misc.get("ID", "?"),
"severity": misc.get("Severity", "UNKNOWN"),
"package": misc.get("Title", "?"),
"installed": "-",
"fixed": "-",
"target": target,
})
for secret in result.get("Secrets", []) or []:
findings.append({
"source": "Trivy (Secret)",
"id": secret.get("RuleID", "?"),
"severity": secret.get("Severity", "UNKNOWN"),
"package": secret.get("Title", "?"),
"installed": "-",
"fixed": "-",
"target": target,
})
return findings
def load_osv(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
data = json.load(f)
for result in data.get("results", []) or []:
source = (result.get("source") or {}).get("path", "?")
for pkg in result.get("packages", []) or []:
info = pkg.get("package", {})
pkg_name = f"{info.get('name', '?')} ({info.get('ecosystem', '?')})"
for vuln in pkg.get("vulnerabilities", []) or []:
findings.append({
"source": "OSV-Scanner",
"id": vuln.get("id", "?"),
"severity": "-",
"package": pkg_name,
"installed": info.get("version", "?"),
"fixed": "-",
"target": source,
})
return findings
def print_summary(findings):
if not findings:
print("Keine Funde.")
return
findings = sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["id"]))
widths = {
key: max(len(key), *(len(str(f[key])) for f in findings))
for key in ("source", "id", "severity", "package", "installed", "fixed", "target")
}
header = ("source", "id", "severity", "package", "installed", "fixed", "target")
row_fmt = " ".join(f"{{:{widths[k]}}}" for k in header)
print(row_fmt.format(*header))
print(row_fmt.format(*("-" * widths[k] for k in header)))
for f in findings:
print(row_fmt.format(*(str(f[k]) for k in header)))
def build_report(findings, run_url):
findings = sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["id"]))
lines = [
"Automatisch erstellt vom Security-Scan-Workflow.",
f"Lauf: {run_url}" if run_url else "",
"",
"| Quelle | ID | Schweregrad | Paket | Installiert | Fix | Ziel |",
"|---|---|---|---|---|---|---|",
]
for f in findings:
lines.append(
f"| {f['source']} | {f['id']} | {f['severity']} | {f['package']} | "
f"{f['installed']} | {f['fixed']} | {f['target']} |"
)
return "\n".join(lines)
def ensure_label(token, gitea_url, repo):
labels = api("GET", f"/repos/{repo}/labels?limit=50", token, gitea_url) or []
for label in labels:
if label.get("name") == LABEL_NAME:
return label["id"]
created = api("POST", f"/repos/{repo}/labels", token, gitea_url, {
"name": LABEL_NAME,
"color": LABEL_COLOR,
"description": "Automatisch verwaltet vom Security-Scan-Workflow",
})
return created["id"]
def find_open_issue(token, gitea_url, repo):
issues = api(
"GET",
f"/repos/{repo}/issues?state=open&type=issues&labels={LABEL_NAME}",
token,
gitea_url,
) or []
for issue in issues:
if issue.get("title") == ISSUE_TITLE:
return issue
return None
def main():
trivy_path = sys.argv[1] if len(sys.argv) > 1 else None
osv_path = sys.argv[2] if len(sys.argv) > 2 else None
gitea_url = os.environ["GITEA_URL"].rstrip("/")
repo = os.environ["REPO"]
token = os.environ["TOKEN"]
run_url = os.environ.get("RUN_URL", "")
osv_exit = int(os.environ.get("OSV_EXIT", "0"))
findings = load_trivy(trivy_path) + load_osv(osv_path)
print_summary(findings)
open_issue = find_open_issue(token, gitea_url, repo)
if findings:
report = build_report(findings, run_url)
if open_issue:
print(f"Kommentiere bestehendes Issue #{open_issue['number']} mit {len(findings)} Fund(en).")
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {"body": report})
else:
label_id = ensure_label(token, gitea_url, repo)
print(f"Erstelle neues Issue mit {len(findings)} Fund(en).")
api("POST", f"/repos/{repo}/issues", token, gitea_url, {
"title": ISSUE_TITLE,
"body": report,
"labels": [label_id],
})
elif open_issue:
print(f"Keine aktuellen Funde mehr. Kommentiere Issue #{open_issue['number']}.")
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {
"body": f"Aktueller Scan hat keine offenen Schwachstellen mehr gefunden.\n\n{run_url}".strip(),
})
else:
print("Keine Funde und kein offenes Issue vorhanden.")
has_trivy_findings = any(f["source"].startswith("Trivy") for f in findings)
if has_trivy_findings or osv_exit == 1:
sys.exit(1)
if __name__ == "__main__":
main()