From ec18862a672018bba5252f128c6c2e6d67034d8b Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 22:29:01 +0200 Subject: [PATCH 1/6] feat(lint-container): add reusable container-lint workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add hadolint (via hadolint:latest-alpine image to track current releases — the action ships with v2.12.0 which crashes on Docker 25's HEALTHCHECK --start-interval flag) plus optional shellcheck against caller-specified scandirs. Consumers: snipe-it-docker-compose-stack, phpbu-docker — both currently duplicate this exact pattern in repo-local lint workflows. Signed-off-by: Sebastian Mendel --- .github/workflows/lint-container.yml | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .github/workflows/lint-container.yml diff --git a/.github/workflows/lint-container.yml b/.github/workflows/lint-container.yml new file mode 100644 index 0000000..094eaab --- /dev/null +++ b/.github/workflows/lint-container.yml @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Netresearch DTT GmbH +# +# Reusable "Lint Container" — Dockerfile lint (hadolint) and optional +# shellcheck against entrypoint / helper scripts shipped in the image. +# +# Caller pattern (snipe-it-style — Dockerfile at repo root, scripts under +# rootfs/usr/local/bin and bin/): +# +# jobs: +# lint: +# uses: netresearch/.github/.github/workflows/lint-container.yml@main +# with: +# shell-scandirs: ./rootfs/usr/local/bin ./bin +# +# Minimum (Dockerfile only, no shellcheck): +# +# jobs: +# lint: +# uses: netresearch/.github/.github/workflows/lint-container.yml@main +# +# DESIGN NOTES +# ============ +# Hadolint runs via `hadolint/hadolint:latest-alpine` directly rather than +# `hadolint/hadolint-action@v3.1.0`. The action bundles hadolint v2.12.0 +# (Mar 2023), which predates Docker 25's HEALTHCHECK --start-interval flag +# and crashes with "invalid flag: --start-interval" on any Dockerfile that +# uses it. Running the upstream image keeps us on current hadolint. +# +# Shellcheck is OPTIONAL — `shell-scandirs` defaults to empty so repos +# without shipped scripts (e.g. phpbu-docker) skip it cleanly. +# +# SECURITY: pinned action SHAs, harden-runner, read-only permissions, +# `persist-credentials: false` on checkout. + +name: Lint Container (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: "Runner label." + type: string + default: "ubuntu-latest" + timeout-minutes: + description: "Per-job timeout in minutes." + type: number + default: 5 + dockerfile-path: + description: "Path to the Dockerfile to lint." + type: string + default: "Dockerfile" + hadolint-config-path: + description: "Path to a hadolint config file (.hadolint.yaml). Mounted read-only into the lint container if present." + type: string + default: ".hadolint.yaml" + hadolint-failure-threshold: + description: "Hadolint --failure-threshold. One of: error, warning, info, style, ignore, none." + type: string + default: "warning" + shell-scandirs: + description: "Space-separated list of directories to scan with shellcheck (passed to action-shellcheck `scandir:`). Leave empty to skip the shellcheck job entirely." + type: string + default: "" + +permissions: + contents: read + +jobs: + hadolint: + name: hadolint + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: hadolint (via latest-alpine image) + env: + # All ${{ ... }} interpolation goes through env vars rather than + # directly into the run-script body — SonarCloud rule + # githubactions:S7630 (shell injection). + DOCKERFILE_PATH: ${{ inputs.dockerfile-path }} + HADOLINT_CONFIG_PATH: ${{ inputs.hadolint-config-path }} + HADOLINT_FAILURE_THRESHOLD: ${{ inputs.hadolint-failure-threshold }} + run: | + set -euo pipefail + if [ ! -f "$DOCKERFILE_PATH" ]; then + echo "::error::Dockerfile not found at: $DOCKERFILE_PATH" + exit 1 + fi + # Mount the hadolint config read-only if it exists; otherwise run + # without one (hadolint will use its built-in defaults). + CONFIG_ARGS=() + if [ -f "$HADOLINT_CONFIG_PATH" ]; then + CONFIG_ARGS=(-v "${GITHUB_WORKSPACE}/${HADOLINT_CONFIG_PATH}:/.config/hadolint.yaml:ro") + HADOLINT_CMD=(hadolint --config /.config/hadolint.yaml --failure-threshold "$HADOLINT_FAILURE_THRESHOLD" -) + else + HADOLINT_CMD=(hadolint --failure-threshold "$HADOLINT_FAILURE_THRESHOLD" -) + fi + docker run --rm -i \ + "${CONFIG_ARGS[@]}" \ + hadolint/hadolint:latest-alpine \ + "${HADOLINT_CMD[@]}" \ + < "$DOCKERFILE_PATH" + + shellcheck: + name: shellcheck + if: inputs.shell-scandirs != '' + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: shellcheck + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 + with: + scandir: ${{ inputs.shell-scandirs }} From 162214132c15e356f055f1c87576600932562630 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 22:29:11 +0200 Subject: [PATCH 2/6] feat(security-container): add reusable post-build Trivy scan workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan a single already-published image reference with Trivy, SARIF upload to GitHub code-scanning. Caller fans out matrices over tags (latest / rolling / branch tags) — one ref per invocation. Distinct from build-container.yml's build-time scan: this is intended for schedule / workflow_run triggers AFTER push, so it catches CVEs disclosed since the build. Default exit-code 0 (informational) matches the snipe-it pattern where third-party transitive CVEs can't be patched downstream — alert surface is the GitHub Security tab via SARIF. Consumers: snipe-it-docker-compose-stack security.yml (latest+rolling matrix), phpbu-docker security.yml (ci+ci-full matrix). Signed-off-by: Sebastian Mendel --- .github/workflows/security-container.yml | 136 +++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .github/workflows/security-container.yml diff --git a/.github/workflows/security-container.yml b/.github/workflows/security-container.yml new file mode 100644 index 0000000..22812fa --- /dev/null +++ b/.github/workflows/security-container.yml @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Netresearch DTT GmbH +# +# Reusable "Security Container" — post-build Trivy CVE scan against an +# already-published container image reference, with SARIF upload to +# GitHub code-scanning. +# +# Distinct from `build-container.yml`'s build-time scan: this workflow +# is intended to run on `schedule` or `workflow_run` AFTER the image is +# pushed, so it picks up CVEs disclosed since the build. Callers +# typically fan out a matrix over tags (`:latest`, `:rolling`, branch +# tags) and call this reusable once per tag. +# +# Caller pattern (matrix over tags, snipe-it-style): +# +# jobs: +# trivy: +# strategy: +# fail-fast: false +# matrix: +# tag: [latest, rolling] +# uses: netresearch/.github/.github/workflows/security-container.yml@main +# with: +# image-ref: ghcr.io/${{ github.repository_owner }}/snipe-it-php-fpm:${{ matrix.tag }} +# sarif-category: trivy-${{ matrix.tag }} +# +# Single-tag minimum: +# +# jobs: +# trivy: +# uses: netresearch/.github/.github/workflows/security-container.yml@main +# with: +# image-ref: ghcr.io/netresearch/phpbu:latest +# +# DESIGN NOTES +# ============ +# Default `exit-code: 0` (informational). The CVEs landing here are +# typically in third-party base images / transitive dependencies the +# consumer can't patch downstream — gating CI on them means CI is +# permanently red. The alert surface is GitHub Security tab (via SARIF) +# plus the daily-cron job log. Operators may pass `exit-code: 1` to +# make scans blocking for repos that can patch. +# +# SECURITY: pinned action SHAs, harden-runner, read-only permissions +# except `security-events: write` for SARIF upload. + +name: Security Container (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: "Runner label." + type: string + default: "ubuntu-latest" + timeout-minutes: + description: "Per-job timeout in minutes." + type: number + default: 15 + image-ref: + description: "Full image reference to scan, e.g. ghcr.io/owner/img:tag. Caller fans out matrix; one ref per invocation." + type: string + required: true + severity: + description: "Comma-separated Trivy severity filter." + type: string + default: "HIGH,CRITICAL" + exit-code: + description: "Trivy exit code on findings. '0' = informational (default, matches snipe-it pattern), '1' = blocking." + type: string + default: "0" + ignore-unfixed: + description: "Pass --ignore-unfixed to Trivy (hide CVEs with no upstream fix yet)." + type: boolean + default: true + vuln-type: + description: "Trivy --vuln-type. Default 'os,library' (both base image and language packages)." + type: string + default: "os,library" + scanners: + description: "Trivy --scanners. Default 'vuln' for CVE-only; pass 'vuln,config,secret' for full surface." + type: string + default: "vuln" + sarif-category: + description: "GitHub code-scanning category for the SARIF upload (must be unique per tag in a matrix)." + type: string + default: "container-scan" + upload-sarif: + description: "Upload SARIF to GitHub code-scanning. Disable when callers want raw Trivy text output only." + type: boolean + default: true + +permissions: + contents: read + +jobs: + trivy: + name: trivy + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + security-events: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ inputs.image-ref }} + format: "sarif" + output: "trivy-results.sarif" + severity: ${{ inputs.severity }} + exit-code: ${{ inputs.exit-code }} + ignore-unfixed: ${{ inputs.ignore-unfixed }} + limit-severities-for-sarif: "true" + vuln-type: ${{ inputs.vuln-type }} + scanners: ${{ inputs.scanners }} + + - name: Upload SARIF to code-scanning + # Skip on merge_group: the gh-readonly-queue ref is deleted by GitHub + # the moment the merge completes, racing with codeql-action/upload-sarif + # and producing a guaranteed `ref ... not found` failure. + if: always() && inputs.upload-sarif && github.event_name != 'merge_group' && hashFiles('trivy-results.sarif') != '' + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + with: + sarif_file: trivy-results.sarif + category: ${{ inputs.sarif-category }} From f702f997b27a427d52bd3a42ce2c76ecea8a7a87 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 22:29:18 +0200 Subject: [PATCH 3/6] feat(smoke-test-container): add reusable container-structure-test workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the caller's image locally (amd64, --load), then run container-structure-test against a config file in the caller's repo. Scope is intentionally narrow: only the structure-test surface that's shared across container repos. Consumer-specific orchestration (docker compose up + HTTP probe, init-script idempotency, env bootstrap) stays in the caller's workflow — those depend on app-specific routes, healthcheck semantics, and secret-shaped placeholders that don't generalise. Consumers: snipe-it-docker-compose-stack smoke-test.yml, phpbu-docker test.yml — both currently duplicate the buildx + load + install-cst + run-cst pattern. Signed-off-by: Sebastian Mendel --- .github/workflows/smoke-test-container.yml | 140 +++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 .github/workflows/smoke-test-container.yml diff --git a/.github/workflows/smoke-test-container.yml b/.github/workflows/smoke-test-container.yml new file mode 100644 index 0000000..379de46 --- /dev/null +++ b/.github/workflows/smoke-test-container.yml @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Netresearch DTT GmbH +# +# Reusable "Smoke-test Container" — build the caller's image locally +# (amd64, --load into the docker daemon), then run +# container-structure-test against a config file in the caller's repo. +# +# Scope is intentionally narrow: only the structure-test surface that's +# shared across container repos. Consumer-specific orchestration (stack +# boot via docker compose, init-script idempotency, HTTP probes against +# known paths) stays in the caller's workflow — it depends on env +# bootstrap, secret-shaped placeholders, and app-specific HTTP routes +# that don't generalise. +# +# Caller pattern: +# +# jobs: +# smoke: +# uses: netresearch/.github/.github/workflows/smoke-test-container.yml@main +# with: +# image-tag: snipe-it-php-fpm:test +# target: runtime +# cst-config-path: tests/container-structure-test.yaml +# +# Minimum (Dockerfile at root, default cst-config path, last build stage): +# +# jobs: +# smoke: +# uses: netresearch/.github/.github/workflows/smoke-test-container.yml@main +# with: +# image-tag: my-app:test +# +# SECURITY: pinned action SHAs, harden-runner, read-only permissions, +# `persist-credentials: false` on checkout. + +name: Smoke-test Container (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: "Runner label." + type: string + default: "ubuntu-latest" + timeout-minutes: + description: "Per-job timeout in minutes." + type: number + default: 20 + context: + description: "Docker build context." + type: string + default: "." + dockerfile-path: + description: "Path to the Dockerfile." + type: string + default: "Dockerfile" + target: + description: "Build stage / target. Leave empty to build the final stage." + type: string + default: "" + build-args: + description: "Newline-separated KEY=VALUE build args passed to docker/build-push-action." + type: string + default: "" + image-tag: + description: "Local tag to assign the built image (used as --image arg for container-structure-test)." + type: string + required: true + cst-config-path: + description: "Path to the container-structure-test config (relative to repo root)." + type: string + default: "tests/container-structure-test.yaml" + cache-scope: + description: "GHA cache scope for buildx. Defaults to 'smoke-test'; override when multiple smoke-test jobs run in parallel and should keep caches separate." + type: string + default: "smoke-test" + +permissions: + contents: read + +jobs: + structure-test: + name: container-structure-test + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build image (amd64, load locally) + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile-path }} + target: ${{ inputs.target }} + platforms: linux/amd64 + load: true + tags: ${{ inputs.image-tag }} + build-args: ${{ inputs.build-args }} + cache-from: type=gha,scope=${{ inputs.cache-scope }} + cache-to: type=gha,scope=${{ inputs.cache-scope }},mode=max + + - name: Verify container-structure-test config exists + env: + CST_CONFIG_PATH: ${{ inputs.cst-config-path }} + run: | + set -euo pipefail + if [ ! -f "$CST_CONFIG_PATH" ]; then + echo "::error::container-structure-test config not found at: $CST_CONFIG_PATH" + exit 1 + fi + + - name: Install container-structure-test + run: | + set -euo pipefail + curl -fsSL -o /tmp/cst \ + "https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64" + sudo install /tmp/cst /usr/local/bin/container-structure-test + container-structure-test version + + - name: Run container-structure-test + env: + IMAGE_TAG: ${{ inputs.image-tag }} + CST_CONFIG_PATH: ${{ inputs.cst-config-path }} + run: | + container-structure-test test \ + --image "$IMAGE_TAG" \ + --config "$CST_CONFIG_PATH" From 136e41ce4dea85bd3b7b3a3be6c8967f675d5a95 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 22:29:27 +0200 Subject: [PATCH 4/6] docs(container-workflows): document the container-CI reusable workflow set Add docs/container-workflows.md covering the four container-focused reusable workflows (build-container, lint-container, security-container, smoke-test-container) plus the supporting set (scorecard, ghcr-retention, gitleaks, lint-*, auto-merge-deps). Includes minimal caller patterns and an explicit list of what stays in the caller (compose orchestration, init-script idempotency, osv-scanner against language lockfiles, multi-track build matrices) so contributors don't try to push those into the abstraction. Signed-off-by: Sebastian Mendel --- docs/container-workflows.md | 119 ++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/container-workflows.md diff --git a/docs/container-workflows.md b/docs/container-workflows.md new file mode 100644 index 0000000..cfb070e --- /dev/null +++ b/docs/container-workflows.md @@ -0,0 +1,119 @@ +# Reusable Container Workflows + +Org-wide reusable workflows for container repos (Dockerfile-based deliverables +published to ghcr.io). All workflows are versioned by their location in +`netresearch/.github` and pinned by caller refs (`@main` or a SHA). + +Consumer repos: [snipe-it-docker-compose-stack](https://github.com/netresearch/snipe-it-docker-compose-stack), +[phpbu-docker](https://github.com/netresearch/phpbu-docker), more to follow. + +## Workflows + +| Workflow | Purpose | Status | +| -------- | ------- | ------ | +| [`build-container.yml`](../.github/workflows/build-container.yml) | Multi-arch buildx + ghcr push + Trivy + cosign + SLSA provenance | Pre-existing | +| [`lint-container.yml`](../.github/workflows/lint-container.yml) | Dockerfile lint (hadolint) + optional shellcheck on shipped scripts | New | +| [`security-container.yml`](../.github/workflows/security-container.yml) | Post-build Trivy scan against a published image tag, SARIF upload | New | +| [`smoke-test-container.yml`](../.github/workflows/smoke-test-container.yml) | Build locally + run container-structure-test | New | +| [`scorecard.yml`](../.github/workflows/scorecard.yml) | OpenSSF Scorecard | Pre-existing | +| [`ghcr-retention.yml`](../.github/workflows/ghcr-retention.yml) | GHCR tag retention / cleanup | Pre-existing | +| [`gitleaks.yml`](../.github/workflows/gitleaks.yml) | Secret scanning | Pre-existing | +| [`lint-workflows.yml`](../.github/workflows/lint-workflows.yml) | actionlint | Pre-existing | +| [`lint-yaml.yml`](../.github/workflows/lint-yaml.yml) | yamllint | Pre-existing | +| [`auto-merge-deps.yml`](../.github/workflows/auto-merge-deps.yml) | Auto-merge Renovate / Dependabot | Pre-existing | + +## Caller patterns + +Each reusable workflow's header block documents its inputs and a copy-pasteable +caller snippet. The short version: + +### Minimal container repo + +```yaml +# .github/workflows/lint.yml +name: lint +on: + push: { branches: [main] } + pull_request: { branches: [main] } +permissions: { contents: read } +jobs: + container: + uses: netresearch/.github/.github/workflows/lint-container.yml@main + with: + shell-scandirs: ./bin ./rootfs/usr/local/bin + workflows: + uses: netresearch/.github/.github/workflows/lint-workflows.yml@main + yaml: + uses: netresearch/.github/.github/workflows/lint-yaml.yml@main +``` + +```yaml +# .github/workflows/smoke-test.yml +name: smoke-test +on: + pull_request: { branches: [main] } + push: { branches: [main] } +permissions: { contents: read } +jobs: + smoke: + uses: netresearch/.github/.github/workflows/smoke-test-container.yml@main + with: + image-tag: my-app:test + target: runtime + cst-config-path: tests/container-structure-test.yaml +``` + +### Post-build security scan with matrix fan-out + +```yaml +# .github/workflows/security.yml +name: security +on: + workflow_run: + workflows: [build] + types: [completed] + branches: [main] + schedule: + - cron: '0 6 * * *' +permissions: { contents: read } +jobs: + trivy: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + strategy: + fail-fast: false + matrix: + tag: [latest, rolling] + uses: netresearch/.github/.github/workflows/security-container.yml@main + with: + image-ref: ghcr.io/${{ github.repository_owner }}/my-app:${{ matrix.tag }} + sarif-category: trivy-${{ matrix.tag }} +``` + +## What stays in the caller + +The reusable workflows deliberately do NOT cover these pieces — they need +caller-specific bootstrap and don't generalise cleanly: + +- `docker compose config` validation with placeholder `.env` substitution + (needs app-specific `.env.example` shape). +- `docker compose up -d --wait` + HTTP probe (needs known route + healthcheck + semantics). +- Initialisation-script idempotency tests (needs caller's `init.sh` contract). +- `osv-scanner` against language lockfiles inside the image (needs lockfile + path + language; varies per stack). +- Multi-track / multi-composer-mode build matrices (caller defines the matrix + axes and tag schemes; `build-container.yml` handles a single `ref` per call). + +## Conventions + +- SPDX `MIT` header + `Copyright (c) 2026 Netresearch DTT GmbH` on every + workflow file. +- All third-party actions SHA-pinned with a trailing `# vX.Y.Z` comment; + Renovate updates them. +- `harden-runner` (egress audit) as the first step in every job. +- `permissions:` enumerated per job — never `read-all` (SonarCloud S8234). +- `${{ ... }}` interpolation passes through `env:` to `run:` blocks + (SonarCloud S7630, shell-injection hardening). +- `persist-credentials: false` on every `actions/checkout`. +- Caller passes secrets by name (`secrets: { GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} }`), + never `secrets: inherit`. From 1a56c99ae1355b95d40a20e20a29df3c137af567 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 22:45:57 +0200 Subject: [PATCH 5/6] =?UTF-8?q?review(PR-141):=20address=20bot=20reviewer?= =?UTF-8?q?=20feedback=20=E2=80=94=20pin=20supply=20chain,=20fix=20GHCR=20?= =?UTF-8?q?auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review threads from copilot-pull-request-reviewer and gemini-code-assist on PR 141. Five are addressed in this commit; the sixth is a false-positive (separate inline reply with explanation). ADDRESSED (5): 1. security-container.yml — missing `packages: read` permission. Without it, Trivy gets HTTP 401 when scanning a private GHCR image (or an org-locked public package). Added at job level alongside `security-events: write`. 2. security-container.yml — no registry login before Trivy. Trivy spawns its own docker-client and does NOT inherit pre-existing daemon credentials. Added a conditional `docker/login-action` step gated on `startsWith(image-ref, 'ghcr.io/')` so non-GHCR callers (docker.io, internal registries) aren't forced through a bogus GHCR auth. Other-registry callers add their own login in a wrapper. 3. smoke-test-container.yml — container-structure-test was fetched from `https://storage.googleapis.com/.../latest/...` (mutable). Now pinned to v1.22.1 by SHA256 (fa35e89...) fetched from the GitHub release asset, with verification before install. Renovate regex-managers can bump version+sha in tandem. 4. lint-container.yml — hadolint was `hadolint/hadolint:latest-alpine` (mutable tag). Pinned by digest: `:v2.12.0-alpine@sha256:3c206a...`. Bumped via Renovate's docker-tag updater. Design-notes comment block updated to explain the pin. 5. docs/container-workflows.md — caller `permissions:` example was `{ contents: read }` only. Added preamble explaining that `security-container.yml` declares its own job-level permissions (security-events: write, packages: read, contents: read) so the caller's top-level `permissions:` block only needs to cover OTHER jobs. Keeps the documented caller-side minimum at `contents: read`. NOT ADDRESSED (1, push-back via inline reply): 6. lint-container.yml line 135 — Copilot suggested the action version comment `# 2.0.0` should be `# v2.0.0` to "match the established convention". Rejected: `ludeeus/action-shellcheck` upstream uses tags WITHOUT a v prefix (verified via `gh api repos/ludeeus/action-shellcheck/tags`). The SHA pin's comment must match upstream's actual tag string, not a synthetic v-prefixed alias. Reply posted to the review thread. VERIFICATION: - actionlint 1.7.12 clean on all three files. - yamllint exit 0 (only pre-existing warnings about single-space SHA pin comments, which the repo's reusable lint-yaml allows). - All files end with single newline (no trailing blank line). Signed-off-by: Sebastian Mendel --- .github/workflows/lint-container.yml | 18 +++++++++++------ .github/workflows/security-container.yml | 23 ++++++++++++++++++++++ .github/workflows/smoke-test-container.yml | 10 +++++++++- docs/container-workflows.md | 6 ++++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint-container.yml b/.github/workflows/lint-container.yml index 094eaab..41dbb91 100644 --- a/.github/workflows/lint-container.yml +++ b/.github/workflows/lint-container.yml @@ -21,11 +21,16 @@ # # DESIGN NOTES # ============ -# Hadolint runs via `hadolint/hadolint:latest-alpine` directly rather than -# `hadolint/hadolint-action@v3.1.0`. The action bundles hadolint v2.12.0 -# (Mar 2023), which predates Docker 25's HEALTHCHECK --start-interval flag -# and crashes with "invalid flag: --start-interval" on any Dockerfile that -# uses it. Running the upstream image keeps us on current hadolint. +# Hadolint runs via the upstream `hadolint/hadolint` image (pinned by +# tag + digest below) rather than `hadolint/hadolint-action@v3.1.0`. +# The action bundles hadolint v2.12.0 from Mar 2023, which predates +# Docker 25's HEALTHCHECK --start-interval flag and crashes with +# "invalid flag: --start-interval" on any Dockerfile that uses it. +# Pinning the image by digest (rather than a mutable tag like +# `:latest-alpine`) keeps the supply-chain story coherent with the rest +# of this workflow — every external dependency is content-addressed. +# Renovate's docker-tag updater bumps the digest periodically when +# upstream cuts a new hadolint release. # # Shellcheck is OPTIONAL — `shell-scandirs` defaults to empty so repos # without shipped scripts (e.g. phpbu-docker) skip it cleanly. @@ -107,9 +112,10 @@ jobs: else HADOLINT_CMD=(hadolint --failure-threshold "$HADOLINT_FAILURE_THRESHOLD" -) fi + # Pinned by digest (not a mutable tag). Bumped via Renovate. docker run --rm -i \ "${CONFIG_ARGS[@]}" \ - hadolint/hadolint:latest-alpine \ + hadolint/hadolint:v2.12.0-alpine@sha256:3c206a451cec6d486367e758645269fd7d696c5ccb6ff59d8b03b0e45268a199 \ "${HADOLINT_CMD[@]}" \ < "$DOCKERFILE_PATH" diff --git a/.github/workflows/security-container.yml b/.github/workflows/security-container.yml index 22812fa..c2f5ab7 100644 --- a/.github/workflows/security-container.yml +++ b/.github/workflows/security-container.yml @@ -100,7 +100,14 @@ jobs: timeout-minutes: ${{ inputs.timeout-minutes }} permissions: contents: read + # SARIF upload to GitHub code-scanning. security-events: write + # Required to pull from ghcr.io//. The PR + # 141 reviewer flagged this — without it, Trivy gets HTTP 401 + # when the registry is GHCR and the package's visibility is + # private (or even public packages owned by an org that locks + # anonymous pulls). + packages: read steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -112,6 +119,22 @@ jobs: with: persist-credentials: false + - name: Log in to GHCR (only when scanning a ghcr.io image) + # Trivy invokes a fresh docker client for each scan; it does NOT + # inherit any pre-existing daemon credentials. For private GHCR + # packages this means HTTP 401 on layer fetch. Conditional on the + # image-ref starting with `ghcr.io/` so callers scanning images on + # other registries (docker.io, internal registries) don't hit a + # bogus GHCR login. Other-registry callers must add their own + # login step in a wrapper or in the caller's workflow before + # invoking this reusable. + if: startsWith(inputs.image-ref, 'ghcr.io/') + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: diff --git a/.github/workflows/smoke-test-container.yml b/.github/workflows/smoke-test-container.yml index 379de46..7f24817 100644 --- a/.github/workflows/smoke-test-container.yml +++ b/.github/workflows/smoke-test-container.yml @@ -123,10 +123,18 @@ jobs: fi - name: Install container-structure-test + # Pinned to v1.22.1 by SHA256. Fetched from the GitHub release + # asset, not the GCS `latest` mirror — the latter is a mutable + # alias that bypasses our supply-chain pinning. Bumped via + # Renovate (regex-managers in renovate.json). + env: + CST_VERSION: "v1.22.1" + CST_SHA256: "fa35e89512a8978585f76cf41397956d2e3a30c62c2ad3fb857b1597074d14ca" run: | set -euo pipefail curl -fsSL -o /tmp/cst \ - "https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64" + "https://github.com/GoogleContainerTools/container-structure-test/releases/download/${CST_VERSION}/container-structure-test-linux-amd64" + echo "${CST_SHA256} /tmp/cst" | sha256sum -c - sudo install /tmp/cst /usr/local/bin/container-structure-test container-structure-test version diff --git a/docs/container-workflows.md b/docs/container-workflows.md index cfb070e..664d52b 100644 --- a/docs/container-workflows.md +++ b/docs/container-workflows.md @@ -65,6 +65,12 @@ jobs: ### Post-build security scan with matrix fan-out +`security-container.yml` declares its own job-level `permissions:` +(`security-events: write` for SARIF upload, `packages: read` for GHCR +pulls, `contents: read` for checkout). The caller's top-level +`permissions:` block applies only to OTHER jobs in the same workflow — +keep it `contents: read` and let the reusable handle its needs. + ```yaml # .github/workflows/security.yml name: security From a4a763ea1071dab53166facdadeaa994a10555cb Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 21 May 2026 22:53:25 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(lint-container):=20correct=20hadolint?= =?UTF-8?q?=20pin=20=E2=80=94=20v2.12.0=20=E2=86=92=20v2.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (1a56c99) pinned hadolint to v2.12.0-alpine in response to Copilot's "mutable tag" review. That digest pins the March 2023 release, which is exactly the broken version the DESIGN NOTES warned about: it crashes with `invalid flag: --start-interval` on any Dockerfile that uses Docker 25's HEALTHCHECK --start-interval= syntax. I assumed `:latest-alpine` and `:v2.12.0-alpine` shared the same hadolint binary (just rebuilt with newer Alpine base). They don't: upstream cut v2.13.1 (Sep 2025) and v2.14.0 (Sep 2025) without updating my memory of the project. The Phase 2 sub-agent caught the regression on the snipe-it caller PR — `lint.yml` failing on snipe-it's HEALTHCHECK --start-interval=5s. Pin corrected to v2.14.0-alpine (@sha256:7aba693c1442eb31c0b015c129697cb3b6cb7da589d85c7562f9deb435a6657c). Comment block extended with a "MUST be >= v2.13" guard so the same mistake doesn't recur on the next Renovate bump. Verified locally against a Dockerfile with the same HEALTHCHECK form as the snipe-it runtime stage: v2.12.0-alpine: /dev/stdin:2:48 invalid flag: --start-interval v2.14.0-alpine: DL3006 warning (unrelated, just a base-image hint) Signed-off-by: Sebastian Mendel --- .github/workflows/lint-container.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-container.yml b/.github/workflows/lint-container.yml index 41dbb91..9f39cea 100644 --- a/.github/workflows/lint-container.yml +++ b/.github/workflows/lint-container.yml @@ -113,9 +113,16 @@ jobs: HADOLINT_CMD=(hadolint --failure-threshold "$HADOLINT_FAILURE_THRESHOLD" -) fi # Pinned by digest (not a mutable tag). Bumped via Renovate. + # MUST be >= v2.13 — v2.12.0 crashes with "invalid flag: + # --start-interval" on Dockerfiles that use Docker 25's + # HEALTHCHECK --start-interval=… (the original failure mode + # that motivated bypassing hadolint-action@v3.1.0 entirely). + # Verified locally: v2.14.0-alpine passes; v2.12.0-alpine + # fails. If you bump this, retest against a Dockerfile that + # has HEALTHCHECK --start-interval. docker run --rm -i \ "${CONFIG_ARGS[@]}" \ - hadolint/hadolint:v2.12.0-alpine@sha256:3c206a451cec6d486367e758645269fd7d696c5ccb6ff59d8b03b0e45268a199 \ + hadolint/hadolint:v2.14.0-alpine@sha256:7aba693c1442eb31c0b015c129697cb3b6cb7da589d85c7562f9deb435a6657c \ "${HADOLINT_CMD[@]}" \ < "$DOCKERFILE_PATH"