Summary
Software supply chain attacks have moved from rare headlines to a regular part of running a JavaScript or PHP product. The Shai-Hulud worm campaigns on npm (first in late 2025, then the larger "Mini Shai-Hulud" wave in May 2026 that also reached PyPI) showed how one compromised maintainer account or CI workflow can push malicious versions into thousands of downstream projects within hours. The malware typically runs from an install script, steals cloud keys, GitHub tokens and environment variables, and then uses those credentials to publish even more infected packages.
The good news: most of the damage is preventable with boring, well-understood controls. This guide walks through a practical hardening plan for Node.js and Laravel/PHP teams in 2026: lockfiles and reproducible installs, release cooldowns, blocking install scripts, locking down GitHub Actions, scanning dependencies, and limiting what a compromised build can steal. Every step is something a small team can roll out in a sprint.
How modern package supply chain attacks actually work
It helps to understand the attack chain before choosing defences. Recent npm campaigns followed a similar pattern:
- Initial access. The attacker phishes a maintainer's npm credentials, steals a long-lived publish token, or abuses a weak CI workflow (for example a
pull_request_targetjob that runs code from a fork with repository secrets, or a poisoned build cache that a later release job restores). - Malicious publish. A new patch version of a popular package is published. Because it is a patch release, any project using caret (
^) or tilde (~) ranges will happily pick it up on the next fresh install. - Execution on install. The payload runs from a
preinstall,postinstallorpreparescript, sometimes hidden in an optional dependency that points at a git URL or tarball. - Credential theft. It harvests
~/.npmrc, AWS/GCP/Azure keys, GitHub tokens and every environment variable in the CI runner or developer laptop. - Propagation. Stolen npm and GitHub tokens are used to publish infected versions of the victim's own packages, which is why these attacks are called worms.
A key lesson from the 2026 wave is that provenance alone is not enough. Security researchers at JFrog and others documented cases where malicious versions were published from a legitimate trusted-publishing workflow because attacker code was already running inside that workflow. Provenance proves where a package was built, not that the build was clean. You need layered defences.
Layer 1: Lockfiles and reproducible installs
The single most effective habit is making sure production and CI never resolve new versions on their own.
Node.js
- Commit
package-lock.json,pnpm-lock.yamloryarn.lock, and review lockfile changes in pull requests. - In CI and Docker builds, use
npm ci(orpnpm install --frozen-lockfile,yarn install --immutable) instead ofnpm install. These fail if the lockfile andpackage.jsondisagree instead of silently upgrading. - Update dependencies deliberately through Dependabot or Renovate pull requests, not as a side effect of a deploy.
# Dockerfile: reproducible, script-free dependency install
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts --omit=dev
Laravel / PHP
- Always commit
composer.lockfor applications, and deploy withcomposer install, nevercomposer update, on servers. - Use
composer install --no-dev --prefer-dist --optimize-autoloaderin production builds. - Run
composer validate --strictin CI to catch a lockfile that is out of sync withcomposer.json.
Layer 2: Add a release cooldown
Most malicious versions are detected and pulled within hours to a day. If your tooling simply refuses to install anything published in the last day or week, you skip the most dangerous window entirely. This is one of the highest-value, lowest-effort controls available in 2026.
pnpm supports this natively with minimumReleaseAge (in minutes). Recent pnpm versions enable a one-day default; you can raise it for stricter projects:
# pnpm-workspace.yaml
minimumReleaseAge: 10080 # wait 7 days before installing a new version
minimumReleaseAgeExclude:
- "@your-company/*" # your own internal packages
trustPolicy: no-downgrade # block versions published with weaker trust than earlier ones
Yarn, Bun and npm have added similar age-gate settings, but they are generally off by default, so check the documentation for the exact version you run and turn it on. For Dependabot and Renovate, configure a minimum release age or cooldown on update pull requests so your bot does not propose a version that is only minutes old.
// renovate.json
{
"extends": ["config:recommended"],
"minimumReleaseAge": "3 days",
"lockFileMaintenance": { "enabled": true }
}
When you genuinely need an urgent security patch, add a documented exception for that one package instead of turning the cooldown off.
Layer 3: Block install scripts by default
Almost every npm worm so far has executed through lifecycle scripts. Very few packages in a typical web app actually need them (native modules such as esbuild, sharp or bcrypt are the usual exceptions).
- npm: set
ignore-scripts=truein the project.npmrcand run the few required build steps explicitly (for examplenpm rebuild sharp). - pnpm: recent versions do not run dependency build scripts unless you allow them. Keep that allowlist short and review every addition.
- Composer: since Composer 2.2, plugins must be explicitly allowed through
config.allow-plugins. Do not set it totruefor everything.
// composer.json (excerpt)
{
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": false
},
"sort-packages": true
}
}
Also block "exotic" dependency sources. A transitive dependency that resolves to a GitHub commit or a raw tarball URL is a red flag in a production app; pnpm can block these for subdependencies, and in code review you should question any git+ or https:// version spec in package.json or a custom VCS repository in composer.json.
Layer 4: Lock down your CI/CD pipeline
Your CI runner is the most valuable target in your stack: it usually holds deploy keys, cloud credentials and publish rights. Treat GitHub Actions (or GitLab CI, Bitbucket Pipelines) as production infrastructure.
GitHub Actions checklist
- Set a default of least privilege at the top of every workflow:
permissions: contents: read, and grant more only to the job that needs it. - Pin third-party actions to a full commit SHA, not a mutable tag like
@v4. - Avoid
pull_request_targetunless you fully understand it; never check out and run fork code in that context. - Keep release and publish jobs separate from test jobs, and do not restore shared dependency caches in a job that has publish or deploy credentials.
- Use OIDC to get short-lived cloud credentials (AWS IAM roles, Google Workload Identity Federation) instead of storing long-lived access keys as secrets.
- Protect release environments with required reviewers.
# .github/workflows/ci.yml
name: ci
on: [pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-commit-sha> # v4, pinned by SHA
- uses: actions/setup-node@<full-commit-sha> # v4, pinned by SHA
with:
node-version: 22
- run: npm ci --ignore-scripts
- run: npm audit signatures
- run: npm test
(Replace the placeholders with the full commit SHAs you verify for the action versions you use; tools like Dependabot can keep pinned SHAs up to date.)
If you publish packages yourself
If your team maintains npm packages or an internal SDK, switch to npm trusted publishing with OIDC instead of long-lived tokens, require 2FA for publishing on the account and organisation, and publish only from a dedicated, minimal release workflow that does not run untrusted code or restore caches built by pull requests.
Layer 5: Scan dependencies continuously
Scanning will not stop a zero-day malicious package by itself, but it catches known-bad and vulnerable versions quickly and gives you an inventory when the next incident hits.
npm audit/pnpm auditandcomposer audit(Composer 2.4+) as a CI step, failing the build on high-severity advisories.npm audit signaturesto verify registry signatures and provenance attestations where available.- GitHub Dependabot alerts or an equivalent SCA tool for vulnerable versions.
- A behaviour-based malware scanner (Socket, Snyk, and similar products) that inspects new package versions for suspicious install scripts, network calls and obfuscation.
- Generate an SBOM (for example with CycloneDX tooling) for each release so you can answer "are we affected?" in minutes rather than days.
# CI step for a Laravel + Vite project
composer validate --strict
composer install --no-interaction --prefer-dist --no-progress
composer audit --no-dev
npm ci --ignore-scripts
npm audit --audit-level=high
Layer 6: Reduce the blast radius
Assume one day something will slip through. The question is what it can reach.
- Developer machines: run dependency installs inside dev containers or Docker (Laravel Sail works well for this), so a malicious script sees a container filesystem rather than your home directory, SSH keys and browser profiles.
- Secrets: keep production secrets out of developer laptops and out of build stages. Load them at runtime from AWS Secrets Manager, Google Secret Manager or your platform's secret store.
- Tokens: prefer short-lived, narrowly scoped tokens. A GitHub fine-grained token for one repository is far less dangerous than a classic token with full
reposcope. - Network egress: build runners that can only reach your registry and package mirrors make exfiltration much harder. Self-hosted runners with egress rules or tools that monitor runner network traffic can help.
- Private registry or proxy: larger teams often route installs through an internal proxy (such as Verdaccio, Artifactory or a cloud artifact registry) where they can quarantine new versions centrally.
What to do if you installed a compromised package
- Identify affected versions from the advisory and search all lockfiles across your repositories for them.
- Assume every secret present on the affected machine or runner is stolen. Rotate npm tokens, GitHub tokens, cloud keys, database passwords and API keys, starting with the most privileged.
- Review cloud audit logs (CloudTrail, Google Cloud Audit Logs) and GitHub audit logs for unusual activity, new repositories, new workflows or unexpected package publishes.
- Pin to a known-good version, clear CI caches, and rebuild images from scratch.
- Write a short post-incident note and turn what you learned into the controls above.
A realistic two-week rollout plan
You do not need a dedicated security team to get most of the benefit:
- Days 1 to 3: enforce lockfiles, switch CI and Docker to
npm ci/composer install, addcomposer auditandnpm auditto CI. - Days 4 to 6: enable a release cooldown in your package manager and update bot; disable install scripts by default and allowlist the few you need.
- Days 7 to 10: set workflow permissions to read-only by default, pin actions to SHAs, move cloud access to OIDC, and remove long-lived keys from CI secrets.
- Days 11 to 14: move local installs into containers, add an SBOM step, and write a one-page incident playbook with a secret-rotation checklist.
For a broader view of application-level risks, pair this with our guide to the OWASP API Security Top 10 for Laravel and Node.js and our Laravel web application security checklist.
FAQ
Is npm more dangerous than Composer?
Not inherently, but the npm ecosystem has far more small transitive dependencies and widely used install scripts, which gives attackers more entry points. Composer and Packagist are targeted too, so the same principles (lockfiles, audits, limiting plugins, reviewing new dependencies) apply to Laravel projects.
Does a release cooldown leave me exposed to known vulnerabilities for longer?
Slightly, which is why you keep an exception path for urgent security patches. In practice, a one- to seven-day delay on routine updates removes far more risk from malicious releases than it adds from delayed patches.
Is npm provenance or trusted publishing enough to trust a package?
No. It is a useful signal and makes stolen tokens less valuable, but 2026 incidents showed malicious versions can still be published from a compromised trusted workflow. Combine provenance with cooldowns, script blocking and scanning.
Should we stop using open-source packages?
No. Open source is essential and generally well maintained. The goal is to install it deliberately: fewer unnecessary dependencies, pinned versions, reviewed updates and limited permissions for the tools that install them.
How often should we rotate CI secrets?
Ideally you replace long-lived CI secrets with short-lived OIDC credentials so there is little to rotate. For secrets that must remain, rotate on a schedule and immediately after any suspected compromise.
Can DayDecoder audit our existing Laravel or Node.js pipeline?
Yes. We review dependency hygiene, CI/CD configuration, secret handling and cloud permissions, then implement the fixes with your team.
Written by
Prakash Tank
Founder & Full-Stack Architect, DayDecoder
Prakash started as a certified Laravel developer and grew into a full-stack architect who owns a project end to end: requirements, architecture, development, deployment, and support. He designs event-driven systems with...
More about Prakash