diff --git a/.github/workflows/lint-container.yml b/.github/workflows/lint-container.yml new file mode 100644 index 0000000..9f39cea --- /dev/null +++ b/.github/workflows/lint-container.yml @@ -0,0 +1,150 @@ +# 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 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. +# +# 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 + # 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.14.0-alpine@sha256:7aba693c1442eb31c0b015c129697cb3b6cb7da589d85c7562f9deb435a6657c \ + "${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 }} diff --git a/.github/workflows/security-container.yml b/.github/workflows/security-container.yml new file mode 100644 index 0000000..c2f5ab7 --- /dev/null +++ b/.github/workflows/security-container.yml @@ -0,0 +1,159 @@ +# 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 + # 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 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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: + 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 }} diff --git a/.github/workflows/smoke-test-container.yml b/.github/workflows/smoke-test-container.yml new file mode 100644 index 0000000..7f24817 --- /dev/null +++ b/.github/workflows/smoke-test-container.yml @@ -0,0 +1,148 @@ +# 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 + # 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://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 + + - 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" diff --git a/docs/container-workflows.md b/docs/container-workflows.md new file mode 100644 index 0000000..664d52b --- /dev/null +++ b/docs/container-workflows.md @@ -0,0 +1,125 @@ +# 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 + +`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 +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`.