Cloud & Container Security · intermediate · ~11 min

CI/CD and software supply-chain security

**What you will learn** - Explain why CI/CD pipelines are high-value targets, and name the four classic pipeline risks (leaked secrets, over-privileged tokens, poisoned pipeline execution, unpinned components). - Describe the main software supply-chain risks: vulnerable dependencies, malicious packages (typosquatting, account takeover), and compromised builds. - Apply core defenses by hand: pin components by commit SHA or content digest, scope tokens to least privilege, and isolate untrusted (fork) workflows so secrets never reach attacker code. - Read and reason about an SBOM (Software Bill of Materials), and use it to answer "are we affected by this new CVE?". - Understand artifact signing and provenance at a conceptual level, and correct the misconception that "passing a scanner" or "a valid signature" alone proves a build is safe. - Draw a simple threat model of a pipeline (assets, trust boundaries, entry points) and write a mitigation-verification test that proves each fix actually rejects bad input.

Overview

Security objective. The asset you are protecting is your ability to ship trustworthy software: your source code, the third-party code baked into every build, and — most critically — the production credentials (deploy keys, cloud roles, registry and signing keys) that live inside the pipeline. The threat is an attacker who reaches those credentials or slips malicious code into a release, so that one compromise ships a backdoor to every customer. By the end you will be able to detect the weak points (unpinned components, over-broad tokens, secrets exposed to fork PRs) and prevent them (pinning, least privilege, isolation, scanning, SBOMs, signing), then verify each defense works.

Almost no real software is built by hand anymore. You push a commit, and an automated pipeline checks out the code, installs dependencies, runs tests, builds an artifact (a binary, a container image, a package), and ships it to production. That pipeline is convenient — and it is also one of the most dangerous places in your whole system, because it sits at a crossroads of three powerful things at once: your source code, hundreds of pieces of other people's code, and the credentials that can deploy to production.

This lesson builds directly on Docker images, containers, and Dockerfile security. There you learned that a container image is assembled from base images and packages you don't fully control, and that what goes into the image matters as much as how you run it. Supply-chain security is that same idea at a larger scale: every dependency, every base image, every CI action you uses: is a piece of trust you are extending to a stranger. If any one of them is malicious or compromised, it can ride straight into your build.

Two terms frame the whole topic. CI/CD stands for Continuous Integration / Continuous Delivery (or Deployment) — the automated process that turns commits into shipped software. The software supply chain is everything that flows into your final artifact: direct dependencies, transitive (indirect) dependencies, base images, build tools, and the build environment itself. Securing the supply chain means being able to answer, at any moment: What exactly is in what I ship, where did it come from, and can I prove it wasn't tampered with? The rest of the lesson teaches the risks and the practical, defensive answers to those questions.

Why it matters

CI/CD systems hold the keys to production. A pipeline typically has deploy keys, a cloud role, and registry credentials sitting right next to code that random contributors can influence. Compromise the pipeline once and you don't just leak one secret — you can ship a backdoor to every customer on the next release. That is the worst kind of leverage an attacker can get.

The supply chain multiplies the problem. A modern application directly depends on a few dozen packages, which pull in hundreds of transitive dependencies you never chose and rarely read. Each one runs with the same privileges as your code. A single malicious package — published through typosquatting or a hijacked maintainer account — can steal secrets at install time or inject code into your build.

This is not theoretical. The SolarWinds attack (2020) inserted a backdoor into a trusted product's build process, and that signed, "legitimate" update was then installed by thousands of organizations including government agencies. The event-stream npm incident showed a popular package handed to a new maintainer who quietly added malicious code targeting a specific wallet app. The Codecov incident showed a compromised CI tool exfiltrating environment variables (full of secrets) from countless pipelines. The common thread: attackers stopped attacking targets directly and started attacking the things targets trust.

In authorized professional work this shows up constantly: security engineers audit pipelines and dependency trees, incident responders use SBOMs to answer "are we exposed to this new CVE?" within minutes, and platform teams are held to frameworks like SLSA, NIST SSDF, and US Executive Order 14028, which increasingly require SBOMs and build-integrity controls. Vendor security questionnaires now ask how you pin dependencies and whether you sign artifacts. The defenses — pinning, least-privilege tokens, software composition analysis (SCA), SBOMs, and signing — used to be "nice to have." They are now baseline expectations, and knowing them is part of being a competent engineer, not just a security specialist.

Core concepts

This section teaches each major idea on its own. Read them in order; later defenses make sense only once you understand the risks.

