From 8868f4d8d10daf0fc866106aff2ac7d08d7041db Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Thu, 20 Aug 2026 16:20:21 +0200 Subject: [PATCH 01/12] [COSY-926] Automate CVE remediation Add a gated weekly security rebuild, scope Dependabot to security updates for vm/go.mod, and add a smoke test the rebuild must pass before it ships. Most CVEs on this image come from the Go toolchain baked into the service binary rather than a dependency manifest, so they are fixed by rebuilding rather than by bumping anything. The rebuild only republishes when it actually clears a CVE, so a no-op never surfaces as an update in Docker Desktop. --- .github/dependabot.yml | 15 ++ .github/workflows/security-rebuild.yml | 205 +++++++++++++++++++++++++ Dockerfile | 2 +- Makefile | 3 + README.md | 34 ++++ scripts/bump-version.sh | 46 ++++++ scripts/smoke-test.sh | 67 ++++++++ 7 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/security-rebuild.yml create mode 100755 scripts/bump-version.sh create mode 100755 scripts/smoke-test.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..583ee51 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + # Security updates only. `open-pull-requests-limit: 0` switches off scheduled + # version updates; Dependabot security updates are unaffected by it. + # + # Scoped to gomod on purpose. The only lang-pkg target Trivy finds in the + # published image is the `service` gobinary, so vm/go.mod is the only manifest + # whose contents can turn into a CVE finding. The UI ships as an esbuild + # bundle with no node_modules, so npm advisories never reach the image and + # would only add PR noise that can't close a security ticket. + - package-ecosystem: gomod + directory: /vm + schedule: + interval: weekly + open-pull-requests-limit: 0 diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml new file mode 100644 index 0000000..6b1decc --- /dev/null +++ b/.github/workflows/security-rebuild.yml @@ -0,0 +1,205 @@ +name: Weekly Security Rebuild + +# Gated security rebuild (COSY-926). +# +# Most CVEs reported against this image come from the Go toolchain baked into +# the `service` binary, not from anything in a manifest: of the 9 findings on +# 2026.7.2, 7 were Go stdlib. Those are fixed by rebuilding on the floating +# `golang:1.25-alpine` tag, which no dependency bot can propose. So once a week +# we rebuild and republish — but ONLY if the rebuild actually clears a CVE, so +# users don't get an update badge in Docker Desktop for a no-op release. +# +# Modelled on the `security-rebuild` job in localstack-pro's aws_flink.yml. + +on: + schedule: + # Tuesdays 08:00 UTC — ~22h ahead of the secops Wednesday 06:00 UTC CVE + # scan, so anything cleared here never gets filed as a finding. + - cron: "0 8 * * TUE" + workflow_dispatch: + inputs: + dry_run: + description: "Scan and rebuild, but never publish" + type: boolean + default: false + +concurrency: + group: security-rebuild + cancel-in-progress: false + +jobs: + security-rebuild: + name: Rebuild and republish if CVEs clear + runs-on: ubuntu-latest + permissions: + contents: write + env: + IMAGE: localstack/localstack-docker-desktop + PLATFORMS: linux/amd64,linux/arm64 + # Mirror the Trivy vuln DBs to dodge GHCR rate limits — same workaround + # the secops weekly scan and aws_flink.yml use (trivy-action issue #389). + TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db" + TRIVY_JAVA_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-java-db,public.ecr.aws/aquasecurity/trivy-java-db" + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read published version + id: version + run: | + TAG=$(sed -n 's/^TAG?=\(.*\)$/\1/p' Makefile) + echo "Currently published: ${IMAGE}:${TAG}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Scan published image (before) + # exit-code is left unset: findings are the signal to rebuild, not a + # failure. --ignore-unfixed matches what the secops pipeline files, so + # an unfixable advisory (e.g. GO-2026-5932) can't loop here forever. + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.IMAGE }}:${{ steps.version.outputs.tag }} + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + format: json + output: before.json + + - name: Determine fixable CVEs (before) + id: before + run: | + jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' before.json | sort -u > before_cves.txt + echo "Fixable HIGH/CRITICAL CVEs on the published image:"; cat before_cves.txt || true + if [ -s before_cves.txt ]; then + echo "proceed=true" >> "$GITHUB_OUTPUT" + else + echo "Published image is clean — nothing to rebuild." + echo "proceed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up QEMU + if: steps.before.outputs.proceed == 'true' + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: Set up Buildx + if: steps.before.outputs.proceed == 'true' + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + with: + platforms: linux/amd64,linux/arm64 + + - name: Download CLI binaries + if: steps.before.outputs.proceed == 'true' + run: bash downloadBinaries.sh + + - name: Build candidate (amd64, for scanning) + if: steps.before.outputs.proceed == 'true' + # --pull --no-cache is the whole point of the job: a cached base layer + # would reproduce the same image and clear nothing. Correctness over + # the few minutes a warm cache would save on a weekly run. + run: | + docker buildx build --load --pull --no-cache \ + --platform linux/amd64 \ + --tag dde-candidate:scan . + + - name: Smoke-test candidate + if: steps.before.outputs.proceed == 'true' + run: ./scripts/smoke-test.sh dde-candidate:scan + + - name: Scan candidate (after) + if: steps.before.outputs.proceed == 'true' + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: dde-candidate:scan + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + format: json + output: after.json + + - name: Compute cleared CVEs (before - after) + id: delta + if: steps.before.outputs.proceed == 'true' + run: | + jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' after.json | sort -u > after_cves.txt + comm -23 before_cves.txt after_cves.txt > cleared.txt + comm -13 before_cves.txt after_cves.txt > introduced.txt + + echo "Cleared by the rebuild:"; cat cleared.txt || true + # Refreshing to newer upstream rarely adds CVEs, and when it does we'd + # still rather ship the net improvement — so this is reported, not gated. + if [ -s introduced.txt ]; then + echo "::warning::Rebuild introduced new CVEs: $(paste -sd', ' introduced.txt)" + fi + + if [ -s cleared.txt ]; then + echo "cleared=true" >> "$GITHUB_OUTPUT" + else + echo "cleared=false" >> "$GITHUB_OUTPUT" + fi + + - name: Report CVEs a rebuild cannot fix + # A rebuild only moves the base image and the Go toolchain. Anything + # left needs a dependency bump, which is Dependabot's half of COSY-926 + # — surface it instead of silently no-op'ing every week until the SLA + # on the secops ticket burns. + if: steps.before.outputs.proceed == 'true' && steps.delta.outputs.cleared == 'false' + run: | + echo "::warning::Rebuild cleared nothing; these need a dependency bump: $(paste -sd', ' before_cves.txt)" + { + echo "### Rebuild cleared no CVEs" + echo + echo "Still present after a clean rebuild — these need a \`vm/go.mod\` bump, not a rebuild:" + echo + sed 's/^/- /' before_cves.txt + } >> "$GITHUB_STEP_SUMMARY" + + - name: Bump version + id: bump + if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run + run: | + NEW=$(./scripts/bump-version.sh cleared.txt) + echo "Releasing ${NEW}" + echo "version=${NEW}" >> "$GITHUB_OUTPUT" + + - name: Commit and tag + if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run + env: + NEW: ${{ steps.bump.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Makefile Dockerfile CHANGELOG.md + git commit -m "Security rebuild ${NEW}" + git tag "v${NEW}" + git push origin HEAD:main "v${NEW}" + + - name: Login to Docker Hub + if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish (multi-arch) + # Published from here rather than by letting build-push-docker.yml pick + # up the tag: pushes made with GITHUB_TOKEN don't trigger workflows. + if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run + env: + NEW: ${{ steps.bump.outputs.version }} + run: | + echo "Publishing ${IMAGE}:${NEW}, clearing:"; cat cleared.txt + docker buildx build --push --pull --no-cache \ + --platform "${PLATFORMS}" \ + --tag "${IMAGE}:${NEW}" . + + - name: Summary + if: always() + run: | + { + echo "### Weekly security rebuild" + echo + echo "- Published image: \`${IMAGE}:${{ steps.version.outputs.tag }}\`" + echo "- Fixable HIGH/CRITICAL before: $(wc -l < before_cves.txt 2>/dev/null || echo 0)" + echo "- Cleared by rebuild: $(wc -l < cleared.txt 2>/dev/null || echo 0)" + echo "- Released: ${{ steps.bump.outputs.version || 'no (nothing cleared)' }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile index b18563f..ef9f27d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=type=cache,target=/usr/src/app/.npm \ COPY ui /ui RUN npm run build -FROM alpine +FROM alpine:3.24 RUN apk upgrade --no-cache LABEL org.opencontainers.image.title="LocalStack" \ org.opencontainers.image.description="Extension of Localstack for Docker desktop" \ diff --git a/Makefile b/Makefile index bbeb5ce..3a38738 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ install-extension: build-extension ## Install the extension update-extension: build-extension ## Update the extension docker extension update $(IMAGE):$(TAG) +smoke-test: build-extension ## Verify the built image starts and ships everything it declares + ./scripts/smoke-test.sh $(IMAGE):$(TAG) + debug: ## Start the extension in debug mode docker extension dev debug $(IMAGE) diff --git a/README.md b/README.md index 14a0041..57e6330 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,40 @@ To contribute, check out our [issue tracker](https://github.com/localstack/local ```bash $ make stop-hot-reloading ``` +## Security maintenance + +Most CVEs reported against this image come from the Go toolchain compiled into +the `service` binary rather than from any dependency manifest, so they are fixed +by rebuilding on a newer `golang:1.25-alpine` rather than by bumping anything. + +The [weekly security rebuild](.github/workflows/security-rebuild.yml) does this +automatically: it scans the published image, rebuilds from scratch, and +republishes a new patch version **only if the rebuild actually clears a CVE**. +Rebuilds that change nothing are not released, so no update badge appears in +Docker Desktop for a no-op. + +Anything a rebuild cannot fix needs a dependency bump. Dependabot raises those +against `vm/go.mod` as security updates; they are reviewed and tested by hand, +and released with the same workflow via `workflow_dispatch`. + +### Validating a CVE fix locally + +```bash +# What is currently published? +trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \ + localstack/localstack-docker-desktop:$(sed -n 's/^TAG?=//p' Makefile) + +# Rebuild from scratch and rescan. --pull --no-cache matters: a cached base +# layer reproduces the old image and clears nothing. +docker build --pull --no-cache -t dde-candidate . +trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed dde-candidate + +# Check the rebuild still works before shipping it +make smoke-test +``` + +Test a change end to end in Docker Desktop with `make install-extension`. + ## Releases Please refer to [`CHANGELOG`](CHANGELOG.md) to see the complete list of changes for each release. diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..1c70f76 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Bump the patch component of the extension version across every place it lives. +# +# The version appears in three files that must agree, plus the git tag the +# publish workflow keys off: +# Makefile TAG?= +# Dockerfile org.opencontainers.image.version= +# CHANGELOG.md ## [] — +# +# Usage: ./scripts/bump-version.sh [cleared-cves-file] +# Prints the new version on stdout. + +set -euo pipefail + +CLEARED_FILE="${1:-}" + +CURRENT=$(sed -n 's/^TAG?=\(.*\)$/\1/p' Makefile) +[ -n "$CURRENT" ] || { echo "could not read TAG from Makefile" >&2; exit 1; } + +# 2026.8.0 -> 2026.8.1. Security rebuilds only ever move the patch component; +# feature releases set the year.month by hand. +MAJOR_MINOR="${CURRENT%.*}" +PATCH="${CURRENT##*.}" +[[ "$PATCH" =~ ^[0-9]+$ ]] || { echo "unexpected TAG format: $CURRENT" >&2; exit 1; } +NEW="${MAJOR_MINOR}.$((PATCH + 1))" + +sed -i "s|^TAG?=${CURRENT}$|TAG?=${NEW}|" Makefile + +# This label was stale for several releases before the bump was scripted, so +# rewrite whatever is there rather than matching the previous version. +sed -i "s|org.opencontainers.image.version=[^ ]*|org.opencontainers.image.version=${NEW}|" Dockerfile + +NOTE="Security update" +if [ -n "$CLEARED_FILE" ] && [ -s "$CLEARED_FILE" ]; then + NOTE="Security update — clears $(paste -sd, "$CLEARED_FILE" | sed 's/,/, /g')" +fi + +ENTRY="## [${NEW}] — $(date -u +%Y-%m-%d)\n\n### Changed\n\n- ${NOTE}\n" + +# Insert above the topmost existing release heading. +awk -v entry="$ENTRY" ' + !done && /^## \[/ { printf "%s\n", entry; done = 1 } + { print } +' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md + +echo "$NEW" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..b306509 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Smoke-test a built extension image without Docker Desktop. +# +# Verifies the three things a rebuild could plausibly break: +# 1. the Go service starts and creates its unix socket +# 2. every host binary declared in metadata.json is present and executable +# 3. the UI bundle and compose/metadata files were copied into the image +# +# Usage: ./scripts/smoke-test.sh (or: make smoke-test) + +set -euo pipefail + +IMAGE="${1:?usage: smoke-test.sh }" +CONTAINER="dde-smoke-$$" +SOCKET="/tmp/extension-LocalStack.sock" + +fail() { echo "SMOKE FAIL: $*" >&2; exit 1; } +cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +echo "==> Smoke-testing $IMAGE" + +# 1. The service starts and listens. +# +# The image CMD points at /run/guest-services, which only exists inside Docker +# Desktop's VM, so point the service at a writable path instead. +docker run -d --name "$CONTAINER" "$IMAGE" /service -socket "$SOCKET" >/dev/null + +for _ in $(seq 1 30); do + if docker exec "$CONTAINER" test -S "$SOCKET" 2>/dev/null; then + break + fi + sleep 1 +done + +docker exec "$CONTAINER" test -S "$SOCKET" 2>/dev/null \ + || { docker logs "$CONTAINER" >&2 || true; fail "/service did not create $SOCKET within 30s"; } + +docker exec "$CONTAINER" pgrep -f '^/service' >/dev/null \ + || { docker logs "$CONTAINER" >&2 || true; fail "/service exited after creating the socket"; } + +echo " ok: /service is listening on $SOCKET" + +# 2. Every host binary declared in metadata.json is shipped and executable. +# +# Docker Desktop copies these onto the host at install time; a missing or +# non-executable path is a broken extension that still builds cleanly. +BINARIES=$(docker run --rm "$IMAGE" cat /metadata.json \ + | jq -r '.host.binaries[]? | to_entries[] | .value[]? | .path') + +[ -n "$BINARIES" ] || fail "metadata.json declares no host binaries" + +while read -r path; do + [ -n "$path" ] || continue + docker exec "$CONTAINER" test -x "$path" \ + || fail "host binary missing or not executable: $path" + echo " ok: $path" +done <<< "$BINARIES" + +# 3. The UI bundle and the files Docker Desktop reads at install time. +for path in /ui/index.html /docker-compose.yaml /metadata.json /localstack.svg; do + docker exec "$CONTAINER" test -s "$path" \ + || fail "missing or empty: $path" + echo " ok: $path" +done + +echo "==> Smoke test passed" From 38602690dea6d5cef5520cc12d58e3fb513e7828 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Thu, 20 Aug 2026 16:39:25 +0200 Subject: [PATCH 02/12] Trim comments to one-liners --- .github/dependabot.yml | 10 ++---- .github/workflows/security-rebuild.yml | 44 +++++++------------------- scripts/bump-version.sh | 19 +++-------- scripts/smoke-test.sh | 21 +++--------- 4 files changed, 21 insertions(+), 73 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 583ee51..f2fce2c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,13 +1,7 @@ version: 2 updates: - # Security updates only. `open-pull-requests-limit: 0` switches off scheduled - # version updates; Dependabot security updates are unaffected by it. - # - # Scoped to gomod on purpose. The only lang-pkg target Trivy finds in the - # published image is the `service` gobinary, so vm/go.mod is the only manifest - # whose contents can turn into a CVE finding. The UI ships as an esbuild - # bundle with no node_modules, so npm advisories never reach the image and - # would only add PR noise that can't close a security ticket. + # Security updates only: open-pull-requests-limit 0 disables scheduled version updates. + # gomod only — vm/go.mod is the sole manifest Trivy sees, the UI ships as a bundle with no node_modules. - package-ecosystem: gomod directory: /vm schedule: diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index 6b1decc..28702a4 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -1,20 +1,10 @@ name: Weekly Security Rebuild -# Gated security rebuild (COSY-926). -# -# Most CVEs reported against this image come from the Go toolchain baked into -# the `service` binary, not from anything in a manifest: of the 9 findings on -# 2026.7.2, 7 were Go stdlib. Those are fixed by rebuilding on the floating -# `golang:1.25-alpine` tag, which no dependency bot can propose. So once a week -# we rebuild and republish — but ONLY if the rebuild actually clears a CVE, so -# users don't get an update badge in Docker Desktop for a no-op release. -# -# Modelled on the `security-rebuild` job in localstack-pro's aws_flink.yml. +# Rebuild weekly to pick up Go toolchain and OS patches, republishing only when it clears a CVE (COSY-926). on: schedule: - # Tuesdays 08:00 UTC — ~22h ahead of the secops Wednesday 06:00 UTC CVE - # scan, so anything cleared here never gets filed as a finding. + # ~22h ahead of the secops Wednesday 06:00 UTC scan, so anything cleared here is never filed. - cron: "0 8 * * TUE" workflow_dispatch: inputs: @@ -36,8 +26,7 @@ jobs: env: IMAGE: localstack/localstack-docker-desktop PLATFORMS: linux/amd64,linux/arm64 - # Mirror the Trivy vuln DBs to dodge GHCR rate limits — same workaround - # the secops weekly scan and aws_flink.yml use (trivy-action issue #389). + # Mirror the Trivy DBs to dodge GHCR rate limits (trivy-action#389), as aws_flink.yml does. TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db" TRIVY_JAVA_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-java-db,public.ecr.aws/aquasecurity/trivy-java-db" @@ -53,9 +42,7 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - name: Scan published image (before) - # exit-code is left unset: findings are the signal to rebuild, not a - # failure. --ignore-unfixed matches what the secops pipeline files, so - # an unfixable advisory (e.g. GO-2026-5932) can't loop here forever. + # --ignore-unfixed matches what the secops pipeline files, so an unfixable advisory can't loop here forever. uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.IMAGE }}:${{ steps.version.outputs.tag }} @@ -93,9 +80,7 @@ jobs: - name: Build candidate (amd64, for scanning) if: steps.before.outputs.proceed == 'true' - # --pull --no-cache is the whole point of the job: a cached base layer - # would reproduce the same image and clear nothing. Correctness over - # the few minutes a warm cache would save on a weekly run. + # Uncached on purpose: a cached base layer would reproduce the same image and clear nothing. run: | docker buildx build --load --pull --no-cache \ --platform linux/amd64 \ @@ -125,8 +110,7 @@ jobs: comm -13 before_cves.txt after_cves.txt > introduced.txt echo "Cleared by the rebuild:"; cat cleared.txt || true - # Refreshing to newer upstream rarely adds CVEs, and when it does we'd - # still rather ship the net improvement — so this is reported, not gated. + # Reported, not gated: we'd still rather ship a net improvement. if [ -s introduced.txt ]; then echo "::warning::Rebuild introduced new CVEs: $(paste -sd', ' introduced.txt)" fi @@ -138,10 +122,7 @@ jobs: fi - name: Report CVEs a rebuild cannot fix - # A rebuild only moves the base image and the Go toolchain. Anything - # left needs a dependency bump, which is Dependabot's half of COSY-926 - # — surface it instead of silently no-op'ing every week until the SLA - # on the secops ticket burns. + # Surface these instead of no-op'ing weekly until the SLA on the secops ticket burns. if: steps.before.outputs.proceed == 'true' && steps.delta.outputs.cleared == 'false' run: | echo "::warning::Rebuild cleared nothing; these need a dependency bump: $(paste -sd', ' before_cves.txt)" @@ -163,15 +144,13 @@ jobs: - name: Commit and tag if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run - env: - NEW: ${{ steps.bump.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Makefile Dockerfile CHANGELOG.md - git commit -m "Security rebuild ${NEW}" - git tag "v${NEW}" - git push origin HEAD:main "v${NEW}" + git commit -m "Security rebuild ${{ steps.bump.outputs.version }}" + git tag "v${{ steps.bump.outputs.version }}" + git push origin HEAD:main "v${{ steps.bump.outputs.version }}" - name: Login to Docker Hub if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run @@ -181,8 +160,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Publish (multi-arch) - # Published from here rather than by letting build-push-docker.yml pick - # up the tag: pushes made with GITHUB_TOKEN don't trigger workflows. + # Published here rather than via build-push-docker.yml: GITHUB_TOKEN pushes don't trigger workflows. if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run env: NEW: ${{ steps.bump.outputs.version }} diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 1c70f76..9754edd 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -1,14 +1,6 @@ #!/bin/bash -# Bump the patch component of the extension version across every place it lives. -# -# The version appears in three files that must agree, plus the git tag the -# publish workflow keys off: -# Makefile TAG?= -# Dockerfile org.opencontainers.image.version= -# CHANGELOG.md ## [] — -# -# Usage: ./scripts/bump-version.sh [cleared-cves-file] -# Prints the new version on stdout. +# Bump the patch version across the Makefile, the Dockerfile label and the CHANGELOG. +# Usage: ./scripts/bump-version.sh [cleared-cves-file] Prints the new version. set -euo pipefail @@ -17,8 +9,7 @@ CLEARED_FILE="${1:-}" CURRENT=$(sed -n 's/^TAG?=\(.*\)$/\1/p' Makefile) [ -n "$CURRENT" ] || { echo "could not read TAG from Makefile" >&2; exit 1; } -# 2026.8.0 -> 2026.8.1. Security rebuilds only ever move the patch component; -# feature releases set the year.month by hand. +# Security rebuilds only move the patch component; feature releases set year.month by hand. MAJOR_MINOR="${CURRENT%.*}" PATCH="${CURRENT##*.}" [[ "$PATCH" =~ ^[0-9]+$ ]] || { echo "unexpected TAG format: $CURRENT" >&2; exit 1; } @@ -26,8 +17,7 @@ NEW="${MAJOR_MINOR}.$((PATCH + 1))" sed -i "s|^TAG?=${CURRENT}$|TAG?=${NEW}|" Makefile -# This label was stale for several releases before the bump was scripted, so -# rewrite whatever is there rather than matching the previous version. +# Rewrite whatever is there — this label was stale for several releases. sed -i "s|org.opencontainers.image.version=[^ ]*|org.opencontainers.image.version=${NEW}|" Dockerfile NOTE="Security update" @@ -37,7 +27,6 @@ fi ENTRY="## [${NEW}] — $(date -u +%Y-%m-%d)\n\n### Changed\n\n- ${NOTE}\n" -# Insert above the topmost existing release heading. awk -v entry="$ENTRY" ' !done && /^## \[/ { printf "%s\n", entry; done = 1 } { print } diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index b306509..bf235b1 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1,12 +1,6 @@ #!/bin/bash -# Smoke-test a built extension image without Docker Desktop. -# -# Verifies the three things a rebuild could plausibly break: -# 1. the Go service starts and creates its unix socket -# 2. every host binary declared in metadata.json is present and executable -# 3. the UI bundle and compose/metadata files were copied into the image -# -# Usage: ./scripts/smoke-test.sh (or: make smoke-test) +# Verify a built extension image starts and ships everything metadata.json declares. +# Usage: ./scripts/smoke-test.sh (or: make smoke-test) set -euo pipefail @@ -20,10 +14,7 @@ trap cleanup EXIT echo "==> Smoke-testing $IMAGE" -# 1. The service starts and listens. -# -# The image CMD points at /run/guest-services, which only exists inside Docker -# Desktop's VM, so point the service at a writable path instead. +# The image CMD points at /run/guest-services, which only exists inside Docker Desktop's VM. docker run -d --name "$CONTAINER" "$IMAGE" /service -socket "$SOCKET" >/dev/null for _ in $(seq 1 30); do @@ -41,10 +32,7 @@ docker exec "$CONTAINER" pgrep -f '^/service' >/dev/null \ echo " ok: /service is listening on $SOCKET" -# 2. Every host binary declared in metadata.json is shipped and executable. -# -# Docker Desktop copies these onto the host at install time; a missing or -# non-executable path is a broken extension that still builds cleanly. +# Docker Desktop copies these onto the host at install time; a missing one still builds cleanly. BINARIES=$(docker run --rm "$IMAGE" cat /metadata.json \ | jq -r '.host.binaries[]? | to_entries[] | .value[]? | .path') @@ -57,7 +45,6 @@ while read -r path; do echo " ok: $path" done <<< "$BINARIES" -# 3. The UI bundle and the files Docker Desktop reads at install time. for path in /ui/index.html /docker-compose.yaml /metadata.json /localstack.svg; do docker exec "$CONTAINER" test -s "$path" \ || fail "missing or empty: $path" From c64aa2de7d31b54d837de69ea37de791c24962f7 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Mon, 24 Aug 2026 15:11:31 +0200 Subject: [PATCH 03/12] Add PR CI that builds the image and runs the smoke test Dependency bumps are the only way to clear CVEs a rebuild cannot, but nothing verified them before a merge. Run make smoke-test on every PR so a bump is tested on its branch rather than after it lands. --- .github/workflows/pr.yml | 23 +++++++++++++++++++++++ README.md | 10 ++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..2428773 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,23 @@ +name: PR + +on: + pull_request: + branches: [ main ] + +concurrency: + group: pr-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke-test: + name: Build and smoke-test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Build and smoke-test + run: make smoke-test diff --git a/README.md b/README.md index 57e6330..8263f0a 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,14 @@ Rebuilds that change nothing are not released, so no update badge appears in Docker Desktop for a no-op. Anything a rebuild cannot fix needs a dependency bump. Dependabot raises those -against `vm/go.mod` as security updates; they are reviewed and tested by hand, -and released with the same workflow via `workflow_dispatch`. +against `vm/go.mod` as security updates; they are reviewed and released with the +same workflow via `workflow_dispatch`. + +Every PR builds the image and runs the smoke test via +[PR CI](.github/workflows/pr.yml), so a bump is verified on its own branch +before it lands. Testing before the merge matters here: if you test afterwards, +the next weekly rebuild sees the CVE count drop and releases the bump without +the test ever having run. ### Validating a CVE fix locally From b334031e27831bbca57f8c84e8ba4602fed36f88 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Mon, 24 Aug 2026 15:22:50 +0200 Subject: [PATCH 04/12] Never cache builds, and smoke-test through one parameterised target Builds are now always --pull --no-cache, in the Makefile rather than at each call site: a cached base layer reproduces the old image and clears no CVE, and that property matters for every build, not just the weekly rebuild's. IMAGE and TAG were already overridable, so the rebuild's separate buildx call and script invocation collapse into make smoke-test IMAGE=dde-candidate TAG=scan, leaving scripts/smoke-test.sh with a single caller. build-extension already guards the binary download, so that step goes too. --- .github/workflows/build-push-docker.yml | 4 ++-- .github/workflows/security-rebuild.yml | 16 ++-------------- Makefile | 2 +- README.md | 12 +++++------- 4 files changed, 10 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index d13bd3a..d0f4674 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -53,5 +53,5 @@ jobs: platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max + pull: true + no-cache: true diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index 28702a4..f325981 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -74,21 +74,9 @@ jobs: with: platforms: linux/amd64,linux/arm64 - - name: Download CLI binaries + - name: Build and smoke-test candidate (amd64, for scanning) if: steps.before.outputs.proceed == 'true' - run: bash downloadBinaries.sh - - - name: Build candidate (amd64, for scanning) - if: steps.before.outputs.proceed == 'true' - # Uncached on purpose: a cached base layer would reproduce the same image and clear nothing. - run: | - docker buildx build --load --pull --no-cache \ - --platform linux/amd64 \ - --tag dde-candidate:scan . - - - name: Smoke-test candidate - if: steps.before.outputs.proceed == 'true' - run: ./scripts/smoke-test.sh dde-candidate:scan + run: make smoke-test IMAGE=dde-candidate TAG=scan - name: Scan candidate (after) if: steps.before.outputs.proceed == 'true' diff --git a/Makefile b/Makefile index 3a38738..f0a5afe 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ NO_COLOR = \033[m build-extension: ## Build service image to be deployed as a desktop extension ls binaries/linux/localstack-* > /dev/null 2>&1 || ./downloadBinaries.sh - docker build --tag=$(IMAGE):$(TAG) . + docker build --pull --no-cache --tag=$(IMAGE):$(TAG) . install-extension: build-extension ## Install the extension docker extension install $(IMAGE):$(TAG) diff --git a/README.md b/README.md index 8263f0a..984d15a 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,11 @@ the test ever having run. trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \ localstack/localstack-docker-desktop:$(sed -n 's/^TAG?=//p' Makefile) -# Rebuild from scratch and rescan. --pull --no-cache matters: a cached base -# layer reproduces the old image and clears nothing. -docker build --pull --no-cache -t dde-candidate . -trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed dde-candidate - -# Check the rebuild still works before shipping it -make smoke-test +# Rebuild a candidate from scratch and check it still works. Builds are always +# --pull --no-cache, so a cached base layer cannot reproduce the old image and +# clear nothing. This is the same command CI runs. +make smoke-test IMAGE=dde-candidate TAG=scan +trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed dde-candidate:scan ``` Test a change end to end in Docker Desktop with `make install-extension`. From c1831073c717914ff69b20aea61cc5062872e298 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Mon, 24 Aug 2026 15:49:02 +0200 Subject: [PATCH 05/12] Keep the release build cached, refreshed by --pull no-cache on build-push-docker.yml put every push to main and every tag through an uncached QEMU arm64 build, on the path you wait on when shipping a security fix. pull: true invalidates the downstream layers whenever a base digest moves, which is what the freshness actually depends on, and the weekly rebuild covers the apk upgrade layer that only no-cache can re-run. Restores the gha cache: without it the runner has nothing to import, so dropping no-cache alone would have left the build cold anyway. --- .github/workflows/build-push-docker.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index d0f4674..adc33f8 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -54,4 +54,5 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} pull: true - no-cache: true + cache-from: type=gha + cache-to: type=gha,mode=max From a2bece9fd5b5b6c5e8f1b403ed448fa34f5c2381 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 25 Aug 2026 14:53:34 +0200 Subject: [PATCH 06/12] Publish before committing the version bump Pushing the bump and the tag first meant a failed publish left main claiming TAG?=x.y.z for an image that was never pushed. The next run reads TAG from the Makefile and scans that tag, so the scan failed and the job died every week after, with nothing watching for CVEs in the meantime. Publishing first fails safe: the working-tree edits go with the runner and main is untouched. The reverse failure -- published, then the push to main rejected because main moved -- leaves an unreferenced image and republishes the same version next week. --- .github/workflows/security-rebuild.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index f325981..a4e375d 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -130,16 +130,6 @@ jobs: echo "Releasing ${NEW}" echo "version=${NEW}" >> "$GITHUB_OUTPUT" - - name: Commit and tag - if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Makefile Dockerfile CHANGELOG.md - git commit -m "Security rebuild ${{ steps.bump.outputs.version }}" - git tag "v${{ steps.bump.outputs.version }}" - git push origin HEAD:main "v${{ steps.bump.outputs.version }}" - - name: Login to Docker Hub if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -158,6 +148,17 @@ jobs: --platform "${PLATFORMS}" \ --tag "${IMAGE}:${NEW}" . + # After the publish on purpose: a failed publish must not leave TAG pointing at an unpushed image. + - name: Commit and tag + if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Makefile Dockerfile CHANGELOG.md + git commit -m "Security rebuild ${{ steps.bump.outputs.version }}" + git tag "v${{ steps.bump.outputs.version }}" + git push origin HEAD:main "v${{ steps.bump.outputs.version }}" + - name: Summary if: always() run: | From df1a6db8b06c86b0023ab71057ead25240e4c787 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Tue, 25 Aug 2026 15:12:45 +0200 Subject: [PATCH 07/12] Make the target image a dispatch input IMAGE was hardcoded in the job env, so testing the publish path meant editing the workflow to point somewhere safe -- easy to get wrong on a file whose whole job is pushing to production. Now a fork can override it at dispatch. The || fallback keeps the scheduled run working: inputs is null on a schedule trigger, so the default only applies to workflow_dispatch. --- .github/workflows/security-rebuild.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index a4e375d..fa01ce7 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -12,6 +12,10 @@ on: description: "Scan and rebuild, but never publish" type: boolean default: false + image: + description: "Image to scan and republish. Override to test in a fork." + type: string + default: localstack/localstack-docker-desktop concurrency: group: security-rebuild @@ -24,7 +28,7 @@ jobs: permissions: contents: write env: - IMAGE: localstack/localstack-docker-desktop + IMAGE: ${{ inputs.image || 'localstack/localstack-docker-desktop' }} PLATFORMS: linux/amd64,linux/arm64 # Mirror the Trivy DBs to dodge GHCR rate limits (trivy-action#389), as aws_flink.yml does. TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db" From 2d638224ca74e70e42ff978351349ab540ed225a Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Wed, 26 Aug 2026 13:06:38 +0200 Subject: [PATCH 08/12] Derive year.month from the date, not from the previous tag Bumping only the patch meant the date part froze at whatever the last hand-made feature release set, so a security release in December 2026 would still have shipped as 2026.8.x. Take year.month from the current date and bump the patch only within the same month; the first release of a new month restarts at 0. Also drops the CHANGELOG's claim that the project follows semver, which it has not for as long as versions have looked like 2026.8.0. --- CHANGELOG.md | 2 +- scripts/bump-version.sh | 23 +++++++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0670939..58ac796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log -All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project uses calendar versioning (`..`). ## [2026.8.0] — 2026-08-19 diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 9754edd..cc95c66 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -1,5 +1,8 @@ #!/bin/bash -# Bump the patch version across the Makefile, the Dockerfile label and the CHANGELOG. +# Bump the version across the Makefile, the Dockerfile label and the CHANGELOG. +# Versions are calendar-based: .., e.g. 2026.8.1. A release in +# the same month as the last one takes the next patch; the first release of a new +# month moves the date forward and restarts the patch at 0. # Usage: ./scripts/bump-version.sh [cleared-cves-file] Prints the new version. set -euo pipefail @@ -9,11 +12,19 @@ CLEARED_FILE="${1:-}" CURRENT=$(sed -n 's/^TAG?=\(.*\)$/\1/p' Makefile) [ -n "$CURRENT" ] || { echo "could not read TAG from Makefile" >&2; exit 1; } -# Security rebuilds only move the patch component; feature releases set year.month by hand. -MAJOR_MINOR="${CURRENT%.*}" -PATCH="${CURRENT##*.}" -[[ "$PATCH" =~ ^[0-9]+$ ]] || { echo "unexpected TAG format: $CURRENT" >&2; exit 1; } -NEW="${MAJOR_MINOR}.$((PATCH + 1))" +# Split 2026.8.1 into the date part (2026.8) and the patch (1). +CURRENT_DATE="${CURRENT%.*}" +CURRENT_PATCH="${CURRENT##*.}" +[[ "$CURRENT_PATCH" =~ ^[0-9]+$ ]] || { echo "unexpected TAG format: $CURRENT" >&2; exit 1; } + +# 10# forces base 10, so a zero-padded month such as 08 is not read as octal. +TODAY_DATE="$(date -u +%Y).$((10#$(date -u +%m)))" + +if [ "$CURRENT_DATE" = "$TODAY_DATE" ]; then + NEW="${TODAY_DATE}.$((CURRENT_PATCH + 1))" +else + NEW="${TODAY_DATE}.0" +fi sed -i "s|^TAG?=${CURRENT}$|TAG?=${NEW}|" Makefile From 8286d0c0378b522b8260f8af7fe83adee47846a6 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Wed, 26 Aug 2026 13:07:16 +0200 Subject: [PATCH 09/12] Name the two images apart IMAGE meant the published Docker Hub image in the job env, and then line 83 reused the same name for the throwaway local build, which is what made dde-candidate:scan hard to place. Now PUBLISHED_IMAGE is the thing on Docker Hub and localstack-docker-desktop:candidate is the local build being evaluated, with the scan artefacts named after whichever image they describe. --- .github/workflows/security-rebuild.yml | 46 +++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index fa01ce7..e30ff6b 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -28,7 +28,7 @@ jobs: permissions: contents: write env: - IMAGE: ${{ inputs.image || 'localstack/localstack-docker-desktop' }} + PUBLISHED_IMAGE: ${{ inputs.image || 'localstack/localstack-docker-desktop' }} PLATFORMS: linux/amd64,linux/arm64 # Mirror the Trivy DBs to dodge GHCR rate limits (trivy-action#389), as aws_flink.yml does. TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db" @@ -42,26 +42,26 @@ jobs: id: version run: | TAG=$(sed -n 's/^TAG?=\(.*\)$/\1/p' Makefile) - echo "Currently published: ${IMAGE}:${TAG}" + echo "Currently published: ${PUBLISHED_IMAGE}:${TAG}" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - - name: Scan published image (before) + - name: Scan published image # --ignore-unfixed matches what the secops pipeline files, so an unfixable advisory can't loop here forever. uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: ${{ env.IMAGE }}:${{ steps.version.outputs.tag }} + image-ref: ${{ env.PUBLISHED_IMAGE }}:${{ steps.version.outputs.tag }} scanners: vuln severity: HIGH,CRITICAL ignore-unfixed: true format: json - output: before.json + output: published.json - - name: Determine fixable CVEs (before) + - name: Determine fixable CVEs on the published image id: before run: | - jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' before.json | sort -u > before_cves.txt - echo "Fixable HIGH/CRITICAL CVEs on the published image:"; cat before_cves.txt || true - if [ -s before_cves.txt ]; then + jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' published.json | sort -u > published_cves.txt + echo "Fixable HIGH/CRITICAL CVEs on the published image:"; cat published_cves.txt || true + if [ -s published_cves.txt ]; then echo "proceed=true" >> "$GITHUB_OUTPUT" else echo "Published image is clean — nothing to rebuild." @@ -80,26 +80,26 @@ jobs: - name: Build and smoke-test candidate (amd64, for scanning) if: steps.before.outputs.proceed == 'true' - run: make smoke-test IMAGE=dde-candidate TAG=scan + run: make smoke-test IMAGE=localstack-docker-desktop TAG=candidate - - name: Scan candidate (after) + - name: Scan candidate if: steps.before.outputs.proceed == 'true' uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: dde-candidate:scan + image-ref: localstack-docker-desktop:candidate scanners: vuln severity: HIGH,CRITICAL ignore-unfixed: true format: json - output: after.json + output: candidate.json - - name: Compute cleared CVEs (before - after) + - name: Compute cleared CVEs (published - candidate) id: delta if: steps.before.outputs.proceed == 'true' run: | - jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' after.json | sort -u > after_cves.txt - comm -23 before_cves.txt after_cves.txt > cleared.txt - comm -13 before_cves.txt after_cves.txt > introduced.txt + jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' candidate.json | sort -u > candidate_cves.txt + comm -23 published_cves.txt candidate_cves.txt > cleared.txt + comm -13 published_cves.txt candidate_cves.txt > introduced.txt echo "Cleared by the rebuild:"; cat cleared.txt || true # Reported, not gated: we'd still rather ship a net improvement. @@ -117,13 +117,13 @@ jobs: # Surface these instead of no-op'ing weekly until the SLA on the secops ticket burns. if: steps.before.outputs.proceed == 'true' && steps.delta.outputs.cleared == 'false' run: | - echo "::warning::Rebuild cleared nothing; these need a dependency bump: $(paste -sd', ' before_cves.txt)" + echo "::warning::Rebuild cleared nothing; these need a dependency bump: $(paste -sd', ' published_cves.txt)" { echo "### Rebuild cleared no CVEs" echo echo "Still present after a clean rebuild — these need a \`vm/go.mod\` bump, not a rebuild:" echo - sed 's/^/- /' before_cves.txt + sed 's/^/- /' published_cves.txt } >> "$GITHUB_STEP_SUMMARY" - name: Bump version @@ -147,10 +147,10 @@ jobs: env: NEW: ${{ steps.bump.outputs.version }} run: | - echo "Publishing ${IMAGE}:${NEW}, clearing:"; cat cleared.txt + echo "Publishing ${PUBLISHED_IMAGE}:${NEW}, clearing:"; cat cleared.txt docker buildx build --push --pull --no-cache \ --platform "${PLATFORMS}" \ - --tag "${IMAGE}:${NEW}" . + --tag "${PUBLISHED_IMAGE}:${NEW}" . # After the publish on purpose: a failed publish must not leave TAG pointing at an unpushed image. - name: Commit and tag @@ -169,8 +169,8 @@ jobs: { echo "### Weekly security rebuild" echo - echo "- Published image: \`${IMAGE}:${{ steps.version.outputs.tag }}\`" - echo "- Fixable HIGH/CRITICAL before: $(wc -l < before_cves.txt 2>/dev/null || echo 0)" + echo "- Published image: \`${PUBLISHED_IMAGE}:${{ steps.version.outputs.tag }}\`" + echo "- Fixable HIGH/CRITICAL on the published image: $(wc -l < published_cves.txt 2>/dev/null || echo 0)" echo "- Cleared by rebuild: $(wc -l < cleared.txt 2>/dev/null || echo 0)" echo "- Released: ${{ steps.bump.outputs.version || 'no (nothing cleared)' }}" } >> "$GITHUB_STEP_SUMMARY" From d4e6e8682e83ce884d1d584e986e9dac859e1898 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Wed, 26 Aug 2026 13:07:36 +0200 Subject: [PATCH 10/12] Commit as localstack[bot] rather than github-actions[bot] Matches the identity the rest of the org releases under (openapi, localstack-cli, localstack-sdk-python, localstack-pro all set these four vars). git honours the GIT_AUTHOR_*/GIT_COMMITTER_* environment directly, so the git config calls go. Attribution only: the push still uses GITHUB_TOKEN, which the publish step relies on, since pushes made with it do not trigger workflows. Pushing the tag as the bot with a PAT would fire build-push-docker.yml and publish the tag twice. --- .github/workflows/security-rebuild.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index e30ff6b..c2f8775 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -30,6 +30,10 @@ jobs: env: PUBLISHED_IMAGE: ${{ inputs.image || 'localstack/localstack-docker-desktop' }} PLATFORMS: linux/amd64,linux/arm64 + GIT_AUTHOR_NAME: localstack[bot] + GIT_AUTHOR_EMAIL: localstack-bot@users.noreply.github.com + GIT_COMMITTER_NAME: localstack[bot] + GIT_COMMITTER_EMAIL: localstack-bot@users.noreply.github.com # Mirror the Trivy DBs to dodge GHCR rate limits (trivy-action#389), as aws_flink.yml does. TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db" TRIVY_JAVA_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-java-db,public.ecr.aws/aquasecurity/trivy-java-db" @@ -156,8 +160,6 @@ jobs: - name: Commit and tag if: steps.delta.outputs.cleared == 'true' && !inputs.dry_run run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Makefile Dockerfile CHANGELOG.md git commit -m "Security rebuild ${{ steps.bump.outputs.version }}" git tag "v${{ steps.bump.outputs.version }}" From 6eb1c2ccfbd00393dd1ae050d5cc06f9a693a83f Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Wed, 26 Aug 2026 13:07:54 +0200 Subject: [PATCH 11/12] Let Dependabot bump the action SHA pins A commit SHA is immutable, which is the point, but it also means nothing tells you a newer release exists. Dependabot updates the pin and the trailing version comment together, so the pins stay current without being unpinned. Unlike the gomod entry this one wants version updates, so no limit of 0; grouped into a single PR so the pins arrive as one review rather than one per action. --- .github/dependabot.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f2fce2c..57d8a5c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,3 +7,12 @@ updates: schedule: interval: weekly open-pull-requests-limit: 0 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" From 8227bcae516c9d1c1a792a8395b15553fa29d83c Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Thu, 27 Aug 2026 11:17:59 +0200 Subject: [PATCH 12/12] Use the ID-prefixed noreply address for localstack[bot] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plain localstack-bot@users.noreply.github.com form copied from openapi's release.yml can render as an unlinked author: the account (id 88328844) was created in 2021, and GitHub links post-2017 accounts by the ID-prefixed address. localstack-cli's homebrew.yml already commits with that form. Cosmetic — attribution only, no change to how the push authenticates. --- .github/workflows/security-rebuild.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-rebuild.yml b/.github/workflows/security-rebuild.yml index c2f8775..129fe49 100644 --- a/.github/workflows/security-rebuild.yml +++ b/.github/workflows/security-rebuild.yml @@ -31,9 +31,9 @@ jobs: PUBLISHED_IMAGE: ${{ inputs.image || 'localstack/localstack-docker-desktop' }} PLATFORMS: linux/amd64,linux/arm64 GIT_AUTHOR_NAME: localstack[bot] - GIT_AUTHOR_EMAIL: localstack-bot@users.noreply.github.com + GIT_AUTHOR_EMAIL: 88328844+localstack-bot@users.noreply.github.com GIT_COMMITTER_NAME: localstack[bot] - GIT_COMMITTER_EMAIL: localstack-bot@users.noreply.github.com + GIT_COMMITTER_EMAIL: 88328844+localstack-bot@users.noreply.github.com # Mirror the Trivy DBs to dodge GHCR rate limits (trivy-action#389), as aws_flink.yml does. TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db" TRIVY_JAVA_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-java-db,public.ecr.aws/aquasecurity/trivy-java-db"