From 3408ed36898a27a3a2447ad02f6014c753b7392a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 06:50:46 +0000 Subject: [PATCH 1/2] Expand CI guides from stubs to full reference docs; update primer and lessons docs/ci/{github-actions,azure-devops,gitlab-ci,jenkins}.md: rewritten from 29-line stubs to comprehensive 280-370 line guides matching the depth of the new CircleCI/CodeBuild/GCB/Bitbucket/Tekton guides added in the PR. Each guide now includes: prerequisites, built-in variables table, full annotated config embedded inline, image ref passing explanation, secrets setup walkthrough, SARIF upload to the platform's security dashboard, artifact storage, and --fail-on-severity gate behaviour. docs/ci-cd-primer.md: added "Supported platforms" table linking all 9 platform guides; updated GitHub/Azure examples to reference the full guides. docs/LESSONS-LEARNED.md: added session entry covering Phase 1 CLI tests, 5-platform CI integration, PR monitoring (runc pre-release fix), and the post-merge stale-doc sweep. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_0189QVFiKNFT5MEsskeEi19t --- docs/LESSONS-LEARNED.md | 9 + docs/ci-cd-primer.md | 28 ++- docs/ci/azure-devops.md | 337 ++++++++++++++++++++++++++++++++-- docs/ci/github-actions.md | 335 ++++++++++++++++++++++++++++++++-- docs/ci/gitlab-ci.md | 286 +++++++++++++++++++++++++++-- docs/ci/jenkins.md | 372 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 1303 insertions(+), 64 deletions(-) diff --git a/docs/LESSONS-LEARNED.md b/docs/LESSONS-LEARNED.md index 3650930..7013d1f 100644 --- a/docs/LESSONS-LEARNED.md +++ b/docs/LESSONS-LEARNED.md @@ -100,3 +100,12 @@ This file is updated by the **Lessons Learned Agent** after each significant tas - **What didn't / was hard:** The `SetURLForTest`/`ResetForTest` production-exposure finding was initially marked actionable but turned out not to be — moving them to `_test.go` breaks cross-package test injection. The `ForTest` naming suffix is the correct Go idiom for this pattern; no fix needed. - **Score (1–5):** 5 — Three real bugs fixed before merge; no false positives that required rollback. - **Lesson / next time:** Always verify that new test packages are included in the CI test glob — it's the most common gap when tests are added late. Run `go test ./...` locally to confirm coverage before opening a PR. + +### Phase 1 CLI tests, 9-platform CI/CD integrations, PR monitoring and stale-doc sweep + +- **When:** Continuation session (PR #2 merge + doc sweep). +- **What we did:** (1) Added three Phase 1 CLI tests to `cmd/cli/main_test.go`: `TestRunScan_dockerfileFindingsMerged` (--dockerfile silent-failure gap), `TestRunScan_sbomWritten` (--sbom output verification), `TestRunScan_offlineSkipsKEV` (confirms zero KEV HTTP calls in offline mode via httptest recording server + kev.SetURLForTest). (2) Added five CI/CD integrations: CircleCI, AWS CodeBuild, Google Cloud Build, Bitbucket Pipelines, Tekton — each with an annotated config template and a comprehensive guide in `docs/ci/`. (3) Monitored PR #2 post-merge: Greptile flagged runc pre-release false-clean (1.2.8-rc1 was returning 0 findings — a real security bug) and a redundant `min()` helper. Fixed both, pushed, then merged. (4) Swept stale docs: COMPARISON.md (3 locations still said "Azure, GitHub, GitLab, Jenkins"), README.md CI section (missing 5 new platforms), ci-cd-primer.md (no mention of new platforms), and the four original CI guide stubs (29 lines each) were all rewritten as comprehensive guides. +- **What worked:** Smart fake Trivy shell script (dispatches on $1 subcommand) scaled cleanly to all three Phase 1 tests without subprocess overhead. Parallel agent dispatch (4 CI integration writers simultaneously) completed all 5 integrations in one session. PR monitoring via `subscribe_pr_activity` + scheduled wakeup caught Greptile's 4/5 finding and fixed it before user touched the branch. Post-merge doc sweep found stale content in 6 files that our PR had not touched. +- **What didn't / was hard:** Greptile's stale comment about the Trivy install URL was pointing at an old commit (pre-fix); needed to verify manually that our current commit already had the fix. The four original CI guides were 29-line stubs with no real content — rewriting to match the new guide quality required parallel agents. +- **Score (1–5):** 5 — Security bug (runc pre-release false-clean) caught and fixed by automated review before merge; all stale docs brought current; 9-platform CI ecosystem fully documented. +- **Lesson / next time:** After adding new features/integrations, grep the entire docs tree for every place the old feature list is mentioned — stale enumeration ("Azure, GitHub, GitLab, Jenkins") appears in COMPARISON, README, primers, and directory listings. A one-liner like `grep -rn "GitLab, Jenkins" docs/` catches them all before they age. diff --git a/docs/ci-cd-primer.md b/docs/ci-cd-primer.md index feec4f4..784d414 100644 --- a/docs/ci-cd-primer.md +++ b/docs/ci-cd-primer.md @@ -100,20 +100,40 @@ See [Baseline test](baseline.md) for all options. No credentials in code — use --- +## Supported platforms + +The scanner works with any CI that can run Docker. Full templates and step-by-step guides exist for all nine major platforms: + +| Platform | Template | Full guide | +|----------|----------|------------| +| GitHub Actions | `ci/github/workflow.example.yml` | [docs/ci/github-actions.md](ci/github-actions.md) | +| GitLab CI | `ci/gitlab/job.example.yml` | [docs/ci/gitlab-ci.md](ci/gitlab-ci.md) | +| Azure DevOps | `ci/azure/pipeline.example.yml` | [docs/ci/azure-devops.md](ci/azure-devops.md) | +| Jenkins | `ci/jenkins/Jenkinsfile.example` | [docs/ci/jenkins.md](ci/jenkins.md) | +| CircleCI | `ci/circleci/config.example.yml` | [docs/ci/circleci.md](ci/circleci.md) | +| AWS CodeBuild | `ci/aws-codebuild/buildspec.yml` | [docs/ci/aws-codebuild.md](ci/aws-codebuild.md) | +| Google Cloud Build | `ci/google-cloud-build/cloudbuild.yaml` | [docs/ci/google-cloud-build.md](ci/google-cloud-build.md) | +| Bitbucket Pipelines | `ci/bitbucket/bitbucket-pipelines.yml` | [docs/ci/bitbucket-pipelines.md](ci/bitbucket-pipelines.md) | +| Tekton | `ci/tekton/scanner-task.yaml` | [docs/ci/tekton.md](ci/tekton.md) | + +Each guide covers: how to pass the image ref from the build step, how to publish reports as artifacts, how to upload SARIF to the platform's security dashboard, and how to set credentials securely. + +--- + ## Example: GitHub Actions (non‑prod) 1. In your repo, add or edit a workflow under `.github/workflows/` (e.g. `scan.yml`). 2. Trigger it on push (or pull_request) to a non‑prod branch. 3. Steps (conceptual): - Checkout code. - - Build your app image (e.g. `docker build -t myapp:latest .`). + - Build your app image (e.g. `docker build -t myapp:${{ github.sha }} .`). - (Optional) Log in to a registry using a secret (e.g. `GHCR` or Docker Hub token). - Build or pull the scanner image, then run: - - `docker run --rm -v $PWD/reports:/reports scanner:latest scan --image myapp:latest --output-dir /reports --format sarif,markdown --fail-on-severity CRITICAL,HIGH` + - `docker run --rm -v $PWD/reports:/reports scanner:latest scan --image myapp:${{ github.sha }} --output-dir /reports --format sarif,markdown --fail-on-severity CRITICAL,HIGH` - Upload the SARIF file (if you use GitHub Code Scanning): `github/codeql-action/upload-sarif`. - Upload the `reports/` folder as an artifact so you can download the Markdown/HTML/CSV. -Templates are in `ci/github/workflow.example.yml`; adapt the image name and the `--fail-on-severity` to your policy. +Full template and variable reference: [docs/ci/github-actions.md](ci/github-actions.md). --- @@ -126,7 +146,7 @@ Templates are in `ci/github/workflow.example.yml`; adapt the image name and the - Publish SARIF with `PublishSecurityAnalysisResults@1`. - Publish the reports folder with `PublishPipelineArtifact@1`. -Templates are in `ci/azure/pipeline.example.yml`; replace the image name and options as above. +Full template and variable reference: [docs/ci/azure-devops.md](ci/azure-devops.md). --- diff --git a/docs/ci/azure-devops.md b/docs/ci/azure-devops.md index cbe15a1..3137aee 100644 --- a/docs/ci/azure-devops.md +++ b/docs/ci/azure-devops.md @@ -1,29 +1,336 @@ -# Azure DevOps integration +# Azure DevOps Pipelines integration -Add the Docker Container Scanner to your pipeline so every build is scanned and SARIF is published to the Security tab. +Add the Docker Container Scanner to your Azure DevOps pipeline so every push is scanned, SARIF results appear in the Security tab, and full reports are available as pipeline artifacts. ## Prerequisites -- Pipeline has Docker available (e.g. `ubuntu-latest` with Docker). -- Scanner image is available: build from this repo or pull from your registry. +- **Microsoft-hosted agent** — the examples below use `ubuntu-latest`, which ships with Docker pre-installed. No additional setup is needed for Docker itself. +- **Docker task** — the `Docker@2` task is built into Azure Pipelines and requires no extra installation. +- **`PublishSecurityAnalysisResults@1` extension** — SARIF publishing to the Security tab requires the [Microsoft Security DevOps](https://marketplace.visualstudio.com/items?itemName=ms-securitydevops.microsoft-security-devops-azdevops) extension (free) installed in your Azure DevOps organisation. Without it, SARIF must be published as a plain artifact instead (see [SARIF publishing](#sarif-publishing)). +- **Scanner image reachable from the agent** — either the public image (`ghcr.io/beejak/docker-scanner:latest`) or one pushed to a private registry your pipeline can authenticate to. -## Steps +## Overview -1. **Build your app image** (or use an existing image ref). -2. **Run the scanner** using the scanner Docker image, passing your image ref and output directory. -3. **Publish SARIF** with `PublishSecurityAnalysisResults@1` so results appear in the Security tab. -4. **Publish report artifacts** (optional) so Markdown/HTML are available as pipeline artifacts. +The example pipeline covers the following steps in order: -## Example +| Step | Task / command | What it does | +|------|---------------|--------------| +| Build application image | `Docker@2` | Builds your app Dockerfile and tags the image with the build ID | +| Build or pull scanner image | `script` | Builds the scanner from source, or pulls a pre-published image | +| Create reports directory | `script` | Creates `$(Build.ArtifactStagingDirectory)/reports` | +| Run container scan | `script` | Runs the scanner container; writes SARIF, Markdown, HTML, and CSV reports | +| Publish SARIF to Security tab | `PublishSecurityAnalysisResults@1` | Surfaces findings in the pipeline Security tab | +| Publish all reports as artifact | `PublishPipelineArtifact@1` | Makes every report file downloadable from the pipeline run | -See [ci/azure/pipeline.example.yml](../../ci/azure/pipeline.example.yml) for a full YAML example. +## Built-in variables -Replace `` with your built image (e.g. `$(containerRegistry)/$(imageRepository):$(tag)` or `app:$(Build.BuildId)`). +Azure Pipelines exposes these predefined variables, which the example pipeline uses to produce unique, traceable image names and paths: -## Registry auth +| Variable | Example value | Used for | +|----------|--------------|---------| +| `Build.BuildId` | `42` | Unique build number — used as the image tag | +| `Build.SourceVersion` | `a3f1b9c2…` | Full Git commit SHA of the triggering commit | +| `Build.Repository.Name` | `my-app` | Repository name — useful for registry path construction | +| `Build.ArtifactStagingDirectory` | `/home/vsts/work/1/a` | Writable staging area; contents are uploaded automatically | +| `Build.SourceBranchName` | `main` | Branch name (short form, without `refs/heads/`) | -If your image or the scanner image is in a private registry, add a Docker service connection and use it in the step that runs the scanner (e.g. login before `docker run`). +Reference any variable in YAML with `$(Variable.Name)`. + +## Full pipeline + +Save this file as `azure-pipelines.yml` at the root of your repository. Inline comments explain each section. + +```yaml +# azure-pipelines.yml +# +# Builds your application image, runs a full container security scan, publishes +# SARIF to the Azure DevOps Security tab, and uploads all reports as a pipeline +# artifact. The build fails if any CRITICAL or HIGH vulnerability is found. + +trigger: + branches: + include: + - main + - master + +pr: + branches: + include: + - main + - master + +# Optional: re-scan nightly even without a new commit, to catch newly published CVEs. +schedules: + - cron: "0 2 * * *" + displayName: Nightly re-scan + branches: + include: [main] + always: true + +pool: + vmImage: ubuntu-latest # Microsoft-hosted; Docker is pre-installed. + +variables: + # Image name incorporates the build ID so every run produces a unique tag. + imageName: "app:$(Build.BuildId)" + + # Use the public scanner image, or substitute a reference to your private registry. + scannerImage: "ghcr.io/beejak/docker-scanner:latest" + + # All report files land here; Azure Pipelines stages this directory for artifact upload. + reportsDir: "$(Build.ArtifactStagingDirectory)/reports" + +steps: + # ── 1. Build your application image ────────────────────────────────────────── + # Docker@2 wraps `docker build` and optionally pushes to a registry. + # Remove `containerRegistry` and `repository` if you do not want to push. + - task: Docker@2 + displayName: Build application image + inputs: + command: build + dockerfile: "**/Dockerfile" # Glob — adjust if your Dockerfile is not at repo root. + tags: "$(Build.BuildId)" + arguments: "-t $(imageName)" + # containerRegistry: my-acr-service-connection # Uncomment to push to ACR after build. + # repository: $(Build.Repository.Name) + + # ── 2. Pull or build the scanner image ─────────────────────────────────────── + # Option A (shown): pull the pre-published scanner image from GHCR. + # Option B: build the scanner from source — replace the script body with: + # docker build -t $(scannerImage) . + - script: docker pull $(scannerImage) + displayName: Pull scanner image + + # ── 3. Create the reports output directory ──────────────────────────────────── + - script: mkdir -p $(reportsDir) + displayName: Create reports directory + + # ── 4. Run the container scan ───────────────────────────────────────────────── + # The scanner runs as a container with access to the host Docker socket so it + # can inspect the image that was just built. Reports are written to $(reportsDir) + # via a bind mount. + # + # --format sarif,markdown,html,csv Writes report.sarif, report.md, report.html, + # report.csv to /reports. + # --check-runtime Checks the running runc/containerd version + # for known advisories. + # --sbom Generates a CycloneDX SBOM alongside reports. + # --fail-on-severity CRITICAL,HIGH Exits 1 if any matching finding is present, + # which fails this pipeline step. + # + # To collect reports even when the policy gate trips, add: + # continueOnError: true + # and re-raise the exit code explicitly (see "Severity gate" section below). + - script: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v $(reportsDir):/reports \ + $(scannerImage) scan \ + --image $(imageName) \ + --output-dir /reports \ + --format sarif,markdown,html,csv \ + --check-runtime \ + --sbom \ + --fail-on-severity CRITICAL,HIGH + displayName: Run container scan + + # ── 5. Publish SARIF to the Security tab ───────────────────────────────────── + # Requires the Microsoft Security DevOps extension in your organisation. + # condition: always() ensures SARIF is published even when the scan step fails. + - task: PublishSecurityAnalysisResults@1 + displayName: Publish SARIF to Security tab + condition: always() + inputs: + SarifFile: "$(reportsDir)/report.sarif" + + # ── 6. Publish all reports as a pipeline artifact ──────────────────────────── + # Makes report.sarif, report.md, report.html, report.csv, and the SBOM available + # on the pipeline run's Artifacts tab. + - task: PublishPipelineArtifact@1 + displayName: Publish scan reports + condition: always() + inputs: + targetPath: "$(reportsDir)" + artifactName: scan-reports + publishLocation: pipeline +``` + +## ACR authentication + +### Recommended: service connection + +A Docker Registry service connection stores credentials in Azure DevOps and never exposes them as plain text in pipeline logs or YAML. + +1. In your Azure DevOps project, go to **Project Settings** > **Service connections**. +2. Click **New service connection** > **Docker Registry**. +3. Choose **Azure Container Registry**, select your subscription, and pick your ACR instance. +4. Name the connection (e.g. `my-acr-connection`) and click **Save**. + +Reference the service connection in the `Docker@2` task: + +```yaml +- task: Docker@2 + displayName: Build and push application image + inputs: + command: buildAndPush + containerRegistry: my-acr-connection # Service connection name. + repository: $(Build.Repository.Name) + dockerfile: "**/Dockerfile" + tags: | + $(Build.BuildId) + $(Build.SourceBranchName) +``` + +To log in to ACR before a plain `docker run` (e.g. to pull the scanner from ACR): + +```yaml +- task: Docker@2 + displayName: Log in to ACR + inputs: + command: login + containerRegistry: my-acr-connection +``` + +### Alternative: environment variables + +If a service connection is not an option, pass credentials as pipeline variables (see [Secrets](#secrets-library-variable-groups-and-pipeline-variables)) and log in with the Docker CLI: + +```yaml +- script: | + echo "$(ACR_PASSWORD)" | docker login \ + $(ACR_LOGIN_SERVER) \ + --username $(ACR_USERNAME) \ + --password-stdin + displayName: Log in to ACR (env var approach) + env: + ACR_PASSWORD: $(ACR_PASSWORD) # Marked secret — injected at runtime, not echoed. +``` + +`$(ACR_LOGIN_SERVER)` is the hostname of your registry, e.g. `myregistry.azurecr.io`. + +The service connection approach is strongly preferred because Azure DevOps rotates and manages the token automatically and the secret never appears in the YAML file. + +## Secrets: Library Variable Groups and Pipeline Variables + +Never hard-code registry passwords, API tokens, or other secrets in `azure-pipelines.yml`. Azure DevOps provides two mechanisms for injecting them safely. + +### Library Variable Groups + +Variable Groups are defined once and linked to multiple pipelines. + +1. Go to **Pipelines** > **Library** > **+ Variable group**. +2. Add variables (e.g. `ACR_USERNAME`, `ACR_PASSWORD`, `SCANNER_TOKEN`). +3. Click the lock icon next to sensitive values to mark them as secrets — they are encrypted at rest and masked in logs. +4. Link the group to your pipeline with the `group` key: + +```yaml +variables: + - group: docker-scanner-secrets # Variable Group name. + - name: imageName + value: "app:$(Build.BuildId)" + - name: reportsDir + value: "$(Build.ArtifactStagingDirectory)/reports" +``` + +Secrets from the group are available as `$(ACR_PASSWORD)` throughout the pipeline but are never echoed to logs. + +### Pipeline Variables + +For per-pipeline secrets, define them directly on the pipeline: + +1. Open your pipeline in Azure DevOps. +2. Click **Edit** > **Variables** (top right). +3. Add the variable name and value, and check **Keep this value secret**. + +Secret pipeline variables must be explicitly mapped into `script` steps via the `env` key — Azure Pipelines does not inject them automatically to prevent accidental exposure: + +```yaml +- script: | + echo "$(REGISTRY_PASSWORD)" | docker login \ + --username $(REGISTRY_USERNAME) \ + --password-stdin + displayName: Registry login + env: + REGISTRY_PASSWORD: $(REGISTRY_PASSWORD) +``` + +## SARIF publishing + +### Via `PublishSecurityAnalysisResults@1` (recommended) + +The `PublishSecurityAnalysisResults@1` task, provided by the [Microsoft Security DevOps](https://marketplace.visualstudio.com/items?itemName=ms-securitydevops.microsoft-security-devops-azdevops) extension, uploads SARIF to the pipeline's **Security** tab where findings are displayed inline with code references. + +**Extension requirement:** an Azure DevOps organisation administrator must install the extension once from the Visual Studio Marketplace. If the task is not available, the pipeline will fail with `##[error] A task is missing`. Ask your organisation admin to install it, or use the fallback below. + +```yaml +- task: PublishSecurityAnalysisResults@1 + displayName: Publish SARIF to Security tab + condition: always() # Run even when the scan step exits non-zero. + inputs: + SarifFile: "$(reportsDir)/report.sarif" +``` + +`condition: always()` is important: without it, a failed scan step (due to `--fail-on-severity`) would skip SARIF publishing, leaving the Security tab empty. + +### Fallback: artifact upload + +If the extension is not available, publish the SARIF file as a pipeline artifact instead: + +```yaml +- task: PublishPipelineArtifact@1 + displayName: Publish SARIF as artifact (fallback) + condition: always() + inputs: + targetPath: "$(reportsDir)/report.sarif" + artifactName: sarif-report + publishLocation: pipeline +``` + +Reviewers can download `report.sarif` from the **Artifacts** tab of the pipeline run and open it in any SARIF viewer (VS Code SARIF extension, GitHub Code Scanning offline upload, etc.). + +## Severity gate and exit code behaviour + +The `--fail-on-severity` flag controls whether the scan step fails the build. + +| Value | Effect | +|-------|--------| +| `--fail-on-severity CRITICAL` | Exits `1` only when CRITICAL findings are present | +| `--fail-on-severity CRITICAL,HIGH` | Exits `1` if CRITICAL or HIGH findings are present | +| _(flag omitted)_ | Always exits `0`; build continues regardless of findings | + +When the scanner exits `1`, Azure Pipelines marks the step as failed and the pipeline run as failed by default. Subsequent steps do not run unless they carry `condition: always()` or `condition: succeededOrFailed()`. + +### Collecting reports on failure + +To ensure reports and SARIF are published even when the gate trips, set `continueOnError: true` on the scan step and re-raise the exit code afterward: + +```yaml +- script: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v $(reportsDir):/reports \ + $(scannerImage) scan \ + --image $(imageName) \ + --output-dir /reports \ + --format sarif,markdown,html,csv \ + --fail-on-severity CRITICAL,HIGH + echo "##vso[task.setvariable variable=SCAN_EXIT_CODE]$?" + displayName: Run container scan + continueOnError: true # Don't halt; let publish steps run first. + +# ... PublishSecurityAnalysisResults and PublishPipelineArtifact steps here ... + +- script: | + if [ "$(SCAN_EXIT_CODE)" != "0" ]; then + echo "##[error] Scanner found CRITICAL/HIGH vulnerabilities. Failing the build." + exit 1 + fi + displayName: Enforce severity gate + condition: always() +``` + +This pattern lets `PublishSecurityAnalysisResults@1` and `PublishPipelineArtifact@1` always run, then re-raises the failure so the pipeline run is still marked as failed. ## CLI reference -See [CLI reference](../cli-reference.md) for all flags (`--severity`, `--offline`, `--baseline-image`, etc.). +For the full list of scanner flags — including `--severity`, `--offline`, `--baseline-image`, `--sbom`, `--check-runtime`, and output format options — see [CLI reference](../cli-reference.md). diff --git a/docs/ci/github-actions.md b/docs/ci/github-actions.md index c25f562..d6ace80 100644 --- a/docs/ci/github-actions.md +++ b/docs/ci/github-actions.md @@ -1,29 +1,336 @@ # GitHub Actions integration -Add the Docker Container Scanner to your workflow so every push/PR is scanned and SARIF is uploaded to the Security tab. +Add the Docker Container Scanner to your GitHub Actions workflow so every push and pull request is scanned, findings are surfaced in the Security tab as Code Scanning alerts, and full reports are available as downloadable artifacts. ## Prerequisites -- Workflow runs on a runner with Docker (e.g. `ubuntu-latest`). -- Scanner image is available: build from this repo or use a published image. +- **Runner**: the workflow must run on a runner that has Docker installed. `ubuntu-latest` (GitHub-hosted) satisfies this out of the box. +- **Docker socket**: the scanner runs as a container and needs to reach the host Docker daemon via `/var/run/docker.sock`. GitHub-hosted Ubuntu runners expose this socket without any additional configuration. +- **Permissions**: the job needs `security-events: write` to upload SARIF to GitHub Code Scanning, and `contents: read` to check out the repository. If you are authenticating to GHCR with the built-in `GITHUB_TOKEN`, you also need `packages: read` (or `packages: write` if you are pushing images). +- **Scanner image**: either build the scanner from this repository (shown in the example below) or pull a published image from `ghcr.io/beejak/docker-scanner:latest`. -## Steps +## Overview -1. **Build your app image** (or use an existing image ref). -2. **Run the scanner** using the scanner Docker image, passing your image ref and output directory. -3. **Upload SARIF** with `github/codeql-action/upload-sarif` so results appear in the Security tab. -4. **Upload report artifact** (optional) so Markdown/HTML are available as workflow artifacts. +The example workflow defines a single job, `scan`, with the following steps: -## Example +| Step | What it does | +|------|-------------| +| Checkout | Clones the repository so the `Dockerfile` is available to build from | +| Build application image | Runs `docker build` and tags the result with the commit SHA | +| Build scanner image | Builds the scanner image once; reused across all subsequent steps | +| Create reports directory | Creates the host-side directory that will be bind-mounted into the scanner | +| Run container scan | Executes the scan, writes SARIF, Markdown, HTML, CSV, and a CycloneDX SBOM | +| Upload SARIF | Sends `report.sarif` to GitHub Code Scanning; runs even after a policy failure | +| Upload artifact | Packages every report file as a downloadable workflow artifact | -See [ci/github/workflow.example.yml](../../ci/github/workflow.example.yml) for a full workflow example. +## Trigger events -Replace `` with your built image (e.g. `app:latest` or `ghcr.io/${{ github.repository }}:latest`). +The example workflow responds to three event types: -## Secrets +```yaml +on: + push: + branches: ["main", "master"] + pull_request: + schedule: + - cron: "0 2 * * *" +``` -If your image or the scanner image is in a private registry, add secrets (e.g. `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`) and log in before running the scanner. +| Event | When it fires | Why it is useful | +|-------|--------------|-----------------| +| `push` | On every commit merged to `main` or `master` | Keeps the Security tab up to date with the state of your default branch | +| `pull_request` | On every PR opened, synchronised, or re-opened | Blocks merges when new vulnerabilities are introduced | +| `schedule` | Nightly at 02:00 UTC | Catches new CVEs published to the vulnerability database between code changes | + +For other branch patterns, adjust the `branches` filter or replace it with `branches-ignore`. + +## GitHub built-in variables + +The workflow uses several expression contexts that GitHub populates automatically — no configuration required: + +| Expression | Example value | Used for | +|-----------|--------------|---------| +| `github.sha` | `a3f9c1d…` | Unique image tag per commit; ensures the scan always targets the exact build being reviewed | +| `github.repository` | `myorg/my-app` | Constructing GHCR image references (`ghcr.io/${{ github.repository }}:latest`) | +| `github.ref_name` | `main` or `feature/foo` | Tagging images with a human-readable branch name in addition to the SHA | +| `github.workspace` | `/home/runner/work/my-app/my-app` | Absolute path used for bind-mounting the reports directory | +| `github.event_name` | `push`, `pull_request`, `schedule` | Conditionally skipping steps that only make sense on certain trigger types | + +## Image reference passing between steps + +Unlike CircleCI, where jobs run in fully isolated environments and must exchange Docker images through a workspace tar file, all steps within a single GitHub Actions job share the **same Docker daemon**. An image built in step 2 is immediately visible to every subsequent step in the same job without any export or import step. + +This means: + +```yaml +- name: Build application image + run: docker build -t ${{ env.IMAGE_NAME }} . + +# No docker save / docker load needed. The image is already in the daemon. +- name: Run container scan + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + ... + ${{ env.SCANNER_IMAGE }} scan --image ${{ env.IMAGE_NAME }} ... +``` + +The scanner container accesses the application image through the shared socket — it calls `docker inspect` and pulls layer data from the same daemon that built it. No intermediate artifact or registry push is required. + +## Full annotated workflow + +The workflow below is a copy-paste-ready starting point. Inline comments explain every non-obvious decision. + +```yaml +# GitHub Actions — Container Security Scan +# +# What this does: +# 1. Builds your application image +# 2. Builds the scanner image (Trivy + CLI + Web UI server) +# 3. Runs a full scan: vulnerability detection, runc advisory, SBOM generation +# 4. Uploads SARIF to GitHub Security tab (Code Scanning alerts) +# 5. Publishes all reports + SBOM as a pipeline artifact +# 6. Fails the build if any CRITICAL or HIGH findings are present +# +# Customise IMAGE_NAME to match your app image. +# Adjust --fail-on-severity to your risk tolerance. + +name: Container Security Scan + +on: + push: + branches: ["main", "master"] + pull_request: + schedule: + # Nightly re-scan so new CVEs in the DB are caught even without a code change. + - cron: "0 2 * * *" + +jobs: + scan: + name: Vulnerability scan + runs-on: ubuntu-latest + + permissions: + contents: read + # Required to upload SARIF results to GitHub Code Scanning. + security-events: write + + env: + # Tag the image with the commit SHA so each run scans a distinct, traceable image. + IMAGE_NAME: app:${{ github.sha }} + SCANNER_IMAGE: scanner:latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Build the application image you want to scan. + - name: Build application image + run: docker build -t ${{ env.IMAGE_NAME }} . + + # Build the scanner image once; reuse across steps via the shared daemon. + - name: Build scanner image + run: docker build -t ${{ env.SCANNER_IMAGE }} . + working-directory: ${{ github.workspace }} + # If the scanner image is published to a registry, pull instead of build: + # run: | + # echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + # docker pull ghcr.io/beejak/docker-scanner:latest + # docker tag ghcr.io/beejak/docker-scanner:latest ${{ env.SCANNER_IMAGE }} + + - name: Create reports directory + run: mkdir -p reports + + # Full scan: all formats + runc advisory + SBOM + fail-on policy. + # + # Flag reference: + # --format sarif,markdown,html,csv + # Writes report.sarif, report.md, report.html, report.csv to /reports. + # --check-runtime + # Checks the runner's runc binary for known container-escape CVEs. + # --sbom + # Generates a CycloneDX SBOM at reports/report.cdx.json. + # --fail-on-severity CRITICAL,HIGH + # Exits 1 if any matching finding is present, failing the step. + # Downstream steps use 'if: always()' so they still run after a failure. + - name: Run container scan + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v ${{ github.workspace }}/reports:/reports \ + ${{ env.SCANNER_IMAGE }} scan \ + --image ${{ env.IMAGE_NAME }} \ + --output-dir /reports \ + --format sarif,markdown,html,csv \ + --check-runtime \ + --sbom \ + --fail-on-severity CRITICAL,HIGH + + # Upload SARIF so findings appear in the GitHub Security → Code Scanning tab. + # + # 'if: always()' is required here. Without it, GitHub Actions skips this step + # whenever a previous step fails — which is exactly what happens when + # --fail-on-severity detects violations and exits 1. The SARIF file exists on + # disk even after a policy failure, so 'always()' ensures it is always uploaded. + # + # The 'category' field namespaces the results inside Code Scanning. If you run + # multiple scans in the same repository (e.g. one for each service), give each + # a distinct category so their alerts do not overwrite each other. + - name: Upload SARIF to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: reports/report.sarif + category: container-scan + + # Publish all reports + SBOM as a downloadable artifact. + # + # 'if: always()' is needed for the same reason as the SARIF upload — reports + # must be preserved even when the scan step exits 1 so teams can review findings. + # Naming the artifact with the SHA makes it easy to correlate with the commit. + - name: Upload scan reports artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: scan-reports-${{ github.sha }} + path: reports/ + retention-days: 30 +``` + +## The `--fail-on-severity` gate + +When `--fail-on-severity CRITICAL,HIGH` is passed, the scanner exits with code `1` if any finding at those severity levels is present. GitHub Actions interprets a non-zero exit code as a step failure and marks the job as failed. + +This means **the two steps that follow the scan step — SARIF upload and artifact upload — would be skipped by default**, because GitHub Actions skips steps after a failure unless instructed otherwise. To prevent this, both steps carry: + +```yaml +if: always() +``` + +`always()` is an expression that evaluates to `true` regardless of the outcome of previous steps, including failures and cancellations. It is the correct choice here because: + +- The SARIF file and report files exist on disk even when findings are present. +- Uploading them is the primary way to communicate *why* the build failed. +- Using `if: failure()` alone would skip the upload on a successful scan. + +Do **not** use `continue-on-error: true` on the scan step itself. That would prevent the job from being marked as failed and would allow the pull request to be merged without addressing policy violations. + +## SARIF upload and Code Scanning + +The `github/codeql-action/upload-sarif@v3` action sends `report.sarif` to GitHub's Code Scanning API. After upload, findings appear under **Security → Code Scanning** in your repository. + +Key parameters: + +| Parameter | Value in example | Notes | +|-----------|-----------------|-------| +| `sarif_file` | `reports/report.sarif` | Path relative to `github.workspace`; must match `--output-dir` | +| `category` | `container-scan` | Namespaces alerts; use a unique value per scanner/service | + +The `security-events: write` permission in the job's `permissions` block is mandatory. Without it the upload call returns HTTP 403 and the step fails with a permission error. + +Once SARIF is uploaded, GitHub links each alert to the relevant file and line when the SARIF includes region information. The scanner produces region-annotated SARIF where applicable. + +## Artifact upload + +`actions/upload-artifact@v4` collects everything in `reports/` — SARIF, Markdown, HTML, CSV, and the CycloneDX SBOM — and makes them available from the **Actions → workflow run → Artifacts** panel. + +```yaml +- name: Upload scan reports artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: scan-reports-${{ github.sha }} + path: reports/ + retention-days: 30 +``` + +The `retention-days: 30` setting keeps artifacts for 30 days. Adjust this to match your compliance requirements. The artifact name includes `github.sha` so each run produces a distinct, non-colliding artifact that is easy to trace back to the triggering commit. + +To download from the CLI: + +```bash +gh run download --name scan-reports- +``` + +## Secrets setup + +For private registries — whether for your application image or for a private scanner image — store credentials as encrypted secrets rather than hard-coding them in the workflow. + +1. In your repository, go to **Settings → Secrets and variables → Actions**. +2. Click **New repository secret**. +3. Add the secrets your workflow needs, for example: + - `REGISTRY_USERNAME` — your registry username or service account name. + - `REGISTRY_PASSWORD` — your registry password, token, or API key. + +Reference secrets in the workflow via the `secrets` context: + +```yaml +- name: Log in to private registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \ + --username "${{ secrets.REGISTRY_USERNAME }}" \ + --password-stdin +``` + +Secrets are masked in workflow logs. They are available to all jobs in the repository by default; for tighter scoping, use [GitHub Environments](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) to restrict secret access to specific branches or require manual approval. + +## GHCR authentication + +GitHub Container Registry (`ghcr.io`) can be accessed in two ways: + +### `GITHUB_TOKEN` (recommended) + +`GITHUB_TOKEN` is automatically provisioned by GitHub Actions for every workflow run. It requires no setup, rotates automatically, and never leaves the Actions context. + +```yaml +- name: Log in to GHCR + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io \ + --username ${{ github.actor }} \ + --password-stdin +``` + +Grant `packages: read` (or `packages: write` if pushing) in the job's `permissions` block: + +```yaml +permissions: + contents: read + security-events: write + packages: read +``` + +`GITHUB_TOKEN` can only access packages belonging to the same repository or organisation. If the scanner image lives in a different organisation's GHCR namespace, use a PAT instead. + +### Personal Access Token (PAT) + +If you need cross-organisation access or your runner environment cannot use `GITHUB_TOKEN`, create a Classic PAT with the `read:packages` scope (and `write:packages` if pushing), store it as a repository secret, and reference it in the login step: + +```yaml +- name: Log in to GHCR with PAT + run: | + echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io \ + --username ${{ secrets.GHCR_USERNAME }} \ + --password-stdin +``` + +Fine-grained PATs do not yet support package registry operations as of mid-2025; use Classic PATs for GHCR. + +## Setting registry secrets + +If your application image lives in a non-GHCR private registry (AWS ECR, GCR, Azure ACR, Docker Hub private), the login step is the same pattern: + +```yaml +- name: Log in to private registry (optional) + run: | + if [ -n "${{ secrets.REGISTRY_USERNAME }}" ]; then + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ secrets.REGISTRY_HOST }}" \ + --username "${{ secrets.REGISTRY_USERNAME }}" \ + --password-stdin + fi +``` + +For cloud-native authentication (AWS OIDC, GCP Workload Identity, Azure Managed Identity), use the provider's official action instead of static credentials — these approaches eliminate long-lived secrets from your repository entirely. ## CLI reference -See [CLI reference](../cli-reference.md) for all flags (`--severity`, `--offline`, `--baseline-image`, etc.). +See [CLI reference](../cli-reference.md) for the full list of scanner flags, including `--severity`, `--offline`, `--baseline-image`, `--sbom`, `--check-runtime`, and output format options. diff --git a/docs/ci/gitlab-ci.md b/docs/ci/gitlab-ci.md index 2a38283..32cd3a1 100644 --- a/docs/ci/gitlab-ci.md +++ b/docs/ci/gitlab-ci.md @@ -1,29 +1,283 @@ -# GitLab CI integration +# GitLab CI/CD integration -Add the Docker Container Scanner to your pipeline so merge requests are scanned and SARIF is shown in the Security dashboard. +Add the Docker Container Scanner to your GitLab pipeline so every push and merge request is scanned, SARIF findings appear in the Security dashboard, and policy gates block merges when critical vulnerabilities are present. ## Prerequisites -- Job has Docker (e.g. `docker:24` with `docker:24-dind` service). -- Scanner image is available: build from this repo or pull from your registry. +- **Docker-in-Docker runner.** Your GitLab runner must be configured with the `docker` executor and must be able to run privileged containers. The `docker:24-dind` service (or `docker:26-dind`) acts as the Docker daemon inside the pipeline job. Runners registered with `--executor docker --docker-privileged` satisfy this requirement; shared runners on GitLab.com already do. +- **`docker:24-dind` service.** Each job that builds or pulls images must declare the DinD service so a Docker daemon is available during the script phase. +- **`DOCKER_TLS_CERTDIR` set to `"/certs"`**. GitLab's official DinD images use mutual TLS between the client and daemon by default. Setting this variable to `/certs` tells both containers (the job image and the service) to write and read TLS certificates from the same shared path. Leaving it unset falls back to an unencrypted socket and produces connection errors on recent images. +- **Scanner image reachable from the build.** Build it from the repo (`docker build`) or pull a pre-published copy from your registry. The example below builds it inline. -## Steps +## GitLab CI/CD built-in variables -1. **Build your app image** in `before_script` or a previous stage. -2. **Run the scanner** in the job script; write reports to a directory (e.g. `reports/`). -3. **Declare SARIF artifact** with `reports: sast: reports/report.sarif` so GitLab shows results in the MR Security widget and Security dashboard. -4. **Archive report files** (optional) with `paths: [reports/]`. +GitLab injects the following variables automatically into every job. You do not need to declare them. -## Example +| Variable | Example value | Purpose | +|---|---|---| +| `CI_COMMIT_SHA` | `a1b2c3d4…` (40 chars) | Full SHA of the commit that triggered the pipeline | +| `CI_REGISTRY_IMAGE` | `registry.gitlab.com/my-group/my-app` | Base path for images in the project's Container Registry | +| `CI_REGISTRY` | `registry.gitlab.com` | Hostname of the GitLab Container Registry | +| `CI_REGISTRY_USER` | `gitlab-ci-token` | Short-lived username for authenticating to `CI_REGISTRY` | +| `CI_REGISTRY_PASSWORD` | *(masked job token)* | Short-lived password matching `CI_REGISTRY_USER`; rotated each pipeline | +| `CI_PROJECT_NAME` | `my-app` | Repository name; useful for tagging images or naming reports | -See [ci/gitlab/job.example.yml](../../ci/gitlab/job.example.yml) for a full job example. +## Image ref construction and why SHA tags are preferred -Replace `` with your built image (e.g. `$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA`). +A tag like `latest` or `main` is mutable — it can point to a different image layer between the `build` and `scan` jobs, undermining the guarantee that you scanned exactly what you built. Using the commit SHA produces an immutable, globally unique tag: -## Registry auth +```bash +IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}" +# e.g. registry.gitlab.com/acme/my-app:a1b2c3d4e5f6... +``` -Use GitLab CI variables (`CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD`) or a job token to pull from the GitLab registry. For other registries, add variables and `docker login` in `before_script`. +This means: -## CLI reference +- The `build` job pushes `…:a1b2c3d4`. +- The `scan` job pulls `…:a1b2c3d4` — guaranteed to be the same layers, regardless of any concurrent pushes on other branches. +- Audit logs, SARIF reports, and the Security dashboard all reference the exact digest that was scanned. -See [CLI reference](../cli-reference.md) for all flags (`--severity`, `--offline`, `--baseline-image`, etc.). +You can additionally push a human-readable tag (e.g. `…:main`) after the SHA-tagged push without losing immutability for the scan. + +## Full annotated `.gitlab-ci.yml` + +The pipeline below defines two stages: `build` (compile and push the image) and `test` (pull, scan, publish results). Copy it to the root of your repository and adjust the variables at the top. + +```yaml +# .gitlab-ci.yml + +# ── Global variables ───────────────────────────────────────────────────────── +variables: + # Immutable image ref: registry path + commit SHA. + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}" + + # Tag for the scanner image built from this repo. + # Replace with a pull reference if you publish the scanner separately, + # e.g. "ghcr.io/beejak/docker-scanner:latest" + SCANNER_IMAGE: "scanner:latest" + + # Required for Docker-in-Docker TLS handshake. + # Both the job container and the dind service read/write certs here. + DOCKER_TLS_CERTDIR: "/certs" + +stages: + - build + - test + +# ── Build application image ────────────────────────────────────────────────── +build: + stage: build + image: docker:26 + services: + - docker:26-dind # Docker daemon sidecar; TLS-secured via DOCKER_TLS_CERTDIR + before_script: + # Authenticate to the GitLab Container Registry using the auto-injected + # short-lived job token. CI_REGISTRY_USER and CI_REGISTRY_PASSWORD are + # rotated each pipeline run — no manual secret management required. + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" + --username "$CI_REGISTRY_USER" --password-stdin + script: + # Build the application image and tag it with the immutable SHA ref. + - docker build -t "$IMAGE_NAME" . + # Push to the GitLab Container Registry so the scan job can pull it. + - docker push "$IMAGE_NAME" + rules: + # Run on branch pushes and merge request pipelines. + - if: $CI_COMMIT_BRANCH + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +# ── Container security scan ────────────────────────────────────────────────── +container-scan: + stage: test + image: docker:26 + services: + - docker:26-dind + # Wait for the build job to succeed before starting. + needs: [build] + before_script: + # Re-authenticate so this job can pull the image pushed by the build job. + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" + --username "$CI_REGISTRY_USER" --password-stdin + script: + # Pull the exact image that was just built. + - docker pull "$IMAGE_NAME" + + # Build the scanner image from source. + # If you publish the scanner separately, replace this with: + # docker pull "$SCANNER_IMAGE" + - docker build -t "$SCANNER_IMAGE" . + + # Create the reports directory on the runner host. + - mkdir -p reports + + # Run the full scan. Key flags explained below. + - | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD/reports:/reports" \ + "$SCANNER_IMAGE" scan \ + --image "$IMAGE_NAME" \ + --output-dir /reports \ + --format sarif,markdown,html,csv \ + --check-runtime \ + --sbom \ + --fail-on-severity CRITICAL,HIGH + + artifacts: + # Upload artifacts even when the scan job fails (i.e. when findings are + # present). This ensures reports are always available for review. + when: always + expire_in: 30 days + + reports: + # Tells GitLab to parse the SARIF file and surface findings in: + # • the Security dashboard (project-level summary) + # • the MR Security widget (diff-level view on merge requests) + container_scanning: reports/report.sarif + + paths: + # Also store all report files as downloadable job artifacts. + - reports/ + + rules: + - if: $CI_COMMIT_BRANCH + - if: $CI_PIPELINE_SOURCE == "merge_request_event" +``` + +### Scan flags explained + +| Flag | Effect | +|---|---| +| `--format sarif,markdown,html,csv` | Writes `report.sarif`, `report.md`, `report.html`, and `report.csv` to `--output-dir` | +| `--check-runtime` | Runs an additional advisory check on the container runtime (runc/containerd) | +| `--sbom` | Generates a Software Bill of Materials alongside the vulnerability report | +| `--fail-on-severity CRITICAL,HIGH` | Exits `1` if any finding at `CRITICAL` or `HIGH` severity is present, failing the job | +| `--output-dir /reports` | Writes all output files into the mounted directory | + +See [CLI reference](../cli-reference.md) for the full list of flags, including `--severity`, `--offline`, `--baseline-image`, and more. + +## Container Registry authentication + +### GitLab Container Registry (auto-injected) + +When your image lives in the same project's GitLab Container Registry, no manual secret setup is needed. GitLab automatically injects `CI_REGISTRY_USER` and `CI_REGISTRY_PASSWORD` as short-lived job tokens scoped to the current pipeline. They expire when the pipeline finishes and are rotated on each run. + +```yaml +before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" + --username "$CI_REGISTRY_USER" --password-stdin +``` + +This is the approach used in the example above. It works for both the `build` (push) and `container-scan` (pull) jobs without any additional configuration. + +### External registries (Docker Hub, AWS ECR, GCR, Azure ACR) + +If your application image or scanner image is stored in an external registry, add the credentials as CI/CD variables (see the next section) and call `docker login` with the appropriate hostname: + +```yaml +before_script: + # Docker Hub + - echo "$DOCKERHUB_TOKEN" | docker login + --username "$DOCKERHUB_USERNAME" --password-stdin + + # AWS ECR (requires AWS CLI in the job image) + - aws ecr get-login-password --region "$AWS_REGION" | + docker login --username AWS --password-stdin "$ECR_REGISTRY" + + # Google Artifact Registry + - echo "$GCP_SA_KEY" | docker login europe-docker.pkg.dev + --username _json_key --password-stdin +``` + +## Custom secrets: CI/CD Variables + +Secrets such as registry passwords, API tokens, and signing keys must never be committed to the repository. Store them as **CI/CD Variables** in GitLab: + +1. Open your project in GitLab and go to **Settings → CI/CD**. +2. Expand the **Variables** section. +3. Click **Add variable** and fill in the key and value. +4. Configure the protection flags appropriate for the secret: + +| Flag | When to enable | +|---|---| +| **Masked** | Enable for passwords and tokens — the value is redacted from job logs wherever it appears | +| **Protected** | Enable to restrict the variable to pipelines running on protected branches and tags (e.g. `main`, release tags); prevents feature branches from accessing production credentials | +| **Expand variable reference** | Leave disabled for secrets that contain `$` characters to avoid unintended shell expansion | + +Variables declared at the **Group** level are inherited by all projects in the group, which is useful for shared registry credentials or organisation-wide scanner configuration. + +### Example: adding a Docker Hub token + +| Key | Value | Masked | Protected | +|---|---|---|---| +| `DOCKERHUB_USERNAME` | `myorg-bot` | No | No | +| `DOCKERHUB_TOKEN` | `dckr_pat_…` | Yes | Yes | + +Access the values in `.gitlab-ci.yml` exactly as shown above — GitLab injects them as environment variables. + +## SARIF upload and the Security dashboard + +The `reports: container_scanning:` key in the `artifacts` block is what connects the scanner output to GitLab's Security features. When a job declares this artifact, GitLab: + +1. **Parses the SARIF file** after the job completes (even if the job failed, because `when: always` is set). +2. **Populates the Security dashboard** at the project level (**Security → Vulnerability Report**) with deduplicated findings across pipelines. +3. **Shows the MR Security widget** on the merge request page, listing new findings introduced by the branch and any findings resolved by it. + +```yaml +artifacts: + when: always # upload even when --fail-on-severity causes a non-zero exit + reports: + container_scanning: reports/report.sarif + paths: + - reports/ # also available as downloadable artifacts +``` + +The `paths` entry makes the full set of report files (`report.md`, `report.html`, `report.csv`, SBOM) downloadable from the **Job artifacts** section of the pipeline UI, in addition to the parsed Security dashboard view. + +> **GitLab tier note.** The Security dashboard and MR Security widget require GitLab Ultimate. On Free and Premium tiers the SARIF file is still uploaded as a downloadable artifact and can be viewed locally or imported into other tools; only the integrated dashboard view is gated. + +## `--fail-on-severity` gate and job failure behaviour + +Passing `--fail-on-severity CRITICAL,HIGH` causes the scanner to exit with code `1` when any finding at those severity levels is present. GitLab treats a non-zero exit from any script line as a job failure, which: + +- **Marks the pipeline as failed**, blocking merge if branch protection rules require a passing pipeline. +- **Does not suppress artifact upload** when `artifacts: when: always` is set — reports are still published to the Security dashboard and available for download. + +To adjust the policy without changing the pipeline file, modify the severity list: + +```yaml +# Block only on critical findings +--fail-on-severity CRITICAL + +# Block on medium and above +--fail-on-severity CRITICAL,HIGH,MEDIUM + +# Scan without blocking (report only) +# Omit --fail-on-severity entirely +``` + +You can also externalise the severity list as a CI/CD variable so it can be updated without a code change: + +```yaml +variables: + FAIL_ON_SEVERITY: "CRITICAL,HIGH" + +# In the scan script: +--fail-on-severity "$FAIL_ON_SEVERITY" +``` + +## Scheduled re-scans + +New CVEs are published continuously. To catch vulnerabilities in already-deployed images without waiting for a code push, create a **Scheduled pipeline** in GitLab: + +1. Go to **CI/CD → Schedules** in your project. +2. Click **New schedule**, set the interval (e.g. `0 3 * * *` for 03:00 UTC daily), and target the `main` branch. +3. The pipeline runs the full `build` + `container-scan` sequence on the latest `main` commit. + +The `container-scan` job can also be written to pull an already-pushed image by SHA or by a stable tag, skipping the build stage entirely for re-scan runs. + +## Example file + +See [ci/gitlab/job.example.yml](../../ci/gitlab/job.example.yml) for the full, copy-paste-ready job definition. + +Replace `${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}` with your image reference if you are not using the GitLab Container Registry. diff --git a/docs/ci/jenkins.md b/docs/ci/jenkins.md index a0d94f6..9c07033 100644 --- a/docs/ci/jenkins.md +++ b/docs/ci/jenkins.md @@ -1,29 +1,371 @@ -# Jenkins integration +# Jenkins Pipelines integration -Add the Docker Container Scanner to your pipeline so every build is scanned and reports are archived. +Add the Docker Container Scanner to your Jenkins declarative pipeline so every build is scanned, policy gates are enforced, and reports are archived as build artifacts — even when the scan fails. ## Prerequisites -- Jenkins has Docker available (agent or Docker pipeline). -- Scanner image is available: build from this repo or pull from your registry. +- **Jenkins 2.387+** with the [Pipeline](https://plugins.jenkins.io/workflow-aggregator/) plugin suite installed. +- **Docker available on the agent** — either installed directly on the build node or exposed via a Docker agent label. See [Docker socket vs Docker-in-Docker](#docker-socket-mount-vs-docker-agent) below. +- **Scanner image reachable** from the build node: either the pre-published image (`ghcr.io/beejak/docker-scanner:latest`) or a locally built image from this repository. +- **Warnings Next Generation plugin** (optional) — enables SARIF results to appear as annotations on the build page. Install via **Manage Jenkins → Plugins → Available plugins → "Warnings Next Generation"**. -## Steps +## Built-in Jenkins environment variables -1. **Build your app image** in a stage. -2. **Run the scanner** in a stage; mount a workspace directory for reports (e.g. `reports/`). -3. **Archive artifacts** in `post { always { archiveArtifacts ... } }` so SARIF and Markdown are available. -4. **Optional:** Use a plugin (e.g. Warnings NG with SARIF format) to publish issues to the build. +Jenkins injects the following variables into every build. The example pipeline uses several of them to produce stable, traceable image tags and report paths. -## Example +| Variable | Example value | Typical use | +|----------|--------------|-------------| +| `BUILD_NUMBER` | `42` | Append to image tag so each build produces a distinct image | +| `GIT_COMMIT` | `a3f1d9c...` | Full SHA of the checked-out commit; use for precise traceability | +| `WORKSPACE` | `/var/jenkins/workspace/my-app` | Absolute path to the job workspace on the agent; mount subdirectories into Docker containers | +| `JOB_NAME` | `my-org/my-app/main` | Folder-qualified job name; useful for labelling reports or registry paths | +| `BUILD_TAG` | `jenkins-my-app-42` | Unique slug combining job name and build number; safe to use as a Docker tag | -See [ci/jenkins/Jenkinsfile.example](../../ci/jenkins/Jenkinsfile.example) for a full pipeline example. +## Docker socket mount vs Docker agent -Replace `IMAGE_NAME` with your built image (e.g. `app:${BUILD_NUMBER}`). +Jenkins pipelines have two common ways to run Docker commands inside a build. -## Registry auth +### Docker socket mount (recommended) -Add Docker credentials in Jenkins (e.g. "Docker Registry" credentials) and use `docker login` in a step if your image or the scanner image is in a private registry. +The agent node runs a Docker daemon and Jenkins mounts `/var/run/docker.sock` into the build container. Any `docker` commands issued in pipeline steps talk to that daemon and can see images built earlier in the same job. + +```groovy +agent any // or agent { label 'docker' } +``` + +Inside `sh` steps, mount the socket explicitly when running the scanner: + +```sh +docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v ${REPORTS_DIR}:/reports \ + ${SCANNER_IMAGE} scan --image ${IMAGE_NAME} ... +``` + +This is the approach used in the example pipeline below. The application image built in the `Build` stage is immediately visible to the scanner in the `Scan` stage because both share the same daemon. + +### Docker agent (`agent { docker { ... } }`) + +Jenkins can run each stage inside a Docker container using `agent { docker { image '...' } }`. This is clean for build environments but introduces a problem for scanning: the scanner needs access to the Docker daemon and the images built in previous stages. When each stage runs in a fresh container, images built earlier are not automatically present unless you: + +- Save the image to a tar file and load it in the scan stage, or +- Push the image to a registry between stages and pull it in the scan stage. + +### Docker-in-Docker (DinD) — avoid for scanning + +DinD runs a separate Docker daemon inside the build container. Images built inside DinD are invisible to the host daemon and vice versa. The scanner cannot reach those images via the socket mount, leading to "image not found" errors. Use the socket mount approach instead. + +## Full annotated Jenkinsfile + +The pipeline below has three stages (Build, Scan, post) and covers every topic in this guide. Copy it to your repository as `Jenkinsfile` and adjust the environment variables at the top. + +```groovy +// Jenkinsfile — Docker Container Scanner integration +// +// Prerequisites: +// - Docker available on the Jenkins agent +// - Scanner image accessible (build locally or pull from registry) +// - Warnings Next Generation plugin (optional, for recordIssues) +// +// Customise IMAGE_NAME, SCANNER_IMAGE, and --fail-on-severity to suit your project. + +pipeline { + // Run on any agent that has Docker. Narrow this with a label if needed: + // agent { label 'docker' } + agent any + + environment { + // Tag the application image with the build number so each run is distinct. + // Swap BUILD_NUMBER for GIT_COMMIT for SHA-based tags. + IMAGE_NAME = "app:${env.BUILD_NUMBER}" + + // Use the pre-published scanner image or build your own: + // sh 'docker build -t scanner:latest .' + SCANNER_IMAGE = 'ghcr.io/beejak/docker-scanner:latest' + + // All report files land under WORKSPACE/reports so archiveArtifacts + // can pick them up with a simple glob. + REPORTS_DIR = "${env.WORKSPACE}/reports" + } + + triggers { + // Nightly re-scan at 02:00 ensures new CVEs added to the Trivy database + // overnight are caught even when no code changes were pushed. + cron('0 2 * * *') + } + + stages { + + // ------------------------------------------------------------------ // + // Stage 1 — Build // + // ------------------------------------------------------------------ // + stage('Build') { + steps { + // Build the application image. The tag includes BUILD_NUMBER so the + // scanner can refer to it unambiguously in the next stage. + sh 'docker build -t ${IMAGE_NAME} .' + } + } + + // ------------------------------------------------------------------ // + // Stage 2 — Scan // + // ------------------------------------------------------------------ // + stage('Scan') { + steps { + // Create the reports directory on the host. This path is bind-mounted + // into the scanner container at /reports. + sh 'mkdir -p ${REPORTS_DIR}' + + // Run the scanner. Key flags explained inline below. + sh ''' + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v ${REPORTS_DIR}:/reports \ + ${SCANNER_IMAGE} scan \ + --image ${IMAGE_NAME} \ + --output-dir /reports \ + --format sarif,markdown,html,csv \ + --check-runtime \ + --sbom \ + --fail-on-severity CRITICAL,HIGH + ''' + // Flag reference: + // + // --image Image to scan; must be visible to the Docker daemon + // reached via the socket mount above. + // + // --output-dir Directory inside the container where reports are + // written. Bind-mounted to REPORTS_DIR on the host. + // + // --format Comma-separated list of output formats. + // sarif → reports/report.sarif (SAST tooling) + // markdown → reports/report.md (PR comments) + // html → reports/report.html (human review) + // csv → reports/report.csv (spreadsheet) + // + // --check-runtime Also checks the agent's runc binary for container- + // escape CVEs (e.g. CVE-2024-21626). + // + // --sbom Writes a CycloneDX SBOM to reports/report.cdx.json. + // + // --fail-on-severity Exit code 1 when any finding at or above the given + // severity is present. CRITICAL,HIGH is a reasonable + // default; adjust to CRITICAL for less strict gating. + // Exit code 1 causes Jenkins to mark the stage FAILED. + } + } + + } + + // -------------------------------------------------------------------- // + // post — runs after all stages, regardless of outcome // + // -------------------------------------------------------------------- // + post { + always { + // Archive every file under reports/ as a build artifact. + // + // Using post { always {} } is essential: if the scan exits 1 (policy + // violation), the Scan stage is marked FAILED and no further stages run, + // but post { always } still executes. Without it, reports from a failed + // scan would be lost. + // + // allowEmptyArchive: true prevents a secondary error when no reports + // were produced (e.g. the scanner crashed before writing files). + archiveArtifacts artifacts: 'reports/**/*', allowEmptyArchive: true + + // Publish a short Markdown summary in the build description so + // reviewers can read the top findings without downloading a file. + script { + def mdReport = "${env.REPORTS_DIR}/report.md" + if (fileExists(mdReport)) { + def summary = readFile(mdReport).take(2000) + currentBuild.description = "
${summary}
" + } + } + + // Optional: publish SARIF results via the Warnings Next Generation plugin. + // When the plugin is installed, this adds an "Issues" tab to the build + // page with filterable, linkable findings — no manual download required. + // Remove or comment out the line below if the plugin is not installed. + recordIssues(tools: [sarif(pattern: 'reports/report.sarif')]) + } + + failure { + echo 'Scan failed or policy gate triggered. Download reports/ artifacts for details.' + } + } +} +``` + +See [ci/jenkins/Jenkinsfile.example](../../ci/jenkins/Jenkinsfile.example) for the copy-paste-ready version of this file. + +## Archiving artifacts after a failed scan + +When `--fail-on-severity` finds a matching vulnerability the scanner exits with code `1`, which marks the `Scan` stage as **FAILED**. Jenkins does not execute further stages after a failure, but `post { always {} }` runs unconditionally — this is the correct place for `archiveArtifacts`. + +```groovy +post { + always { + archiveArtifacts artifacts: 'reports/**/*', allowEmptyArchive: true + } +} +``` + +To view archived reports: + +1. Open the failed build in the Jenkins UI. +2. Click **Build Artifacts** in the left-hand sidebar (or the **Artifacts** link on the build summary page). +3. Download or open `report.sarif`, `report.md`, `report.html`, or `report.csv`. + +## SARIF viewer: Warnings Next Generation plugin + +### With the plugin installed + +The `recordIssues` step (already included in the example pipeline) ingests the SARIF file and renders findings directly in the Jenkins build UI: + +```groovy +recordIssues(tools: [sarif(pattern: 'reports/report.sarif')]) +``` + +After the build: + +1. Open the build page. +2. Click the **Issues** tab (added by the plugin). +3. Browse, filter, and sort findings by severity, file, or rule ID. +4. Trend charts across builds are available on the job page. + +### Without the plugin + +The SARIF file is still archived as a build artifact and can be: + +- Downloaded and opened in **VS Code** with the [SARIF Viewer extension](https://marketplace.visualstudio.com/items?itemName=MS-SarifVSCode.sarif-viewer). +- Uploaded to **GitHub Code Scanning** via the `github/codeql-action/upload-sarif` action for annotation on pull requests. +- Opened directly in any other SARIF-compatible tool. + +## Credentials: private registry authentication + +If your application image or the scanner image is in a private registry, store credentials in Jenkins and inject them at runtime. Never hard-code passwords or tokens in the `Jenkinsfile`. + +### Adding credentials in Jenkins + +1. Go to **Manage Jenkins → Credentials → System → Global credentials (unrestricted)**. +2. Click **Add Credentials**. +3. For a registry username and password, choose **Username with password**; for a token or API key, choose **Secret text**. +4. Set a memorable **ID** (e.g. `docker-registry-creds` or `scanner-registry-token`). + +### Using `withCredentials` in the pipeline + +**Username and password** (e.g. Docker Hub, self-hosted registry): + +```groovy +stage('Scan') { + steps { + withCredentials([usernamePassword( + credentialsId: 'docker-registry-creds', + usernameVariable: 'REGISTRY_USER', + passwordVariable: 'REGISTRY_PASS' + )]) { + sh ''' + echo "$REGISTRY_PASS" | docker login \ + --username "$REGISTRY_USER" \ + --password-stdin + + docker pull ${SCANNER_IMAGE} + + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v ${REPORTS_DIR}:/reports \ + ${SCANNER_IMAGE} scan \ + --image ${IMAGE_NAME} \ + --output-dir /reports \ + --format sarif,markdown,html \ + --fail-on-severity CRITICAL,HIGH + ''' + } + } +} +``` + +**Secret text** (e.g. a registry token or API key): + +```groovy +withCredentials([string(credentialsId: 'scanner-registry-token', variable: 'REGISTRY_TOKEN')]) { + sh ''' + echo "$REGISTRY_TOKEN" | docker login ghcr.io \ + --username _token \ + --password-stdin + ''' +} +``` + +For registries that require a hostname (AWS ECR, GCR, Azure ACR), add the hostname to the `docker login` command and store it as an additional secret or a plain environment variable: + +```groovy +environment { + REGISTRY_HOST = 'your-account.dkr.ecr.us-east-1.amazonaws.com' +} +``` + +```sh +docker login "$REGISTRY_HOST" \ + --username "$REGISTRY_USER" \ + --password-stdin <<< "$REGISTRY_PASS" +``` + +Jenkins masks the values of variables bound by `withCredentials` in the build log, so credentials never appear in plain text. + +## `--fail-on-severity` policy gate + +The `--fail-on-severity` flag causes the scanner to exit with code `1` when at least one finding at or above the specified severity is present. Jenkins interprets a non-zero exit code from an `sh` step as a stage failure. + +```sh +--fail-on-severity CRITICAL,HIGH +``` + +| Exit code | Meaning | Jenkins build result | +|-----------|---------|---------------------| +| `0` | No findings above the threshold | Stage passes | +| `1` | One or more findings at or above threshold | Stage marked **FAILED** | + +Adjust the threshold to match your team's risk tolerance: + +| Value | Effect | +|-------|--------| +| `CRITICAL` | Only block on the most severe findings | +| `CRITICAL,HIGH` | Block on critical and high (recommended starting point) | +| `CRITICAL,HIGH,MEDIUM` | Stricter; suitable for regulated environments | + +To make the pipeline continue (collect reports) rather than fail hard when the gate triggers, wrap the `sh` step with `catchError`: + +```groovy +catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') { + sh ''' + docker run --rm ... --fail-on-severity CRITICAL,HIGH + ''' +} +``` + +This marks the build **UNSTABLE** instead of **FAILED**, which can be useful when introducing the scanner to an existing project with a large backlog of findings. + +## Nightly cron trigger + +New CVEs are published continuously. A scheduled nightly scan catches vulnerabilities that appear in the Trivy database between code pushes: + +```groovy +triggers { + cron('0 2 * * *') +} +``` + +This follows standard cron syntax (`minute hour day month weekday`). The example runs at 02:00 every night. To additionally trigger on every push, combine with an SCM poll or use a webhook: + +```groovy +triggers { + cron('0 2 * * *') // nightly + pollSCM('H/5 * * * *') // poll SCM every 5 minutes (use webhooks instead when possible) +} +``` ## CLI reference -See [CLI reference](../cli-reference.md) for all flags (`--severity`, `--offline`, `--baseline-image`, etc.). +See [CLI reference](../cli-reference.md) for the full list of scanner flags, including `--severity`, `--offline`, `--baseline-image`, `--sbom`, `--check-runtime`, and output format options. From 910a57aa5e65dd56b05bbe8b562f07472c3aeb4f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 06:59:54 +0000 Subject: [PATCH 2/2] Fix P1/P2 Greptile findings in github-actions.md and gitlab-ci.md - github-actions.md: Invert scanner image step to pull from GHCR by default (build-from-source is now the commented alternative); fixes the case where a user copies the workflow into their own repo and docker build targets their application Dockerfile instead of the scanner. Remove stale "mid-2025" date from PAT note; link to GitHub PAT docs instead. - gitlab-ci.md: Same inversion for the scanner image step (pull from GHCR as default). Fix YAML scalar folding in both docker login blocks so multi-line commands use a proper | literal block rather than implicit continuation indent. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_0189QVFiKNFT5MEsskeEi19t --- docs/ci/github-actions.md | 20 ++++++++++---------- docs/ci/gitlab-ci.md | 14 ++++++++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/ci/github-actions.md b/docs/ci/github-actions.md index d6ace80..5117a00 100644 --- a/docs/ci/github-actions.md +++ b/docs/ci/github-actions.md @@ -128,15 +128,15 @@ jobs: - name: Build application image run: docker build -t ${{ env.IMAGE_NAME }} . - # Build the scanner image once; reuse across steps via the shared daemon. - - name: Build scanner image - run: docker build -t ${{ env.SCANNER_IMAGE }} . - working-directory: ${{ github.workspace }} - # If the scanner image is published to a registry, pull instead of build: - # run: | - # echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - # docker pull ghcr.io/beejak/docker-scanner:latest - # docker tag ghcr.io/beejak/docker-scanner:latest ${{ env.SCANNER_IMAGE }} + # Pull the published scanner image from GHCR. No source code required. + - name: Pull scanner image + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker pull ghcr.io/beejak/docker-scanner:latest + docker tag ghcr.io/beejak/docker-scanner:latest ${{ env.SCANNER_IMAGE }} + # To build the scanner from source instead (e.g. in a fork or dev workflow), + # replace the run block above with: + # run: docker build -t ${{ env.SCANNER_IMAGE }} /path/to/docker-scanner - name: Create reports directory run: mkdir -p reports @@ -313,7 +313,7 @@ If you need cross-organisation access or your runner environment cannot use `GIT --password-stdin ``` -Fine-grained PATs do not yet support package registry operations as of mid-2025; use Classic PATs for GHCR. +Fine-grained PATs do not support package registry operations; use Classic PATs for GHCR. See [GitHub's PAT documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) for current scope details. ## Setting registry secrets diff --git a/docs/ci/gitlab-ci.md b/docs/ci/gitlab-ci.md index 32cd3a1..c9b86d3 100644 --- a/docs/ci/gitlab-ci.md +++ b/docs/ci/gitlab-ci.md @@ -74,7 +74,8 @@ build: # Authenticate to the GitLab Container Registry using the auto-injected # short-lived job token. CI_REGISTRY_USER and CI_REGISTRY_PASSWORD are # rotated each pipeline run — no manual secret management required. - - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" + - | + echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" \ --username "$CI_REGISTRY_USER" --password-stdin script: # Build the application image and tag it with the immutable SHA ref. @@ -96,16 +97,17 @@ container-scan: needs: [build] before_script: # Re-authenticate so this job can pull the image pushed by the build job. - - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" + - | + echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" \ --username "$CI_REGISTRY_USER" --password-stdin script: # Pull the exact image that was just built. - docker pull "$IMAGE_NAME" - # Build the scanner image from source. - # If you publish the scanner separately, replace this with: - # docker pull "$SCANNER_IMAGE" - - docker build -t "$SCANNER_IMAGE" . + # Pull the published scanner image from GHCR. + # To build from source instead, replace with: docker build -t "$SCANNER_IMAGE" /path/to/docker-scanner + - docker pull ghcr.io/beejak/docker-scanner:latest + - docker tag ghcr.io/beejak/docker-scanner:latest "$SCANNER_IMAGE" # Create the reports directory on the runner host. - mkdir -p reports