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,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core scanner module
|
|
3
|
+
* Pre and post-install security scanning for npm packages
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { RegistryClient, createRegistryClient } from './registry';
|
|
9
|
+
import { blocklistManager, TYPOSQUATTING_PATTERNS } from './blocklist';
|
|
10
|
+
import { scanFile, scanDirectory, scanPackageJsonScripts, CODE_EXTENSIONS } from './patterns';
|
|
11
|
+
import { VulnerabilityChecker, createVulnerabilityChecker } from './vuln';
|
|
12
|
+
import {
|
|
13
|
+
analyzeLicense,
|
|
14
|
+
checkMaintainerTrust,
|
|
15
|
+
validateRepository,
|
|
16
|
+
analyzeDependencies,
|
|
17
|
+
analyzeFileStructure
|
|
18
|
+
} from './extended';
|
|
19
|
+
import { fullIntegrityCheck } from './integrity';
|
|
20
|
+
import type {
|
|
21
|
+
PackageMetadata,
|
|
22
|
+
ScanResult,
|
|
23
|
+
Threat,
|
|
24
|
+
ThreatType,
|
|
25
|
+
ScanOptions,
|
|
26
|
+
PostInstallScanResult,
|
|
27
|
+
FileThreat
|
|
28
|
+
} from '../types';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Main scanner class
|
|
32
|
+
*/
|
|
33
|
+
export class Scanner {
|
|
34
|
+
private registry: RegistryClient;
|
|
35
|
+
private options: ScanOptions;
|
|
36
|
+
private vulnChecker: VulnerabilityChecker;
|
|
37
|
+
|
|
38
|
+
constructor(options: ScanOptions = {}) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.registry = createRegistryClient({ registry: options.registry });
|
|
41
|
+
this.vulnChecker = createVulnerabilityChecker(process.env.GITHUB_TOKEN);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pre-install scan: Check a package before installation
|
|
46
|
+
*/
|
|
47
|
+
async preInstallScan(packageName: string, version?: string): Promise<ScanResult> {
|
|
48
|
+
const threats: Threat[] = [];
|
|
49
|
+
let score = 0;
|
|
50
|
+
|
|
51
|
+
// 1. Check blocklist
|
|
52
|
+
const blocklistEntry = blocklistManager.isBlocklisted(packageName);
|
|
53
|
+
if (blocklistEntry) {
|
|
54
|
+
threats.push({
|
|
55
|
+
type: 'blocklisted',
|
|
56
|
+
severity: blocklistEntry.severity,
|
|
57
|
+
message: `Package is blocklisted: ${blocklistEntry.reason}`,
|
|
58
|
+
details: `Source: ${blocklistEntry.source || 'internal'}`
|
|
59
|
+
});
|
|
60
|
+
score += blocklistEntry.severity === 'critical' ? 100 : 75;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. Check typosquatting
|
|
64
|
+
const typosquatMatches = blocklistManager.detectTyposquatting(packageName);
|
|
65
|
+
if (typosquatMatches.length > 0) {
|
|
66
|
+
threats.push({
|
|
67
|
+
type: 'typosquatting',
|
|
68
|
+
severity: 'high',
|
|
69
|
+
message: `Possible typosquatting of "${typosquatMatches.join(', ')}"`,
|
|
70
|
+
details: 'This package name is similar to popular packages - may be attempting to confuse users'
|
|
71
|
+
});
|
|
72
|
+
score += 60;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. Fetch metadata
|
|
76
|
+
let rawMetadata: any;
|
|
77
|
+
try {
|
|
78
|
+
rawMetadata = await this.registry.getPackageMetadata(packageName, version);
|
|
79
|
+
} catch (e: any) {
|
|
80
|
+
threats.push({
|
|
81
|
+
type: 'supply_chain',
|
|
82
|
+
severity: 'medium',
|
|
83
|
+
message: `Could not fetch package metadata: ${e.message}`
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
packageName,
|
|
87
|
+
version: version || 'unknown',
|
|
88
|
+
status: 'warning',
|
|
89
|
+
threats,
|
|
90
|
+
score: Math.min(score, 100)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const metadata: PackageMetadata = {
|
|
95
|
+
name: rawMetadata.name,
|
|
96
|
+
version: rawMetadata['dist-tags']?.latest || version || 'unknown',
|
|
97
|
+
description: rawMetadata.description,
|
|
98
|
+
publisher: rawMetadata.publisher,
|
|
99
|
+
maintainers: rawMetadata.maintainers,
|
|
100
|
+
repository: rawMetadata.repository,
|
|
101
|
+
homepage: rawMetadata.homepage,
|
|
102
|
+
license: rawMetadata.license
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Get latest version data
|
|
106
|
+
const latestVersion = rawMetadata.versions?.[metadata.version] || {};
|
|
107
|
+
metadata.dependencies = latestVersion.dependencies;
|
|
108
|
+
metadata.devDependencies = latestVersion.devDependencies;
|
|
109
|
+
metadata.peerDependencies = latestVersion.peerDependencies;
|
|
110
|
+
metadata.scripts = latestVersion.scripts;
|
|
111
|
+
metadata.time = rawMetadata.time;
|
|
112
|
+
|
|
113
|
+
// 4. Check for suspicious scripts
|
|
114
|
+
if (metadata.scripts) {
|
|
115
|
+
const suspiciousScripts = ['postinstall', 'preinstall', 'postpublish', 'preuninstall', 'postuninstall'];
|
|
116
|
+
for (const script of suspiciousScripts) {
|
|
117
|
+
if (metadata.scripts?.[script]) {
|
|
118
|
+
const isComplex = metadata.scripts?.[script].length > 100 ||
|
|
119
|
+
/curl|wget|npm\|pipe|\$\(|\||\&\&/.test(metadata.scripts?.[script] || '');
|
|
120
|
+
|
|
121
|
+
threats.push({
|
|
122
|
+
type: 'suspicious_script',
|
|
123
|
+
severity: isComplex ? 'high' : 'medium',
|
|
124
|
+
message: `Package has "${script}" script that runs automatically`,
|
|
125
|
+
details: metadata.scripts?.[script].substring(0, 100)
|
|
126
|
+
});
|
|
127
|
+
score += isComplex ? 40 : 20;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 5. Check publisher reputation
|
|
133
|
+
if (!metadata.publisher && !metadata.maintainers?.length) {
|
|
134
|
+
threats.push({
|
|
135
|
+
type: 'unknown_publisher',
|
|
136
|
+
severity: 'medium',
|
|
137
|
+
message: 'Package has no verified publisher information'
|
|
138
|
+
});
|
|
139
|
+
score += 15;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 6. Check version age
|
|
143
|
+
if (metadata.time?.created) {
|
|
144
|
+
const created = new Date(metadata.time.created);
|
|
145
|
+
const daysSince = (Date.now() - created.getTime()) / (1000 * 60 * 60 * 24);
|
|
146
|
+
if (daysSince < 7 && !metadata.maintainers?.length) {
|
|
147
|
+
threats.push({
|
|
148
|
+
type: 'supply_chain',
|
|
149
|
+
severity: 'medium',
|
|
150
|
+
message: `Package was published less than ${Math.floor(daysSince)} days ago with no maintainers`
|
|
151
|
+
});
|
|
152
|
+
score += 20;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 7. Check dependencies
|
|
157
|
+
if (metadata.dependencies) {
|
|
158
|
+
const depCount = Object.keys(metadata.dependencies).length;
|
|
159
|
+
if (depCount > 100) {
|
|
160
|
+
threats.push({
|
|
161
|
+
type: 'supply_chain',
|
|
162
|
+
severity: 'low',
|
|
163
|
+
message: `Package has ${depCount} dependencies - large attack surface`
|
|
164
|
+
});
|
|
165
|
+
score += 10;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check for dependency confusion
|
|
169
|
+
for (const dep of Object.keys(metadata.dependencies)) {
|
|
170
|
+
if (dep.startsWith('@') && !dep.includes('/')) {
|
|
171
|
+
threats.push({
|
|
172
|
+
type: 'dependency_confusion',
|
|
173
|
+
severity: 'medium',
|
|
174
|
+
message: `Scoped dependency "@${dep}" may be internal package confusion`
|
|
175
|
+
});
|
|
176
|
+
score += 15;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 8. Check vulnerability databases (OSV, GitHub Advisory, npm Audit)
|
|
183
|
+
if (this.options.checkVulnerabilities !== false) {
|
|
184
|
+
try {
|
|
185
|
+
const vulnResult = await this.vulnChecker.checkPackage(packageName, version || metadata.version);
|
|
186
|
+
|
|
187
|
+
for (const vuln of vulnResult.vulnerabilities) {
|
|
188
|
+
if (vuln.id.startsWith('CVE-') || vuln.id.startsWith('GHSA-')) {
|
|
189
|
+
threats.push({
|
|
190
|
+
type: 'vulnerability',
|
|
191
|
+
severity: vuln.severity,
|
|
192
|
+
message: `${vuln.source.toUpperCase()} vulnerability: ${vuln.id}`,
|
|
193
|
+
details: vuln.summary || vuln.details,
|
|
194
|
+
location: vuln.patched_versions ? `Fixed in: ${vuln.patched_versions}` : undefined
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (vuln.severity === 'critical') score += 50;
|
|
198
|
+
else if (vuln.severity === 'high') score += 30;
|
|
199
|
+
else if (vuln.severity === 'medium') score += 15;
|
|
200
|
+
else score += 5;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// Vulnerability check failed, continue without
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 9. License analysis
|
|
209
|
+
const licenseAnalysis = analyzeLicense(metadata.license);
|
|
210
|
+
if (licenseAnalysis.risk === 'high') {
|
|
211
|
+
threats.push({
|
|
212
|
+
type: 'supply_chain',
|
|
213
|
+
severity: 'medium',
|
|
214
|
+
message: `License risk: ${licenseAnalysis.details}`,
|
|
215
|
+
details: metadata.license
|
|
216
|
+
});
|
|
217
|
+
score += 20;
|
|
218
|
+
} else if (licenseAnalysis.risk === 'critical') {
|
|
219
|
+
threats.push({
|
|
220
|
+
type: 'supply_chain',
|
|
221
|
+
severity: 'high',
|
|
222
|
+
message: `License: ${licenseAnalysis.details}`,
|
|
223
|
+
details: metadata.license
|
|
224
|
+
});
|
|
225
|
+
score += 40;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 10. Maintainer trust check
|
|
229
|
+
const mainterTrust = checkMaintainerTrust(metadata.maintainers, metadata.publisher);
|
|
230
|
+
if (!mainterTrust.isTrusted && mainterTrust.score < 30) {
|
|
231
|
+
threats.push({
|
|
232
|
+
type: 'unknown_publisher',
|
|
233
|
+
severity: 'medium',
|
|
234
|
+
message: `Maintainer trust: ${mainterTrust.details}`
|
|
235
|
+
});
|
|
236
|
+
score += 15;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 11. Repository validation
|
|
240
|
+
if (metadata.repository) {
|
|
241
|
+
const repoCheck = await validateRepository(metadata.repository.url, packageName);
|
|
242
|
+
if (!repoCheck.valid) {
|
|
243
|
+
for (const issue of repoCheck.issues) {
|
|
244
|
+
threats.push({
|
|
245
|
+
type: 'supply_chain',
|
|
246
|
+
severity: 'medium',
|
|
247
|
+
message: `Repository issue: ${issue}`,
|
|
248
|
+
details: repoCheck.details
|
|
249
|
+
});
|
|
250
|
+
score += 10;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 12. Deprecated dependency check
|
|
256
|
+
const depAnalysis = analyzeDependencies(metadata.dependencies, metadata.devDependencies);
|
|
257
|
+
if (depAnalysis.outdatedCount > 0) {
|
|
258
|
+
threats.push({
|
|
259
|
+
type: 'supply_chain',
|
|
260
|
+
severity: 'low',
|
|
261
|
+
message: `${depAnalysis.outdatedCount} deprecated/outdated dependencies`,
|
|
262
|
+
details: depAnalysis.details.slice(0, 3).join('; ')
|
|
263
|
+
});
|
|
264
|
+
score += depAnalysis.outdatedCount * 2;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 13. Package integrity check
|
|
268
|
+
const integrityChecked = await fullIntegrityCheck(packageName, version || metadata.version);
|
|
269
|
+
const integrity = integrityChecked.integrity;
|
|
270
|
+
const sizeInfo = integrityChecked.size;
|
|
271
|
+
const tarballInfo = integrityChecked.tarball;
|
|
272
|
+
|
|
273
|
+
if (!integrity.valid) {
|
|
274
|
+
threats.push({
|
|
275
|
+
type: 'supply_chain',
|
|
276
|
+
severity: 'high',
|
|
277
|
+
message: `Integrity check failed: ${integrity.details}`,
|
|
278
|
+
details: `Expected: ${integrity.expectedHash?.substring(0, 20)}...`
|
|
279
|
+
});
|
|
280
|
+
score += 50;
|
|
281
|
+
}
|
|
282
|
+
if (sizeInfo.suspicious) {
|
|
283
|
+
threats.push({
|
|
284
|
+
type: 'supply_chain',
|
|
285
|
+
severity: 'medium',
|
|
286
|
+
message: sizeInfo.details
|
|
287
|
+
});
|
|
288
|
+
score += 20;
|
|
289
|
+
}
|
|
290
|
+
if (tarballInfo.hasNativeCode) {
|
|
291
|
+
// Native code is usually fine for big packages, but warn
|
|
292
|
+
threats.push({
|
|
293
|
+
type: 'supply_chain',
|
|
294
|
+
severity: 'low',
|
|
295
|
+
message: 'Package contains native code'
|
|
296
|
+
});
|
|
297
|
+
score += 5;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Determine status
|
|
301
|
+
const status = this.determineStatus(threats, score);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
packageName,
|
|
305
|
+
version: metadata?.version || version || 'unknown',
|
|
306
|
+
status,
|
|
307
|
+
threats,
|
|
308
|
+
metadata,
|
|
309
|
+
score
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Post-install scan: Scan downloaded packages
|
|
315
|
+
*/
|
|
316
|
+
async postInstallScan(folderPath?: string): Promise<PostInstallScanResult> {
|
|
317
|
+
const startTime = Date.now();
|
|
318
|
+
const scanFolder = folderPath || path.join(process.cwd(), 'node_modules');
|
|
319
|
+
const allThreats: FileThreat[] = [];
|
|
320
|
+
let scannedPackages = 0;
|
|
321
|
+
|
|
322
|
+
if (!fs.existsSync(scanFolder)) {
|
|
323
|
+
throw new Error(`node_modules folder not found: ${scanFolder}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Find all packages
|
|
327
|
+
const packages = this.findPackages(scanFolder);
|
|
328
|
+
|
|
329
|
+
for (const pkg of packages) {
|
|
330
|
+
scannedPackages++;
|
|
331
|
+
|
|
332
|
+
// Scan package directory
|
|
333
|
+
if (fs.existsSync(pkg)) {
|
|
334
|
+
const files = scanDirectory(pkg);
|
|
335
|
+
|
|
336
|
+
for (const [filePath, content] of files) {
|
|
337
|
+
const fileThreats = scanFile(filePath, content);
|
|
338
|
+
allThreats.push(...fileThreats.map(t => ({
|
|
339
|
+
...t,
|
|
340
|
+
package: pkg.split(path.sep).slice(-2).join(path.sep)
|
|
341
|
+
})));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Check package.json
|
|
345
|
+
const packageJsonPath = path.join(pkg, 'package.json');
|
|
346
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
347
|
+
try {
|
|
348
|
+
const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
349
|
+
if (pkgJson.scripts) {
|
|
350
|
+
const scriptThreats = scanPackageJsonScripts(pkgJson.scripts);
|
|
351
|
+
allThreats.push(...scriptThreats.map(t => ({
|
|
352
|
+
...t,
|
|
353
|
+
package: pkg.split(path.sep).slice(-2).join(path.sep)
|
|
354
|
+
})));
|
|
355
|
+
}
|
|
356
|
+
} catch (e) {
|
|
357
|
+
// Skip invalid package.json
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
scannedPackages,
|
|
365
|
+
threats: allThreats,
|
|
366
|
+
duration: Date.now() - startTime
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Full scan: Pre-scan, then install, then post-scan
|
|
372
|
+
*/
|
|
373
|
+
async fullScan(packageName: string, version?: string, folderPath?: string): Promise<{
|
|
374
|
+
pre: ScanResult;
|
|
375
|
+
post: PostInstallScanResult;
|
|
376
|
+
}> {
|
|
377
|
+
const pre = await this.preInstallScan(packageName, version);
|
|
378
|
+
|
|
379
|
+
if (pre.status === 'blocked') {
|
|
380
|
+
return { pre, post: { scannedPackages: 0, threats: [], duration: 0 } };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Run post-install scan if folder provided
|
|
384
|
+
let post: PostInstallScanResult = { scannedPackages: 0, threats: [], duration: 0 };
|
|
385
|
+
if (folderPath) {
|
|
386
|
+
post = await this.postInstallScan(folderPath);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { pre, post };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Find all packages in node_modules
|
|
394
|
+
*/
|
|
395
|
+
private findPackages(nodeModulesPath: string): string[] {
|
|
396
|
+
const packages: string[] = [];
|
|
397
|
+
|
|
398
|
+
function findPkgs(dir: string, depth: number = 0) {
|
|
399
|
+
if (depth > 3) return; // Limit depth
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
403
|
+
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
if (entry.isDirectory()) {
|
|
406
|
+
const fullPath = path.join(dir, entry.name);
|
|
407
|
+
|
|
408
|
+
// Skip scoped packages at root level
|
|
409
|
+
if (entry.name.startsWith('@')) {
|
|
410
|
+
findPkgs(fullPath, depth + 1);
|
|
411
|
+
} else if (fs.existsSync(path.join(fullPath, 'package.json'))) {
|
|
412
|
+
packages.push(fullPath);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch (e) {
|
|
417
|
+
// Skip inaccessible
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
findPkgs(nodeModulesPath);
|
|
422
|
+
return packages;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Determine overall status from threats and score
|
|
427
|
+
*/
|
|
428
|
+
private determineStatus(threats: Threat[], score: number): 'safe' | 'warning' | 'danger' | 'blocked' {
|
|
429
|
+
if (threats.some(t => t.type === 'blocklisted')) {
|
|
430
|
+
return 'blocked';
|
|
431
|
+
}
|
|
432
|
+
if (score >= 60 || threats.some(t => t.severity === 'critical')) {
|
|
433
|
+
return 'danger';
|
|
434
|
+
}
|
|
435
|
+
if (score >= 30 || threats.some(t => t.severity === 'high')) {
|
|
436
|
+
return 'warning';
|
|
437
|
+
}
|
|
438
|
+
return 'safe';
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function createScanner(options?: ScanOptions): Scanner {
|
|
443
|
+
return new Scanner(options);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Export for CLI
|
|
447
|
+
export default { Scanner, createScanner };
|