Authorization and ethics note. Everything here is defensive. The attack descriptions exist so you can recognize and prevent these problems in systems you own or are explicitly authorized to test. Practice only on your own repositories, throwaway accounts, local runners, or intentionally vulnerable lab projects. Never probe, typosquat against, or attempt to take over a third party's package, pipeline, or registry. A short authorization checklist appears in the practice tasks.

A threat model of a pipeline

Before the individual risks, picture the whole battlefield. A pipeline takes untrusted input (commits, pull requests) and combines it with highly trusted assets (secrets, deploy access).

                         TRUST BOUNDARY
  UNTRUSTED INPUT          (the CI runner)             TRUSTED ASSETS
  ---------------    |==============================|   --------------
  push to main  ---->|  checkout code               |   - deploy keys
  pull request  ---->|  install deps (npm/pip/...)  |-->- cloud role
  fork PR       ---->|  run build + test scripts    |   - registry creds
  scheduled job ---->|  build artifact / image      |   - signing keys
                     |  publish + deploy            |
                     |==============================|
         entry points        ^                ^
                             pulls in       pulls in
                          dependencies     CI actions / base images
                          (supply chain)   (supply chain)

Assets are what an attacker wants: secrets and the ability to ship code. Trust boundaries are where untrusted input crosses into the trusted environment — the moment a fork's PR causes code to run on your runner. Entry points are every way attacker-influenced data or code enters: commits, PRs, dependencies, and actions. Good defense means controlling what crosses each boundary.

Knowledge check: (1) What asset is really being protected here — the source code, or the deploy credentials next to it? (2) Where is the trust boundary in this diagram, and what insecure assumption does "a fork PR is just a code suggestion" make? (3) Why is a pull request from a fork more dangerous than a push to your own main branch?

Pipeline risk 1: leaked secrets

Definition. A secret (token, key, password) that becomes readable by someone who shouldn't have it.

How it happens. Secrets get echo-ed into build logs, baked into images, stored in plaintext, or — most subtly — exposed to a workflow triggered by an untrusted fork. If logs are public, the secret is now public.

Internally: CI systems inject secrets as environment variables or files into the runner. Anything running in that job can read them, including a malicious dependency installed three layers deep.

When to worry / pitfall. A classic pitfall is the pull_request_target trigger on GitHub Actions, which runs with secrets in the context of the base repo but can be influenced by fork code. The insecure assumption is "my build script is trusted" — but a fork can change the script the build runs. The fix is to never expose secrets to untrusted code paths and to mask/scrub logs.

Knowledge check: Which log line would tell you a secret leaked, and why must the pipeline mask secret values before writing any log at all?

Pipeline risk 2: over-privileged tokens

Definition. A credential granted more power than the job needs.

Plain language. If a job only reads code, its token should not be able to write to the repo, publish packages, or push to prod. Over-broad tokens turn a small compromise into a large one.

Structure. On GitHub Actions, the built-in GITHUB_TOKEN can be scoped per-job with permissions:. Defaulting to permissions: contents: read and granting writes only where needed is least privilege in practice.

Pitfall. Leaving the token at its broad default "because it works" — it works for you and for an attacker who lands code in the job.

Pipeline risk 3: poisoned pipeline execution (PPE)

Definition. An attacker getting their code to run inside your trusted CI environment.

How it works. Build logic often lives in the repo: Makefile, package.json scripts, npm install lifecycle hooks, a setup.py. If an attacker can modify any of these (directly, or via a PR that CI runs), their code executes with the pipeline's privileges. This is the bridge from "can edit a file" to "can read every secret."

  attacker edits build script  -->  CI runs it  -->  reads $DEPLOY_KEY
                                                 -->  exfiltrates / deploys backdoor

When NOT to be relaxed. Any workflow that runs attacker-influenced scripts and has access to secrets is a PPE risk. Isolate them: the untrusted path gets no secrets and no publish rights.

Knowledge check: Which pipeline log fields (trigger type, permissions granted, whether an install-time script ran) would let a responder detect a PPE attempt, and why should this only ever be reproduced in an authorized lab repo you own?

Pipeline risk 4: unpinned components

Definition. Referring to a dependency or CI action by a mutable name (a tag or branch) instead of an immutable identifier (a digest or pinned commit).

Plain language. uses: someorg/action@main means "run whatever main points to at run time." The maintainer — or an attacker who compromises them — can change what main points to after you wrote that line. You re-run an old workflow and silently get new, possibly malicious, code.

