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