diff --git a/.github/actions/dhi-login/action.yml b/.github/actions/dhi-login/action.yml new file mode 100644 index 00000000..4dac2fdb --- /dev/null +++ b/.github/actions/dhi-login/action.yml @@ -0,0 +1,28 @@ +name: Log in to dhi.io +description: > + Authenticates to dhi.io (Docker Hardened Images), gated on a Docker + subscription — anonymous pulls 401. Both Dockerfiles default to dhi.io/* + base images (see each Dockerfile's FROM comment), so every job that builds + or scans an image needs this first. Written directly to + ~/.docker/config.json rather than through docker/login-action, whose + username/password inputs don't accept a pre-combined blob — this matches + the format `docker login` itself writes. + +inputs: + username: + description: DHI credential username (org secret DOCKERHUB_USERNAME — DHI's subscription is tied to a Docker Hub account) + required: true + token: + description: DHI credential token (org secret DOCKERHUB_TOKEN) + required: true + +runs: + using: composite + steps: + - shell: bash + run: | + AUTH=$(printf '%s:%s' "${{ inputs.username }}" "${{ inputs.token }}" | base64 -w0) + mkdir -p ~/.docker + cat > ~/.docker/config.json <> "$GITHUB_OUTPUT" + + - name: Cache Trivy binary and vulnerability DB + uses: actions/cache@v4 + with: + path: | + bin/trivy + ~/.cache/trivy + key: trivy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Makefile') }}-${{ steps.trivy-date.outputs.date }} + restore-keys: | + trivy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Makefile') }}- + + - 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" + + - name: Log in to dhi.io + uses: ./.github/actions/dhi-login + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + token: ${{ secrets.DOCKERHUB_TOKEN }} + + # GITHUB_TOKEN can be denied write_package when creating a brand-new + # package under a personal (non-org) namespace, even with this + # workflow's `packages: write` and the repo's default token permissions + # both set to write — a documented GHCR quirk. GHCR_PAT (classic PAT, + # write:packages) is the standard workaround; falls back to + # GITHUB_TOKEN when no such secret is set, which is enough once the + # package exists or for an org-owned repo. + - 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 + + # GHCR image refs must be lowercase; github.repository_owner preserves + # the owner's real case, so it can't be used as-is. This resolves to + # whichever account or org owns the repo the tag was pushed to — a + # fork pushes under the fork owner, no per-person edit needed. + - name: Set image owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + # Single source of truth for the build-time identity vars (see + # pkg/version) — written to $GITHUB_ENV so the build-push-action step + # below can reference them as build-args the same way a local + # `source install/scripts/version-vars.sh` would. + - name: Resolve version vars + run: | + source install/scripts/version-vars.sh + { + echo "ONIX_VERSION=$ONIX_VERSION" + echo "GIT_COMMIT=$GIT_COMMIT" + echo "GIT_TREE_STATE=$GIT_TREE_STATE" + echo "BUILD_DATE=$BUILD_DATE" + } >> "$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: . + file: Dockerfile.adapter-with-plugins + platforms: ${{ matrix.platform }} + build-args: | + ONIX_VERSION=${{ env.ONIX_VERSION }} + GIT_COMMIT=${{ env.GIT_COMMIT }} + GIT_TREE_STATE=${{ env.GIT_TREE_STATE }} + BUILD_DATE=${{ env.BUILD_DATE }} + load: true + tags: ${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }} + + # trivy-release-gate exits 1 on a finding — the release gate, not just + # a report — before any push below runs. One scan covers every registry. + - name: Trivy image scan (gate) + run: make trivy-release-gate IMAGE=${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }} + + # 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/onix-gcp-terraform-deploy.yml b/.github/workflows/onix-gcp-terraform-deploy.yml index 478979be..4d35fe94 100644 --- a/.github/workflows/onix-gcp-terraform-deploy.yml +++ b/.github/workflows/onix-gcp-terraform-deploy.yml @@ -1,7 +1,9 @@ name: Terraform Deploy to GCP on: - push: + # push trigger disabled — this always fails (missing Gerrit credentials in + # this repo) and ran on every push, including unrelated branches. Kept as + # manual-only until it's confirmed dead or folded into build-and-push.yml. workflow_dispatch: # Manual triggerr jobs: diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..47f4136f --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,107 @@ +name: Run Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # 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: + # fetch-depth: 0 — cover-diff below needs the base branch's history + # locally to compute the PR's changed files; a PR checkout otherwise + # holds only the head commit. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make build + - run: make test-ci + + # continue-on-error so a coverage miss still posts its report; the last + # step in the job turns that outcome back into a failure. Always + # diff-scoped, never whole-repo — main only ever moves by PR merge + # (CLAUDE.md: never commit directly to main), so a PR's own diff is + # already the gate that matters. On a pull_request event that's the PR + # branch vs its target; on a push to main (the merge landing) it's + # github.event.before, the commit main pointed to right before this one. + - name: Check coverage + id: coverage + continue-on-error: true + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + make cover-diff BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + else + make cover-diff BASE_REF="${{ github.event.before }}" + fi + + # Marker goes into the file itself, not just the step summary — it's + # what body-path feeds the PR comment below, and find-comment matches + # on this exact string to update rather than duplicate that comment. + - name: Publish coverage summary + run: | + printf '\n%s\n' "$(cat coverage-report.md)" > coverage-report.md + cat coverage-report.md >> "$GITHUB_STEP_SUMMARY" + + # Always posted, pass or fail or not-applicable — a comment that only + # shows up on failure is ambiguous from the PR itself: did coverage + # run at all, pass, or just never trigger? Always-visible with an + # explicit ✅/❌ (cover-diff's own output) answers that without a + # reviewer having to open the job. Updated in place across runs, not + # reposted, via find-comment below. + # + # 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 minimum — see the summary above" + exit 1 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..cfc42c5c --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,215 @@ +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 + + # The trivy-action this replaced cached both the binary and its + # vulnerability DB across runs; `make trivy-deps`/`trivy-image` alone + # would re-download both (binary ~30MB, DB ~100MB+) on every run. + # Keyed on the Makefile's own hash so a TRIVY_VERSION bump there busts + # the cache automatically, plus a daily date so the DB itself doesn't + # go stale for longer than a day. + - name: Trivy cache date + id: trivy-date + run: echo "date=$(date -u +%F)" >> "$GITHUB_OUTPUT" + + - name: Cache Trivy binary and vulnerability DB + uses: actions/cache@v4 + with: + path: | + bin/trivy + ~/.cache/trivy + key: trivy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Makefile') }}-${{ steps.trivy-date.outputs.date }} + restore-keys: | + trivy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Makefile') }}- + + # 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. + # + # Logic lives in the Makefile's trivy-deps target — same command a local + # `make trivy-deps` runs. + - name: Trivy dependency scan (report) + run: make trivy-deps + + # 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: a report-formatting step must never be able to + # fail the job — 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 + + - name: Log in to dhi.io + uses: ./.github/actions/dhi-login + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + token: ${{ secrets.DOCKERHUB_TOKEN }} + + # Both Dockerfiles default to dhi.io/* base images, and Trivy reads the + # base layers plus the Go build info embedded in the binary — including + # `stdlib`, so a Go toolchain CVE is reported here and nowhere else the + # dependency scan above cannot see. + - name: Build image + run: | + source install/scripts/version-vars.sh + docker build -f Dockerfile.adapter-with-plugins \ + --build-arg ONIX_VERSION="$ONIX_VERSION" \ + --build-arg GIT_COMMIT="$GIT_COMMIT" \ + --build-arg GIT_TREE_STATE="$GIT_TREE_STATE" \ + --build-arg BUILD_DATE="$BUILD_DATE" \ + -t network-adapter:${{ github.sha }} . + # Plain `docker build`, not build-push-action, matches security.yml's + # single-arch report scan — build-and-push.yml is where per-arch, + # buildx-driven builds actually matter. + + - name: Trivy image scan (report) + run: make trivy-image IMAGE=network-adapter:${{ github.sha }} + + - name: Upload image findings + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-image.sarif + category: trivy-image + + - name: Assemble Trivy image PR comment + if: github.event_name == 'pull_request' + continue-on-error: true + run: | + { + echo "" + 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 — logic lives in the Makefile's trivy-gate target so a + # local `make trivy-gate` checks the exact same two files this job + # just wrote. Last in the job, so a red build still leaves both reports + # behind. + - name: Gate on HIGH or CRITICAL findings + run: make trivy-gate || { echo "::error::HIGH or CRITICAL Trivy findings — see job log above"; exit 1; } diff --git a/.gitignore b/.gitignore index 618e9b42..48d66b76 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,14 @@ create_benchmark_issues.sh # Ignore coverage output files coverage.out coverage.html +coverage-report.md + +# Makefile tool/build artifacts (golangci-lint, gotestsum, trivy binaries) +/bin/ +trivy-deps.sarif +trivy-image.sarif +trivy-deps-comment.md +trivy-image-comment.md # Ignore the schema directory used for testing /schemas/ diff --git a/Deployment/deployment.yaml b/Deployment/deployment.yaml index 561fdb5a..443ac03c 100644 --- a/Deployment/deployment.yaml +++ b/Deployment/deployment.yaml @@ -19,6 +19,11 @@ spec: containers: - name: onix-adapter image: "asia-south1-docker.pkg.dev/trusty-relic-370809/onix-adapter-cicd/beckn-onix:latest" #------ + # $(CONFIG_FILE) is expanded by the kubelet against the env below + # before the container starts -- the image's shell-less DHI base + # has no `sh -c` to do that substitution itself. + args: + - "--config=$(CONFIG_FILE)" ports: - containerPort: 8080 env: diff --git a/Dockerfile.adapter b/Dockerfile.adapter index 41dd7ca8..0b1fd641 100644 --- a/Dockerfile.adapter +++ b/Dockerfile.adapter @@ -1,4 +1,23 @@ -FROM golang:1.26.1-bookworm AS builder +# syntax=docker/dockerfile:1 + +# 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 -f Dockerfile.adapter \ +# --build-arg BUILD_IMAGE=golang:1.26.1-bookworm \ +# --build-arg RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base:latest \ +# -t network-adapter:dev . +# +# Tags verified against github.com/docker-hardened-images/catalog +# image/golang/debian-12/1.26-dev.yaml and image/debian-base/debian-12/12.yaml. +# No cgo here, but the -dev variant's gcc/g++/make come bundled regardless — +# nothing extra to install for either the default or the public override. +ARG BUILD_IMAGE=dhi.io/golang:1.26-debian12-dev +ARG RUNTIME_IMAGE=dhi.io/debian-base:bookworm + +FROM ${BUILD_IMAGE} AS builder WORKDIR /workspace/app COPY cmd/adapter ./cmd/adapter @@ -15,16 +34,15 @@ ARG BUILD_DATE=unknown RUN go build -ldflags "-X github.com/beckn-one/beckn-onix/pkg/version.Version=${ONIX_VERSION} -X github.com/beckn-one/beckn-onix/pkg/version.GitCommit=${GIT_COMMIT} -X github.com/beckn-one/beckn-onix/pkg/version.GitTreeState=${GIT_TREE_STATE} -X github.com/beckn-one/beckn-onix/pkg/version.BuildDate=${BUILD_DATE}" -o server cmd/adapter/main.go -# Create a minimal runtime image -FROM cgr.dev/chainguard/wolfi-base:latest -# ✅ Alpine is removed; using minimal Debian +# Debian-based hardened runtime, run-as nonroot by default — kept explicit +# below since RUNTIME_IMAGE can be overridden to wolfi-base, which uses the +# same uid 65532 convention. A shell is required here: CMD below needs +# `sh -c` to expand $CONFIG_FILE. +FROM ${RUNTIME_IMAGE} WORKDIR /app -# Copy only the built binary and plugin COPY --from=builder /workspace/app/server . -# Expose port 8080 EXPOSE 8080 - -# Run the Go server with the config flag from environment variable. -CMD ["sh", "-c", "./server --config=${CONFIG_FILE}"] \ No newline at end of file +USER 65532:65532 +CMD ["sh", "-c", "./server --config=${CONFIG_FILE}"] diff --git a/Dockerfile.adapter-with-plugins b/Dockerfile.adapter-with-plugins index a7ba5e98..6f72a4c0 100644 --- a/Dockerfile.adapter-with-plugins +++ b/Dockerfile.adapter-with-plugins @@ -1,11 +1,31 @@ -FROM golang:1.26.1-bookworm AS builder +# syntax=docker/dockerfile:1 + +# 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 -f Dockerfile.adapter-with-plugins \ +# --build-arg BUILD_IMAGE=golang:1.26.1-bookworm \ +# --build-arg RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base:latest \ +# -t network-adapter-plugins:dev . +# +# Tags verified against github.com/docker-hardened-images/catalog +# image/golang/debian-12/1.26-dev.yaml and image/static/debian-12/static-glibc.yaml. +# -buildmode=plugin, and plugin.Open at runtime, both need a C toolchain and +# a real glibc dynamic linker — the -dev variant's gcc/g++/make come +# bundled, same as the public bookworm override. +ARG BUILD_IMAGE=dhi.io/golang:1.26-debian12-dev +ARG RUNTIME_IMAGE=dhi.io/static:20250419-glibc-debian12 + +FROM ${BUILD_IMAGE} AS builder WORKDIR /workspace/app # Specifically copy only the necessary files and directories COPY cmd/adapter/ ./cmd/adapter/ -COPY core/ ./core/ -COPY pkg/ ./pkg/ +COPY core/ ./core +COPY pkg/ ./pkg COPY install/build-plugins.sh ./install/build-plugins.sh COPY install/scripts/version-vars.sh ./install/scripts/version-vars.sh COPY go.mod . @@ -30,8 +50,12 @@ RUN chmod +x install/build-plugins.sh && \ ONIX_VERSION="${ONIX_VERSION}" GIT_COMMIT="${GIT_COMMIT}" GIT_TREE_STATE="${GIT_TREE_STATE}" BUILD_DATE="${BUILD_DATE}" \ ./install/build-plugins.sh -# Create minimal runtime image -FROM cgr.dev/chainguard/wolfi-base:latest +# glibc-only runtime — no shell, no perl/ncurses/util-linux (the packages +# that were carrying unpatched CVEs on the fuller debian-base image), but +# keeps libc6 so plugin.Open's dynamic linking still works. Explicit USER +# below since RUNTIME_IMAGE can be overridden to wolfi-base, which uses the +# same uid 65532 convention. +FROM ${RUNTIME_IMAGE} WORKDIR /app # Copy binary and plugins built with same Go version @@ -39,5 +63,10 @@ COPY --from=builder /workspace/app/server . COPY --from=builder /workspace/app/plugins ./plugins EXPOSE 8081 - -CMD ["sh", "-c", "./server --config=${CONFIG_FILE}"] +USER 65532:65532 +# Exec form, not `sh -c` — this base has no shell, and no Go code reads +# CONFIG_FILE either. The deployment layer resolves it instead: Kubernetes +# expands $(CONFIG_FILE) in args itself before exec'ing the container (see +# Deployment/deployment.yaml) -- no shell involved on either side. A plain +# `docker run` without that substitution needs an explicit --config flag. +ENTRYPOINT ["/app/server"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0bd4ab21 --- /dev/null +++ b/Makefile @@ -0,0 +1,175 @@ +# OAN Network Adapter — build, test and toolchain targets. +# +# Mirrors discovery-service's Makefile shape (same target names, same +# run-tests/security/build-and-push workflows call these, not raw commands), +# adapted to this module: no DB, no sqlc/migrate, no separate tools/ module — +# golangci-lint, gotestsum and trivy install straight into bin/ via `go +# install`/curl, same as discovery-service does for gotestsum and trivy. + +GO ?= go +BIN_DIR := bin +IMAGE ?= network-adapter:dev + +# CI thresholds/pins live here, not duplicated into workflow env blocks — one +# source of truth for both a local `make` run and the GitHub Actions runner. +MIN_COVERAGE ?= 80 +BASE_REF ?= origin/main +SEVERITY ?= HIGH,CRITICAL +GOLANGCI_LINT_VERSION := v2.5.0 +GOTESTSUM_VERSION := v1.13.0 +TRIVY_VERSION := v0.74.0 + +GOLANGCI_LINT := $(BIN_DIR)/golangci-lint +GOTESTSUM := $(BIN_DIR)/gotestsum +TRIVY := $(BIN_DIR)/trivy + +.DEFAULT_GOAL := help + +## help: list the available targets +help: + @grep -hE '^## ' $(MAKEFILE_LIST) | sed 's/^## / /' | sort + +## build: compile the adapter binary +# Scoped to cmd/adapter, not ./... — pkg/plugin/implementation/*/cmd holds +# `package main` sources meant only for `go build -buildmode=plugin` +# (install/build-plugins.sh), with no func main() for an ordinary build. +build: + $(GO) build -trimpath -o $(BIN_DIR)/ ./cmd/adapter/... + +## test: run the unit and integration suites +test: + $(GO) test -race ./... + +## cover: run the suites and write a coverage profile +cover: + $(GO) test -race -covermode=atomic -coverprofile=coverage.out ./... + +## test-ci: run the suites through gotestsum — one line per package, coverage +## profile written alongside. What run-tests.yml calls; `make test` +## stays the plain everyday entrypoint. +test-ci: $(GOTESTSUM) + $(GOTESTSUM) --format pkgname --format-hide-empty-pkg -- \ + -race -coverprofile=coverage.out -covermode=atomic ./... + +## cover-diff: coverage restricted to files changed vs BASE_REF — a PR review +## needs the diff's number, not the whole repo's. On failure, +## names the changed files dragging the number down (worst first). +cover-diff: coverage.out + @CHANGED=$$(git diff --name-only --diff-filter=ACMR "$(BASE_REF)...HEAD" -- '*.go' | grep -v '_test\.go$$' || true); \ + if [ -z "$$CHANGED" ]; then \ + echo "📊 **Test Coverage: ✅ Passed** — not applicable, no changed Go files vs $(BASE_REF)" | tee coverage-report.md; \ + exit 0; \ + fi; \ + MODULE=$$($(GO) list -m); \ + RESULT=$$(echo "$$CHANGED" | awk -v mod="$$MODULE/" -v min="$(MIN_COVERAGE)" ' \ + NR==FNR { want[mod $$0] = 1; next } \ + { f = $$1; sub(/:.*/, "", f); if (!(f in want)) next; \ + tot[f] += $$(NF-1); if ($$NF > 0) cov[f] += $$(NF-1) } \ + END { \ + T = 0; C = 0; \ + for (f in tot) { \ + T += tot[f]; C += cov[f]; \ + p = int(cov[f] * 100 / tot[f]); \ + disp = f; sub("^" mod, "", disp); \ + if (p < min) print "FILE\t" p "\t" disp; \ + } \ + if (T == 0) { print "EMPTY"; exit } \ + print "TOTAL\t" int(C * 100 / T) \ + }' - coverage.out); \ + if echo "$$RESULT" | grep -q '^EMPTY$$'; then \ + echo "📊 **Test Coverage: ✅ Passed** — not applicable, changed files carry no coverable statements" | tee coverage-report.md; \ + exit 0; \ + fi; \ + PCT=$$(echo "$$RESULT" | awk -F'\t' '$$1=="TOTAL"{print $$2}'); \ + if [ "$$PCT" -lt "$(MIN_COVERAGE)" ]; then \ + BELOW=$$(echo "$$RESULT" | awk -F'\t' '$$1=="FILE"{printf "%s\t%s\n",$$2,$$3}' | sort -n); \ + TOTAL_BELOW=$$(echo "$$BELOW" | wc -l); \ + { \ + echo "📊 **Test Coverage: ❌ Failed** — $${PCT}% of changed lines covered, min $(MIN_COVERAGE)%"; \ + echo; \ + echo "| File | Coverage |"; \ + echo "|---|---|"; \ + echo "$$BELOW" | head -15 | awk -F'\t' '{printf "| `%s` | %s%% |\n", $$2, $$1}'; \ + [ "$$TOTAL_BELOW" -gt 15 ] && echo "| … | $$((TOTAL_BELOW - 15)) more file(s) below $(MIN_COVERAGE)% |"; \ + } > coverage-report.md; \ + else \ + echo "📊 **Test Coverage: ✅ Passed** — $${PCT}% of changed lines covered, min $(MIN_COVERAGE)%" > coverage-report.md; \ + fi; \ + cat coverage-report.md; \ + [ "$$PCT" -ge "$(MIN_COVERAGE)" ] + +## trivy-deps: dependency graph scan (T4), SARIF report. Catches what the +## image scan structurally cannot — a vulnerable module only the +## test suite imports, so it's never linked into the binary. +trivy-deps: $(TRIVY) + $(TRIVY) fs . --severity $(SEVERITY) --exit-code 0 \ + --format sarif --output trivy-deps.sarif + +TRIVY_IMAGE_SCAN = $(TRIVY) image $(IMAGE) --severity $(SEVERITY) + +## trivy-image: shipped image scan (T4), SARIF report. IMAGE names the ref. +trivy-image: $(TRIVY) + $(TRIVY_IMAGE_SCAN) --exit-code 0 --format sarif --output trivy-image.sarif + +## trivy-release-gate: same image scan as trivy-image, but exit 1 on a +## finding instead of writing a report — the pre-push +## release gate build-and-push.yml runs once per +## arch-tagged local image, before anything is pushed. +trivy-release-gate: $(TRIVY) + $(TRIVY_IMAGE_SCAN) --exit-code 1 --format table + +## trivy-gate: fail if either SARIF report already produced by a scan step +## carries a finding. Reads the reports rather than rescanning. +trivy-gate: + @fail=0; \ + for report in trivy-deps.sarif trivy-image.sarif; do \ + count=$$(jq '[.runs[].results[]?] | length' "$$report"); \ + echo "$${report}: $${count} $(SEVERITY)"; \ + if [ "$$count" -gt 0 ]; then \ + jq -r '.runs[].results[]? | "\(.ruleId) \(.message.text)"' "$$report"; \ + fail=1; \ + fi; \ + done; \ + exit $$fail + +## lint: vet, format check and static analysis +lint: $(GOLANGCI_LINT) + $(GOLANGCI_LINT) run ./... + $(GOLANGCI_LINT) fmt --diff ./... + +## fmt: apply the formatters lint checks for +fmt: $(GOLANGCI_LINT) + $(GOLANGCI_LINT) fmt ./... + +## docker: build the adapter image +docker: + docker build -f Dockerfile.adapter -t $(IMAGE) . + +## tools: build the pinned toolchain into bin/ +tools: $(GOLANGCI_LINT) $(GOTESTSUM) $(TRIVY) + +## clean: remove build output and coverage/scan artifacts +clean: + rm -rf $(BIN_DIR) coverage.out coverage-report.md trivy-deps.sarif trivy-image.sarif + +$(GOLANGCI_LINT): + @mkdir -p $(BIN_DIR) + GOBIN=$(abspath $(BIN_DIR)) $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + +# gotestsum is CI-only (see run-tests.yml), so it doesn't belong in the +# adapter's or the linter's dependency graph either one. +$(GOTESTSUM): + @mkdir -p $(BIN_DIR) + GOBIN=$(abspath $(BIN_DIR)) $(GO) install gotest.tools/gotestsum@$(GOTESTSUM_VERSION) + +# The prebuilt release binary, not `go install`: trivy's rpm-db parser needs +# cgo, and its module graph is comparable in size to golangci-lint's for a +# tool nothing here imports — the official install script is what +# aquasecurity itself recommends over building from source for exactly this. +$(TRIVY): + @mkdir -p $(BIN_DIR) + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | \ + sh -s -- -b $(abspath $(BIN_DIR)) $(TRIVY_VERSION) + +.PHONY: help build test cover test-ci cover-diff lint fmt trivy-deps \ + trivy-image trivy-release-gate trivy-gate docker tools clean