np-audit 0.0.1-beta
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 +212 -0
- package/bin/npa.js +3 -0
- package/package.json +33 -0
- package/src/aware.js +183 -0
- package/src/cli.js +262 -0
- package/src/config.js +71 -0
- package/src/detector.js +235 -0
- package/src/fetcher.js +129 -0
- package/src/lockfile.js +107 -0
- package/src/output.js +92 -0
- package/src/scanner.js +331 -0
- package/src/tarball.js +117 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.npmauditor.json');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
10
|
+
blockScore: 7,
|
|
11
|
+
warnScore: 4,
|
|
12
|
+
registry: 'https://registry.npmjs.org',
|
|
13
|
+
timeout: 30000,
|
|
14
|
+
parallelFetches: 5,
|
|
15
|
+
skipScopes: [],
|
|
16
|
+
skipPackages: [],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const VALID_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
|
20
|
+
|
|
21
|
+
function readJSON(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadConfig(cwd) {
|
|
30
|
+
const base = { ...DEFAULT_CONFIG };
|
|
31
|
+
const global_ = readJSON(GLOBAL_CONFIG_PATH) || {};
|
|
32
|
+
const local = cwd ? readJSON(path.join(cwd, '.npmauditor.json')) || {} : {};
|
|
33
|
+
return Object.assign(base, coerce(global_), coerce(local));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function coerce(obj) {
|
|
37
|
+
const result = {};
|
|
38
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
39
|
+
if (!VALID_KEYS.has(key)) continue;
|
|
40
|
+
const def = DEFAULT_CONFIG[key];
|
|
41
|
+
if (Array.isArray(def)) {
|
|
42
|
+
result[key] = Array.isArray(val) ? val : [val];
|
|
43
|
+
} else if (typeof def === 'number') {
|
|
44
|
+
const n = Number(val);
|
|
45
|
+
if (!isNaN(n)) result[key] = n;
|
|
46
|
+
} else {
|
|
47
|
+
result[key] = val;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setGlobalConfig(key, rawValue) {
|
|
54
|
+
if (!VALID_KEYS.has(key)) {
|
|
55
|
+
throw new Error(`Unknown config key "${key}". Valid keys: ${[...VALID_KEYS].join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
const current = readJSON(GLOBAL_CONFIG_PATH) || {};
|
|
58
|
+
const patch = coerce({ [key]: rawValue });
|
|
59
|
+
if (Object.keys(patch).length === 0) {
|
|
60
|
+
throw new Error(`Invalid value "${rawValue}" for key "${key}"`);
|
|
61
|
+
}
|
|
62
|
+
const updated = Object.assign(current, patch);
|
|
63
|
+
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
64
|
+
return updated;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getGlobalConfigPath() {
|
|
68
|
+
return GLOBAL_CONFIG_PATH;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG, VALID_KEYS };
|
package/src/detector.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─── Individual detection checks ─────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect eval / dynamic code execution.
|
|
7
|
+
* @param {string} code
|
|
8
|
+
* @returns {Finding|null}
|
|
9
|
+
*/
|
|
10
|
+
function checkEval(code) {
|
|
11
|
+
const patterns = [
|
|
12
|
+
/\beval\s*\(/,
|
|
13
|
+
/new\s+Function\s*\(/,
|
|
14
|
+
/vm\.runInThisContext\s*\(/,
|
|
15
|
+
/vm\.runInNewContext\s*\(/,
|
|
16
|
+
/vm\.Script\s*\(/,
|
|
17
|
+
];
|
|
18
|
+
const matched = patterns.filter(p => p.test(code));
|
|
19
|
+
if (matched.length === 0) return null;
|
|
20
|
+
return { name: 'eval/dynamic-exec', score: 8, detail: `eval-like call found (${matched.length} pattern(s))` };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect obfuscator.io signature: _0x variable naming.
|
|
25
|
+
* @param {string} code
|
|
26
|
+
* @returns {Finding|null}
|
|
27
|
+
*/
|
|
28
|
+
function checkObfuscatorIo(code) {
|
|
29
|
+
const matches = code.match(/_0x[0-9a-fA-F]+/g) || [];
|
|
30
|
+
if (matches.length < 3) return null;
|
|
31
|
+
return { name: 'obfuscator.io', score: 9, detail: `${matches.length} _0x identifiers found` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detect high-entropy strings (likely encoded/encrypted payloads).
|
|
36
|
+
* @param {string} code
|
|
37
|
+
* @returns {Finding|null}
|
|
38
|
+
*/
|
|
39
|
+
function checkHighEntropy(code) {
|
|
40
|
+
// Extract string literals (single, double, template)
|
|
41
|
+
const stringRe = /(?:"([^"\\]|\\.){50,}"|'([^'\\]|\\.){50,}'|`([^`\\]|\\.){50,}`)/g;
|
|
42
|
+
let match;
|
|
43
|
+
let maxEntropy = 0;
|
|
44
|
+
let worst = '';
|
|
45
|
+
while ((match = stringRe.exec(code)) !== null) {
|
|
46
|
+
const s = match[0].slice(1, -1);
|
|
47
|
+
const e = shannonEntropy(s);
|
|
48
|
+
if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
|
|
49
|
+
}
|
|
50
|
+
if (maxEntropy < 4.5) return null;
|
|
51
|
+
return {
|
|
52
|
+
name: 'high-entropy-string',
|
|
53
|
+
score: 6,
|
|
54
|
+
detail: `Entropy ${maxEntropy.toFixed(2)} in string "${worst}…"`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Detect dense hex escape sequences (\x41).
|
|
60
|
+
* @param {string} code
|
|
61
|
+
* @returns {Finding|null}
|
|
62
|
+
*/
|
|
63
|
+
function checkHexEscapes(code) {
|
|
64
|
+
const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
|
|
65
|
+
if (hexMatches < 10) return null;
|
|
66
|
+
return { name: 'hex-escape-density', score: 5, detail: `${hexMatches} \\xNN hex escapes found` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detect String.fromCharCode with many numeric arguments.
|
|
71
|
+
* @param {string} code
|
|
72
|
+
* @returns {Finding|null}
|
|
73
|
+
*/
|
|
74
|
+
function checkFromCharCode(code) {
|
|
75
|
+
const re = /String\.fromCharCode\s*\(([^)]+)\)/g;
|
|
76
|
+
let match;
|
|
77
|
+
let maxArgs = 0;
|
|
78
|
+
while ((match = re.exec(code)) !== null) {
|
|
79
|
+
const args = match[1].split(',').filter(a => /^\s*\d+\s*$/.test(a));
|
|
80
|
+
if (args.length > maxArgs) maxArgs = args.length;
|
|
81
|
+
}
|
|
82
|
+
if (maxArgs < 5) return null;
|
|
83
|
+
return { name: 'fromCharCode', score: 7, detail: `String.fromCharCode with ${maxArgs} numeric args` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Detect base64 decode combined with eval-like execution.
|
|
88
|
+
* @param {string} code
|
|
89
|
+
* @returns {Finding|null}
|
|
90
|
+
*/
|
|
91
|
+
function checkBase64Exec(code) {
|
|
92
|
+
const hasBase64 = /atob\s*\(|Buffer\.from\s*\([^)]*,\s*['"]base64['"]\)/.test(code);
|
|
93
|
+
const hasExec = /eval\s*\(|new\s+Function\s*\(|\.exec\s*\(/.test(code);
|
|
94
|
+
if (!hasBase64) return null;
|
|
95
|
+
if (hasBase64 && !hasExec) {
|
|
96
|
+
return { name: 'base64-decode', score: 3, detail: 'Base64 decode found — verify usage' };
|
|
97
|
+
}
|
|
98
|
+
return { name: 'base64-decode+exec', score: 8, detail: 'Base64 decode with code execution found' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Detect child_process / shell execution patterns.
|
|
103
|
+
* @param {string} code
|
|
104
|
+
* @returns {Finding|null}
|
|
105
|
+
*/
|
|
106
|
+
function checkChildProcess(code) {
|
|
107
|
+
const patterns = [
|
|
108
|
+
/require\s*\(\s*['"]child_process['"]\s*\)/,
|
|
109
|
+
/\bexec\s*\(/,
|
|
110
|
+
/\bspawn\s*\(/,
|
|
111
|
+
/\bexecSync\s*\(/,
|
|
112
|
+
/\bspawnSync\s*\(/,
|
|
113
|
+
/\bexecFile\s*\(/,
|
|
114
|
+
];
|
|
115
|
+
const matched = patterns.filter(p => p.test(code));
|
|
116
|
+
if (matched.length === 0) return null;
|
|
117
|
+
return { name: 'child-process', score: 5, detail: `Shell execution found (${matched.length} pattern(s))` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Detect large hex literal arrays (common in minified obfuscated code).
|
|
122
|
+
* @param {string} code
|
|
123
|
+
* @returns {Finding|null}
|
|
124
|
+
*/
|
|
125
|
+
function checkHexArray(code) {
|
|
126
|
+
// Count 0x1234-style literals
|
|
127
|
+
const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
|
|
128
|
+
if (hexLiterals < 20) return null;
|
|
129
|
+
return { name: 'hex-array', score: 7, detail: `${hexLiterals} hex literal values found` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Detect process.env access (potential credential exfiltration signal).
|
|
134
|
+
* @param {string} code
|
|
135
|
+
* @returns {Finding|null}
|
|
136
|
+
*/
|
|
137
|
+
function checkProcessEnv(code) {
|
|
138
|
+
const matches = (code.match(/process\.env\b/g) || []).length;
|
|
139
|
+
if (matches === 0) return null;
|
|
140
|
+
return { name: 'process-env', score: 3, detail: `${matches} process.env access(es)` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Detect suspicious network calls (data exfiltration).
|
|
145
|
+
* @param {string} code
|
|
146
|
+
* @returns {Finding|null}
|
|
147
|
+
*/
|
|
148
|
+
function checkNetworkCalls(code) {
|
|
149
|
+
const patterns = [
|
|
150
|
+
/require\s*\(\s*['"]https?['"]\s*\)/,
|
|
151
|
+
/require\s*\(\s*['"]net['"]\s*\)/,
|
|
152
|
+
/require\s*\(\s*['"]dns['"]\s*\)/,
|
|
153
|
+
/fetch\s*\(/,
|
|
154
|
+
/XMLHttpRequest/,
|
|
155
|
+
/\.request\s*\(/,
|
|
156
|
+
];
|
|
157
|
+
const matched = patterns.filter(p => p.test(code));
|
|
158
|
+
if (matched.length === 0) return null;
|
|
159
|
+
return { name: 'network-call', score: 4, detail: `Network call found (${matched.length} pattern(s))` };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Entropy helper ──────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function shannonEntropy(str) {
|
|
165
|
+
if (!str || str.length === 0) return 0;
|
|
166
|
+
const freq = {};
|
|
167
|
+
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
168
|
+
let entropy = 0;
|
|
169
|
+
for (const count of Object.values(freq)) {
|
|
170
|
+
const p = count / str.length;
|
|
171
|
+
entropy -= p * Math.log2(p);
|
|
172
|
+
}
|
|
173
|
+
return entropy;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Main detection function ─────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const CHECKS = [
|
|
179
|
+
checkEval,
|
|
180
|
+
checkObfuscatorIo,
|
|
181
|
+
checkHighEntropy,
|
|
182
|
+
checkHexEscapes,
|
|
183
|
+
checkFromCharCode,
|
|
184
|
+
checkBase64Exec,
|
|
185
|
+
checkChildProcess,
|
|
186
|
+
checkHexArray,
|
|
187
|
+
checkProcessEnv,
|
|
188
|
+
checkNetworkCalls,
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run all checks against a code string.
|
|
193
|
+
* @param {string} code
|
|
194
|
+
* @param {object} config { blockScore, warnScore }
|
|
195
|
+
* @returns {{ score: number, findings: Finding[], verdict: 'BLOCK'|'WARN'|'OK' }}
|
|
196
|
+
*/
|
|
197
|
+
function detectObfuscation(code, config = { blockScore: 7, warnScore: 4 }) {
|
|
198
|
+
if (!code || typeof code !== 'string') {
|
|
199
|
+
return { score: 0, findings: [], verdict: 'OK' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const findings = [];
|
|
203
|
+
for (const check of CHECKS) {
|
|
204
|
+
const result = check(code);
|
|
205
|
+
if (result) findings.push(result);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Score = highest individual finding score (weighted max — avoid double-penalizing)
|
|
209
|
+
const score = findings.length > 0
|
|
210
|
+
? Math.max(...findings.map(f => f.score))
|
|
211
|
+
: 0;
|
|
212
|
+
|
|
213
|
+
let verdict;
|
|
214
|
+
if (score >= config.blockScore) verdict = 'BLOCK';
|
|
215
|
+
else if (score >= config.warnScore) verdict = 'WARN';
|
|
216
|
+
else verdict = 'OK';
|
|
217
|
+
|
|
218
|
+
return { score, findings, verdict };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
detectObfuscation,
|
|
223
|
+
shannonEntropy,
|
|
224
|
+
// Export individual checks for testing
|
|
225
|
+
checkEval,
|
|
226
|
+
checkObfuscatorIo,
|
|
227
|
+
checkHighEntropy,
|
|
228
|
+
checkHexEscapes,
|
|
229
|
+
checkFromCharCode,
|
|
230
|
+
checkBase64Exec,
|
|
231
|
+
checkChildProcess,
|
|
232
|
+
checkHexArray,
|
|
233
|
+
checkProcessEnv,
|
|
234
|
+
checkNetworkCalls,
|
|
235
|
+
};
|
package/src/fetcher.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const url = require('url');
|
|
7
|
+
|
|
8
|
+
const MAX_REDIRECTS = 5;
|
|
9
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Perform an HTTP/HTTPS GET and collect the response body as a Buffer.
|
|
13
|
+
* Follows redirects up to MAX_REDIRECTS.
|
|
14
|
+
* @param {string} rawUrl
|
|
15
|
+
* @param {object} opts { timeout?, headers? }
|
|
16
|
+
* @returns {Promise<Buffer>}
|
|
17
|
+
*/
|
|
18
|
+
function fetch(rawUrl, opts = {}) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let redirects = 0;
|
|
21
|
+
|
|
22
|
+
function request(target) {
|
|
23
|
+
const parsed = new url.URL(target);
|
|
24
|
+
const isHttps = parsed.protocol === 'https:';
|
|
25
|
+
const lib = isHttps ? https : http;
|
|
26
|
+
|
|
27
|
+
const reqOpts = {
|
|
28
|
+
hostname: parsed.hostname,
|
|
29
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
30
|
+
path: parsed.pathname + parsed.search,
|
|
31
|
+
method: 'GET',
|
|
32
|
+
headers: Object.assign({
|
|
33
|
+
'User-Agent': 'npa/1.0.0 (npm-auditor)',
|
|
34
|
+
'Accept': '*/*',
|
|
35
|
+
}, opts.headers || {}),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const timeout = opts.timeout || DEFAULT_TIMEOUT;
|
|
39
|
+
const req = lib.request(reqOpts, (res) => {
|
|
40
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
41
|
+
if (++redirects > MAX_REDIRECTS) {
|
|
42
|
+
return reject(new Error(`Too many redirects for ${rawUrl}`));
|
|
43
|
+
}
|
|
44
|
+
res.resume();
|
|
45
|
+
return request(res.headers.location);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (res.statusCode !== 200) {
|
|
49
|
+
res.resume();
|
|
50
|
+
return reject(new Error(`HTTP ${res.statusCode} for ${target}`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const chunks = [];
|
|
54
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
55
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
56
|
+
res.on('error', reject);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
req.setTimeout(timeout, () => {
|
|
60
|
+
req.destroy(new Error(`Request timed out after ${timeout}ms: ${target}`));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
req.on('error', reject);
|
|
64
|
+
req.end();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
request(rawUrl);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fetch a tarball as a Buffer.
|
|
73
|
+
* @param {string} tarballUrl
|
|
74
|
+
* @param {object} opts { timeout? }
|
|
75
|
+
* @returns {Promise<Buffer>}
|
|
76
|
+
*/
|
|
77
|
+
function fetchTarball(tarballUrl, opts = {}) {
|
|
78
|
+
return fetch(tarballUrl, opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch JSON from npm registry.
|
|
83
|
+
* @param {string} jsonUrl
|
|
84
|
+
* @param {object} opts { timeout? }
|
|
85
|
+
* @returns {Promise<object>}
|
|
86
|
+
*/
|
|
87
|
+
async function fetchJSON(jsonUrl, opts = {}) {
|
|
88
|
+
const buf = await fetch(jsonUrl, {
|
|
89
|
+
...opts,
|
|
90
|
+
headers: { 'Accept': 'application/json' },
|
|
91
|
+
});
|
|
92
|
+
return JSON.parse(buf.toString('utf8'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the canonical tarball URL when the lockfile resolved field is missing.
|
|
97
|
+
* @param {string} name package name (may be scoped)
|
|
98
|
+
* @param {string} version
|
|
99
|
+
* @param {string} registry base URL e.g. 'https://registry.npmjs.org'
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function buildTarballUrl(name, version, registry) {
|
|
103
|
+
const base = registry.replace(/\/$/, '');
|
|
104
|
+
if (name.startsWith('@')) {
|
|
105
|
+
// Scoped: @scope/pkg → @scope/pkg/-/@scope/pkg-version.tgz
|
|
106
|
+
const [scope, pkg] = name.split('/');
|
|
107
|
+
const encoded = encodeURIComponent(scope) + '%2F' + pkg;
|
|
108
|
+
return `${base}/${encoded}/-/${pkg}-${version}.tgz`;
|
|
109
|
+
}
|
|
110
|
+
return `${base}/${name}/-/${name}-${version}.tgz`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verify a tarball Buffer against an integrity string from the lockfile.
|
|
115
|
+
* Supports sha512-<base64> and sha1-<base64>.
|
|
116
|
+
* @param {Buffer} buffer
|
|
117
|
+
* @param {string} integrity e.g. "sha512-abc123=="
|
|
118
|
+
* @returns {boolean} true if valid or integrity is empty
|
|
119
|
+
*/
|
|
120
|
+
function verifyIntegrity(buffer, integrity) {
|
|
121
|
+
if (!integrity) return true;
|
|
122
|
+
const match = integrity.match(/^(sha512|sha1)-(.+)$/);
|
|
123
|
+
if (!match) return true;
|
|
124
|
+
const [, algo, expected] = match;
|
|
125
|
+
const actual = crypto.createHash(algo).update(buffer).digest('base64');
|
|
126
|
+
return actual === expected;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { fetchTarball, fetchJSON, buildTarballUrl, verifyIntegrity };
|
package/src/lockfile.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse package-lock.json (v1, v2, v3) and return a normalized flat array of packages.
|
|
8
|
+
* @param {string} cwd directory containing package-lock.json
|
|
9
|
+
* @returns {{ lockfileVersion: number, packages: PackageDescriptor[] }}
|
|
10
|
+
*/
|
|
11
|
+
function parseLockfile(cwd) {
|
|
12
|
+
const lockPath = path.join(cwd, 'package-lock.json');
|
|
13
|
+
if (!fs.existsSync(lockPath)) {
|
|
14
|
+
throw new Error(`package-lock.json not found in ${cwd}. Run "npm install" first or use "npa install".`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let lockData;
|
|
18
|
+
try {
|
|
19
|
+
lockData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
20
|
+
} catch (e) {
|
|
21
|
+
throw new Error(`Failed to parse package-lock.json: ${e.message}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const version = lockData.lockfileVersion || 1;
|
|
25
|
+
let packages;
|
|
26
|
+
|
|
27
|
+
if (version >= 2 && lockData.packages) {
|
|
28
|
+
packages = flattenV2(lockData.packages);
|
|
29
|
+
} else if (lockData.dependencies) {
|
|
30
|
+
packages = flattenV1(lockData.dependencies);
|
|
31
|
+
} else {
|
|
32
|
+
packages = [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { lockfileVersion: version, packages };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Flatten v2/v3 lockfile packages object (key → descriptor).
|
|
40
|
+
* @param {object} pkgsObj
|
|
41
|
+
* @returns {PackageDescriptor[]}
|
|
42
|
+
*/
|
|
43
|
+
function flattenV2(pkgsObj) {
|
|
44
|
+
const result = [];
|
|
45
|
+
for (const [key, entry] of Object.entries(pkgsObj)) {
|
|
46
|
+
if (key === '') continue; // root package
|
|
47
|
+
if (entry.link) continue; // workspace symlink
|
|
48
|
+
|
|
49
|
+
const name = nameFromKey(key);
|
|
50
|
+
if (!name) continue;
|
|
51
|
+
|
|
52
|
+
result.push({
|
|
53
|
+
name,
|
|
54
|
+
version: entry.version || '',
|
|
55
|
+
resolved: entry.resolved || '',
|
|
56
|
+
integrity: entry.integrity || '',
|
|
57
|
+
hasInstallScript: entry.hasInstallScript === true,
|
|
58
|
+
dev: entry.dev === true,
|
|
59
|
+
optional: entry.optional === true,
|
|
60
|
+
inBundle: entry.inBundle === true,
|
|
61
|
+
link: false,
|
|
62
|
+
_lockKey: key,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Recursively flatten v1 lockfile dependencies tree.
|
|
70
|
+
* @param {object} deps
|
|
71
|
+
* @param {PackageDescriptor[]} result
|
|
72
|
+
* @returns {PackageDescriptor[]}
|
|
73
|
+
*/
|
|
74
|
+
function flattenV1(deps, result = []) {
|
|
75
|
+
for (const [name, entry] of Object.entries(deps || {})) {
|
|
76
|
+
result.push({
|
|
77
|
+
name,
|
|
78
|
+
version: entry.version || '',
|
|
79
|
+
resolved: entry.resolved || '',
|
|
80
|
+
integrity: entry.integrity || '',
|
|
81
|
+
hasInstallScript: false, // not indicated in v1 — must check
|
|
82
|
+
dev: entry.dev === true,
|
|
83
|
+
optional: entry.optional === true,
|
|
84
|
+
inBundle: entry.bundled === true,
|
|
85
|
+
link: false,
|
|
86
|
+
_lockKey: `node_modules/${name}`,
|
|
87
|
+
});
|
|
88
|
+
if (entry.dependencies) {
|
|
89
|
+
flattenV1(entry.dependencies, result);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract the npm package name from a v2/v3 lockfile key.
|
|
97
|
+
* Examples:
|
|
98
|
+
* "node_modules/express" → "express"
|
|
99
|
+
* "node_modules/@babel/core" → "@babel/core"
|
|
100
|
+
* "foo/node_modules/bar" → "bar"
|
|
101
|
+
*/
|
|
102
|
+
function nameFromKey(key) {
|
|
103
|
+
const match = key.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)$/);
|
|
104
|
+
return match ? match[1] : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { parseLockfile, nameFromKey };
|
package/src/output.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const RESET = '\x1b[0m';
|
|
4
|
+
const BOLD = '\x1b[1m';
|
|
5
|
+
const DIM = '\x1b[2m';
|
|
6
|
+
const RED = '\x1b[31m';
|
|
7
|
+
const GREEN = '\x1b[32m';
|
|
8
|
+
const YELLOW = '\x1b[33m';
|
|
9
|
+
const BLUE = '\x1b[34m';
|
|
10
|
+
const CYAN = '\x1b[36m';
|
|
11
|
+
const WHITE = '\x1b[37m';
|
|
12
|
+
const BG_RED = '\x1b[41m';
|
|
13
|
+
const BG_YELLOW = '\x1b[43m';
|
|
14
|
+
|
|
15
|
+
const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR || process.env.CI;
|
|
16
|
+
|
|
17
|
+
function c(code, text) {
|
|
18
|
+
if (NO_COLOR) return text;
|
|
19
|
+
return `${code}${text}${RESET}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function bold(text) { return c(BOLD, text); }
|
|
23
|
+
function dim(text) { return c(DIM, text); }
|
|
24
|
+
function red(text) { return c(RED, text); }
|
|
25
|
+
function green(text) { return c(GREEN, text); }
|
|
26
|
+
function yellow(text) { return c(YELLOW, text); }
|
|
27
|
+
function blue(text) { return c(BLUE, text); }
|
|
28
|
+
function cyan(text) { return c(CYAN, text); }
|
|
29
|
+
function white(text) { return c(WHITE, text); }
|
|
30
|
+
|
|
31
|
+
function error(msg) {
|
|
32
|
+
process.stderr.write(red(`✖ ${msg}`) + '\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function warn(msg) {
|
|
36
|
+
process.stderr.write(yellow(`⚠ ${msg}`) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function info(msg) {
|
|
40
|
+
process.stdout.write(cyan(`ℹ ${msg}`) + '\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function success(msg) {
|
|
44
|
+
process.stdout.write(green(`✔ ${msg}`) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function log(msg) {
|
|
48
|
+
process.stdout.write(msg + '\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function verdictBadge(verdict) {
|
|
52
|
+
if (NO_COLOR) return `[${verdict}]`;
|
|
53
|
+
if (verdict === 'BLOCK') return `${BG_RED}${BOLD} BLOCK ${RESET}`;
|
|
54
|
+
if (verdict === 'WARN') return `${BG_YELLOW}\x1b[30m WARN ${RESET}`;
|
|
55
|
+
return `${GREEN} OK ${RESET}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printScanHeader() {
|
|
59
|
+
log('');
|
|
60
|
+
log(bold(cyan('npa') + ' — npm package auditor'));
|
|
61
|
+
log(dim('Static obfuscation detection for install scripts'));
|
|
62
|
+
log(dim('─'.repeat(60)));
|
|
63
|
+
log('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function printPackageResult(pkg, result) {
|
|
67
|
+
const badge = verdictBadge(result.verdict);
|
|
68
|
+
const name = bold(`${pkg.name}@${pkg.version}`);
|
|
69
|
+
const score = result.score > 0 ? dim(` (score: ${result.score})`) : '';
|
|
70
|
+
log(` ${badge} ${name}${score}`);
|
|
71
|
+
for (const finding of result.findings) {
|
|
72
|
+
log(` ${dim('└')} ${yellow(finding.name)}: ${dim(finding.detail)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function printSummary(results) {
|
|
77
|
+
const blocked = results.filter(r => r.verdict === 'BLOCK').length;
|
|
78
|
+
const warned = results.filter(r => r.verdict === 'WARN').length;
|
|
79
|
+
const ok = results.filter(r => r.verdict === 'OK').length;
|
|
80
|
+
|
|
81
|
+
log('');
|
|
82
|
+
log(dim('─'.repeat(60)));
|
|
83
|
+
log(` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`);
|
|
84
|
+
log('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
bold, dim, red, green, yellow, blue, cyan, white,
|
|
89
|
+
error, warn, info, success, log,
|
|
90
|
+
verdictBadge, printScanHeader, printPackageResult, printSummary,
|
|
91
|
+
RESET, BOLD, DIM, RED, GREEN, YELLOW, BLUE, CYAN,
|
|
92
|
+
};
|