@lateos/npm-scan 0.18.2 → 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.
Files changed (113) hide show
  1. package/CHANGELOG.md +265 -233
  2. package/LICENSING.md +19 -19
  3. package/README.de.md +708 -708
  4. package/README.fr.md +707 -707
  5. package/README.ja.md +704 -704
  6. package/README.md +861 -826
  7. package/README.zh.md +708 -708
  8. package/VALIDATION.md +92 -0
  9. package/backend/cra.js +68 -68
  10. package/backend/db/pg-schema.sql +155 -0
  11. package/backend/db/schema.sql +32 -32
  12. package/backend/db.js +88 -88
  13. package/backend/detectors/atk-001-lifecycle.js +17 -17
  14. package/backend/detectors/atk-002-obfusc.js +261 -261
  15. package/backend/detectors/atk-003-creds.js +13 -13
  16. package/backend/detectors/atk-004-persist.js +13 -13
  17. package/backend/detectors/atk-005-exfil.js +13 -13
  18. package/backend/detectors/atk-006-depconf.js +14 -14
  19. package/backend/detectors/atk-007-typosquat.js +34 -34
  20. package/backend/detectors/atk-008-tarball-tamper.js +91 -91
  21. package/backend/detectors/atk-009-dormant-trigger.js +62 -62
  22. package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
  23. package/backend/detectors/atk-011-transitive-prop.js +76 -76
  24. package/backend/detectors/config/thresholds.js +66 -0
  25. package/backend/detectors/config/whitelist.json +74 -0
  26. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
  27. package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
  28. package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
  29. package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
  30. package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
  31. package/backend/detectors/hf-impersonation/index.js +396 -396
  32. package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
  33. package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
  34. package/backend/detectors/hf-impersonation/simhash.js +46 -46
  35. package/backend/detectors/index.js +87 -81
  36. package/backend/detectors/lib/ast-patterns.js +21 -0
  37. package/backend/detectors/lib/entropy-analyzer.js +24 -0
  38. package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
  39. package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
  40. package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
  41. package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
  42. package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
  43. package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
  44. package/backend/detectors/megalodon/index.js +80 -80
  45. package/backend/detectors/megalodon/types.js +9 -9
  46. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
  47. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
  48. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
  49. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
  50. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
  51. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
  52. package/backend/detectors/mini-shai-hulud/index.js +118 -118
  53. package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
  54. package/backend/detectors/tier1-binary-embed.js +34 -5
  55. package/backend/detectors/tier1-obfuscation-heuristics.js +156 -0
  56. package/backend/detectors/tier1-slsa-attestation.js +12 -0
  57. package/backend/detectors/tier1-version-anomaly.js +187 -0
  58. package/backend/detectors.test.js +88 -0
  59. package/backend/fetch.js +175 -175
  60. package/backend/index.js +4 -4
  61. package/backend/license.js +89 -89
  62. package/backend/lockfile.js +379 -379
  63. package/backend/pdf.js +245 -245
  64. package/backend/policy.js +193 -193
  65. package/backend/report.js +254 -254
  66. package/backend/sbom.js +66 -66
  67. package/backend/scripts/analyze-false-positives.js +146 -0
  68. package/backend/scripts/analyze-validation.js +151 -0
  69. package/backend/scripts/detect-false-positives.js +93 -0
  70. package/backend/scripts/fetch-top-packages.js +129 -0
  71. package/backend/scripts/validate-detectors.js +142 -0
  72. package/backend/siem/cef.js +32 -32
  73. package/backend/siem/ecs.js +40 -40
  74. package/backend/siem/index.js +18 -18
  75. package/backend/siem/qradar.js +56 -56
  76. package/backend/siem/sentinel.js +27 -27
  77. package/backend/tests-d5-enhanced.test.js +46 -0
  78. package/backend/tests-d6-version-anomaly.test.js +58 -0
  79. package/backend/tests-d6.test.js +116 -0
  80. package/backend/tests-d6c.test.js +106 -0
  81. package/backend/tests-d7-obfuscation.test.js +91 -0
  82. package/backend/tests.test.js +898 -0
  83. package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
  84. package/backend/vsix-scan/detectors/burst-publish.js +52 -52
  85. package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
  86. package/backend/vsix-scan/detectors/known-ioc.js +105 -105
  87. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
  88. package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
  89. package/backend/vsix-scan/index.js +183 -183
  90. package/backend/vsix-scan/marketplace-client.js +145 -145
  91. package/backend/vsix-scan/vsix-iocs.json +31 -31
  92. package/cli/cli.js +458 -458
  93. package/package.json +74 -57
  94. package/.dockerignore +0 -20
  95. package/.husky/pre-commit +0 -1
  96. package/SECURITY.md +0 -73
  97. package/deploy/helm/npm-scan/Chart.yaml +0 -22
  98. package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
  99. package/deploy/helm/npm-scan/templates/api.yaml +0 -94
  100. package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
  101. package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
  102. package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
  103. package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
  104. package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
  105. package/deploy/helm/npm-scan/values.yaml +0 -103
  106. package/scripts/download-corpus.js +0 -30
  107. package/scripts/gen-mal-corpus.js +0 -35
  108. package/scripts/generate-campaign-fixtures.js +0 -170
  109. package/src/config/top-5000.json +0 -87
  110. package/test/fixtures/lockfiles/npm-lock.json +0 -69
  111. package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
  112. package/test/fixtures/lockfiles/yarn.lock +0 -104
  113. package/test/fixtures/mock-data.js +0 -69
