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.

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.
Applies to
  • GitHub Actions GitHub-hosted ubuntu-24.04 runners
  • gitleaks 8.30.1
  • Semgrep CE 1.180.0
  • CodeQL Action v4 (4.38.3)
  • OSV-Scanner 2.6.0
  • Checkov 3.3.26
  • Trivy 0.75.0
  • ZAP 2.17.0 (action-baseline 0.15.0)
  • Go 1.27
  • actions/attest v4 (4.2.2)
  • Kyverno 1.19

CI/CD pipelines for application code, infrastructure and containers. Each scanner category misses whole classes of weakness, so no single gate is enough.

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

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

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.

Before you start

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

  • Read GitHub Actions workflow YAML.
  • Know what SAST, SCA and DAST tools report.
Go to the guided practice

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.

Put each control where it gets the right input
StageControlExample toolBlocks when
Pre-commitSecretsgitleaks hookAny match in staged changes
Pull requestSecrets in new commitsgitleaksAny match
Pull requestSASTSemgrep, CodeQLA new high-confidence finding
Pull requestSCAOSV-ScannerThe change adds a known vulnerability
Pull requestIaCCheckovA listed critical check fails
BuildImage scan and SBOMTrivyA fixable HIGH or CRITICAL finding
TestDAST baselineZAPA rule marked FAIL
TestFuzzinggo test -fuzzAny crash or failed property
ReleaseProvenance and SBOM attestationsactions/attest or cosignRequired for every release
DeployAdmission controlKyverno, Sigstore policy-controllerMissing or wrong-identity attestation
NightlyFull history, new advisories, long fuzzingSame toolsReports 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.

Know what each category finds and misses
CategoryFinds wellMisses
Secrets scanningHard-coded credentials in code and history (CWE-798)Secrets with no matching rule, encoded or split values, secrets outside the repository
SASTInjection 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)
SCAKnown-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 scanningPublic storage, open admin ports, missing encryption (CWE-732, CWE-311)Effective permissions across many policies; drift after deployment
Image scanningVulnerable OS and language packages (CWE-1395), root user (CWE-250), secrets in layersBugs in your own code; settings applied only at deploy time
DASTMissing 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
FuzzingCrashes, 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 admissionUnknown 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.

Configurationosv-scanner.tomlTOML
[[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.

Configuration.pre-commit-config.yamlYAML
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.

semgrep-rules/http-timeouts.yamlYAML
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.

.github/workflows/devsecops.ymlYAML
# .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.

Configuration.zap/rules.tsvPlain 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.

Testinternal/money/parse_test.goGo
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.

Configurationrequire-release-provenance.yamlYAML
# 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.

Shell
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.

Choose tools by evidence from your own code
CriterionWhat to check
CoverageYour languages, frameworks, lockfiles, IaC formats and base images
PrecisionTrue and false positives in a triaged sample from your code
Diff-aware modeCan it block only on findings the change introduces?
OutputSARIF 2.1.0 or JSON with stable rule IDs and fingerprints
ExceptionsPer-finding suppression with a reason and an expiry date
Custom rulesYou can write rules and test them in CI
DataAdvisory sources, update speed, reachability analysis, offline mode
LicensingTerms for the engine and, separately, for its rules
Supply chainSigned 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 scenario material
Plain 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. 1. Remove unearned authority

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

    Expected result and reasoning

    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. 2. Fix the gates that teach people to bypass them

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

    Expected result and reasoning

    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. 3. Decide what blocks today

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

    Expected result and reasoning

    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. 4. Add the missing controls

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

    Expected result and reasoning

    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. 5. Prove the gates work

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

    Expected result and reasoning

    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.

Your 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.

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.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Node.js 22+ · no dependencies · GitHub Actions and GitLab CI YAML · 45–75 min

Repair a security pipeline for GitHub Actions and GitLab CI

Repair two pipelines that run gitleaks, Semgrep, OSV-Scanner, Trivy and cosign but cannot fail and trust pull requests, until every action is pinned to a commit SHA, every image to a digest, gates block and only the main branch signs the pushed digest.

In the extracted folder, run:

Shell
node --test exercise.test.mjs

13 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

Static checks of the YAML text; the pipelines are not run. Each scan command was run locally with the pinned images, and the reference workflow passed actionlint 1.7.12 and zizmor 1.30.1.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. NIST IR 8397: Guidelines on Minimum Standards for Developer Verification of Software. October 2021.
  2. OWASP CI/CD Security Cheat Sheet
  3. GitHub Docs: Secure use reference for GitHub Actions
  4. GitHub Docs: Artifact attestations
  5. GitHub Advisory GHSA-69fq-xp46-6x23 (CVE-2026-33634): Trivy ecosystem supply chain compromise
  6. OSV-Scanner: GitHub Action and pull-request scanning
  7. ZAP: Baseline scan
  8. Kyverno: ImageValidatingPolicy
  9. Semgrep: Licensing
  10. CodeQL CLI license terms

Reference information

SDLC phase
TestingRelease

Download this guide as Markdown