From d99a7b1eee21880aeb197e781f0a98d32d5c18f5 Mon Sep 17 00:00:00 2001 From: maksimtech <268127343+maksimtech@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:18:49 +0200 Subject: [PATCH] ci(scout): scan the image once it is published, and gate on that scan Docker Scout reads the published latest image from Docker Hub but ran only on a push to main, weekly or by hand. After the certifi release the Security Posture gate stayed red on the replaced image until Docker Scout and the gate were dispatched by hand. docker-scout.yml now follows the workflow that pushes the image (workflow_run, successful runs only) and security-posture.yml follows "Docker Scout CVE Scan"; the NIS2 document says the scan runs on every image build. Same change as maksimtech/mailradar#67. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/docker-scout.yml | 8 +++ .github/workflows/security-posture.yml | 6 ++ CHANGELOG.md | 9 +++ tests/test_scan_after_publish.py | 80 ++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 tests/test_scan_after_publish.py diff --git a/.github/workflows/docker-scout.yml b/.github/workflows/docker-scout.yml index 3bcadb0..239dee5 100644 --- a/.github/workflows/docker-scout.yml +++ b/.github/workflows/docker-scout.yml @@ -1,5 +1,11 @@ name: Docker Scout CVE Scan on: + # Also as soon as a new image is on Docker Hub: until 2026-10-24 the scan, and the + # Security Posture gate that reads it, kept describing the image a release had + # replaced until somebody dispatched them by hand. + workflow_run: + workflows: ["Build and Push Docker Image"] + types: [completed] # The scan reads maksimtech/patchradar:latest from Docker Hub, so it is only # meaningful once a new image is published — which happens on a tag, not on a # push to main. Without this, re-scanning after a release meant re-running an @@ -15,6 +21,8 @@ permissions: jobs: scout: name: Docker Scout + # After a push that failed, Docker Hub still has the old image: nothing to rescan. + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-26.04 steps: - name: Checkout diff --git a/.github/workflows/security-posture.yml b/.github/workflows/security-posture.yml index 4dd08ee..8c79a23 100644 --- a/.github/workflows/security-posture.yml +++ b/.github/workflows/security-posture.yml @@ -20,6 +20,12 @@ on: # review date that passes while nobody pushes has to fail on its own. schedule: - cron: '0 6 * * 1' + # After Docker Scout has read a newly published image: the alerts it closes and + # opens are what main's verdict is about, and until 2026-10-24 that verdict + # waited for the next push or for somebody to run this by hand. + workflow_run: + workflows: ["Docker Scout CVE Scan"] + types: [completed] workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa54a7..a8d1776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ what has already shipped, and for nothing else. See `RELEASING.md`. ## [Unreleased] +### Fixed + +- **Docker Scout scans the image as soon as it is published, and Security Posture reads that scan.** + The scan reads `latest` from Docker Hub but ran only on a push to main, weekly or by hand: after the + certifi release the gate stayed red on the replaced image until both were dispatched by hand. + `docker-scout.yml` now follows the image workflow (`workflow_run`, successful runs only) and + `security-posture.yml` follows the scan. `docs/nis2-compliance.md` already said the scan ran on every image build; now it does, and `tests/test_scan_after_publish.py` + holds them together. + ## [2026.48] - 2026-10-23 ### Security diff --git a/tests/test_scan_after_publish.py b/tests/test_scan_after_publish.py new file mode 100644 index 0000000..f8a6327 --- /dev/null +++ b/tests/test_scan_after_publish.py @@ -0,0 +1,80 @@ +"""Docker Scout reads the image as soon as it is published, and the Security Posture gate reads that scan. + +Docker Scout scans maksimtech/patchradar:latest from Docker Hub. It ran on a push to main, on a schedule and by +hand, never after a new image was pushed: on 2026-10-24, after the certifi release, every Radar's gate stayed red +on the published image's old certifi until Docker Scout was dispatched by hand, image by image, and the gate run +again. Now the scan follows the workflow that pushes the image, and the gate follows the scan. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +WORKFLOWS = Path(__file__).resolve().parent.parent / ".github" / "workflows" +# The workflows that push maksimtech/patchradar to Docker Hub +IMAGE_WORKFLOWS = ["Build and Push Docker Image"] + + +def load(name: str) -> dict: + return yaml.safe_load((WORKFLOWS / name).read_text(encoding="utf-8")) + + +def triggers(workflow: dict) -> dict: + # YAML 1.1 reads the bare key `on` as the boolean True + return workflow.get("on", workflow.get(True)) or {} + + +def names_of_workflows() -> set[str]: + return {load(path.name).get("name") for path in WORKFLOWS.glob("*.yml")} + + +@pytest.fixture(scope="module") +def scout() -> dict: + return load("docker-scout.yml") + + +@pytest.fixture(scope="module") +def posture() -> dict: + return load("security-posture.yml") + + +def test_the_scan_follows_the_workflow_that_pushes_the_image(scout): + run = triggers(scout).get("workflow_run") + assert run, "docker-scout.yml has no workflow_run trigger" + assert sorted(run["workflows"]) == sorted(IMAGE_WORKFLOWS) + assert run["types"] == ["completed"] + + +def test_the_workflows_it_follows_exist(): + """workflow_run matches by name: a misspelt one would make the trigger silently never fire.""" + assert set(IMAGE_WORKFLOWS) <= names_of_workflows() + + +def test_a_failed_push_triggers_no_scan(scout): + """Nothing new is on Docker Hub when the image workflow failed: scanning would re-read the old image.""" + for job in scout["jobs"].values(): + condition = " ".join(str(job.get("if", "")).split()) + assert "github.event.workflow_run.conclusion == 'success'" in condition + assert "github.event_name != 'workflow_run'" in condition + + +def test_the_gate_follows_the_scan(posture): + run = triggers(posture).get("workflow_run") + assert run, "security-posture.yml has no workflow_run trigger" + assert run["workflows"] == ["Docker Scout CVE Scan"] + assert run["types"] == ["completed"] + assert "Docker Scout CVE Scan" in names_of_workflows() + + +def test_the_scan_still_runs_by_hand_and_on_its_schedule(scout): + on = triggers(scout) + assert "workflow_dispatch" in on and "schedule" in on + + +def test_the_customer_document_says_when_the_image_is_scanned(): + """docs/nis2-compliance.md is held to what the repository does: the row for Docker Scout names the build.""" + doc = (WORKFLOWS.parent.parent / "docs" / "nis2-compliance.md").read_text(encoding="utf-8") + row = next(line for line in doc.splitlines() if line.startswith("| `Docker Scout` |")) + assert "ogni build dell'immagine" in row