trigger: test with fresh Gitea token
All checks were successful
coverage 99% coverage
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/infra-ci Pipeline was successful

This commit is contained in:
kert
2026-03-23 13:44:43 -04:00
parent 9f4b0eedae
commit 9f03a8e7ab
7 changed files with 294 additions and 14 deletions

149
.woodpecker/harden.yml Normal file
View File

@@ -0,0 +1,149 @@
# ── Hardening pipeline ────────────────────────────────────────────
# Rebuilds all images with apt-get/apk upgrade, scans for vulns,
# and auto-closes Gitea vuln issues when scans are clean.
# Trigger: manual or weekly cron.
when:
- event: [manual, cron]
variables:
- &buildx_image woodpeckerci/plugin-docker-buildx:5-insecure
- &buildx_base
buildkit_config: |
[registry."gitea.homelab.fhirworx.io"]
http = true
registry: gitea.homelab.fhirworx.io
username:
from_secret: registry_user
password:
from_secret: registry_pass
- &trivy_scan
image: aquasec/trivy:latest
- &diag_env
UV_PYTHON_PREFERENCE: only-system
UV_LINK_MODE: copy
UV_PROJECT_ENVIRONMENT: .venv
GITEA_TOKEN:
from_secret: gitea_token
steps:
# ── Build hardened images ──────────────────────────────────
- name: build-notebooks
image: *buildx_image
settings:
<<: *buildx_base
repo: gitea.homelab.fhirworx.io/homelab/notebooks
dockerfile: notebooks/Dockerfile
context: notebooks/
tags: [hardened, latest]
pull_image: true
no_cache: true
- name: build-zotero
image: *buildx_image
settings:
<<: *buildx_base
repo: gitea.homelab.fhirworx.io/homelab/zotero
dockerfile: zotero/Dockerfile
context: zotero/
tags: [hardened, latest]
pull_image: true
no_cache: true
- name: build-docs
image: *buildx_image
settings:
<<: *buildx_base
repo: gitea.homelab.fhirworx.io/homelab/docs
dockerfile: docs/Dockerfile
context: .
tags: [hardened, latest]
pull_image: true
no_cache: true
- name: build-api
image: *buildx_image
settings:
<<: *buildx_base
repo: gitea.homelab.fhirworx.io/homelab/api
dockerfile: api/Dockerfile
context: .
tags: [hardened, latest]
pull_image: true
no_cache: true
- name: build-mc
image: *buildx_image
settings:
<<: *buildx_base
repo: gitea.homelab.fhirworx.io/homelab/mc
dockerfile: rustfs/Dockerfile.mc
context: rustfs/
tags: [hardened, latest]
pull_image: true
no_cache: true
# ── Scan hardened images ───────────────────────────────────
- name: scan-notebooks
<<: *trivy_scan
commands:
- trivy image --image-src remote --insecure --severity HIGH,CRITICAL
--exit-code 0 --format json -o notebooks-scan.json
"gitea.homelab.fhirworx.io/homelab/notebooks:hardened"
depends_on: [build-notebooks]
- name: scan-zotero
<<: *trivy_scan
commands:
- trivy image --image-src remote --insecure --severity HIGH,CRITICAL
--exit-code 0 --format json -o zotero-scan.json
"gitea.homelab.fhirworx.io/homelab/zotero:hardened"
depends_on: [build-zotero]
- name: scan-docs
<<: *trivy_scan
commands:
- trivy image --image-src remote --insecure --severity HIGH,CRITICAL
--exit-code 0 --format json -o docs-scan.json
"gitea.homelab.fhirworx.io/homelab/docs:hardened"
depends_on: [build-docs]
- name: scan-api
<<: *trivy_scan
commands:
- trivy image --image-src remote --insecure --severity HIGH,CRITICAL
--exit-code 0 --format json -o api-scan.json
"gitea.homelab.fhirworx.io/homelab/api:hardened"
depends_on: [build-api]
# ── Close resolved vuln issues OR file new ones ────────────
- name: close-or-report-vulns
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
environment:
<<: *diag_env
commands:
- uv sync --no-dev
- |
for f in notebooks-scan.json zotero-scan.json docs-scan.json api-scan.json; do
if [ -f "$f" ]; then
uv run python -m api.diag.vuln --close "$f" || \
uv run python -m api.diag.vuln "$f" || true
fi
done
depends_on:
- scan-notebooks
- scan-zotero
- scan-docs
- scan-api
# ── Failure reporter ─────────────────────────────────────────
- name: report-failure
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
failure: ignore
environment:
<<: *diag_env
commands:
- uv sync --no-dev
- uv run python -m api.diag
when:
- status: [failure]

