pnpm-shield 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +402 -0
- package/lib/checks.js +192 -0
- package/lib/colors.js +24 -0
- package/lib/docs.js +154 -0
- package/lib/fixes.js +187 -0
- package/lib/runner.js +255 -0
- package/lib/selector.js +123 -0
- package/lib/ui.js +117 -0
- package/package.json +34 -0
- package/pnpm-shield.js +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# 🛡️ pnpm-shield
|
|
2
|
+
|
|
3
|
+
> **Supply chain attack protection audit tool for pnpm projects.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/pnpm-shield)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
9
|
+
[](https://github.com/manuxstack/pnpm-shield)
|
|
10
|
+
|
|
11
|
+
`pnpm-shield` audits your pnpm project and developer environment against the most common supply chain attack vectors — postinstall script injection, dependency confusion, phantom dependencies, and accidental npm usage. It runs **13 checks**, explains every finding with attack vectors and remediation steps linked to official docs, and can **auto-fix all of them** interactively.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why this matters
|
|
16
|
+
|
|
17
|
+
The npm registry is the largest software registry in the world — and one of the most targeted. Attackers abuse `postinstall` scripts, typosquatting, and account takeovers to execute arbitrary code on every machine that runs `npm install` or `pnpm install`. The attacks below all share one trait: **they would have been stopped by a correctly configured pnpm environment.**
|
|
18
|
+
|
|
19
|
+
| Incident | Year | Attack vector | Source |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| [event-stream backdoor](https://snyk.io/blog/a-post-mortem-of-the-malicious-event-stream-backdoor/) | 2018 | Malicious `postinstall` injected after maintainer handover | Snyk |
|
|
22
|
+
| [eslint-scope credential theft](https://eslint.org/blog/2018/07/postmortem-for-malicious-package-publishes/) | 2018 | Stolen npm credentials → `postinstall` exfiltrated `.npmrc` tokens | ESLint |
|
|
23
|
+
| [ua-parser-js takeover](https://github.com/advisories/GHSA-pjwm-rvh2-c87w) | 2021 | npm account hijacked → cryptominer + RAT via `postinstall` | GitHub Advisory |
|
|
24
|
+
| [dependency confusion](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610) | 2021 | Public package shadows internal name, executes on install | Alex Birsan |
|
|
25
|
+
| [node-ipc sabotage](https://socket.dev/blog/node-ipc-supply-chain-attack) | 2022 | Maintainer added destructive `postinstall` targeting Russian IPs | Socket.dev |
|
|
26
|
+
| [colors + faker protest](https://snyk.io/blog/open-source-npm-packages-colors-faker/) | 2022 | Maintainer corrupted own packages, breaking thousands of projects | Snyk |
|
|
27
|
+
| [xz-utils backdoor](https://openwall.com/lists/oss-security/2024/03/29/4) | 2024 | 2-year social engineering → malicious build script in release tarball | Openwall |
|
|
28
|
+
| [polyfill.io CDN hijack](https://sansec.io/research/polyfill-supply-chain-attack) | 2024 | Domain acquired → CDN injected malicious JS into 100k+ sites | Sansec |
|
|
29
|
+
| [nx package compromise](https://arcticwolf.com/resources/blog/nx-package-compromise-2025/) | Aug 2025 | Malicious `nx` versions published → credential theft + filesystem scan | Arctic Wolf |
|
|
30
|
+
| [Shai-Hulud worm](https://www.wiz.io/blog/shai-hulud-npm-worm) | Sep 2025 | Self-replicating npm worm stole cloud tokens and re-published infected packages | Wiz |
|
|
31
|
+
| [axios maintainer compromise](https://arcticwolf.com/resources/blog/axios-supply-chain-attack-2026/) | Mar 2026 | Hijacked maintainer account → `postinstall` RAT in `axios` v1.14.1 | Arctic Wolf |
|
|
32
|
+
| [TanStack / TeamPCP campaign](https://cybernews.com/security/teamPCP-tanstack-supply-chain/) | Apr 2026 | Poisoned CI/CD cache → malicious publishes across TanStack ecosystem | Cybernews |
|
|
33
|
+
|
|
34
|
+
> 💡 **Every `postinstall` attack in this list is blocked by `ignore-scripts=true`.** The dependency confusion attacks are mitigated by a strict `pnpm-lock.yaml` and the `packageManager` field enforced by Corepack. `pnpm-shield` checks for all of these protections.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Run directly without installing (recommended for one-off audits):
|
|
44
|
+
pnpm dlx pnpm-shield
|
|
45
|
+
|
|
46
|
+
# Install globally:
|
|
47
|
+
pnpm add -g pnpm-shield
|
|
48
|
+
|
|
49
|
+
# Add as a dev dependency in your project:
|
|
50
|
+
pnpm add -D pnpm-shield
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Zero production dependencies.** Everything uses Node.js built-ins.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Run in the root of your project:
|
|
61
|
+
pnpm-shield
|
|
62
|
+
|
|
63
|
+
# Same command, shorter alias:
|
|
64
|
+
pnpm-check
|
|
65
|
+
|
|
66
|
+
# CI mode — non-interactive, exits with code 1 on failures:
|
|
67
|
+
pnpm-shield --ci
|
|
68
|
+
|
|
69
|
+
# Show help:
|
|
70
|
+
pnpm-shield --help
|
|
71
|
+
|
|
72
|
+
# Show version:
|
|
73
|
+
pnpm-shield --version
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Interactive menu
|
|
79
|
+
|
|
80
|
+
After the audit runs, an interactive prompt lets you explore and fix findings without leaving the terminal.
|
|
81
|
+
|
|
82
|
+
### Commands
|
|
83
|
+
|
|
84
|
+
| Command | Action |
|
|
85
|
+
|---------|--------|
|
|
86
|
+
| `?` | Open an **arrow-key browser** across all 13 checks — navigate with ↑↓, press Enter to read documentation |
|
|
87
|
+
| `?N` | Read docs for check N directly, e.g. `?3` |
|
|
88
|
+
| `fix` | Open a **visual multi-selector** for fixes — navigate with ↑↓, toggle with Space, confirm with Enter |
|
|
89
|
+
| `all` | Apply all auto-fixable items at once |
|
|
90
|
+
| `q` | Quit |
|
|
91
|
+
|
|
92
|
+
### Documentation panel
|
|
93
|
+
|
|
94
|
+
Every check has an integrated documentation panel showing:
|
|
95
|
+
- **Why it matters** — the security rationale
|
|
96
|
+
- **Attack vector** — a concrete attack scenario
|
|
97
|
+
- **How to fix** — step-by-step remediation commands
|
|
98
|
+
- **Official references** — links to pnpm docs, Node.js docs, and security post-mortems
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## What it checks
|
|
103
|
+
|
|
104
|
+
`pnpm-shield` runs **13 security checks** across three categories. All non-passing checks support **auto-fix**.
|
|
105
|
+
|
|
106
|
+
### 🖥 Environment
|
|
107
|
+
|
|
108
|
+
| # | Check | Severity | Auto-fix |
|
|
109
|
+
|---|-------|----------|---------|
|
|
110
|
+
| 1 | pnpm is installed and in PATH | CRITICAL | — |
|
|
111
|
+
| 2 | Shell alias `npm → pnpm` | HIGH | ✅ Adds alias to shell config |
|
|
112
|
+
| 3 | Corepack enabled and managing pnpm | HIGH | ✅ Runs `corepack enable pnpm` |
|
|
113
|
+
| 4 | No foreign lockfiles (`package-lock.json`, `yarn.lock`, `bun.lockb`) | CRITICAL | ✅ Deletes foreign lockfiles |
|
|
114
|
+
|
|
115
|
+
### ⚙️ pnpm / npm Configuration
|
|
116
|
+
|
|
117
|
+
| # | Check | Severity | Auto-fix |
|
|
118
|
+
|---|-------|----------|---------|
|
|
119
|
+
| 5 | Global `ignore-scripts = true` | CRITICAL | ✅ `pnpm config set ignore-scripts true` |
|
|
120
|
+
| 6 | Local `.npmrc`: `ignore-scripts=true` | HIGH | ✅ Appends to `.npmrc` |
|
|
121
|
+
| 7 | Local `.npmrc`: `save-exact=true` | MEDIUM | ✅ Appends to `.npmrc` |
|
|
122
|
+
| 8 | Local `.npmrc`: `shamefully-hoist=false` | LOW | ✅ Appends to `.npmrc` |
|
|
123
|
+
| 9 | Local `.npmrc`: `engine-strict=true` | MEDIUM | ✅ Appends to `.npmrc` |
|
|
124
|
+
|
|
125
|
+
### 📦 package.json Hardening
|
|
126
|
+
|
|
127
|
+
| # | Check | Severity | Auto-fix |
|
|
128
|
+
|---|-------|----------|---------|
|
|
129
|
+
| 10 | `pnpm.onlyBuiltDependencies` whitelist | HIGH | ✅ Adds `[]` to `package.json` |
|
|
130
|
+
| 11 | `packageManager` field pinned to `pnpm@X.Y.Z` | HIGH | ✅ Sets current pnpm version |
|
|
131
|
+
| 12 | `engines.node` range specified | MEDIUM | ✅ Sets `>=` current Node major |
|
|
132
|
+
| 13 | `pnpm-lock.yaml` present | HIGH | ✅ Runs `pnpm install` |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Grading
|
|
137
|
+
|
|
138
|
+
After the audit, your project receives a security grade:
|
|
139
|
+
|
|
140
|
+
| Grade | Score | Meaning |
|
|
141
|
+
|-------|-------|---------|
|
|
142
|
+
| **A+** | ≥ 92% | Fortress — all critical paths hardened |
|
|
143
|
+
| **A** | ≥ 84% | Excellent — minor optional improvements available |
|
|
144
|
+
| **B** | ≥ 76% | Good — a few medium-risk items to address |
|
|
145
|
+
| **C** | ≥ 60% | Fair — notable gaps that should be closed |
|
|
146
|
+
| **D** | < 60% | Needs attention — critical or multiple high failures |
|
|
147
|
+
|
|
148
|
+
Score = passed checks + 0.5 × warnings.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Check details
|
|
153
|
+
|
|
154
|
+
### 1. pnpm installed — CRITICAL
|
|
155
|
+
pnpm is the only mainstream package manager with `onlyBuiltDependencies` whitelisting, per-project `ignore-scripts`, and a content-addressable store with integrity verification.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
corepack enable pnpm
|
|
159
|
+
# or:
|
|
160
|
+
curl -fsSL https://get.pnpm.io/install.sh | sh
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
📎 [pnpm Installation](https://pnpm.io/installation)
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### 2. Shell alias `npm → pnpm` — HIGH ✅
|
|
168
|
+
Even with pnpm fully configured, typing `npm install` by muscle memory invokes the real npm binary. npm ignores your `.npmrc`, your `pnpm-lock.yaml`, and your `onlyBuiltDependencies` whitelist.
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
echo 'alias npm=pnpm' >> ~/.zshrc && source ~/.zshrc
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
📎 [Typosquatting attacks](https://blog.npmjs.org/post/163723642530/crossenv-malware-on-the-npm-registry)
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### 3. Corepack managing pnpm — HIGH ✅
|
|
179
|
+
Corepack (built into Node.js ≥ 16.9) reads `"packageManager"` in `package.json` and **blocks npm and yarn** project-wide. It also ensures every developer uses the exact same pnpm version.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
corepack enable
|
|
183
|
+
corepack enable pnpm
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
📎 [Corepack docs](https://nodejs.org/api/corepack.html) · [pnpm + Corepack](https://pnpm.io/installation#using-corepack)
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### 4. No foreign lockfiles — CRITICAL ✅
|
|
191
|
+
A `package-lock.json` or `yarn.lock` alongside `pnpm-lock.yaml` creates two conflicting sources of truth. CI systems may pick the wrong one, installing different (potentially malicious) resolved versions.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
rm package-lock.json yarn.lock bun.lockb
|
|
195
|
+
pnpm install
|
|
196
|
+
git add pnpm-lock.yaml
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
📎 [Dependency confusion attack](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610)
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### 5. Global `ignore-scripts=true` — CRITICAL ✅
|
|
204
|
+
Packages can declare `postinstall`, `preinstall`, and `install` lifecycle scripts that run arbitrary shell commands. This is the primary vector for supply chain attacks (event-stream 2018, node-ipc 2022, xz-utils 2024).
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pnpm config set ignore-scripts true
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
> **Note:** With `ignore-scripts=true`, packages that legitimately need build scripts (e.g. `esbuild`, `sharp`) will break. Use check #10 (`onlyBuiltDependencies`) to whitelist exactly those packages.
|
|
211
|
+
|
|
212
|
+
📎 [pnpm ignore-scripts](https://pnpm.io/npmrc#ignore-scripts) · [event-stream post-mortem](https://snyk.io/blog/a-post-mortem-of-the-malicious-event-stream-backdoor/)
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
### 6. Local `.npmrc`: `ignore-scripts=true` — HIGH ✅
|
|
217
|
+
The global config can differ across machines and CI environments. A local `.npmrc` commits the rule into the repository, protecting every developer and every CI runner regardless of their global config.
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
echo "ignore-scripts=true" >> .npmrc
|
|
221
|
+
git add .npmrc
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
### 7. `save-exact=true` — MEDIUM ✅
|
|
227
|
+
By default pnpm saves deps with a `^` prefix (e.g. `^1.2.3`), allowing any compatible update. An attacker who compromises a package can publish `1.2.4` with malicious code and every project using `^1.2.3` adopts it on the next install.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
echo "save-exact=true" >> .npmrc
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
📎 [Semver hijacking](https://snyk.io/blog/ten-npm-security-best-practices/)
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
### 8. `shamefully-hoist=false` — LOW ✅
|
|
238
|
+
pnpm uses a strict, isolated `node_modules` layout by default. `shamefully-hoist=true` flattens it like npm, allowing packages to import dependencies they never declared (**phantom dependencies**).
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
echo "shamefully-hoist=false" >> .npmrc
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
📎 [Phantom dependencies](https://pnpm.io/blog/2020/05/27/flat-node-modules-is-not-the-only-way)
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
### 9. `engine-strict=true` — MEDIUM ✅
|
|
249
|
+
Some security patches are Node.js-version-specific. Running code on an EOL Node version may miss critical fixes.
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
echo "engine-strict=true" >> .npmrc
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
📎 [Node.js release schedule](https://nodejs.org/en/about/previous-releases)
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
### 10. `pnpm.onlyBuiltDependencies` — HIGH ✅
|
|
260
|
+
Even with `ignore-scripts=true`, you may need certain packages to run build scripts (e.g. `esbuild`, `sharp`). This whitelist gives surgical, auditable control over which packages may run scripts.
|
|
261
|
+
|
|
262
|
+
```jsonc
|
|
263
|
+
// package.json
|
|
264
|
+
{
|
|
265
|
+
"pnpm": {
|
|
266
|
+
"onlyBuiltDependencies": ["esbuild", "sharp"]
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
An **empty array `[]`** blocks all postinstall scripts without exception.
|
|
272
|
+
|
|
273
|
+
📎 [onlyBuiltDependencies](https://pnpm.io/package_json#pnpmonlybuiltdependencies)
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
### 11. `packageManager` field — HIGH ✅
|
|
278
|
+
Tells Corepack the exact package manager and version the project requires. Corepack will then block npm and yarn and auto-download the correct pnpm version for any contributor.
|
|
279
|
+
|
|
280
|
+
```jsonc
|
|
281
|
+
// package.json
|
|
282
|
+
{
|
|
283
|
+
"packageManager": "pnpm@11.1.1"
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
📎 [packageManager field](https://nodejs.org/api/packages.html#packagemanager)
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
### 12. `engines.node` range — MEDIUM ✅
|
|
292
|
+
Declares the minimum Node.js version. Combined with `engine-strict=true`, pnpm refuses to install on incompatible environments, preventing use of EOL runtimes with known CVEs.
|
|
293
|
+
|
|
294
|
+
```jsonc
|
|
295
|
+
// package.json
|
|
296
|
+
{
|
|
297
|
+
"engines": { "node": ">=20" }
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
📎 [engines field](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines)
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
### 13. `pnpm-lock.yaml` present — HIGH ✅
|
|
306
|
+
The lockfile pins exact resolved versions AND SHA-512 integrity hashes for every package in the full dependency tree. Without it, `pnpm install` resolves versions fresh each time and can silently adopt a compromised release.
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
pnpm install # generates pnpm-lock.yaml
|
|
310
|
+
git add pnpm-lock.yaml
|
|
311
|
+
# Never add pnpm-lock.yaml to .gitignore!
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
📎 [pnpm lockfile format](https://pnpm.io/lockfile-format) · [Should lockfiles be committed?](https://pnpm.io/faq#should-lockfile-be-committed)
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## CI/CD integration
|
|
319
|
+
|
|
320
|
+
### GitHub Actions
|
|
321
|
+
|
|
322
|
+
```yaml
|
|
323
|
+
# .github/workflows/security.yml
|
|
324
|
+
name: Security Audit
|
|
325
|
+
|
|
326
|
+
on: [push, pull_request]
|
|
327
|
+
|
|
328
|
+
jobs:
|
|
329
|
+
pnpm-shield:
|
|
330
|
+
runs-on: ubuntu-latest
|
|
331
|
+
steps:
|
|
332
|
+
- uses: actions/checkout@v4
|
|
333
|
+
- uses: actions/setup-node@v4
|
|
334
|
+
with:
|
|
335
|
+
node-version: 20
|
|
336
|
+
- run: pnpm dlx pnpm-shield --ci
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Native git pre-commit hook (no extra dependencies)
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
cat > .git/hooks/pre-commit << 'EOF'
|
|
343
|
+
#!/bin/sh
|
|
344
|
+
pnpm-shield --ci
|
|
345
|
+
EOF
|
|
346
|
+
chmod +x .git/hooks/pre-commit
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Recommended baseline configuration
|
|
352
|
+
|
|
353
|
+
**`.npmrc`**
|
|
354
|
+
```ini
|
|
355
|
+
ignore-scripts=true
|
|
356
|
+
save-exact=true
|
|
357
|
+
shamefully-hoist=false
|
|
358
|
+
engine-strict=true
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**`package.json` additions**
|
|
362
|
+
```jsonc
|
|
363
|
+
{
|
|
364
|
+
"packageManager": "pnpm@11.1.1",
|
|
365
|
+
"engines": { "node": ">=20" },
|
|
366
|
+
"pnpm": {
|
|
367
|
+
"onlyBuiltDependencies": []
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## Project structure
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
pnpm-shield.js ← Entry point (20 lines)
|
|
378
|
+
lib/
|
|
379
|
+
colors.js ← ANSI color constants
|
|
380
|
+
docs.js ← Per-check documentation + official references
|
|
381
|
+
checks.js ← Runs all 13 security checks
|
|
382
|
+
ui.js ← Terminal output (header, results, doc panel, summary)
|
|
383
|
+
selector.js ← Raw TTY arrow-key interactive selector (zero deps)
|
|
384
|
+
fixes.js ← Auto-fix implementations for all 13 checks
|
|
385
|
+
runner.js ← Orchestrates the full audit + interactive menu
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Contributing
|
|
391
|
+
|
|
392
|
+
Pull requests are welcome. To add a new check:
|
|
393
|
+
|
|
394
|
+
1. Add the result in `lib/checks.js` with a `docKey` and `fix` key
|
|
395
|
+
2. Add documentation in `lib/docs.js` with `why`, `attack`, `fix`, and `refs`
|
|
396
|
+
3. Add a fix handler in `lib/fixes.js`
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## License
|
|
401
|
+
|
|
402
|
+
MIT
|
package/lib/checks.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { sectionHeader } = require('./ui');
|
|
8
|
+
|
|
9
|
+
// ─── Result builder ───────────────────────────────────────────────────────────
|
|
10
|
+
function makeResult(status, severity, label, detail, tip, fix, docKey) {
|
|
11
|
+
return { status, severity, label, detail: detail || '', tip: tip || '', fix: fix || null, docKey: docKey || null };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function tryExec(cmd) {
|
|
15
|
+
try { return execSync(cmd, { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); }
|
|
16
|
+
catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── Main check runner ────────────────────────────────────────────────────────
|
|
20
|
+
function runChecks(cwd) {
|
|
21
|
+
const results = [];
|
|
22
|
+
|
|
23
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
24
|
+
const rcPath = path.join(cwd, '.npmrc');
|
|
25
|
+
const lockNpm = path.join(cwd, 'package-lock.json');
|
|
26
|
+
const lockYarn = path.join(cwd, 'yarn.lock');
|
|
27
|
+
const lockBun = path.join(cwd, 'bun.lockb');
|
|
28
|
+
const lockPnpm = path.join(cwd, 'pnpm-lock.yaml');
|
|
29
|
+
|
|
30
|
+
// ── Section 1: Environment ──────────────────────────────────────────────────
|
|
31
|
+
sectionHeader('🖥 ', 'Environment');
|
|
32
|
+
|
|
33
|
+
// C1 — pnpm installed
|
|
34
|
+
const pnpmVer = tryExec('pnpm --version');
|
|
35
|
+
results.push(pnpmVer
|
|
36
|
+
? makeResult('pass', 'CRITICAL', 'pnpm is installed and in PATH', `v${pnpmVer}`, '', null, 'pnpm_installed')
|
|
37
|
+
: makeResult('fail', 'CRITICAL', 'pnpm not found in PATH', '', 'Run: npm install -g pnpm (one-time bootstrap only)', null, 'pnpm_installed')
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// C2 — npm → pnpm alias
|
|
41
|
+
const shellCfgs = [
|
|
42
|
+
path.join(os.homedir(), '.zshrc'),
|
|
43
|
+
path.join(os.homedir(), '.bashrc'),
|
|
44
|
+
path.join(os.homedir(), '.bash_profile'),
|
|
45
|
+
path.join(os.homedir(), '.profile'),
|
|
46
|
+
path.join(os.homedir(), '.config', 'fish', 'config.fish'),
|
|
47
|
+
];
|
|
48
|
+
let aliasFile = null;
|
|
49
|
+
for (const cfg of shellCfgs) {
|
|
50
|
+
if (fs.existsSync(cfg)) {
|
|
51
|
+
const txt = fs.readFileSync(cfg, 'utf8');
|
|
52
|
+
if (/alias\s+npm\s*=\s*['"]?pnpm['"]?/.test(txt) || /alias\s+npm\s+pnpm/.test(txt)) {
|
|
53
|
+
aliasFile = cfg; break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
results.push(aliasFile
|
|
58
|
+
? makeResult('pass', 'HIGH', 'Shell alias: npm → pnpm', path.basename(aliasFile), '', null, 'npm_alias')
|
|
59
|
+
: makeResult('fail', 'HIGH', 'No npm → pnpm shell alias found', '',
|
|
60
|
+
"Typing 'npm install' bypasses all pnpm protections. Add: alias npm=pnpm", 'npmAlias', 'npm_alias')
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// C3 — Corepack
|
|
64
|
+
const corepackVer = tryExec('corepack --version');
|
|
65
|
+
if (corepackVer) {
|
|
66
|
+
const pnpmWhich = tryExec('which pnpm') || '';
|
|
67
|
+
results.push(pnpmWhich.includes('corepack')
|
|
68
|
+
? makeResult('pass', 'HIGH', 'Corepack is enabled and managing pnpm', `corepack v${corepackVer}`, '', null, 'corepack')
|
|
69
|
+
: makeResult('warn', 'HIGH', 'Corepack installed but pnpm not managed by it', '', 'Run: corepack enable pnpm', 'corepack', 'corepack')
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
results.push(makeResult('warn', 'HIGH', 'Corepack not detected', '',
|
|
73
|
+
'Enable with: corepack enable', 'corepack', 'corepack'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// C4 — No foreign lockfiles
|
|
77
|
+
const foreign = [
|
|
78
|
+
{ file: lockNpm, name: 'package-lock.json' },
|
|
79
|
+
{ file: lockYarn, name: 'yarn.lock' },
|
|
80
|
+
{ file: lockBun, name: 'bun.lockb' },
|
|
81
|
+
].filter(l => fs.existsSync(l.file));
|
|
82
|
+
|
|
83
|
+
results.push(foreign.length === 0
|
|
84
|
+
? makeResult('pass', 'CRITICAL', 'No foreign lockfiles detected', 'package-lock.json / yarn.lock / bun.lockb absent', '', null, 'foreign_locks')
|
|
85
|
+
: makeResult('fail', 'CRITICAL', `Foreign lockfile(s) found: ${foreign.map(l => l.name).join(', ')}`, '',
|
|
86
|
+
"Delete them and run 'pnpm install' to re-lock with pnpm", 'foreignLocks', 'foreign_locks')
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ── Section 2: pnpm / npm Configuration ────────────────────────────────────
|
|
90
|
+
sectionHeader('⚙️ ', 'pnpm / npm Configuration');
|
|
91
|
+
|
|
92
|
+
// C5 — Global ignore-scripts
|
|
93
|
+
const globalIgnore = tryExec('pnpm config get ignore-scripts');
|
|
94
|
+
results.push(globalIgnore === 'true'
|
|
95
|
+
? makeResult('pass', 'CRITICAL', 'Global ignore-scripts = true', 'Blocks postinstall code injection system-wide', '', null, 'global_ignore_scripts')
|
|
96
|
+
: makeResult('fail', 'CRITICAL', `Global ignore-scripts = ${globalIgnore ?? 'unknown'}`, '',
|
|
97
|
+
'Run: pnpm config set ignore-scripts true', 'globalIgnoreScripts', 'global_ignore_scripts')
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Read local .npmrc once
|
|
101
|
+
const npmrc = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : '';
|
|
102
|
+
const hasRc = fs.existsSync(rcPath);
|
|
103
|
+
|
|
104
|
+
// C6 — Local .npmrc ignore-scripts
|
|
105
|
+
if (!hasRc) {
|
|
106
|
+
results.push(makeResult('fail', 'HIGH', 'No local .npmrc file found', '',
|
|
107
|
+
'Create .npmrc and add: ignore-scripts=true', 'localNpmrc', 'local_npmrc'));
|
|
108
|
+
} else if (npmrc.includes('ignore-scripts=true')) {
|
|
109
|
+
results.push(makeResult('pass', 'HIGH', 'Local .npmrc: ignore-scripts=true', 'Protection locked at project level', '', null, 'local_npmrc'));
|
|
110
|
+
} else {
|
|
111
|
+
results.push(makeResult('fail', 'HIGH', 'Local .npmrc: ignore-scripts=true missing', '',
|
|
112
|
+
"Add 'ignore-scripts=true' to .npmrc", 'localNpmrc', 'local_npmrc'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// C7 — save-exact
|
|
116
|
+
results.push(npmrc.includes('save-exact=true')
|
|
117
|
+
? makeResult('pass', 'MEDIUM', 'Local .npmrc: save-exact=true', 'Pins exact dep versions — no semver drift', '', null, 'save_exact')
|
|
118
|
+
: makeResult('warn', 'MEDIUM', 'Local .npmrc: save-exact not set', '',
|
|
119
|
+
"Add 'save-exact=true' to pin exact versions", 'saveExact', 'save_exact')
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// C8 — shamefully-hoist
|
|
123
|
+
if (npmrc.includes('shamefully-hoist=false')) {
|
|
124
|
+
results.push(makeResult('pass', 'MEDIUM', 'Local .npmrc: shamefully-hoist=false', 'Strict dep isolation enforced', '', null, 'shamefully_hoist'));
|
|
125
|
+
} else if (npmrc.includes('shamefully-hoist=true')) {
|
|
126
|
+
results.push(makeResult('fail', 'MEDIUM', 'Local .npmrc: shamefully-hoist=true (flat node_modules!)', '',
|
|
127
|
+
"Set 'shamefully-hoist=false' — flat installs allow phantom dependency attacks", 'shamefullyHoist', 'shamefully_hoist'));
|
|
128
|
+
} else {
|
|
129
|
+
results.push(makeResult('warn', 'LOW', 'Local .npmrc: shamefully-hoist not set explicitly', '',
|
|
130
|
+
"pnpm defaults to false (safe), but add 'shamefully-hoist=false' to be explicit", 'shamefullyHoist', 'shamefully_hoist'));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// C9 — engine-strict
|
|
134
|
+
results.push(npmrc.includes('engine-strict=true')
|
|
135
|
+
? makeResult('pass', 'MEDIUM', 'Local .npmrc: engine-strict=true', 'Rejects mismatched Node.js versions', '', null, 'engine_strict')
|
|
136
|
+
: makeResult('warn', 'MEDIUM', 'Local .npmrc: engine-strict not set', '',
|
|
137
|
+
"Add 'engine-strict=true' to enforce engines.node range", 'engineStrict', 'engine_strict')
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// ── Section 3: package.json Hardening ──────────────────────────────────────
|
|
141
|
+
sectionHeader('📦', 'package.json Hardening');
|
|
142
|
+
|
|
143
|
+
if (!fs.existsSync(pkgPath)) {
|
|
144
|
+
results.push(makeResult('warn', 'HIGH', 'package.json not found', '', "Run 'pnpm init' first", null, null));
|
|
145
|
+
} else {
|
|
146
|
+
let pkg = null;
|
|
147
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); }
|
|
148
|
+
catch { results.push(makeResult('fail', 'CRITICAL', 'package.json is not valid JSON', '', 'Fix the syntax error', null, null)); }
|
|
149
|
+
|
|
150
|
+
if (pkg) {
|
|
151
|
+
// C10 — onlyBuiltDependencies
|
|
152
|
+
if (pkg.pnpm && Array.isArray(pkg.pnpm.onlyBuiltDependencies)) {
|
|
153
|
+
const n = pkg.pnpm.onlyBuiltDependencies.length;
|
|
154
|
+
results.push(makeResult('pass', 'HIGH', 'pnpm.onlyBuiltDependencies whitelist present',
|
|
155
|
+
n === 0 ? 'Empty — blocks ALL postinstall scripts' : `${n} package(s) allowed`, '', null, 'only_built_deps'));
|
|
156
|
+
} else {
|
|
157
|
+
results.push(makeResult('warn', 'HIGH', 'pnpm.onlyBuiltDependencies not configured', '',
|
|
158
|
+
"Add 'pnpm.onlyBuiltDependencies: []' to block all postinstall scripts", 'onlyBuiltDeps', 'only_built_deps'));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// C11 — packageManager
|
|
162
|
+
if (pkg.packageManager && pkg.packageManager.startsWith('pnpm@')) {
|
|
163
|
+
results.push(makeResult('pass', 'HIGH', `packageManager = "${pkg.packageManager}"`,
|
|
164
|
+
'Corepack can enforce this and block npm/yarn', '', null, 'package_manager_field'));
|
|
165
|
+
} else {
|
|
166
|
+
results.push(makeResult('fail', 'HIGH',
|
|
167
|
+
`packageManager field is ${pkg.packageManager ? `"${pkg.packageManager}"` : 'absent'}`, '',
|
|
168
|
+
'Add: "packageManager": "pnpm@X.Y.Z" to enable Corepack enforcement',
|
|
169
|
+
'packageManager', 'package_manager_field'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// C12 — engines.node
|
|
173
|
+
if (pkg.engines && pkg.engines.node) {
|
|
174
|
+
results.push(makeResult('pass', 'MEDIUM', `engines.node = "${pkg.engines.node}"`,
|
|
175
|
+
'Rejects incompatible Node versions (needs engine-strict=true)', '', null, 'engines_node'));
|
|
176
|
+
} else {
|
|
177
|
+
results.push(makeResult('warn', 'MEDIUM', 'No engines.node range specified', '',
|
|
178
|
+
'Add: "engines": { "node": ">=20" }', 'enginesNode', 'engines_node'));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// C13 — pnpm-lock.yaml
|
|
182
|
+
results.push(fs.existsSync(lockPnpm)
|
|
183
|
+
? makeResult('pass', 'HIGH', 'pnpm-lock.yaml found', 'Project is locked with pnpm', '', null, 'pnpm_lock')
|
|
184
|
+
: makeResult('warn', 'HIGH', 'pnpm-lock.yaml not found', '', "Run 'pnpm install' to generate it", 'pnpmInstall', 'pnpm_lock')
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { runChecks };
|
package/lib/colors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Shared ANSI color constants. */
|
|
4
|
+
const c = {
|
|
5
|
+
green: '\x1b[32m',
|
|
6
|
+
red: '\x1b[31m',
|
|
7
|
+
yellow: '\x1b[33m',
|
|
8
|
+
orange: '\x1b[38;5;208m',
|
|
9
|
+
blue: '\x1b[34m',
|
|
10
|
+
cyan: '\x1b[36m',
|
|
11
|
+
magenta: '\x1b[35m',
|
|
12
|
+
white: '\x1b[97m',
|
|
13
|
+
reset: '\x1b[0m',
|
|
14
|
+
bold: '\x1b[1m',
|
|
15
|
+
dim: '\x1b[2m',
|
|
16
|
+
bg: {
|
|
17
|
+
red: '\x1b[41m',
|
|
18
|
+
green: '\x1b[42m',
|
|
19
|
+
yellow: '\x1b[43m',
|
|
20
|
+
blue: '\x1b[44m',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
module.exports = c;
|