From 2f1fb7fa80f4ee4f66beba2bf77d926da5ead68f Mon Sep 17 00:00:00 2001 From: "H.E. Pennypacker" <115990865+pennypacker-he@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:58:45 +0000 Subject: [PATCH] [patch] Harden verified image publication --- .github/workflows/lint-test-build.yml | 82 +++++++++++++++++++++++++-- Dockerfile | 10 +++- ci/workflow_contract_test.go | 76 +++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 ci/workflow_contract_test.go diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index d7fb0e5..d11b3dc 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -1,13 +1,23 @@ name: build-push on: + pull_request: push: + branches: + - main + tags: + - "*" workflow_dispatch: +permissions: + contents: read + jobs: lint-test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 @@ -19,8 +29,14 @@ jobs: with: version: latest - - name: Install dependencies - run: go get . + - name: Validate workflow syntax + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + + - name: Download dependencies + run: go mod download + + - name: Require tidy module metadata + run: go mod tidy -diff - run: go build @@ -35,13 +51,67 @@ jobs: with: files: ./coverage.out fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + image-check: + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + architecture: + - runner: ubuntu-24.04 + platform: linux/amd64 + suffix: amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + suffix: arm64 + runs-on: ${{ matrix.architecture.runner }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 + + - name: Build native image without credentials + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + with: + context: . + load: true + platforms: ${{ matrix.architecture.platform }} + provenance: false + push: false + tags: lightsout:ci-${{ matrix.architecture.suffix }} + + - name: Scan native image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: lightsout:ci-${{ matrix.architecture.suffix }} + format: table + exit-code: "1" + ignore-unfixed: true + severity: HIGH,CRITICAL + vuln-type: os,library run: + name: run + if: ${{ always() && github.event_name == 'pull_request' }} + needs: [lint-test, image-check] + permissions: {} + uses: libops/.github/.github/workflows/pr-status.yaml@057262171193c6d563e7b08ae93d0c4aebfce2bd # credential-free-required-status + with: + needs-json: ${{ toJSON(needs) }} + + publish: + if: github.ref == 'refs/heads/main' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) needs: [lint-test] - uses: libops/.github/.github/workflows/build-push-ghcr.yaml@d7f0ba06b4c8a1f8559892ea65a0b1af4d024dd3 # main + uses: libops/.github/.github/workflows/build-push.yaml@8e27d95846671a9e319f1900e86a488a1d4f39b3 # resilient-guarded-signed-image-publisher + with: + ref: ${{ github.sha }} + expected-main-sha: ${{ github.ref == 'refs/heads/main' && github.sha || '' }} + scan: true + sign: true + certificate-identity: https://github.com/libops/.github/.github/workflows/build-push.yaml@8e27d95846671a9e319f1900e86a488a1d4f39b3 permissions: contents: read packages: write - secrets: inherit + id-token: write diff --git a/Dockerfile b/Dockerfile index a4f7fe8..000593d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/libops/go1.25:main@sha256:f43c9b34f888d2ac53e87c8e061554f826b8eb580863d7b21fd787b6f0378f8f AS builder +FROM golang:1.25.12-alpine@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder SHELL ["/bin/ash", "-o", "pipefail", "-ex", "-c"] @@ -13,10 +13,14 @@ COPY *.go ./ RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/binary . -FROM ghcr.io/libops/go1.25:main@sha256:f43c9b34f888d2ac53e87c8e061554f826b8eb580863d7b21fd787b6f0378f8f +FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b + +RUN apk add --no-cache ca-certificates COPY --from=builder /app/binary /app/binary +USER 65532:65532 + ENV \ PORT=8808 \ INACTIVITY_TIMEOUT=90 \ @@ -25,3 +29,5 @@ ENV \ GCP_ZONE= \ GCP_INSTANCE_NAME= \ LIBOPS_KEEP_ONLINE= + +CMD ["/app/binary"] diff --git a/ci/workflow_contract_test.go b/ci/workflow_contract_test.go new file mode 100644 index 0000000..b905b16 --- /dev/null +++ b/ci/workflow_contract_test.go @@ -0,0 +1,76 @@ +package ci + +import ( + "os" + "strings" + "testing" +) + +const sharedPublisherSHA = "8e27d95846671a9e319f1900e86a488a1d4f39b3" + +func TestImagePublicationWorkflowContract(t *testing.T) { + workflow, err := os.ReadFile("../.github/workflows/lint-test-build.yml") + if err != nil { + t.Fatal(err) + } + contents := string(workflow) + + required := []string{ + "pull_request:", + "if: github.event_name == 'pull_request'", + "if: github.ref == 'refs/heads/main' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))", + "Build native image without credentials", + "persist-credentials: false", + "go mod tidy -diff", + "libops/.github/.github/workflows/build-push.yaml@" + sharedPublisherSHA, + "ref: ${{ github.sha }}", + "expected-main-sha: ${{ github.ref == 'refs/heads/main' && github.sha || '' }}", + "scan: true", + "sign: true", + "certificate-identity: https://github.com/libops/.github/.github/workflows/build-push.yaml@" + sharedPublisherSHA, + "packages: write", + "id-token: write", + } + for _, value := range required { + if !strings.Contains(contents, value) { + t.Errorf("image workflow must contain %q", value) + } + } + + forbidden := []string{ + "build-push.yaml@main", + "build-push-ghcr.yaml", + "secrets: inherit", + "secrets.", + "docker-registry:", + "additional-gar-registry:", + } + for _, value := range forbidden { + if strings.Contains(contents, value) { + t.Errorf("image workflow must not contain %q", value) + } + } +} + +func TestRuntimeImageContract(t *testing.T) { + dockerfile, err := os.ReadFile("../Dockerfile") + if err != nil { + t.Fatal(err) + } + contents := string(dockerfile) + + for _, value := range []string{ + "FROM golang:1.25.12-alpine@sha256:", + "FROM alpine:3.24@sha256:", + "USER 65532:65532", + `CMD ["/app/binary"]`, + } { + if !strings.Contains(contents, value) { + t.Errorf("Dockerfile must contain %q", value) + } + } + + if strings.Contains(contents, "ghcr.io/libops/go") { + t.Error("Lightsout must not depend on a moving LibOps Go utility image") + } +}