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
63 changes: 63 additions & 0 deletions .github/workflows/native-image-rescan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Native image rescan

# Server #802: rescans the latest stable native image weekly, so advisories
# or fixes published after a release are seen. It reads the image from the
# registry and runs nothing. A HIGH or CRITICAL row with a fixed version
# fails the run; record the disposition on server#802. Unfixed rows are
# inventory in the kept receipts.

on:
schedule:
- cron: '17 6 * * 1'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: native-image-rescan
cancel-in-progress: false

env:
GHCR_IMAGE: ghcr.io/durable-workflow/server

jobs:
rescan:
name: Rescan latest ${{ matrix.platform }}
runs-on: ubuntu-latest
timeout-minutes: 20
env:
RESOURCE_SCOPE: dw-native-rescan-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index }}
strategy:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64]
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- name: Resolve latest to its platform digest
id: resolve
env:
PLATFORM: ${{ matrix.platform }}
run: echo "image=$(python3 -I scripts/ci/resolve_latest_platform_digest.py "$PLATFORM")" >> "$GITHUB_OUTPUT"

- name: Scan the latest ${{ matrix.platform }} digest
env:
IMAGE: ${{ steps.resolve.outputs.image }}
PLATFORM: ${{ matrix.platform }}
run: bash scripts/ci/native-image-scan.sh "$IMAGE" "$PLATFORM" native-scan

- name: Remove this job's scanner container
if: always()
run: docker rm -f "$RESOURCE_SCOPE-trivy" || true

- name: Keep the scan receipt
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: native-rescan-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: native-scan
retention-days: 90
if-no-files-found: ignore
5 changes: 5 additions & 0 deletions .github/workflows/native-promotion-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ on:
- scripts/conformance/server-parity/native-image-corpus.sh
- tests/Unit/Support/native_alias_*test.py
- tests/Unit/Support/native_qualification_test.py
- scripts/ci/native-image-scan.sh
- scripts/ci/native_scan_receipt.py
- scripts/ci/resolve_latest_platform_digest.py
- tests/Unit/Support/native_scan_receipt_test.py
- .github/workflows/native-*.yml
- .github/workflows/published-compose.yml
permissions:
Expand All @@ -29,5 +33,6 @@ jobs:
run: |
python3 -m unittest discover -s tests/Unit/Support -p 'native_qualification_test.py' -v
python3 -m unittest discover -s tests/Unit/Support -p 'native_alias_*test.py' -v
python3 -m unittest discover -s tests/Unit/Support -p 'native_scan_receipt_test.py' -v
- name: Preserve release, candidate and monotonic alias contracts
run: bash scripts/ci/check-native-release-tags.sh
74 changes: 74 additions & 0 deletions .github/workflows/native-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -353,5 +353,79 @@ jobs:
subject-digest: ${{ steps.index.outputs.digest }}
push-to-registry: true

# Server #802: each published platform digest is scanned with the pinned
# Trivy. A HIGH or CRITICAL row with a fixed version fails this job, and
# alias promotion requires every job of this run to succeed, so it holds
# promotion. Unfixed rows are inventory in the kept receipt.
scan-amd64:
name: Scan published linux/amd64
needs: [build-amd64, publish]
if: github.ref == format('refs/tags/{0}', inputs.tag)
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
env:
RESOURCE_SCOPE: dw-native-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
steps:
- name: Check out trusted release tooling
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Scan the published linux/amd64 digest
env:
IMAGE: ${{ env.GHCR_IMAGE }}@${{ needs.build-amd64.outputs.digest }}
run: bash scripts/ci/native-image-scan.sh "$IMAGE" linux/amd64 native-scan

- name: Remove this job's scanner container
if: always()
run: docker rm -f "$RESOURCE_SCOPE-trivy" || true

- name: Keep the linux/amd64 scan receipt
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: native-scan-amd64
path: native-scan
retention-days: 90
if-no-files-found: ignore

