---
key: "devsecops-pipeline"
title: "Place each security control in the pipeline and decide what it blocks"
url: "https://softwaresecurity.com/domains/appsec/testing/devsecops-pipeline/"
api: "https://softwaresecurity.com/api/v1/articles/devsecops-pipeline.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-798","CWE-1395","CWE-1104","CWE-494","A02:2025","A03:2025","A08:2025","V13.3","V15.1","V15.2"]
tags: ["Dependency management","CI/CD security","Kubernetes and containers","Security testing"]
stages: ["test","release"]
roles: ["developer","appsec-engineer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Place each security control in the pipeline and decide what it blocks

Put secrets scanning, SAST, SCA, IaC and image scanning, DAST, fuzzing, attestation and admission control where each sees the right input, block only on findings a developer can act on, and deploy only images the pipeline has attested.

Canonical URL: https://softwaresecurity.com/domains/appsec/testing/devsecops-pipeline/

## In short

Scan for secrets before commit and again in CI. Run SAST, SCA and IaC checks on every pull request, scan the built image, run a DAST baseline against it, and fuzz parsers briefly per pull request and longer at night. Attest the exact image digest you release and verify that attestation in an admission controller. Block on new, high-confidence, fixable findings; report the rest to an owner, and give every exception a reason and an expiry date.

- Do block on findings the change introduces; track existing findings in a separate backlog.
- Do pin actions to full commit SHAs and tool images to digests; scanners run with your secrets too.
- Do scan, attest and deploy the same image digest.
- Don't treat any single tool as proof of security; each category misses whole classes of weakness.
- Don't suppress a finding without a reason and an expiry date.
- Don't run pull-request code under `pull_request_target` with secrets or write tokens.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Workflow token permissions | `contents: read` at workflow level; add scopes per job | Once any scope is listed, unlisted scopes become `none`. | https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax |
| Third-party action references | Full-length commit SHA, version in a comment | Tags can be moved; the March 2026 trivy-action compromise moved nearly all of them. | https://docs.github.com/en/actions/reference/security/secure-use |
| Secrets gate | Fail on any gitleaks finding (exit code 1) | Record confirmed false positives by fingerprint in `.gitleaksignore`. | https://github.com/gitleaks/gitleaks#exit-codes |
| SAST gate | `semgrep scan --severity ERROR --error --baseline-commit <base SHA>` | Blocks only `ERROR` findings absent from the base commit. | https://docs.semgrep.dev/cli-reference |
| SCA gate | OSV-Scanner pull-request workflow; fail on newly introduced vulnerabilities | Go call analysis hides uncalled findings unless `--all-vulns` is set. | https://google.github.io/osv-scanner/github-action/ |
| Container image gate | `trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1` | `--ignore-unfixed` hides findings without a fixed version; review those separately. | https://trivy.dev/latest/docs/configuration/filtering/ |
| IaC gate | `checkov --hard-fail-on <short list of check IDs>` | Other failed checks are reported, and the exit code stays 0. | https://www.checkov.io/2.Basics/CLI%20Command%20Reference.html |
| DAST gate | ZAP baseline with `-c rules.tsv -I`; `FAIL` only for triaged rules | Exit codes: 0 pass, 1 at least one FAIL, 2 warnings only, 3 other failure. | https://www.zaproxy.org/docs/docker/baseline-scan/ |
| Fuzzing budget | `-fuzztime 60s` per target on pull requests; longer runs nightly | Failing inputs are written to `testdata/fuzz` and replayed by plain `go test`. | https://go.dev/doc/security/fuzz/ |
| Exceptions | Finding ID, reason and expiry (`ignoreUntil`, `exp:`) | Expired entries are reported again. | https://google.github.io/osv-scanner/configuration/ |
| Admission policy | Deny images without provenance from the release workflow; `failurePolicy: Fail` | — | https://kyverno.io/docs/policy-types/image-validating-policy/ |

## Verify it

1. Commit a fake token on a test branch: the pre-commit hook must refuse it, and the CI secrets job must fail when the hook is skipped.
2. Open a pull request that adds a dependency with a known, reachable vulnerability; the SCA job must fail and name the advisory.
3. Remove a header that a FAIL rule checks and confirm the DAST job fails; restore it and confirm the job passes.
4. Deploy an image built outside the release workflow to a test namespace; the admission controller must deny it.
5. Run `actionlint` and `zizmor` on the workflow and confirm every third-party action is pinned to a full commit SHA.
6. List every suppression in `osv-scanner.toml`, `.trivyignore` and `checkov:skip` comments; each needs a reason and an expiry or ticket.

## What you will learn

- Place each security control at the stage where it sees the right input.
- Set blocking and reporting thresholds, and manage exceptions with expiry dates.
- Harden the pipeline itself and enforce provenance at deployment.

Audience: Developers, platform engineers and AppSec engineers who build or review CI/CD pipelines.

Before you start:

- Read GitHub Actions workflow YAML.
- Know what SAST, SCA and DAST tools report.

Time: About 49 minutes: 11 to read, 12 on the code and 26 for the exercise, plus 45 to 75 for the optional pipeline project

## Put each control where it gets the right input

A DevSecOps pipeline places each automated check where it sees the right input: staged changes for secrets, source and lockfiles for static checks, the built image for image scanning, a running instance for DAST, and the release digest for signing. NIST's minimum standard for developer verification (NISTIR 8397) lists the same families on top of threat modeling and ordinary tests: static analysis, a search for hardcoded secrets, fuzzing, web application scanning and checks of included components. NIST SSDF 1.1 asks for the same evidence in practices PW.7.2, PW.8.2, PW.4.4, PS.2.1 and PS.3.2.

| Stage | Control | Example tool | Blocks when |
| --- | --- | --- | --- |
| Pre-commit | Secrets | gitleaks hook | Any match in staged changes |
| Pull request | Secrets in new commits | gitleaks | Any match |
| Pull request | SAST | Semgrep, CodeQL | A new high-confidence finding |
| Pull request | SCA | OSV-Scanner | The change adds a known vulnerability |
| Pull request | IaC | Checkov | A listed critical check fails |
| Build | Image scan and SBOM | Trivy | A fixable `HIGH` or `CRITICAL` finding |
| Test | DAST baseline | ZAP | A rule marked `FAIL` |
| Test | Fuzzing | `go test -fuzz` | Any crash or failed property |
| Release | Provenance and SBOM attestations | `actions/attest` or `cosign` | Required for every release |
| Deploy | Admission control | Kyverno, Sigstore policy-controller | Missing or wrong-identity attestation |
| Nightly | Full history, new advisories, long fuzzing | Same tools | Reports only; opens tickets |

## Know what each category finds and misses

Each category sees one representation of the software, so each has blind spots. None of them knows your authorization rules; keep those in threat models and abuse-case tests.

Tools that look at the same component can also disagree. In a test service pinned to `golang.org/x/text` v0.3.7, OSV-Scanner 2.6.0 reported one vulnerability; two more appeared only with `--all-vulns`, marked uncalled because its Go call analysis found no path to them. Trivy 0.75.0 scanned the compiled image without reachability analysis and reported two `HIGH` findings with fixes. Both answers are correct for different questions, so write down which question each gate asks.

| Category | Finds well | Misses |
| --- | --- | --- |
| Secrets scanning | Hard-coded credentials in code and history (CWE-798) | Secrets with no matching rule, encoded or split values, secrets outside the repository |
| SAST | Injection flows (CWE-89, CWE-78, CWE-79), path traversal (CWE-22), unsafe deserialization (CWE-502), risky crypto calls (CWE-327) | Missing or wrong authorization (CWE-862, CWE-863, CWE-639) and business logic errors (CWE-840) |
| SCA | Known-vulnerable dependencies (CWE-1395) and unmaintained ones (CWE-1104) | Flaws with no advisory, malicious packages before anyone reports them (CWE-506), copied code with no manifest |
| IaC scanning | Public storage, open admin ports, missing encryption (CWE-732, CWE-311) | Effective permissions across many policies; drift after deployment |
| Image scanning | Vulnerable OS and language packages (CWE-1395), root user (CWE-250), secrets in layers | Bugs in your own code; settings applied only at deploy time |
| DAST | Missing protection headers (CWE-693), cookie flags (CWE-614, CWE-1004), reflected XSS (CWE-79) | Pages it never reaches or cannot log in to; cross-user access unless it has two identities |
| Fuzzing | Crashes, panics, integer overflows (CWE-190) and, in memory-unsafe code, out-of-bounds access (CWE-787, CWE-125) | Anything without an oracle, such as authorization or quiet data leaks |
| Attestation and admission | Unknown or altered artifacts reaching production (CWE-494) | Vulnerable or malicious code built by the trusted pipeline |

## Block on findings that are new, confident and fixable

A gate that fails on every finding gets bypassed. Block when the change introduced the finding, the rule or advisory is one you trust, and the author can fix it now. Report everything else to an owner. Diff-aware modes make this practical: Semgrep's `--baseline-commit` reports only findings absent from the base commit, OSV-Scanner's pull-request workflow compares scans before and after the change, and gitleaks can scan just the pull request's commits.

Severity alone is a weak trigger. Weigh exploitation and reach: CISA's Known Exploited Vulnerabilities catalog, FIRST's EPSS estimate of the probability that a CVE is exploited in the next 30 days, and call analysis where the tool supports it. Treat `--ignore-unfixed` as a scheduling choice, not acceptance; an unfixed vulnerability may still need a compensating control. A nightly scan of the default branch turns new advisories against unchanged code into work.

## Make every exception specific, explained and temporary

Suppress one finding in one place, with a reason and an end date, in a file reviewed like code. Each tool has a mechanism: `ignoreUntil` in `osv-scanner.toml`, `exp:` in `.trivyignore`, a reason after `checkov:skip`, `nosemgrep` comments, and fingerprints in `.gitleaksignore`. In testing, OSV-Scanner and Trivy both reported a suppressed finding again once its expiry date had passed. Count expired and renewed exceptions; a growing pile means the gate is wrong or nobody owns the backlog.

```toml
[[IgnoredVulns]]
id = "GO-2022-1059"
ignoreUntil = 2026-11-30
reason = "Accept-Language is capped at 256 bytes by the edge proxy; upgrade tracked in SEC-123"
```

## Stop secrets before they leave the laptop

A pre-commit hook gives the fastest feedback, but anyone can skip it with `SKIP=gitleaks` or `git commit --no-verify`. Repeat the scan in CI on the pull request's commits, scan full history nightly, and add server-side push protection where your platform offers it. If a real secret was pushed, rotate it; deleting the commit does not revoke the credential. The hook below is pinned to the commit behind the v8.30.1 tag.

```yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # v8.30.1
    hooks:
      - id: gitleaks
```

## Write rules for your own invariants and test them

Registry rule packs cover common mistakes. Your threat model yields project rules, such as which server constructor to use or which data access must follow an authorization check. This rule flags Go HTTP servers without a header read timeout (CWE-400). Put a Go file with the same base name next to it, mark lines with `ruleid:` and `ok:` comments, and run `semgrep --test`; the test fails if the rule misses a `ruleid:` line or flags an `ok:` line. Keep rules out of dot-directories: `semgrep --test` found no tests under `.semgrep/` but ran them under `semgrep-rules/`. In a Go module, start the fixture with `//go:build ignore` so the build skips it.

```yaml
rules:
  - id: go-http-server-without-timeouts
    languages: [go]
    severity: ERROR
    message: >-
      HTTP server without a header read timeout lets slow clients hold
      connections open. Use http.Server with ReadHeaderTimeout set.
    metadata:
      cwe: "CWE-400: Uncontrolled Resource Consumption"
    pattern-either:
      - pattern: http.ListenAndServe(...)
      - pattern: http.ListenAndServeTLS(...)
      - patterns:
          - pattern: "http.Server{...}"
          - pattern-not: "http.Server{..., ReadHeaderTimeout: $T, ...}"
          - pattern-not: "http.Server{..., ReadTimeout: $T, ...}"
```

## Example pipeline for GitHub Actions

This workflow applies the control map to a Go service with a container image and Terraform. Every job starts with a read-only token and adds only the scopes it needs. Actions are pinned to full commit SHAs and tool images to digests. Pull requests run the checks; a push to main also releases, and the release job waits for every gate. The workflow passes actionlint 1.7.12 and zizmor 1.30.1.

CodeQL is free for open-source code hosted on GitHub; private code needs a paid GitHub license, and uploading SARIF from private repositories requires GitHub Code Security. Semgrep CE is LGPL-2.1, but Semgrep-maintained registry rules are licensed for internal business use only.

A downloadable variant of this pipeline for both GitHub Actions and GitLab CI, with keyless cosign signing of the pushed digest and an SBOM attestation, is the security pipeline project on /labs/: its starter files run every scanner but cannot fail, and 13 checks guide the repair.

```yaml
# .github/workflows/devsecops.yml
# Pinned versions current on 2026-10-10. Let Dependabot or Renovate propose updates.
name: devsecops

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  schedule:
    - cron: "17 3 * * *" # nightly: full-history secrets scan, longer fuzzing

permissions:
  contents: read # every job starts read-only; jobs add only what they need

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  secrets:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: gitleaks (PR commits on pull requests, full history otherwise)
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          docker run --rm -v "$PWD:/repo" \
            ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
            git /repo --redact --no-banner ${BASE_SHA:+"--log-opts=$BASE_SHA..$HEAD_SHA"}

  semgrep:
    runs-on: ubuntu-24.04
    container: semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Semgrep (blocks on new ERROR findings)
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          semgrep scan --config p/default --config semgrep-rules/ --exclude semgrep-rules \
            --severity ERROR --error --metrics off --sarif-output semgrep.sarif \
            ${BASE_SHA:+"--baseline-commit=$BASE_SHA"}
      - uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        if: ${{ !cancelled() }}
        with:
          sarif_file: semgrep.sarif
          category: semgrep

  codeql:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      security-events: write
      actions: read # needed in private repositories
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
      - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          languages: go
          build-mode: autobuild
          queries: security-extended
      - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          category: /language:go

  osv-pr: # blocks only on vulnerabilities this pull request introduces
    if: github.event_name == 'pull_request'
    permissions:
      actions: read
      contents: read
      security-events: write
    uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc # v2.6.0

  osv-full: # catches new advisories against unchanged dependencies
    if: github.event_name != 'pull_request'
    permissions:
      actions: read
      contents: read
      security-events: write
    uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc # v2.6.0

  iac:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Checkov (blocks on a short list of checks, reports the rest)
        run: |
          docker run --rm -v "$PWD:/src" -w /src \
            ghcr.io/bridgecrewio/checkov:3.3.26@sha256:8e63f217cb084f1c1a067326a9cf6e37d54bdc82e5822210d50ca4e2f647dd93 \
            -d infra --framework terraform --compact --quiet \
            --hard-fail-on CKV_AWS_20,CKV_AWS_57,CKV_AWS_24,CKV_AWS_25,CKV_AWS_62 \
            -o cli -o sarif --output-file-path console,checkov.sarif
      - uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        if: ${{ !cancelled() }}
        with:
          sarif_file: checkov.sarif
          category: checkov

  fuzz:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
      - name: Fuzz the amount parser (60 s on pull requests, 15 min nightly)
        env:
          FUZZTIME: ${{ github.event_name == 'schedule' && '15m' || '60s' }}
        run: go test ./internal/money -run '^$' -fuzz '^FuzzParseCents$' -fuzztime "$FUZZTIME"
      - name: Keep failing inputs so they can be committed as regression tests
        if: failure()
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: fuzz-failures
          path: internal/money/testdata/fuzz

  image:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: docker build -t expenses:ci .
      - name: Trivy (blocks on HIGH and CRITICAL findings that have a fix)
        run: |
          docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$PWD:/src" -w /src \
            ghcr.io/aquasecurity/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \
            image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 expenses:ci
      - name: Start the app for DAST
        run: |
          docker run -d --name app -p 8080:8080 expenses:ci
          for _ in $(seq 30); do curl -fsS http://localhost:8080/healthz && break; sleep 1; done
      - name: ZAP baseline (blocks only on rules marked FAIL in .zap/rules.tsv)
        uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 # v0.15.0
        with:
          target: http://localhost:8080/amount?v=12.34
          docker_name: ghcr.io/zaproxy/zaproxy:2.17.0@sha256:7aaa659b0d43078febd82e29bad112285c370727e86ab8340444220e17d9f0d2
          rules_file_name: .zap/rules.tsv
          cmd_options: -I
          fail_action: true
          allow_issue_writing: false

  release:
    if: github.event_name == 'push'
    needs: [secrets, semgrep, codeql, osv-full, iac, fuzz, image]
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
      artifact-metadata: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build, scan the exact image, push it, record its digest
        id: image
        run: |
          image="ghcr.io/${GITHUB_REPOSITORY,,}"
          docker build -t "$image:$GITHUB_SHA" .
          trivy() {
            docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$PWD:/src" -w /src \
              ghcr.io/aquasecurity/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa "$@"
          }
          trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$image:$GITHUB_SHA"
          trivy image --format cyclonedx --output sbom.cdx.json "$image:$GITHUB_SHA"
          docker push "$image:$GITHUB_SHA"
          echo "name=$image" >> "$GITHUB_OUTPUT"
          echo "digest=$(docker buildx imagetools inspect "$image:$GITHUB_SHA" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
      - name: Attest SLSA build provenance
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-name: ${{ steps.image.outputs.name }}
          subject-digest: ${{ steps.image.outputs.digest }}
          push-to-registry: true
      - name: Attest the SBOM
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-name: ${{ steps.image.outputs.name }}
          subject-digest: ${{ steps.image.outputs.digest }}
          sbom-path: sbom.cdx.json
          push-to-registry: true
```

## Run DAST against the image you built

The image job starts the built container and runs a ZAP baseline scan, which spiders the target for a minute and checks responses passively, without attacking. Every rule reports as a warning by default. The rules file promotes rules you have triaged to `FAIL`. With `-I`, warnings no longer set a failing exit code, so the build breaks only on `FAIL` rules. Against the sample service, the scan failed until the handler set `X-Content-Type-Options: nosniff`. Run ZAP's full scan, which does attack, against staging on a schedule, with a test account for each role.

```text
# ZAP baseline rules: FAIL blocks the build, WARN reports, IGNORE suppresses (tab-separated)
10021	FAIL	(X-Content-Type-Options Header Missing)
10038	FAIL	(Content Security Policy (CSP) Header Not Set)
10049	IGNORE	(Storable and Cacheable Content: API responses carry no user data)
```

## Fuzz parsers briefly per pull request and longer at night

The fuzz job runs Go's built-in fuzzer for 60 seconds. Against a deliberately buggy amount parser it found an integer overflow (CWE-190) in under a second and saved the input under `testdata/fuzz`, where plain `go test` replays it. Commit that file with the fix. For C and C++, use libFuzzer with sanitizers or ClusterFuzzLite; the property check matters as much as the engine.

```go
package money

import (
	"fmt"
	"testing"
)

func FuzzParseCents(f *testing.F) {
	for _, seed := range []string{"0", "12", "12.3", "12.34", "999999.99"} {
		f.Add(seed)
	}
	f.Fuzz(func(t *testing.T, s string) {
		cents, err := ParseCents(s) // must never panic
		if err != nil {
			return
		}
		if cents < 0 {
			t.Fatalf("ParseCents(%q) = %d, want >= 0", s, cents)
		}
		again, err := ParseCents(fmt.Sprintf("%d.%02d", cents/100, cents%100))
		if err != nil || again != cents {
			t.Fatalf("round trip of %q: got %d, %v", s, again, err)
		}
	})
}
```

## Attest the exact digest and verify it before it runs

The release job builds once, scans that image, pushes it, and attests its digest with SLSA build provenance and the CycloneDX SBOM from the scan. GitHub artifact attestations are Sigstore-signed statements naming the workflow that built the artifact. GitHub says they provide SLSA v1.0 Build Level 2 and that a reusable workflow can reach Level 3. In public repositories they work on every current plan; private repositories need GitHub Enterprise Cloud. Elsewhere, `cosign sign` and `cosign attest` provide the same building blocks.

Verify where deployment is authorized. Kubernetes' built-in `ValidatingAdmissionPolicy` evaluates CEL against the request and cannot fetch signatures, so use a webhook controller. This Kyverno policy denies pods whose images lack provenance from the main-branch release workflow. In a test cluster it admitted an attested image and rewrote its reference to the verified digest. It denied an unattested image, and it denied the attested image when the policy named a different workflow. Images outside `matchImageReferences` are not checked, so add a registry allowlist. The `ctlog` setting suits public repositories; private ones use GitHub's own Sigstore instance, for which GitHub documents a policy-controller trust root.

```yaml
# Kyverno 1.19: admit images only with SLSA provenance from the main-branch release workflow
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: require-release-provenance
spec:
  validationActions: [Deny]
  failurePolicy: Fail
  webhookConfiguration:
    timeoutSeconds: 30
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  matchImageReferences:
    - glob: "ghcr.io/example-org/expenses*"
  attestors:
    - name: release
      cosign:
        keyless:
          identities:
            - issuer: https://token.actions.githubusercontent.com
              subject: https://github.com/example-org/expenses/.github/workflows/devsecops.yml@refs/heads/main
        ctlog:
          url: https://rekor.sigstore.dev
  attestations:
    - name: slsa
      intoto:
        type: https://slsa.dev/provenance/v1
  validations:
    - expression: >-
        images.containers.map(image, verifyAttestationSignatures(image, attestations.slsa, [attestors.release])).all(e, e > 0)
      message: Image lacks SLSA provenance from the main-branch release workflow
```

## Treat the pipeline and its scanners as production systems

Security tools run next to your source and often your secrets. On 19 March 2026 an attacker with stolen credentials force-pushed 76 of the 77 trivy-action version tags to malicious commits and published a malicious Trivy release; the payload collected CI secrets before running the normal scan (CVE-2026-33634, in CISA's KEV catalog). Trivy images referenced by digest were unaffected. Pinning trivy-action by SHA protected only commits from April 2025 onward, because older ones fetched setup-trivy by a mutable tag. Pin actions by SHA and images by digest, check what a pinned action pulls in, update through reviewed pull requests, set `persist-credentials: false`, and never run pull-request code under `pull_request_target` with secrets or write tokens; /domains/supply-chain/ci-cd/untrusted-workflow-boundaries/ covers that boundary. Verify tool signatures where the project publishes them.

```bash
cosign verify ghcr.io/aquasecurity/trivy:0.75.0 \
  --certificate-identity-regexp '^https://github.com/aquasecurity/trivy/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
```

## Choose tools by evidence from your own code

Feature lists do not predict noise. Trial candidates on two or three of your own repositories and triage a sample of findings before you commit. Free options cover every stage: Semgrep CE and its fork Opengrep (LGPL-2.1); OSV-Scanner, Trivy, Checkov, ZAP, cosign and Kyverno (Apache-2.0); gitleaks (MIT); and CodeQL, which is free for open-source projects. The gitleaks GitHub Action needs a license key for organization accounts; the CLI and container do not.

/domains/appsec/testing/appsec-tool-categories-and-evaluation/ describes each category's blind spots in more detail and shows how to score candidates on a seeded repository before you commit.

| Criterion | What to check |
| --- | --- |
| Coverage | Your languages, frameworks, lockfiles, IaC formats and base images |
| Precision | True and false positives in a triaged sample from your code |
| Diff-aware mode | Can it block only on findings the change introduces? |
| Output | SARIF 2.1.0 or JSON with stable rule IDs and fingerprints |
| Exceptions | Per-finding suppression with a reason and an expiry date |
| Custom rules | You can write rules and test them in CI |
| Data | Advisory sources, update speed, reachability analysis, offline mode |
| Licensing | Terms for the engine and, separately, for its rules |
| Supply chain | Signed releases or images you can pin by digest |

## Avoid the pitfalls that make pipelines noisy or hollow

These failures recur across teams and tools.

- Scanning one image and shipping another. Build once; scan, attest and deploy the same digest.
- Trusting a clean DAST run that never got past the login page. Check that responses came from authenticated pages.
- Reading no findings as secure. A tool reports only what its rules and inputs let it see.
- Blocking pull requests on old findings, which teaches teams to override the gate.
- Suppressions with no owner or end date.
- A pipeline weaker than production: mutable tags, long-lived cloud keys, write-all tokens.

## Guided practice

You inherit the pipeline below. Most pull-request runs fail and developers merge with an administrator override. Redesign placement, gates and authority.

### Supplied material

```text
Repository: payments-api (Go service, Dockerfile, Terraform in infra/)
ci.yml today, one job:
  on: pull_request_target                  # also runs for fork pull requests
  permissions: write-all
  - uses: actions/checkout@v4 with ref: the pull request head SHA
  - uses: aquasecurity/trivy-action@0.28.0 (tag) scan-type: fs, all severities, exit-code: 1
  - run: checkov -d infra --soft-fail
  - uses: zaproxy/action-baseline (tag) fail_action: true, no rules file, no -I
  - run: semgrep scan --config p/default --error
Deployment: kubectl apply with image tag :latest; no admission policy; no secrets scanning anywhere.
Last 30 days: 41 of 52 pull-request runs failed. 37 failures were Trivy LOW or MEDIUM findings with no
fixed version, or ZAP warnings. Developers now merge with an administrator override.
Findings on main today: Semgrep 23 ERROR (all older than 90 days); OSV-Scanner 2 called and 5 uncalled
vulnerabilities, both called ones with fixes; Checkov 31 failed checks, including CKV_AWS_24 (SSH open to 0.0.0.0/0).
```

### 1. Remove unearned authority

List what lets untrusted code reach secrets or write access, and the fix for each item.

Worked answer: `pull_request_target` plus a checkout of the pull request head runs fork code with repository secrets and a `write-all` token. Switch to `pull_request`, set `permissions: contents: read` at workflow level and add scopes per job, pin actions to full commit SHAs, and set `persist-credentials: false`.

### 2. Fix the gates that teach people to bypass them

Give each scanner a blocking condition that a developer can act on.

Worked answer: Trivy: `HIGH` and `CRITICAL` with `--ignore-unfixed`, scanning the built image instead of the file system. ZAP: a rules file with a few triaged `FAIL` rules and `-I`. Semgrep: `--severity ERROR` with `--baseline-commit` on pull requests. Checkov: `--hard-fail-on` a short list that includes `CKV_AWS_24`, without `--soft-fail`.

### 3. Decide what blocks today

Sort the current findings on main into fix now, time-boxed exception, or backlog.

Worked answer: Fix the two called OSV vulnerabilities now, since fixes exist, or record time-boxed exceptions with a reason. Track the five uncalled ones as normal upgrades. Fix `CKV_AWS_24`. Put the 23 older Semgrep findings into owned backlog tickets; they do not block new pull requests.

### 4. Add the missing controls

Name the controls absent from this pipeline and where each runs.

Worked answer: Secrets scanning in a pre-commit hook and in CI; an SBOM and an image scan of the built image; DAST against that image; provenance attestation of the pushed digest; deployment by digest instead of `:latest`; and an admission policy that requires the attestation.

### 5. Prove the gates work

Write three tests that show the redesigned pipeline blocks what it should.

Worked answer: A pull request with a fake token fails the secrets job; a pull request adding a dependency with a reachable known vulnerability fails SCA; an image built outside the release workflow is denied at admission. Record the run links as evidence.

Deliverable: A revised workflow outline with per-job permissions, the blocking condition for each tool, an exceptions file with expiry dates, and three gate tests with expected results.

Scope: Desk exercise with illustrative numbers. Real thresholds depend on your risk appetite, your tools' precision on your code, and how fast your team can fix what blocks.

## Executable practice

- [Repair a security pipeline for GitHub Actions and GitLab CI: project ZIP](https://softwaresecurity.com/downloads/projects/security-pipeline-365c6d54e88d6c1c.zip) — Node.js 22+ · no dependencies · GitHub Actions and GitLab CI YAML

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [NIST IR 8397: Guidelines on Minimum Standards for Developer Verification of Software](https://nvlpubs.nist.gov/nistpubs/ir/2021/NIST.IR.8397.pdf). October 2021.
- [OWASP CI/CD Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/CI_CD_Security_Cheat_Sheet.html)
- [GitHub Docs: Secure use reference for GitHub Actions](https://docs.github.com/en/actions/reference/security/secure-use)
- [GitHub Docs: Artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations)
- [GitHub Advisory GHSA-69fq-xp46-6x23 (CVE-2026-33634): Trivy ecosystem supply chain compromise](https://github.com/advisories/GHSA-69fq-xp46-6x23)
- [OSV-Scanner: GitHub Action and pull-request scanning](https://google.github.io/osv-scanner/github-action/)
- [ZAP: Baseline scan](https://www.zaproxy.org/docs/docker/baseline-scan/)
- [Kyverno: ImageValidatingPolicy](https://kyverno.io/docs/policy-types/image-validating-policy/)
- [Semgrep: Licensing](https://docs.semgrep.dev/licensing)
- [CodeQL CLI license terms](https://github.com/github/codeql-cli-binaries/blob/main/LICENSE.md)