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_targetwith secrets or write tokens.
| 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/ |
- 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.
- Open a pull request that adds a dependency with a known, reachable vulnerability; the SCA job must fail and name the advisory.
- Remove a header that a FAIL rule checks and confirm the DAST job fails; restore it and confirm the job passes.
- Deploy an image built outside the release workflow to a test namespace; the admission controller must deny it.
- Run
actionlintandzizmoron the workflow and confirm every third-party action is pinned to a full commit SHA. - List every suppression in
osv-scanner.toml,.trivyignoreandcheckov:skipcomments; 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.
- Part of
- Workflows: Test an application, Build an AppSec program
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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.
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 |
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 |
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.
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.
[[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"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.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # v8.30.1
hooks:
- id: gitleaksRegistry 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.
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, ...}"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.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: trueThe 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.
# 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)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.
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)
}
})
}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.
# 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 workflowSecurity 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.
cosign verify ghcr.io/aquasecurity/trivy:0.75.0 \
--certificate-identity-regexp '^https://github.com/aquasecurity/trivy/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comFeature 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 |
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
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.
Expected result and reasoning
pull_request_targetplus a checkout of the pull request head runs fork code with repository secrets and awrite-alltoken. Switch topull_request, setpermissions: contents: readat workflow level and add scopes per job, pin actions to full commit SHAs, and setpersist-credentials: false.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:
HIGHandCRITICALwith--ignore-unfixed, scanning the built image instead of the file system. ZAP: a rules file with a few triagedFAILrules and-I. Semgrep:--severity ERRORwith--baseline-commiton pull requests. Checkov:--hard-fail-ona short list that includesCKV_AWS_24, without--soft-fail.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. 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. 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.
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.
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:
node --test exercise.test.mjs13 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.
- NIST IR 8397: Guidelines on Minimum Standards for Developer Verification of Software. October 2021.
- OWASP CI/CD Security Cheat Sheet
- GitHub Docs: Secure use reference for GitHub Actions
- GitHub Docs: Artifact attestations
- GitHub Advisory GHSA-69fq-xp46-6x23 (CVE-2026-33634): Trivy ecosystem supply chain compromise
- OSV-Scanner: GitHub Action and pull-request scanning
- ZAP: Baseline scan
- Kyverno: ImageValidatingPolicy
- Semgrep: Licensing
- CodeQL CLI license terms