Supply-chain security
Learn how to verify JavaScript dependencies with audit reports, lockfiles, SRI, provenance, name checks, and smaller dependency choices.
- 01List the parts of the chainName the packages, tools, registries, CDNs, and CI systems that can change the code your users run.
- 02Check installs and CDN filesKnow what
npm audit, lockfiles,npm ci, install-script review, provenance, and SRI each protect against, and what they miss. - 03Use fewer, safer packagesSpot lookalike package names, keep private names on your private registry, and use built-in tools instead of small packages.
Check before you trust
Most JavaScript projects run code that other people wrote. That code comes as packages. A package is a folder of code that someone published so that others can reuse it. A package your project needs is called a dependency.
Supply-chain security means checking outside code before it runs. Outside code can run in four places:
- on your own laptop;
- in CI, the server that tests and builds your project after every change;
- inside a build tool;
- in your user's browser.
JavaScript makes reusing code easy. This lesson shows how to reuse it without trusting it blindly.
A software supply chain is everything that can change the code your users finally run:
- the packages you install, and the packages they install;
- the tools that build and test your code;
- the registry, the online store that packages come from;
- CDNs, servers that send ready-made files, such as scripts, straight to browsers;
- the secret keys, called tokens, that let someone publish;
- the steps that publish a new version.
The rule of this lesson: check the code you install before you trust it.
This lesson builds on npm & package.json. That lesson explains version ranges, lockfiles, and install scripts. This lesson also links back to transpilers and polyfills. In 2024 a polyfill CDN, Polyfill.io, started sending harmful code to some visitors. You will also see why bundlers, Web Crypto, text encoding, and XSS defenses matter here.
A food shop does not grow its own food. It picks suppliers it trusts. It checks that every pack is sealed. It writes down each batch number. When a recall notice comes, it takes that batch off the shelves. It stops buying from a supplier when the owner changes or the food is unsafe.
JavaScript teams do the same with package names, lockfiles, audits, hashes, and release steps.
- In real life: Farm, truck, warehouse, and shop
- In JavaScript: Package authors, registries, CDNs, CI, and npm
- In real life: Ingredient label
- In JavaScript:
package.jsonand release notes - In real life: Sealed pack with a batch number
- In JavaScript: Lockfile hashes and SRI hashes
- In real life: Recall notice
- In JavaScript: Security warnings from
npm audit, Dependabot, Renovate, Snyk, or Socket
What the supply chain includes
REAL COUNTSThe chain is bigger than the packages you install yourself. It includes:
- direct dependencies, the packages you list in
package.json; - transitive dependencies, the packages that your packages need;
- build tools, registries, and the CI machines that run them;
- tokens, lockfiles, CDNs, and scripts the browser loads;
- the people and bots that are allowed to publish any of these.
The table lists each part. Read across: what the part is, then how you check it.
| Part of the chain | What it is | How you check it |
|---|---|---|
| Direct dependencies | The packages you list in your package.json files. | Check each name, owner, past releases, license, and install scripts, and whether you need it at all. |
| Transitive dependencies | The packages that your packages need, and the packages those need. | Read lockfile changes. This repository's root lockfile has 832 package entries, but its workspaces name only 56 direct dependencies. |
| Build tools | Bundlers, compilers, minifiers, test tools, and release scripts. | Treat them like any package. They run on your laptop and in CI, before users ever see your app. |
| CDNs | Scripts, styles, polyfills, fonts, and analytics that the browser loads from another site. | Use SRI for files that never change. Avoid services that can send different bytes each time. |
| CI and registries | Tokens, publish workflows, package registries, file stores, and GitHub Actions. | Give tokens the fewest permissions, use trusted publishing, scan for leaked secrets, and keep release steps easy to review. |
Most of the chain is transitive dependencies. Each package you add can bring its own packages, and those bring more. This repository shows how big the gap is. A script read the root package-lock.json, the lockfile npm writes. It found:
- 832 package entries, not counting the project itself;
- only 56 different direct dependency names. They come from the 3 projects in the repository, called workspaces, each with its own
package.json.
package.json files name 56 dependencies. The root lockfile installs 832 packages, because each one brings packages of its own.That gap is why lockfiles, review tools, and fewer dependencies matter.
const fs = require("node:fs");const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8"));const entries = Object.entries(lock.packages ?? {});const lockfilePackageEntries = entries.filter(([path]) => path !== "").length;const workspaces = entries.filter(([path, entry]) => path !== "" && !path.startsWith("node_modules/") && entry.name);const direct = new Set();for (const [, entry] of workspaces) { for (const bucket of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) { for (const name of Object.keys(entry[bucket] ?? {})) direct.add(name); }}console.log(lockfilePackageEntries + " lockfile package entries");console.log(direct.size + " direct dependency names across workspace manifests");This is a Node.js script, so it does not run in the browser. Here is what its lines do:
- Line 2 reads the lockfile and turns its JSON text into an object.
- Line 4 counts every package entry except the root one, whose path is
"". - Lines 5 to 7 keep the workspace entries: the ones outside
node_modules/that have a name. - Lines 8 to 13 put every dependency name from those workspaces into a
Set. ASetkeeps each name only once. - Lines 14 and 15 print both counts.
Direct dependencies are the names your team chose. Transitive dependencies are code you still run, because your choices pulled them in. Attackers often aim at packages deep in the tree. Such a package can be used by millions of projects, yet almost nobody on those teams reads its code.
Real attacks and what they teach
TIMELINEEach event below really happened. Each one shows a different way the chain can fail:
- a package disappears, and builds break;
- a new maintainer, the person who looks after a package, adds harmful code;
- an attacker steals a maintainer's account;
- a maintainer breaks their own package on purpose, as a protest;
- an attacker spends years winning a project's trust;
- a CDN gets a new owner;
- phishing, a fake email or page, steals a login;
- a package copies itself to other packages while it installs, like a worm.
Each row is one event. Read when it happened, then what it teaches.
| What was hit | When | What it teaches |
|---|---|---|
| left-pad | March 22, 2016 | After a naming dispute, a maintainer removed his small npm packages. Builds around the world broke, and npm made removing packages harder. |
| event-stream | 2018 | A new maintainer added a dependency, flatmap-stream, with harmful code aimed at users of the Copay wallet. It was found in November 2018. |
| ua-parser-js | October 22, 2021 | An attacker stole the maintainer's account and published versions that installed coin miners and password stealers. |
| colors/faker | January 2022 | The maintainer broke his own popular packages on purpose: colors got an endless loop, and faker got broken releases. |
| xz-utils | March 2024 | Not npm, but worth knowing. After years of winning trust, an attacker hid a backdoor for SSH logins in xz 5.6.0 and 5.6.1 release files. Andres Freund found it. |
| polyfill.io | February-June 2024 | The CDN got a new owner. Scripts from cdn.polyfill.io were then reported to send harmful JavaScript to some visitors of sites that used it. |
| npm phishing and Shai-Hulud | September 2025 | Fake emails stole maintainer logins. Then an npm worm used install scripts to steal secrets and publish harmful versions of more packages, including popular ones such as @ctrl/tinycolor. The package counts changed as reports were updated. |
xz-utils is not an npm package. It is a compression library that operating systems use. It still matters to JavaScript developers. Build machines depend on operating-system libraries, CI images, and maintainers you may never meet.
The Shai-Hulud reports matter for another reason. Installing a package can run its code before your app even starts. If that code steals npm, GitHub, or cloud passwords, the attacker can publish more harmful packages.
For each event, ask how your setup would respond:
- Would the lockfile keep the bad version, or stop a surprise new one?
- Would
npm ciinstall exactly the same packages again? - Would
--ignore-scriptslet you look at the package safely? - Would a secret scanner notice a leaked token?
- Would a CDN hash make the browser refuse a changed file?
Different attacks need different defenses.
A package you know can still turn harmful when someone takes over the account that publishes it. Three habits make that less likely and limit the harm:
- sign-in with a second step that phishing cannot trick, such as a hardware security key;
- trusted publishing: CI proves who it is with a short-lived OIDC token, a login that expires within minutes, instead of a saved npm token;
- a short list of people who are allowed to publish.
If a new release looks harmful:
- Pause deployments.
- Find the version you installed, in lockfiles and CI logs.
- Cancel every npm, GitHub, and cloud token that may have leaked, and make new ones.
- Rebuild from source code you have reviewed.
Do not expect npm ci to fix a bad lockfile. It installs exactly what the lockfile says, the bad version included.
npm audit, lockfiles, and install scripts
COMMANDSnpm audit checks your installed packages against a list of advisories. An advisory is a public warning that some versions of a package have a security problem. For each match, the report shows:
- how serious the problem is, called its severity;
- which of your packages pulls in the weak one;
- which version fixes it;
- sometimes, a command that applies the fix.
It has two limits:
- It knows nothing about a brand-new attack until someone publishes an advisory.
- It can warn about code your app never runs, or code that only development tools use.
npm audit --audit-level=highnpm audit fixnpm audit fix --forcenpm cinpm install --ignore-scriptsThese are terminal commands, not browser code. Line by line:
npm audit --audit-level=highlists advisories. It fails only when one ishighorcritical.npm audit fixinstalls fixed versions that stay inside the version ranges you allow.npm audit fix --forcealso allows bigger jumps, such as a new major version. Treat it like a move to a new version: review the changes and run your tests.npm ciinstalls exactly what the lockfile says. It is meant for CI.npm install --ignore-scriptsinstalls without running the packages' install scripts.
npm ci treats the lockfile as the truth:
- It stops with an error if
package.jsonandpackage-lock.jsondisagree. - It deletes the old
node_modulesfolder first. - It never changes
package.jsonorpackage-lock.json.
If a setting such as --legacy-peer-deps was used to make the lockfile, CI must use the same setting. Teams usually save it in the project's .npmrc file, npm's settings file.
A lockfile does not remove risk. It records the exact version and an integrity hash of every package, so the same packages install again next time. An integrity hash is a short fingerprint computed from a file's bytes. If any byte changes, the fingerprint changes.
Teams pick one of two ways to set versions:
- Pin every version exactly. You get fewer surprises, but updates take more work.
- Allow a range in
package.json, such as^5.1.0, and commit the lockfile. The^means "any5.xversion from5.1.0up". This is the usual choice for apps. You update on purpose, CI repeats the lockfile exactly, and update tools open a pull request for each change.
const manifestRange = "^5.1.0";
const lockedVersion = "5.3.2";
console.log(manifestRange + " -> " + lockedVersion);Line 1 is the range that package.json allows. Line 2 is the one exact version that the lockfile recorded. Line 4 prints ^5.1.0 -> 5.3.2.
package.json allows any version from the range ^5.1.0. The lockfile writes down the one version npm picked, 5.3.2, and npm ci installs that one every time.The arrow in that output is only text. It is not a real version check. npm checks the real lockfile, and npm ci installs the recorded version without picking a newer one.
An integrity hash catches bytes that differ from the file npm saw when it wrote the lockfile. It does not tell you who wrote those bytes. It also does not tell you whether the package was safe at that moment. So review lockfile changes the way you review code changes. Look closely when a package name, version, download address, or integrity value changes.
npm audit only knows published advisories. It does not scan for malware, and it does not check whether your app really uses the weak code. A clean report means no published advisory matched. It cannot clear a stolen account or a new harmful release. The report also sends your package names to your registry, private names included. Check your company's rules before you run it in CI.
- A new package adds a
postinstallscript - CI uses
npm cifrom a committed lockfile - The package has many weekly downloads
- A CDN script with a fixed version has a checked
sha384-...integrity value - The package name is
loadshwhen you meantlodash npm auditreports zero vulnerabilities today
Sort each card by what it tells you when you review a package. A risk signal means stop and look closer. A safeguard is a defense. Some cards help, but are not enough on their own.
A lifecycle script is a command that a package lists in its package.json. npm runs it at a set moment. The install scripts, preinstall, install, and postinstall, run while the package installs, with your permissions.
npm install --ignore-scripts lets you install a new package to look at it, without running those scripts. It has limits:
- It only skips install scripts. Code that your app imports later still runs.
- Some packages, such as ones that must compile code for your machine, do not work without their install step.
- It is a way to look, not a safe sandbox. It is not a lasting fix for a package you do not trust.
const scripts = { postinstall: "node ./build.js" };
const hookNames = ["preinstall", "install", "postinstall"];
const installHooks = Object.keys(scripts).filter((name) => hookNames.includes(name));
console.log(installHooks.join(", ") || "no install hooks");This small example prints postinstall. It only reads the script names. It never runs build.js.
- Line 1 is a
scriptsobject, like the one in apackage.json. It has onepostinstallscript. - Line 2 lists the three script names that npm runs during install.
- Line 3 keeps only the names in
scriptsthat are install scripts. - Line 5 prints those names, or
no install hooksif there are none.
In a real review, also read what the script does, and the files it runs. Then decide whether a normal install is fine.
Subresource Integrity
HASH ITSome pages load scripts from a CDN. If the CDN's file changes, your page runs the new code without asking you. Subresource Integrity, or SRI, stops that. In the HTML, you write the hash the file should have. The browser then:
- downloads the file;
- computes the hash of the bytes it got;
- compares that hash with the
integrityattribute; - runs the file only if they match. If they differ, it refuses the file.
The hash method here is SHA-384. It turns any bytes into a 48-byte fingerprint. SRI fits files that should never change, such as a CDN file with its version number in its address.
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>- Line 2 is the file's address. The exact version,
5.3.8, is part of it. - Line 3 is the expected hash:
sha384-, then the fingerprint written in base64. Base64 is a way to write bytes as letters, digits,+, and/. - Line 4,
crossorigin="anonymous", lets the browser check a file from another site. It is explained below.
integrity value. Change one byte and the hash changes, so the file is refused.Step through how an SRI value is made: bytes, a SHA-384 hash, base64, and sha384- in front.
script
async function computeSriHash(source) { const bytes = new TextEncoder().encode(source); const digest = await crypto.subtle.digest("SHA-384", bytes); const base64 = btoa(String.fromCharCode(...new Uint8Array(digest))); return `sha384-${base64}`;} computeSriHash(file).then(console.log);async function computeSriHash(source) { const bytes = new TextEncoder().encode(source); const digest = await crypto.subtle.digest("SHA-384", bytes); const base64 = btoa(String.fromCharCode(...new Uint8Array(digest))); return `sha384-${base64}`;} const file = 'console.log("CompleteFrontend");\n';computeSriHash(file).then(console.log);sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4eComputing...runWaiting for Web Crypto.
Waiting for Web Crypto.
crypto.subtle.digest('SHA-384', bytes). Then it compares the result with the original value, the way a browser checks a script that has an SRI attribute.crossorigin="anonymous" asks the browser to download the file with CORS and without cookies. CORS is the browser's rule for reading files from another site. The browser needs it to check many files that come from other sites.
If the CDN changes even one byte, the hash changes, and the browser refuses the file. So SRI does not fit files that change at the same address. Two examples:
- a
latest.jsfile that always holds the newest version; - a service like the old Polyfill.io, which built different code for each visitor.
If you copy the hash after the file is already harmful, SRI locks in the harmful file. Use SRI together with trusted sources and versions you have reviewed. Host the file yourself when that makes sense. The Web Crypto and text encoding lessons explain the hashing and the bytes behind it.
Typosquatting and dependency confusion
NAME CHECKTyposquatting means publishing a package under a name that looks like a popular one. The attacker hopes people will mistype the name. The fake name can be:
- a typo, such as
loadshinstead oflodash; - the right name without its scope;
- a word that looks almost the same.
A scope is the @name/ part at the start of names like @acme/tools. It groups packages under one owner.
Dependency confusion is a different attack. Say your company has a private package for its own use. If npm is not told where that name lives, it may fetch a package with the same name from the public registry instead. An attacker may have published one there on purpose.
Each row below is one attack. Read what goes wrong, then the first defense to use.
| Attack | What goes wrong | First defense |
|---|---|---|
| Typosquatting | You type a name like loadsh, and npm installs an attacker's package. | Before you install, check the exact name, the owner, the source code link, and past releases. |
| Dependency confusion | Your private package name is fetched from the public registry, because no setting says where it lives. | Put private packages under a scope your company owns. Send that scope to your private registry, on laptops and in CI. |
| Stolen maintainer account | An attacker publishes a new release under the real package name. | Protect publisher accounts, keep the list of publishers short, and check the proof that comes with each release. |
The playground below runs a small name checker. It compares the name you type with a list of popular names. It counts edits: how many single letters you must add, remove, or change to turn one name into the other. This count is called the Levenshtein distance.
loadsh is only 2 edits away from lodash: the a and the d swap places. That is close enough for the checker to say suspicious: true.const popularPackages = ["lodash", "react", "express", "next", "axios"];function normalizePackageName(name) { return name.toLowerCase().replace(/[^a-z0-9]/g, "");}function levenshteinDistance(left, right) { const previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let row = 1; row <= left.length; row += 1) { const current = [row]; for (let column = 1; column <= right.length; column += 1) { const cost = left[row - 1] === right[column - 1] ? 0 : 1; current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost); } previous.splice(0, previous.length, ...current); } return previous[right.length];}function checkSuspiciousPackageName(candidate) { const normalized = normalizePackageName(candidate); const closest = popularPackages .map((packageName) => ({ packageName, distance: levenshteinDistance(normalized, normalizePackageName(packageName)) })) .sort((left, right) => left.distance - right.distance)[0]; const suspicious = closest.distance > 0 && closest.distance <= 2; return { candidate, closest: closest.packageName, distance: closest.distance, suspicious };}console.log(JSON.stringify(checkSuspiciousPackageName("loadsh")));loadshlodash2suspiciousCompared with the 15 popular names on the lesson's list.
Stop and check: loadsh is only 2 edits from lodash. Look at the package page before you install it.
Step through a real name check. It only tells you to look closer; a person still checks who owns the package and why.
script
function normalizePackageName(name) { return name.toLowerCase().replace(/[^a-z0-9]/g, "");}function levenshteinDistance(left, right) { const previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let row = 1; row <= left.length; row += 1) { const current = [row]; for (let column = 1; column <= right.length; column += 1) { const cost = left[row - 1] === right[column - 1] ? 0 : 1; current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost); } previous.splice(0, previous.length, ...current); } return previous[right.length];}function checkSuspiciousPackageName(candidate) { const normalized = normalizePackageName(candidate); const closest = popularPackages .map((packageName) => ({ packageName, distance: levenshteinDistance(normalized, normalizePackageName(packageName)) })) .sort((left, right) => left.distance - right.distance)[0]; const suspicious = closest.distance > 0 && closest.distance <= 2; return { candidate, closest: closest.packageName, distance: closest.distance, suspicious };}console.log(JSON.stringify(checkSuspiciousPackageName("loadsh")));A scope helps when your company owns it and every tool uses the same registry setting. Put private packages under your private scope. Then tell npm to fetch @acme/* from your private registry, not from the public one.
@acme:registry=https://npm.acme.internal///npm.acme.internal/:_authToken=${NPM_TOKEN}This is npm's settings file, not a shell script.
- Line 1 sends every
@acmepackage to the private registry. - Line 2 gives the login token for that registry only. The token comes from
${NPM_TOKEN}, an environment variable: a value set outside the code.
Never write the token itself in a file you commit. Check this setting in CI as well as on your laptop.
Slopsquatting is a newer kind of typosquatting. AI coding assistants sometimes make up package names that sound right but do not exist. Attackers then publish packages under those names. So treat an install command from an AI as a suggestion, not a fact. Before you run it:
- search the registry for the name;
- read the package page;
- check who maintains it and where its source code lives;
- ask whether you need a package at all.
Fewer, better dependencies
REVIEWThe safest dependency is the one you do not need. Browsers and JavaScript now include many tools that older projects installed as packages:
fetchsends network requests;URLandURLSearchParamsbuild and read web addresses;Intlformats dates, numbers, and lists for many languages;structuredClonemakes a deep copy of an object;Array.isArraytests whether a value is an array;crypto.subtlecomputes hashes;TextEncoderturns text into bytes.
const original = { user: { name: "Ada" }, tags: ["security"] };const copy = structuredClone(original);copy.tags.push("reviewed"); console.log(original.tags.join(", "));console.log(new URL("/javascript/npm", "https://completefrontend.com").href);console.log(new Intl.ListFormat("en").format(["fetch", "URL", "structuredClone"]));- Line 2 makes a deep copy, so line 3 adds
"reviewed"to the copy only. - Line 5 prints
security, because the original did not change. - Line 6 builds a full address:
https://completefrontend.com/javascript/npm. - Line 7 prints
fetch, URL, and structuredClone.
Ask the questions in this table before you add a package. Read across: the question, what to look at, and the habit that helps.
| Question | What to look at | Habit that helps |
|---|---|---|
| Do you need it? | Can a built-in tool do the job? | Use fetch, URL, Intl, structuredClone, crypto.subtle, or TextEncoder when they do the job. |
| Is it looked after? | Recent releases, answered issues, tests, and how many people can maintain it. | A tiny package nobody looks after can be riskier than five clear lines of your own code. |
| What runs at install? | preinstall, install, and postinstall scripts, code compiled on install, and downloaded program files. | Read the scripts before the first install. Use --ignore-scripts when you only want to look. |
| How big is it? | Its size, how it exports code, and how many packages it brings along. | A package that brings many others for one small helper is a lot to check. |
| Who publishes it? | License, maintainers, provenance, sign-in protection, and release notes. | A healthy project makes each release easy to explain and easy to undo. |
| Can you trust its CI actions? | Who owns each action, its code at the pinned commit, token permissions, and how pins get updated. | Pin full commit SHAs, give the fewest permissions, and review every update. |
const packageChecklist = { name: "left-pad-alternative", needed: false, maintenance: "last release 3 years ago", installScripts: ["postinstall"], license: "MIT",}; console.log(packageChecklist.needed ? "review deeply" : "prefer platform code");console.log(packageChecklist.installScripts.includes("postinstall") ? "inspect scripts" : "no install script");- Lines 2 to 6 record what you found about the package.
- Line 9 prints
prefer platform code, becauseneededisfalse. - Line 10 prints
inspect scripts, because the package has apostinstallscript.
Vendoring means copying a small piece of code you have reviewed into your own repository, instead of installing it as a package. It suits small helpers that rarely change. Nothing runs at install time, and no extra packages come along. But the code is now yours: fixes, license rules, and updates are your job.
Install a package instead when the code is large, security-related, well looked after, and hard to get right yourself.
Tools and a review routine
Tools help most when you read what they find, instead of letting them decide for you:
- Dependabot and Renovate open a pull request for each dependency update.
- Socket and Snyk flag risky packages, known malware, and known security problems. Some also check whether your code reaches the weak part.
- npm provenance and registry signatures add proof about how a package was published, when the package and registry support them.
npm publish --provenancenpm audit signaturesThese are terminal commands.
- A registry signature is a mark the registry adds to a package. npm uses it to check that the package's name, version, and hash are what the registry published.
- Provenance is a public record that links a published package to its source repository and to the build that made it.
Line 1 publishes a package with provenance. Line 2, npm audit signatures, checks the signatures and provenance records of your installed packages, where they exist. Neither one proves that the source code is harmless.
name: Publish packageon: release: types: [published]permissions: contents: readjobs: publish: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 registry-url: https://registry.npmjs.org - run: npm ci - run: npm publish --provenance --access publicThis is a GitHub Actions workflow file, not code you can run here. A GitHub Action is a reusable step that someone else wrote, so it is part of your supply chain too. Line by line:
- Lines 10 to 12 let the job only read the repository, plus
id-token: write. That lets it get a short-lived OIDC token for trusted publishing, instead of a saved npm token. - Lines 14 and 15 pin each action to a full commit SHA: the long ID of one exact version of its code. A tag such as
v7can be moved to other code; a SHA cannot. The comment shows the matching version. - Line 19 installs exactly what the lockfile says.
- Line 20 publishes the package with provenance.
Read each action's code at that commit. Update the pins through reviewed pull requests, for example ones that Dependabot opens. A pin keeps one version fixed. It does not make that version safe, and you still need to update it.
Each defense in this table catches some problems and misses others. Read across: what it catches, then what it does not catch.
| Defense | What it catches | What it does not catch |
|---|---|---|
| npm audit | Published advisories that match your installed packages. | New malware, stolen accounts, lookalike names, careless maintainers, and problems nobody has reported yet. |
| Lockfile + npm ci | Installs the same packages, with the same hashes, every time. | Whether those packages are safe, looked after, or free of harmful install scripts. |
| Subresource Integrity | Makes the browser refuse a CDN file whose bytes do not match the hash. | Files that change on purpose, packages installed by npm, and a file that was already harmful when you copied its hash. |
| Registry signatures | Checks that a package's name, version, and hash are what a supporting registry published. | Who built the code, whether its source is safe, and packages that have no signature. |
| Provenance | Links a published package to its source repository and the build that made it. | Whether the source is harmless, whether the publisher's account was stolen, and builds that do not support it. |
| Full-SHA action pin | Keeps a workflow on one exact version of an action until you change the pin. | Whether that version is safe or up to date. Review and update pins on purpose. |
Put together, a review routine looks like this:
- Start with the need. Skip the package if a built-in tool or a few lines of your own code do the job.
- Before the first install, check the name, scope, owner, source code, license, past releases, and install scripts.
- Install with a committed lockfile, read the lockfile changes, and use
npm ciin CI. - Run
npm auditand other risk tools. Apply updates through pull requests, with tests. - Pin GitHub Actions to full SHAs you have reviewed. Give workflows the fewest permissions they need. Update pins through reviewed pull requests.
- Use SRI, or host the file yourself, for scripts the browser loads from other sites. Publish your own packages with provenance.
Common misconceptions
- "A clean
npm auditmeans safe." It means no published advisory matched your packages. It says nothing about owners, names, install scripts, or new releases. - "A lockfile stops attacks." A lockfile stops surprise version changes. It can also keep a bad version until you update on purpose.
- "SRI solves every CDN risk." SRI protects the exact bytes of a file that never changes. It does not help with files that change, or with a hash copied from a file that was already harmful.
- "Popular packages are safe." Popular packages are attractive to attackers. Popularity is one clue, not proof.
- "Install scripts are always harmful." Many are normal, especially for packages that compile code. The point is to know when you are running someone else's code.
- "An install command from an AI is a fact." AI assistants can make up package names that sound right. Attackers publish packages under those names.
Each row is advice you may hear. Read the question to ask, then a better habit.
| When you hear | Ask | Better habit |
|---|---|---|
| "Just run audit fix." | Will it jump to a new major version and need code changes? | Read the changes and run the tests, especially with --force. |
| "Load it from a CDN." | Does the address name an exact version? Is the owner trustworthy? Is there an SRI hash? | Host the file yourself, or use an address with an exact version and a checked hash. |
| "Install the package the AI suggested." | Does the package exist? Who owns it? Why do we need it? | Check the name yourself, and use a built-in tool when one fits. |
Practice exercises
5 EXERCISESType exactly what this code prints.
const lockfilePackageEntries = 832;
const directDependencyNames = 56;
console.log(lockfilePackageEntries + " vs " + directDependencyNames);It prints 832 vs 56. Most of what you install is transitive, so most review work is there too.
Type the SRI string for the sample file.
console.log("sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e");This SRI string fits only the exact sample bytes from the lesson. Change one byte and it no longer fits.
Type exactly what the name-check code prints.
const result = { candidate: "loadsh", closest: "lodash", distance: 2, suspicious: true };
console.log(result.closest + " / " + result.distance + " / " + result.suspicious);It prints lodash / 2 / true. So check the package page before you install loadsh.
Type the phrase this code prints.
const auditKnows = ["published advisories", "severity", "patched versions"];
console.log(auditKnows.includes("unreported malware") ? "complete" : "advisory database only");It prints advisory database only. Audit is useful, but it does not find every kind of harmful code.
Which built-in tool should replace the tiny package?
const tinyPackage = { name: "is-array", native: "Array.isArray" };
console.log(tinyPackage.native);Array.isArray(value)Use the built-in Array.isArray instead of adding a package for this tiny job.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is the software supply chain of a frontend app?
Choose an answer to see the explanation.
Question 2 of 8What does the real repository count print?
Read the code, then predictconst lockfilePackageEntries = 832; const directDependencyNames = 56; console.log(lockfilePackageEntries + " vs " + directDependencyNames);Choose an answer to see the explanation.
Question 3 of 8Which sentence about
npm auditis true?Choose an answer to see the explanation.
Question 4 of 8What does the SRI helper print for the sample file?
Read the code, then predictconsole.log("sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e");Choose an answer to see the explanation.
Question 5 of 8Why does the checker flag
loadsh?Read the code, then predictconst popularPackages = ["lodash", "react", "express", "next", "axios"]; function normalizePackageName(name) { return name.toLowerCase().replace(/[^a-z0-9]/g, ""); } function levenshteinDistance(left, right) { const previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let row = 1; row <= left.length; row += 1) { const current = [row]; for (let column = 1; column <= right.length; column += 1) { const cost = left[row - 1] === right[column - 1] ? 0 : 1; current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost); } previous.splice(0, previous.length, ...current); } return previous[right.length]; } function checkSuspiciousPackageName(candidate) { const normalized = normalizePackageName(candidate); const closest = popularPackages .map((packageName) => ({ packageName, distance: levenshteinDistance(normalized, normalizePackageName(packageName)) })) .sort((left, right) => left.distance - right.distance)[0]; const suspicious = closest.distance > 0 && closest.distance <= 2; return { candidate, closest: closest.packageName, distance: closest.distance, suspicious }; } console.log(JSON.stringify(checkSuspiciousPackageName("loadsh")));Choose an answer to see the explanation.
Question 6 of 8Which defense fits a CDN script that must never change without you knowing?
Choose an answer to see the explanation.
Question 7 of 8What does
npm install --ignore-scriptshelp with?Choose an answer to see the explanation.
Question 8 of 8Which event best shows an attacker slowly winning trust, outside npm?
Choose an answer to see the explanation.
Key takeaways
- The supply chain is every package, the packages they bring, build tools, registries, CDNs, CI, tokens, and publish steps.
npm auditreports published advisories. A lockfile andnpm cimake installs repeatable, not safe.- SRI makes the browser hash a CDN file and refuse it if the hash differs. It only fits files that never change.
- Typosquatting, dependency confusion, and slopsquatting all trick you with a name. Check names, scopes, and registry settings.
- Fewer, better dependencies are easier to check. Use built-in tools, read install scripts, and treat tool results as clues to review.
One-line summary: treat every new package and outside script like a code change you review, and use hashes, lockfiles, provenance, and fewer packages so you can check what you trust.
Up next: the isolation and sandboxing lesson shows how to run code you do not trust behind stronger walls, for the times when checking is not enough.