fix scan uploads, report-failure, add vuln issue reporter
- 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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
211
src/api/diag/vuln.py
Normal 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
254
tests/api/test_diag_vuln.py
Normal 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]
|
||||
Reference in New Issue
Block a user