scan-arm64:
name: Scan published linux/arm64
needs: [build-arm64, publish]
if: github.ref == format('refs/tags/{0}', inputs.tag)
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
env:
RESOURCE_SCOPE: dw-native-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
steps:
- name: Check out trusted release tooling
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Scan the published linux/arm64 digest
env:
IMAGE: ${{ env.GHCR_IMAGE }}@${{ needs.build-arm64.outputs.digest }}
run: bash scripts/ci/native-image-scan.sh "$IMAGE" linux/arm64 native-scan

- name: Remove this job's scanner container
if: always()
run: docker rm -f "$RESOURCE_SCOPE-trivy" || true

- name: Keep the linux/arm64 scan receipt
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: native-scan-arm64
path: native-scan
retention-days: 90
if-no-files-found: ignore

# Rolling aliases are deliberately absent. Future final tags containing
# this source use native-alias-promotion.yml after existing qualification.
7 changes: 5 additions & 2 deletions Dockerfile.native
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ RUN cargo build --release --locked \
&& install -m 0755 target/release/durable-workflow-server /durable-workflow-server

# A digest-pinned slim runtime: glibc for the binary, nothing else. TLS uses
# rustls with compiled-in web PKI roots, and SQLite is bundled.
FROM mirror.gcr.io/library/debian@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8
# rustls with compiled-in web PKI roots, and SQLite is bundled. Debian 12
# bookworm-slim (12.15) as its multi-architecture index, refreshed for
# CVE-2026-109518 (apt 2.6.2), which the release scan found fixable in the
# previous pin (server#802).
FROM mirror.gcr.io/library/debian@sha256:734a72b3d6142a71993b4bc580ca01d8ca687f77a2c422d96a961d3376b179bc
COPY --from=build /durable-workflow-server /usr/local/bin/durable-workflow-server
# The PHP image's paths, so a taken-over installation keeps its volumes:
# SQLite in /app/database, and external payload objects under /app/storage,
Expand Down
12 changes: 10 additions & 2 deletions docs/native-release-promotion.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ any frozen release remain subject to their separate operator and Security gates.
`native-release.yml` publishes and attests exact version tags only. It never
moves `X.Y`, `X` or `latest`. A final release follows this order:

1. Publish both architecture children and the same index in both registries.
1. Publish both architecture children and the same index in both registries,
then scan each published child with the pinned Trivy (`scan-amd64`,
`scan-arm64`, server#802). A HIGH or CRITICAL row with a fixed version fails
the run, and the manifest builder refuses a release run with a failed job,
so that finding holds promotion. Unfixed rows are inventory in the
`native-scan-*` receipts for Security's disposition on server#802.
`native-image-rescan.yml` rescans `latest` weekly.
2. Run the existing native-image corpus on that exact GHCR index from the release
tag, then the existing published-Compose four-cell matrix on that same index
from that tag. These are separately admitted invocations, not automatic
Expand Down Expand Up @@ -51,7 +57,9 @@ hand-written evidence file is needed. Run the commands from a checkout of
commit=$(git rev-parse "$tag^{commit}")
```

2. Publish the tag and wait for the run to succeed. Note its run ID.
2. Publish the tag and wait for the run to succeed, scans included. Note its
run ID. If a scan fails, fix the image (for example, refresh the base digest)
in a new release.

```sh
gh workflow run native-release.yml --ref "$tag" -f tag="$tag" -f release_commit="$commit"
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci/check-docker-workflow-isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# explicit model update and countercontrol review before gaining coverage.
QUALIFICATION_CLEANUP_SOURCES = {
"scripts/ci/native_qualification.py": "775655e8d60ecd9a468970cdc28ea40860e0b1549b75343786837f2a35f37eac",
"scripts/ci/native_alias_evidence.py": "7097eb55cf32b698539860b4a0a1bd010e251baf2def9f9e77f145aab96f60db",
"scripts/ci/native_alias_evidence.py": "2d4a05b812a1a914679ddf017b46de2bcb1dedbbf19c09297002f026f63b50d9",
"scripts/ci/native_alias_transport.py": "5a925299f3fc5c58fb937e85de579d3bed70942b19b464aca78a77e6d0d63273",
}

Expand Down
4 changes: 3 additions & 1 deletion scripts/ci/check-native-release-tags.sh
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,9 @@ tag_gate="github.ref == format('refs/tags/{0}', inputs.tag)"
expected_conditions="plan=none
build-amd64=$tag_gate
build-arm64=$tag_gate
publish=$tag_gate"
publish=$tag_gate
scan-amd64=$tag_gate
scan-arm64=$tag_gate"
test "$job_conditions" = "$expected_conditions" \
|| fail "native-release.yml job conditions changed; expected:
$expected_conditions
Expand Down
50 changes: 50 additions & 0 deletions scripts/ci/native-image-scan.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Scans one native image platform digest with the pinned Trivy and writes a
# compact receipt (server#802). Exits 1 when a HIGH or CRITICAL row has a
# fixed version, which holds promotion; unfixed rows are inventory only.
#
# usage: native-image-scan.sh IMAGE@sha256:DIGEST PLATFORM OUT_DIR
set -euo pipefail

image=${1:?image by digest}
platform=${2:?platform}
out=${3:?output directory}

# Trivy 0.76.1's multi-architecture index. Its linux/amd64 manifest is
# sha256:2e6c00d711ab…, the one Security scanned with on server#802.
scanner=docker.io/aquasec/trivy@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c

if [[ ! $image =~ ^[a-z0-9./-]+@sha256:[0-9a-f]{64}$ ]]; then
echo "native-image-scan: name the image by digest, not $image" >&2
exit 2
fi
case $platform in
linux/amd64 | linux/arm64) ;;
*) echo "native-image-scan: unsupported platform $platform" >&2; exit 2 ;;
esac