The fix is immutability: pin to a full commit SHA or image digest. A digest is a content hash; if the bytes change, the digest changes, so a pinned digest can't be swapped underneath you.

  mutable:  someorg/action@v3        -> could change later
  mutable:  ubuntu:latest           -> a moving target
  immutable: someorg/action@a1b2c3d (full commit sha)
  immutable: ubuntu@sha256:9f0e...   (content digest)

Knowledge check: Why does pinning to the tag v3 still leave you exposed, even though it looks specific?

Supply-chain risk 1: vulnerable dependencies

Definition. A package you depend on (directly or transitively) that has a known, publicly listed vulnerability (a CVE — Common Vulnerabilities and Exposures entry).

Detection. Tools that scan your dependency tree against vulnerability databases are called software composition analysis (SCA) or dependency scanning (e.g., npm audit, pip-audit, Trivy, Grype, Dependabot).

Pitfall / misconception. Most of your risk lives in transitive dependencies you never explicitly added, so scanning only your direct dependencies misses most of the surface. And a clean scan does not prove you are secure — scanners only know published CVEs, miss logic flaws and unpublished issues, and can over-report code paths you never call. "The scanner is green" is a signal, not a guarantee.

Supply-chain risk 2: malicious packages

Definition. A package deliberately built or modified to harm whoever installs it.

Two common routes:

  • Typosquatting — publishing a package with a name one keystroke away from a popular one (reqeusts vs requests), hoping for a typo.
  • Account takeover / handoff — a maintainer's account is hijacked, or maintenance is handed to a stranger who later adds malicious code (the event-stream pattern).

Malicious code typically runs at install or build time via lifecycle hooks, so you don't even need to call the package for it to fire.

Supply-chain risk 3: compromised builds

Definition. The build environment itself is subverted, so a clean source tree still produces a malicious artifact (the SolarWinds class).

Why it's hard. The output is signed and looks legitimate, because it was produced by your real pipeline. This is exactly why a valid signature is not proof the contents are safe — it only proves the artifact came from the signer unchanged; if the signer's build was poisoned, the malware is signed too. Defenses are provenance (proving where/how it was built) and reproducible builds (independently rebuilding to confirm the bytes match).

Defense: SBOM (Software Bill of Materials)

Definition. A complete, machine-readable inventory of every component in an artifact — names, versions, and ideally hashes — in a standard format (SPDX or CycloneDX).

Why it matters. When a new CVE drops at 2 a.m., the question is "are we affected, and where?" Without an SBOM you grep through a dozen repos hoping you remember every transitive dependency. With an SBOM you query a list and get a definitive answer in seconds. This is exactly what the quiz points at: an SBOM is the inventory that lets you map a newly disclosed CVE to your own software.

Defense: artifact signing and provenance

Definition. Cryptographically signing what you ship so consumers can verify it came from you and wasn't altered.

Plain language. A signature is a tamper-evident seal. Verification at deploy time means a swapped or rebuilt artifact fails the check and never runs. Provenance metadata (e.g., SLSA attestations) records how the artifact was built, raising the bar against compromised-build attacks. Remember two limits: a signature proves integrity and origin, not safety of contents; and "we sign our images" only helps if something actually verifies the signature at deploy time.

Knowledge check: Your deploy step verifies image signatures before running. An attacker pushes a tampered image to your registry under the same tag. What happens at deploy — and what would you expect to see in the deploy log?

Syntax notes

There is no programming "syntax" here, but the configuration patterns below are the practical building blocks. The annotations show the secure form; all snippets are lab-safe (no real hosts or secrets).

# GitHub Actions: least-privilege token + pinned action
permissions:
  contents: read            # start read-only; grant writes only where needed
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # PINNED to a full commit SHA (immutable), with the human-readable tag as a comment
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# Pin base images by digest, not by a moving tag like :latest
FROM debian@sha256:2bc5c236e9b262645a323e9088dfa399b8b54772b71e08f80b3f08c5d52cb88c
# Generate an SBOM for a built image (CycloneDX format) using Syft
syft my-image:1.4.2 -o cyclonedx-json > sbom.json

# Scan that SBOM (or the image) for known CVEs using Grype
grype sbom:./sbom.json
# Secrets in examples/tests use placeholders only -- never a real value
export API_KEY=<development-placeholder>

Key points: pin by digest/SHA, not tag; declare minimal permissions:; produce an SBOM as a build output; run an SCA scan as a pipeline step that can fail the build; and keep secrets as placeholders in anything you commit.

Lesson

