@lateos/npm-scan 0.16.4 → 0.17.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 (96) hide show
  1. package/.dockerignore +20 -20
  2. package/.husky/pre-commit +1 -1
  3. package/CHANGELOG.md +199 -199
  4. package/LICENSING.md +19 -19
  5. package/README.de.md +708 -708
  6. package/README.fr.md +707 -707
  7. package/README.ja.md +704 -704
  8. package/README.md +826 -826
  9. package/README.zh.md +708 -708
  10. package/SECURITY.md +72 -72
  11. package/backend/cra.js +68 -68
  12. package/backend/db/schema.sql +32 -32
  13. package/backend/db.js +88 -88
  14. package/backend/detectors/atk-001-lifecycle.js +17 -17
  15. package/backend/detectors/atk-002-obfusc.js +261 -261
  16. package/backend/detectors/atk-003-creds.js +13 -13
  17. package/backend/detectors/atk-004-persist.js +13 -13
  18. package/backend/detectors/atk-005-exfil.js +13 -13
  19. package/backend/detectors/atk-006-depconf.js +14 -14
  20. package/backend/detectors/atk-007-typosquat.js +34 -34
  21. package/backend/detectors/atk-008-tarball-tamper.js +91 -91
  22. package/backend/detectors/atk-009-dormant-trigger.js +62 -62
  23. package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
  24. package/backend/detectors/atk-011-transitive-prop.js +76 -76
  25. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
  26. package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
  27. package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
  28. package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
  29. package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
  30. package/backend/detectors/hf-impersonation/index.js +396 -396
  31. package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
  32. package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
  33. package/backend/detectors/hf-impersonation/simhash.js +46 -46
  34. package/backend/detectors/index.js +75 -44
  35. package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
  36. package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
  37. package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
  38. package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
  39. package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
  40. package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
  41. package/backend/detectors/megalodon/index.js +80 -80
  42. package/backend/detectors/megalodon/types.js +9 -9
  43. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
  44. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
  45. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
  46. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
  47. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
  48. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
  49. package/backend/detectors/mini-shai-hulud/index.js +118 -118
  50. package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
  51. package/backend/detectors/tier1-binary-embed.js +219 -0
  52. package/backend/detectors/tier1-infostealer.js +280 -0
  53. package/backend/detectors/tier1-lifecycle-hook.js +176 -0
  54. package/backend/detectors/tier1-metadata-spoof.js +180 -0
  55. package/backend/detectors/tier1-typosquat.js +219 -0
  56. package/backend/fetch.js +175 -175
  57. package/backend/index.js +4 -4
  58. package/backend/license.js +89 -89
  59. package/backend/lockfile.js +379 -379
  60. package/backend/pdf.js +245 -245
  61. package/backend/policy.js +193 -176
  62. package/backend/report.js +254 -254
  63. package/backend/sbom.js +66 -66
  64. package/backend/siem/cef.js +32 -32
  65. package/backend/siem/ecs.js +40 -40
  66. package/backend/siem/index.js +18 -18
  67. package/backend/siem/qradar.js +56 -56
  68. package/backend/siem/sentinel.js +27 -27
  69. package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
  70. package/backend/vsix-scan/detectors/burst-publish.js +52 -52
  71. package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
  72. package/backend/vsix-scan/detectors/known-ioc.js +105 -105
  73. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
  74. package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
  75. package/backend/vsix-scan/index.js +183 -183
  76. package/backend/vsix-scan/marketplace-client.js +145 -145
  77. package/backend/vsix-scan/vsix-iocs.json +31 -31
  78. package/cli/cli.js +458 -458
  79. package/deploy/helm/npm-scan/Chart.yaml +21 -21
  80. package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
  81. package/deploy/helm/npm-scan/templates/api.yaml +93 -93
  82. package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
  83. package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
  84. package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
  85. package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
  86. package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
  87. package/deploy/helm/npm-scan/values.yaml +102 -102
  88. package/package.json +57 -57
  89. package/scripts/download-corpus.js +30 -30
  90. package/scripts/gen-mal-corpus.js +34 -34
  91. package/scripts/generate-campaign-fixtures.js +170 -0
  92. package/src/config/top-5000.json +87 -0
  93. package/test/fixtures/lockfiles/npm-lock.json +68 -68
  94. package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
  95. package/test/fixtures/lockfiles/yarn.lock +103 -103
  96. package/test/fixtures/mock-data.js +69 -69