# The scanner container's name derives from RESOURCE_SCOPE, so the job's
# cleanup step can remove it.
scope_identity="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${GITHUB_JOB:-native-scan}"
if test -z "${RESOURCE_SCOPE:-}"; then

Check failure on line 29 in scripts/ci/native-image-scan.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of 'test' command for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_server&issues=AaHNhJ5bm3HtgnLrLD0K&open=AaHNhJ5bm3HtgnLrLD0K&pullRequest=1158
RESOURCE_SCOPE="dw-native-$scope_identity-$(date -u +%Y%m%d%H%M%S)-$$"
fi

mkdir -p "$out"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/cache" "$work/report"
name=scan-${platform#linux/}

trivy() {
docker run --rm --name "$RESOURCE_SCOPE-trivy" -u "$(id -u):$(id -g)" \
-v "$work/cache:/cache" -v "$work/report:/report" "$scanner" "$@"
}

# Read the image from the registry for the one platform; nothing runs it.
trivy image --cache-dir /cache --image-src remote --platform "$platform" \
--scanners vuln --severity HIGH,CRITICAL --format json --output /report/trivy.json --quiet "$image"
trivy version --cache-dir /cache --format json > "$work/report/version.json"

python3 -I "$(dirname "$0")/native_scan_receipt.py" "$(cat "$work/report/version.json")" \
"$image" "$platform" "$scanner" < "$work/report/trivy.json" > "$out/$name.json"
5 changes: 5 additions & 0 deletions scripts/ci/native_alias_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@
"Attest the index's build provenance in GHCR",
"Attest the index's build provenance in Docker Hub",
),
# Server #802: a fixed-version HIGH or CRITICAL finding fails the scan.
**{
"Scan published " + platform: ("Scan the published " + platform + " digest",)
for platform in PLATFORMS
},
}
QUALIFICATION_STEPS = {
"corpus": (
Expand Down
106 changes: 106 additions & 0 deletions scripts/ci/native_scan_receipt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Compact receipt for one Trivy scan of a native image platform digest (#802).

Reads Trivy's JSON report and `trivy version` JSON and writes a small,
metadata-only receipt: the scanned digest, scanner and database identity,
every HIGH/CRITICAL row with its fix status, and a fingerprint of those rows.
The full report is not kept.

A row is actionable when Trivy reports a fixed version for it (status
`fixed`). Actionable rows hold promotion: the script exits 1. Unfixed
distribution rows are inventory for Security's reachability disposition on
server#802, not a failure.

Usage: native_scan_receipt.py VERSION_JSON IMAGE PLATFORM SCANNER_IMAGE < REPORT
(the Trivy report on standard input, `trivy version --format json` as text)
"""
from __future__ import annotations

import hashlib
import json
import re
import sys
from typing import Any

SCHEMA = "durable-workflow.native-image-scan/v1"
SEVERITIES = ("CRITICAL", "HIGH")
DIGEST_REF = re.compile(r"[a-z0-9./-]+@sha256:[0-9a-f]{64}\Z")


def rows(report: dict[str, Any]) -> list[dict[str, Any]]:
found = []
for result in report.get("Results") or []:
for vulnerability in result.get("Vulnerabilities") or []:
severity = vulnerability.get("Severity")
if severity not in SEVERITIES:
continue
fixed = vulnerability.get("FixedVersion") or None
status = vulnerability.get("Status") or ("fixed" if fixed else "unknown")
found.append({
"id": vulnerability["VulnerabilityID"],
"severity": severity,
"package": vulnerability["PkgName"],
"installed": vulnerability.get("InstalledVersion"),
"fixed": fixed,
"status": status,
# os-pkgs: distribution packages; lang-pkgs: Cargo and other
# language dependencies.
"class": result.get("Class"),
"actionable": fixed is not None and status == "fixed",
})
found.sort(key=lambda row: (SEVERITIES.index(row["severity"]), row["id"], row["package"], row["installed"] or ""))
return found


def receipt(report: dict[str, Any], version: dict[str, Any], image: str, platform: str, scanner: str) -> dict[str, Any]:
if not DIGEST_REF.match(image) or not DIGEST_REF.match(scanner):
raise SystemExit("image and scanner must be named by digest")
digests = (report.get("Metadata") or {}).get("RepoDigests") or []
if image not in digests:
raise SystemExit(f"report does not name the scanned digest {image}")
found = rows(report)
database = version.get("VulnerabilityDB") or {}
normalized = json.dumps([{key: row[key] for key in ("id", "package", "installed", "fixed", "status")} for row in found],
separators=(",", ":"), sort_keys=True)
return {
"schema": SCHEMA,
"image": image,
"platform": platform,
"os": (report.get("Metadata") or {}).get("OS"),
"scanned_at": report.get("CreatedAt"),
"scanner": {"name": "trivy", "version": version.get("Version"), "image": scanner},
"database": {"version": database.get("Version"), "updated_at": database.get("UpdatedAt"),
"downloaded_at": database.get("DownloadedAt")},
"counts": {
"critical": sum(row["severity"] == "CRITICAL" for row in found),
"high": sum(row["severity"] == "HIGH" for row in found),
"advisories": len({row["id"] for row in found}),
"actionable": sum(row["actionable"] for row in found),
},
"fingerprint": "sha256:" + hashlib.sha256(normalized.encode()).hexdigest(),
"findings": found,
}


def main(argv: list[str]) -> int:
if len(argv) != 5:
print(__doc__, file=sys.stderr)
return 2
_, version_text, image, platform, scanner = argv
report = json.load(sys.stdin)
version = json.loads(version_text)
result = receipt(report, version, image, platform, scanner)
json.dump(result, sys.stdout, indent=1)
sys.stdout.write("\n")
counts = result["counts"]
print(f"{platform}: {counts['critical']} critical, {counts['high']} high, {counts['actionable']} with a fixed version",
file=sys.stderr)
for row in result["findings"]:
if row["actionable"]:
print(f"actionable: {row['id']} {row['package']} {row['installed']} -> {row['fixed']} ({row['severity']})",
file=sys.stderr)
return 1 if counts["actionable"] else 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
Loading
Loading