package/cli/cli.js CHANGED
@@ -1,459 +1,459 @@
1
- #!/usr/bin/env node
2
-
3
- import { Command } from 'commander';
4
- import { watch } from 'fs';
5
- import { statSync } from 'fs';
6
- import { execSync } from 'child_process';
7
- import { glob } from 'glob';
8
- import { isFeatureEnabled, generateKey } from '../backend/license.js';
9
-
10
- function requirePremium(feature, licenseKey) {
11
- if (!isFeatureEnabled(feature, licenseKey)) {
12
- console.error(`Error: "${feature}" requires a premium license key.`);
13
- console.error(` Pass --license-key <key> or set NPM_SCAN_LICENSE_KEY env var.`);
14
- console.error(` Contact leo@lateos.ai for a premium license.`);
15
- process.exit(1);
16
- }
17
- }
18
-
19
- const program = new Command()
20
- .name('npm-scan')
21
- .description('npm supply chain security scanner')
22
- .version('0.9.7');
23
-
24
- program
25
- .command('scan')
26
- .description('Scan a package')
27
- .argument('[target]', 'package name')
28
- .option('-f, --file <path>', 'local tarball path')
29
- .option('-l, --license-key <key>', 'Premium license')
30
- .option('--sbom [format]', 'Generate SBOM (json/xml/spdx)')
31
- .option('-p, --policy <path>', 'Policy file (YAML/JSON)')
32
- .option('--fail-on <level>', 'Exit with code 1 if findings >= level (low|medium|high|critical)', 'none')
33
- .option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
34
- .option('--csv [file]', 'Output CSV format to file or stdout')
35
- .option('--score-only', 'Output only the risk score (0-10)')
36
- .option('--audit-log <file>', 'Append scan record to immutable audit log (JSONL format)')
37
- .option('--fips', 'Enable FIPS 140-2/3 crypto mode (requires FIPS-enabled Node.js)')
38
- .option('--cache-dir <path>', 'Cache directory for offline/air-gapped scans')
39
- .option('--cache-ttl <seconds>', 'Cache TTL in seconds (default: 604800 = 7 days)', '604800')
40
- .option('--cache-size <bytes>', 'Max cache size in bytes (default: 1GB)', '1000000000')
41
- .option('--vsix <extensionId>', 'Scan a VS Code extension (e.g. nrwl.angular-console)')
42
- .action(async (target, options) => {
43
- try {
44
- if (options.fips) {
45
- process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS || '') + ' --enable-fips';
46
- }
47
-
48
- const fetchOptions = {
49
- cacheDir: options.cacheDir,
50
- cacheTTL: parseInt(options.cacheTtl || '604800'),
51
- cacheMaxSize: parseInt(options.cacheSize || '1000000000')
52
- };
53
-
54
- if (!target && !options.file && !options.vsix) {
55
- console.error('Error: specify a package name, --file <path>, or --vsix <extensionId>');
56
- process.exit(1);
57
- }
58
-
59
- if (options.vsix && !target && !options.file) {
60
- const { vsixScan } = await import('../backend/vsix-scan/index.js');
61
- const vsixFindings = await vsixScan(options.vsix);
62
- const { saveScan } = await import('../backend/db.js');
63
- const scanId = await saveScan(options.vsix, 'latest', vsixFindings);
64
- const vsixOutput = JSON.stringify({ scanId, findings: vsixFindings, blocked: false, riskScore: 0, vsix: true }, null, 2);
65
- console.log(vsixOutput);
66
- return;
67
- }
68
-
69
- const policy = options.policy
70
- ? await import('../backend/policy.js').then(m => m.loadPolicy(options.policy))
71
- : null;
72
-
73
- if (policy) {
74
- const { isAllowed } = await import('../backend/policy.js');
75
- if (target && isAllowed(target, policy)) {
76
- console.log(JSON.stringify({ scanId: null, findings: [], skipped: true, reason: `Package '${target}' is in policy allowlist` }));
77
- return;
78
- }
79
- }
80
-
81
- const { pkgJson, jsFiles, allFiles, tmpDir, meta } = options.file
82
- ? await import('../backend/fetch.js').then(m => m.scanLocalTarball(options.file))
83
- : await import('../backend/fetch.js').then(m => m.fetchPackage(target, fetchOptions));
84
- const pkgName = target || pkgJson.name || 'unknown';
85
- const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles, meta, allFiles));
86
- let vsixFindings = [];
87
- if (options.vsix) {
88
- const { vsixScan } = await import('../backend/vsix-scan/index.js');
89
- vsixFindings = await vsixScan(options.vsix);
90
- }
91
- const allFindings = [...findings, ...vsixFindings];
92
- const { saveScan } = await import('../backend/db.js');
93
- const scanId = await saveScan(pkgName, 'latest', allFindings);
94
-
95
- let outputFindings = allFindings;
96
- let blocked = false;
97
-
98
- if (policy) {
99
- const { applyPolicy } = await import('../backend/policy.js');
100
- const result = applyPolicy(findings, pkgName, policy);
101
- outputFindings = result.findings;
102
- blocked = result.blocked;
103
- }
104
-
105
- const { calculateRiskScore } = await import('../backend/report.js');
106
- const riskScore = calculateRiskScore(outputFindings);
107
-
108
- if (options.scoreOnly) {
109
- console.log(riskScore);
110
- import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
111
- return;
112
- }
113
-
114
- if (options.sarif) {
115
- const { generateSARIF } = await import('../backend/report.js');
116
- const scan = { package_name: pkgName, version: pkgJson.version || 'latest', findings: outputFindings };
117
- const sarifOutput = generateSARIF(scan);
118
- if (options.sarif === true || !options.sarif) {
119
- console.log(sarifOutput);
120
- } else {
121
- const { writeFileSync } = await import('fs');
122
- writeFileSync(options.sarif, sarifOutput);
123
- console.log(`SARIF output written to ${options.sarif}`);
124
- }
125
- } else if (options.csv) {
126
- const { generateCSV } = await import('../backend/report.js');
127
- const scan = { package_name: pkgName, version: pkgJson.version || 'latest', findings: outputFindings };
128
- const csvOutput = generateCSV([scan]);
129
- if (options.csv === true || !options.csv) {
130
- console.log(csvOutput);
131
- } else {
132
- const { writeFileSync } = await import('fs');
133
- writeFileSync(options.csv, csvOutput);
134
- console.log(`CSV output written to ${options.csv}`);
135
- }
136
- } else if (options.sbom) {
137
- const { generateSBOM } = await import('../backend/sbom.js');
138
- const pkg = { name: pkgName, version: pkgJson.version || 'latest' };
139
- const sbom = generateSBOM(pkg, outputFindings, options.sbom === true ? 'json' : options.sbom);
140
- console.log(sbom);
141
- } else {
142
- console.log(JSON.stringify({scanId, findings: outputFindings, blocked, riskScore}, null, 2));
143
- }
144
-
145
- if (options.auditLog) {
146
- const { writeFileSync, appendFileSync } = await import('fs');
147
- const entry = {
148
- timestamp: new Date().toISOString(),
149
- command: `scan ${target || options.file}`,
150
- package: pkgName,
151
- version: pkgJson.version || 'latest',
152
- riskScore,
153
- findingsCount: outputFindings.length,
154
- exitCode: 0
155
- };
156
- appendFileSync(options.auditLog, JSON.stringify(entry) + '\n');
157
- }
158
-
159
- if (blocked) {
160
- console.error('Policy: scan blocked due to fail_on threshold');
161
- process.exit(1);
162
- }
163
-
164
- if (options.failOn !== 'none') {
165
- const severityLevels = { low: 1, medium: 2, high: 3, critical: 4 };
166
- const failLevel = severityLevels[options.failOn] || 0;
167
- const hasBlockingFindings = outputFindings.some(f => (severityLevels[f.severity] || 0) >= failLevel);
168
- if (hasBlockingFindings) {
169
- console.error(`Fail: findings with severity >= ${options.failOn} detected`);
170
- process.exit(1);
171
- }
172
- }
173
-
174
- import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
175
- } catch (e) {
176
- console.error(e.message);
177
- process.exit(1);
178
- }
179
- });
180
-
181
- program
182
- .command('scan-lockfile')
183
- .description('Scan package lockfile (npm/yarn/pnpm)')
184
- .option('-f, --file <path>', 'lockfile path', 'package-lock.json')
185
- .option('--fail-on <level>', 'Exit with code 1 if findings >= level (low|medium|high|critical)', 'none')
186
- .option('--csv [file]', 'Output CSV format to file or stdout')
187
- .option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
188
- .option('--watch', 'Watch for changes and re-scan automatically')
189
- .option('--debounce <ms>', 'Debounce delay in ms before rescanning (default: 1000)', '1000')
190
- .option('--silent', 'Suppress stdout output (useful for piping)')
191
- .option('--monorepo', 'Scan all lockfiles in workspace (auto-detect type)')
192
- .option('--yarn', 'Force yarn.lock format')
193
- .option('--pnpm', 'Force pnpm-lock.yaml format')
194
- .action(async (options) => {
195
- const silent = options.silent;
196
- const debounce = parseInt(options.debounce, 10) || 1000;
197
- const isWatch = options.watch;
198
- const isMonorepo = options.monorepo;
199
-
200
- if (isWatch) {
201
- if (isMonorepo) {
202
- const lockfiles = await glob('**/{package-lock.json,yarn.lock,pnpm-lock.yaml}', { ignore: 'node_modules/**' });
203
-
204
- if (!silent) {
205
- console.log(`\x1b[32m✔\x1b[0m npm-scan watch mode (monorepo) — ${lockfiles.length} lockfiles`);
206
- console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
207
- }
208
-
209
- let timers = {};
210
- for (const lf of lockfiles) {
211
- if (!silent) console.log(` Watching: ${lf}`);
212
- const watcher = watch(lf, (eventType) => {
213
- if (eventType !== 'change') return;
214
- clearTimeout(timers[lf]);
215
- timers[lf] = setTimeout(() => {
216
- if (!silent) {
217
- console.log(`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lf} changed — scanning...`);
218
- }
219
- const lockType = lf.includes('yarn') ? '--yarn' : lf.includes('pnpm') ? '--pnpm' : '';
220
- try {
221
- execSync(`node cli/cli.js scan-lockfile -f "${lf}" --fail-on ${options.failOn || 'high'} --silent ${lockType}`, { stdio: silent ? 'ignore' : 'inherit' });
222
- } catch (e) {}
223
- }, debounce);
224
- });
225
- }
226
-
227
- process.on('SIGINT', () => {
228
- if (!silent) console.log('\n\x1b[33m✖\x1b[0m Stopped.');
229
- process.exit(0);
230
- });
231
- } else {
232
- const lockfile = options.file;
233
- let lastSize = 0;
234
- try { lastSize = statSync(lockfile).size; } catch {}
235
-
236
- if (!silent) {
237
- console.log(`\x1b[32m✔\x1b[0m npm-scan watch mode — ${lockfile}`);
238
- console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
239
- }
240
-
241
- const watcher = watch(lockfile, (eventType) => {
242
- if (eventType !== 'change') return;
243
- const size = statSync(lockfile).size;
244
- if (size === lastSize) return;
245
- lastSize = size;
246
- if (!silent) console.log(`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lockfile} changed — rescanning...`);
247
- try {
248
- execSync(`node cli/cli.js scan-lockfile --fail-on ${options.failOn || 'high'} --silent`, { stdio: silent ? 'ignore' : 'inherit' });
249
- } catch (e) {}
250
- });
251
-
252
- process.on('SIGINT', () => {
253
- watcher.close();
254
- if (!silent) console.log('\n\x1b[33m✖\x1b[0m Stopped.');
255
- process.exit(0);
256
- });
257
- }
258
- } else {
259
- const lockfile = options.file;
260
- try {
261
- const { parseLockfile, generateLockfileReport } = await import('../backend/lockfile.js');
262
-
263
- if (!silent) console.log(`\x1b[32m✔\x1b[0m Scanning lockfile: ${lockfile}`);
264
-
265
- const lockfileData = parseLockfile(lockfile, { autoDetect: !options.yarn && !options.pnpm });
266
- const results = generateLockfileReport(lockfileData);
267
-
268
- if (!silent) {
269
- console.log(` Total deps: ${results.totalDependencies}`);
270
- console.log(` Lockfile version: ${results.lockfileVersion}`);
271
- if (results.findings.length > 0) {
272
- console.log(`\n\x1b[31m🔴\x1b[0m ${results.findings.length} finding(s) found:\n`);
273
- for (const f of results.findings) {
274
- const color = f.severity === 'critical' ? '\x1b[31m' : f.severity === 'high' ? '\x1b[91m' : f.severity === 'medium' ? '\x1b[33m' : '\x1b[32m';
275
- console.log(` ${color}${f.severity.toUpperCase().padEnd(8)}\x1b[0m ${f.id}: ${f.title}`);
276
- console.log(` ${f.description}`);
277
- }
278
- } else {
279
- console.log(`\n\x1b[32m✔\x1b[0m No threats found.`);
280
- }
281
- console.log(`\n\x1b[36mRisk Score: ${results.riskScore}/10\x1b[0m`);
282
- }
283
-
284
- console.log(JSON.stringify(results, null, 2));
285
-
286
- if (results.findings.length > 0) {
287
- const failOn = options.failOn || 'none';
288
- if (failOn !== 'none') {
289
- const weights = { critical: 5, high: 4, medium: 3, low: 2, info: 1 };
290
- const maxWeight = Math.max(...results.findings.map(f => weights[f.severity] || 0));
291
- const failThreshold = weights[failOn] || 0;
292
- if (maxWeight >= failThreshold) process.exit(1);
293
- }
294
- }
295
- } catch (e) {
296
- console.error(`Error: ${e.message}`);
297
- process.exit(1);
298
- }
299
- }
300
- });
301
-
302
- program
303
- .command('report')
304
- .description('Generate report')
305
- .option('-i, --id <id>', 'Scan ID')
306
- .option('--sbom [format]', 'SBOM format (json/xml/spdx)')
307
- .option('--html', 'HTML report')
308
- .option('--text', 'Plain text report')
309
- .option('--csv [file]', 'CSV export to file or stdout')
310
- .option('--nist', 'NIST 800-161 compliance report')
311
- .option('--cra', 'EU CRA compliance report')
312
- .option('--stig', 'STIG compliance report (DISA SRG-APP)')
313
- .option('--siem <format>', 'SIEM format (cef|ecs|sentinel|qradar)')
314
- .option('--pdf', 'PDF report (premium)')
315
- .option('-o, --output <path>', 'Output file path')
316
- .option('-l, --license-key <key>', 'Premium license')
317
- .action(async (options) => {
318
- const licenseKey = options.licenseKey || process.env.NPM_SCAN_LICENSE_KEY;
319
- const { getRecentScans, getFindings, getScan } = await import('../backend/db.js');
320
-
321
- if (options.id) {
322
- const findings = await getFindings(options.id);
323
- const scanInfo = await getScan(options.id);
324
- const pkgName = scanInfo?.package_name || 'scan-' + options.id;
325
- const pkgVer = scanInfo?.version || 'unknown';
326
- const pkg = { name: pkgName, version: pkgVer };
327
- const scan = findings.length ? { package_name: pkgName, version: pkgVer, findings } : null;
328
-
329
- if (options.siem) {
330
- requirePremium('siem', licenseKey);
331
- const { generateSIEM } = await import('../backend/siem/index.js');
332
- console.log(generateSIEM(scan ? [scan] : [], options.siem));
333
- } else if (options.cra) {
334
- requirePremium('cra', licenseKey);
335
- const { generateCRA } = await import('../backend/cra.js');
336
- console.log(generateCRA(scan ? [scan] : []));
337
- } else if (options.pdf) {
338
- requirePremium('nist-pdf', licenseKey);
339
- const { generatePDF } = await import('../backend/pdf.js');
340
- const pdfBytes = await generatePDF(scan ? [scan] : []);
341
- const outPath = options.output || `${pkgName}-${options.id}-report.pdf`;
342
- await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
343
- console.log(`PDF report written to ${outPath}`);
344
- } else if (options.text) {
345
- const { generateText } = await import('../backend/report.js');
346
- console.log(generateText(scan ? [scan] : []));
347
- } else if (options.sbom) {
348
- const { generateSBOM } = await import('../backend/sbom.js');
349
- const sbom = generateSBOM(pkg, findings, options.sbom === true ? 'json' : options.sbom);
350
- console.log(sbom);
351
- } else if (options.html || options.nist) {
352
- const { generateHTML } = await import('../backend/report.js');
353
- const html = generateHTML(scan ? [scan] : []);
354
- console.log(html);
355
- } else if (options.stig) {
356
- const { generateSTIG } = await import('../backend/report.js');
357
- const stig = generateSTIG(scan ? [scan] : []);
358
- console.log(stig);
359
- } else {
360
- console.log(JSON.stringify(findings, null, 2));
361
- }
362
- } else {
363
- const scans = await getRecentScans();
364
- const scansWithFindings = await Promise.all(scans.map(async s => ({ ...s, findings: await getFindings(s.id) })));
365
-
366
- if (options.siem) {
367
- requirePremium('siem', licenseKey);
368
- const { generateSIEM } = await import('../backend/siem/index.js');
369
- console.log(generateSIEM(scansWithFindings, options.siem));
370
- } else if (options.cra) {
371
- requirePremium('cra', licenseKey);
372
- const { generateCRA } = await import('../backend/cra.js');
373
- console.log(generateCRA(scansWithFindings));
374
- } else if (options.pdf) {
375
- requirePremium('nist-pdf', licenseKey);
376
- const { generatePDF } = await import('../backend/pdf.js');
377
- const pdfBytes = await generatePDF(scansWithFindings);
378
- const date = new Date().toISOString().slice(0, 10);
379
- const outPath = options.output || `npm-scan-report-${date}.pdf`;
380
- await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
381
- console.log(`PDF report written to ${outPath}`);
382
- } else if (options.text) {
383
- const { generateText } = await import('../backend/report.js');
384
- console.log(generateText(scansWithFindings));
385
- } else if (options.html || options.nist) {
386
- const { generateHTML } = await import('../backend/report.js');
387
- const html = generateHTML(scansWithFindings);
388
- console.log(html);
389
- } else if (options.stig) {
390
- const { generateSTIG } = await import('../backend/report.js');
391
- const stig = generateSTIG(scansWithFindings);
392
- console.log(stig);
393
- } else {
394
- console.log('Recent scans:', JSON.stringify(scans, null, 2));
395
- }
396
- }
397
- });
398
-
399
- program
400
- .command('serve')
401
- .description('Start API server (premium feature)')
402
- .option('-p, --port <port>', 'Port', '8000')
403
- .option('-h, --host <host>', 'Host', '0.0.0.0')
404
- .action(async (options) => {
405
- const licenseKey = process.env.NPM_SCAN_LICENSE_KEY || options.licenseKey;
406
- requirePremium('rest-api', licenseKey);
407
-
408
- const { createServer } = await import('http');
409
- const server = createServer(async (req, res) => {
410
- const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' };
411
-
412
- if (req.url === '/health') {
413
- res.writeHead(200, headers);
414
- res.end(JSON.stringify({ status: 'ok', version: program.version() }));
415
- return;
416
- }
417
-
418
- if (req.url === '/scan' && req.method === 'POST') {
419
- let body = '';
420
- req.on('data', chunk => body += chunk);
421
- req.on('end', async () => {
422
- try {
423
- const { package: pkg, options: scanOpts } = JSON.parse(body);
424
- const { scan } = await import('../backend/fetch.js');
425
- const results = await scan(pkg, { ...scanOpts, licenseKey });
426
- res.writeHead(200, headers);
427
- res.end(JSON.stringify({ results }));
428
- } catch (e) {
429
- res.writeHead(500, headers);
430
- res.end(JSON.stringify({ error: e.message }));
431
- }
432
- });
433
- return;
434
- }
435
-
436
- if (req.url.startsWith('/siem') && options.siemEnabled) {
437
- requirePremium('siem', licenseKey);
438
- res.writeHead(200, headers);
439
- res.end(JSON.stringify({ siem: 'enabled', endpoint: process.env.SIEM_ENDPOINT }));
440
- return;
441
- }
442
-
443
- if (req.url.startsWith('/pdf') && options.pdfEnabled) {
444
- requirePremium('nist-pdf', licenseKey);
445
- res.writeHead(200, headers);
446
- res.end(JSON.stringify({ pdf: 'enabled' }));
447
- return;
448
- }
449
-
450
- res.writeHead(404, headers);
451
- res.end(JSON.stringify({ error: 'Not found' }));
452
- });
453
-
454
- server.listen(options.port, options.host, () => {
455
- console.log(`npm-scan API server running on http://${options.host}:${options.port}`);
456
- });
457
- });
458
-
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { watch } from 'fs';
5
+ import { statSync } from 'fs';
6
+ import { execSync } from 'child_process';
7
+ import { glob } from 'glob';
8
+ import { isFeatureEnabled, generateKey } from '../backend/license.js';
9
+
10
+ function requirePremium(feature, licenseKey) {
11
+ if (!isFeatureEnabled(feature, licenseKey)) {
12
+ console.error(`Error: "${feature}" requires a premium license key.`);
13
+ console.error(` Pass --license-key <key> or set NPM_SCAN_LICENSE_KEY env var.`);
14
+ console.error(` Contact leo@lateos.ai for a premium license.`);
15
+ process.exit(1);
16
+ }
17
+ }
18
+
19
+ const program = new Command()
20
+ .name('npm-scan')
21
+ .description('npm supply chain security scanner')
22
+ .version('0.9.7');
23
+
24
+ program
25
+ .command('scan')
26
+ .description('Scan a package')
27
+ .argument('[target]', 'package name')
28
+ .option('-f, --file <path>', 'local tarball path')
29
+ .option('-l, --license-key <key>', 'Premium license')
30
+ .option('--sbom [format]', 'Generate SBOM (json/xml/spdx)')
31
+ .option('-p, --policy <path>', 'Policy file (YAML/JSON)')
32
+ .option('--fail-on <level>', 'Exit with code 1 if findings >= level (low|medium|high|critical)', 'none')
33
+ .option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
34
+ .option('--csv [file]', 'Output CSV format to file or stdout')
35
+ .option('--score-only', 'Output only the risk score (0-10)')
36
+ .option('--audit-log <file>', 'Append scan record to immutable audit log (JSONL format)')
37
+ .option('--fips', 'Enable FIPS 140-2/3 crypto mode (requires FIPS-enabled Node.js)')
38
+ .option('--cache-dir <path>', 'Cache directory for offline/air-gapped scans')
39
+ .option('--cache-ttl <seconds>', 'Cache TTL in seconds (default: 604800 = 7 days)', '604800')
40
+ .option('--cache-size <bytes>', 'Max cache size in bytes (default: 1GB)', '1000000000')
41
+ .option('--vsix <extensionId>', 'Scan a VS Code extension (e.g. nrwl.angular-console)')
42
+ .action(async (target, options) => {
43
+ try {
44
+ if (options.fips) {
45
+ process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS || '') + ' --enable-fips';
46
+ }
47
+
48
+ const fetchOptions = {
49
+ cacheDir: options.cacheDir,
50
+ cacheTTL: parseInt(options.cacheTtl || '604800'),
51
+ cacheMaxSize: parseInt(options.cacheSize || '1000000000')
52
+ };
53
+
54
+ if (!target && !options.file && !options.vsix) {
55
+ console.error('Error: specify a package name, --file <path>, or --vsix <extensionId>');
56
+ process.exit(1);
57
+ }
58
+
59
+ if (options.vsix && !target && !options.file) {
60
+ const { vsixScan } = await import('../backend/vsix-scan/index.js');
61
+ const vsixFindings = await vsixScan(options.vsix);
62
+ const { saveScan } = await import('../backend/db.js');
63
+ const scanId = await saveScan(options.vsix, 'latest', vsixFindings);
64
+ const vsixOutput = JSON.stringify({ scanId, findings: vsixFindings, blocked: false, riskScore: 0, vsix: true }, null, 2);
65
+ console.log(vsixOutput);
66
+ return;
67
+ }
68
+
69
+ const policy = options.policy
70
+ ? await import('../backend/policy.js').then(m => m.loadPolicy(options.policy))
71
+ : null;
72
+
73
+ if (policy) {
74
+ const { isAllowed } = await import('../backend/policy.js');
75
+ if (target && isAllowed(target, policy)) {
76
+ console.log(JSON.stringify({ scanId: null, findings: [], skipped: true, reason: `Package '${target}' is in policy allowlist` }));
77
+ return;
78
+ }
79
+ }
80
+
81
+ const { pkgJson, jsFiles, allFiles, tmpDir, meta } = options.file
82
+ ? await import('../backend/fetch.js').then(m => m.scanLocalTarball(options.file))
83
+ : await import('../backend/fetch.js').then(m => m.fetchPackage(target, fetchOptions));
84
+ const pkgName = target || pkgJson.name || 'unknown';
85
+ const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles, meta, allFiles));
86
+ let vsixFindings = [];
87
+ if (options.vsix) {
88
+ const { vsixScan } = await import('../backend/vsix-scan/index.js');
89
+ vsixFindings = await vsixScan(options.vsix);
90
+ }
91
+ const allFindings = [...findings, ...vsixFindings];
92
+ const { saveScan } = await import('../backend/db.js');
93
+ const scanId = await saveScan(pkgName, 'latest', allFindings);
94
+
95
+ let outputFindings = allFindings;
96
+ let blocked = false;
97
+
98
+ if (policy) {
99
+ const { applyPolicy } = await import('../backend/policy.js');
100
+ const result = applyPolicy(findings, pkgName, policy);
101
+ outputFindings = result.findings;
102
+ blocked = result.blocked;
103
+ }
104
+
105
+ const { calculateRiskScore } = await import('../backend/report.js');
106
+ const riskScore = calculateRiskScore(outputFindings);
107
+
108
+ if (options.scoreOnly) {
109
+ console.log(riskScore);
110
+ import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
111
+ return;
112
+ }
113
+
114
+ if (options.sarif) {
115
+ const { generateSARIF } = await import('../backend/report.js');
116
+ const scan = { package_name: pkgName, version: pkgJson.version || 'latest', findings: outputFindings };
117
+ const sarifOutput = generateSARIF(scan);
118
+ if (options.sarif === true || !options.sarif) {
119
+ console.log(sarifOutput);
120
+ } else {
121
+ const { writeFileSync } = await import('fs');
122
+ writeFileSync(options.sarif, sarifOutput);
123
+ console.log(`SARIF output written to ${options.sarif}`);
124
+ }
125
+ } else if (options.csv) {
126
+ const { generateCSV } = await import('../backend/report.js');
127
+ const scan = { package_name: pkgName, version: pkgJson.version || 'latest', findings: outputFindings };
128
+ const csvOutput = generateCSV([scan]);
129
+ if (options.csv === true || !options.csv) {
130
+ console.log(csvOutput);
131
+ } else {
132
+ const { writeFileSync } = await import('fs');
133
+ writeFileSync(options.csv, csvOutput);
134
+ console.log(`CSV output written to ${options.csv}`);
135
+ }
136
+ } else if (options.sbom) {
137
+ const { generateSBOM } = await import('../backend/sbom.js');
138
+ const pkg = { name: pkgName, version: pkgJson.version || 'latest' };
139
+ const sbom = generateSBOM(pkg, outputFindings, options.sbom === true ? 'json' : options.sbom);
140
+ console.log(sbom);
141
+ } else {
142
+ console.log(JSON.stringify({scanId, findings: outputFindings, blocked, riskScore}, null, 2));
143
+ }
144
+
145
+ if (options.auditLog) {
146
+ const { writeFileSync, appendFileSync } = await import('fs');
147
+ const entry = {
148
+ timestamp: new Date().toISOString(),
149
+ command: `scan ${target || options.file}`,
150
+ package: pkgName,
151
+ version: pkgJson.version || 'latest',
152
+ riskScore,
153
+ findingsCount: outputFindings.length,
154
+ exitCode: 0
155
+ };
156
+ appendFileSync(options.auditLog, JSON.stringify(entry) + '\n');
157
+ }
158
+
159
+ if (blocked) {
160
+ console.error('Policy: scan blocked due to fail_on threshold');
161
+ process.exit(1);
162
+ }
163
+
164
+ if (options.failOn !== 'none') {
165
+ const severityLevels = { low: 1, medium: 2, high: 3, critical: 4 };
166
+ const failLevel = severityLevels[options.failOn] || 0;
167
+ const hasBlockingFindings = outputFindings.some(f => (severityLevels[f.severity] || 0) >= failLevel);
168
+ if (hasBlockingFindings) {
169
+ console.error(`Fail: findings with severity >= ${options.failOn} detected`);
170
+ process.exit(1);
171
+ }
172
+ }
173
+
174
+ import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
175
+ } catch (e) {
176
+ console.error(e.message);
177
+ process.exit(1);
178
+ }
179
+ });
180
+
181
+ program
182
+ .command('scan-lockfile')
183
+ .description('Scan package lockfile (npm/yarn/pnpm)')
184
+ .option('-f, --file <path>', 'lockfile path', 'package-lock.json')
185
+ .option('--fail-on <level>', 'Exit with code 1 if findings >= level (low|medium|high|critical)', 'none')
186
+ .option('--csv [file]', 'Output CSV format to file or stdout')
187
+ .option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
188
+ .option('--watch', 'Watch for changes and re-scan automatically')
189
+ .option('--debounce <ms>', 'Debounce delay in ms before rescanning (default: 1000)', '1000')
190
+ .option('--silent', 'Suppress stdout output (useful for piping)')
191
+ .option('--monorepo', 'Scan all lockfiles in workspace (auto-detect type)')
192
+ .option('--yarn', 'Force yarn.lock format')
193
+ .option('--pnpm', 'Force pnpm-lock.yaml format')
194
+ .action(async (options) => {
195
+ const silent = options.silent;
196
+ const debounce = parseInt(options.debounce, 10) || 1000;
197
+ const isWatch = options.watch;
198
+ const isMonorepo = options.monorepo;
199
+
200
+ if (isWatch) {
201
+ if (isMonorepo) {
202
+ const lockfiles = await glob('**/{package-lock.json,yarn.lock,pnpm-lock.yaml}', { ignore: 'node_modules/**' });
203
+
204
+ if (!silent) {
205
+ console.log(`\x1b[32m✔\x1b[0m npm-scan watch mode (monorepo) — ${lockfiles.length} lockfiles`);
206
+ console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
207
+ }
208
+
209
+ let timers = {};
210
+ for (const lf of lockfiles) {
211
+ if (!silent) console.log(` Watching: ${lf}`);
212
+ const watcher = watch(lf, (eventType) => {
213
+ if (eventType !== 'change') return;
214
+ clearTimeout(timers[lf]);
215
+ timers[lf] = setTimeout(() => {
216
+ if (!silent) {
217
+ console.log(`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lf} changed — scanning...`);
218
+ }
219
+ const lockType = lf.includes('yarn') ? '--yarn' : lf.includes('pnpm') ? '--pnpm' : '';
220
+ try {
221
+ execSync(`node cli/cli.js scan-lockfile -f "${lf}" --fail-on ${options.failOn || 'high'} --silent ${lockType}`, { stdio: silent ? 'ignore' : 'inherit' });
222
+ } catch (e) {}
223
+ }, debounce);
224
+ });
225
+ }
226
+
227
+ process.on('SIGINT', () => {
228
+ if (!silent) console.log('\n\x1b[33m✖\x1b[0m Stopped.');
229
+ process.exit(0);
230
+ });
231
+ } else {
232
+ const lockfile = options.file;
233
+ let lastSize = 0;
234
+ try { lastSize = statSync(lockfile).size; } catch {}
235
+
236
+ if (!silent) {
237
+ console.log(`\x1b[32m✔\x1b[0m npm-scan watch mode — ${lockfile}`);
238
+ console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
239
+ }
240
+
241
+ const watcher = watch(lockfile, (eventType) => {
242
+ if (eventType !== 'change') return;
243
+ const size = statSync(lockfile).size;
244
+ if (size === lastSize) return;
245
+ lastSize = size;
246
+ if (!silent) console.log(`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lockfile} changed — rescanning...`);
247
+ try {
248
+ execSync(`node cli/cli.js scan-lockfile --fail-on ${options.failOn || 'high'} --silent`, { stdio: silent ? 'ignore' : 'inherit' });
249
+ } catch (e) {}
250
+ });
251
+
252
+ process.on('SIGINT', () => {
253
+ watcher.close();
254
+ if (!silent) console.log('\n\x1b[33m✖\x1b[0m Stopped.');
255
+ process.exit(0);
256
+ });
257
+ }
258
+ } else {
259
+ const lockfile = options.file;
260
+ try {
261
+ const { parseLockfile, generateLockfileReport } = await import('../backend/lockfile.js');
262
+
263
+ if (!silent) console.log(`\x1b[32m✔\x1b[0m Scanning lockfile: ${lockfile}`);
264
+
265
+ const lockfileData = parseLockfile(lockfile, { autoDetect: !options.yarn && !options.pnpm });
266
+ const results = generateLockfileReport(lockfileData);
267
+
268
+ if (!silent) {
269
+ console.log(` Total deps: ${results.totalDependencies}`);
270
+ console.log(` Lockfile version: ${results.lockfileVersion}`);
271
+ if (results.findings.length > 0) {
272
+ console.log(`\n\x1b[31m🔴\x1b[0m ${results.findings.length} finding(s) found:\n`);
273
+ for (const f of results.findings) {
274
+ const color = f.severity === 'critical' ? '\x1b[31m' : f.severity === 'high' ? '\x1b[91m' : f.severity === 'medium' ? '\x1b[33m' : '\x1b[32m';
275
+ console.log(` ${color}${f.severity.toUpperCase().padEnd(8)}\x1b[0m ${f.id}: ${f.title}`);
276
+ console.log(` ${f.description}`);
277
+ }
278
+ } else {
279
+ console.log(`\n\x1b[32m✔\x1b[0m No threats found.`);
280
+ }
281
+ console.log(`\n\x1b[36mRisk Score: ${results.riskScore}/10\x1b[0m`);
282
+ }
283
+
284
+ console.log(JSON.stringify(results, null, 2));
285
+
286
+ if (results.findings.length > 0) {
287
+ const failOn = options.failOn || 'none';
288
+ if (failOn !== 'none') {
289
+ const weights = { critical: 5, high: 4, medium: 3, low: 2, info: 1 };
290
+ const maxWeight = Math.max(...results.findings.map(f => weights[f.severity] || 0));
291
+ const failThreshold = weights[failOn] || 0;
292
+ if (maxWeight >= failThreshold) process.exit(1);
293
+ }
294
+ }
295
+ } catch (e) {
296
+ console.error(`Error: ${e.message}`);
297
+ process.exit(1);
298
+ }
299
+ }
300
+ });
301
+
302
+ program
303
+ .command('report')
304
+ .description('Generate report')
305
+ .option('-i, --id <id>', 'Scan ID')
306
+ .option('--sbom [format]', 'SBOM format (json/xml/spdx)')
307
+ .option('--html', 'HTML report')
308
+ .option('--text', 'Plain text report')
309
+ .option('--csv [file]', 'CSV export to file or stdout')
310
+ .option('--nist', 'NIST 800-161 compliance report')
311
+ .option('--cra', 'EU CRA compliance report')
312
+ .option('--stig', 'STIG compliance report (DISA SRG-APP)')
313
+ .option('--siem <format>', 'SIEM format (cef|ecs|sentinel|qradar)')
314
+ .option('--pdf', 'PDF report (premium)')
315
+ .option('-o, --output <path>', 'Output file path')
316
+ .option('-l, --license-key <key>', 'Premium license')
317
+ .action(async (options) => {
318
+ const licenseKey = options.licenseKey || process.env.NPM_SCAN_LICENSE_KEY;
319
+ const { getRecentScans, getFindings, getScan } = await import('../backend/db.js');
320
+
321
+ if (options.id) {
322
+ const findings = await getFindings(options.id);
323
+ const scanInfo = await getScan(options.id);
324
+ const pkgName = scanInfo?.package_name || 'scan-' + options.id;
325
+ const pkgVer = scanInfo?.version || 'unknown';
326
+ const pkg = { name: pkgName, version: pkgVer };
327
+ const scan = findings.length ? { package_name: pkgName, version: pkgVer, findings } : null;
328
+
329
+ if (options.siem) {
330
+ requirePremium('siem', licenseKey);
331
+ const { generateSIEM } = await import('../backend/siem/index.js');
332
+ console.log(generateSIEM(scan ? [scan] : [], options.siem));
333
+ } else if (options.cra) {
334
+ requirePremium('cra', licenseKey);
335
+ const { generateCRA } = await import('../backend/cra.js');
336
+ console.log(generateCRA(scan ? [scan] : []));
337
+ } else if (options.pdf) {
338
+ requirePremium('nist-pdf', licenseKey);
339
+ const { generatePDF } = await import('../backend/pdf.js');
340
+ const pdfBytes = await generatePDF(scan ? [scan] : []);
341
+ const outPath = options.output || `${pkgName}-${options.id}-report.pdf`;
342
+ await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
343
+ console.log(`PDF report written to ${outPath}`);
344
+ } else if (options.text) {
345
+ const { generateText } = await import('../backend/report.js');
346
+ console.log(generateText(scan ? [scan] : []));
347
+ } else if (options.sbom) {
348
+ const { generateSBOM } = await import('../backend/sbom.js');
349
+ const sbom = generateSBOM(pkg, findings, options.sbom === true ? 'json' : options.sbom);
350
+ console.log(sbom);
351
+ } else if (options.html || options.nist) {
352
+ const { generateHTML } = await import('../backend/report.js');
353
+ const html = generateHTML(scan ? [scan] : []);
354
+ console.log(html);
355
+ } else if (options.stig) {
356
+ const { generateSTIG } = await import('../backend/report.js');
357
+ const stig = generateSTIG(scan ? [scan] : []);
358
+ console.log(stig);
359
+ } else {
360
+ console.log(JSON.stringify(findings, null, 2));
361
+ }
362
+ } else {
363
+ const scans = await getRecentScans();
364
+ const scansWithFindings = await Promise.all(scans.map(async s => ({ ...s, findings: await getFindings(s.id) })));
365
+
366
+ if (options.siem) {
367
+ requirePremium('siem', licenseKey);
368
+ const { generateSIEM } = await import('../backend/siem/index.js');
369
+ console.log(generateSIEM(scansWithFindings, options.siem));
370
+ } else if (options.cra) {
371
+ requirePremium('cra', licenseKey);
372
+ const { generateCRA } = await import('../backend/cra.js');
373
+ console.log(generateCRA(scansWithFindings));
374
+ } else if (options.pdf) {
375
+ requirePremium('nist-pdf', licenseKey);
376
+ const { generatePDF } = await import('../backend/pdf.js');
377
+ const pdfBytes = await generatePDF(scansWithFindings);
378
+ const date = new Date().toISOString().slice(0, 10);
379
+ const outPath = options.output || `npm-scan-report-${date}.pdf`;
380
+ await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
381
+ console.log(`PDF report written to ${outPath}`);
382
+ } else if (options.text) {
383
+ const { generateText } = await import('../backend/report.js');
384
+ console.log(generateText(scansWithFindings));
385
+ } else if (options.html || options.nist) {
386
+ const { generateHTML } = await import('../backend/report.js');
387
+ const html = generateHTML(scansWithFindings);
388
+ console.log(html);
389
+ } else if (options.stig) {
390
+ const { generateSTIG } = await import('../backend/report.js');
391
+ const stig = generateSTIG(scansWithFindings);
392
+ console.log(stig);
393
+ } else {
394
+ console.log('Recent scans:', JSON.stringify(scans, null, 2));
395
+ }
396
+ }
397
+ });
398
+
399
+ program
400
+ .command('serve')
401
+ .description('Start API server (premium feature)')
402
+ .option('-p, --port <port>', 'Port', '8000')
403
+ .option('-h, --host <host>', 'Host', '0.0.0.0')
404
+ .action(async (options) => {
405
+ const licenseKey = process.env.NPM_SCAN_LICENSE_KEY || options.licenseKey;
406
+ requirePremium('rest-api', licenseKey);
407
+
408
+ const { createServer } = await import('http');
409
+ const server = createServer(async (req, res) => {
410
+ const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' };
411
+
412
+ if (req.url === '/health') {
413
+ res.writeHead(200, headers);
414
+ res.end(JSON.stringify({ status: 'ok', version: program.version() }));
415
+ return;
416
+ }
417
+
418
+ if (req.url === '/scan' && req.method === 'POST') {
419
+ let body = '';
420
+ req.on('data', chunk => body += chunk);
421
+ req.on('end', async () => {
422
+ try {
423
+ const { package: pkg, options: scanOpts } = JSON.parse(body);
424
+ const { scan } = await import('../backend/fetch.js');
425
+ const results = await scan(pkg, { ...scanOpts, licenseKey });
426
+ res.writeHead(200, headers);
427
+ res.end(JSON.stringify({ results }));
428
+ } catch (e) {
429
+ res.writeHead(500, headers);
430
+ res.end(JSON.stringify({ error: e.message }));
431
+ }
432
+ });
433
+ return;
434
+ }
435
+
436
+ if (req.url.startsWith('/siem') && options.siemEnabled) {
437
+ requirePremium('siem', licenseKey);
438
+ res.writeHead(200, headers);
439
+ res.end(JSON.stringify({ siem: 'enabled', endpoint: process.env.SIEM_ENDPOINT }));
440
+ return;
441
+ }
442
+
443
+ if (req.url.startsWith('/pdf') && options.pdfEnabled) {
444
+ requirePremium('nist-pdf', licenseKey);
445
+ res.writeHead(200, headers);
446
+ res.end(JSON.stringify({ pdf: 'enabled' }));
447
+ return;
448
+ }
449
+
450
+ res.writeHead(404, headers);
451
+ res.end(JSON.stringify({ error: 'Not found' }));
452
+ });
453
+
454
+ server.listen(options.port, options.host, () => {
455
+ console.log(`npm-scan API server running on http://${options.host}:${options.port}`);
456
+ });
457
+ });
458
+
459
459
  program.parse();