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:
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user