@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/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
  }