Modern software is built and shipped by automated pipelines. These pipelines pull in huge amounts of third-party code. That makes them a large — and often overlooked — attack surface.

CI/CD pipeline risks

Pipelines such as GitHub Actions, GitLab CI, and Jenkins run code with powerful credentials: deploy keys, cloud roles, and registry access.

The main risks are:

  • Secrets in pipelines or logs. Secrets can be printed to logs, or read by pull-request workflows coming from forks.
  • Over-privileged tokens. For example, a GITHUB_TOKEN that has write or admin scope when it only needs read.
  • Poisoned pipeline execution. An attacker who can edit a workflow or a build script can run their code in the trusted CI environment.
  • Unpinned actions or images. Writing uses: someaction@main runs whatever that tag points to later — which the maintainer (or an attacker) can change.

Software supply chain

Your app depends on hundreds of transitive dependencies — packages pulled in indirectly by the packages you chose directly.

The main risks are:

  • Vulnerable dependencies. Packages with known CVEs (publicly listed vulnerabilities). These are found by dependency scanning, also called software composition analysis (SCA).
  • Malicious packages. Created through typosquatting (a name close to a real package) or hijacked maintainer accounts. They run code at install or build time.
  • Compromised build systems. The SolarWinds class of attack, where a backdoor is injected during the build.

Defenses

  • Pin dependencies and CI actions by version or digest, then verify their checksums or signatures.
  • Give pipeline tokens the least privilege they need. Isolate untrusted (fork) workflows, and never expose secrets to them.
  • Run dependency scanning and keep a maintained SBOM (Software Bill of Materials) — an inventory of every component you ship. When a new CVE appears, the SBOM tells you immediately whether you are affected.
  • Sign artifacts to provide provenance, and review your lockfiles.

Code examples

This example follows an insecure -> secure -> verify shape so you can see the vulnerable pattern, the fix, and a check that proves the fix works.

