Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/docker-scout.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/security-posture.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/test_scan_after_publish.py
Original file line number Diff line number Diff line change
@@ -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
Loading