Securing GitHub Actions from supply chain attacks
GitHub Actions is the most widely used CI/CD platform in the world. It runs on every push to millions of repositories, installs npm and Python packages, and has access to the most sensitive credentials in most development organizations. It is also a prime target for supply chain attacks.
The two attack surfaces in GitHub Actions
Your package installs. Every npm ci or pip install in your workflow installs packages from npm and PyPI. If any of those packages is compromised — at the moment your workflow runs — your CI runner executes malicious code with access to your GitHub secrets, AWS credentials, Docker registry tokens, and everything else in your CI environment.
The GitHub Actions themselves. Every uses: some-action@v1 in your workflow runs code maintained by a third party. If that action is compromised — either by a maintainer account takeover or by a dependency in the action — your workflow runs malicious code.
This post focuses on the first surface (package installs). For action pinning, see the GitHub docs on pinning actions to a full commit SHA.
The credential exposure problem
When your workflow runs npm ci or pip install, those processes have access to every secret in your env block. Common patterns that create unnecessary exposure:
# Unnecessarily exposes all secrets during package install
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
steps:
- run: npm ci # has access to all env vars above
- run: npm run build
- run: npm run deploy # the only step that needs AWS credentials
A malicious package installed during npm ci would have access to AWS_ACCESS_KEY_ID and DATABASE_URL immediately.
Fix: Use step-level environment variables, not job-level:
steps:
- run: npm ci # no secrets in scope
- run: npm run build # no secrets in scope
- run: npm run deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
The deploy step has the AWS credentials. The install and build steps don't.
Minimal permissions
Set the minimum required permissions for each workflow:
permissions:
contents: read # can read the repo
packages: read # can read GitHub Package Registry (if needed)
# everything else: none
If your workflow doesn't deploy, doesn't create releases, and doesn't write to the repo, it needs contents: read and nothing else. A compromised package that tries to write to the repo or create a release will fail with a permissions error.
Use Veln's setup action
name: CI
on: [push, pull_request]
permissions:
contents: read
pull-requests: write
checks: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Veln setup — wraps subsequent package installs
- uses: veln-sh/setup-action@v1
with:
license-key: ${{ secrets.VELN_LICENSE_KEY }}
mode: enforce
cache: "true"
post-pr-comment: "true"
# Cache the Veln result cache
- uses: actions/cache@v4
with:
path: ~/.veln/ci-cache.json
key: veln-${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/requirements.txt') }}
# These are now protected by Veln Gate
- run: npm ci
- run: pip install -r requirements.txt
Veln Gate intercepts all package manager traffic from this point forward. BLOCK or WARN verdicts fail the build immediately.
Python-specific: use OIDC for AWS credentials
For deployments that need AWS access, use GitHub Actions OIDC instead of long-lived AWS access keys:
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
aws-region: us-east-1
OIDC tokens are short-lived (valid for the duration of the job) and scoped to the specific repository. A malicious package that exfiltrates an OIDC token gets a credential that expires within minutes and can't be used from outside GitHub's infrastructure.
Isolate install from execution
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: veln-sh/setup-action@v1
with:
license-key: ${{ secrets.VELN_LICENSE_KEY }}
- run: npm ci
- uses: actions/upload-artifact@v4
with:
name: node-modules
path: node_modules
build:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: node-modules
path: node_modules
- run: npm run build
env:
API_KEY: ${{ secrets.API_KEY }}
The install job has no secrets. The build job has secrets but runs pre-verified packages. A malicious package installed in the install job can't access the API_KEY because it doesn't exist in that job's environment.
GitHub Actions is a high-value target for supply chain attacks. Veln's CI integration adds verification to every package install.