@lateos/npm-scan 0.16.0 → 0.16.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +20 -20
- package/.husky/pre-commit +1 -1
- package/CHANGELOG.md +199 -199
- package/LICENSING.md +19 -19
- package/README.de.md +708 -669
- package/README.fr.md +707 -668
- package/README.ja.md +704 -665
- package/README.md +826 -801
- package/README.zh.md +708 -669
- package/SECURITY.md +72 -72
- package/backend/cra.js +68 -68
- package/backend/db/schema.sql +32 -32
- package/backend/db.js +88 -88
- package/backend/detectors/atk-001-lifecycle.js +17 -17
- package/backend/detectors/atk-002-obfusc.js +261 -261
- package/backend/detectors/atk-003-creds.js +13 -13
- package/backend/detectors/atk-004-persist.js +13 -13
- package/backend/detectors/atk-005-exfil.js +13 -13
- package/backend/detectors/atk-006-depconf.js +14 -14
- package/backend/detectors/atk-007-typosquat.js +34 -34
- package/backend/detectors/atk-008-tarball-tamper.js +91 -91
- package/backend/detectors/atk-009-dormant-trigger.js +62 -62
- package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
- package/backend/detectors/atk-011-transitive-prop.js +76 -76
- package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +24 -0
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +24 -0
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +90 -0
- package/backend/detectors/axios-poisoning/index.js +94 -0
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
- package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
- package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
- package/backend/detectors/hf-impersonation/index.js +396 -396
- package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
- package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
- package/backend/detectors/hf-impersonation/simhash.js +46 -46
- package/backend/detectors/index.js +75 -38
- package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
- package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
- package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
- package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
- package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
- package/backend/detectors/megalodon/index.js +80 -80
- package/backend/detectors/megalodon/types.js +9 -9
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
- package/backend/detectors/mini-shai-hulud/index.js +118 -118
- package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
- package/backend/detectors/msh-supplement/d1-obfuscation.js +18 -0
- package/backend/detectors/msh-supplement/d2-persistence.js +47 -0
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +35 -0
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +33 -0
- package/backend/detectors/msh-supplement/index.js +107 -0
- package/backend/detectors/tier1-binary-embed.js +219 -0
- package/backend/detectors/tier1-infostealer.js +280 -0
- package/backend/detectors/tier1-lifecycle-hook.js +176 -0
- package/backend/detectors/tier1-metadata-spoof.js +180 -0
- package/backend/detectors/tier1-typosquat.js +219 -0
- package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +77 -0
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +37 -0
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +66 -0
- package/backend/detectors/typosquat-vpmdhaj/index.js +98 -0
- package/backend/fetch.js +175 -175
- package/backend/index.js +4 -4
- package/backend/license.js +89 -89
- package/backend/lockfile.js +379 -379
- package/backend/pdf.js +245 -245
- package/backend/policy.js +193 -176
- package/backend/provenance.js +79 -0
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- package/backend/siem/cef.js +32 -32
- package/backend/siem/ecs.js +40 -40
- package/backend/siem/index.js +18 -18
- package/backend/siem/qradar.js +56 -56
- package/backend/siem/sentinel.js +27 -27
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
- package/backend/vsix-scan/detectors/burst-publish.js +52 -52
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
- package/backend/vsix-scan/detectors/known-ioc.js +105 -105
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
- package/backend/vsix-scan/index.js +183 -183
- package/backend/vsix-scan/marketplace-client.js +145 -145
- package/backend/vsix-scan/vsix-iocs.json +31 -31
- package/cli/cli.js +458 -458
- package/deploy/helm/npm-scan/Chart.yaml +21 -21
- package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
- package/deploy/helm/npm-scan/templates/api.yaml +93 -93
- package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
- package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
- package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
- package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
- package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
- package/deploy/helm/npm-scan/values.yaml +102 -102
- package/package.json +57 -57
- package/scripts/download-corpus.js +30 -30
- package/scripts/gen-mal-corpus.js +34 -34
- package/scripts/generate-campaign-fixtures.js +170 -0
- package/src/config/top-5000.json +87 -0
- package/test/fixtures/lockfiles/npm-lock.json +68 -68
- package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
- package/test/fixtures/lockfiles/yarn.lock +103 -103
- 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
|
+
};
|
package/backend/license.js
CHANGED
|
@@ -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
|
}
|