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 ADDED
@@ -0,0 +1,402 @@
1
+ # 🛡️ pnpm-shield
2
+
3
+ > **Supply chain attack protection audit tool for pnpm projects.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/pnpm-shield.svg)](https://www.npmjs.com/package/pnpm-shield)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+ [![Zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen)](package.json)
9
+ [![GitHub](https://img.shields.io/badge/github-manuxstack%2Fpnpm--shield-181717?logo=github)](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;