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 +32 -4
- package/bin/cli.js +92 -71
- package/package.json +1 -1
- package/src/supplychain.js +235 -0
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
|
-
--
|
|
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 —
|
|
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
|
-
|
|
12
|
-
--staged Scan files staged for commit (default;
|
|
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
|
|
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.
|
|
29
|
+
Docs & allowlisting: see README. Scan config via .polinguardrc.json.`;
|
|
30
30
|
|
|
31
|
-
function
|
|
32
|
-
const
|
|
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')
|
|
39
|
-
else if (a
|
|
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
|
-
|
|
45
|
-
}
|
|
64
|
+
const c = useColor(argv);
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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.
|
|
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 };
|