@lateos/npm-scan 0.17.1 → 0.18.1
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/.dockerignore +20 -20
- package/.husky/pre-commit +1 -1
- package/CHANGELOG.md +199 -199
- package/LICENSING.md +19 -19
- package/README.de.md +708 -708
- package/README.fr.md +707 -707
- package/README.ja.md +704 -704
- package/README.md +826 -826
- package/README.zh.md +708 -708
- package/SECURITY.md +72 -72
- package/backend/cra.js +68 -68
- package/backend/db/schema.sql +32 -32
- package/backend/db.js +88 -88
- package/backend/detectors/atk-001-lifecycle.js +17 -17
- package/backend/detectors/atk-002-obfusc.js +261 -261
- package/backend/detectors/atk-003-creds.js +13 -13
- package/backend/detectors/atk-004-persist.js +13 -13
- package/backend/detectors/atk-005-exfil.js +13 -13
- package/backend/detectors/atk-006-depconf.js +14 -14
- package/backend/detectors/atk-007-typosquat.js +34 -34
- package/backend/detectors/atk-008-tarball-tamper.js +91 -91
- package/backend/detectors/atk-009-dormant-trigger.js +62 -62
- package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
- package/backend/detectors/atk-011-transitive-prop.js +76 -76
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
- package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
- package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
- package/backend/detectors/hf-impersonation/index.js +396 -396
- package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
- package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
- package/backend/detectors/hf-impersonation/simhash.js +46 -46
- package/backend/detectors/index.js +75 -75
- package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
- package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
- package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
- package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
- package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
- package/backend/detectors/megalodon/index.js +80 -80
- package/backend/detectors/megalodon/types.js +9 -9
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
- package/backend/detectors/mini-shai-hulud/index.js +118 -118
- package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
- package/backend/fetch.js +175 -175
- package/backend/index.js +4 -4
- package/backend/license.js +89 -89
- package/backend/lockfile.js +379 -379
- package/backend/pdf.js +245 -245
- package/backend/policy.js +193 -193
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- package/backend/siem/cef.js +32 -32
- package/backend/siem/ecs.js +40 -40
- package/backend/siem/index.js +18 -18
- package/backend/siem/qradar.js +56 -56
- package/backend/siem/sentinel.js +27 -27
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
- package/backend/vsix-scan/detectors/burst-publish.js +52 -52
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
- package/backend/vsix-scan/detectors/known-ioc.js +105 -105
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
- package/backend/vsix-scan/index.js +183 -183
- package/backend/vsix-scan/marketplace-client.js +145 -145
- package/backend/vsix-scan/vsix-iocs.json +31 -31
- package/cli/cli.js +458 -458
- package/deploy/helm/npm-scan/Chart.yaml +21 -21
- package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
- package/deploy/helm/npm-scan/templates/api.yaml +93 -93
- package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
- package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
- package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
- package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
- package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
- package/deploy/helm/npm-scan/values.yaml +102 -102
- package/package.json +57 -57
- package/scripts/download-corpus.js +30 -30
- package/scripts/gen-mal-corpus.js +34 -34
- package/test/fixtures/lockfiles/npm-lock.json +68 -68
- package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
- package/test/fixtures/lockfiles/yarn.lock +103 -103
- package/test/fixtures/mock-data.js +69 -69
package/backend/lockfile.js
CHANGED
|
@@ -1,380 +1,380 @@
|
|
|
1
|
-
import { readFileSync } from 'fs';
|
|
2
|
-
import { resolve, dirname } from 'path';
|
|
3
|
-
import yaml from 'js-yaml';
|
|
4
|
-
|
|
5
|
-
export function parseLockfile(filePath, options = {}) {
|
|
6
|
-
const { autoDetect = false } = options;
|
|
7
|
-
try {
|
|
8
|
-
const content = readFileSync(filePath, 'utf8');
|
|
9
|
-
const ext = filePath.split('.').pop().toLowerCase();
|
|
10
|
-
|
|
11
|
-
if (ext === 'json' || ext === 'jsonc') {
|
|
12
|
-
return parseNpmLockfile(content, filePath);
|
|
13
|
-
}
|
|
14
|
-
if (ext === 'lock' && !autoDetect) {
|
|
15
|
-
return parseYarnLockfile(content, filePath);
|
|
16
|
-
}
|
|
17
|
-
if (ext === 'yaml' || ext === 'yml') {
|
|
18
|
-
return parsePnpmLockfile(content, filePath);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (autoDetect) {
|
|
22
|
-
if (content.trimStart().startsWith('{')) {
|
|
23
|
-
return parseNpmLockfile(content, filePath);
|
|
24
|
-
}
|
|
25
|
-
if (content.includes('__metadata')) {
|
|
26
|
-
return parsePnpmLockfile(content, filePath);
|
|
27
|
-
}
|
|
28
|
-
if (content.includes('@npm:') || /^\s*"?[\w@/-]+['"]?\s*,\s*$/m.test(content)) {
|
|
29
|
-
return parseYarnLockfile(content, filePath);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return parseNpmLockfile(content, filePath);
|
|
34
|
-
} catch (e) {
|
|
35
|
-
throw new Error(`Failed to parse lockfile: ${e.message}`);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseNpmLockfile(content, filePath) {
|
|
40
|
-
const lockfile = JSON.parse(content);
|
|
41
|
-
const packages = [];
|
|
42
|
-
|
|
43
|
-
if (lockfile.packages) {
|
|
44
|
-
for (const [key, pkg] of Object.entries(lockfile.packages)) {
|
|
45
|
-
if (key === '') continue;
|
|
46
|
-
const name = pkg.name || key.replace(/^node_modules\//, '').replace(/^[^/]+\//, '');
|
|
47
|
-
packages.push({
|
|
48
|
-
name,
|
|
49
|
-
version: pkg.version || 'unknown',
|
|
50
|
-
resolved: pkg.resolved || '',
|
|
51
|
-
integrity: pkg.integrity || '',
|
|
52
|
-
path: key,
|
|
53
|
-
peerDeps: pkg.peerDependencies || {},
|
|
54
|
-
dev: pkg.dev || false,
|
|
55
|
-
optional: pkg.optional || false,
|
|
56
|
-
scripts: pkg.scripts || {},
|
|
57
|
-
dependencies: pkg.dependencies || {}
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const rootDeps = lockfile.packages?.['node_modules/'] || {};
|
|
63
|
-
return {
|
|
64
|
-
version: lockfile.lockfileVersion,
|
|
65
|
-
packages,
|
|
66
|
-
root: {
|
|
67
|
-
name: rootDeps.name || 'unknown',
|
|
68
|
-
version: rootDeps.version || 'unknown',
|
|
69
|
-
dependencies: rootDeps.dependencies || {},
|
|
70
|
-
devDependencies: rootDeps.devDependencies || {},
|
|
71
|
-
peerDependencies: rootDeps.peerDependencies || {}
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function parseYarnLockfile(content, filePath) {
|
|
77
|
-
const packages = [];
|
|
78
|
-
const lines = content.split('\n');
|
|
79
|
-
let i = 0;
|
|
80
|
-
const n = lines.length;
|
|
81
|
-
|
|
82
|
-
const MULTI_ENTRY_RE = /^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*,\s*"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
|
|
83
|
-
const SINGLE_ENTRY_RE = /^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
|
|
84
|
-
|
|
85
|
-
while (i < n) {
|
|
86
|
-
let line = lines[i].trimEnd();
|
|
87
|
-
|
|
88
|
-
let specs = [];
|
|
89
|
-
|
|
90
|
-
const multiMatch = line.match(MULTI_ENTRY_RE);
|
|
91
|
-
const singleMatch = line.match(SINGLE_ENTRY_RE);
|
|
92
|
-
|
|
93
|
-
if (multiMatch) {
|
|
94
|
-
specs = [
|
|
95
|
-
{ name: multiMatch[1], specVersion: multiMatch[2] },
|
|
96
|
-
{ name: multiMatch[3], specVersion: multiMatch[4] }
|
|
97
|
-
];
|
|
98
|
-
} else if (singleMatch) {
|
|
99
|
-
specs = [{ name: singleMatch[1], specVersion: singleMatch[2] }];
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (specs.length > 0) {
|
|
103
|
-
let version = '';
|
|
104
|
-
let resolved = '';
|
|
105
|
-
let integrity = '';
|
|
106
|
-
const dependencies = {};
|
|
107
|
-
const optionalDependencies = {};
|
|
108
|
-
const peerDependencies = {};
|
|
109
|
-
let dev = false;
|
|
110
|
-
let optional = false;
|
|
111
|
-
|
|
112
|
-
i++;
|
|
113
|
-
while (i < n) {
|
|
114
|
-
const bodyLine = lines[i];
|
|
115
|
-
const bodyTrim = bodyLine.trimEnd();
|
|
116
|
-
|
|
117
|
-
if (bodyTrim === '' || bodyTrim.startsWith('#')) {
|
|
118
|
-
i++;
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (bodyTrim.endsWith(':') && !bodyLine.startsWith(' ')) {
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (bodyTrim.startsWith('version ')) {
|
|
127
|
-
const vMatch = bodyTrim.match(/^version ['"]([^'"]+)['"]/);
|
|
128
|
-
if (vMatch) version = vMatch[1];
|
|
129
|
-
} else if (bodyTrim.match(/^\s*resolved\s+(.+)/)) {
|
|
130
|
-
const rMatch = bodyTrim.match(/^\s*resolved\s+(.+)/);
|
|
131
|
-
if (rMatch) {
|
|
132
|
-
resolved = rMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
|
133
|
-
if (resolved.startsWith('https://registry.yarnpkg.com/')) {
|
|
134
|
-
resolved = resolved.replace('https://registry.yarnpkg.com/', 'https://registry.npmjs.org/');
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
} else if (bodyTrim.startsWith('integrity ')) {
|
|
138
|
-
integrity = bodyTrim.replace('integrity ', '').trim();
|
|
139
|
-
} else if (bodyTrim.startsWith('dependencies')) {
|
|
140
|
-
const m = bodyTrim.match(/^dependencies\s+(.*)/);
|
|
141
|
-
if (m) parseDepList(m[1], dependencies);
|
|
142
|
-
} else if (bodyTrim.startsWith('optionalDependencies')) {
|
|
143
|
-
const m = bodyTrim.match(/^optionalDependencies\s+(.*)/);
|
|
144
|
-
if (m) parseDepList(m[1], optionalDependencies);
|
|
145
|
-
} else if (bodyTrim.startsWith('peerDependencies')) {
|
|
146
|
-
const m = bodyTrim.match(/^peerDependencies\s+(.*)/);
|
|
147
|
-
if (m) parseDepList(m[1], peerDependencies);
|
|
148
|
-
} else if (bodyTrim.match(/^\s*dev\s+(true|false)$/)) {
|
|
149
|
-
dev = bodyTrim.includes('true');
|
|
150
|
-
} else if (bodyTrim.match(/^\s*optional\s+(true|false)$/)) {
|
|
151
|
-
optional = bodyTrim.includes('true');
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
i++;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
for (const { name, specVersion } of specs) {
|
|
158
|
-
packages.push({
|
|
159
|
-
name,
|
|
160
|
-
version: version || specVersion,
|
|
161
|
-
resolved,
|
|
162
|
-
integrity,
|
|
163
|
-
path: `node_modules/${name}`,
|
|
164
|
-
peerDeps: peerDependencies,
|
|
165
|
-
dev,
|
|
166
|
-
optional,
|
|
167
|
-
scripts: {},
|
|
168
|
-
dependencies,
|
|
169
|
-
optionalDependencies
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
} else {
|
|
173
|
-
i++;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const rootDeps = {};
|
|
178
|
-
const rootDevDeps = {};
|
|
179
|
-
|
|
180
|
-
for (const pkg of packages) {
|
|
181
|
-
const topDeps = pkg.dev ? rootDevDeps : rootDeps;
|
|
182
|
-
for (const depName of Object.keys(pkg.dependencies)) {
|
|
183
|
-
topDeps[depName] = pkg.dependencies[depName];
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
version: 2,
|
|
189
|
-
packages,
|
|
190
|
-
root: {
|
|
191
|
-
name: 'root',
|
|
192
|
-
version: 'unknown',
|
|
193
|
-
dependencies: rootDeps,
|
|
194
|
-
devDependencies: rootDevDeps,
|
|
195
|
-
peerDependencies: {}
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function parseDepList(str, dest) {
|
|
201
|
-
const cleaned = str.replace(/^[[\]]/g, '').trim();
|
|
202
|
-
if (!cleaned) return;
|
|
203
|
-
const re = /([\w@./-]+)\s+\^?([\w@./-]+)/g;
|
|
204
|
-
let m;
|
|
205
|
-
while ((m = re.exec(cleaned)) !== null) {
|
|
206
|
-
dest[m[1]] = m[2];
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function parsePnpmLockfile(content, filePath) {
|
|
211
|
-
const lockfile = yaml.load(content);
|
|
212
|
-
const packages = [];
|
|
213
|
-
|
|
214
|
-
if (lockfile.packages) {
|
|
215
|
-
for (const [key, pkg] of Object.entries(lockfile.packages)) {
|
|
216
|
-
const nameMatch = key.match(/^\/(.+?)@([^@/]+)$/);
|
|
217
|
-
if (!nameMatch) continue;
|
|
218
|
-
const name = nameMatch[1];
|
|
219
|
-
const version = nameMatch[2];
|
|
220
|
-
|
|
221
|
-
const resolved = pkg.resolution?.url || '';
|
|
222
|
-
let integrity = '';
|
|
223
|
-
if (pkg.resolution?.integrity) {
|
|
224
|
-
integrity = pkg.resolution.integrity;
|
|
225
|
-
} else if (pkg.resolution?.sha512) {
|
|
226
|
-
integrity = `sha512-${pkg.resolution.sha512}`;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
packages.push({
|
|
230
|
-
name,
|
|
231
|
-
version,
|
|
232
|
-
resolved,
|
|
233
|
-
integrity,
|
|
234
|
-
path: `node_modules/${name}`,
|
|
235
|
-
peerDeps: pkg.peerDependencies || {},
|
|
236
|
-
dev: pkg.dev || false,
|
|
237
|
-
optional: pkg.optional || false,
|
|
238
|
-
scripts: pkg.hasBundledMedia ? { bundled: true } : {},
|
|
239
|
-
dependencies: pkg.dependencies || {},
|
|
240
|
-
optionalDependencies: pkg.optionalDependencies || {}
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const rootDeps = lockfile.importers?.['.'] || lockfile.root || {};
|
|
246
|
-
const rootDepsMap = rootDeps.dependencies || {};
|
|
247
|
-
const rootDevDepsMap = rootDeps.devDependencies || {};
|
|
248
|
-
const rootPeerDepsMap = rootDeps.peerDependencies || {};
|
|
249
|
-
|
|
250
|
-
const version = lockfile.version || (lockfile.lockfileVersion ?? 6);
|
|
251
|
-
|
|
252
|
-
return {
|
|
253
|
-
version,
|
|
254
|
-
packages,
|
|
255
|
-
root: {
|
|
256
|
-
name: 'root',
|
|
257
|
-
version: lockfile.lockfileVersion ? 'unknown' : 'unknown',
|
|
258
|
-
dependencies: rootDepsMap,
|
|
259
|
-
devDependencies: rootDevDepsMap,
|
|
260
|
-
peerDependencies: rootPeerDepsMap
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export function checkMaliciousPatterns(pkg) {
|
|
266
|
-
const findings = [];
|
|
267
|
-
const name = pkg.name?.toLowerCase() || '';
|
|
268
|
-
|
|
269
|
-
const typosquatPatterns = [
|
|
270
|
-
/^(lodash|lodahs|lodash-js|lodashexe)$/,
|
|
271
|
-
/^(axios|axio|ax10s|ax1os)$/,
|
|
272
|
-
/^(react|reakt|reackt|r3act)$/,
|
|
273
|
-
/^(express|expres|expresjs|exress)$/,
|
|
274
|
-
/^(vue|vu3|vujs|vuejs)$/,
|
|
275
|
-
/^(webpack|webpak|webpackjs)$/,
|
|
276
|
-
];
|
|
277
|
-
|
|
278
|
-
for (const pattern of typosquatPatterns) {
|
|
279
|
-
if (pattern.test(name)) {
|
|
280
|
-
findings.push({
|
|
281
|
-
id: 'ATK-007',
|
|
282
|
-
severity: 'high',
|
|
283
|
-
title: 'Typosquat detected',
|
|
284
|
-
description: `Package name "${pkg.name}" is similar to popular packages`,
|
|
285
|
-
evidence: `similar to ${pattern.source}`
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return findings;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function analyzeDependencyGraph(lockfileData) {
|
|
294
|
-
const findings = [];
|
|
295
|
-
const pkgMap = new Map();
|
|
296
|
-
|
|
297
|
-
for (const pkg of lockfileData.packages) {
|
|
298
|
-
pkgMap.set(pkg.name, pkg);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
for (const pkg of lockfileData.packages) {
|
|
302
|
-
if (pkg.peerDeps && Object.keys(pkg.peerDeps).length > 0) {
|
|
303
|
-
for (const [peerName, peerVersion] of Object.entries(pkg.peerDeps)) {
|
|
304
|
-
if (peerName.includes('plugin') || peerName.includes('hook') || peerName.includes('ext')) {
|
|
305
|
-
findings.push({
|
|
306
|
-
id: 'ATK-011',
|
|
307
|
-
severity: 'high',
|
|
308
|
-
title: 'Transitive propagation (worm)',
|
|
309
|
-
description: `Package "${pkg.name}" depends on peer "${peerName}@${peerVersion}" - potential worm propagation chain`,
|
|
310
|
-
evidence: `peer dep chain: ${pkg.name} -> ${peerName}`
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (pkg.dependencies && typeof pkg.dependencies === 'object' && Object.keys(pkg.dependencies).length > 5) {
|
|
317
|
-
const transitiveCount = Object.keys(pkg.dependencies).filter(k => k.includes('/')).length;
|
|
318
|
-
if (transitiveCount > 3) {
|
|
319
|
-
findings.push({
|
|
320
|
-
id: 'ATK-011',
|
|
321
|
-
severity: 'medium',
|
|
322
|
-
title: 'Transitive propagation (worm)',
|
|
323
|
-
description: `Package "${pkg.name}" has excessive transitive dependencies (${transitiveCount} scoped)`,
|
|
324
|
-
evidence: `heavy transitive dep chain: ${pkg.name}`
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (pkg.optionalDependencies && Object.keys(pkg.optionalDependencies).length > 10) {
|
|
330
|
-
findings.push({
|
|
331
|
-
id: 'ATK-011',
|
|
332
|
-
severity: 'low',
|
|
333
|
-
title: 'Transitive propagation (worm)',
|
|
334
|
-
description: `Package "${pkg.name}" has excessive optional dependencies (${Object.keys(pkg.optionalDependencies).length})`,
|
|
335
|
-
evidence: `optional dep chain: ${pkg.name} -> [${Object.keys(pkg.optionalDependencies).slice(0, 3).join(', ')}, ...]`
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return findings;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export function generateLockfileReport(lockfileData) {
|
|
344
|
-
const total = lockfileData.packages.length;
|
|
345
|
-
const dev = lockfileData.packages.filter(p => p.dev).length;
|
|
346
|
-
const optional = lockfileData.packages.filter(p => p.optional).length;
|
|
347
|
-
|
|
348
|
-
const findings = [];
|
|
349
|
-
|
|
350
|
-
for (const pkg of lockfileData.packages) {
|
|
351
|
-
const maliciousFindings = checkMaliciousPatterns(pkg);
|
|
352
|
-
findings.push(...maliciousFindings);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
findings.push(...analyzeDependencyGraph(lockfileData));
|
|
356
|
-
|
|
357
|
-
return {
|
|
358
|
-
scanId: Date.now(),
|
|
359
|
-
package: lockfileData.root.name,
|
|
360
|
-
version: lockfileData.root.version,
|
|
361
|
-
totalDependencies: total,
|
|
362
|
-
devDependencies: dev,
|
|
363
|
-
optionalDependencies: optional,
|
|
364
|
-
lockfileVersion: lockfileData.version,
|
|
365
|
-
findings,
|
|
366
|
-
riskScore: calculateRiskScore(findings)
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function calculateRiskScore(findings) {
|
|
371
|
-
if (!findings.length) return '0.0';
|
|
372
|
-
const weights = { critical: 10, high: 7, medium: 4, low: 2, info: 0.5 };
|
|
373
|
-
const maxSeverity = findings.reduce((max, f) => {
|
|
374
|
-
const w = weights[f.severity] || 0;
|
|
375
|
-
return Math.max(max, w);
|
|
376
|
-
}, 0);
|
|
377
|
-
const countBonus = Math.min(findings.length * 0.3, 3);
|
|
378
|
-
const score = Math.min(maxSeverity + countBonus, 10);
|
|
379
|
-
return score.toFixed(1);
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
export function parseLockfile(filePath, options = {}) {
|
|
6
|
+
const { autoDetect = false } = options;
|
|
7
|
+
try {
|
|
8
|
+
const content = readFileSync(filePath, 'utf8');
|
|
9
|
+
const ext = filePath.split('.').pop().toLowerCase();
|
|
10
|
+
|
|
11
|
+
if (ext === 'json' || ext === 'jsonc') {
|
|
12
|
+
return parseNpmLockfile(content, filePath);
|
|
13
|
+
}
|
|
14
|
+
if (ext === 'lock' && !autoDetect) {
|
|
15
|
+
return parseYarnLockfile(content, filePath);
|
|
16
|
+
}
|
|
17
|
+
if (ext === 'yaml' || ext === 'yml') {
|
|
18
|
+
return parsePnpmLockfile(content, filePath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (autoDetect) {
|
|
22
|
+
if (content.trimStart().startsWith('{')) {
|
|
23
|
+
return parseNpmLockfile(content, filePath);
|
|
24
|
+
}
|
|
25
|
+
if (content.includes('__metadata')) {
|
|
26
|
+
return parsePnpmLockfile(content, filePath);
|
|
27
|
+
}
|
|
28
|
+
if (content.includes('@npm:') || /^\s*"?[\w@/-]+['"]?\s*,\s*$/m.test(content)) {
|
|
29
|
+
return parseYarnLockfile(content, filePath);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return parseNpmLockfile(content, filePath);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
throw new Error(`Failed to parse lockfile: ${e.message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseNpmLockfile(content, filePath) {
|
|
40
|
+
const lockfile = JSON.parse(content);
|
|
41
|
+
const packages = [];
|
|
42
|
+
|
|
43
|
+
if (lockfile.packages) {
|
|
44
|
+
for (const [key, pkg] of Object.entries(lockfile.packages)) {
|
|
45
|
+
if (key === '') continue;
|
|
46
|
+
const name = pkg.name || key.replace(/^node_modules\//, '').replace(/^[^/]+\//, '');
|
|
47
|
+
packages.push({
|
|
48
|
+
name,
|
|
49
|
+
version: pkg.version || 'unknown',
|
|
50
|
+
resolved: pkg.resolved || '',
|
|
51
|
+
integrity: pkg.integrity || '',
|
|
52
|
+
path: key,
|
|
53
|
+
peerDeps: pkg.peerDependencies || {},
|
|
54
|
+
dev: pkg.dev || false,
|
|
55
|
+
optional: pkg.optional || false,
|
|
56
|
+
scripts: pkg.scripts || {},
|
|
57
|
+
dependencies: pkg.dependencies || {}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rootDeps = lockfile.packages?.['node_modules/'] || {};
|
|
63
|
+
return {
|
|
64
|
+
version: lockfile.lockfileVersion,
|
|
65
|
+
packages,
|
|
66
|
+
root: {
|
|
67
|
+
name: rootDeps.name || 'unknown',
|
|
68
|
+
version: rootDeps.version || 'unknown',
|
|
69
|
+
dependencies: rootDeps.dependencies || {},
|
|
70
|
+
devDependencies: rootDeps.devDependencies || {},
|
|
71
|
+
peerDependencies: rootDeps.peerDependencies || {}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseYarnLockfile(content, filePath) {
|
|
77
|
+
const packages = [];
|
|
78
|
+
const lines = content.split('\n');
|
|
79
|
+
let i = 0;
|
|
80
|
+
const n = lines.length;
|
|
81
|
+
|
|
82
|
+
const MULTI_ENTRY_RE = /^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*,\s*"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
|
|
83
|
+
const SINGLE_ENTRY_RE = /^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
|
|
84
|
+
|
|
85
|
+
while (i < n) {
|
|
86
|
+
let line = lines[i].trimEnd();
|
|
87
|
+
|
|
88
|
+
let specs = [];
|
|
89
|
+
|
|
90
|
+
const multiMatch = line.match(MULTI_ENTRY_RE);
|
|
91
|
+
const singleMatch = line.match(SINGLE_ENTRY_RE);
|
|
92
|
+
|
|
93
|
+
if (multiMatch) {
|
|
94
|
+
specs = [
|
|
95
|
+
{ name: multiMatch[1], specVersion: multiMatch[2] },
|
|
96
|
+
{ name: multiMatch[3], specVersion: multiMatch[4] }
|
|
97
|
+
];
|
|
98
|
+
} else if (singleMatch) {
|
|
99
|
+
specs = [{ name: singleMatch[1], specVersion: singleMatch[2] }];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (specs.length > 0) {
|
|
103
|
+
let version = '';
|
|
104
|
+
let resolved = '';
|
|
105
|
+
let integrity = '';
|
|
106
|
+
const dependencies = {};
|
|
107
|
+
const optionalDependencies = {};
|
|
108
|
+
const peerDependencies = {};
|
|
109
|
+
let dev = false;
|
|
110
|
+
let optional = false;
|
|
111
|
+
|
|
112
|
+
i++;
|
|
113
|
+
while (i < n) {
|
|
114
|
+
const bodyLine = lines[i];
|
|
115
|
+
const bodyTrim = bodyLine.trimEnd();
|
|
116
|
+
|
|
117
|
+
if (bodyTrim === '' || bodyTrim.startsWith('#')) {
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (bodyTrim.endsWith(':') && !bodyLine.startsWith(' ')) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (bodyTrim.startsWith('version ')) {
|
|
127
|
+
const vMatch = bodyTrim.match(/^version ['"]([^'"]+)['"]/);
|
|
128
|
+
if (vMatch) version = vMatch[1];
|
|
129
|
+
} else if (bodyTrim.match(/^\s*resolved\s+(.+)/)) {
|
|
130
|
+
const rMatch = bodyTrim.match(/^\s*resolved\s+(.+)/);
|
|
131
|
+
if (rMatch) {
|
|
132
|
+
resolved = rMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
|
133
|
+
if (resolved.startsWith('https://registry.yarnpkg.com/')) {
|
|
134
|
+
resolved = resolved.replace('https://registry.yarnpkg.com/', 'https://registry.npmjs.org/');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} else if (bodyTrim.startsWith('integrity ')) {
|
|
138
|
+
integrity = bodyTrim.replace('integrity ', '').trim();
|
|
139
|
+
} else if (bodyTrim.startsWith('dependencies')) {
|
|
140
|
+
const m = bodyTrim.match(/^dependencies\s+(.*)/);
|
|
141
|
+
if (m) parseDepList(m[1], dependencies);
|
|
142
|
+
} else if (bodyTrim.startsWith('optionalDependencies')) {
|
|
143
|
+
const m = bodyTrim.match(/^optionalDependencies\s+(.*)/);
|
|
144
|
+
if (m) parseDepList(m[1], optionalDependencies);
|
|
145
|
+
} else if (bodyTrim.startsWith('peerDependencies')) {
|
|
146
|
+
const m = bodyTrim.match(/^peerDependencies\s+(.*)/);
|
|
147
|
+
if (m) parseDepList(m[1], peerDependencies);
|
|
148
|
+
} else if (bodyTrim.match(/^\s*dev\s+(true|false)$/)) {
|
|
149
|
+
dev = bodyTrim.includes('true');
|
|
150
|
+
} else if (bodyTrim.match(/^\s*optional\s+(true|false)$/)) {
|
|
151
|
+
optional = bodyTrim.includes('true');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const { name, specVersion } of specs) {
|
|
158
|
+
packages.push({
|
|
159
|
+
name,
|
|
160
|
+
version: version || specVersion,
|
|
161
|
+
resolved,
|
|
162
|
+
integrity,
|
|
163
|
+
path: `node_modules/${name}`,
|
|
164
|
+
peerDeps: peerDependencies,
|
|
165
|
+
dev,
|
|
166
|
+
optional,
|
|
167
|
+
scripts: {},
|
|
168
|
+
dependencies,
|
|
169
|
+
optionalDependencies
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rootDeps = {};
|
|
178
|
+
const rootDevDeps = {};
|
|
179
|
+
|
|
180
|
+
for (const pkg of packages) {
|
|
181
|
+
const topDeps = pkg.dev ? rootDevDeps : rootDeps;
|
|
182
|
+
for (const depName of Object.keys(pkg.dependencies)) {
|
|
183
|
+
topDeps[depName] = pkg.dependencies[depName];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
version: 2,
|
|
189
|
+
packages,
|
|
190
|
+
root: {
|
|
191
|
+
name: 'root',
|
|
192
|
+
version: 'unknown',
|
|
193
|
+
dependencies: rootDeps,
|
|
194
|
+
devDependencies: rootDevDeps,
|
|
195
|
+
peerDependencies: {}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseDepList(str, dest) {
|
|
201
|
+
const cleaned = str.replace(/^[[\]]/g, '').trim();
|
|
202
|
+
if (!cleaned) return;
|
|
203
|
+
const re = /([\w@./-]+)\s+\^?([\w@./-]+)/g;
|
|
204
|
+
let m;
|
|
205
|
+
while ((m = re.exec(cleaned)) !== null) {
|
|
206
|
+
dest[m[1]] = m[2];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parsePnpmLockfile(content, filePath) {
|
|
211
|
+
const lockfile = yaml.load(content);
|
|
212
|
+
const packages = [];
|
|
213
|
+
|
|
214
|
+
if (lockfile.packages) {
|
|
215
|
+
for (const [key, pkg] of Object.entries(lockfile.packages)) {
|
|
216
|
+
const nameMatch = key.match(/^\/(.+?)@([^@/]+)$/);
|
|
217
|
+
if (!nameMatch) continue;
|
|
218
|
+
const name = nameMatch[1];
|
|
219
|
+
const version = nameMatch[2];
|
|
220
|
+
|
|
221
|
+
const resolved = pkg.resolution?.url || '';
|
|
222
|
+
let integrity = '';
|
|
223
|
+
if (pkg.resolution?.integrity) {
|
|
224
|
+
integrity = pkg.resolution.integrity;
|
|
225
|
+
} else if (pkg.resolution?.sha512) {
|
|
226
|
+
integrity = `sha512-${pkg.resolution.sha512}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
packages.push({
|
|
230
|
+
name,
|
|
231
|
+
version,
|
|
232
|
+
resolved,
|
|
233
|
+
integrity,
|
|
234
|
+
path: `node_modules/${name}`,
|
|
235
|
+
peerDeps: pkg.peerDependencies || {},
|
|
236
|
+
dev: pkg.dev || false,
|
|
237
|
+
optional: pkg.optional || false,
|
|
238
|
+
scripts: pkg.hasBundledMedia ? { bundled: true } : {},
|
|
239
|
+
dependencies: pkg.dependencies || {},
|
|
240
|
+
optionalDependencies: pkg.optionalDependencies || {}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const rootDeps = lockfile.importers?.['.'] || lockfile.root || {};
|
|
246
|
+
const rootDepsMap = rootDeps.dependencies || {};
|
|
247
|
+
const rootDevDepsMap = rootDeps.devDependencies || {};
|
|
248
|
+
const rootPeerDepsMap = rootDeps.peerDependencies || {};
|
|
249
|
+
|
|
250
|
+
const version = lockfile.version || (lockfile.lockfileVersion ?? 6);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
version,
|
|
254
|
+
packages,
|
|
255
|
+
root: {
|
|
256
|
+
name: 'root',
|
|
257
|
+
version: lockfile.lockfileVersion ? 'unknown' : 'unknown',
|
|
258
|
+
dependencies: rootDepsMap,
|
|
259
|
+
devDependencies: rootDevDepsMap,
|
|
260
|
+
peerDependencies: rootPeerDepsMap
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function checkMaliciousPatterns(pkg) {
|
|
266
|
+
const findings = [];
|
|
267
|
+
const name = pkg.name?.toLowerCase() || '';
|
|
268
|
+
|
|
269
|
+
const typosquatPatterns = [
|
|
270
|
+
/^(lodash|lodahs|lodash-js|lodashexe)$/,
|
|
271
|
+
/^(axios|axio|ax10s|ax1os)$/,
|
|
272
|
+
/^(react|reakt|reackt|r3act)$/,
|
|
273
|
+
/^(express|expres|expresjs|exress)$/,
|
|
274
|
+
/^(vue|vu3|vujs|vuejs)$/,
|
|
275
|
+
/^(webpack|webpak|webpackjs)$/,
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
for (const pattern of typosquatPatterns) {
|
|
279
|
+
if (pattern.test(name)) {
|
|
280
|
+
findings.push({
|
|
281
|
+
id: 'ATK-007',
|
|
282
|
+
severity: 'high',
|
|
283
|
+
title: 'Typosquat detected',
|
|
284
|
+
description: `Package name "${pkg.name}" is similar to popular packages`,
|
|
285
|
+
evidence: `similar to ${pattern.source}`
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return findings;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function analyzeDependencyGraph(lockfileData) {
|
|
294
|
+
const findings = [];
|
|
295
|
+
const pkgMap = new Map();
|
|
296
|
+
|
|
297
|
+
for (const pkg of lockfileData.packages) {
|
|
298
|
+
pkgMap.set(pkg.name, pkg);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const pkg of lockfileData.packages) {
|
|
302
|
+
if (pkg.peerDeps && Object.keys(pkg.peerDeps).length > 0) {
|
|
303
|
+
for (const [peerName, peerVersion] of Object.entries(pkg.peerDeps)) {
|
|
304
|
+
if (peerName.includes('plugin') || peerName.includes('hook') || peerName.includes('ext')) {
|
|
305
|
+
findings.push({
|
|
306
|
+
id: 'ATK-011',
|
|
307
|
+
severity: 'high',
|
|
308
|
+
title: 'Transitive propagation (worm)',
|
|
309
|
+
description: `Package "${pkg.name}" depends on peer "${peerName}@${peerVersion}" - potential worm propagation chain`,
|
|
310
|
+
evidence: `peer dep chain: ${pkg.name} -> ${peerName}`
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (pkg.dependencies && typeof pkg.dependencies === 'object' && Object.keys(pkg.dependencies).length > 5) {
|
|
317
|
+
const transitiveCount = Object.keys(pkg.dependencies).filter(k => k.includes('/')).length;
|
|
318
|
+
if (transitiveCount > 3) {
|
|
319
|
+
findings.push({
|
|
320
|
+
id: 'ATK-011',
|
|
321
|
+
severity: 'medium',
|
|
322
|
+
title: 'Transitive propagation (worm)',
|
|
323
|
+
description: `Package "${pkg.name}" has excessive transitive dependencies (${transitiveCount} scoped)`,
|
|
324
|
+
evidence: `heavy transitive dep chain: ${pkg.name}`
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (pkg.optionalDependencies && Object.keys(pkg.optionalDependencies).length > 10) {
|
|
330
|
+
findings.push({
|
|
331
|
+
id: 'ATK-011',
|
|
332
|
+
severity: 'low',
|
|
333
|
+
title: 'Transitive propagation (worm)',
|
|
334
|
+
description: `Package "${pkg.name}" has excessive optional dependencies (${Object.keys(pkg.optionalDependencies).length})`,
|
|
335
|
+
evidence: `optional dep chain: ${pkg.name} -> [${Object.keys(pkg.optionalDependencies).slice(0, 3).join(', ')}, ...]`
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return findings;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function generateLockfileReport(lockfileData) {
|
|
344
|
+
const total = lockfileData.packages.length;
|
|
345
|
+
const dev = lockfileData.packages.filter(p => p.dev).length;
|
|
346
|
+
const optional = lockfileData.packages.filter(p => p.optional).length;
|
|
347
|
+
|
|
348
|
+
const findings = [];
|
|
349
|
+
|
|
350
|
+
for (const pkg of lockfileData.packages) {
|
|
351
|
+
const maliciousFindings = checkMaliciousPatterns(pkg);
|
|
352
|
+
findings.push(...maliciousFindings);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
findings.push(...analyzeDependencyGraph(lockfileData));
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
scanId: Date.now(),
|
|
359
|
+
package: lockfileData.root.name,
|
|
360
|
+
version: lockfileData.root.version,
|
|
361
|
+
totalDependencies: total,
|
|
362
|
+
devDependencies: dev,
|
|
363
|
+
optionalDependencies: optional,
|
|
364
|
+
lockfileVersion: lockfileData.version,
|
|
365
|
+
findings,
|
|
366
|
+
riskScore: calculateRiskScore(findings)
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function calculateRiskScore(findings) {
|
|
371
|
+
if (!findings.length) return '0.0';
|
|
372
|
+
const weights = { critical: 10, high: 7, medium: 4, low: 2, info: 0.5 };
|
|
373
|
+
const maxSeverity = findings.reduce((max, f) => {
|
|
374
|
+
const w = weights[f.severity] || 0;
|
|
375
|
+
return Math.max(max, w);
|
|
376
|
+
}, 0);
|
|
377
|
+
const countBonus = Math.min(findings.length * 0.3, 3);
|
|
378
|
+
const score = Math.min(maxSeverity + countBonus, 10);
|
|
379
|
+
return score.toFixed(1);
|
|
380
380
|
}
|