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.
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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 ofcontents: 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 arun: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_targetandworkflow_runrun 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 setsallow-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:
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' },
],
},
},
}; Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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.
Checkpoint
Check: Hardening a workflow file
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
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:
// 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.
Checkpoint
Check: Verifying a release before deploying it
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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
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:
- Token permissions. If the workflow has no top-level
permissions, add{ contents: 'read' }. Leave an existing one as it is. - Pinned actions. Replace every
usesthat names a tag or a branch with the same action at the SHA frompins, for exampleactions/checkout@3d3c42e5…. Leave local actions (./…) and references that already end in 40 hexadecimal characters as they are. Ifpinshas no entry for a reference, throw anErrorwhose message contains that reference. - 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'senv. The variable is the last part of the expression in capitals:${{ github.event.pull_request.title }}becomes$TITLE, withenv: { TITLE: '${{ github.event.pull_request.title }}' }. Keep anyenvthe step already has, and other expressions, such as${{ secrets.TOKEN }}, as they are. - No untrusted checkout. If the workflow runs on
pull_request_target, run it onpull_requestinstead, with the same settings, and removewith.reffrom checkout steps whoserefnamesgithub.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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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?
-
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?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- A03:2025 Software Supply Chain Failures (OWASP Foundation)
- Secure use reference (GitHub Actions) (GitHub Docs)
- Workflow dependency lockfiles (GitHub Docs)
- tj-actions/changed-files through 45.0.7 allows remote attackers to discover secrets by reading actions logs (GHSA-mrrh-fwg8-r2c3) (GitHub Advisory Database)
- Supply Chain Compromise of Third-Party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154) (Cybersecurity and Infrastructure Security Agency (CISA))
- actions/checkout (inputs, and the changes in versions 6 and 7) (GitHub (actions/checkout maintainers))
- actionlint, a static checker for GitHub Actions workflow files (actionlint project)
- zizmor, static analysis for GitHub Actions (zizmor project)
- SLSA v1.2: Build track basics (OpenSSF SLSA project)
- SLSA v1.2: Build provenance (OpenSSF SLSA project)
- DSSE protocol (Dead Simple Signing Envelope) (Secure Systems Lab (DSSE project))
- Sigstore overview (Sigstore project (OpenSSF))
- Verifying signatures with cosign (Sigstore project (OpenSSF))
- Generating provenance statements (npm Docs)
- Trusted Publishers (Python Package Index (PyPI))
- NIST SP 800-218: Secure Software Development Framework (SSDF) Version 1.1 (National Institute of Standards and Technology (NIST))
- CI/CD Security Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress