Lorem, ipsum dolor sit amet consectetur adipisicing elit. Qui, itaque voluptate ipsa non enim amet ducimus voluptatibus deserunt nam esse!
Hardening npm and GitHub Actions with CISA's Supply Chain Security Checklist

Hardening npm and GitHub Actions with CISA's Supply Chain Security Checklist

pr0h0
npmgithub-actionscisasupply-chain-security
AI Usage (89%)

Why CISA's supply-chain guidance matters for JavaScript teams now

Recent reporting on package compromise, phishing, and CI/CD abuse keeps circling the same weak point: JavaScript teams tend to trust too many moving parts by default. That trust stays hidden until a maintainer account is hijacked, a package script fires during install, or a workflow gets edited to leak secrets.

CISA’s supply-chain guidance is useful because it is not really about one product or one framework. It is about narrowing trust, making dependencies visible, and refusing to let a build system turn into an unreviewed execution surface. That lines up neatly with npm and GitHub Actions, which are both powerful and easy to overtrust.

What I like about this checklist for JavaScript teams is simple: npm and GitHub Actions are where untrusted code enters the repo and where trusted secrets leave it. If those two phases are not separated, you end up with one oversized pipeline where install-time behavior, test-time behavior, and release-time behavior all share the same privileges.

The news signal: package compromise, phishing, and CI/CD abuse are converging

The pattern in current supply-chain incidents is pretty consistent:

  • attackers go after maintainer identities first
  • then they abuse package metadata, install scripts, or release channels
  • then they hunt for CI secrets, publish tokens, cloud credentials, or reusable workflow trust
  • finally, they move from one compromised dependency to many downstream consumers

Phishing matters because the first compromise is often human. Package compromise matters because it turns a normal npm install into code execution. CI/CD abuse matters because the build system usually has more access than the application itself.

If you only defend one layer, the other layers still carry the blast radius.

What this walkthrough focuses on: npm dependencies and GitHub Actions

This walkthrough narrows the checklist to two places JavaScript teams live in every day:

  • npm package intake
  • GitHub Actions workflow execution

That is deliberate. These are the parts where the attack surface is both common and easy to harden without changing your product architecture.

I am not trying to turn this into a generic “supply chain hygiene” post. The useful questions here are concrete:

  • What exactly do we trust when we run npm install?
  • What exactly do we trust when a GitHub Action runs with a token?
  • What should be blocked before code, scripts, or secrets cross the boundary?

Translate the checklist into two trust boundaries

Dependency trust boundary: packages, maintainers, tarballs, and lockfiles

For npm, the trust boundary is not just “the package name.”

You are trusting all of these pieces at once:

  • the maintainer identity behind the package
  • the registry metadata
  • the tarball contents
  • the dependency graph in your lockfile
  • any install-time scripts
  • any transitive package that can run during install or test

That matters because the package you import in code is not the only thing that executes. A compromised transitive dependency can run before your tests even start.

I usually think of this boundary as: “what can run before I have a chance to inspect it?” If the answer includes package scripts, postinstall hooks, or dependency resolution changes, the boundary is too loose.

CI trust boundary: workflow YAML, runners, secrets, and tokens

For GitHub Actions, the trust boundary is the workflow file plus the environment it controls.

You are trusting:

  • the workflow YAML in the repository
  • third-party actions referenced by name or SHA
  • reusable workflows called from other repos
  • runner permissions and network access
  • secrets injected into jobs
  • the default GITHUB_TOKEN
  • event context, especially from forks and untrusted PRs

A workflow is not just automation. It is code that can read secrets, write releases, trigger deployments, and call external services. Once a workflow has those privileges, every edit to it deserves the same review you would give to a privileged backend change.

Harden npm package intake before code ever runs

Prefer exact versions, lockfiles, and reproducible installs

The first rule is boring, but it works: install exactly what you reviewed.

Use a lockfile and prefer deterministic installs:

  • commit package-lock.json, npm-shrinkwrap.json, or the equivalent lockfile
  • use npm ci in CI instead of npm install
  • avoid version ranges for production dependencies unless there is a very specific reason
  • pin tools in CI rather than letting the latest minor release drift in

A reproducible install does two things. First, it makes dependency review possible. Second, it makes compromise visible. If the lockfile changes unexpectedly, that is a signal worth investigating.

A practical baseline looks like this:

npm ci
npm audit --audit-level=high

That does not remove supply-chain risk by itself, but it cuts down on surprise. Surprise is what attackers want.

Inspect package metadata for ownership, release behavior, and install-time scripts

When I review a package, I look past the version number. The metadata often tells you more than the README does.

Useful checks include:

  • who publishes the package and whether that account is stable
  • when the package changed ownership or publishing behavior
  • whether the package has install scripts
  • whether the package recently added new maintainers
  • whether the release cadence changed abruptly

postinstall, preinstall, and prepare are the big ones. They are not automatically malicious, but they are executable code that runs at exactly the point when many teams are least careful.

A quick local inspection pattern:

npm view some-package scripts
npm view some-package maintainers
npm view some-package time --json
npm view some-package dist.integrity

That will not tell you whether a package is safe. It will tell you whether it behaves like a normal library or like something trying to do hidden work during installation.

Reduce install-time risk with ignore-scripts, scoped reviews, and allowlists

If your project does not need install scripts, disable them in CI.

npm ci --ignore-scripts

That is the cleanest default for a lot of builds. It blocks a whole class of install-time abuse without forcing you to inspect every transitive package by hand.

There are tradeoffs. Some legitimate packages use install scripts to build native modules or fetch binaries. In those cases, do not just turn scripts back on globally and hope for the best. Use a narrower approach:

  • keep ignore-scripts on for the main install
  • explicitly allow only the packages you have reviewed
  • separate build steps that require scripts from the dependency-fetch step
  • document why a package needs script execution

A simple policy table helps teams avoid ad hoc exceptions:

SituationSafer defaultWhy
Pure JS app dependenciesnpm ci --ignore-scriptsBlocks most install-time execution
Native module buildisolate build stepreduces the number of packages that can execute
New dependency with scriptsmanual review before enablingscript hooks are code, not metadata
Unknown transitive packagedo not whitelist automaticallytransitive trust is still trust

The point is to make script execution a conscious decision, not a side effect of dependency resolution.

Verify provenance where available and understand what it does and does not prove

Provenance is helpful, but it is easy to misread.

If a package or artifact includes provenance metadata, that can help you confirm where it came from and how it was built. In the npm and GitHub ecosystem, that is increasingly useful for tracing a release back to a source repo and a controlled build process.

But provenance is not a certificate of safety.

It can help answer questions like:

  • was this package published from an expected CI system?
  • does the build link back to a known repository?
  • was the artifact generated by the release process we expected?

It does not answer:

  • is the code free of backdoors?
  • is the maintainer account uncompromised?
  • is the package semantically safe for my application?
  • did a malicious change already get merged into the source?

Use provenance as one signal in a broader review process. It strengthens trust. It does not replace trust review.

Reduce blast radius inside the repository

Split application code from build and release responsibilities

A lot of CI risk comes from collapsing too many responsibilities into the same job.

If the install job can read secrets, build artifacts, publish packages, and deploy infrastructure, then a compromised dependency has access to everything.

A better shape is to split responsibilities:

  • one job installs and tests dependencies
  • another job packages artifacts
  • another job publishes or deploys
  • only the final job gets write access and secrets

That makes secret access conditional instead of ambient. If the test job is compromised, it should not be able to release anything.

Keep secrets out of dependency-install and test jobs

This is one of the simplest hardening moves and one of the most missed.

Dependency-install jobs should usually not need:

  • package publish tokens
  • cloud provider credentials
  • deployment credentials
  • production database secrets
  • webhook signing keys

Test jobs should usually not need them either.

If a test needs a secret to hit a staging service, make sure it is a staging secret with limited scope and limited lifetime. Avoid broad tokens that can be reused outside the test job.

The rule I use is: if a job can be triggered by untrusted code, it should not receive secrets that matter outside that job.

Use minimal permissions on the default GITHUB_TOKEN

GitHub Actions gives each workflow a token. That token often has more access than the workflow actually needs unless you constrain it.

Set permissions explicitly. Start from read-only and add only what each job requires.

Example:

name: ci

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@<commit-sha>
      - uses: actions/setup-node@<commit-sha>
      - run: npm ci --ignore-scripts
      - run: npm test

If a job does not need write access, do not give it write access. That sounds obvious, but a lot of repos still rely on defaults and inherit more privilege than they meant to.

Harden GitHub Actions workflows against abuse

Pin third-party actions by commit SHA, not floating tags

This is one of the most important supply-chain controls, and JavaScript teams should treat it as standard.

Avoid floating references like:

- uses: actions/checkout@v4

That is convenient, but it trusts the moving tag. If the tag moves or the release line is compromised, your workflow changes without a repo diff.

Prefer a full commit SHA:

- uses: actions/checkout@<full-commit-sha>

That makes the dependency explicit and reviewable. Yes, it is more annoying to update. That annoyance is part of the defense.

The same rule applies to:

  • third-party lint actions
  • test reporters
  • release automation actions
  • caching helpers
  • deployment actions

A pinned action is still code you trust, but at least it is code you can inspect, diff, and update on purpose.

Treat reusable workflows as supply-chain dependencies too

Reusable workflows are often treated like internal infrastructure. They should be treated like versioned dependencies.

If repository A calls workflow B from repository C, then C becomes part of your trust chain. That means:

  • pin the reusable workflow reference
  • review changes to the reusable workflow with the same care as app code
  • restrict who can edit the reusable workflow repository
  • keep the reusable workflow small and auditable

A reusable workflow is attractive because it centralizes logic. That also means it centralizes compromise if it is edited carelessly.

Lock down triggers, branches, and pull-request contexts

Triggers are part of the security model.

Prefer patterns like:

  • push to protected branches for releases
  • pull_request for untrusted contributions
  • manual approval or environment gates for deployment
  • branch protection on release branches
  • required status checks before merge

Be especially careful with pull_request_target. It runs in the context of the base repository, which means it can access secrets and write tokens if you are not careful. That event exists for useful reasons, but it is easy to misuse.

My default rule is simple: never run untrusted code with privileged context.

That means:

  • do not check out and execute forked PR code in a privileged job
  • do not use secrets in jobs that can be influenced by external contributors
  • separate comment automation from build and deploy automation
  • review any workflow that uses pull_request_target as if it were privileged backend code

Prevent secret exposure on forked PRs and untrusted events

Forked pull requests are where many teams accidentally leak secrets.

If your workflow runs on forked PRs, make sure:

  • secrets are not injected into the job
  • write tokens are not available
  • caches do not store sensitive material
  • artifacts do not contain credentials
  • logs do not print environment variables or token-bearing commands

If you need to test code from forks, keep the job strictly read-only and avoid any action that can reach into external systems.

A useful pattern is to separate validation from privileged tasks:

  • PR job: lint, unit tests, static analysis
  • merge job: build, package, publish
  • release job: deploy, sign, notify

That way, the untrusted path never sees production secrets.

Build safer workflow patterns for npm projects

A secure install job: checkout, setup-node, cache, install, audit

A clean install job is the easiest place to start.

name: ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  install-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@<commit-sha>
      - uses: actions/setup-node@<commit-sha>
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci --ignore-scripts
      - run: npm audit --audit-level=high
      - run: npm test

