npm-scan-plus 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +32 -0
- package/.github/CODEOWNERS +3 -0
- package/.github/workflows/ci.yml +105 -0
- package/.prettierrc +10 -0
- package/FUNDING.yml +1 -0
- package/PLAN.md +151 -0
- package/README.md +150 -0
- package/bin/npm-scan +13 -0
- package/bin/npm-scan-wrap +100 -0
- package/dist/cli/index.d.ts +18 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +299 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/lib/blocklist.d.ts +45 -0
- package/dist/lib/blocklist.d.ts.map +1 -0
- package/dist/lib/blocklist.js +256 -0
- package/dist/lib/blocklist.js.map +1 -0
- package/dist/lib/extended.js +314 -0
- package/dist/lib/extended.js.map +1 -0
- package/dist/lib/integrity.js +247 -0
- package/dist/lib/integrity.js.map +1 -0
- package/dist/lib/patterns.d.ts +76 -0
- package/dist/lib/patterns.d.ts.map +1 -0
- package/dist/lib/patterns.js +414 -0
- package/dist/lib/patterns.js.map +1 -0
- package/dist/lib/registry.d.ts +42 -0
- package/dist/lib/registry.d.ts.map +1 -0
- package/dist/lib/registry.js +157 -0
- package/dist/lib/registry.js.map +1 -0
- package/dist/lib/scanner.d.ts +43 -0
- package/dist/lib/scanner.d.ts.map +1 -0
- package/dist/lib/scanner.js +432 -0
- package/dist/lib/scanner.js.map +1 -0
- package/dist/lib/vuln.js +284 -0
- package/dist/lib/vuln.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/jest.config.js +18 -0
- package/package.json +56 -0
- package/src/cli/index.ts +336 -0
- package/src/lib/blocklist.ts +239 -0
- package/src/lib/extended.ts +384 -0
- package/src/lib/integrity.ts +253 -0
- package/src/lib/patterns.ts +404 -0
- package/src/lib/registry.ts +146 -0
- package/src/lib/scanner.ts +447 -0
- package/src/lib/vuln.ts +321 -0
- package/src/types.ts +102 -0
- package/tests/blocklist.test.ts +89 -0
- package/tests/extended.test.ts +204 -0
- package/tests/patterns.test.ts +147 -0
- package/tests/scanner.test.ts +116 -0
- package/tests/vuln.test.ts +66 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended security analysis module
|
|
3
|
+
* Additional checks: license, repo validation, maintainer trust, download anomalies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
|
|
9
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
10
|
+
|
|
11
|
+
/** License risk classification */
|
|
12
|
+
const LICENSE_RISK = {
|
|
13
|
+
// High risk - requires source sharing or has legal issues
|
|
14
|
+
'gpl-3.0': { level: 'high', reason: 'GPLv3 - copyleft, may affect proprietary code' },
|
|
15
|
+
'gpl-2.0': { level: 'medium', reason: 'GPLv2 - copyleft' },
|
|
16
|
+
'agpl-3.0': { level: 'high', reason: 'AGPLv3 - strong copyleft, SaaS exposure' },
|
|
17
|
+
'lgpl-3.0': { level: 'medium', reason: 'LGPLv3 - more permissive than GPL' },
|
|
18
|
+
'mpl-2.0': { level: 'low', reason: 'Mozilla Public License' },
|
|
19
|
+
' EPL-2.0': { level: 'low', reason: 'Eclipse Public License' },
|
|
20
|
+
'eupl-1.2': { level: 'medium', reason: 'European Union Public License' },
|
|
21
|
+
// Low risk - permissive
|
|
22
|
+
'mit': { level: 'low', reason: 'Permissive' },
|
|
23
|
+
'bsd': { level: 'low', reason: 'Permissive' },
|
|
24
|
+
'apache-2.0': { level: 'low', reason: 'Permissive' },
|
|
25
|
+
'isc': { level: 'low', reason: 'Permissive' },
|
|
26
|
+
'unlicense': { level: 'low', reason: 'Public domain' },
|
|
27
|
+
// Suspicious - requires investigation
|
|
28
|
+
'custom': { level: 'medium', reason: 'Custom license - review required' },
|
|
29
|
+
'none': { level: 'high', reason: 'No license - copyright issues' },
|
|
30
|
+
'proprietary': { level: 'high', reason: 'Proprietary - may restrict use' }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Known package maintainers with trust scores */
|
|
34
|
+
const TRUSTED_MAINTAINERS = new Set([
|
|
35
|
+
// Popular package maintainers
|
|
36
|
+
'ljharb', // Jordan Harband (lodash)
|
|
37
|
+
'sindresorhus', // Sindre Sohrus
|
|
38
|
+
'jdalton', // John-David Dalton (lodash creator)
|
|
39
|
+
'substack',
|
|
40
|
+
'juliangruber',
|
|
41
|
+
'rvagg',
|
|
42
|
+
'addaleax',
|
|
43
|
+
'bnb',
|
|
44
|
+
'fy',
|
|
45
|
+
'watson',
|
|
46
|
+
'leorom',
|
|
47
|
+
'd3',
|
|
48
|
+
'mbostock',
|
|
49
|
+
'mikaelbr',
|
|
50
|
+
'analog',
|
|
51
|
+
// Large organizations
|
|
52
|
+
'facebook',
|
|
53
|
+
'google',
|
|
54
|
+
'microsoft',
|
|
55
|
+
'amazon',
|
|
56
|
+
'netflix',
|
|
57
|
+
'stripe',
|
|
58
|
+
'paypal',
|
|
59
|
+
'twitter',
|
|
60
|
+
'uber'
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Suspicious TLDs for typosquatting detection */
|
|
64
|
+
const SUSPICIOUS_TLDS = ['xyz', 'app', 'dev', 'io', 'co', 'sh', 'js', 'cn', 'ru'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Analyze license risk
|
|
68
|
+
*/
|
|
69
|
+
export function analyzeLicense(license: string | undefined): {
|
|
70
|
+
risk: 'low' | 'medium' | 'high' | 'critical';
|
|
71
|
+
details: string;
|
|
72
|
+
permissive: boolean;
|
|
73
|
+
} {
|
|
74
|
+
if (!license) {
|
|
75
|
+
return {
|
|
76
|
+
risk: 'high',
|
|
77
|
+
details: 'No license specified - copyright issues',
|
|
78
|
+
permissive: false
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Normalize license string
|
|
83
|
+
const normalized = license.toString().replace(/^(\d+\.)+/, '').trim().toLowerCase();
|
|
84
|
+
const known = LICENSE_RISK[normalized as keyof typeof LICENSE_RISK];
|
|
85
|
+
|
|
86
|
+
if (known) {
|
|
87
|
+
return {
|
|
88
|
+
risk: known.level as any,
|
|
89
|
+
details: known.reason,
|
|
90
|
+
permissive: known.level === 'low'
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check for OR patterns (multiple licenses)
|
|
95
|
+
if (license.includes(' OR ')) {
|
|
96
|
+
return {
|
|
97
|
+
risk: 'medium',
|
|
98
|
+
details: 'Multiple licenses - review required',
|
|
99
|
+
permissive: false
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
risk: 'low',
|
|
105
|
+
details: 'Unknown but present',
|
|
106
|
+
permissive: true
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if maintainer is trusted/known
|
|
112
|
+
*/
|
|
113
|
+
export function checkMaintainerTrust(maintainers: any[], publisher: any): {
|
|
114
|
+
score: number;
|
|
115
|
+
isTrusted: boolean;
|
|
116
|
+
details: string;
|
|
117
|
+
} {
|
|
118
|
+
const allAuthors = [...(maintainers || []), publisher].filter(Boolean);
|
|
119
|
+
const names = allAuthors.map(m => m.username || m.name).filter(Boolean);
|
|
120
|
+
|
|
121
|
+
if (names.length === 0) {
|
|
122
|
+
return {
|
|
123
|
+
score: 0,
|
|
124
|
+
isTrusted: false,
|
|
125
|
+
details: 'No maintainers identified'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check if any trusted maintainer
|
|
130
|
+
const trustedCount = names.filter(n => TRUSTED_MAINTAINERS.has(n.toLowerCase())).length;
|
|
131
|
+
const trustRatio = trustedCount / names.length;
|
|
132
|
+
|
|
133
|
+
if (trustRatio > 0.5) {
|
|
134
|
+
return {
|
|
135
|
+
score: 100,
|
|
136
|
+
isTrusted: true,
|
|
137
|
+
details: `${trustedCount} trusted maintainer(s): ${names.join(', ')}`
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (trustedCount > 0) {
|
|
142
|
+
return {
|
|
143
|
+
score: 50,
|
|
144
|
+
isTrusted: false,
|
|
145
|
+
details: `Some trusted maintainer(s): ${names.join(', ')}`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
score: 20,
|
|
151
|
+
isTrusted: false,
|
|
152
|
+
details: `Unknown maintainer(s): ${names.join(', ')}`
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Validate repository URL exists and matches package
|
|
158
|
+
*/
|
|
159
|
+
export async function validateRepository(
|
|
160
|
+
repoUrl: string | undefined,
|
|
161
|
+
packageName: string
|
|
162
|
+
): Promise<{
|
|
163
|
+
valid: boolean;
|
|
164
|
+
details: string;
|
|
165
|
+
issues: string[];
|
|
166
|
+
}> {
|
|
167
|
+
const issues: string[] = [];
|
|
168
|
+
|
|
169
|
+
if (!repoUrl) {
|
|
170
|
+
return {
|
|
171
|
+
valid: false,
|
|
172
|
+
details: 'No repository URL',
|
|
173
|
+
issues: ['missing repository']
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Parse different repo formats
|
|
178
|
+
let normalizedUrl = repoUrl;
|
|
179
|
+
|
|
180
|
+
// Handle shorthand
|
|
181
|
+
if (!repoUrl.startsWith('http')) {
|
|
182
|
+
if (repoUrl.startsWith('github:')) {
|
|
183
|
+
normalizedUrl = 'https://' + repoUrl.replace('github:', '');
|
|
184
|
+
} else if (repoUrl.startsWith('gist:')) {
|
|
185
|
+
normalizedUrl = 'https://gist.github.com/' + repoUrl.replace('gist:', '');
|
|
186
|
+
} else if (repoUrl.startsWith('bitbucket:')) {
|
|
187
|
+
normalizedUrl = 'https://bitbucket.org/' + repoUrl.replace('bitbucket:', '');
|
|
188
|
+
} else if (repoUrl.startsWith('gitlab:')) {
|
|
189
|
+
normalizedUrl = 'https://gitlab.com/' + repoUrl.replace('gitlab:', '');
|
|
190
|
+
} else {
|
|
191
|
+
normalizedUrl = 'https://' + repoUrl;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Extract owner/repo for GitHub
|
|
196
|
+
const githubMatch = normalizedUrl.match(/github\.com[/:]([\w-]+)\/([\w-.]+)/);
|
|
197
|
+
if (githubMatch) {
|
|
198
|
+
const [, owner, repo] = githubMatch;
|
|
199
|
+
const cleanRepo = repo.replace(/\.git$/, '');
|
|
200
|
+
|
|
201
|
+
// Check if repo name roughly matches package name
|
|
202
|
+
const expectedPackage = packageName.replace(/^@[\w-]+\//, ''); // Handle scoped packages
|
|
203
|
+
const nameMatch = cleanRepo.toLowerCase() === expectedPackage.toLowerCase() ||
|
|
204
|
+
cleanRepo.toLowerCase() === expectedPackage.replace(/[-_]/g, '').toLowerCase();
|
|
205
|
+
|
|
206
|
+
if (!nameMatch) {
|
|
207
|
+
issues.push(`repo name "${cleanRepo}" != package "${expectedPackage}"`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check for suspicious domains
|
|
212
|
+
const url = new URL(normalizedUrl);
|
|
213
|
+
if (SUSPICIOUS_TLDS.includes(url.hostname.split('.').pop() || '')) {
|
|
214
|
+
issues.push(`suspicious TLD: ${url.hostname}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
valid: issues.length === 0,
|
|
219
|
+
details: normalizedUrl,
|
|
220
|
+
issues
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check for release anomalies (sudden popularity spike = typosquatting indicator)
|
|
226
|
+
*/
|
|
227
|
+
export async function checkReleaseAnomalies(
|
|
228
|
+
packageName: string,
|
|
229
|
+
metadata: any
|
|
230
|
+
): Promise<{
|
|
231
|
+
suspicious: boolean;
|
|
232
|
+
details: string;
|
|
233
|
+
metrics: any;
|
|
234
|
+
}> {
|
|
235
|
+
const metrics: any = {
|
|
236
|
+
versionCount: 0,
|
|
237
|
+
hasNewVersions: false,
|
|
238
|
+
rapidRelease: false
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
// Fetch full package data
|
|
243
|
+
const response = await fetch(`${NPM_REGISTRY}/${packageName}`);
|
|
244
|
+
const data: any = await response.json();
|
|
245
|
+
|
|
246
|
+
const versions = Object.keys(data.versions || {});
|
|
247
|
+
metrics.versionCount = versions.length;
|
|
248
|
+
|
|
249
|
+
// Check for recent rapid releases (potential automated publishing)
|
|
250
|
+
if (data.time) {
|
|
251
|
+
const timeKeys = Object.keys(data.time).filter(k => k !== 'created' && k !== 'modified');
|
|
252
|
+
if (timeKeys.length > 1) {
|
|
253
|
+
const sorted = timeKeys.sort((a, b) =>
|
|
254
|
+
new Date(data.time[a]).getTime() - new Date(data.time[b]).getTime()
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Check if multiple releases within hours
|
|
258
|
+
if (sorted.length >= 2) {
|
|
259
|
+
const lastRelease = new Date(data.time[sorted[sorted.length - 1]]);
|
|
260
|
+
const prevRelease = new Date(data.time[sorted[sorted.length - 2]]);
|
|
261
|
+
const hoursApart = (lastRelease.getTime() - prevRelease.getTime()) / (1000 * 60 * 60);
|
|
262
|
+
|
|
263
|
+
if (hoursApart < 1) {
|
|
264
|
+
metrics.rapidRelease = true;
|
|
265
|
+
metrics.hoursApart = hoursApart;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch (e) {
|
|
271
|
+
// Ignore - just for metrics
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const suspicious = metrics.rapidRelease;
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
suspicious,
|
|
278
|
+
details: suspicious ? 'Unusual rapid release pattern' : 'Normal release pattern',
|
|
279
|
+
metrics
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Analyze dependencies for freshness and known issues
|
|
285
|
+
*/
|
|
286
|
+
export function analyzeDependencies(
|
|
287
|
+
dependencies: Record<string, string> | undefined,
|
|
288
|
+
devDependencies: Record<string, string> | undefined
|
|
289
|
+
): {
|
|
290
|
+
outdatedCount: number;
|
|
291
|
+
issues: string[];
|
|
292
|
+
details: string[];
|
|
293
|
+
} {
|
|
294
|
+
const allDeps = { ...dependencies, ...devDependencies };
|
|
295
|
+
const issues: string[] = [];
|
|
296
|
+
const details: string[] = [];
|
|
297
|
+
|
|
298
|
+
if (!allDeps || Object.keys(allDeps).length === 0) {
|
|
299
|
+
return { outdatedCount: 0, issues: [], details: [] };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check for very old/deprecated packages
|
|
303
|
+
const deprecated = [
|
|
304
|
+
'request', // Deprecated in favor of fetch, axios
|
|
305
|
+
'moment', // Deprecated in favor of date-fns, dayjs
|
|
306
|
+
'lodash', // Should use lodash-es for tree-shaking
|
|
307
|
+
'querystring', // Built into Node.js
|
|
308
|
+
'underscore' // Use lodash or native
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
for (const [dep, ver] of Object.entries(allDeps)) {
|
|
312
|
+
if (deprecated.includes(dep)) {
|
|
313
|
+
issues.push(`${dep} is deprecated or not recommended`);
|
|
314
|
+
details.push(`${dep}@${ver}: consider alternatives`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Check for very old major versions still in use
|
|
318
|
+
if (ver.startsWith('^0.') || ver.startsWith('~0.')) {
|
|
319
|
+
issues.push(`${dep} using old major version`);
|
|
320
|
+
details.push(`${dep}@${ver}: may have vulnerabilities`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
outdatedCount: issues.length,
|
|
326
|
+
issues,
|
|
327
|
+
details
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Check file structure for suspicious patterns
|
|
333
|
+
*/
|
|
334
|
+
export function analyzeFileStructure(files: string[]): {
|
|
335
|
+
suspicious: boolean;
|
|
336
|
+
issues: string[];
|
|
337
|
+
} {
|
|
338
|
+
const issues: string[] = [];
|
|
339
|
+
|
|
340
|
+
// Check for hidden files that shouldn't be there
|
|
341
|
+
const suspiciousFiles = [
|
|
342
|
+
/^\./,
|
|
343
|
+
/\.sh$/,
|
|
344
|
+
/\.bash$/,
|
|
345
|
+
/ bash/,
|
|
346
|
+
/script/i
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
// Check for common attack vectors
|
|
350
|
+
const suspiciousPaths = [
|
|
351
|
+
/proc\/self/,
|
|
352
|
+
/\/etc\//,
|
|
353
|
+
/~\/ /,
|
|
354
|
+
/\$HOME/,
|
|
355
|
+
/\.ssh\//
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
for (const pattern of suspiciousPaths) {
|
|
360
|
+
if (pattern.test(file)) {
|
|
361
|
+
issues.push(`suspicious path: ${file}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
suspicious: issues.length > 0,
|
|
368
|
+
issues
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Combined extended analysis (sync parts only)
|
|
374
|
+
*/
|
|
375
|
+
export function extendedAnalysis(
|
|
376
|
+
packageName: string,
|
|
377
|
+
metadata: any
|
|
378
|
+
) {
|
|
379
|
+
const license = analyzeLicense(metadata.license);
|
|
380
|
+
const maintainer = checkMaintainerTrust(metadata.maintainers, metadata.publisher);
|
|
381
|
+
const dependencies = analyzeDependencies(metadata.dependencies, metadata.devDependencies);
|
|
382
|
+
|
|
383
|
+
return { license, maintainer, repository: { valid: true, details: '', issues: [] }, dependencies };
|
|
384
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package integrity verification
|
|
3
|
+
* Hash verification, signature checking, tarball analysis
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as crypto from 'crypto';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as tar from 'tar';
|
|
11
|
+
|
|
12
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Verify package integrity using npm registry integrity field
|
|
16
|
+
*/
|
|
17
|
+
export async function verifyIntegrity(
|
|
18
|
+
packageName: string,
|
|
19
|
+
version: string,
|
|
20
|
+
registry: string = NPM_REGISTRY
|
|
21
|
+
): Promise<any> {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${registry}/${packageName}/${version}`);
|
|
24
|
+
const data: any = await response.json();
|
|
25
|
+
|
|
26
|
+
const expectedHash = data.dist?.integrity || null;
|
|
27
|
+
|
|
28
|
+
if (!expectedHash) {
|
|
29
|
+
return {
|
|
30
|
+
valid: true,
|
|
31
|
+
expectedHash: null,
|
|
32
|
+
actualHash: null,
|
|
33
|
+
algorithm: 'none',
|
|
34
|
+
details: 'No integrity hash in registry'
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const algorithm = expectedHash.startsWith('sha512') ? 'sha512' : 'sha256';
|
|
39
|
+
const tarballUrl = data.dist?.tarball;
|
|
40
|
+
|
|
41
|
+
if (!tarballUrl) {
|
|
42
|
+
return {
|
|
43
|
+
valid: false,
|
|
44
|
+
expectedHash,
|
|
45
|
+
actualHash: null,
|
|
46
|
+
algorithm,
|
|
47
|
+
details: 'No tarball URL found'
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tarballResponse = await fetch(tarballUrl);
|
|
52
|
+
const buffer = Buffer.from(await tarballResponse.arrayBuffer());
|
|
53
|
+
|
|
54
|
+
const actualHash = algorithm === 'sha512'
|
|
55
|
+
? 'sha512-' + crypto.createHash('sha512').update(buffer).digest('base64')
|
|
56
|
+
: 'sha256-' + crypto.createHash('sha256').update(buffer).digest('base64');
|
|
57
|
+
|
|
58
|
+
const valid = actualHash === expectedHash;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
valid,
|
|
62
|
+
expectedHash,
|
|
63
|
+
actualHash,
|
|
64
|
+
algorithm,
|
|
65
|
+
details: valid ? 'Integrity verified' : 'Integrity mismatch!'
|
|
66
|
+
};
|
|
67
|
+
} catch (error: any) {
|
|
68
|
+
return {
|
|
69
|
+
valid: false,
|
|
70
|
+
expectedHash: null,
|
|
71
|
+
actualHash: null,
|
|
72
|
+
algorithm: 'none',
|
|
73
|
+
details: `Verification failed: ${error.message}`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Analyze package size for anomalies
|
|
80
|
+
*/
|
|
81
|
+
export async function analyzePackageSize(
|
|
82
|
+
packageName: string,
|
|
83
|
+
version: string,
|
|
84
|
+
registry: string = NPM_REGISTRY
|
|
85
|
+
): Promise<any> {
|
|
86
|
+
const THRESHOLD_MB = 50;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(`${registry}/${packageName}/${version}`);
|
|
90
|
+
const data: any = await response.json();
|
|
91
|
+
|
|
92
|
+
const size = data.dist?.unpackedSize || data.dist?.size || 0;
|
|
93
|
+
const sizeFormatted = formatBytes(size);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
size,
|
|
97
|
+
sizeFormatted,
|
|
98
|
+
suspicious: size > THRESHOLD_MB * 1024 * 1024,
|
|
99
|
+
threshold: THRESHOLD_MB,
|
|
100
|
+
details: size > THRESHOLD_MB * 1024 * 1024
|
|
101
|
+
? `Package size ${sizeFormatted} exceeds ${THRESHOLD_MB}MB threshold`
|
|
102
|
+
: `Package size ${sizeFormatted} is normal`
|
|
103
|
+
};
|
|
104
|
+
} catch (error: any) {
|
|
105
|
+
return {
|
|
106
|
+
size: 0,
|
|
107
|
+
sizeFormatted: '0 B',
|
|
108
|
+
suspicious: false,
|
|
109
|
+
threshold: THRESHOLD_MB,
|
|
110
|
+
details: 'Could not determine package size'
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract and analyze package.json from tarball
|
|
117
|
+
*/
|
|
118
|
+
export async function analyzeTarball(
|
|
119
|
+
packageName: string,
|
|
120
|
+
version: string,
|
|
121
|
+
registry: string = NPM_REGISTRY
|
|
122
|
+
): Promise<any> {
|
|
123
|
+
const tempDir = path.join(os.tmpdir(), `npm-scan-tarball-${Date.now()}`);
|
|
124
|
+
const tarballPath = path.join(tempDir, 'package.tgz');
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(`${registry}/${packageName}/${version}`);
|
|
128
|
+
const data: any = await response.json();
|
|
129
|
+
|
|
130
|
+
const tarballUrl = data.dist?.tarball;
|
|
131
|
+
if (!tarballUrl) {
|
|
132
|
+
return {
|
|
133
|
+
hasPkgJson: false,
|
|
134
|
+
main: null,
|
|
135
|
+
bin: {},
|
|
136
|
+
files: [],
|
|
137
|
+
fileCount: 0,
|
|
138
|
+
hasNativeCode: false,
|
|
139
|
+
details: 'No tarball URL'
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
const tarballResponse = await fetch(tarballUrl);
|
|
146
|
+
fs.writeFileSync(tarballPath, Buffer.from(await tarballResponse.arrayBuffer()));
|
|
147
|
+
|
|
148
|
+
const extractDir = path.join(tempDir, 'extracted');
|
|
149
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
await tar.extract({
|
|
152
|
+
file: tarballPath,
|
|
153
|
+
cwd: extractDir
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const entries = fs.readdirSync(extractDir);
|
|
157
|
+
const pkgDir = entries.find(e => e.startsWith('package'));
|
|
158
|
+
const packageJsonPath = path.join(extractDir, pkgDir || '', 'package.json');
|
|
159
|
+
|
|
160
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
161
|
+
return {
|
|
162
|
+
hasPkgJson: false,
|
|
163
|
+
main: null,
|
|
164
|
+
bin: {},
|
|
165
|
+
files: [],
|
|
166
|
+
fileCount: 0,
|
|
167
|
+
hasNativeCode: false,
|
|
168
|
+
details: 'package.json not found in tarball'
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
173
|
+
const allFiles = getAllFiles(path.join(extractDir, pkgDir || ''));
|
|
174
|
+
|
|
175
|
+
const nativeExtensions = ['.node', '.dll', '.dylib', '.so', '.a', '.o'];
|
|
176
|
+
const hasNativeCode = allFiles.some(f => nativeExtensions.some(ext => f.endsWith(ext)));
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
hasPkgJson: true,
|
|
180
|
+
main: pkgJson.main || null,
|
|
181
|
+
bin: pkgJson.bin || {},
|
|
182
|
+
files: allFiles,
|
|
183
|
+
fileCount: allFiles.length,
|
|
184
|
+
hasNativeCode,
|
|
185
|
+
details: hasNativeCode ? 'Contains native code' : 'Pure JavaScript'
|
|
186
|
+
};
|
|
187
|
+
} catch (error: any) {
|
|
188
|
+
return {
|
|
189
|
+
hasPkgJson: false,
|
|
190
|
+
main: null,
|
|
191
|
+
bin: {},
|
|
192
|
+
files: [],
|
|
193
|
+
fileCount: 0,
|
|
194
|
+
hasNativeCode: false,
|
|
195
|
+
details: `Analysis failed: ${error.message}`
|
|
196
|
+
};
|
|
197
|
+
} finally {
|
|
198
|
+
try {
|
|
199
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
200
|
+
} catch (e) {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatBytes(bytes: number): string {
|
|
205
|
+
if (bytes === 0) return '0 B';
|
|
206
|
+
|
|
207
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
208
|
+
let unitIndex = 0;
|
|
209
|
+
let size = bytes;
|
|
210
|
+
|
|
211
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
212
|
+
size = size / 1024;
|
|
213
|
+
unitIndex++;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return size.toFixed(2) + ' ' + units[unitIndex];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getAllFiles(dir: string, baseDir?: string): string[] {
|
|
220
|
+
const files: string[] = [];
|
|
221
|
+
const base = baseDir || dir;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
225
|
+
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
const fullPath = path.join(dir, entry.name);
|
|
228
|
+
const relativePath = path.relative(base, fullPath);
|
|
229
|
+
|
|
230
|
+
if (entry.isDirectory()) {
|
|
231
|
+
files.push(...getAllFiles(fullPath, base));
|
|
232
|
+
} else if (entry.isFile()) {
|
|
233
|
+
files.push(relativePath);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {}
|
|
237
|
+
|
|
238
|
+
return files;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function fullIntegrityCheck(
|
|
242
|
+
packageName: string,
|
|
243
|
+
version: string,
|
|
244
|
+
registry?: string
|
|
245
|
+
): Promise<any> {
|
|
246
|
+
const [integrity, size, tarball] = await Promise.all([
|
|
247
|
+
verifyIntegrity(packageName, version, registry),
|
|
248
|
+
analyzePackageSize(packageName, version, registry),
|
|
249
|
+
analyzeTarball(packageName, version, registry)
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
return { integrity, size, tarball };
|
|
253
|
+
}
|