(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

This workflow is the classic poisoned-pipeline setup: it runs on pull_request_target (so it has repo secrets), then executes a script from the checked-out code. A fork PR can change that script and read the secret.

# .github/workflows/INSECURE-do-not-deploy.yml
name: insecure-example
on: pull_request_target        # DANGER: runs WITH secrets, but fork can influence code
permissions: write-all         # DANGER: every step gets write/admin power
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # DANGER: mutable tag, not pinned
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # checks out FORK code
      - run: ./scripts/ci.sh               # DANGER: fork-controlled script runs next to secrets
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Why it is dangerous: a fork can edit scripts/ci.sh to print or exfiltrate DEPLOY_KEY, and write-all plus the pull_request_target trigger means attacker code runs with credentials and repo write access. The @v4 tag is also mutable.

(2) SECURE fix

Same goals — build and test every PR, publish only trusted commits — but the untrusted path has no secrets and cannot publish, everything is pinned, and lifecycle scripts are disabled.

# .github/workflows/build.yml
name: build-and-scan

on:
  push:
    branches: [ main ]      # trusted: our own protected branch
  pull_request:             # untrusted: may come from a fork (NO secrets are exposed here)

# Default to read-only for the whole workflow (least privilege).
permissions:
  contents: read

jobs:
  # Runs on EVERY PR, including forks. Has NO secrets and cannot publish.
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      # Action pinned to an immutable commit SHA (tag kept as a comment for humans).
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
        with:
          node-version: '20'

      # Install EXACTLY what the lockfile says; ignore lifecycle scripts to blunt
      # malicious install-time code. 'npm ci' fails if package-lock.json drifts.
      - run: npm ci --ignore-scripts

      # Software composition analysis: fail the build on a high/critical CVE.
      - run: npm audit --audit-level=high

      - run: npm test

  # Runs ONLY on push to main (never on fork PRs). This job is allowed credentials.
  publish:
    needs: build-and-test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write       # narrowly grant ONLY what publishing needs
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      # Build from a base image pinned by content digest, not ':latest'.
      - run: docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .

      # Produce a Software Bill of Materials and keep it as a build artifact.
      - uses: anchore/sbom-action@e8d2a6937ecead383dfe75190d104edd1f9c5751 # v0.16.0
        with:
          image: ghcr.io/${{ github.repository }}:${{ github.sha }}
          format: cyclonedx-json
          output-file: sbom.json

      - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
        with:
          name: sbom
          path: sbom.json

(3) VERIFY — prove the fix rejects bad input and accepts good input

Run these in a lab repo you own. Each check has an expected result.

# CHECK A -- fork isolation REJECTS the attack path.
# From a throwaway fork, open a PR whose ci step tries: echo "$DEPLOY_KEY".
# EXPECTED: build-and-test runs, but $DEPLOY_KEY is empty/undefined (no secret exposed)
#           and the 'publish' job is skipped because event is pull_request, not push.

# CHECK B -- SCA gate REJECTS a known-vulnerable dependency.
# In a scratch branch, pin a dep to a version with a known high CVE, then:
npm audit --audit-level=high
echo "exit code: $?"        # EXPECTED: non-zero -> the build FAILS (bad input rejected)

# CHECK C -- clean tree ACCEPTS good input.
# Remove/upgrade the vulnerable dep, then:
npm audit --audit-level=high
echo "exit code: $?"        # EXPECTED: 0 -> the build PASSES (good input accepted)

# CHECK D -- pins are immutable. Confirm every uses:/FROM is a SHA or digest:
grep -REn 'uses:|^FROM' .github/workflows Dockerfile \
  | grep -Ev '@[0-9a-f]{40}|@sha256:[0-9a-f]{64}'   # EXPECTED: no output (nothing mutable)

What it does. Every pull request (including from forks) runs build-and-test, but that job has no secrets and cannot publish, so even if a fork's code or a malicious dependency runs, there is nothing valuable to steal and no way to ship. Only a push to the protected main branch runs publish, which is granted the single narrow scope it needs (packages: write) and produces an image plus an SBOM artifact.

Expected result. On a normal PR: dependencies install from the lockfile, npm audit passes (or fails the PR if a high-severity CVE is present), and tests run. On a merge to main: the image builds from a digest-pinned base, an sbom.json is generated and uploaded as a downloadable artifact, and the image is published. The VERIFY block shows a red build for bad input (Checks A and B) and a green build for good input (Check C), which is the whole point of a mitigation test.

Key edge cases. If package-lock.json is out of sync, npm ci fails fast (good — it prevents silent dependency drift). If a transitive dependency has a high CVE, --audit-level=high fails the build; you then either upgrade or document an accepted risk. --ignore-scripts reduces install-time attack surface but can break packages that legitimately need build steps, so verify your build still works after adding it.

Lab cleanup / reset. Delete the throwaway fork and any scratch branches, revoke any test token you created, and remove the intentionally vulnerable workflow file so it can never run outside the lab.

Line by line

Walking through the SECURE workflow in execution order, and what each choice defends against.

Line / block What happens Why it matters
on: push (main) / pull_request Defines two trigger types Separates trusted (our branch) from untrusted (fork PR) entry points
permissions: contents: read (top) Sets a read-only default for the whole workflow Least privilege by default; jobs opt up, never start broad
build-and-test job Runs on every PR This is the untrusted path — it deliberately has no secrets
uses: ...checkout@b4ffde6... Pulls a specific commit of the action Immutable pin; the action can't be swapped under us later
npm ci --ignore-scripts Installs exactly the lockfile, skipping lifecycle hooks Blocks the most common malicious-package execution path
npm audit --audit-level=high Scans the dependency tree for CVEs SCA gate; a high-severity finding fails the build
npm test Runs the test suite Normal CI quality gate
publish job, needs: build-and-test Runs only after tests pass Won't publish broken or vulnerable builds
if: github.event_name == 'push' Skips the job for any pull_request The credentialed job never runs for fork PRs (PPE defense)
permissions: packages: write Grants exactly one extra scope The deploy token can publish — and nothing more
docker build ... from digest base Builds from a pinned base image Reproducible, tamper-resistant base layer
sbom-action Generates sbom.json Inventory for future CVE lookups
upload-artifact Stores the SBOM The bill of materials travels with the release

Trace of a hostile fork PR (how attacker leverage collapses step by step):

Step State Result
Attacker opens PR from fork with a malicious postinstall script pull_request event fires Only build-and-test is eligible
npm ci --ignore-scripts runs lifecycle hooks disabled postinstall never executes
Suppose the script somehow ran job has no secrets; packages: write absent nothing to exfiltrate, no publish path
Runner reaches publish job github.event_name == pull_request, not push job is skipped entirely
Net effect attacker code met empty, powerless ground attack yields nothing

Contrast this with the INSECURE example: there, pull_request_target + write-all + a fork-controlled ci.sh means step 2 would run the attacker's code with DEPLOY_KEY in the environment — the exact collapse of the trust boundary this design prevents.

Common mistakes

Mistake 1 — pinning to a tag and calling it "pinned."

# WRONG: a tag is mutable; it can be re-pointed at new code later
- uses: someorg/deploy-action@v3

Why it's wrong: v3 is a label, not the bytes. If the maintainer's account is compromised, v3 can be moved to malicious code and your next run pulls it. Corrected:

- uses: someorg/deploy-action@9c2f1e7a3b... # v3 (full commit SHA)

Recognize/prevent it by grepping your workflows for @v or @main/@master in uses: lines (see Check D in the code section).

Mistake 2 — exposing secrets to untrusted workflows.

# WRONG: pull_request_target runs with secrets AND can be influenced by fork code
on: pull_request_target
jobs:
  test:
    steps:
      - run: ./scripts/deploy.sh   # fork-controlled script + live secrets

Why it's wrong: this is textbook poisoned-pipeline execution — attacker code runs next to your credentials. Corrected: use plain pull_request (no secrets), do the privileged work only on push to a protected branch, and never run repo scripts with secrets present on fork-triggered events. Recognize it by auditing every pull_request_target usage.

Mistake 3 — broad default token "to make it work."

# WRONG
permissions: write-all

Why it's wrong: every step now has write/admin power; one compromised dependency owns the repo. Corrected: permissions: contents: read at the top and grant specific scopes per job. Prevent it by reviewing the permissions: block in code review.

Mistake 4 — only scanning direct dependencies / ignoring the lockfile. Running a scan but committing without a lockfile (or using npm install instead of npm ci) means each build resolves slightly different versions, so your scan results don't match what actually shipped. Corrected: commit the lockfile, install with npm ci, and scan the resolved tree (or the SBOM). Recognize it when local and CI scans disagree.

Mistake 5 — "the scanner is green, so we're secure" / "it's signed, so it's safe." A passing SCA scan only rules out published CVEs in scanned components; it says nothing about logic bugs, unpublished issues, or a poisoned build. A valid signature only proves the bytes came from the signer unchanged — signed malware is still malware. Corrected: treat scans and signatures as layers, keep provenance and human review in the loop, and never claim anything is "completely secure."

Mistake 6 — treating the SBOM as a one-time document. An SBOM generated once and never regenerated is stale the moment a dependency changes. Corrected: generate it on every release build as a pipeline artifact, so it always matches the shipped bytes.

Debugging tips

Because this is configuration and process rather than C, the "bugs" are misconfigurations and false confidence. Concrete steps when things don't work:

"My build suddenly fails after I pinned an action by SHA." You likely copied the SHA of the wrong ref or a non-existent commit. Verify the SHA exists in the action's repo and corresponds to the tag you intended (git ls-remote against the action repo, or check the tag on GitHub). Keep the human tag in a trailing comment so you can re-verify.

"npm ci fails with a lockfile error." package.json and package-lock.json are out of sync. Run npm install locally, commit the updated lockfile, and push. npm ci is supposed to fail on drift — that's the safety feature, not a bug.

"The dependency scan passes locally but fails in CI (or vice versa)." Different versions resolved. Confirm both environments use the same lockfile and the same install command (npm ci, not npm install). Scan the SBOM rather than a fresh install so you scan exactly what shipped.

"A CVE is reported but we don't think we're affected." Use the SBOM to check whether the vulnerable component is actually present, whether the vulnerable version range matches yours, and whether the vulnerable code path is reachable. Document the analysis; tools can over-report (a vulnerable function may never be called). Do not silently suppress — record the accepted-risk decision.

"Signature verification fails at deploy." Either the artifact was rebuilt/tampered (treat as a real incident until proven otherwise), the wrong public key/identity is configured, or the signature wasn't produced. Re-run the signing step and confirm the verifying identity matches the signer.

Questions to ask when a pipeline behaves unexpectedly: Which trigger fired (push vs pull_request vs pull_request_target)? What permissions did this job actually have? Did any install-time script run? Is every external reference pinned to an immutable identifier? What does the SBOM say is actually present? Who or what triggered the run, and does the audit log show any first-time publisher or permission escalation?

Memory safety

Security & safety. This topic is a security topic, so the defensive practices are the core of it.

Risks to keep front of mind: secret exposure (logs, fork PRs, baked-in images), over-privileged automation tokens, poisoned pipeline execution, mutable/unpinned components, vulnerable transitive dependencies, malicious packages running at install time, and compromised builds producing legitimately-signed malware.

Defensive practices, layered:

  • Pin everything immutable. Actions by commit SHA, container bases by digest, dependencies by lockfile. Immutability removes the "it changed underneath me" class entirely.
  • Least privilege by default. Start tokens read-only; grant the minimum scope per job; prefer short-lived OIDC-issued cloud credentials over long-lived static keys.
  • Isolate untrusted input. Fork PRs run without secrets and without publish rights. Privileged work happens only on protected branches.
  • Reduce install-time execution. Where feasible, install without lifecycle scripts; review new dependencies before adding them.
  • Continuous SCA + maintained SBOM. Scan on every build and fail on high/critical findings; regenerate the SBOM each release so you can answer CVE questions instantly.
  • Sign and verify. Sign artifacts; verify signatures (and ideally provenance) at deploy so tampered artifacts never run — and remember verification proves origin/integrity, not that the contents are benign.

Detection and logging guidance. For every pipeline run, log a structured record: timestamp, who/what triggered it (source actor and event type), the workflow/commit (correlation id you can trace end to end), the permissions/scopes the job actually used, the resource touched (what was built, published, and to which registry), the result of every SCA scan and signature verification, and the security decision made (build gated, artifact rejected, risk accepted). Alert on first-time publishers, permission escalations, pull_request_target usage, and any verification failure.

Never log passwords, tokens, API keys, session cookies, signing/private keys, or full secret values — mask them, and use placeholders like API_KEY=<development-placeholder> in examples and tests. Keep build logs access-controlled; public logs are a common secret-leak source. Avoid logging unneeded PII.

Which events signal abuse: a job on a fork trigger suddenly requesting write scopes, an install-time script reaching out to the network, a new maintainer publishing a package minutes after gaining access, or a signature check failing on an artifact under an existing tag. How false positives arise: a scanner flagging a CVE in a code path you never call, npm ci failing on a legitimate but un-committed lockfile update, or a signature failure caused by a rotated-but-not-yet-distributed key rather than tampering. Investigate before you conclude — but treat verification failures as real incidents until cleared.

Mitigation verification (how to test the fix). To confirm fork isolation: open a test PR from a throwaway fork that tries to print an env var, and verify no secret appears and the publish job is skipped. To confirm SCA gating: pin a dependency to a version with a known high CVE in a scratch branch and confirm the build fails, then remove it and confirm it passes. To confirm signing: tamper with a built artifact in a lab registry and confirm deploy-time verification rejects it. These are the Checks A–D in the code section.

Real-world uses

Where this shows up in practice (authorized work). Every team using GitHub Actions, GitLab CI, Jenkins, CircleCI, or similar is doing supply-chain security whether they name it or not. A concrete case: a security engineer is asked to harden a company's own release pipeline before a compliance audit — they pin every action and base image, split the fork path from the credentialed publish path, add an SCA gate, and wire SBOM generation into each release so the incident team can answer "are we exposed?" when the next CVE lands. All of that happens on repositories the company owns.

Container platforms (Docker Hub, GHCR, ECR) increasingly surface image SBOMs and vulnerability scans. Cloud deploys use OIDC to hand pipelines short-lived credentials instead of static keys. Open-source ecosystems (npm, PyPI, crates.io) now offer provenance and signing so consumers can verify package origin. Government and enterprise procurement frequently require an SBOM (driven by US Executive Order 14028 and NIST guidance), and frameworks like SLSA define maturity levels for build integrity.

Professional best-practice habits.

Beginner rules:

  • Pin actions by commit SHA and base images by digest; commit your lockfiles.
  • Set permissions: contents: read as the default and grant scopes per job (least privilege).
  • Never let fork PRs touch secrets; do privileged work only on protected branches (secure defaults).
  • Add a dependency scan that fails the build on high/critical CVEs (validation + error handling).
  • Generate an SBOM as a build artifact on every release.

Advanced habits:

  • Use short-lived OIDC credentials and per-environment deploy roles (least privilege at scale).
  • Sign artifacts and verify signatures + provenance at deploy time (e.g., SLSA attestations).
  • Pursue reproducible builds so a third party can independently confirm your bytes.
  • Maintain a dependency-review/allowlist process and monitor for newly added or newly maintained packages.
  • Centralize and alert on pipeline audit logs; rehearse the "new critical CVE" response using the SBOM.

Throughout, favor readability and reviewability: a pinned uses: line with a tag comment, an explicit permissions: block, and a committed lockfile make security decisions visible in code review rather than buried in a tool's defaults.

Practice tasks

Work these in order; each adds difficulty. Do all of them on your own repositories or throwaway lab projects only.

Authorization checklist (before any lab): (1) You own the repo/account or have explicit written permission. (2) The runner is a personal/throwaway or local runner, not shared production infrastructure. (3) No real secrets are used — placeholders only (API_KEY=<development-placeholder>). (4) You have a cleanup plan (delete forks, revoke test tokens, remove intentionally vulnerable files) and will run it.

Beginner 1 — Find the mutable references. Objective: audit a workflow for unpinned components. Take any .github/workflows/*.yml you control (or write a small one) and list every uses: and every FROM in your Dockerfile. Requirements: classify each as mutable (tag/branch) or immutable (SHA/digest). Output: a two-column table. Hint: anything after @ that isn't a 40-char hex string (or a sha256: digest) is mutable. Concepts: pinning, immutability.

Beginner 2 — Lock down the token. Objective: apply least privilege. Add a top-level permissions: contents: read to a workflow, then identify which single job actually needs a write scope and grant only that scope to only that job. Requirements: the workflow must still build/test/publish correctly. Hint: most jobs only read code. Concepts: over-privileged tokens, least privilege.

Intermediate 1 — Generate and query an SBOM. Objective: produce an inventory and use it. Build a small container image, generate an SBOM with syft <image> -o cyclonedx-json > sbom.json, then answer: how many total components are listed, and is a specific package (you pick one) present and at what version? Output: the count and the package/version answer, copied from the SBOM. Constraint: do it locally. Concepts: SBOM, transitive dependencies. Defensive conclusion: note how you would use this SBOM to answer a future "are we affected by CVE-X?" query.

Intermediate 2 — Gate the build on a CVE (lab only). Objective: prove your SCA actually blocks. In a scratch branch, add a dependency with a known high-severity CVE, then add a scan step (npm audit --audit-level=high, pip-audit, or grype) that fails the build. Requirements: show the failing run, then upgrade/remove the dependency and show it passing. Input/output: a failing CI run, then a green one. Concepts: SCA, build gating. Defensive conclusion: remediate (upgrade/remove) and verify the green run — a red-then-green pair is your mitigation proof. Cleanup: delete the scratch branch.

Challenge — Isolate an untrusted fork workflow. Objective: design a two-job workflow that is safe against poisoned pipeline execution. Requirements: (1) a build-and-test job that runs on pull_request with no secrets and no publish rights; (2) a publish job guarded by if: github.event_name == 'push' with exactly one narrow write scope; (3) install with lifecycle scripts disabled. Verify (lab only): open a test PR from a throwaway fork that attempts to read an env var and confirm nothing leaks and the publish job is skipped. Constraints: no real secrets reachable from the PR path. Concepts: PPE, trust boundaries, least privilege, secret isolation. Defensive conclusion: your deliverable is the design plus the verification evidence (empty secret + skipped publish) and a note on what pipeline log fields would detect the attempt. Cleanup: delete the fork and any test token. Do not reveal a full solution to others.

Summary

CI/CD pipelines and the software supply chain are a top-tier modern attack surface because pipelines hold production credentials while pulling in vast amounts of code from strangers. The four pipeline risks are leaked secrets, over-privileged tokens, poisoned pipeline execution, and unpinned (mutable) components; the three supply-chain risks are vulnerable dependencies, malicious packages, and compromised builds.

The defenses are concrete and reinforce each other: pin every component by an immutable identifier (commit SHA or content digest); grant tokens least privilege and scope them per job; isolate untrusted fork workflows so secrets and publish rights never reach attacker-influenced code; run SCA scanning that fails the build on serious CVEs; maintain an SBOM so you can instantly answer "are we affected by this new CVE?"; and sign and verify artifacts so tampered builds never deploy. Every fix pairs with a mitigation-verification test (red for bad input, green for good input) and with logging that captures who triggered a run, what scopes it used, and the result of each scan and signature check.

Key syntax/commands to remember: permissions: contents: read by default; uses: pinned to a 40-char SHA with the tag as a comment; base images pinned by sha256: digest; committed lockfiles installed with npm ci --ignore-scripts; npm audit --audit-level=high (or grype/pip-audit) as a gate; and syft ... -o cyclonedx-json for the SBOM. The most common mistakes are mistaking a tag for a pin, exposing secrets to fork PRs via pull_request_target, defaulting tokens to write-all, and believing "the scanner is green" or "it's signed" means "it's safe" — neither proves a system is secure, and nothing is ever "completely secure." Remember the core question of supply-chain security: what is in what I ship, where did it come from, and can I prove it wasn't tampered with?