A few notes on why this shape works:

  • checkout happens first so the lockfile is in scope
  • setup-node pins the runtime
  • cache reduces install time but should not change trust
  • npm ci --ignore-scripts blocks install hooks
  • audit runs after install so it sees the actual dependency tree

If you need native builds, keep those in a separate job or a separate step with a documented exception.

A secure test job: no write tokens, no release secrets, no deployment hooks

Test jobs should fail closed.

That means no release secrets, no registry write tokens, no cloud credentials, and no deployment access. The job should be able to validate code and nothing more.

A good test job is boring:

  • checkout code
  • install dependencies
  • run tests
  • upload non-sensitive artifacts if needed
  • exit

Do not let test helpers publish coverage to a service using broad credentials unless that is truly necessary. If a test can be turned into code execution, the test environment should still be harmless.

A secure release job: protected environments and human approval

Release jobs deserve their own controls.

Use:

  • protected environments
  • required approvals
  • separate publish credentials
  • branch restrictions
  • short-lived tokens where possible

A release job should not run on the same trigger as a PR test. It should run only when the code has already passed the lower-trust gates.

Example pattern:

release:
  runs-on: ubuntu-latest
  needs: install-and-test
  if: github.ref == 'refs/heads/main'
  environment: production
  permissions:
    contents: read
    packages: write
  steps:
    - uses: actions/checkout@<commit-sha>
    - run: npm ci --ignore-scripts
    - run: npm publish

If your organization supports it, add human approval before the environment is unlocked. That gives you one last checkpoint before secrets and publishing rights are used.

Add integrity checks and policy gates

Compare dependency diffs with lockfile-aware review

When the lockfile changes, review it like a security artifact.

Questions to ask:

  • Which packages were added, removed, or upgraded?
  • Did the dependency tree suddenly deepen?
  • Did a package introduce new scripts?
  • Did the package gain a new maintainer or publish source?
  • Did the tarball integrity change unexpectedly?

Lockfile diffs are noisy, but they are still useful. You are looking for shifts in trust, not just version bumps.

A simple workflow is:

  1. review the lockfile diff in the PR
  2. check the top-level package change
  3. inspect new transitive dependencies
  4. look for script additions
  5. confirm the change matches the intended feature work

Enforce npm audit, license checks, and dependency age heuristics carefully

Automated policy gates help, but they need tuning.

Useful checks include:

  • npm audit for known vulnerabilities
  • license policy checks for legal risk
  • package age heuristics for unusually fresh or unusually abandoned packages
  • dependency count limits for new features
  • script presence checks for install-time execution

Be careful not to overfit the policy to false comfort. An npm audit passing does not mean a package is trustworthy. It only means known advisories did not trip your current threshold.

A balanced policy treats automated checks as a filter, not a verdict.

Detect drift in workflow files, package scripts, and publish settings

Some of the most important supply-chain changes are tiny.

