In 2024, the XZ Utils backdoor nearly shipped to every Debian and Red Hat server. The PyTorch CI/CD was so badly configured that researchers stole secrets from Google, Meta, Boeing, and Lockheed Martin. The Ultralytics PyPI package was poisoned through a GitHub Actions cache. 390,000 credentials were stolen from security professionals over the course of a year. These were not zero-day exploits - they were workflow failures that anyone could have caught with the right checks in place.
The attacks that compromised the software supply chain in the past two years did not break cryptographic algorithms or discover novel vulnerabilities. They exploited gaps in how developers manage dependencies, configure CI/CD pipelines, handle credentials, publish packages, and monitor their systems. Every single one of these gaps has a fix. Here are the 15 changes that will have the highest impact on your security posture right now.
Category 1: Dependency Management
1. Commit and Enforce Lockfiles
The Problem: Without a lockfile, npm install, pip install, or go get resolves packages from registry metadata that can be altered after your last commit. An attacker who gains control of a package namespace (or spoofs a package at a higher version) can silently substitute malicious code. Dependency confusion attacks have succeeded by publishing a malicious public package with the same name as an internal private package.
The Fix: In your CI pipeline, add a step that fails the build if lockfile contents have changed since the last commit. For npm, add this to your pipeline:
git diff --exit-code package-lock.json
For python projects, Use a modern project manager like uv or pip-tools to manage dependencies via pyproject.toml, and enforce a strict lockfile verification step in your CI pipeline to catch uncommitted dependency updates or drift.
For uv (the fastest industry standard), add this check step to your GitHub Actions workflow:
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Verify uv.lock is in sync
run: uv lock --check
- name: Install dependencies with frozen lockfile
run: uv sync --frozen
2. Enable GitHub Dependency Review on All Pull Requests
The Problem: Dependencies with known vulnerabilities can sit in your repository for months before someone notices. GitHub's Dependency Graph knows about CVEs affecting your dependencies before your code even runs in production, but this information is useless unless you enforce a check on every pull request.
The Fix: Add the dependency-review-action to your workflows with fail-on-severity set to high and critical:
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
fail-on-unpatched: true
deny: [
"GHSA-xxxx-xxxx-xxxx"
]
Set up a policy that blocks merges when high or critical vulnerabilities are present. This prevents known vulnerable dependencies from reaching main branch, eliminating an entire class of attacks that exploit published CVEs.
3. Verify Subresource Integrity Hashes for CDN Scripts
The Problem: Third-party CDN scripts are a frequent vector for supply chain attacks because they are loaded by browsers automatically and receive your users' cookies and input. When a CDN node is compromised (as happened with several CDN providers in recent years), malicious scripts can be injected into files your users download. Without integrity verification, you never know if the script your users load is the script you wrote.
The Fix: When adding any CDN script, always include the SHA-384 integrity attribute. Generate the hash for any CDN resource before trusting it:
curl -sS https://cdn.example.com/lib.js | openssl dgst -sha384 -binary | base64
Then in your HTML:
<script src="https://cdn.example.com/lib.js"
integrity="sha384-YOUR_BASE64_HASH_HERE"
crossorigin="anonymous"></script>
If your CDN is compromised and serves a different file, the browser refuses to execute it because the hash does not match. This prevents injected malicious code from compromised CDN nodes from affecting your users.
Category 2: CI/CD Hardening
4. Never Run Self-Hosted Runners on Pull_Request Triggers from Forks
The Problem: When a pull request comes from a fork, it can contain arbitrary code that CI will execute. If your CI is configured to run on pull_request events with runs-on: self-hosted, that fork's code runs on infrastructure that has access to your secrets, internal network, and production secrets. This is exactly how researchers compromised PyTorch's self-hosted runner and stole secrets from Google, Meta, Boeing, and Lockheed Martin.
The Fix: Remove pull_request from any job using runs-on: self-hosted. Use workflow_dispatch or push triggers only for self-hosted runners:
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: self-hosted # No pull_request trigger here
For external contributions, use GitHub-hosted runners. This prevents malicious fork code from executing on your privileged infrastructure and stealing the secrets your self-hosted runner has access to.
5. Set Explicit GITHUB_TOKEN Permissions Scoped to Minimum Required
The Problem: GitHub Actions creates a GITHUB_TOKEN for every workflow run. By default, this token has broad permissions including read/write on all repositories and contents. If an attacker extracts this token (through logs, artifacts, or compromised actions), they can use it to push code, create issues, and access resources within your organization.
The Fix: Add explicit permissions to every workflow file, scoping to the minimum required:
permissions:
contents: read
pull-requests: write
id-token: write # Only if using OIDC
security-events: write # Only if using code scanning
Never use permissions: write-all. Review the OIDC documentation for your cloud provider to understand what id-token: write actually grants. This limits the blast radius of a stolen token to only the permissions the workflow actually needs.
6. Require Manual Approval via GitHub Environments for All Production Deployments
The Problem: CI/CD pipelines that deploy to production automatically are a single point of failure. If an attacker compromises a workflow or injects code into a build step, they can deploy arbitrary code to production without human review. Automated deployments have shipped malware in multiple supply chain attacks because no human was in the loop.
The Fix: Create a production environment in your repository settings, enable "Required reviewers" with at least 2 approvers, and add environment: production to your deployment jobs:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Blocks until 2 reviewers approve
steps:
- name: Deploy
run: ./deploy.sh
This ensures that every production deployment pauses for human review. An attacker who compromises CI cannot auto-deploy malware - the deployment stops waiting for human approval, and your team can investigate the anomalous workflow run.
7. Use GitHub-Hosted Runners for Untrusted Code Contexts
The Problem: When you accept external contributions, review security advisories, or run code from untrusted sources, using self-hosted runners gives that code access to your internal secrets, network, and build infrastructure. Self-hosted runners are designed to have secrets available to steps - and that includes untrusted code running in the same job.
The Fix: For any workflow triggered by external contributions, pull requests from forks, or unverified sources, use GitHub-hosted runners exclusively:
jobs:
build-external:
runs-on: ubuntu-latest # GitHub-hosted, no secrets by default
if: github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Build
run: ./build.sh
GitHub-hosted runners do not have access to repository secrets unless explicitly granted. This isolation prevents untrusted code from accessing your secrets while still allowing you to run CI on external contributions.
Category 3: Secrets and Authentication
8. Replace Static CI Secrets with OIDC Authentication
The Problem: Long-lived static secrets stored in GitHub Secrets are a high-value target. When stolen, they remain valid for months or years.
The Fix (Corrected): Implement OIDC for your CI/CD systems to receive short-lived, scoped tokens instead of static secrets. For AWS in GitHub Actions, you need to:
Step 1: Configure the OIDC provider in AWS (one-time setup):
Create an IAM OIDC identity provider for GitHub:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 1b511abead59c6ce207077c0bf0e0043b1382612
Step 2: Create an IAM role with a trust policy:
"Version": "2012-10-17",`
"Statement": [`
{`
"Effect": "Allow",`
"Principal": {`
"Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"`
},`
"Action": "sts:AssumeRoleWithWebIdentity",`
"Condition": {`
"StringEquals": {`
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",`
"token.actions.githubusercontent.com:sub": "repo:<OWNER>/<REPO>:ref:refs/heads/main"`
}`
}`
}`
]`
}
Step 3: Use the correct action in your workflow:
id-token: write # Required for OIDC - this requests the JWT token from GitHub`
contents: read`
jobs:`
deploy:`
runs-on: ubuntu-latest`
steps:`
- name: Configure AWS Credentials`
uses: aws-actions/configure-aws-credentials@v4 # Use v4, NOT the SAML version`
with:`
role-to-assume: arn:aws:iam::123456789012:role/MyGitHubActionsRole`
aws-region: us-east-1
Why this works: OIDC tokens expire within minutes, limiting the blast radius of a stolen token compared to static credentials that can be used for months.
9. Rotate and Automatically Expire All CI/CD Service Account Credentials
The Problem: Service accounts used in CI/CD often have static long-lived API keys that never rotate. If one of these keys is compromised in a data breach, a supply chain attack, or an insider threat, the attacker retains access indefinitely. Most organizations do not know how many service account keys they have or which ones are still active.
The Fix: Set a 90-day maximum lifetime on all service account keys used in CI. For AWS IAM:
# Step 1: Create new access key
aws iam create-access-key --user-name my-ci-service-account
# Step 2: Update your CI/CD system with the new key
# Step 3: Test that CI/CD works with the new key
# Step 4: Deactivate the old key
aws iam update-access-key \
--user-name my-ci-service-account \
--access-key-id AKIAIOSFODNN7OLDKEY \
--status Inactive
# Step 5: Monitor for 24-48 hours for any access denied errors
# Step 6: Permanently delete the old key
aws iam delete-access-key \
--user-name my-ci-service-account \
--access-key-id AKIAIOSFODNN7OLDKEY
For GCP service accounts:
# Step 1: Create a new key
gcloud iam service-accounts keys create new-key.json \
--iam-account my-ci@project.iam.gserviceaccount.com
# Step 2: Update your CI/CD system with the new key file
# Step 3: Test that CI/CD works with the new key
# Step 4: Delete the old key (replace with actual key ID)
gcloud iam service-accounts keys delete \
--iam-account my-ci@project.iam.gserviceaccount.com \
abcd1234abcd1234abcd1234abcd1234abcd1234
To automate this with AWS: Use Terraform with time-based rotation triggers that create new keys every 60-90 days and automatically store them in AWS Secrets Manager. Alternatively, set up a Lambda function with EventBridge Scheduler to create keys at 60-70 days, deactivate at 80-90 days, and delete after 90 days, sending SNS notifications at each stage.
This prevents credential reuse after compromise — even if an attacker obtains a key, it becomes invalid within three months.
10. Audit and Revoke Unused API Tokens in Package Registries
The Problem: API tokens for PyPI, npm, and other package registries are often created for specific workflows and then forgotten. These tokens remain active indefinitely and are stored in GitHub Secrets, making them discoverable in repository history, leaked in logs, or exfiltrated through compromised CI. The Ultralytics second-wave attack succeeded because old PyPI tokens remained active after the team had migrated to Trusted Publishing.
The Fix: Audit every API token in your GitHub Secrets and package registry settings. Check the "Last used" timestamp on each token. For PyPI tokens in your GitHub Secrets, verify that each one was not created before you implemented Trusted Publishing (OIDC). For any token that predates your OIDC migration, revoke it immediately:
# Check npm access tokens
npm access ls-tokens
# Revoke old tokens
npm token revoke <token-id>
Then remove the corresponding secret from GitHub Secrets. This prevents attackers from using abandoned tokens to publish malicious updates to your packages.
Category 4: Package Publishing Security
11: Enable Trusted Publishing (OIDC) for npm (Corrected)
The Problem: Long-lived npm API tokens stored in GitHub Secrets can be stolen, exposing your package publishing credentials.
The Fix (Corrected): Replace API token publishing with Trusted Publishing (OIDC) for npm. Here's the complete, working implementation:
Step 1: Configure the trusted publisher on npmjs.com:
Navigate to your package settings → "Trusted Publisher" section. Enter:
- Organization or user: Your GitHub username or organization
- Repository: Your repository name
- Workflow filename:
publish.yml(must match your workflow file)
Step 2: Add the workflow to .github/workflows/publish.yml:
name: Publish to npm
on:
release:
types: [published]
permissions:
id-token: write # Required for OIDC trusted publishing
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
# CRITICAL: The npm version bundled with GitHub runners is too old for scoped packages
- run: npm install -g npm@latest
- run: npm run build --if-present
- run: npm test
# The --provenance flag enables OIDC authentication and generates attestations
- run: npm publish --provenance --access public
Step 3: Ensure your package.json has the correct fields:
{
"name": "@your-org/your-package",
"publishConfig": {
"access": "public",
"provenance": true
},
"repository": {
"type": "git",
"url": "git+https://github.com/your-org/your-repo.git"
}
}
Critical Requirements (often missed):
-
npm version must be 11.5.1 or later - The npm version bundled with GitHub's Node.js runner is too old for scoped package OIDC support. You must upgrade it in your workflow.
-
The
--provenanceflag is required - Despite some documentation stating it's automatic, you must explicitly add this flag to trigger OIDC authentication. -
repository.urlinpackage.jsonmust match your GitHub repo exactly - npm validates this against your trusted publisher configuration. -
Remove any
NODE_AUTH_TOKENsecrets - If present, they override the OIDC flow and cause authentication to fail.
Why this works: Trusted Publishing binds your package publication cryptographically to your GitHub Actions workflow. There is no long-lived token to steal, and only code running in your specific workflow can publish to npm.
12. Enable Package Provenance Attestations and Verify Them at Install Time
The Problem: Even with Trusted Publishing, a compromised workflow can still publish a malicious artifact under your package name. Without a verifiable attestation linking the artifact to your expected build process, users have no way to distinguish a legitimate build from a compromised one.
The Fix: Generate Sigstore attestations for every build and publish them alongside your package. Use cosign to verify at install time:
# Verify attestation before install
cosign verify-attestation \
--type pypi \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/OWNER/REPO/.github/workflows/publish.yml@refs/heads/main \
pypi-name/package-name@1.0.0
PyPI now supports attestations natively. You can use pip install --verify with Sigstore to check that the package you are installing was built by your expected workflow. If a package has no attestation from your expected CI, flag it as untrusted.
13. Never Publish from Local Machines
The Problem: Publishing from a developer machine requires storing a registry API token on that machine. Any malware, shoulder surfing, or credential reuse on that machine exposes the token. If the laptop is stolen or the developer leaves, the token remains active and must be manually revoked.
The Fix: Delete any scripts or documentation that show pip publish, npm publish, or similar commands run from a local machine. All package publishing must go through CI with OIDC authentication:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC authentication only
steps:
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publisher@v1
with:
user: __token__
If you find a pip publish command in a local script, delete it. Remove the PyPI token from your local machine. If publishing from CI is not currently set up for your package, set it up now - this is one of the highest-value security improvements you can make.
Category 5: Monitoring and Response
14. Set Up Automated Dependency Update PRs with Dependabot or Renovate
The Problem: Most supply chain vulnerabilities exist in indirect (transitive) dependencies that developers never directly touch. A vulnerable version of a library two levels deep in your dependency tree can persist for months or years before a CVE is published and actively exploited. Without automated tooling to surface these updates, your team has no systematic way to stay current.
** The Fix:** Configure Renovate with automated PR creation for all dependencies, but disable automerge for now:
{
"packageRules": [
{
"matchPackagePatterns": ["*"],
"automerge": false,
"schedule": ["every weekday"]
},
{
"matchDepTypes": ["security"],
"assignees": ["@security-team"],
"labels": ["security"]
}
]
}
This creates daily PRs for all dependency updates, surfaces security fixes immediately, and routes them to your security team. Once your team is comfortable with the process, you can enable automerge for non-critical updates with sufficient test coverage.
15. Generate and Monitor SBOMs for All Production Artifacts
The Problem: When a new vulnerability is announced (like Log4Shell or the XZ Utils backdoor), security teams must scramble to determine which of their applications are affected. Without a systematic inventory of what every application contains, this becomes a manual audit of every build - which takes days and leaves applications exposed in the meantime.
The Fix: Generate a Software Bill of Materials (SBOM) for every build and store it alongside the artifact. Use syft to generate SBOMs:
syft packages dir:. -o cyclonedx-json=sbom.json
Store the SBOM in your artifact registry:
gh release upload v1.2.3 sbom.json
When a new CVE is announced, query your SBOM database instead of running npm audit on every repo:
# Query all SBOMs for a specific CVE
grep -l "CVE-2024-1234" $(find ./sboms -name "*.json")
This turns vulnerability response from a multi-day manual audit into a database query. You know within minutes which applications contain a vulnerable component and can prioritize patching accordingly.
The Pattern
Every attack described in this article - the XZ Utils backdoor, the PyTorch runner compromise, the Ultralytics poisoning - exploited a gap that has a known fix. These were not sophisticated attacks requiring zero-days. They exploited trust relationships that should not have existed, secrets that should not have been long-lived, workflows that should not have run on privileged infrastructure, and package publishing that should have been bound to verified CI.
Fix these 15 items and you eliminate the vast majority of supply chain attack vectors currently in use. None of them require exotic tools or specialized knowledge. They require consistent application of existing controls and a commitment to treating CI/CD infrastructure with the same security rigor you apply to production systems.
More useful resources: https://x.com/dohypemyhustle
