Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 4 – A03:2025 Software Supply Chain Failures

Build pipelines and artifact integrity

Harden CI workflows with scoped tokens, pinned actions and safe scripts, then sign provenance, verify every artifact and promote one tested build.

  • Advanced
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Configure CI workflows with least-privilege tokens, pinned actions and no event values inside scripts
  • Explain build provenance, signing and attestations, with SLSA build levels and Sigstore keyless signing
  • Verify an artifact's digest, signer and source before it is deployed
  • Separate build from deploy and promote one artifact instead of rebuilding it for each environment
  • Prepare the response to a compromised action or a leaked CI secret

Before you start

What you’ll learn · Before you start

What you will learn

  • Configure CI workflows with least-privilege tokens, pinned actions and no event values inside scripts
  • Explain build provenance, signing and attestations, with SLSA build levels and Sigstore keyless signing
  • Verify an artifact's digest, signer and source before it is deployed
  • Separate build from deploy and promote one artifact instead of rebuilding it for each environment
  • Prepare the response to a compromised action or a leaked CI secret

Before you start

On this page

In 2025 someone moved the version tags of a widely used GitHub Action to a malicious commit, and workflows that named the action by one of those tags printed their own secrets into their logs. CISA asked every team that used it to check its runs over a window of three and a half days, in repositories that had not changed a line.

You will harden ShopNest’s workflow from 8 findings to none, sign provenance for a release and verify six releases with two verifiers, build once and promote the same artifact, and prepare the response to a compromised action or a leaked CI secret.

The pipeline holds the keys, so it is a target

A CI pipeline reads the code, installs the dependencies, holds the tokens that publish packages and the keys that deploy them. OWASP’s A03 page lists, among the signs of a vulnerable supply chain, a CI/CD pipeline more lightly defended than the systems it builds and deploys, and no separation of duties, so that one person can write code and push it to production with nobody else looking. Its defences for build servers name separation of duties, access control, signed builds, secrets scoped to an environment and logs that show tampering.

The 2025 case shows how little it takes. The advisory for the tj-actions/changed-files action, CVE-2025-30066, says its version tags were changed after the fact to point to a malicious commit, whose code read secrets from the runner’s memory and printed them in the workflow logs. CISA’s alert lists the kinds of secret at risk: access keys, GitHub personal access tokens, npm tokens and private RSA keys. A second compromised action, reviewdog/action-setup, CVE-2025-30154, was involved, and CISA added both to its catalogue of known exploited vulnerabilities. Step through what a moved tag does to a workflow:

Interactive · message by message A tag that moved under a workflow Step through a run that names an action by a tag, before and after the tag is pointed at another commit.
A tag that moved under a workflow, step 6 of 6. Messages so far: Your workflow to The action's repository: which commit is v44?; The action's repository to The runner and its log: the new commit runs.Your workflowuses: …/action@3d3c42e5…The action's repositoryv44 now points elsewhereThe runner and its logsecrets printed in the logwhich commit is v44?the new commit runs

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A tag is a pointer. The workflow names a third-party action by a version tag. Each run asks the action's repository which commit the tag points to, and runs that commit.
  2. The tag is moved. In the 2025 compromise, someone with access to the action's repository pointed several existing version tags at a new, malicious commit. Nothing changed in the repositories that used it.
  3. The next run takes it. The next run resolved the same tag to the new commit and ran it with the job's secrets. No pull request, no review, no diff showed the change.
  4. Secrets in the log. The advisory says the code read secrets from the runner's memory and printed them in the workflow log. In a public repository anyone can read those logs.
  5. A full SHA does not move. A step that names the action by a full commit SHA runs the code that was reviewed, whatever happens to the tags. Moving a tag changes nothing for it.
  6. Pinned is not proof. A pin freezes what you chose. The advisory told anyone pinned to the malicious commit's SHA to update at once, so pins still need review when they change, and rotation when they were wrong.

Not affiliated

MySmartCoPilot is independent of OWASP, CISA, GitHub, the projects of the actions named here and the tools this lesson cites. The incident comes from CISA’s alert and the GitHub Advisory Database, under References; it shows how a pipeline can fail, not a fault of any one project.

Checkpoint

Check: Why the pipeline is a target

  1. One question A workflow names an action as owner/action@v44. Someone with access to the action's repository points the tag v44 at a new commit. What happens on the workflow's next run?

    Choose one answer.

    Show the answer: One question

    Answer: It runs the new commit, with the job's secrets, and nothing changes in the workflow's own repository

    A version tag is a movable pointer. Workflows that named the compromised action by a tag ran the malicious commit on their next run, and the advisory says its code printed secrets into the logs.

A workflow file is code: scope the token, pin actions, keep event values out of scripts

GitHub’s secure-use reference for Actions gives the rules, and ShopNest’s old workflow broke most of them:

  • A token with the least rights. The automatic GITHUB_TOKEN should get only the permissions a job needs, following least privilege: Giving every program, account and person only the rights its task needs, so that a bug or a stolen credential can reach as little as possible. More in “Security principles every builder applies”: a permissions: key of contents: read, widened per job where a job must write.
  • Actions pinned to a full commit SHA. A tag can be moved or deleted. A full-length SHA runs the code that was reviewed. Where a repository adopts GitHub’s workflow dependency lockfile, in public preview, the lockfile records the resolved commit of every action, transitive ones included, and checks the source repository at run time.
  • No event values inside scripts. An expression such as ${{ github.event.pull_request.title }} in a run: step is expanded into the script’s text before the shell starts, so a pull request’s author decides part of the command. This is script injection (CI): In a CI workflow, an expression that holds a value such as a pull request's title, written inside a run script, is expanded into the script's text before the shell runs it, so a value someone else controls becomes part of the command.. Pass the value through an environment variable, which the shell treats as data.
  • No untrusted code under privileged triggers. pull_request_target and workflow_run run with the base repository’s token and secrets, so they must not check out a pull request’s code. Under these triggers, actions/checkout 7 refuses a fork’s pull request code unless the workflow sets allow-unsafe-pr-checkout: true.

