polin-guard 0.2.0 → 0.3.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 CHANGED
@@ -123,20 +123,48 @@ Drop [`scan-injection.sh`](scan-injection.sh) into your repo (works with the
123
123
  ## Usage
124
124
 
125
125
  ```text
126
- polin-guard [options] [paths...]
126
+ polin-guard [scan] [options] [paths...] Scan files for injected payloads (default)
127
+ polin-guard install-audit [--strict] Audit dependencies for supply-chain risk
128
+ polin-guard harden [--fix] Check/enable install-time hardening
127
129
 
130
+ Scan options:
128
131
  --staged Scan staged content (default; for pre-commit hooks; covers --amend)
129
132
  --all Scan all git-tracked files
130
133
  --ci Alias for --all (use in CI)
131
134
  [paths...] Scan specific files (no git required)
132
135
  --strict Treat warnings as blocking too
133
- --quiet Only print on findings
134
- -h, --help Show help
135
- -v, --version
136
+ -h, --help · -v, --version
136
137
 
137
138
  Exit 0 = clean · 1 = blocking finding · 2 = usage error
138
139
  ```
139
140
 
141
+ ## Supply-chain audit (root-cause defense)
142
+
143
+ The scanner catches a payload that's already in your tree. **`install-audit`
144
+ attacks the entry point** — the malicious dependency that runs code at
145
+ `npm install`/build time — without installing anything:
146
+
147
+ ```bash
148
+ npx polin-guard install-audit
149
+ ```
150
+
151
+ It flags, by reading `package.json` and your lockfile:
152
+
153
+ - 🔴 **malicious lifecycle scripts** — `postinstall`/`prepare`/… that pipe the
154
+ network to a shell, `eval`, `node -e`, base64, raw-IP URLs, `child_process`
155
+ - 🔴 **non-default registry** resolutions in the lockfile (registry hijack)
156
+ - 🔴 **typosquat / homoglyph** dependency names (one edit away from popular packages)
157
+ - 🟡 **non-registry sources** (git/http/tarball/file) dependencies
158
+ - ℹ️ a **count of packages that declare install/build scripts** — your real attack surface
159
+
160
+ Then **shut the door** so dependency scripts can't execute at all:
161
+
162
+ ```bash
163
+ npx polin-guard harden --fix # writes ignore-scripts=true to .npmrc
164
+ ```
165
+
166
+ Run `install-audit` in CI too — see [`examples/github-action.yml`](examples/github-action.yml).
167
+
140
168
  ## Configuration
141
169
 
142
170
  Optional `.polinguardrc.json` in your repo root:
package/bin/cli.js CHANGED
@@ -2,17 +2,20 @@
2
2
  'use strict';
3
3
 
4
4
  const { run } = require('../src/scan');
5
+ const { auditInstall, harden } = require('../src/supplychain');
5
6
 
6
- const HELP = `polin-guard — block obfuscated code-injection payloads before they are committed
7
+ const HELP = `polin-guard — stop obfuscated injection & malicious dependencies
7
8
 
8
9
  Usage:
9
- polin-guard [options] [paths...]
10
+ polin-guard [scan] [options] [paths...] Scan files for injected payloads (default)
11
+ polin-guard install-audit [options] Audit dependencies for supply-chain risk
12
+ polin-guard harden [--fix] Check/enable install-time hardening
10
13
 
11
- Modes:
12
- --staged Scan files staged for commit (default; use in pre-commit hooks).
14
+ Scan modes:
15
+ --staged Scan files staged for commit (default; for pre-commit hooks).
13
16
  --all Scan all git-tracked files.
14
17
  --ci Alias for --all (use in CI).
15
- [paths...] Scan specific files/globs (no git required).
18
+ [paths...] Scan specific files (no git required).
16
19
 
17
20
  Options:
18
21
  --strict Treat warnings as blocking (exit non-zero on warnings too).
@@ -21,95 +24,113 @@ Options:
21
24
  -h, --help Show this help.
22
25
  -v, --version Show version.
23
26
 
24
- Exit codes:
25
- 0 clean (or only warnings without --strict)
26
- 1 blocking finding(s) detected
27
- 2 usage / runtime error
27
+ Exit codes: 0 clean · 1 blocking finding(s) · 2 usage/runtime error
28
28
 
29
- Docs & allowlisting: see README. Add a config via .polinguardrc.json.`;
29
+ Docs & allowlisting: see README. Scan config via .polinguardrc.json.`;
30
30
 
31
- function parseArgs(argv) {
32
- const o = { mode: 'staged', paths: [], strict: false, quiet: false, color: true };
31
+ function paint(s, code, enabled) {
32
+ const ESC = String.fromCharCode(27);
33
+ return enabled ? ESC + '[' + code + 'm' + s + ESC + '[0m' : s;
34
+ }
35
+ const useColor = (argv) => !argv.includes('--no-color') && process.stdout.isTTY;
36
+
37
+ /** Shared pretty-printer for a list of {severity, ruleId, message, where|file,line}. */
38
+ function printFindings(title, findings, blocking, c) {
39
+ const header = blocking
40
+ ? paint(`✖ ${title}`, '1;31', c)
41
+ : paint(`⚠ ${title}`, '33', c);
42
+ process.stderr.write(`\n${header}\n\n`);
43
+ for (const f of findings) {
44
+ const tag = f.severity === 'critical' ? paint('CRITICAL', '1;31', c)
45
+ : f.severity === 'warning' ? paint('warning ', '33', c)
46
+ : paint('info ', '36', c);
47
+ const loc = f.where || (f.line === 0 ? `${f.file} (file-level)` : `${f.file}:${f.line}`);
48
+ process.stderr.write(` ${tag} ${paint(loc, '36', c)} [${f.ruleId}]\n ${f.message}\n`);
49
+ }
50
+ process.stderr.write('\n');
51
+ }
52
+
53
+ function cmdScan(argv) {
54
+ const o = { mode: 'staged', paths: [], strict: false, quiet: false };
33
55
  for (const a of argv) {
34
56
  if (a === '--staged') o.mode = 'staged';
35
57
  else if (a === '--all' || a === '--ci') o.mode = 'all';
36
58
  else if (a === '--strict') o.strict = true;
37
59
  else if (a === '--quiet') o.quiet = true;
38
- else if (a === '--no-color') o.color = false;
39
- else if (a === '-h' || a === '--help') o.help = true;
40
- else if (a === '-v' || a === '--version') o.version = true;
41
- else if (a.startsWith('-')) { o.unknown = a; }
60
+ else if (a === '--no-color') { /* handled globally */ }
61
+ else if (a.startsWith('-')) { process.stderr.write(`polin-guard: unknown option ${a}\n`); return 2; }
42
62
  else { o.paths.push(a); o.mode = 'paths'; }
43
63
  }
44
- return o;
45
- }
64
+ const c = useColor(argv);
46
65
 
47
- function paint(s, code, enabled) {
48
- const ESC = String.fromCharCode(27);
49
- return enabled ? ESC + "[" + code + "m" + s + ESC + "[0m" : s;
50
- }
51
-
52
- function main() {
53
- const o = parseArgs(process.argv.slice(2));
54
- const c = o.color && process.stdout.isTTY;
66
+ let res;
67
+ try { res = run({ mode: o.mode, paths: o.paths, strict: o.strict }); }
68
+ catch (e) { process.stderr.write(`polin-guard: ${e.message}\n`); return 2; }
55
69
 
56
- if (o.help) { process.stdout.write(HELP + '\n'); return 0; }
57
- if (o.version) {
58
- try { process.stdout.write(require('../package.json').version + '\n'); } catch { process.stdout.write('0.0.0\n'); }
70
+ if (res.findings.length === 0) {
71
+ if (!o.quiet) process.stdout.write(paint('✓ polin-guard: no injection indicators found', '32', c) +
72
+ ` (${res.filesScanned} file${res.filesScanned === 1 ? '' : 's'} scanned)\n`);
59
73
  return 0;
60
74
  }
61
- if (o.unknown) { process.stderr.write(`polin-guard: unknown option ${o.unknown}\n`); return 2; }
75
+ printFindings('polin-guard: potential code injection detected', res.findings, res.blocking, c);
76
+ if (res.blocking) {
77
+ process.stderr.write(
78
+ 'Commit blocked. If this is a genuine attack, do NOT commit — investigate the file.\n' +
79
+ 'Verified false positive? Use a "// polinguard-allow-line" comment or .polinguardrc.json.\n' +
80
+ '(Bypass for one commit: git commit --no-verify.)\n');
81
+ return 1;
82
+ }
83
+ process.stderr.write('Warnings only — not blocking. Use --strict to block on warnings.\n');
84
+ return 0;
85
+ }
62
86
 
87
+ function cmdAudit(argv) {
88
+ const strict = argv.includes('--strict');
89
+ const quiet = argv.includes('--quiet');
90
+ const c = useColor(argv);
63
91
  let res;
64
- try {
65
- res = run({ mode: o.mode, paths: o.paths, strict: o.strict });
66
- } catch (e) {
67
- process.stderr.write(`polin-guard: ${e.message}\n`);
68
- return 2;
69
- }
92
+ try { res = auditInstall(process.cwd()); }
93
+ catch (e) { process.stderr.write(`polin-guard: ${e.message}\n`); return 2; }
70
94
 
95
+ const blocking = strict ? (res.critical.length + res.warnings.length) > 0 : res.critical.length > 0;
71
96
  if (res.findings.length === 0) {
72
- if (!o.quiet) {
73
- process.stdout.write(paint('✓ polin-guard: no injection indicators found', '32', c) +
74
- ` (${res.filesScanned} file${res.filesScanned === 1 ? '' : 's'} scanned)\n`);
75
- }
97
+ if (!quiet) process.stdout.write(paint('✓ polin-guard: no supply-chain risks found', '32', c) + '\n');
76
98
  return 0;
77
99
  }
100
+ printFindings('polin-guard: supply-chain risks detected', res.findings, blocking, c);
101
+ process.stderr.write(`${res.critical.length} critical, ${res.warnings.length} warning(s). ` +
102
+ `Run \`polin-guard harden --fix\` to block install scripts.\n`);
103
+ return blocking ? 1 : 0;
104
+ }
78
105
 
79
- const header = res.blocking
80
- ? paint('✖ polin-guard: potential code injection detected', '1;31', c)
81
- : paint('⚠ polin-guard: review-worthy findings', '33', c);
82
- process.stderr.write(`\n${header}\n\n`);
106
+ function cmdHarden(argv) {
107
+ const fix = argv.includes('--fix');
108
+ const c = useColor(argv);
109
+ let r;
110
+ try { r = harden(process.cwd(), { fix }); }
111
+ catch (e) { process.stderr.write(`polin-guard: ${e.message}\n`); return 2; }
83
112
 
84
- // Group findings by file.
85
- const byFile = new Map();
86
- for (const f of res.findings) {
87
- if (!byFile.has(f.file)) byFile.set(f.file, []);
88
- byFile.get(f.file).push(f);
89
- }
90
- for (const [file, items] of byFile) {
91
- process.stderr.write(paint(file, '36', c) + '\n');
92
- for (const it of items) {
93
- const tag = it.severity === 'critical'
94
- ? paint('CRITICAL', '1;31', c)
95
- : paint('warning ', '33', c);
96
- const loc = it.line === 0 ? `${file} (file-level)` : `${file}:${it.line}`;
97
- process.stderr.write(` ${tag} ${loc} [${it.ruleId}]\n ${it.message}\n`);
98
- }
99
- process.stderr.write('\n');
100
- }
113
+ if (r.hasIgnore) process.stdout.write(paint('✓ ignore-scripts is already enabled', '32', c) + ` (${r.npmrc})\n`);
114
+ else if (r.applied) process.stdout.write(paint('✓ enabled ignore-scripts=true', '32', c) + ` in ${r.npmrc}\n`);
115
+ else process.stdout.write(paint('⚠ ignore-scripts is NOT enabled', '33', c) + ` — run \`polin-guard harden --fix\` to set it.\n`);
101
116
 
102
- if (res.blocking) {
103
- process.stderr.write(
104
- 'Commit blocked. If this is a genuine attack, do NOT commit — investigate the file.\n' +
105
- 'If this is a verified false positive, acknowledge the line with a\n' +
106
- `"// polinguard-allow-line" comment, an "polinguard-allow-next-line" comment above it,\n` +
107
- 'or adjust .polinguardrc.json. (Bypass for one commit: git commit --no-verify.)\n'
108
- );
109
- return 1;
110
- }
111
- process.stderr.write('Warnings only — not blocking. Use --strict to block on warnings.\n');
117
+ process.stdout.write('\nRecommendations:\n');
118
+ for (const rec of r.recommendations) process.stdout.write(` • ${rec}\n`);
112
119
  return 0;
113
120
  }
114
121
 
122
+ function main() {
123
+ const argv = process.argv.slice(2);
124
+ if (argv.includes('-h') || argv.includes('--help')) { process.stdout.write(HELP + '\n'); return 0; }
125
+ if (argv.includes('-v') || argv.includes('--version')) {
126
+ try { process.stdout.write(require('../package.json').version + '\n'); } catch { process.stdout.write('0.0.0\n'); }
127
+ return 0;
128
+ }
129
+ const sub = argv[0];
130
+ if (sub === 'install-audit' || sub === 'audit') return cmdAudit(argv.slice(1));
131
+ if (sub === 'harden') return cmdHarden(argv.slice(1));
132
+ if (sub === 'scan') return cmdScan(argv.slice(1));
133
+ return cmdScan(argv); // default: scan
134
+ }
135
+
115
136
  process.exit(main());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polin-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Block obfuscated build/commit-time code-injection payloads (hidden long-line JS stagers) before they enter your repo. Zero dependencies. Works as a pre-commit hook, in CI, or standalone.",
5
5
  "bin": {
6
6
  "polin-guard": "bin/cli.js"
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Supply-chain layer for polin-guard.
5
+ *
6
+ * The scanner (scan.js) catches a payload that is already in your tree. This
7
+ * module attacks the *root cause*: a malicious dependency that executes code at
8
+ * `npm install` / build time. It reads manifests and lockfiles WITHOUT installing
9
+ * anything, and flags the vectors that actually deliver these attacks:
10
+ *
11
+ * - lifecycle install scripts (pre/post-install, prepare, …) — how install-time
12
+ * code runs at all — and especially scripts that pipe the network to a shell
13
+ * - dependencies pulled from non-registry sources (git/http/tarball/file)
14
+ * - packages resolved from a non-default registry (registry hijack)
15
+ * - transitive packages that declare an install/build script (the real surface)
16
+ * - typosquatted / homoglyph package names
17
+ *
18
+ * `harden()` flips on the defenses (ignore-scripts) so install scripts can't run.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const LIFECYCLE = [
25
+ 'preinstall', 'install', 'postinstall',
26
+ 'preuninstall', 'postuninstall',
27
+ 'prepare', 'prepublish', 'prepublishOnly', 'prepack', 'postpack',
28
+ ];
29
+
30
+ // Commands inside a script that strongly indicate malicious install-time code.
31
+ const SUSPICIOUS_SCRIPT = new RegExp(
32
+ [
33
+ '(?:curl|wget)\\s+[^|&;]*\\|\\s*(?:sh|bash|node|python)', // curl … | sh
34
+ '\\bnode\\s+(?:-e|--eval)\\b', // node -e "…"
35
+ '\\bbash\\s+-c\\b',
36
+ 'base64\\s+(?:-d|--decode|-D)',
37
+ '\\beval\\b',
38
+ '\\b(?:powershell|iwr|Invoke-WebRequest|certutil)\\b',
39
+ '\\bchild_process\\b',
40
+ 'https?:\\/\\/\\d{1,3}(?:\\.\\d{1,3}){3}', // raw IP URL
41
+ '\\b(?:atob|fromCharCode)\\b',
42
+ '\\/dev\\/tcp\\/',
43
+ ].join('|'),
44
+ 'i'
45
+ );
46
+
47
+ // A small set of very popular packages, for typosquat distance checks.
48
+ const POPULAR = [
49
+ 'react', 'react-dom', 'lodash', 'express', 'chalk', 'axios', 'commander',
50
+ 'webpack', 'vue', 'next', 'nuxt', 'vite', 'eslint', 'prettier', 'typescript',
51
+ 'tailwindcss', 'dotenv', 'jest', 'babel', 'rollup', 'moment', 'dayjs',
52
+ 'request', 'debug', 'colors', 'cross-env', 'node-fetch', 'uuid',
53
+ ];
54
+
55
+ const DEFAULT_REGISTRY_HOSTS = new Set(['registry.npmjs.org']);
56
+
57
+ function levenshtein(a, b) {
58
+ const m = a.length, n = b.length;
59
+ const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
60
+ for (let j = 0; j <= n; j++) d[0][j] = j;
61
+ for (let i = 1; i <= m; i++) {
62
+ for (let j = 1; j <= n; j++) {
63
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
64
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
65
+ }
66
+ }
67
+ return d[m][n];
68
+ }
69
+
70
+ function readJson(p) {
71
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
72
+ }
73
+
74
+ function allowedRegistryHosts(cwd) {
75
+ const hosts = new Set(DEFAULT_REGISTRY_HOSTS);
76
+ for (const p of [path.join(cwd, '.npmrc'), path.join(require('os').homedir(), '.npmrc')]) {
77
+ try {
78
+ const txt = fs.readFileSync(p, 'utf8');
79
+ const m = txt.match(/^\s*registry\s*=\s*(\S+)/m);
80
+ if (m) { try { hosts.add(new URL(m[1]).host); } catch { /* ignore */ } }
81
+ } catch { /* no .npmrc */ }
82
+ }
83
+ return hosts;
84
+ }
85
+
86
+ function hostOf(url) {
87
+ try { return new URL(url.replace(/^git\+/, '')).host; } catch { return null; }
88
+ }
89
+
90
+ function isNonRegistrySpec(spec) {
91
+ return /^(?:git\+|git:|github:|gitlab:|bitbucket:|https?:|file:|link:|portal:)/.test(spec) ||
92
+ /^[\w.-]+\/[\w.-]+(?:#.*)?$/.test(spec); // github user/repo shorthand
93
+ }
94
+
95
+ /** Audit package.json scripts + dependency sources. */
96
+ function auditManifest(cwd, findings) {
97
+ const pkgPath = path.join(cwd, 'package.json');
98
+ const pkg = readJson(pkgPath);
99
+ if (!pkg) { findings.push({ severity: 'info', ruleId: 'no-manifest', where: 'package.json', message: 'No readable package.json found.' }); return null; }
100
+
101
+ const scripts = pkg.scripts || {};
102
+ for (const [name, body] of Object.entries(scripts)) {
103
+ const isLifecycle = LIFECYCLE.includes(name);
104
+ if (SUSPICIOUS_SCRIPT.test(String(body))) {
105
+ findings.push({ severity: 'critical', ruleId: 'malicious-script', where: `package.json scripts.${name}`,
106
+ message: `Script "${name}" runs a suspicious command (network-to-shell / eval / encoded): ${String(body).slice(0, 120)}` });
107
+ } else if (isLifecycle) {
108
+ findings.push({ severity: 'warning', ruleId: 'install-script', where: `package.json scripts.${name}`,
109
+ message: `This package defines a lifecycle install script "${name}" — it will run on install.` });
110
+ }
111
+ }
112
+
113
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
114
+ const deps = pkg[field] || {};
115
+ for (const [name, spec] of Object.entries(deps)) {
116
+ if (typeof spec === 'string' && isNonRegistrySpec(spec)) {
117
+ findings.push({ severity: 'warning', ruleId: 'non-registry-source', where: `package.json ${field}.${name}`,
118
+ message: `Dependency "${name}" is installed from a non-registry source: ${spec}` });
119
+ }
120
+ // typosquat / homoglyph
121
+ if (/[^\x00-\x7f]/.test(name)) {
122
+ findings.push({ severity: 'critical', ruleId: 'homoglyph-name', where: `package.json ${field}.${name}`,
123
+ message: `Dependency name "${name}" contains non-ASCII characters (possible homoglyph squat).` });
124
+ } else {
125
+ const base = name.replace(/^@[^/]+\//, '');
126
+ for (const pop of POPULAR) {
127
+ if (base !== pop && Math.abs(base.length - pop.length) <= 1 && levenshtein(base, pop) === 1) {
128
+ findings.push({ severity: 'critical', ruleId: 'typosquat', where: `package.json ${field}.${name}`,
129
+ message: `Dependency "${name}" is one character away from popular package "${pop}" (possible typosquat).` });
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return pkg;
137
+ }
138
+
139
+ /** Audit lockfiles for non-registry resolutions and install-script packages. */
140
+ function auditLockfiles(cwd, findings) {
141
+ const allowed = allowedRegistryHosts(cwd);
142
+ let installScriptPkgs = 0;
143
+
144
+ // npm: package-lock.json (v2/v3)
145
+ const lock = readJson(path.join(cwd, 'package-lock.json'));
146
+ if (lock && lock.packages) {
147
+ for (const [loc, info] of Object.entries(lock.packages)) {
148
+ if (!loc) continue;
149
+ if (info.hasInstallScript) installScriptPkgs++;
150
+ const resolved = info.resolved;
151
+ if (resolved && /^https?:/.test(resolved)) {
152
+ const h = hostOf(resolved);
153
+ if (h && !allowed.has(h)) {
154
+ findings.push({ severity: 'critical', ruleId: 'foreign-registry', where: `package-lock.json ${loc}`,
155
+ message: `Package resolved from a non-default registry/host "${h}": ${resolved}` });
156
+ }
157
+ } else if (resolved && /^git\+|^git:/.test(resolved)) {
158
+ findings.push({ severity: 'warning', ruleId: 'git-source', where: `package-lock.json ${loc}`,
159
+ message: `Package installed from a git source: ${resolved}` });
160
+ }
161
+ }
162
+ }
163
+
164
+ // pnpm: pnpm-lock.yaml (line scan — no YAML dependency)
165
+ try {
166
+ const txt = fs.readFileSync(path.join(cwd, 'pnpm-lock.yaml'), 'utf8');
167
+ const lines = txt.split(/\r?\n/);
168
+ for (let i = 0; i < lines.length; i++) {
169
+ const l = lines[i];
170
+ if (/^\s*requiresBuild:\s*true/.test(l)) installScriptPkgs++;
171
+ const tb = l.match(/tarball:\s*(\S+)/);
172
+ if (tb) {
173
+ const h = hostOf(tb[1]);
174
+ if (h && !allowed.has(h)) {
175
+ findings.push({ severity: 'critical', ruleId: 'foreign-registry', where: 'pnpm-lock.yaml',
176
+ message: `Package tarball from a non-default host "${h}": ${tb[1]}` });
177
+ }
178
+ }
179
+ if (/resolution:\s*\{[^}]*\b(repo|commit)\b/.test(l)) {
180
+ findings.push({ severity: 'warning', ruleId: 'git-source', where: 'pnpm-lock.yaml',
181
+ message: `Package resolved from a git source: ${l.trim().slice(0, 100)}` });
182
+ }
183
+ }
184
+ } catch { /* no pnpm-lock */ }
185
+
186
+ // yarn: yarn.lock
187
+ try {
188
+ const txt = fs.readFileSync(path.join(cwd, 'yarn.lock'), 'utf8');
189
+ for (const m of txt.matchAll(/resolved\s+"([^"]+)"/g)) {
190
+ const h = hostOf(m[1]);
191
+ if (h && /^https?:/.test(m[1]) && !allowed.has(h)) {
192
+ findings.push({ severity: 'critical', ruleId: 'foreign-registry', where: 'yarn.lock',
193
+ message: `Package resolved from a non-default host "${h}": ${m[1]}` });
194
+ }
195
+ }
196
+ } catch { /* no yarn.lock */ }
197
+
198
+ if (installScriptPkgs > 0) {
199
+ findings.push({ severity: 'info', ruleId: 'install-script-count', where: 'lockfile',
200
+ message: `${installScriptPkgs} installed package(s) declare an install/build script. Run \`polin-guard harden\` to block them with ignore-scripts.` });
201
+ }
202
+ }
203
+
204
+ /** Run the full supply-chain audit. */
205
+ function auditInstall(cwd) {
206
+ const findings = [];
207
+ auditManifest(cwd, findings);
208
+ auditLockfiles(cwd, findings);
209
+ const critical = findings.filter((f) => f.severity === 'critical');
210
+ const warnings = findings.filter((f) => f.severity === 'warning');
211
+ return { findings, critical, warnings, blocking: critical.length > 0 };
212
+ }
213
+
214
+ /** Check (and optionally apply) install-time hardening for this project. */
215
+ function harden(cwd, { fix = false } = {}) {
216
+ const npmrc = path.join(cwd, '.npmrc');
217
+ let current = '';
218
+ try { current = fs.readFileSync(npmrc, 'utf8'); } catch { /* none */ }
219
+ const hasIgnore = /^\s*ignore-scripts\s*=\s*true\s*$/m.test(current);
220
+
221
+ const result = { hasIgnore, applied: false, npmrc, recommendations: [] };
222
+ if (!hasIgnore) {
223
+ result.recommendations.push('Set `ignore-scripts=true` in .npmrc to stop dependency install scripts from executing.');
224
+ if (fix) {
225
+ const next = (current.replace(/\s*$/, '') + '\nignore-scripts=true\n').replace(/^\n/, '');
226
+ fs.writeFileSync(npmrc, next);
227
+ result.applied = true;
228
+ }
229
+ }
230
+ result.recommendations.push('Install with a frozen lockfile: `npm ci` / `pnpm install --frozen-lockfile`.');
231
+ result.recommendations.push('Pin the registry and review lockfile diffs in code review.');
232
+ return result;
233
+ }
234
+
235
+ module.exports = { auditInstall, harden, levenshtein, isNonRegistrySpec, SUSPICIOUS_SCRIPT, LIFECYCLE };