diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..1728b85 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,251 @@ +name: Build and Push + +# Annotated tags only, per OAN convention: v{SemVer} (e.g. v0.0.1), with an +# optional -rcN pre-release suffix (e.g. v0.1.1-rc3). Anything else (-test, +# -beta, ...) does not match, on purpose. +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+" + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: discovery-service + +jobs: + build: + strategy: + matrix: + include: + - runner: ubuntu-latest + platform: linux/amd64 + arch: amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + arch: arm64 + + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + # One flag per registry, read from repo variables (Settings > Secrets + # and variables > Actions > Variables), so a registry is switched on + # without a workflow edit. GHCR is the only one we use today, so it + # defaults on; everything else defaults off until its secrets exist. + # Adding a registry later is: a flag here, a login step below, and one + # more branch in "Tag and push" and the manifest job's create_manifest + # calls — the build/scan steps never change. + - name: Set registry flags + run: | + echo "GHCR_ENABLED=${{ vars.GHCR_ENABLED != 'false' }}" >> "$GITHUB_ENV" + echo "DOCKERHUB_ENABLED=${{ vars.DOCKERHUB_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "AZURE_CONTAINER_REGISTRY_ENABLED=${{ vars.AZURE_CONTAINER_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "GOOGLE_ARTIFACT_REGISTRY_ENABLED=${{ vars.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a + # Docker subscription — anonymous pulls 401. Unrelated to which + # registry we push the built image to below. + # + # Accepts either credential shape: DHI_AUTH already holds the combined + # base64 "user:token" blob docker login itself writes to config.json, + # or DHI_USERNAME/DHI_PAT are two plain values to combine into one. + # Either way it's config.json's own auth format, so it's written + # directly rather than through docker/login-action, whose + # username/password inputs don't accept a pre-combined blob. + - name: Log in to dhi.io + run: | + if [ -n "${{ secrets.DHI_AUTH }}" ]; then + AUTH="${{ secrets.DHI_AUTH }}" + else + AUTH=$(printf '%s:%s' "${{ secrets.DHI_USERNAME }}" "${{ secrets.DHI_PAT }}" | base64 -w0) + fi + mkdir -p ~/.docker + cat > ~/.docker/config.json <> "$GITHUB_ENV" + + # One local, unpushed tag per arch — the manifest job below stitches + # amd64 and arm64 into the single tag users actually pull. + # + # load: true instead of push: true — the image lands in this runner's + # local Docker daemon first so Trivy can scan the exact bytes about to + # ship, before it is tagged for or pushed to any registry. + - name: Build (local, unpushed) + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + load: true + tags: ${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }} + + # exit-code: 1 fails the job here, before any push below runs — the + # release gate, not just a report. One scan covers every registry. + - name: Trivy image scan (gate) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }} + severity: HIGH,CRITICAL + exit-code: "1" + format: table + + # Re-tag the already-scanned local image for each enabled registry and + # push it there. No rebuild, no re-scan. + - name: Tag and push + run: | + LOCAL="${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }}" + TAG="${{ github.ref_name }}-${{ matrix.arch }}" + + if [ "$GHCR_ENABLED" = "true" ]; then + DEST="ghcr.io/${OWNER}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi + if [ "$DOCKERHUB_ENABLED" = "true" ]; then + DEST="${{ secrets.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi + if [ "$AZURE_CONTAINER_REGISTRY_ENABLED" = "true" ]; then + DEST="${{ secrets.AZURE_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi + if [ "$GOOGLE_ARTIFACT_REGISTRY_ENABLED" = "true" ]; then + DEST="${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GOOGLE_ARTIFACT_REPO }}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi + + manifest: + runs-on: ubuntu-latest + needs: build + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Set registry flags + run: | + echo "GHCR_ENABLED=${{ vars.GHCR_ENABLED != 'false' }}" >> "$GITHUB_ENV" + echo "DOCKERHUB_ENABLED=${{ vars.DOCKERHUB_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "AZURE_CONTAINER_REGISTRY_ENABLED=${{ vars.AZURE_CONTAINER_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "GOOGLE_ARTIFACT_REGISTRY_ENABLED=${{ vars.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + + - name: Log in to GHCR + if: env.GHCR_ENABLED == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + if: env.DOCKERHUB_ENABLED == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Log in to Azure Container Registry + if: env.AZURE_CONTAINER_REGISTRY_ENABLED == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ secrets.AZURE_REGISTRY_NAME }} + username: ${{ secrets.AZURE_REGISTRY_USERNAME }} + password: ${{ secrets.AZURE_REGISTRY_PASSWORD }} + + - name: Authenticate to Google Cloud + if: env.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' + uses: google-github-actions/auth@v3 + with: + token_format: "access_token" + workload_identity_provider: ${{ secrets.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }} + + - name: Configure Docker for Google Artifact Registry + if: env.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' + run: gcloud auth configure-docker ${{ secrets.GCP_REGION }}-docker.pkg.dev --quiet + + - name: Set image owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + # imagetools create makes one manifest list from the two arch-specific + # images the build job just pushed to that registry — this is what + # lets `docker pull` resolve the right arch automatically on either + # amd64 or arm64. One create_manifest call per enabled registry. + - name: Create multi-arch manifests + run: | + create_manifest() { + local BASE="$1" + docker buildx imagetools create \ + -t "${BASE}:${{ github.ref_name }}" \ + -t "${BASE}:latest" \ + "${BASE}:${{ github.ref_name }}-amd64" \ + "${BASE}:${{ github.ref_name }}-arm64" + } + + if [ "$GHCR_ENABLED" = "true" ]; then + create_manifest "ghcr.io/${OWNER}/${{ env.IMAGE_NAME }}" + fi + if [ "$DOCKERHUB_ENABLED" = "true" ]; then + create_manifest "${{ secrets.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}" + fi + if [ "$AZURE_CONTAINER_REGISTRY_ENABLED" = "true" ]; then + create_manifest "${{ secrets.AZURE_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}" + fi + if [ "$GOOGLE_ARTIFACT_REGISTRY_ENABLED" = "true" ]; then + create_manifest "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GOOGLE_ARTIFACT_REPO }}/${{ env.IMAGE_NAME }}" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a05b54c..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -env: - # Pinned rather than inherited: production defaults to noop (A5), and without - # this the whole semantic path would go untested from the day semantic search - # was deferred. - EMBEDDING_PROVIDER: hashing - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - # A glob, not a list: the service module has no dependencies yet, so - # a literal go.sum path would fail to resolve and disable the cache. - cache-dependency-path: "**/go.sum" - - run: make lint - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - # A glob, not a list: the service module has no dependencies yet, so - # a literal go.sum path would fail to resolve and disable the cache. - cache-dependency-path: "**/go.sum" - - run: make build - - run: make test - - security: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - # A glob, not a list: the service module has no dependencies yet, so - # a literal go.sum path would fail to resolve and disable the cache. - cache-dependency-path: "**/go.sum" - - # T4, half one: known vulnerabilities in the Go dependency graph. - - name: govulncheck - run: make security - - # T4, half two: the shipped image. The scan is of the image and not of - # go.sum so that the toolchain pinned in tools/go.mod — which is never - # linked into the binary — cannot fail a release it does not ship in. - - name: Build image - run: docker build -t discovery-service:${{ github.sha }} . - - - name: Trivy image scan - uses: aquasecurity/trivy-action@0.28.0 - with: - image-ref: discovery-service:${{ github.sha }} - severity: HIGH,CRITICAL - exit-code: "1" - format: table diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..c6349e7 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,167 @@ +name: Run Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned rather than inherited: production defaults to noop (A5), and without + # this the whole semantic path would go untested from the day semantic search + # was deferred. + EMBEDDING_PROVIDER: hashing + MIN_COVERAGE: "80" + GOTESTSUM_VERSION: v1.13.0 + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # A glob, not a list: the service module has no dependencies yet, so + # a literal go.sum path would fail to resolve and disable the cache. + cache-dependency-path: "**/go.sum" + + # continue-on-error rather than dropping the job: CLAUDE.md keeps CI + # lint as the safety net behind the pre-commit hook, which is bypassable + # (--no-verify). Advisory here, not a gate — a red lint check must never + # be the reason a PR can't merge, only a reason someone looks. + - run: make lint + continue-on-error: true + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # A glob, not a list: the service module has no dependencies yet, so + # a literal go.sum path would fail to resolve and disable the cache. + cache-dependency-path: "**/go.sum" + - run: make build + + # gotestsum instead of `go test` directly: one tick per package with its + # coverage on the same line, and the output of failing tests only. + # Installed here rather than pinned in tools/go.mod because this is the + # only place that needs it — `make test` stays as it is. + - name: Install gotestsum + run: go install gotest.tools/gotestsum@${{ env.GOTESTSUM_VERSION }} + + # pkgname, not the github-actions format. gotestsum drives `go test + # -json`, which implies -v, so every format that prints per-test output + # prints the output of PASSING tests too — and the acceptance suite logs + # a spec-fetch warning per test. Measured: the github-actions format + # emits 755 collapsible groups, most of them holding a warning about a + # cache fallback that is the intended behaviour. pkgname reports one line + # per package and still prints failing tests in full, with file:line. + # + # One run, not two: the profile costs nothing on top of the suite, so the + # previous `make test` plus `make cover` pair paid for every test twice + # to learn the same pass/fail answer. EMBEDDING_PROVIDER is pinned at the + # workflow level, which is what the Makefile's TEST_ENV does locally. + - name: Test + run: | + gotestsum --format pkgname --format-hide-empty-pkg -- \ + -race -coverprofile=coverage.out -covermode=atomic ./... + + # continue-on-error so a coverage miss still posts its report; the last + # step in the job turns that outcome back into a failure. + - name: Check coverage + id: coverage + continue-on-error: true + uses: vladopajic/go-test-coverage@v2 + with: + profile: coverage.out + threshold-total: ${{ env.MIN_COVERAGE }} + + # go-test-coverage's own `report` output is the same tabwriter dump + # ReportForHuman prints to a terminal, JSON-wrapped as one big string — + # not structured data, so consuming it verbatim just moves the dump into + # the PR comment. Built from coverage.out directly instead: the headline + # from the action's `total-coverage` output (a clean 0-100 integer, no + # parsing needed), and a real markdown table for the rest — only the + # files below the threshold, since a table entry for a file that's + # already fine answers a question nobody asked. No uncovered-line + # ranges: those matter to whoever opens the file, not to a PR summary. + # + # The file-level percentage is computed the same way `go tool cover` + # computes it — covered statements over total statements per file, read + # straight from the profile's `file:pos numStmt count` lines — so it + # can't drift from what the gate itself measured. + - name: Assemble coverage report + run: | + TOTAL="${{ steps.coverage.outputs.total-coverage }}" + if [ -z "$TOTAL" ]; then + { + echo "" + echo "### Test coverage unavailable — see the Test step" + } > coverage-report.md + else + STATUS="passed" + [ "$TOTAL" -lt "$MIN_COVERAGE" ] && STATUS="failed" + MODULE="github.com/OpenAgriNet/discovery-service/" + { + echo "" + echo "### Test coverage: ${TOTAL}% (min ${MIN_COVERAGE}%) — ${STATUS}" + echo + BELOW=$(awk -F'[: ]' -v mod="$MODULE" -v min="$MIN_COVERAGE" 'NR>1 { + f=$1; s=$(NF-1); c=$NF + t[f]+=s; if (c>0) v[f]+=s + } + END { + for (f in t) { p=v[f]/t[f]*100; sub(mod,"",f); if (p coverage-report.md + fi + cat coverage-report.md >> "$GITHUB_STEP_SUMMARY" + + # continue-on-error on both: a fork PR's default GITHUB_TOKEN is + # read-only, so create-or-update-comment 403s there even with + # pull-requests: write declared. A comment that can't post must never + # fail the job — coverage already gated above, this step is cosmetic. + - name: Find existing coverage comment + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/find-comment@v3 + id: find-coverage-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-coverage-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: coverage-report.md + edit-mode: replace + + - name: Gate on minimum coverage + if: steps.coverage.outcome == 'failure' + run: | + echo "::error::coverage is below the ${MIN_COVERAGE}% minimum" + exit 1 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..cd156c5 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,247 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + # Findings are uploaded as SARIF for GitHub's own code scanning UI (Security + # tab, diff annotations) AND posted as a PR comment — the SARIF path alone + # took three clicks (check -> run -> results) to see a finding that used to + # be one scroll away in the PR conversation. pull-requests: write is for + # the comment; security-events: write is for the SARIF upload. + pull-requests: write + security-events: write + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +jobs: + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # T4, half one: the dependency graph as written. This catches what the + # image scan structurally cannot — a vulnerable module that only the test + # suite imports, so it is never linked into the binary and never appears + # in a layer. The first real finding was exactly that shape: + # github.com/moby/go-archive, pulled in by testcontainers. + # + # skip-dirs excludes tools/ (a separate go.mod for build-time tooling, + # e.g. golangci-lint) for the reason that module exists at all: the + # linter's dependency graph is not the binary's, so it cannot fail a + # release it does not ship in. + # + # limit-severities-for-sarif is what makes the SARIF obey `severity`. + # Without it the report carries every severity Trivy knows and the + # Security tab fills with LOW noise the gate below would never fail on. + - name: Trivy dependency scan (report) + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scan-ref: . + skip-dirs: tools + severity: HIGH,CRITICAL + limit-severities-for-sarif: true + exit-code: "0" + format: sarif + output: trivy-deps.sarif + + # A category per scan: without it the second upload replaces the first, + # because GitHub keys an analysis on (ref, category). + - name: Upload dependency findings + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-deps.sarif + category: trivy-dependencies + + # Posted right after the report, on every run — pass or fail, and + # before the image is even built — so a PR shows the dependency finding + # immediately rather than after the whole job finishes. Reads the same + # SARIF just uploaded above; no second scan. + # + # Trivy's SARIF has no structured per-field severity/version columns; + # each finding's detail lives as prose in message.text ("Package: + # ...\nSeverity: ...\n..."), so the table columns below are pulled out + # of that text rather than from dedicated JSON fields. + # continue-on-error: this step's own jq bug is what took the whole job + # down last run — capture(...)?.v is valid on jq 1.8 (what a workstation + # has) but a syntax error on jq 1.7.1 (what this runner image ships), + # so it never reached the image build at all. Fixed below with a `def`, + # which sidesteps the postfix-`?`-then-field-access ambiguity outright, + # but a report-formatting step must never be able to fail the job + # regardless — the gate at the bottom is what decides that. + - name: Assemble Trivy dependency PR comment + if: github.event_name == 'pull_request' + continue-on-error: true + run: | + { + echo "" + echo "### 🛡️ Trivy — Go dependency scan (HIGH,CRITICAL)" + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + echo + jq -r ' + def val(re; default): (capture(re) // {v: default}).v; + [ .runs[].results[]? ] as $found + | if ($found | length) == 0 then + "No HIGH or CRITICAL findings." + else + "| Package | Severity | Installed | Fixed in | Advisory |", + "|---|---|---|---|---|", + ( $found[] | .ruleId as $id | (.message.text // "") as $m | + "| `" + ($m | val("Package: (?[^\\n]+)"; "?")) + "` " + + "| " + ($m | val("Severity: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Installed Version: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Fixed Version: (?[^\\n]+)"; "—")) + " " + + "| [" + $id + "](" + ($m | val("Link: \\[[^]]+\\]\\((?[^)]+)\\)"; "")) + ") |" + ) + end + ' trivy-deps.sarif + } > trivy-deps-comment.md + + - name: Find existing Trivy dependency comment + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/find-comment@v3 + id: find-trivy-deps-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + # continue-on-error: a fork PR's default GITHUB_TOKEN is read-only, so + # this 403s there even with pull-requests: write declared. The gate at + # the bottom of this job is what actually decides pass/fail; a comment + # that can't post must never fail the job over that. + - name: Comment Trivy dependency report on PR + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-trivy-deps-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: trivy-deps-comment.md + edit-mode: replace + + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a + # Docker subscription — anonymous pulls 401. Same login a local build + # needs (see the Dockerfile's FROM comment), just non-interactive here. + # + # Accepts either credential shape: DHI_AUTH already holds the combined + # base64 "user:token" blob docker login itself writes to config.json, + # or DHI_USERNAME/DHI_PAT are two plain values to combine into one. + # Either way it's config.json's own auth format, so it's written + # directly rather than through docker/login-action, whose + # username/password inputs don't accept a pre-combined blob. + - name: Log in to dhi.io + run: | + if [ -n "${{ secrets.DHI_AUTH }}" ]; then + AUTH="${{ secrets.DHI_AUTH }}" + else + AUTH=$(printf '%s:%s' "${{ secrets.DHI_USERNAME }}" "${{ secrets.DHI_PAT }}" | base64 -w0) + fi + mkdir -p ~/.docker + cat > ~/.docker/config.json <" + echo "### 🛡️ Trivy — image scan (HIGH,CRITICAL)" + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + echo + jq -r ' + def val(re; default): (capture(re) // {v: default}).v; + [ .runs[].results[]? ] as $found + | if ($found | length) == 0 then + "No HIGH or CRITICAL findings." + else + "| Package | Severity | Installed | Fixed in | Advisory |", + "|---|---|---|---|---|", + ( $found[] | .ruleId as $id | (.message.text // "") as $m | + "| `" + ($m | val("Package: (?[^\\n]+)"; "?")) + "` " + + "| " + ($m | val("Severity: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Installed Version: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Fixed Version: (?[^\\n]+)"; "—")) + " " + + "| [" + $id + "](" + ($m | val("Link: \\[[^]]+\\]\\((?[^)]+)\\)"; "")) + ") |" + ) + end + ' trivy-image.sarif + } > trivy-image-comment.md + + - name: Find existing Trivy image comment + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/find-comment@v3 + id: find-trivy-image-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Comment Trivy image report on PR + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-trivy-image-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: trivy-image-comment.md + edit-mode: replace + + # Gate on the reports already produced rather than scanning a third and + # fourth time. Two independent scans of the same thing can disagree — + # Trivy refreshes its database on each run — so a gate that rescans can + # fail on a finding that is in no uploaded report, which is the one state + # nobody can act on. Reading the SARIF makes the gate and the Security + # tab the same answer by construction. + # + # Last in the job, so a red build still leaves both reports behind. + # limit-severities-for-sarif above is what makes counting results + # equivalent to Trivy's own `--exit-code 1 --severity HIGH,CRITICAL`. + - name: Gate on HIGH or CRITICAL findings + run: | + fail=0 + for report in trivy-deps.sarif trivy-image.sarif; do + count=$(jq '[.runs[].results[]?] | length' "$report") + echo "${report}: ${count} HIGH/CRITICAL" + if [ "$count" -gt 0 ]; then + jq -r '.runs[].results[]? | "::error::\(.ruleId) \(.message.text)"' "$report" + fail=1 + fi + done + exit "$fail" diff --git a/Dockerfile b/Dockerfile index 3531255..5272307 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,36 @@ # syntax=docker/dockerfile:1 -FROM golang:1.25-alpine AS build +# DHI (Docker Hardened Images) requires `docker login dhi.io` first — a Docker +# subscription credential this build cannot supply. Both stages default to it +# because it's what CI and every shipped image use, but a local build without +# that subscription can override both build-args with public equivalents: +# +# docker build \ +# --build-arg BUILD_IMAGE=golang:1.27-alpine \ +# --build-arg RUNTIME_IMAGE=gcr.io/distroless/static-debian12:nonroot \ +# --build-arg INSTALL_BUILD_TOOLS=true \ +# -t discovery-service:dev . +# +# INSTALL_BUILD_TOOLS=true is required with the public build image: DHI's +# -dev variant already bundles gcc/g++/musl-dev/make (see below), the public +# golang:alpine image does not, and this build needs a C toolchain regardless +# of which base runs it. +ARG BUILD_IMAGE=dhi.io/golang:1.27-alpine-dev +ARG RUNTIME_IMAGE=dhi.io/static:20260611-musl-alpine +ARG INSTALL_BUILD_TOOLS=false + +# Tag verified against +# github.com/docker-hardened-images/catalog image/golang/alpine-3.24/1.27-dev.yaml; +# that -dev variant already bundles gcc/g++/musl-dev/make, which is why the +# apk add below is conditional rather than unconditional — github.com/uber/h3-go/v4 +# is a cgo binding around Uber's C library (there is no pure-Go H3, and H3 is +# how every geometry in the index is covered), so this build needs a C +# toolchain from somewhere. +FROM ${BUILD_IMAGE} AS build WORKDIR /src -# A C toolchain, because one dependency needs one: github.com/uber/h3-go/v4 is -# a cgo binding around Uber's C library, and under CGO_ENABLED=0 its build -# constraints exclude every file in the package. There is no pure-Go H3, and H3 -# is how every geometry in the index is covered. -RUN apk add --no-cache gcc musl-dev +ARG INSTALL_BUILD_TOOLS +RUN if [ "$INSTALL_BUILD_TOOLS" = "true" ]; then apk add --no-cache gcc g++ musl-dev make; fi # Dependency layer first: go.mod and go.sum change far less often than source, # so the module download stays cached across ordinary code edits. go.su[m] is a @@ -30,13 +53,19 @@ COPY migrations/ ./migrations/ # # cgo is ON and the link is STATIC, which is not a contradiction: musl links # fully static cleanly, so the result still needs no libc at runtime and the -# distroless/static base below stays correct. Dropping -static here does not -# fail the build — it fails the first container start, with a missing loader -# and no Go stack to say why. +# dhi/static base below stays correct. Dropping -static here does not fail the +# build — it fails the first container start, with a missing loader and no Go +# stack to say why. RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w -extldflags '-static'" \ -o /out/discovery-service ./cmd/discovery-service -FROM gcr.io/distroless/static-debian12:nonroot +# dhi/static musl-alpine variant, verified against +# image/static/alpine-3.24/static-musl.yaml — runs as uid 65532 / user +# "nonroot" by default already, same as the distroless image it replaces, and +# matches the musl static link above. distroless/static-nonroot (the public +# override) uses the same uid 65532 convention, so USER below needs no +# override of its own. +FROM ${RUNTIME_IMAGE} AS runtime WORKDIR /app COPY --from=build /out/discovery-service /app/discovery-service @@ -49,6 +78,8 @@ COPY config/common.yaml /app/config/common.yaml # ages with the image. Air-gapped deploys mount a cache file at # VALIDATION_SPEC_CACHE_PATH instead. -USER nonroot:nonroot +# Static hardened images carry no /etc/passwd entries, so the user is numeric, +# not a name — 65532 is the same nonroot uid distroless used. +USER 65532:65532 EXPOSE 8080 ENTRYPOINT ["/app/discovery-service"] diff --git a/go.mod b/go.mod index 7af65c1..cb53813 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/uber/h3-go/v4 v4.5.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -78,5 +78,5 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 5fc99e5..6e7df69 100644 --- a/go.sum +++ b/go.sum @@ -179,8 +179,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -191,8 +191,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=