diff --git a/.gitea/workflows/security-scan.yaml b/.gitea/workflows/security-scan.yaml new file mode 100644 index 0000000..058bd9d --- /dev/null +++ b/.gitea/workflows/security-scan.yaml @@ -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 diff --git a/scripts/report-security-issue.py b/scripts/report-security-issue.py new file mode 100644 index 0000000..c7b9ebb --- /dev/null +++ b/scripts/report-security-issue.py @@ -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()