Watch for drift in:

  • .github/workflows/*.yml
  • package.json scripts
  • package publish configuration
  • release automation settings
  • branch protection rules
  • environment secrets and approvals

A malicious or careless change often starts with a workflow edit that looks harmless:

  • add a curl command
  • broaden permissions
  • echo a secret into a log
  • switch from a pinned SHA to a tag
  • call out to an unfamiliar domain

Those are the kinds of changes a policy gate should flag even if the diff is small.

Monitor for compromise signals in the JS supply chain

Look for unexpected postinstall behavior, token use, or new maintainer activity

Monitoring works best when you know what normal looks like.

Signals worth watching:

  • new postinstall or preinstall hooks
  • new dependencies that execute during install
  • unexpected package publish times
  • sudden maintainer changes
  • failed or repeated login events on maintainer accounts
  • unusual token usage from CI or package registries

If you maintain an internal package registry or proxy, you can also alert on anomalies in fetch patterns and package versions. A change in install behavior is sometimes the first visible sign of a compromise.

Watch for workflow edits that add exfiltration paths or broaden permissions

On the GitHub side, alert on:

  • workflow file changes
  • permission changes in workflow YAML
  • addition of unpinned third-party actions
  • new curl, wget, or base64 decode steps in CI
  • access to new secrets
  • changes to triggers like pull_request_target
  • new outbound network calls in privileged jobs

The point is not to ban every network call. The point is to detect when a workflow starts doing more than build and test work.

Alert on anomalous package updates, release cadence changes, and provenance gaps

Compromise often shows up as behavior drift.

Examples:

  • a package that releases every few months suddenly ships multiple updates in a day
  • a stable maintainer account starts publishing from a different CI source
  • provenance metadata disappears where it used to exist
  • a package that never used scripts suddenly adds one
  • a workflow references a new third-party action without review

These are not proof of compromise, but they are good reasons to pause and inspect.

Incident response when a dependency or workflow is suspect

Contain the workflow: revoke tokens, disable runners, and freeze publishing

If you suspect a compromised package or workflow, act like it is live until proven otherwise.

Immediate containment steps:

  • revoke publish tokens and CI credentials
  • disable or quarantine the affected workflow
  • stop automatic publishing
  • pause runners if you think they were exposed
  • block outbound access if exfiltration is still possible
  • preserve logs before rotating everything away

The key is to stop the bleeding before you start the full investigation.

Triage blast radius across npm caches, secret stores, and deploy targets

After containment, map what the suspect code could reach.

Check:

  • which repos imported the package
  • which builds used the workflow
  • which caches might have retained poisoned artifacts
  • which secrets were available in the job
  • which deployment targets were reachable
  • whether any tokens were printed or sent externally

This is where teams often underestimate exposure. A compromised build job can touch package registries, artifact storage, deployment systems, and chat notifications all in one run.

Rebuild from known-good commits and rotate credentials in the right order

Recovery should be deliberate.

A safe sequence is usually:

  1. identify the last known-good commit or release
  2. disable the suspect workflow or dependency version
  3. rebuild from a clean runner or clean environment
  4. rotate the credentials that were exposed first
  5. reissue long-lived tokens only after affected systems are clean
  6. verify published artifacts and deployment targets
  7. restore automation gradually

Rotate in dependency order. If the workflow exposed a registry token, rotate that before any downstream secrets that could be used through the registry path. If a deployment key was exposed, rotate that before re-enabling deploy jobs.

A practical implementation checklist for teams

What to change this week

  • pin third-party GitHub Actions by commit SHA
  • switch CI installs to npm ci --ignore-scripts
  • set explicit permissions for every workflow
  • remove secrets from PR validation jobs
  • add branch protection to release branches
  • review any workflow that uses pull_request_target
  • inspect package scripts for dependencies that run during install

What to automate next

  • lockfile diff review in pull requests
  • npm audit and policy checks in CI
  • alerts on workflow file changes
  • dependency metadata checks for new maintainers or new scripts
  • provenance verification where your tooling supports it
  • release environment approvals
  • secret scanning and token rotation hooks

What to review quarterly

  • workflow permissions and secret scope
  • third-party action pins
  • package allowlists and exceptions
  • dependency age and maintainer risk
  • registry and publish settings
  • incident response playbooks
  • whether any job still needs install scripts enabled

Conclusion: make trust explicit, narrow, and auditable

CISA’s supply-chain checklist is useful because it pushes the right habit: do not trust convenience more than you trust boundaries.

For JavaScript teams, that means two things first. Treat npm package intake as an execution boundary, not a download step. Treat GitHub Actions as privileged infrastructure, not just build glue.

Once you do that, the hardening work gets simpler:

  • pin what you can
  • isolate what you cannot pin
  • remove secrets from untrusted paths
  • keep install-time execution on a short leash
  • make release privileges explicit
  • watch for drift in workflows and dependencies

That is not flashy, but it is what actually narrows blast radius when the supply chain gets noisy.

Share this post

More posts

Comments