fix scan uploads, report-failure, add vuln issue reporter
Some checks failed
ci/woodpecker/push/infra-ci Pipeline was successful
coverage 99% coverage
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed

- Switch trivy from plugin to commands step so JSON output lands
  in shared workspace for S3 upload (#104)
- Add failure: ignore to upload and report steps so they don't
  block each other (#105)
- Deploy step depends on scans directly, not uploads
- Add ci label (id=13) to failure issues (#106)
- Add report-failure + report-vulns to rebuild-all.yml (#106)
- New api.diag.vuln module: parses trivy JSON, files Gitea issues
  for HIGH/CRITICAL vulns with known fixes, de-duplicates by
  image name, 23 tests (#107)

fix #104 fix #105 refs #106 refs #107
This commit is contained in:
kert
2026-03-22 12:48:59 -04:00
parent 5f2cf23387
commit 4e195ec9e6
5 changed files with 570 additions and 73 deletions

View File

@@ -25,6 +25,16 @@ variables:
from_secret: registry_user
password:
from_secret: registry_pass
- &trivy_scan
image: aquasec/trivy:latest
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
- &diag_env
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
steps:
# ── Python package ──────────────────────────────────────────
@@ -61,15 +71,11 @@ steps:
- latest
- name: scan-notebooks
image: woodpeckerci/plugin-trivy
settings:
image_ref: "gitea.homelab.fhirworx.io/homelab/notebooks:sha-${CI_COMMIT_SHA:0:8}"
severity: HIGH,CRITICAL
exit_code: 0
output: notebooks-scan.json
format: json
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
<<: *trivy_scan
commands:
- trivy image --severity HIGH,CRITICAL --exit-code 0 --format json
-o notebooks-scan.json
"gitea.homelab.fhirworx.io/homelab/notebooks:sha-${CI_COMMIT_SHA:0:8}"
depends_on:
- build-push-notebooks
@@ -86,15 +92,11 @@ steps:
- latest
- name: scan-zotero
image: woodpeckerci/plugin-trivy
settings:
image_ref: "gitea.homelab.fhirworx.io/homelab/zotero:sha-${CI_COMMIT_SHA:0:8}"
severity: HIGH,CRITICAL
exit_code: 0
output: zotero-scan.json
format: json
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
<<: *trivy_scan
commands:
- trivy image --severity HIGH,CRITICAL --exit-code 0 --format json
-o zotero-scan.json
"gitea.homelab.fhirworx.io/homelab/zotero:sha-${CI_COMMIT_SHA:0:8}"
depends_on:
- build-push-zotero
@@ -121,15 +123,11 @@ steps:
- prep-docs-context
- name: scan-docs
image: woodpeckerci/plugin-trivy
settings:
image_ref: "gitea.homelab.fhirworx.io/homelab/docs:sha-${CI_COMMIT_SHA:0:8}"
severity: HIGH,CRITICAL
exit_code: 0
output: docs-scan.json
format: json
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
<<: *trivy_scan
commands:
- trivy image --severity HIGH,CRITICAL --exit-code 0 --format json
-o docs-scan.json
"gitea.homelab.fhirworx.io/homelab/docs:sha-${CI_COMMIT_SHA:0:8}"
depends_on:
- build-push-docs
@@ -146,15 +144,11 @@ steps:
- latest
- name: scan-api
image: woodpeckerci/plugin-trivy
settings:
image_ref: "gitea.homelab.fhirworx.io/homelab/api:sha-${CI_COMMIT_SHA:0:8}"
severity: HIGH,CRITICAL
exit_code: 0
output: api-scan.json
format: json
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
<<: *trivy_scan
commands:
- trivy image --severity HIGH,CRITICAL --exit-code 0 --format json
-o api-scan.json
"gitea.homelab.fhirworx.io/homelab/api:sha-${CI_COMMIT_SHA:0:8}"
depends_on:
- build-push-api
@@ -173,6 +167,7 @@ steps:
# ── Upload scan results ─────────────────────────────────────
- name: upload-notebooks-scan
image: woodpeckerci/plugin-s3
failure: ignore
settings:
endpoint: http://rustfs:9000
bucket: gitea
@@ -188,6 +183,7 @@ steps:
- name: upload-zotero-scan
image: woodpeckerci/plugin-s3
failure: ignore
settings:
endpoint: http://rustfs:9000
bucket: gitea
@@ -203,6 +199,7 @@ steps:
- name: upload-docs-scan
image: woodpeckerci/plugin-s3
failure: ignore
settings:
endpoint: http://rustfs:9000
bucket: gitea
@@ -218,6 +215,7 @@ steps:
- name: upload-api-scan
image: woodpeckerci/plugin-s3
failure: ignore
settings:
endpoint: http://rustfs:9000
bucket: gitea
@@ -231,6 +229,24 @@ steps:
depends_on:
- scan-api
# ── Report vulnerabilities ──────────────────────────────────
- name: report-vulns
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
failure: ignore
environment:
<<: *diag_env
commands:
- uv sync --no-dev
- |
for f in notebooks-scan.json zotero-scan.json docs-scan.json api-scan.json; do
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
done
depends_on:
- scan-notebooks
- scan-zotero
- scan-docs
- scan-api
# ── Deploy: pull latest code + restart services ────────────────
- name: deploy
image: docker:cli
@@ -255,10 +271,11 @@ steps:
- docker compose up -d --remove-orphans
depends_on:
- publish-package
- upload-notebooks-scan
- upload-zotero-scan
- upload-api-scan
- upload-docs-scan
- scan-notebooks
- scan-zotero
- scan-docs
- scan-api
- build-push-mc
# ── Provision: derive credentials and rotate backends ────────
- name: provision
@@ -278,12 +295,9 @@ steps:
# ── Failure reporter ─────────────────────────────────────────
- name: report-failure
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
failure: ignore
environment:
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
<<: *diag_env
commands:
- uv sync --no-dev
- uv run python -m api.diag

View File

@@ -12,6 +12,16 @@ variables:
from_secret: registry_user
password:
from_secret: registry_pass
- &trivy_scan
image: aquasec/trivy:latest
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
- &diag_env
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
steps:
- name: build-push-notebooks
@@ -80,38 +90,48 @@ steps:
- latest
- name: scan-notebooks
image: woodpeckerci/plugin-trivy
settings:
image_ref: "gitea.homelab.fhirworx.io/homelab/notebooks:sha-${CI_COMMIT_SHA:0:8}"
severity: HIGH,CRITICAL
exit_code: 0
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
<<: *trivy_scan
commands:
- trivy image --severity HIGH,CRITICAL --exit-code 0 --format json
-o notebooks-scan.json
"gitea.homelab.fhirworx.io/homelab/notebooks:sha-${CI_COMMIT_SHA:0:8}"
depends_on:
- build-push-notebooks
- name: scan-zotero
image: woodpeckerci/plugin-trivy
settings:
image_ref: "gitea.homelab.fhirworx.io/homelab/zotero:sha-${CI_COMMIT_SHA:0:8}"
severity: HIGH,CRITICAL
exit_code: 0
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
<<: *trivy_scan
commands:
- trivy image --severity HIGH,CRITICAL --exit-code 0 --format json
-o zotero-scan.json
"gitea.homelab.fhirworx.io/homelab/zotero:sha-${CI_COMMIT_SHA:0:8}"
depends_on:
- build-push-zotero
- name: scan-api
image: woodpeckerci/plugin-trivy
settings:
image_ref: "gitea.homelab.fhirworx.io/homelab/api:sha-${CI_COMMIT_SHA:0:8}"
severity: HIGH,CRITICAL
exit_code: 0
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
<<: *trivy_scan
commands:
- trivy image --severity HIGH,CRITICAL --exit-code 0 --format json
-o api-scan.json
"gitea.homelab.fhirworx.io/homelab/api:sha-${CI_COMMIT_SHA:0:8}"
depends_on:
- build-push-api
- name: report-vulns
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
failure: ignore
environment:
<<: *diag_env
commands:
- uv sync --no-dev
- |
for f in notebooks-scan.json zotero-scan.json api-scan.json; do
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
done
depends_on:
- scan-notebooks
- scan-zotero
- scan-api
- name: deploy
image: docker:cli
volumes:
@@ -132,16 +152,14 @@ steps:
- scan-zotero
- scan-api
- build-push-docs
- build-push-mc
# ── Failure reporter ─────────────────────────────────────────
- name: report-failure
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
failure: ignore
environment:
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
<<: *diag_env
commands:
- uv sync --no-dev
- uv run python -m api.diag

View File

@@ -134,7 +134,7 @@ def main() -> int:
{
"title": title,
"body": body,
"labels": [],
"labels": [13],
},
)
log.info("Filed issue #%s: %s", result.get("number"), title)
@@ -157,7 +157,7 @@ def main() -> int:
{
"title": title,
"body": body,
"labels": [],
"labels": [13],
},
)
log.info("Filed issue #%s: %s", result.get("number"), title)

211
src/api/diag/vuln.py Normal file
View File

@@ -0,0 +1,211 @@
"""Parse trivy JSON scan results and file Gitea issues for vulnerabilities.
Usage::
uv run python -m api.diag.vuln notebooks-scan.json
Reads a trivy JSON report, extracts HIGH/CRITICAL vulnerabilities with
known fixes, and files a single Gitea issue per image summarising them.
Skips if no actionable vulns found. De-duplicates by checking for
existing open issues with the same image name.
"""
from __future__ import annotations
import json
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from api.clients.gitea import GiteaClient
log = logging.getLogger(__name__)
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4}
@dataclass
class Vuln:
cve: str
severity: str
pkg_name: str
installed: str
fixed: str
title: str = ""
@dataclass
class ScanReport:
image: str
vulns: list[Vuln] = field(default_factory=list)
@property
def critical_count(self) -> int:
return sum(1 for v in self.vulns if v.severity == "CRITICAL")
@property
def high_count(self) -> int:
return sum(1 for v in self.vulns if v.severity == "HIGH")
@property
def summary(self) -> str:
parts = []
if self.critical_count:
parts.append(f"{self.critical_count} CRITICAL")
if self.high_count:
parts.append(f"{self.high_count} HIGH")
return ", ".join(parts) or "no actionable vulns"
def parse_trivy_json(data: dict) -> ScanReport:
"""Parse a trivy JSON report into a ScanReport."""
image = data.get("ArtifactName", data.get("artifactName", "unknown"))
vulns: list[Vuln] = []
results = data.get("Results", data.get("results", []))
for result in results:
for v in result.get("Vulnerabilities", result.get("vulnerabilities", [])):
severity = v.get("Severity", v.get("severity", "UNKNOWN")).upper()
if severity not in ("HIGH", "CRITICAL"):
continue
fixed = v.get("FixedVersion", v.get("fixedVersion", ""))
if not fixed:
continue
vulns.append(
Vuln(
cve=v.get("VulnerabilityID", v.get("vulnerabilityID", "?")),
severity=severity,
pkg_name=v.get("PkgName", v.get("pkgName", "?")),
installed=v.get("InstalledVersion", v.get("installedVersion", "?")),
fixed=fixed,
title=v.get("Title", v.get("title", "")),
)
)
vulns.sort(key=lambda v: (SEVERITY_ORDER.get(v.severity, 99), v.cve))
return ScanReport(image=image, vulns=vulns)
def build_vuln_issue_body(report: ScanReport, pipeline: str = "") -> str:
"""Build a markdown issue body from a ScanReport."""
lines: list[str] = []
lines.append(f"**Image:** `{report.image}`")
if pipeline:
lines.append(f"**Pipeline:** #{pipeline}")
lines.append(f"**Summary:** {report.summary}")
lines.append("")
lines.append("| CVE | Severity | Package | Installed | Fixed | Title |")
lines.append("|-----|----------|---------|-----------|-------|-------|")
for v in report.vulns:
title = v.title[:60] + "..." if len(v.title) > 60 else v.title
lines.append(
f"| {v.cve} | **{v.severity}** | {v.pkg_name} "
f"| {v.installed} | {v.fixed} | {title} |"
)
lines.append("")
return "\n".join(lines)
def build_vuln_issue_title(report: ScanReport) -> str:
"""Build the issue title."""
short_image = report.image.rsplit("/", 1)[-1].split(":")[0]
return f"vuln: {short_image} — {report.summary}"
def file_vuln_issue(
scan_path: str | Path,
*,
owner: str = "homelab",
repo: str = "stack",
pipeline: str = "",
label_ids: list[int] | None = None,
) -> dict | None:
"""Parse a trivy JSON file and file a Gitea issue if vulns found.
Returns the created issue dict, or None if no vulns or posting fails.
"""
path = Path(scan_path)
if not path.exists():
log.warning("Scan file not found: %s", path)
return None
data = json.loads(path.read_text())
report = parse_trivy_json(data)
if not report.vulns:
log.info("No actionable vulns in %s", path.name)
return None
title = build_vuln_issue_title(report)
body = build_vuln_issue_body(report, pipeline)
token = os.environ.get("GITEA_TOKEN", "")
if not token:
log.error("No GITEA_TOKEN — cannot file issue")
return None
client = GiteaClient(token)
# De-duplicate: check for existing open issue with same image
short_image = report.image.rsplit("/", 1)[-1].split(":")[0]
try:
existing = client.get(
f"/repos/{owner}/{repo}/issues",
params={"state": "open", "type": "issues", "limit": 50},
).json()
for issue in existing:
if f"vuln: {short_image}" in issue.get("title", ""):
log.info(
"Existing open vuln issue #%s for %s — skipping",
issue["number"],
short_image,
)
client.close()
return None
except Exception:
log.exception("Failed to check existing issues")
issue_body: dict = {"title": title, "body": body}
if label_ids:
issue_body["labels"] = label_ids
try:
result = client.create_issue(owner, repo, issue_body)
log.info("Filed vuln issue #%s: %s", result.get("number"), title)
client.close()
return result
except Exception:
log.exception("Failed to file vuln issue")
client.close()
return None
def main() -> int:
"""CLI entry point: parse scan JSON files and file issues."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <scan.json> [scan2.json ...]", file=sys.stderr)
return 1
pipeline = os.environ.get("CI_PIPELINE_NUMBER", "")
label_ids = [11] # quality label
filed = 0
for path in sys.argv[1:]:
result = file_vuln_issue(
path,
pipeline=pipeline,
label_ids=label_ids,
)
if result:
filed += 1
log.info("Filed %d vuln issue(s)", filed)
return 0
if __name__ == "__main__":
sys.exit(main())

254
tests/api/test_diag_vuln.py Normal file
View File

@@ -0,0 +1,254 @@
"""Tests for api.diag.vuln — trivy vulnerability issue reporter."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
from api.diag.vuln import (
ScanReport,
Vuln,
build_vuln_issue_body,
build_vuln_issue_title,
file_vuln_issue,
parse_trivy_json,
)
SAMPLE_TRIVY = {
"ArtifactName": "gitea.homelab.fhirworx.io/homelab/notebooks:sha-abc12345",
"Results": [
{
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2026-1111",
"Severity": "CRITICAL",
"PkgName": "openssl",
"InstalledVersion": "3.0.1",
"FixedVersion": "3.0.2",
"Title": "Buffer overflow in SSL handshake",
},
{
"VulnerabilityID": "CVE-2026-2222",
"Severity": "HIGH",
"PkgName": "curl",
"InstalledVersion": "7.88.0",
"FixedVersion": "7.88.1",
"Title": "HSTS bypass via redirect",
},
{
"VulnerabilityID": "CVE-2026-3333",
"Severity": "MEDIUM",
"PkgName": "zlib",
"InstalledVersion": "1.2.13",
"FixedVersion": "1.2.14",
"Title": "Denial of service",
},
{
"VulnerabilityID": "CVE-2026-4444",
"Severity": "HIGH",
"PkgName": "libxml2",
"InstalledVersion": "2.10.3",
"FixedVersion": "",
"Title": "No fix available",
},
]
}
],
}
EMPTY_TRIVY = {"ArtifactName": "empty-image", "Results": []}
LOW_ONLY_TRIVY = {
"ArtifactName": "low-image",
"Results": [
{
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2026-9999",
"Severity": "LOW",
"PkgName": "bash",
"InstalledVersion": "5.2",
"FixedVersion": "5.3",
"Title": "Minor issue",
},
]
}
],
}
class TestParseTrivy:
def test_extracts_image_name(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
assert "notebooks" in report.image
def test_filters_high_critical_only(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
severities = {v.severity for v in report.vulns}
assert severities <= {"HIGH", "CRITICAL"}
assert "MEDIUM" not in severities
def test_excludes_no_fix(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
cves = {v.cve for v in report.vulns}
assert "CVE-2026-4444" not in cves
def test_includes_fixable_vulns(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
cves = {v.cve for v in report.vulns}
assert "CVE-2026-1111" in cves
assert "CVE-2026-2222" in cves
def test_sorted_critical_first(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
assert report.vulns[0].severity == "CRITICAL"
assert report.vulns[1].severity == "HIGH"
def test_empty_results(self) -> None:
report = parse_trivy_json(EMPTY_TRIVY)
assert report.vulns == []
def test_low_only_excluded(self) -> None:
report = parse_trivy_json(LOW_ONLY_TRIVY)
assert report.vulns == []
def test_handles_lowercase_keys(self) -> None:
data = {
"artifactName": "test",
"results": [
{
"vulnerabilities": [
{
"vulnerabilityID": "CVE-2026-5555",
"severity": "critical",
"pkgName": "pkg",
"installedVersion": "1.0",
"fixedVersion": "1.1",
"title": "Test",
}
]
}
],
}
report = parse_trivy_json(data)
assert len(report.vulns) == 1
class TestScanReport:
def test_critical_count(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
assert report.critical_count == 1
def test_high_count(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
assert report.high_count == 1
def test_summary_format(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
assert "1 CRITICAL" in report.summary
assert "1 HIGH" in report.summary
def test_empty_summary(self) -> None:
report = parse_trivy_json(EMPTY_TRIVY)
assert report.summary == "no actionable vulns"
class TestBuildIssue:
def test_title_contains_image_short_name(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
title = build_vuln_issue_title(report)
assert "notebooks" in title
assert "vuln:" in title
def test_title_contains_counts(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
title = build_vuln_issue_title(report)
assert "CRITICAL" in title
def test_body_has_table(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
body = build_vuln_issue_body(report, pipeline="42")
assert "| CVE |" in body
assert "CVE-2026-1111" in body
assert "openssl" in body
def test_body_contains_pipeline(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
body = build_vuln_issue_body(report, pipeline="42")
assert "#42" in body
def test_body_contains_image(self) -> None:
report = parse_trivy_json(SAMPLE_TRIVY)
body = build_vuln_issue_body(report)
assert "notebooks" in body
def test_body_truncates_long_title(self) -> None:
report = ScanReport(
image="test",
vulns=[
Vuln(
cve="CVE-X",
severity="HIGH",
pkg_name="p",
installed="1",
fixed="2",
title="A" * 100,
)
],
)
body = build_vuln_issue_body(report)
assert "..." in body
class TestFileVulnIssue:
def test_skips_if_no_file(self, tmp_path: Path) -> None:
result = file_vuln_issue(tmp_path / "missing.json")
assert result is None
def test_skips_if_no_vulns(self, tmp_path: Path) -> None:
scan = tmp_path / "empty.json"
scan.write_text(json.dumps(EMPTY_TRIVY))
result = file_vuln_issue(scan)
assert result is None
def test_skips_if_no_token(self, tmp_path: Path) -> None:
scan = tmp_path / "scan.json"
scan.write_text(json.dumps(SAMPLE_TRIVY))
with patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=False):
result = file_vuln_issue(scan)
assert result is None
def test_deduplicates(self, tmp_path: Path) -> None:
scan = tmp_path / "scan.json"
scan.write_text(json.dumps(SAMPLE_TRIVY))
mock_client = MagicMock()
mock_client.get.return_value.json.return_value = [
{"number": 99, "title": "vuln: notebooks — old issue"}
]
with (
patch.dict("os.environ", {"GITEA_TOKEN": "tok"}, clear=False),
patch("api.diag.vuln.GiteaClient", return_value=mock_client),
):
result = file_vuln_issue(scan)
assert result is None
mock_client.create_issue.assert_not_called()
def test_creates_issue(self, tmp_path: Path) -> None:
scan = tmp_path / "scan.json"
scan.write_text(json.dumps(SAMPLE_TRIVY))
mock_client = MagicMock()
mock_client.get.return_value.json.return_value = []
mock_client.create_issue.return_value = {"number": 200}
with (
patch.dict("os.environ", {"GITEA_TOKEN": "tok"}, clear=False),
patch("api.diag.vuln.GiteaClient", return_value=mock_client),
):
result = file_vuln_issue(scan, label_ids=[11])
assert result == {"number": 200}
call_args = mock_client.create_issue.call_args[0]
assert call_args[0] == "homelab"
assert call_args[1] == "stack"
issue = call_args[2]
assert "vuln:" in issue["title"]
assert issue["labels"] == [11]