mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
3668c71860
* feat(security): add SBOM attestations, VEX document, and retire .trivyignore Add OpenVEX triage document (security/vex/sencho.openvex.json) for the 5 residual CVEs vendored inside docker/compose v5.1.2 that were carried over from the previous PR. All 5 are marked not_affected with justifications. Configure Trivy in both CI and release workflows to consume the VEX document via trivy.yaml so the same source of truth gates PR scans and release scans. Delete .trivyignore, which is fully superseded by the VEX file. Add two new release pipeline steps after image publication: - CycloneDX 1.6 SBOM via anchore/sbom-action (also installs syft) - SPDX 2.3 SBOM via syft directly (reuses OCI layer cache from prior step) Both are attached as cosign OCI referrer attestations (keyless, OIDC-signed) and uploaded as GitHub Release assets alongside the OpenVEX file. Bump docker-publish.yml permissions from contents:read to contents:write, required for softprops/action-gh-release to create Release assets. Add docs/operations/verifying-images.mdx with copy-paste verification commands for all supply-chain artifacts: signature, SLSA provenance, CycloneDX SBOM, SPDX SBOM, OpenVEX, and Rekor entry. Update docs.json navigation and expand the Supply chain security section in docs/security.mdx. Add a Verifying Release Artifacts section to SECURITY.md. * fix(vex): cover otel SDK CVE-2026-39883 in rebuilt Docker CLI binary The rebuilt Docker CLI v29.4.0 vendors otel/sdk v1.42.0, which still contains CVE-2026-39883 (BSD kenv PATH hijacking; fixed in v1.43.0). docker-compose v5.1.2 vendors otel/sdk v1.38.0 separately. The original VEX statement only covered the compose binary's location and version, so Trivy's scan of /usr/local/bin/docker was not suppressed. Add a second subcomponent entry for the CLI binary path with the correct vendored version. The not_affected justification (BSD-only code path; we ship linux/amd64 and linux/arm64 only) holds for both binaries. * fix(ci): use list form for vulnerability.vex in trivy.yaml Trivy's config schema requires vulnerability.vex to be a list (mapped to the multi-value --vex flag). The previous bare-string value was silently dropped, so the OpenVEX document was never loaded and HIGH findings already covered by VEX statements still failed the scan. * fix(ci): mirror VEX CVE in .trivyignore for local-image scan Trivy does not emit an OCI purl for locally-built images without a RepoDigests entry (aquasecurity/trivy#9399), so OpenVEX product matching against the CI build target sencho:pr-test resolves to no artifact and every statement is silently dropped. The VEX document remains the canonical triage record and is still attached as a cosign attestation on the published image; this file just mirrors the single CVE that surfaces on the local scan so CI does not block on a finding already triaged in VEX. Updates the Trivy step comment to document the relationship between the two files.
234 lines
11 KiB
YAML
234 lines
11 KiB
YAML
name: Build and Publish Docker Image
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: docker-publish
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
push_to_registry:
|
|
name: Push Docker image to Docker Hub
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
# DOCKERHUB_USERNAME and DOCKERHUB_TOKEN live in the `production` environment,
|
|
# not repo-wide secrets. Any future workflow that tries to push to Docker Hub
|
|
# without declaring this environment will fail to resolve the credentials,
|
|
# which is exactly the blast-radius reduction we want. Adding a required
|
|
# reviewer to the environment in repo settings also turns every release into
|
|
# a manual-approval gate without any workflow change.
|
|
environment: production
|
|
permissions:
|
|
contents: write
|
|
# Required for cosign keyless signing via GitHub OIDC and for uploading
|
|
# SBOM/VEX files to GitHub Releases via softprops/action-gh-release.
|
|
id-token: write
|
|
steps:
|
|
- name: Check out the repo
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- name: Set up QEMU
|
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
|
with:
|
|
platforms: arm64
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
|
|
|
# Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so
|
|
# the `apk upgrade` layer rebuilds at least once per calendar day, even
|
|
# when every other input to the layer is cached. See Dockerfile:115-122
|
|
# for the rationale. Both the pre-publish scan build and the multi-arch
|
|
# push build below consume this so Trivy scans a fresh layer.
|
|
- name: Compute daily apk cache bust value
|
|
id: apk-bust
|
|
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Log in to Docker Hub
|
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Install cosign
|
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
|
|
|
- name: Extract metadata (tags, labels) for Docker
|
|
id: meta
|
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
|
with:
|
|
images: saelix/sencho
|
|
# On a v-tag push we publish:
|
|
# latest always points at the newest release
|
|
# X.Y.Z the immutable semver tag
|
|
# X.Y moving minor tag for users who want latest patch
|
|
# The pre-1.0 constraint hides the {{major}}-only tag until we ship 1.0,
|
|
# since on 0.x every minor is potentially breaking.
|
|
tags: |
|
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
|
|
# Build an amd64-only variant into the local daemon first so Trivy can
|
|
# scan the exact release artifact before it is tagged and pushed. This
|
|
# keeps vulnerable releases out of the `latest` and semver tags that
|
|
# users actually pull. Because the push-build step that follows runs in
|
|
# the same job against the same buildkit daemon, it reuses this build's
|
|
# layers from the daemon's in-memory cache (observed wall-time: the
|
|
# push-build typically finishes faster than the scan-build despite
|
|
# producing multi-arch output). The `cache-from` pull is just a
|
|
# cold-start fallback for the first-ever run or when the buildkit daemon
|
|
# is fresh; we intentionally do NOT write `cache-to` here because the
|
|
# push-build below writes a strictly better (multi-arch, mode=max)
|
|
# cache entry moments later. The tag lives in the `localhost/` namespace
|
|
# so a future `push: false` -> `push: true` mistake cannot publish it.
|
|
# Scanning only amd64 is a defensible proxy for the multi-arch release:
|
|
# distro package CVEs are arch-agnostic at the manifest level, and
|
|
# arch-specific container-relevant CVEs are extraordinarily rare.
|
|
- name: Build release image for pre-publish scan (amd64, loaded)
|
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
with:
|
|
context: .
|
|
push: false
|
|
load: true
|
|
platforms: linux/amd64
|
|
tags: localhost/sencho:release-scan
|
|
cache-from: type=registry,ref=saelix/sencho:buildcache
|
|
build-args: |
|
|
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
|
|
|
- name: Re-scan release image for vulnerabilities (Trivy)
|
|
# Gates the release on the same HIGH/CRITICAL policy as the PR scan.
|
|
# CVEs suppressed via the OpenVEX document (trivy.yaml -> security/vex/
|
|
# sencho.openvex.json) are the single source of truth across PR CI and
|
|
# release CI.
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
image-ref: localhost/sencho:release-scan
|
|
exit-code: '1'
|
|
severity: 'CRITICAL,HIGH'
|
|
format: 'table'
|
|
trivy-config: trivy.yaml
|
|
|
|
# Start the scanned image headless on the runner and poll /api/health
|
|
# until it returns 200. Catches entrypoint regressions, native module
|
|
# ABI breakage, and first-boot crashes on the exact artifact that the
|
|
# multi-arch push below will republish moments later. Intentionally
|
|
# placed BEFORE the publish step so a smoke failure does not move
|
|
# `latest` or the semver tags on Docker Hub. The health endpoint is
|
|
# public and returns before any DB or Docker-socket work, so the
|
|
# container only needs a free port, no env vars, and no volume mounts.
|
|
- name: Smoke test release image (pre-publish)
|
|
run: |
|
|
set -euo pipefail
|
|
# Verify source-built Docker CLI and Compose binaries are present and functional.
|
|
# Both checks run in one container to avoid double start-up overhead.
|
|
docker run --rm --entrypoint sh localhost/sencho:release-scan -c \
|
|
'docker --version && docker compose version'
|
|
|
|
# Not using --rm so that a crashed container sticks around long
|
|
# enough for `docker logs` in the trap to surface the stack trace.
|
|
# The trap force-removes it explicitly whether it is still running
|
|
# or already exited.
|
|
docker run -d --name sencho-smoke -p 1852:1852 localhost/sencho:release-scan
|
|
trap 'docker logs sencho-smoke 2>&1 || true; docker rm -f sencho-smoke >/dev/null 2>&1 || true' EXIT
|
|
for i in $(seq 1 30); do
|
|
if curl -fsS http://localhost:1852/api/health >/dev/null 2>&1; then
|
|
echo "Container healthy after ${i}s"
|
|
curl -s http://localhost:1852/api/health
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "FAILED: /api/health did not become ready within 30s"
|
|
exit 1
|
|
|
|
- name: Build and push Docker image
|
|
id: build
|
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
with:
|
|
context: .
|
|
push: true
|
|
platforms: linux/amd64,linux/arm64
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
labels: ${{ steps.meta.outputs.labels }}
|
|
cache-from: type=registry,ref=saelix/sencho:buildcache
|
|
cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max
|
|
build-args: |
|
|
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
|
# SBOM + provenance attestations are embedded as OCI referrers on the
|
|
# published image. Inspect with: docker buildx imagetools inspect <img>
|
|
sbom: true
|
|
provenance: mode=max
|
|
|
|
- name: Sign published image with cosign (keyless)
|
|
# Signs every tag produced by metadata-action using the ambient GitHub
|
|
# OIDC token. No private keys, no secrets. Users verify with:
|
|
# cosign verify saelix/sencho:<tag> \
|
|
# --certificate-identity-regexp "https://github.com/AnsoCode/Sencho/.*" \
|
|
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
# Each ref is pinned to the immutable digest so signatures are bound to
|
|
# the exact manifest, not the mutable tag pointer.
|
|
env:
|
|
TAGS: ${{ steps.meta.outputs.tags }}
|
|
DIGEST: ${{ steps.build.outputs.digest }}
|
|
run: |
|
|
# Build a single cosign invocation with every tag pinned to the digest.
|
|
# All tags resolve to the same manifest, so one call signs them all and
|
|
# saves N-1 Rekor/Fulcio round trips. On workflow_dispatch $TAGS is empty
|
|
# and the refs array stays empty, so we no-op cleanly.
|
|
refs=()
|
|
while IFS= read -r tag; do
|
|
[ -n "$tag" ] || continue
|
|
refs+=("${tag}@${DIGEST}")
|
|
done <<< "$TAGS"
|
|
if [ ${#refs[@]} -gt 0 ]; then
|
|
cosign sign --yes "${refs[@]}"
|
|
else
|
|
echo "No tags to sign (likely a workflow_dispatch run on a non-tag ref)."
|
|
fi
|
|
|
|
- name: Generate CycloneDX SBOM
|
|
# syft scans the published manifest by digest for richer package
|
|
# extraction than the BuildKit-native SBOM. Also installs syft into
|
|
# PATH so the SPDX step below can reuse the OCI layer cache.
|
|
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.20.2
|
|
with:
|
|
image: saelix/sencho@${{ steps.build.outputs.digest }}
|
|
format: cyclonedx-json
|
|
output-file: sbom.cdx.json
|
|
upload-artifact: false
|
|
|
|
- name: Generate SPDX SBOM
|
|
# Reuses the syft binary and OCI layer cache from the prior step.
|
|
run: |
|
|
syft saelix/sencho@${{ steps.build.outputs.digest }} \
|
|
-o spdx-json=sbom.spdx.json
|
|
|
|
- name: Attest SBOMs and VEX with cosign (keyless)
|
|
# Attaches CycloneDX SBOM, SPDX SBOM, and OpenVEX document as signed
|
|
# OCI referrer attestations on the published digest. Verification
|
|
# commands are documented in docs/operations/verifying-images.mdx.
|
|
env:
|
|
DIGEST: ${{ steps.build.outputs.digest }}
|
|
run: |
|
|
IMAGE_REF="saelix/sencho@${DIGEST}"
|
|
cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "${IMAGE_REF}"
|
|
cosign attest --yes --predicate sbom.spdx.json --type spdxjson "${IMAGE_REF}"
|
|
cosign attest --yes --predicate security/vex/sencho.openvex.json --type openvex "${IMAGE_REF}"
|
|
|
|
- name: Upload SBOM and VEX to GitHub Release
|
|
# Fallback for consumers who do not use cosign; files are also
|
|
# available as signed OCI attestations via the attest step above.
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.3.2
|
|
with:
|
|
files: |
|
|
sbom.cdx.json
|
|
sbom.spdx.json
|
|
security/vex/sencho.openvex.json
|