package/backend/fetch.js CHANGED
@@ -1,176 +1,176 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { extract } from 'tar';
5
- import zlib from 'zlib';
6
- import { Readable } from 'stream';
7
- import { pipeline } from 'stream/promises';
8
-
9
- export async function fetchPackage(target, options = {}) {
10
- const { cacheDir, cacheTTL = 604800, cacheMaxSize = 1000000000 } = options;
11
- let name, version;
12
-
13
- if (target.startsWith('@')) {
14
- const lastAt = target.lastIndexOf('@');
15
- name = target.slice(0, lastAt);
16
- version = target.slice(lastAt + 1);
17
- if (!version) version = undefined;
18
- } else {
19
- const idx = target.indexOf('@');
20
- name = idx > -1 ? target.slice(0, idx) : target;
21
- version = idx > -1 ? target.slice(idx + 1) : undefined;
22
- }
23
-
24
- const endpoint = version ? `/${encodeURIComponent(name)}/${version}` : `/${encodeURIComponent(name)}/latest`;
25
-
26
- if (cacheDir) {
27
- const cached = getFromCache(cacheDir, target, cacheTTL);
28
- if (cached) {
29
- const tmpDir = path.join(os.tmpdir(), 'npm-scan-cache-' + Date.now());
30
- return { ...(await extractTarball(cached, tmpDir)), meta: null };
31
- }
32
- }
33
-
34
- const metaRes = await fetch(`https://registry.npmjs.org${endpoint}`);
35
- const meta = await metaRes.json();
36
-
37
- if (!metaRes.ok || !meta.dist?.tarball) {
38
- throw new Error(`Package '${target}' not found on npm (${metaRes.status})`);
39
- }
40
-
41
- const tarUrl = meta.dist.tarball;
42
- const tarRes = await fetch(tarUrl);
43
- const buffer = Buffer.from(await tarRes.arrayBuffer());
44
- if (buffer.length > 500 * 1024 * 1024) throw new Error('Tarball too large');
45
-
46
- // Save to cache if enabled
47
- if (cacheDir) {
48
- saveToCache(cacheDir, target, buffer, cacheTTL, cacheMaxSize);
49
- }
50
-
51
- const tmpDir = path.join(os.tmpdir(), 'npm-scan-' + Date.now());
52
- return { ...(await extractTarball(buffer, tmpDir)), meta };
53
- }
54
-
55
- function getFromCache(cacheDir, target, ttl) {
56
- const cachePath = path.join(cacheDir, `${target.replace('/', '-')}.tgz`);
57
- const metaPath = path.join(cacheDir, `${target.replace('/', '-')}.meta.json`);
58
-
59
- try {
60
- if (!fs.existsSync(cachePath) || !fs.existsSync(metaPath)) return null;
61
-
62
- const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
63
- const age = (Date.now() - meta.timestamp) / 1000;
64
-
65
- if (age > ttl) {
66
- fs.unlinkSync(cachePath);
67
- fs.unlinkSync(metaPath);
68
- return null;
69
- }
70
-
71
- return fs.readFileSync(cachePath);
72
- } catch {
73
- return null;
74
- }
75
- }
76
-
77
- function saveToCache(cacheDir, target, buffer, ttl, maxSize) {
78
- try {
79
- if (!fs.existsSync(cacheDir)) {
80
- fs.mkdirSync(cacheDir, { recursive: true });
81
- }
82
-
83
- // Prune if needed
84
- pruneCache(cacheDir, maxSize);
85
-
86
- const safeName = target.replace('/', '-');
87
- const cachePath = path.join(cacheDir, `${safeName}.tgz`);
88
- const metaPath = path.join(cacheDir, `${safeName}.meta.json`);
89
-
90
- fs.writeFileSync(cachePath, buffer);
91
- fs.writeFileSync(metaPath, JSON.stringify({ timestamp: Date.now(), size: buffer.length }));
92
- } catch (e) {
93
- // Cache write failure - continue without caching
94
- }
95
- }
96
-
97
- function pruneCache(cacheDir, maxSize) {
98
- try {
99
- const files = fs.readdirSync(cacheDir).filter(f => f.endsWith('.meta.json'));
100
- let totalSize = 0;
101
- const fileInfos = [];
102
-
103
- for (const f of files) {
104
- const meta = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf8'));
105
- const tarFile = f.replace('.meta.json', '.tgz');
106
- const size = meta.size || 0;
107
- totalSize += size;
108
- fileInfos.push({ tarFile, metaFile: f, timestamp: meta.timestamp, size });
109
- }
110
-
111
- if (totalSize > maxSize) {
112
- // Sort by oldest first and remove until under limit
113
- fileInfos.sort((a, b) => a.timestamp - b.timestamp);
114
- for (const info of fileInfos) {
115
- if (totalSize <= maxSize * 0.8) break; // Leave 20% margin
116
- try {
117
- fs.unlinkSync(path.join(cacheDir, info.tarFile));
118
- fs.unlinkSync(path.join(cacheDir, info.metaFile));
119
- totalSize -= info.size;
120
- } catch {}
121
- }
122
- }
123
- } catch {
124
- // Prune failure - ignore
125
- }
126
- }
127
-
128
- export async function scanLocalTarball(filePath) {
129
- const buffer = fs.readFileSync(filePath);
130
- const tmpDir = path.join(os.tmpdir(), 'npm-scan-local-' + Date.now());
131
- return await extractTarball(buffer, tmpDir);
132
- }
133
-
134
- async function extractTarball(buffer, tmpDir) {
135
- fs.mkdirSync(tmpDir, { recursive: true });
136
-
137
- const stream = Readable.from(buffer);
138
- await pipeline(
139
- stream,
140
- zlib.createGunzip(),
141
- extract({ cwd: tmpDir, strip: 1 })
142
- );
143
-
144
- const pkgPath = path.join(tmpDir, 'package.json');
145
- const pkgJsonStr = fs.readFileSync(pkgPath, 'utf8');
146
- const pkgJson = JSON.parse(pkgJsonStr);
147
-
148
- const jsFiles = walkFiles(tmpDir, '.js').map(p => ({
149
- path: p,
150
- content: fs.readFileSync(p, 'utf8')
151
- }));
152
-
153
- const allFiles = walkFiles(tmpDir, '').map(p => ({
154
- path: p,
155
- content: fs.readFileSync(p, 'utf8')
156
- }));
157
-
158
- return { pkgJson, jsFiles, allFiles, tmpDir };
159
- }
160
-
161
- function walkFiles(dir, ext) {
162
- const results = [];
163
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
164
- const full = path.join(dir, entry.name);
165
- if (entry.isDirectory() && entry.name !== 'node_modules') {
166
- results.push(...walkFiles(full, ext));
167
- } else if (entry.isFile() && full.endsWith(ext)) {
168
- results.push(full);
169
- }
170
- }
171
- return results;
172
- }
173
-
174
- export function cleanup(tmpDir) {
175
- fs.rmSync(tmpDir, { recursive: true, force: true });
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { extract } from 'tar';
5
+ import zlib from 'zlib';
6
+ import { Readable } from 'stream';
7
+ import { pipeline } from 'stream/promises';
8
+
9
+ export async function fetchPackage(target, options = {}) {
10
+ const { cacheDir, cacheTTL = 604800, cacheMaxSize = 1000000000 } = options;
11
+ let name, version;
12
+
13
+ if (target.startsWith('@')) {
14
+ const lastAt = target.lastIndexOf('@');
15
+ name = target.slice(0, lastAt);
16
+ version = target.slice(lastAt + 1);
17
+ if (!version) version = undefined;
18
+ } else {
19
+ const idx = target.indexOf('@');
20
+ name = idx > -1 ? target.slice(0, idx) : target;
21
+ version = idx > -1 ? target.slice(idx + 1) : undefined;
22
+ }
23
+
24
+ const endpoint = version ? `/${encodeURIComponent(name)}/${version}` : `/${encodeURIComponent(name)}/latest`;
25
+
26
+ if (cacheDir) {
27
+ const cached = getFromCache(cacheDir, target, cacheTTL);
28
+ if (cached) {
29
+ const tmpDir = path.join(os.tmpdir(), 'npm-scan-cache-' + Date.now());
30
+ return { ...(await extractTarball(cached, tmpDir)), meta: null };
31
+ }
32
+ }
33
+
34
+ const metaRes = await fetch(`https://registry.npmjs.org${endpoint}`);
35
+ const meta = await metaRes.json();
36
+
37
+ if (!metaRes.ok || !meta.dist?.tarball) {
38
+ throw new Error(`Package '${target}' not found on npm (${metaRes.status})`);
39
+ }
40
+
41
+ const tarUrl = meta.dist.tarball;
42
+ const tarRes = await fetch(tarUrl);
43
+ const buffer = Buffer.from(await tarRes.arrayBuffer());
44
+ if (buffer.length > 500 * 1024 * 1024) throw new Error('Tarball too large');
45
+
46
+ // Save to cache if enabled
47
+ if (cacheDir) {
48
+ saveToCache(cacheDir, target, buffer, cacheTTL, cacheMaxSize);
49
+ }
50
+
51
+ const tmpDir = path.join(os.tmpdir(), 'npm-scan-' + Date.now());
52
+ return { ...(await extractTarball(buffer, tmpDir)), meta };
53
+ }
54
+
55
+ function getFromCache(cacheDir, target, ttl) {
56
+ const cachePath = path.join(cacheDir, `${target.replace('/', '-')}.tgz`);
57
+ const metaPath = path.join(cacheDir, `${target.replace('/', '-')}.meta.json`);
58
+
59
+ try {
60
+ if (!fs.existsSync(cachePath) || !fs.existsSync(metaPath)) return null;
61
+
62
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
63
+ const age = (Date.now() - meta.timestamp) / 1000;
64
+
65
+ if (age > ttl) {
66
+ fs.unlinkSync(cachePath);
67
+ fs.unlinkSync(metaPath);
68
+ return null;
69
+ }
70
+
71
+ return fs.readFileSync(cachePath);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function saveToCache(cacheDir, target, buffer, ttl, maxSize) {
78
+ try {
79
+ if (!fs.existsSync(cacheDir)) {
80
+ fs.mkdirSync(cacheDir, { recursive: true });
81
+ }
82
+
83
+ // Prune if needed
84
+ pruneCache(cacheDir, maxSize);
85
+
86
+ const safeName = target.replace('/', '-');
87
+ const cachePath = path.join(cacheDir, `${safeName}.tgz`);
88
+ const metaPath = path.join(cacheDir, `${safeName}.meta.json`);
89
+
90
+ fs.writeFileSync(cachePath, buffer);
91
+ fs.writeFileSync(metaPath, JSON.stringify({ timestamp: Date.now(), size: buffer.length }));
92
+ } catch (e) {
93
+ // Cache write failure - continue without caching
94
+ }
95
+ }
96
+
97
+ function pruneCache(cacheDir, maxSize) {
98
+ try {
99
+ const files = fs.readdirSync(cacheDir).filter(f => f.endsWith('.meta.json'));
100
+ let totalSize = 0;
101
+ const fileInfos = [];
102
+
103
+ for (const f of files) {
104
+ const meta = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf8'));
105
+ const tarFile = f.replace('.meta.json', '.tgz');
106
+ const size = meta.size || 0;
107
+ totalSize += size;
108
+ fileInfos.push({ tarFile, metaFile: f, timestamp: meta.timestamp, size });
109
+ }
110
+
111
+ if (totalSize > maxSize) {
112
+ // Sort by oldest first and remove until under limit
113
+ fileInfos.sort((a, b) => a.timestamp - b.timestamp);
114
+ for (const info of fileInfos) {
115
+ if (totalSize <= maxSize * 0.8) break; // Leave 20% margin
116
+ try {
117
+ fs.unlinkSync(path.join(cacheDir, info.tarFile));
118
+ fs.unlinkSync(path.join(cacheDir, info.metaFile));
119
+ totalSize -= info.size;
120
+ } catch {}
121
+ }
122
+ }
123
+ } catch {
124
+ // Prune failure - ignore
125
+ }
126
+ }
127
+
128
+ export async function scanLocalTarball(filePath) {
129
+ const buffer = fs.readFileSync(filePath);
130
+ const tmpDir = path.join(os.tmpdir(), 'npm-scan-local-' + Date.now());
131
+ return await extractTarball(buffer, tmpDir);
132
+ }
133
+
134
+ async function extractTarball(buffer, tmpDir) {
135
+ fs.mkdirSync(tmpDir, { recursive: true });
136
+
137
+ const stream = Readable.from(buffer);
138
+ await pipeline(
139
+ stream,
140
+ zlib.createGunzip(),
141
+ extract({ cwd: tmpDir, strip: 1 })
142
+ );
143
+
144
+ const pkgPath = path.join(tmpDir, 'package.json');
145
+ const pkgJsonStr = fs.readFileSync(pkgPath, 'utf8');
146
+ const pkgJson = JSON.parse(pkgJsonStr);
147
+
148
+ const jsFiles = walkFiles(tmpDir, '.js').map(p => ({
149
+ path: p,
150
+ content: fs.readFileSync(p, 'utf8')
151
+ }));
152
+
153
+ const allFiles = walkFiles(tmpDir, '').map(p => ({
154
+ path: p,
155
+ content: fs.readFileSync(p, 'utf8')
156
+ }));
157
+
158
+ return { pkgJson, jsFiles, allFiles, tmpDir };
159
+ }
160
+
161
+ function walkFiles(dir, ext) {
162
+ const results = [];
163
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
164
+ const full = path.join(dir, entry.name);
165
+ if (entry.isDirectory() && entry.name !== 'node_modules') {
166
+ results.push(...walkFiles(full, ext));
167
+ } else if (entry.isFile() && full.endsWith(ext)) {
168
+ results.push(full);
169
+ }
170
+ }
171
+ return results;
172
+ }
173
+
174
+ export function cleanup(tmpDir) {
175
+ fs.rmSync(tmpDir, { recursive: true, force: true });
176
176
  }
package/backend/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export default {
2
- version: '0.1.0',
3
- description: 'npm-scan - Supply chain security for npm'
4
- };
1
+ export default {
2
+ version: '0.1.0',
3
+ description: 'npm-scan - Supply chain security for npm'
4
+ };
@@ -1,90 +1,90 @@
1
- import { createHmac, timingSafeEqual } from 'crypto';
2
-
3
- const HMAC_KEY = process.env.NPM_SCAN_LICENSE_SECRET || 'npm-scan-default-dev-key';
4
-
5
- const FEATURE_TIERS = {
6
- community: [],
7
- premium: ['sandbox', 'siem', 'cra', 'nist-pdf', 'rest-api', 'webhooks', 'helm'],
8
- enterprise: ['sandbox', 'siem', 'cra', 'nist-pdf', 'rest-api', 'webhooks', 'helm', 'sso', 'audit-logs', 'pg-backend', 'kubernetes'],
9
- };
10
-
11
- const ALL_FEATURES = Object.values(FEATURE_TIERS).flat();
12
- const ALLOWED_UNLOCKED = ['sbom', 'nist-html', 'html-report', 'sqlite'];
13
-
14
- function generateSignature(payload) {
15
- return createHmac('sha256', HMAC_KEY).update(JSON.stringify(payload)).digest('hex');
16
- }
17
-
18
- export function generateKey(edition, options = {}) {
19
- const payload = {
20
- edition,
21
- issued: new Date().toISOString(),
22
- exp: options.expiresAt || null,
23
- seats: options.seats || 1,
24
- org: options.org || null,
25
- };
26
- const sig = generateSignature(payload);
27
- const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
28
- return `npm-scan-${edition}-${encoded}.${sig}`;
29
- }
30
-
31
- export function validateLicense(key, feature = '*') {
32
- if (!key) {
33
- throw new Error('No license key provided');
34
- }
35
-
36
- if (feature === 'scan' || ALLOWED_UNLOCKED.includes(feature)) {
37
- return { edition: 'community', features: ALL_FEATURES };
38
- }
39
-
40
- const parts = key.split('-');
41
- if (parts.length < 4 || !key.includes('.')) {
42
- throw new Error('Invalid license key format');
43
- }
44
-
45
- const edition = parts[2];
46
- const encodedPayload = parts.slice(3).join('-').split('.')[0];
47
- const sig = key.split('.')[1];
48
-
49
- let payload;
50
- try {
51
- payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
52
- } catch {
53
- throw new Error('Invalid license key payload');
54
- }
55
-
56
- const expectedSig = generateSignature(payload);
57
- const sigBuf = Buffer.from(sig, 'hex');
58
- const expectedBuf = Buffer.from(expectedSig, 'hex');
59
- if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
60
- throw new Error('Invalid license key signature');
61
- }
62
-
63
- if (payload.exp && new Date(payload.exp) < new Date()) {
64
- throw new Error('License key expired');
65
- }
66
-
67
- const allowed = FEATURE_TIERS[edition];
68
- if (!allowed) {
69
- throw new Error(`Unknown license edition: ${edition}`);
70
- }
71
-
72
- if (feature !== '*' && !allowed.includes(feature) && !ALLOWED_UNLOCKED.includes(feature)) {
73
- throw new Error(`Feature "${feature}" requires ${edition === 'community' ? 'premium' : 'enterprise'} license`);
74
- }
75
-
76
- return { edition, features: allowed, ...payload };
77
- }
78
-
79
- export function isFeatureEnabled(feature, licenseKey = process.env.NPM_SCAN_LICENSE_KEY) {
80
- try {
81
- if (!licenseKey) {
82
- const unlocked = feature === 'scan' || ALLOWED_UNLOCKED.includes(feature);
83
- if (unlocked) return true;
84
- }
85
- validateLicense(licenseKey, feature);
86
- return true;
87
- } catch {
88
- return false;
89
- }
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+
3
+ const HMAC_KEY = process.env.NPM_SCAN_LICENSE_SECRET || 'npm-scan-default-dev-key';
4
+
5
+ const FEATURE_TIERS = {
6
+ community: [],
7
+ premium: ['sandbox', 'siem', 'cra', 'nist-pdf', 'rest-api', 'webhooks', 'helm'],
8
+ enterprise: ['sandbox', 'siem', 'cra', 'nist-pdf', 'rest-api', 'webhooks', 'helm', 'sso', 'audit-logs', 'pg-backend', 'kubernetes'],
9
+ };
10
+
11
+ const ALL_FEATURES = Object.values(FEATURE_TIERS).flat();
12
+ const ALLOWED_UNLOCKED = ['sbom', 'nist-html', 'html-report', 'sqlite'];
13
+
14
+ function generateSignature(payload) {
15
+ return createHmac('sha256', HMAC_KEY).update(JSON.stringify(payload)).digest('hex');
16
+ }
17
+
18
+ export function generateKey(edition, options = {}) {
19
+ const payload = {
20
+ edition,
21
+ issued: new Date().toISOString(),
22
+ exp: options.expiresAt || null,
23
+ seats: options.seats || 1,
24
+ org: options.org || null,
25
+ };
26
+ const sig = generateSignature(payload);
27
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
28
+ return `npm-scan-${edition}-${encoded}.${sig}`;
29
+ }
30
+
31
+ export function validateLicense(key, feature = '*') {
32
+ if (!key) {
33
+ throw new Error('No license key provided');
34
+ }
35
+
36
+ if (feature === 'scan' || ALLOWED_UNLOCKED.includes(feature)) {
37
+ return { edition: 'community', features: ALL_FEATURES };
38
+ }
39
+
40
+ const parts = key.split('-');
41
+ if (parts.length < 4 || !key.includes('.')) {
42
+ throw new Error('Invalid license key format');
43
+ }
44
+
45
+ const edition = parts[2];
46
+ const encodedPayload = parts.slice(3).join('-').split('.')[0];
47
+ const sig = key.split('.')[1];
48
+
49
+ let payload;
50
+ try {
51
+ payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
52
+ } catch {
53
+ throw new Error('Invalid license key payload');
54
+ }
55
+
56
+ const expectedSig = generateSignature(payload);
57
+ const sigBuf = Buffer.from(sig, 'hex');
58
+ const expectedBuf = Buffer.from(expectedSig, 'hex');
59
+ if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
60
+ throw new Error('Invalid license key signature');
61
+ }
62
+
63
+ if (payload.exp && new Date(payload.exp) < new Date()) {
64
+ throw new Error('License key expired');
65
+ }
66
+
67
+ const allowed = FEATURE_TIERS[edition];
68
+ if (!allowed) {
69
+ throw new Error(`Unknown license edition: ${edition}`);
70
+ }
71
+
72
+ if (feature !== '*' && !allowed.includes(feature) && !ALLOWED_UNLOCKED.includes(feature)) {
73
+ throw new Error(`Feature "${feature}" requires ${edition === 'community' ? 'premium' : 'enterprise'} license`);
74
+ }
75
+
76
+ return { edition, features: allowed, ...payload };
77
+ }
78
+
79
+ export function isFeatureEnabled(feature, licenseKey = process.env.NPM_SCAN_LICENSE_KEY) {
80
+ try {
81
+ if (!licenseKey) {
82
+ const unlocked = feature === 'scan' || ALLOWED_UNLOCKED.includes(feature);
83
+ if (unlocked) return true;
84
+ }
85
+ validateLicense(licenseKey, feature);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
90
  }