Skip to content

Search docs

Jump between documentation pages.

Browse docs

Supply-chain security

Provenance links a package to its build. A 24-hour release-age floor delays new versions, and ignoreScripts prevents dependency lifecycle scripts from running during installation. None of this stops every supply-chain attack; this page says what each control covers and what it does not.

npm worm campaigns ship in waves: chalk/debug in September 2025, node-ipc in May 2026, the @tanstack/* compromise on 2026-05-11. The pattern is consistent: a single phished maintainer or one CI cache-poisoning bug becomes thousands of downstream installs in minutes. DaloyJS is built and shipped with that threat model in mind, and we recommend the same defaults for your project.

How DaloyJS itself is published

The publish pipeline, tag to tarball
  1. 01triggerSigned tag pushrelease.yml only. Fork PRs can't
  2. 02gateProtected environmentmaintainer approval required
  3. 03runnerharden-runner egress blocknpm + GitHub + Sigstore only
  4. 04authOIDC trusted publishno long-lived NPM_TOKEN
  5. 05attest--provenance via Sigstoretarball bound to commit + run
Only a signed tag can start a release, and only after maintainer approval on a network-restricted runner. There is no long-lived publish token to steal, and every tarball carries a provenance attestation back to its source commit.
  • Releases run in a separate workflow (release.yml) that is triggered only by a signed tag push and gated by a protected GitHub Environment requiring maintainer approval. Fork PRs cannot touch it.
  • npm trusted publishing (OIDC) with --provenance: every @daloyjs/core tarball is bound to its source commit and workflow run via Sigstore. There is no long-lived NPM_TOKEN in repo secrets to steal.
  • Build and publish are separate jobs (from 1.5.4). A job with no publish credentials builds the package, runs npm pack, and records the tarball's sha256. The publish job only verifies that hash and stages that exact tarball, so the job holding id-token: write never runs the build or a dependency's code.
  • id-token: write is granted only to the publish job, on the post-approval runner, with egress blocked to everything except npm, GitHub, and Sigstore (via step-security/harden-runner).
  • No GitHub Actions cache in the standard CI workflow. Cache scope bridges fork PRs and pushes to main, which is the poisoning channel that bridged TanStack's PR pipeline into its release pipeline.
  • No pull_request_target that runs fork code. CI uses the safe pull_request trigger. The one narrow exception (a workflow that auto-closes external PRs) never checks out, installs, or runs any PR code. A zizmor check on every PR fails the build on the dangerous pull_request_target-plus-fork-checkout pattern.
  • Third-party GitHub Actions are SHA-pinned so a retargeted version tag cannot silently change what CI executes.
  • CodeQL, OpenSSF Scorecard, Dependabot all run continuously, and CODEOWNERS blocks any change to .github/, package.json, the lockfile, or .npmrc without a maintainer review.
  • ClusterFuzzLite continuously fuzzes the untrusted-input parsers with Jazzer.js, on every PR that touches src/ and again in a daily batch run (see below).
  • Lockfile source verification runs in CI via pnpm verify:lockfile and fails if pnpm-lock.yaml introduces git dependency sources or non-registry tarball URLs.

Full policy and incident-response playbook: SECURITY.md.

How the framework itself is fuzzed

Beyond static analysis, the untrusted-input parsers in @daloyjs/core are continuously fuzzed with Jazzer.js wired through ClusterFuzzLite. A per-PR code-change run fuzzes anything that touches src/, and a daily batch job fuzzes the full corpus. This is also what earns the OpenSSF Scorecard Fuzzing check.

Each target asserts the function's documented contract, not only "does not crash". A declared rejection (for example a BadRequestError on malformed input) is correct behavior and is ignored. Any other thrown error, or a hang, is a finding:

  • safeJsonParse: only throws BadRequestError, and never returns an object carrying a __proto__ / constructor / prototype own key.
  • readRequestCookie: never throws while parsing an untrusted Cookie header.
  • decodeCursor: only throws BadRequestError on a malformed pagination cursor.
  • parseCron: only throws CronParseError.
  • parseIp: never throws (returns undefined on unrecognized input).
  • sanitizeHeaderName / sanitizeHeaderValue: only throw BadRequestError, and an accepted value never contains CR, LF, or NUL.

The harness, including the per-target oracle and the digest-pinned OSS-Fuzz build image, lives in .clusterfuzzlite/.

Defaults you get from pnpm create daloy

When you choose pnpm, a project scaffolded with create-daloy gets a pnpm-workspace.yaml that turns on the install-time controls below. pnpm 11+ reads these settings from pnpm-workspace.yaml (camelCase keys) and ignores them in .npmrc; the scaffold also writes an .npmrc with the same values for npm and older tooling. Keep them on.

pnpm-workspace.yaml (shipped by create-daloy)yaml
# Wait 24h (1440 minutes) before resolving a freshly published version.
# npm worm campaigns are typically detected and unpublished within hours.
minimumReleaseAge: 1440

# Only direct dependencies may use git or tarball sources.
blockExoticSubdeps: true

# Never run dependency lifecycle scripts (preinstall / install /
# postinstall / prepare).
ignoreScripts: true

# Reproducible installs.
strictPeerDependencies: true
verifyStoreIntegrity: true
preferFrozenLockfile: true
autoInstallPeers: true

What these controls do and do not cover:

  • ignoreScripts stops install-script payloads, the channel used by self-replicating worms such as Shai-Hulud. It does not stop a payload that runs when your code imports the package, which is how the chalk/debug and node-ipc compromises executed.
  • Import-time payloads are mitigated only by the release-age cooldown (a bad version caught within 24 hours never resolves) and by a committed lockfile (an existing install does not move to the bad version on its own).
  • The cooldown is consumer-side: it protects the installs that set it, and only against compromises detected within the window.

Other package managers (from 1.5.4):

  • npm scaffolds get an .npmrc with ignore-scripts=true, min-release-age=1 (days), and engine-strict=true.
  • Yarn scaffolds get a .yarnrc.yml with enableScripts: false and a minimum release age.
  • Bun scaffolds get a bunfig.toml with minimumReleaseAge (Bun 1.3+).
  • Template Dockerfiles pin their base images by digest, so a retagged base image cannot change what your container build pulls.

When (and only when) you scaffold with --package-manager npm, the CLI adds an npm >= 12 floor to engines and swaps the pnpm .npmrc for an npm-native one containing engine-strict=true, so npm refuses to install on an older CLI instead of only printing a warning. pnpm scaffolds get the equivalent guard: a pnpm >= 11 floor in engines.pnpm, which pnpm always enforces at install time. This matters because pnpm older than 11 silently ignores the minimumReleaseAge setting in pnpm-workspace.yaml, which disables the 24-hour release-age cooldown. The CLI also warns during scaffolding when the installed pnpm is below the floor. Yarn and Bun scaffolds never run npm or pnpm, so they get neither floor. This keeps npm users on a CLI new enough for the modern lockfile and provenance-verification behavior the project relies on.

Optional CI bundle for user projects

create-daloy --with-ci adds the GitHub-side controls that do not come from a package install: CI with top-level permissions: {}, SHA-pinned actions, harden-runner, no package-manager cache, disabled lifecycle scripts, lockfile-source verification, CodeQL, OpenSSF Scorecard, zizmor, Dependabot, CODEOWNERS, and SECURITY.md. Templates can also get a manual-only deploy.yml starter: container templates publish a Docker image to GHCR, while Vercel and Cloudflare templates run their platform CLIs with credentials from GitHub Actions secrets and variables. The scaffolder deliberately omits npm publishing workflows because generated projects are REST API services, not reusable libraries.

bash
pnpm create daloy@latest my-api --template node-basic --package-manager pnpm --with-ci --code-owner @acme/security

GitHub settings are still your responsibility: replace the CODEOWNERS owner if needed, enable branch protection, require the generated checks, and turn on secret scanning plus push protection.

On GitLab, Bitbucket, Azure DevOps, Jenkins, or an on-prem runner, you still inherit the runtime guardrails, @daloyjs/core's zero-runtime-dependency package, SBOM and npm provenance, and the pnpm install-time controls if you choose pnpm. Translate the GitHub workflow rules above into your CI host: start from no default write permissions, avoid shared dependency caches for untrusted code, keep installs reproducible, and isolate any job that can publish or deploy.

If you legitimately need a postinstall

Disabling scripts is global. On pnpm 11+, allow a build script for a package you actually trust (e.g. esbuild) explicitly in pnpm-workspace.yaml, the way the DaloyJS repo does:

pnpm-workspace.yamlyaml
strictDepBuilds: true   # an unreviewed lifecycle script fails the install
allowBuilds:
  esbuild: true

package.json#pnpm.onlyBuiltDependencies is the pre-v11 form; pnpm 11 does not read package.json#pnpm. Each entry should be reviewed in PR.

Avoid git and tarball dependencies

DaloyJS also checks its root lockfile for dependency sources that bypass the normal npm registry path. In this repo, pnpm verify:lockfile fails on git dependencies and non-registry tarball URLs so a transitive source change cannot slip through as ordinary version churn.

Optional: install-time malware scanners

The 24-hour minimum-release-age cooldown is what bridges the gap between a malicious version being published and the registry yanking it. Aikido's “quantum incident response” write-up makes the point that you cannot out-react an npm worm once it lands: prevention at install time is the only viable defense. DaloyJS's install defaults already implement that thesis (cooldown, no transitive lifecycle hooks, zero runtime deps in @daloyjs/core, frozen + verified store). For belt-and-braces beyond the cooldown, install a real-time scanner that intercepts package-manager calls and checks each requested version against a live malware feed before it touches disk:

bash
# Aikido Safe Chain, free, no account required.
# Wraps npm / pnpm / yarn / npx / pnpx and refuses known-malicious
# package versions before they install.
npm install -g @aikidosec/safe-chain
safe-chain setup

DaloyJS deliberately does not add safe-chain (or any other third-party scanner) as a dependency or scaffold default. @daloyjs/core ships zero runtime dependencies by policy and any install-time tool you run is your trust decision, not the framework's. Equivalent commercial offerings (Socket, Snyk Advisor, JFrog Curation, npm's own Package Trust) sit at the same layer. Pick one or run none, but understand that the minimumReleaseAge: 1440 cooldown is already doing much of the work the article recommends, for compromises caught within a day.

Mapped to the ENISA package-manager advisory

ENISA's Technical Advisory for Secure Use of Package Managers (v1.1, March 2026) is the EU reference checklist for consuming third-party packages, organised across a four-stage life cycle. The DaloyJS repo implements the integration checklist, including the two controls ENISA itself flags as “optional” or “more suited for high-security environments.” The table notes which controls a pnpm scaffold also gets and which are gates in the DaloyJS repo only.

ENISA package-consumption life cycle
  1. stage 1Selecttrustworthy, verified, maintained
  2. stage 2Integrateintegrity, source, scripts, pinning
  3. stage 3Monitorscan, track CVEs, ownership changes
  4. stage 4Mitigateassess, prioritise, patch, document
The DaloyJS repo covers the Select and Integrate controls; a pnpm scaffold gets the install-time ones. Monitor is daily SCA plus Dependabot. Per-app CVE reachability triage in Mitigate is the consumer's job.
ENISA recommendationDaloyJS control
Installation script prevention (ignore-scripts; ENISA flags as “high-security”)Scaffold default: ignoreScripts: true. Repo gate: pnpm verify:no-lifecycle-scripts (checks the published packages' own manifests)
Release-age delay (--before; ENISA flags as “optional and situational”)Scaffold default and repo policy: minimumReleaseAge: 1440 (24h)
Integrity / lockfile verification (SHA-512)frozen-lockfile + verify-store-integrity + pnpm verify:lockfile
Package source enforcement (trusted registry only)registry= pinned + pnpm verify:lockfile
SBOM creationCycloneDX 1.5 + SPDX 2.3 per release + pnpm verify:sbom
Trusted Publishing + provenanceOIDC trusted publishing (no long-lived token) + --provenance Sigstore
Internal allowlist of approved package namesRepo gate only: pnpm verify:known-dep-names
Reduce dependencies (“is the dependency needed?”)@daloyjs/core ships zero runtime deps (verify:no-runtime-deps)

ENISA section 5.2 names slopsquatting (attackers pre-registering hallucinated package names that AI tools emit) as a first-class AI-era threat. DaloyJS narrows both axes, with limits. On the name axis, verify:known-dep-names forces every top-level dependency in the DaloyJS repo onto an explicit allowlist; it is a CI gate in this repo, and scaffolded apps do not get it, so in your project a new dependency name is caught only by your own review. On the time axis, the minimumReleaseAge: 1440 cooldown waits out the window in which a slop-squat is typically detected and unpublished, which only helps against squats somebody detects within a day. Full recommendation-by-control table: SECURITY.md → ENISA mapping.

ENISA section 5.2's concern is backed by survey data. Aikido's State of AI in Security & Development 2026 report (450 practitioners) found that 69% of organizations have uncovered vulnerabilities introduced by AI-generated code and 1 in 5 had a serious incident tied to it, and that automated CI gates reduce incidents while manual review and tool sprawl do not. That is the case for DaloyJS's posture here: secure defaults mean AI-generated code starts from a safer baseline (it still needs review), and the fail-closed verify:* gates are the kind of automated, low-false-positive guardrail the report associates with fewer incidents.

What to do if a maintainer account is phished

The September 2025 chalk/debug compromise started with a single fake npmjs.help 2FA-reset email. If you suspect a maintainer (yours or an upstream's) was phished:

  1. Pin every direct dependency that lists the affected maintainer to the last known-good version in your lockfile.
  2. pnpm audit --prod and rotate any deployment credential the install host had access to (npm token, GitHub token, AWS keys, SSH keys).
  3. Bump minimumReleaseAge in pnpm-workspace.yaml further (e.g. 4320 for 72h) until the campaign settles. On pnpm 11+ a minimum-release-age line in .npmrc is ignored.
  4. Subscribe to GitHub Security Advisories for your dependency tree.

Hardening your own GitHub Actions

If you publish your own application's artifacts from CI, copy these rules:

  • Never use pull_request_target to check out fork code.
  • Top-level permissions: {}; opt back in per job.
  • Pin third-party actions to a commit SHA (Dependabot will keep them updated). A retargeted tag has the same blast radius as cache poisoning.
  • Separate the publish job. Do not put id-token: write on a workflow that runs untrusted code in any earlier step. OIDC tokens have been pulled from runner memory in real attacks.
  • Use step-security/harden-runner on the publish job with egress-policy: block and an explicit allowlist.
  • Use a protected GitHub Environment (required reviewers) for any job that can publish.

Further reading