View File

@@ -3,6 +3,9 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app WORKDIR /app
# Patch base image CVEs
RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
# Copy project files for install # Copy project files for install
COPY pyproject.toml uv.lock README.md ./ COPY pyproject.toml uv.lock README.md ./
COPY src/ src/ COPY src/ src/
@@ -18,6 +21,9 @@ COPY stack.toml ./
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["uv", "run", "--no-sync", "uvicorn", "api.server:app", \ CMD ["uv", "run", "--no-sync", "uvicorn", "api.server:app", \
"--host", "0.0.0.0", "--port", "8000", \ "--host", "0.0.0.0", "--port", "8000", \
"--workers", "1", "--log-level", "info"] "--workers", "1", "--log-level", "info"]

View File

@@ -44,7 +44,12 @@ RUN pnpm build
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM nginx:alpine FROM nginx:alpine
RUN apk upgrade --no-cache
COPY --from=build /docs/build /usr/share/nginx/html COPY --from=build /docs/build /usr/share/nginx/html
COPY docs/nginx.conf /etc/nginx/conf.d/default.conf COPY docs/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO /dev/null http://localhost:80/ || exit 1

View File

@@ -13,7 +13,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
NVIDIA_DRIVER_CAPABILITIES=compute,utility NVIDIA_DRIVER_CAPABILITIES=compute,utility
# System dependencies # System dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
curl \ curl \
ca-certificates \ ca-certificates \
git \ git \
@@ -50,6 +50,9 @@ COPY --chown=${USER_UID}:${USER_GID} .marimo-config/loch.css /home/${USERNAME}/.
EXPOSE 2718 EXPOSE 2718
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -sf http://localhost:2718/health || exit 1
CMD ["uv", "run", "--project", "/home/kert/workspace", \ CMD ["uv", "run", "--project", "/home/kert/workspace", \
"marimo", "edit", \ "marimo", "edit", \
"--host", "0.0.0.0", "--port", "2718", "--headless", "--no-token", \ "--host", "0.0.0.0", "--port", "2718", "--headless", "--no-token", \

View File

@@ -182,28 +182,107 @@ def file_vuln_issue(
return None return None
def close_vuln_issue(
scan_path: str | Path,
*,
owner: str = "homelab",
repo: str = "stack",
) -> bool:
"""Close the matching vuln issue if scan is clean (no fixable HIGH/CRITICAL).
Returns True if an issue was closed, False otherwise.
"""
path = Path(scan_path)
if not path.exists():
log.warning("Scan file not found: %s", path)
return False
data = json.loads(path.read_text())
report = parse_trivy_json(data)
short_image = report.image.rsplit("/", 1)[-1].split(":")[0]
token = os.environ.get("GITEA_TOKEN", "")
if not token:
log.error("No GITEA_TOKEN — cannot close issues")
return False
client = GiteaClient(token)
if report.vulns:
log.info(
"%s still has %d fixable vulns — not closing",
short_image,
len(report.vulns),
)
client.close()
return False
# Find open vuln issue for this image
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", ""):
# Close with comment
client.post(
f"/repos/{owner}/{repo}/issues/{issue['number']}/comments",
json={
"body": "Scan clean — no fixable HIGH/CRITICAL vulnerabilities remaining.\n\nClosed by hardening pipeline."
},
)
client.patch(
f"/repos/{owner}/{repo}/issues/{issue['number']}",
json={"state": "closed"},
)
log.info("Closed vuln issue #%s for %s", issue["number"], short_image)
client.close()
return True
except Exception:
log.exception("Failed to close vuln issue for %s", short_image)
log.info("No open vuln issue found for %s", short_image)
client.close()
return False
def main() -> int: def main() -> int:
"""CLI entry point: parse scan JSON files and file issues.""" """CLI entry point: parse scan JSON files and file/close issues."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
if len(sys.argv) < 2: close_mode = "--close" in sys.argv
print(f"Usage: {sys.argv[0]} <scan.json> [scan2.json ...]", file=sys.stderr) paths = [a for a in sys.argv[1:] if not a.startswith("--")]
if not paths:
print(
f"Usage: {sys.argv[0]} [--close] <scan.json> [scan2.json ...]",
file=sys.stderr,
)
return 1 return 1
pipeline = os.environ.get("CI_PIPELINE_NUMBER", "") pipeline = os.environ.get("CI_PIPELINE_NUMBER", "")
label_ids = [11] # quality label label_ids = [11] # quality label
filed = 0 filed = 0
for path in sys.argv[1:]: closed = 0
result = file_vuln_issue( for path in paths:
path, if close_mode:
pipeline=pipeline, if close_vuln_issue(path):
label_ids=label_ids, closed += 1
) else:
if result: result = file_vuln_issue(
filed += 1 path,
pipeline=pipeline,
label_ids=label_ids,
)
if result:
filed += 1
log.info("Filed %d vuln issue(s)", filed) if close_mode:
log.info("Closed %d vuln issue(s)", closed)
else:
log.info("Filed %d vuln issue(s)", filed)
return 0 return 0

View File

@@ -11,6 +11,7 @@ from api.diag.vuln import (
Vuln, Vuln,
build_vuln_issue_body, build_vuln_issue_body,
build_vuln_issue_title, build_vuln_issue_title,
close_vuln_issue,
file_vuln_issue, file_vuln_issue,
parse_trivy_json, parse_trivy_json,
) )
@@ -252,3 +253,40 @@ class TestFileVulnIssue:
issue = call_args[2] issue = call_args[2]
assert "vuln:" in issue["title"] assert "vuln:" in issue["title"]
assert issue["labels"] == [11] assert issue["labels"] == [11]
class TestCloseVulnIssue:
def test_skips_if_vulns_remain(self, tmp_path: Path) -> None:
scan = tmp_path / "scan.json"
scan.write_text(json.dumps(SAMPLE_TRIVY))
with patch.dict("os.environ", {"GITEA_TOKEN": "tok"}, clear=False):
result = close_vuln_issue(scan)
assert result is False
def test_closes_matching_issue(self, tmp_path: Path) -> None:
scan = tmp_path / "scan.json"
scan.write_text(json.dumps(EMPTY_TRIVY))
mock_client = MagicMock()
mock_client.get.return_value.json.return_value = [
{"number": 114, "title": "vuln: empty-image — old issue"}
]
with (
patch.dict("os.environ", {"GITEA_TOKEN": "tok"}, clear=False),
patch("api.diag.vuln.GiteaClient", return_value=mock_client),
):
result = close_vuln_issue(scan)
assert result is True
mock_client.patch.assert_called_once()
mock_client.post.assert_called_once()
def test_no_issue_to_close(self, tmp_path: Path) -> None:
scan = tmp_path / "scan.json"
scan.write_text(json.dumps(EMPTY_TRIVY))
mock_client = MagicMock()
mock_client.get.return_value.json.return_value = []
with (
patch.dict("os.environ", {"GITEA_TOKEN": "tok"}, clear=False),
patch("api.diag.vuln.GiteaClient", return_value=mock_client),
):
result = close_vuln_issue(scan)
assert result is False

View File

@@ -4,7 +4,7 @@ FROM ghcr.io/selkies-project/nvidia-egl-desktop:latest
USER root USER root
# Install Zotero # Install Zotero
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
curl \ curl \
ca-certificates \ ca-certificates \
&& curl -sL https://raw.githubusercontent.com/retorquere/zotero-deb/master/install.sh | bash \ && curl -sL https://raw.githubusercontent.com/retorquere/zotero-deb/master/install.sh | bash \