This program checks ShopNest’s workflow, in the parsed form a YAML parser returns, for those rules and two more: a job that reads secrets without an environment, and a deploy job that builds again. Press Edit, add the line delete fixed.permissions; under the import, and run it again to see the checker flag the fixed workflow once more:

Six checks on ShopNest's workflow, before and after

JavaScript (workflow_check.mjs) · workflow/workflow_check.mjs

// Six checks a reviewer can run on a GitHub Actions workflow, written against its parsed form, on ShopNest's ci.yml
// as it was and as it is now. Real linters such as actionlint and zizmor read the YAML and know far more rules; these
// six are the ones this lesson explains, and each finding says what to change.
import { fixed, weak } from './workflows.mjs';

const FULL_SHA = /@[0-9a-f]{40}$/;
const EVENT_VALUE = /\$\{\{\s*github\.(?:event\.|head_ref\b)/;
const PR_HEAD = /github\.event\.(?:pull_request\.head|workflow_run\.head)/;

function check(workflow) {
  const found = [];
  const add = (where, rule, advice) => found.push(`${where}: ${rule}: ${advice}`);
  const triggers = Object.keys(workflow.on);
  if (!workflow.permissions) add('workflow', 'permissions', 'no permissions key, so the token gets the repository default');
  for (const [id, job] of Object.entries(workflow.jobs)) {
    const deploys = id.includes('deploy') || job.environment !== undefined;
    if (JSON.stringify(job).includes('${{ secrets.') && !job.environment) add(`job ${id}`, 'environment', 'reads secrets with no environment to protect them');
    job.steps.forEach((step, n) => {
      const at = `job ${id}, step ${n + 1}`;
      if (step.uses && !step.uses.startsWith('./') && !FULL_SHA.test(step.uses)) add(at, 'pin', `${step.uses} is a tag that can move; pin the full commit SHA`);
      if (step.run && EVENT_VALUE.test(step.run)) add(at, 'injection', 'an event value is pasted into the script; pass it in env');
      const untrusted = triggers.includes('pull_request_target') || triggers.includes('workflow_run');
      if (untrusted && step.uses?.startsWith('actions/checkout@') && PR_HEAD.test(step.with?.ref ?? '')) {
        add(at, 'untrusted-checkout', "runs the pull request's code with the base repository's token and secrets");
      }
      if (deploys && step.run && /\bnpm (?:ci|install|run build)\b/.test(step.run)) add(at, 'rebuild', 'builds again; deploy the artifact the tests passed');
    });
  }
  return found;
}

for (const [name, workflow] of [['ci.yml as it was', weak], ['ci.yml now', fixed]]) {
  const found = check(workflow);
  console.log(`${name}: ${found.length} findings`);
  for (const line of found) console.log(`  ${line}`);
}

Output

ci.yml as it was: 8 findings
  workflow: permissions: no permissions key, so the token gets the repository default
  job test, step 1: pin: actions/checkout@v4 is a tag that can move; pin the full commit SHA
  job test, step 1: untrusted-checkout: runs the pull request's code with the base repository's token and secrets
  job test, step 2: pin: actions/setup-node@v4 is a tag that can move; pin the full commit SHA
  job test, step 4: injection: an event value is pasted into the script; pass it in env
  job deploy: environment: reads secrets with no environment to protect them
  job deploy, step 1: pin: actions/checkout@v4 is a tag that can move; pin the full commit SHA
  job deploy, step 2: rebuild: builds again; deploy the artifact the tests passed
ci.yml now: 0 findings

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node workflow_check.mjs

JavaScript (workflows.mjs) · workflow/workflows.mjs

// ShopNest's .github/workflows/ci.yml in its parsed form, the object a YAML parser returns for the file. `weak` is the
// workflow as it was; `fixed` is the same pipeline after this lesson. Each pinned SHA is the commit that the version in
// its comment points to.
export const weak = {
  name: 'CI',
  on: { pull_request_target: { branches: ['main'] }, push: { branches: ['main'] } },
  jobs: {
    test: {
      'runs-on': 'ubuntu-24.04',
      steps: [
        { uses: 'actions/checkout@v4', with: { ref: '${{ github.event.pull_request.head.sha }}' } },
        { uses: 'actions/setup-node@v4', with: { 'node-version': '24.21.0' } },
        { run: 'npm ci' },
        { name: 'Show what is tested', run: 'echo "Testing: ${{ github.event.pull_request.title }}"' },
        { run: 'npm test' },
      ],
    },
    deploy: {
      needs: 'test',
      if: "github.ref == 'refs/heads/main'",
      'runs-on': 'ubuntu-24.04',
      steps: [
        { uses: 'actions/checkout@v4' },
        { run: 'npm ci && npm run build' },
        { run: './scripts/deploy.sh dist', env: { DEPLOY_KEY: '${{ secrets.DEPLOY_KEY }}' } },
      ],
    },
  },
};

export const fixed = {
  name: 'CI',
  on: { pull_request: { branches: ['main'] }, push: { branches: ['main'] } },
  permissions: { contents: 'read' },
  jobs: {
    build: {
      'runs-on': 'ubuntu-24.04',
      steps: [
        { uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' }, // v7.0.1
        { uses: 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1', with: { 'node-version': '24.21.0' } }, // v7.1.0
        { run: 'npm ci --ignore-scripts' },
        { name: 'Show what is tested', env: { TITLE: '${{ github.event.pull_request.title }}' }, run: 'echo "Testing: $TITLE"' },
        { run: 'npm test && npm run build && tar -czf shopnest.tgz dist' },
        { uses: 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9', with: { name: 'shopnest', path: 'shopnest.tgz' } }, // v7.0.2
      ],
    },
    deploy: {
      needs: 'build',
      if: "github.ref == 'refs/heads/main'",
      'runs-on': 'ubuntu-24.04',
      environment: 'production',
      permissions: { contents: 'read', 'id-token': 'write' },
      steps: [
        { uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' }, // v7.0.1
        { uses: 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333', with: { name: 'shopnest' } }, // v8.0.2
        { run: './scripts/verify-and-deploy.sh shopnest.tgz' },
      ],
    },
  },
};

The old workflow has 8 findings: no permissions key, three actions named by a tag, the pull request’s title pasted into a script, a checkout of the pull request’s head under pull_request_target, a deploy job that reads a secret without an environment, and a deploy job that runs npm ci && npm run build again. The fixed one has none. Real linters go further: actionlint checks syntax, expressions and action inputs and flags script injection by untrusted inputs, and zizmor finds and fixes security issues in GitHub Actions setups. Run one in CI on every change to .github/, and protect the folder with CODEOWNERS, as GitHub advises.

Counting on secret masking

GitHub’s reference says redaction of secrets in logs is not guaranteed: a value transformed by a step, or a secret stored as a block of JSON, can appear unmasked, and any step can read the secret itself. Keep each secret out of the jobs that do not need it, and store each sensitive value as its own secret.

How you name each action is a choice with costs. Answer three questions about one action, and the table shows which references fit:

Interactive · answer and compare How should a workflow name this action? Answer three questions about one action your workflows use; the table rules out the ways that do not fit.
Who publishes the action?
Does a tool such as Dependabot open pull requests when your actions release new versions?
Does the action itself use other actions, as a composite action can?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to reference an action, by what a moved tag does, how fixes arrive and the work each needs
Reference If the tag is moved Getting fixes Work for the team
A version tag Looks like: owner/action@v7 Poor the next run takes the new commit, with nobody reviewing it Good fixes arrive as soon as the tag moves Good nothing to maintain
A full commit SHA Looks like: the 40-character SHA, version in a comment Good the reviewed commit runs; a moved tag changes nothing Poor none until someone changes the pin Mixed one SHA per step to review and to update
A SHA kept current Looks like: Dependabot proposes each new SHA Good the reviewed commit runs until a pull request changes it Good a pull request for each new release of the action Mixed every bump is a pull request someone reads
A workflow lockfile Looks like: .github/workflows/actions.lock, in preview Good records the resolved commit and checks the source repository at run time Mixed updates go through the lockfile, which a command regenerates Mixed a preview feature: its tooling and limits may change
A copy you maintain Looks like: the action, forked into your organisation Good only your organisation can move it Poor every upstream fix must be ported by you Poor you maintain the action as your own code
How the answers decide
  • “Who publishes the action?” A third party: rules out A version tag. GitHub's guidance pins third-party actions to a full commit SHA: a tag can be moved or deleted by whoever controls the repository.
  • “Does a tool such as Dependabot open pull requests when your actions release new versions?” No: rules out A SHA kept current and adds a caveat to A full commit SHA. Without update pull requests a SHA pin keeps an old version for ever; turn on Dependabot for actions, or review pins on a schedule.
  • “Does the action itself use other actions, as a composite action can?” Yes, it calls others: adds a caveat to A full commit SHA and A SHA kept current. A SHA pins the action's own code; actions it calls by tag can still move. A workflow lockfile records those transitive actions too.
  • “Who publishes the action?” Our own organisation: adds a caveat to A version tag. A tag in your own organisation moves only if your own access is misused, which is exactly what a pin protects against.

GitHub Actions Linter (Workflow Security and Expression Checks) Paste a workflow file of your own and see its expression errors, unpinned actions and security findings.

Checkpoint

Check: Hardening a workflow file

  1. Question 1 of 2 A step runs echo "Testing ${{ github.event.pull_request.title }}". Why is that a finding, and what is the fix?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The expression is expanded into the script's text before the shell runs, so the pull request's author decides part of the command; pass the title in env and use $TITLE

    Expressions in run scripts are a known injection point, and actionlint flags script injection by untrusted inputs. Values from the event payload belong in env, never in the script's text.

  2. Question 2 of 2 Why does the checker flag a checkout of github.event.pull_request.head.sha in a workflow that runs on pull_request_target?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: pull_request_target runs with the base repository's token and secrets, so checking out and running the pull request's code gives that code those rights

    Run untrusted code on the pull_request trigger, which gets no secrets for pull requests from forks, and keep pull_request_target for steps that never execute the pull request's code.

Provenance says how an artifact was built, and a signature makes it checkable

An artifact on its own says nothing about where it came from. build provenance: A record of how an artifact was built: the builder, the source repository and ref, the inputs and the artifact's digest. SLSA's provenance is an in-toto statement whose subject names the file; signed by the build platform, it can be checked. is the record: which builder ran, from which repository and ref, with which inputs, producing which file, named by its digest. SLSA, a framework of the Open Source Security Foundation, grades how far that record can be trusted. Its version 1.2 defines these build levels:

Level What it requires What it protects against
Build L0 Nothing Nothing
Build L1 Provenance exists, made by the build platform: who built the package, how and from what Mistakes in releasing; it is easy to forge
Build L2 A hosted build platform that signs the provenance, and consumers who check it Tampering after the build
Build L3 A hardened platform: runs isolated from each other, signing secrets out of reach of build steps Tampering during the build, by insiders or with stolen credentials

The provenance is a JSON statement in the in-toto format, with predicateType https://slsa.dev/provenance/v1, a subject that names the file and its SHA-256 digest, and a predicate that holds builder.id and the externalParameters of the build. The specification puts the verifier’s duty plainly: consumers must accept only specific pairs of signer and builder, and should reject external parameters they do not expect.

Signing used to mean guarding a long-lived private key. keyless signing: Signing without a long-lived key: the signer proves an identity through OIDC, receives a short-lived certificate for it, signs once and discards the key, and the event is logged publicly. A verifier checks the expected identity and issuer. with Sigstore removes it: the signer proves an identity through OpenID Connect: a standard way for a service, such as a CI platform, to issue a signed token that says who or what is calling, which another service can check without a shared secret., such as a CI workflow’s, receives a short-lived certificate bound to that identity, signs once, discards the key, and the signing is recorded in the Rekor transparency log. A verifier then names who it expects, with cosign’s --certificate-identity and --certificate-oidc-issuer. npm provenance statements and PyPI’s trusted publishers bring the same idea to registries; PyPI’s publishing tokens from trusted publishing are valid for 15 minutes.

Checkpoint

Check: Provenance, SLSA levels and keyless signing

  1. Question 1 of 2 A build platform writes provenance for every package but does not sign it. Which SLSA build level is that, and what does it protect against?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Build L1, which helps against mistakes in releasing but is easy to forge

    Each level adds a guarantee: provenance exists (L1), it is signed by a hosted platform (L2), and the platform is hardened against tampering during the build (L3).

  2. Question 2 of 2 With Sigstore's keyless signing there is no long-lived public key to check against. What must a verifier name instead?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The identity it expects, such as a CI workflow, and the OIDC issuer that vouched for it, for example with cosign's --certificate-identity and --certificate-oidc-issuer

    Keyless signing replaces "which key" with "which identity". A verifier that accepts any valid certificate accepts everyone's signatures.

Verify the digest, the signer and the source before you deploy

A signature proves only that a key signed some bytes. The deploy step has to ask three questions: was it signed by the builder we trust, does the provenance name the exact file in hand, and was it built from the source we release from? This program signs provenance for ShopNest’s release with an Ed25519 key, in a Dead Simple Signing Envelope: a small JSON wrapper that holds a payload, its type and one or more signatures, each of which covers both the type and the payload., then gives six releases to two verifiers: one that checks the signature with the key that came with the release, and one that does the full check:

Six releases, a signature-only check and a full check JavaScript · sign/sign_verify.mjs
// ShopNest's release with signed provenance: an in-toto statement with a SLSA provenance predicate, in a DSSE envelope,
// signed with Ed25519. The keys come from fixed seeds so that the run repeats; a real build platform keeps its key to
// itself, or signs with Sigstore's short-lived certificates. Six releases go to two verifiers.
import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';

const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
function keyFrom(seedText) {
  const seed = createHash('sha256').update(seedText).digest();
  const privateKey = createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]), format: 'der', type: 'pkcs8' });
  const publicKey = createPublicKey(privateKey);
  return { privateKey, publicKey, id: sha256(publicKey.export({ type: 'spki', format: 'der' })).slice(0, 12) };
}
const builder = keyFrom('the release builder of ShopNest');
const stranger = keyFrom('a key that is not the release builder');

const BUILDER_ID = 'https://ci.shopnest.example/builders/release';
const REPO = 'https://git.shopnest.example/shopnest/app';
const COMMIT = '5e0c1a9d3b7f24c86e1f0a2b9c8d7e6f5a4b3c2d';
const TYPE = 'application/vnd.in-toto+json';
// DSSE's pre-authentication encoding: the signature covers the payload type and the payload, each with its length.
const pae = (body) => Buffer.concat([Buffer.from(`DSSEv1 ${TYPE.length} ${TYPE} ${body.length} `), body]);

const bundle = (run) => Buffer.from(`// ShopNest 1.4.0, CI run ${run}\nexport const shop = 'ShopNest';\n`);
function provenance(file, ref, run) {
  return {
    _type: 'https://in-toto.io/Statement/v1',
    subject: [{ name: 'shopnest-1.4.0.js', digest: { sha256: sha256(file) } }],
    predicateType: 'https://slsa.dev/provenance/v1',
    predicate: {
      buildDefinition: {
        buildType: 'https://ci.shopnest.example/build-types/node@v1',
        externalParameters: { repository: REPO, ref },
        resolvedDependencies: [{ uri: `git+${REPO}`, digest: { gitCommit: COMMIT } }],
      },
      runDetails: { builder: { id: BUILDER_ID }, metadata: { invocationId: `run-${run}` } },
    },
  };
}
function envelope(statement, key) {
  const body = Buffer.from(JSON.stringify(statement));
  return { payloadType: TYPE, payload: body.toString('base64'), signatures: [{ keyid: key.id, sig: sign(null, pae(body), key.privateKey).toString('base64') }] };
}
const signedBy = (env, publicKey) => verify(null, pae(Buffer.from(env.payload, 'base64')), publicKey, Buffer.from(env.signatures[0].sig, 'base64'));

/** Accepts any release whose envelope verifies with the key that came with it. */
const signatureOnly = (r) => (signedBy(r.envelope, r.publicKey) ? 'accept' : 'refuse');

/** Accepts only the release builder's signature, on provenance that names this file and the expected source. */
function fullCheck(r) {
  if (!signedBy(r.envelope, builder.publicKey)) return "refuse: no valid signature by the release builder's key";
  const statement = JSON.parse(Buffer.from(r.envelope.payload, 'base64'));
  if (statement.predicate.runDetails.builder.id !== BUILDER_ID) return 'refuse: another builder';
  if (statement.subject[0].digest.sha256 !== sha256(r.file)) return 'refuse: the file is not the one the provenance names';
  const { repository, ref } = statement.predicate.buildDefinition.externalParameters;
  if (repository !== REPO || ref !== 'refs/heads/main') return `refuse: built from ${ref}, not refs/heads/main`;
  return 'accept';
}

const file = bundle(4821);
const release = { file, envelope: envelope(provenance(file, 'refs/heads/main', 4821), builder), publicKey: builder.publicKey };
const swapped = Buffer.from(file);
swapped[swapped.length - 3] ^= 1;
const edited = JSON.parse(Buffer.from(release.envelope.payload, 'base64'));
edited.subject[0].digest.sha256 = sha256(swapped);
const branch = bundle(4830);
const cases = [
  ['1. The release, as built and tested', release],
  ['2. One byte of the file changed', { ...release, file: swapped }],
  ['3. File swapped, provenance edited', { ...release, file: swapped, envelope: { ...release.envelope, payload: Buffer.from(JSON.stringify(edited)).toString('base64') } }],
  ['4. File swapped, signed by another key', { file: swapped, envelope: envelope(provenance(swapped, 'refs/heads/main', 4821), stranger), publicKey: stranger.publicKey }],
  ['5. Built from another branch', { file: branch, envelope: envelope(provenance(branch, 'refs/heads/experiment', 4830), builder), publicKey: builder.publicKey }],
  ['6. Rebuilt later from the same commit', { ...release, file: bundle(4907) }],
];

console.log(`shopnest-1.4.0.js: sha256 ${sha256(file).slice(0, 16)}…, provenance signed by key ${builder.id}`);
console.log(`${'Release'.padEnd(41)}${'Signature only'.padEnd(16)}Full check`);
for (const [name, r] of cases) console.log(`${name.padEnd(41)}${signatureOnly(r).padEnd(16)}${fullCheck(r)}`);

Output

shopnest-1.4.0.js: sha256 54d96cb8ba82c9d1…, provenance signed by key cf2167470c55
Release                                  Signature only  Full check
1. The release, as built and tested      accept          accept
2. One byte of the file changed          accept          refuse: the file is not the one the provenance names
3. File swapped, provenance edited       refuse          refuse: no valid signature by the release builder's key
4. File swapped, signed by another key   accept          refuse: no valid signature by the release builder's key
5. Built from another branch             accept          refuse: built from refs/heads/experiment, not refs/heads/main
6. Rebuilt later from the same commit    accept          refuse: the file is not the one the provenance names

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sign_verify.mjs

The signature-only check accepted five of the six. It accepted a file with one byte changed, because it never compared the file’s digest with the subject; a file swapped and signed by a stranger’s key, because the release carried that key; a build from another branch; and a rebuild of the same commit, whose bytes the tests never saw. It refused only the provenance edited after signing. The full check refused all five, each with the reason a deploy log should record. DSSE’s own specification adds a detail that matters here: the key id in an envelope is not authenticated, so it may narrow the keys to try, never decide which key is trusted.

Key idea

Trust a release only when the expected builder signed provenance that names this file’s digest and the source you release from; a valid signature from any key proves nothing on its own.

Hash Generator Compute the SHA-256 of a file you are about to deploy and compare it with the digest its provenance names.

Checkpoint

Check: Verifying a release before deploying it

  1. Question 1 of 2 In the run, why did the signature-only check accept the release whose file had one byte changed?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It verified the signature on the provenance, which was untouched, and never compared the file's digest with the subject

    Provenance is a signed statement about a digest. Verifying the statement without hashing the file proves only that someone once signed something.

  2. Question 2 of 2 Release 4 carried its own public key, and its provenance verified with that key. Why did the full check refuse it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The full check verifies only with the release builder's key, which it knows in advance, and that key did not sign this provenance

    Decide in advance which signer you trust for which builder, as SLSA's specification asks: accept only specific pairs of signer and builder.

Build once and promote the same artifact

OWASP’s A03 page asks for builds that cannot change, and for the same artifact to be promoted from one environment to the next instead of being rebuilt in each; NIST’s Secure Software Development Framework asks for a way to verify the integrity of each release (PS.2) and for each release to be archived with its provenance (PS.3). Follow ShopNest’s release path:

Interactive · stage by stage Build once, then promote the same artifact Step through one release. Watch which stage holds which credentials, and what is verified before each deploy.
Build once, then promote the same artifact, step 7 of 7. Showing Build once, Test that artifact, Sign and attest, Store by digest, Deploy to staging, Promote to production. In focus: Promote to production.rebuild: new bytesBuild onceno deploy credentialsTest that artifactthe bytes that will shipSign and attestdigest and provenanceStore by digestimmutable and read-onlyDeploy to stagingverify firstPromote to productionverify, approve, deploy

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. One build, no keys to production. The build runs on a fresh runner from the reviewed commit, with a read-only token and no deploy credentials, so a hostile dependency in the build has nothing to deploy with.
  2. Test what will ship. The tests run against the built artifact, the exact bytes that will go to production, not against a different build of the same code.
  3. Sign and attest. The build platform records provenance, the artifact's SHA-256 digest with the repository, the ref and the builder, and signs it.
  4. Store by digest. The artifact and its provenance are stored where nothing can overwrite them, and every later stage asks for it by digest, not by a name that could be reused.
  5. Verify, then deploy to staging. The staging deploy checks the signature, the builder, the source ref and the digest of the file it holds, as the full check of the run does, and deploys only on a match.
  6. The rebuild the path refuses. Building again for each environment makes new bytes that no test saw and no provenance names. In the run, a rebuild of the same commit was refused for exactly that reason.
  7. Promote the same digest. Production receives the digest that passed staging. Its job uses an environment whose secrets open only after a reviewer approves, and a short-lived OIDC credential that can deploy and do nothing else.

The credentials follow the stages. The build job holds a read-only token and no deploy secret. The deploy job runs in a GitHub environment with required reviewers, and GitHub’s reference says a job cannot read an environment’s secrets until a reviewer has approved it. Better still, the deploy job holds no long-lived secret at all: with OIDC, a workload identity: An identity that a platform gives to a running job or service, so that it can prove who it is and receive short-lived credentials instead of holding a stored key. More in “Secrets and keys: storage, rotation, scanning”, it receives a short-lived credential from the cloud provider, which is why ShopNest’s fixed workflow gives id-token: write to its deploy job alone. Nobody can then ship code to production that only one person has seen, and a hostile package in the build reaches nothing it could deploy.

Checkpoint

Check: Building once and promoting one artifact

  1. One question The staging deploy refused a rebuild of the same commit. Why is rebuilding for each environment a problem?

    Choose one answer.

    Show the answer: One question

    Answer: A rebuild makes new bytes that the tests never saw and that the provenance does not name, so production would get an untested artifact

    Build once, test that artifact, sign its provenance, and promote the same digest. OWASP's A03 page asks for exactly this.

When an action or a CI secret is compromised

An alert names an action you use, or a secret turns up in a log. The order of the response is set by what each step protects:

Step What to do Why
1. Find the runs List every run that used the action, or could read the secret, in the window the advisory gives The window bounds what was exposed
2. Rotate Revoke and replace every secret those runs could read, not only the one named A step reads anything its job holds
3. Search the logs Look for secrets in the logs, also in encoded forms, and delete exposed logs CISA warned that secrets may appear double-encoded in base64
4. Pin or remove Move to a verified SHA of a fixed release, such as 46.0.1 for tj-actions/changed-files, or drop the action A tag may still point to bad code
5. Check what changed Review recent workflow changes, releases and published artifacts A stolen token may have changed more
6. Add detection Alert on new or changed workflow files, limit where runners may connect, keep CODEOWNERS on .github/ The next one is found sooner

A leaked secret follows the same order from step 2: revoke it first, then find every place it was used, and check what it did while it was valid. secret scanning: Reading code, history, logs or images for values that look like secrets, by provider formats, names, entropy or all three. A scanner trades missed leaks against false alarms, and an allowlist records the decoys. More in “Secrets and keys: storage, rotation, scanning” of logs, repositories and artifacts finds the copies nobody remembered.

Secret & Credential Leak Scanner Paste a CI log from an affected run and list every token and key it exposed, before you rotate them.

Checkpoint

Check: Responding to a compromised action or secret

  1. One question A compromised action ran in 12 workflow runs that could read 5 secrets, but the advisory says it targeted npm tokens. What do you rotate?

    Choose one answer.

    Show the answer: One question

    Answer: All 5 secrets those runs could read

    Rotate by what could be read, not by what you believe was taken. Then search the logs, encoded forms included.

Practise: harden ShopNest’s workflow

Write the fixes that the checker asked for: read-only permissions, actions pinned to full SHAs, event values moved into environment variables, and no checkout of a pull request’s code under pull_request_target. The sample tests check each rule and that the original workflow is left unchanged:

Exercise · Hard · JavaScript

Harden a CI workflow, step by step

Write harden(workflow, pins) in harden.mjs. workflow is a GitHub Actions workflow in its parsed form, as in the lesson: on, an optional permissions, and jobs, each with steps. pins maps an action reference such as "actions/checkout@v7.0.1" to the full commit SHA of that version. Return a hardened copy, and never change workflow itself:

  1. Token permissions. If the workflow has no top-level permissions, add { contents: 'read' }. Leave an existing one as it is.
  2. Pinned actions. Replace every uses that names a tag or a branch with the same action at the SHA from pins, for example actions/checkout@3d3c42e5…. Leave local actions (./…) and references that already end in 40 hexadecimal characters as they are. If pins has no entry for a reference, throw an Error whose message contains that reference.
  3. No event values inside scripts. In every run, replace each ${{ github.event.… }} and ${{ github.head_ref }} with a shell variable, and add the expression to the step's env. The variable is the last part of the expression in capitals: ${{ github.event.pull_request.title }} becomes $TITLE, with env: { TITLE: '${{ github.event.pull_request.title }}' }. Keep any env the step already has, and other expressions, such as ${{ secrets.TOKEN }}, as they are.
  4. No untrusted checkout. If the workflow runs on pull_request_target, run it on pull_request instead, with the same settings, and remove with.ref from checkout steps whose ref names github.event.pull_request.head.

Starter code · harden.mjs

/** Returns a hardened copy of a parsed GitHub Actions workflow; `pins` maps "owner/action@tag" to a full commit SHA. */
export function harden(workflow, pins) {
  // Starter: it copies the workflow and adds read-only permissions to every workflow, nothing else.
  const copy = JSON.parse(JSON.stringify(workflow));
  copy.permissions = { contents: 'read' };
  return copy;
}
The sample tests · harden.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { harden } from './harden.mjs';

const CHECKOUT = '3d3c42e5aac5ba805825da76410c181273ba90b1';
const NODE = '949feb2413d6458794dcd2491c4babbbce0c15c1';
// The commits that the tags v7.0.1 of actions/checkout and v7.1.0 of actions/setup-node point to.
const PINS = { 'actions/checkout@v7.0.1': CHECKOUT, 'actions/setup-node@v7.1.0': NODE };
const job = (...steps) => ({ 'runs-on': 'ubuntu-24.04', steps });
const flow = (extra) => ({ name: 'CI', on: { push: { branches: ['main'] } }, jobs: { test: job({ run: 'npm test' }) }, ...extra });

test('read-only token permissions are added only when the workflow has none', () => {
  assert.deepEqual(harden(flow(), PINS).permissions, { contents: 'read' });
  const own = { contents: 'read', 'pull-requests': 'write' };
  assert.deepEqual(harden(flow({ permissions: own }), PINS).permissions, own, 'an existing permissions key is kept');
});

test('tags become full commit SHAs, and other references are kept', () => {
  const pinned = 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9';
  const out = harden(flow({ jobs: { test: job({ uses: 'actions/checkout@v7.0.1' }, { uses: 'actions/setup-node@v7.1.0', with: { 'node-version': '24' } }, { uses: pinned }, { uses: './.github/actions/report' }) } }), PINS);
  assert.deepEqual(out.jobs.test.steps.map((s) => s.uses), [`actions/checkout@${CHECKOUT}`, `actions/setup-node@${NODE}`, pinned, './.github/actions/report']);
  assert.deepEqual(out.jobs.test.steps[1].with, { 'node-version': '24' });
  assert.throws(() => harden(flow({ jobs: { test: job({ uses: 'someone/action@main' }) } }), PINS), /someone\/action@main/, 'an unknown reference throws, naming it');
});

test('event values move out of the script into env', () => {
  const step = {
    env: { CI: 'true' },
    run: 'echo "PR: ${{ github.event.pull_request.title }} (${{ github.head_ref }})" && echo "${{ github.event.pull_request.title }}" && echo ${{ secrets.TOKEN }}',
  };
  const out = harden(flow({ jobs: { test: job(step) } }), PINS).jobs.test.steps[0];
  assert.equal(out.run, 'echo "PR: $TITLE ($HEAD_REF)" && echo "$TITLE" && echo ${{ secrets.TOKEN }}');
  assert.deepEqual(out.env, { CI: 'true', TITLE: '${{ github.event.pull_request.title }}', HEAD_REF: '${{ github.head_ref }}' });
});

test('pull_request_target becomes pull_request, and the head checkout loses its ref', () => {
  const weak = {
    name: 'CI',
    on: { pull_request_target: { branches: ['main'] }, push: { branches: ['main'] } },
    jobs: { test: job({ uses: 'actions/checkout@v7.0.1', with: { ref: '${{ github.event.pull_request.head.sha }}', 'fetch-depth': 0 } }, { uses: 'actions/checkout@v7.0.1', with: { ref: 'docs' } }) },
  };
  const out = harden(weak, PINS);
  assert.deepEqual(out.on, { pull_request: { branches: ['main'] }, push: { branches: ['main'] } });
  assert.deepEqual(out.jobs.test.steps[0], { uses: `actions/checkout@${CHECKOUT}`, with: { 'fetch-depth': 0 } });
  assert.deepEqual(out.jobs.test.steps[1].with, { ref: 'docs' }, 'a checkout of another ref is kept');
});

test('the input is never changed', () => {
  const weak = { name: 'CI', on: { pull_request_target: {} }, jobs: { test: job({ uses: 'actions/checkout@v7.0.1', with: { ref: '${{ github.event.pull_request.head.ref }}' } }, { run: 'echo ${{ github.event.pull_request.body }}' }) } };
  const before = JSON.stringify(weak);
  const out = harden(weak, PINS);
  assert.equal(JSON.stringify(weak), before);
  assert.equal(out.jobs.test.steps[0].with, undefined, 'an empty with is removed');
  assert.deepEqual(out.jobs.test.steps[1], { run: 'echo $BODY', env: { BODY: '${{ github.event.pull_request.body }}' } });
});
A hint

Start with a deep copy, JSON.parse(JSON.stringify(workflow)), and change only the copy. For the scripts, a regular expression such as /\$\{\{\s*(github\.(?:event\.[\w.]+|head_ref))\s*\}\}/g finds each event value; take the part after the last dot, in capitals, as the variable's name.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then work through the questions on pipelines, provenance, verification and promotion, each one explained the moment you answer:

Practice that adapts to you

Practise this lesson

15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 What makes a CI pipeline worth attacking, more than the application it builds?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: It holds the tokens that publish and the keys that deploy, so control of it reaches every release

    A pipeline can change what every user receives. That is why A03 asks for separation of duties, scoped secrets and signed builds around it.

  2. Question 2 of 15 At a small company, any developer can push to main, and every push to main deploys to production. Which part of A03 does this break, and what is the smallest fix?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: Separation of duties; require a reviewed pull request for main and an approval on the production environment before the deploy job runs

    OWASP lists a supply chain without separation of duties as vulnerable. Branch protection with review and an environment approval are the two switches that fix it.

  3. Question 3 of 15 What does GitHub's secure-use reference advise for the automatic GITHUB_TOKEN?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Set the default to read-only access to the repository's contents, and grant each job only the permissions it needs

    A permissions key of contents: read at the top of the workflow, widened per job, keeps a compromised step from writing to the repository.

  4. Question 4 of 15 Which of these uses lines is pinned in the sense of GitHub's guidance?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

    Only the 40-character SHA pins the code. Keep the version in a comment beside it, so that a person and Dependabot can see what it is.

  5. Question 5 of 15 You pin a composite action to a full commit SHA, but inside it the action calls two other actions by tag. What does your pin protect, and what does not?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: The composite action's own code is fixed, but the two actions it calls can still move; a workflow lockfile records those transitive actions too

    A pin is only as deep as the references it fixes. Prefer actions that pin their own dependencies, and adopt the lockfile where you can.

  6. Question 6 of 15 In a SLSA provenance statement, what does the subject identify?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: The artifact the build produced, by its name and its digest, such as SHA-256

    Everything a verifier checks hangs on the subject's digest: the claims are about the file with that digest and no other.

  7. Question 7 of 15 What does SLSA's Build L2 add to L1?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: A hosted build platform that signs the provenance, with consumers who check it, so tampering after the build is detected

    L1 says provenance exists, L2 that a hosted platform signed it, L3 that the platform resists tampering during the build itself.

  8. Question 8 of 15 A vendor gives you unsigned Build L1 provenance saying a package was built from its main branch. How much should your deploy rely on it?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Use it to catch mistakes, such as the wrong branch, but not as proof against tampering, since anyone who can change the file can change the statement

    Ask for signed provenance, L2 or above, before you let it decide a deploy; until then, treat it as a release note.

  9. Question 9 of 15 A DSSE envelope carries a keyid next to its signature. What may a verifier use it for?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: To narrow the keys it tries, never to decide which key is trusted, because the keyid is not authenticated

    Trust comes from configuration you control: which keys, or which identities, may sign for which builder.

  10. Question 10 of 15 The genuine release builder signed provenance for a build of refs/heads/experiment. Why did the full check refuse it?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Its external parameters name another ref than the one ShopNest releases from, and SLSA asks verifiers to check those parameters

    Check the source as well as the signer. A trusted builder's signature on an untrusted input is still an untrusted release.

  11. Question 11 of 15 A deploy script parses the provenance JSON and checks its fields, then verifies the signature last. What is wrong with that order?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: It reads and acts on claims before knowing who made them; verify the signature with the trusted key first, then parse and check the fields

    Authenticate first, then parse, then compare. The full check of the run follows that order.

  12. Question 12 of 15 What does a GitHub environment with required reviewers do to the secrets of the deploy job?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: The job cannot read the environment's secrets until a reviewer approves it

    Environments scope secrets to the jobs that deploy, and required reviewers turn a push into a deploy only with a second person's approval.

  13. Question 13 of 15 The deploy job holds a long-lived cloud access key as a secret. What is the better design, and why?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Give only the deploy job id-token write and let it exchange its OIDC token for a short-lived credential that can deploy and nothing else

    GitHub's reference recommends OIDC so that workflows stop storing cloud credentials as long-lived secrets. Scope the cloud-side trust to the repository, the branch and the environment.

  14. Question 14 of 15 Put the response to a compromised action in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 14 of 15

    Answer:

    1. List every run that used the action in the advisory's window
    2. Rotate every secret those runs could read
    3. Search the logs for secrets, encoded forms included
    4. Pin a verified SHA of a fixed release, or remove the action
    5. Review recent workflow changes, releases and artifacts
    6. Add detection for new or changed workflow files

    Finding the runs bounds the exposure, rotation ends it, the log search and the review show what else happened, and detection shortens the next incident.

  15. Question 15 of 15 A team searches the logs of affected runs for its secrets as plain text, finds nothing, and closes the incident. What did it miss?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: CISA warned that secrets may appear double-encoded in base64, and code can send secrets elsewhere than the log, so rotate anyway and search for encoded forms

    Rotate first, search second. A search can show that a secret leaked; it can never show that it did not.

Interview questions

Warm-up: why pin a GitHub Action to a full commit SHA instead of a version tag?

A tag is a movable pointer: whoever controls the action’s repository, legitimately or with stolen access, can point it at different code, and every workflow that names the tag runs the new code on its next run, with its secrets and without any change or review in your repository. A full-length commit SHA names one exact commit, so the code that runs is the code that was reviewed. GitHub’s secure-use reference recommends it for third-party actions. Pinning freezes a choice rather than proving it was good, so pair it with update pull requests, for example from Dependabot, and review each new SHA like a code change. Where GitHub’s workflow dependency lockfile is adopted, it records the resolved commits, transitive actions included.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    In a review you find a workflow that runs on pull_request_target, checks out the pull request's head and runs the test suite, with deploy secrets in the job's environment. What is the risk, and how do you fix it without losing the checks that contributors rely on?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    You learn that a deploy key may have been printed in the logs of a public repository's CI runs for the last two days, through a third-party action. What do you do in the first hours, and what changes afterwards?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why is a CI pipeline a target, and what did the tj-actions/changed-files incident show?

    The pipeline reads the code, installs dependencies and holds the tokens that publish and the keys that deploy. In 2025 the version tags of tj-actions/changed-files (CVE-2025-30066) were moved to a malicious commit that printed runner secrets into the workflow logs of every workflow naming the tag; CISA added it and reviewdog/action-setup (CVE-2025-30154) to its known-exploited catalogue.

  2. Which four rules does the lesson apply to a GitHub Actions workflow file?

    A token with least privilege (permissions: contents: read, widened per job); actions pinned to a full commit SHA; no event values inside run: scripts (pass them through an environment variable); and no untrusted code under pull_request_target or workflow_run, which carry the base repository's token and secrets.

  3. What is script injection in a workflow, and what is the fix?

    An expression such as ${{ github.event.pull_request.title }} in a run: step is expanded into the script's text before the shell starts, so a pull request's author decides part of the command. Pass the value through an environment variable, which the shell treats as data.

  4. Why pin an action to a full commit SHA and not to a tag?

    A tag can be moved or deleted, so every workflow naming it runs the new code with its secrets and without any change in your repository; a full-length SHA names one exact commit. Pinning freezes a choice rather than proving it good, so pair it with update pull requests such as Dependabot's and review each new SHA like a code change.

  5. What is build provenance, and what do SLSA build levels L1 to L3 mean?

    Provenance is the record of which builder ran, from which repository and ref, with which inputs, producing which file, named by digest. L1: it exists, made by the build platform (easy to forge). L2: a hosted platform signs it and consumers check it, which protects against tampering after the build. L3: a hardened, isolated platform keeps signing secrets out of reach of build steps, which protects against tampering during the build.

  6. What must a deploy step check before trusting a signed release, and what does keyless signing change?

    That the expected builder signed the provenance, that it names the digest of the exact file in hand, and that it was built from the source you release from. A signature from any key proves nothing alone: the lesson's signature-only check accepted 5 of 6 releases, one with a changed byte. Keyless signing with Sigstore uses a short-lived certificate bound to an OIDC identity, and the verifier names the identity and issuer it expects.

  7. How should a release move from build to production?

    Build once, with a read-only token and no deploy secret, then promote the same artifact (the same digest) from one environment to the next instead of rebuilding. The deploy job runs in an environment that waits for a person and holds no long-lived secret: with OIDC it receives a short-lived credential from the cloud provider, and only that job gets id-token: write.

  8. What is the order of response to a compromised action or a leaked CI secret?

    Find the runs in the advisory's window; rotate every secret those runs could read, not only the one named; search the logs for secrets, also in encoded forms (CISA warned of double base64); pin to a verified SHA of a fixed release or drop the action; review recent workflow changes, releases and artifacts; and add detection, such as alerts on changed workflow files and CODEOWNERS on .github/.

Key takeaways

  • A CI pipeline holds the keys to publish and deploy, and a moved tag of one action was enough to print secrets into the logs of every workflow that used it.
  • Treat workflow files as code: least-privilege token permissions, actions pinned to full SHAs, event values passed through environment variables, and no untrusted checkout under privileged triggers.
  • Provenance records how an artifact was built; SLSA’s levels say how far it can be trusted, and keyless signing ties a signature to an identity instead of a stored key.
  • A release is trusted only when the expected builder signed provenance that names its digest and its source: the signature-only check accepted five of the six releases, and the full check refused all five bad ones.
  • Build once without deploy credentials, then promote the same digest, deploying with a short-lived identity behind an approval.
  • After a compromise, find the runs, rotate everything they could read, search the logs, pin or remove, review what changed and add detection.

Terms in this lesson

build provenance
A record of how an artifact was built: the builder, the source repository and ref, the inputs and the artifact's digest. SLSA's provenance is an in-toto statement whose subject names the file; signed by the build platform, it can be checked.
keyless signing
Signing without a long-lived key: the signer proves an identity through OIDC, receives a short-lived certificate for it, signs once and discards the key, and the event is logged publicly. A verifier checks the expected identity and issuer.
script injection (CI)
In a CI workflow, an expression that holds a value such as a pull request's title, written inside a run script, is expanded into the script's text before the shell runs it, so a value someone else controls becomes part of the command.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.