@lateos/npm-scan 0.18.2 → 0.18.3
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 +233 -233
- package/LICENSING.md +19 -19
- package/README.de.md +708 -708
- package/README.fr.md +707 -707
- package/README.ja.md +704 -704
- package/README.md +826 -826
- package/README.zh.md +708 -708
- 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/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 +81 -81
- 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/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 -193
- 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/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
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
{
|
|
2
|
-
"lastUpdated": "2026-05-24T00:00:00.000Z",
|
|
3
|
-
"waves": {
|
|
4
|
-
"wave1": {
|
|
5
|
-
"id": "mini-shai-hulud-wave1",
|
|
6
|
-
"description": "TanStack CI/CD hijack (mid-May 2026) — 84 malicious versions across 42 packages in ~6 minutes via compromised GitHub Actions CI. Forged SLSA BL3 provenance attestations.",
|
|
7
|
-
"windowMinutes": 6,
|
|
8
|
-
"iocs": [
|
|
9
|
-
{
|
|
10
|
-
"type": "packageScope",
|
|
11
|
-
"value": "@tanstack",
|
|
12
|
-
"maliciousVersionRanges": [],
|
|
13
|
-
"notes": "Seed IOC — update from threat intel feed. Affected: @tanstack/router, @tanstack/react-router, @tanstack/query, @tanstack/form, @tanstack/store, @tanstack/virtual, @tanstack/ranger, @tanstack/table."
|
|
14
|
-
}
|
|
15
|
-
]
|
|
16
|
-
},
|
|
17
|
-
"wave2": {
|
|
18
|
-
"id": "mini-shai-hulud-wave2",
|
|
19
|
-
"description": "AntV/atool maintainer account compromise (late May 2026) — 600+ malicious versions across 300+ packages in ~22 minutes. ~16M weekly download blast radius.",
|
|
20
|
-
"windowMinutes": 22,
|
|
21
|
-
"iocs": [
|
|
22
|
-
{
|
|
23
|
-
"type": "publisherAccount",
|
|
24
|
-
"value": "atool",
|
|
25
|
-
"compromiseWindowStart": "2026-05-20T00:00:00.000Z",
|
|
26
|
-
"compromiseWindowEnd": null,
|
|
27
|
-
"notes": "Seed IOC — compromised @antv/atool maintainer account. Update compromise window from threat intel."
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
"type": "packageScope",
|
|
31
|
-
"value": "@antv",
|
|
32
|
-
"maliciousVersionRanges": [],
|
|
33
|
-
"notes": "Blast radius: @antv/g2, @antv/g6, @antv/x6, @antv/l7, echarts-for-react, timeago.js. Seed IOC — update from threat intel."
|
|
34
|
-
}
|
|
35
|
-
]
|
|
36
|
-
},
|
|
37
|
-
"wave3": {
|
|
38
|
-
"id": "nx-console-wave3",
|
|
39
|
-
"description": "Nx Console 18.95.0 VS Code extension compromise (May 18, 2026, CVE-2026-48027, TeamPCP) — contributor token stolen via TanStack wave1 (May 11), 7-day dwell, malicious extension published using npx to fetch 498KB obfuscated Bun payload from dangling orphan commit on nrwl/nx repo. ~3M installs exposed.",
|
|
40
|
-
"windowMinutes": 36,
|
|
41
|
-
"iocs": [
|
|
42
|
-
{
|
|
43
|
-
"type": "extensionId",
|
|
44
|
-
"value": "nrwl.angular-console",
|
|
45
|
-
"maliciousVersionRanges": ["18.95.0"],
|
|
46
|
-
"notes": "Nx Console v18.95.0 — malicious VS Code extension. CVE-2026-48027. Exposure window: 11 min on Marketplace, 36 min on Open VSX."
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"type": "publisherAccount",
|
|
50
|
-
"value": "nrwl",
|
|
51
|
-
"compromiseWindowStart": "2026-05-11T00:00:00.000Z",
|
|
52
|
-
"compromiseWindowEnd": "2026-05-18T13:09:00.000Z",
|
|
53
|
-
"notes": "Nx contributor token stolen via TanStack wave1 on May 11; 7-day dwell before publishing malicious extension on May 18."
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
"type": "packageScope",
|
|
57
|
-
"value": "@nx",
|
|
58
|
-
"maliciousVersionRanges": [],
|
|
59
|
-
"notes": "NX_CONSOLE_DOWNSTREAM: npm packages under @nx scope deployed by compromised Nx contributor. Check for versions published within 7 days of 2026-05-18."
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
"type": "packageScope",
|
|
63
|
-
"value": "nrwl",
|
|
64
|
-
"maliciousVersionRanges": [],
|
|
65
|
-
"notes": "NX_CONSOLE_DOWNSTREAM: nrwl-scoped npm packages — monitor for anomalous burst publishing."
|
|
66
|
-
}
|
|
67
|
-
]
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
"iocs": [
|
|
71
|
-
{
|
|
72
|
-
"type": "sha512",
|
|
73
|
-
"value": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
74
|
-
"package": "@antv/g2",
|
|
75
|
-
"wave": 2,
|
|
76
|
-
"notes": "Placeholder sha512 — replace with actual SHA-512 integrity hash from npm dist.integrity of a confirmed malicious version."
|
|
77
|
-
}
|
|
78
|
-
]
|
|
79
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"lastUpdated": "2026-05-24T00:00:00.000Z",
|
|
3
|
+
"waves": {
|
|
4
|
+
"wave1": {
|
|
5
|
+
"id": "mini-shai-hulud-wave1",
|
|
6
|
+
"description": "TanStack CI/CD hijack (mid-May 2026) — 84 malicious versions across 42 packages in ~6 minutes via compromised GitHub Actions CI. Forged SLSA BL3 provenance attestations.",
|
|
7
|
+
"windowMinutes": 6,
|
|
8
|
+
"iocs": [
|
|
9
|
+
{
|
|
10
|
+
"type": "packageScope",
|
|
11
|
+
"value": "@tanstack",
|
|
12
|
+
"maliciousVersionRanges": [],
|
|
13
|
+
"notes": "Seed IOC — update from threat intel feed. Affected: @tanstack/router, @tanstack/react-router, @tanstack/query, @tanstack/form, @tanstack/store, @tanstack/virtual, @tanstack/ranger, @tanstack/table."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"wave2": {
|
|
18
|
+
"id": "mini-shai-hulud-wave2",
|
|
19
|
+
"description": "AntV/atool maintainer account compromise (late May 2026) — 600+ malicious versions across 300+ packages in ~22 minutes. ~16M weekly download blast radius.",
|
|
20
|
+
"windowMinutes": 22,
|
|
21
|
+
"iocs": [
|
|
22
|
+
{
|
|
23
|
+
"type": "publisherAccount",
|
|
24
|
+
"value": "atool",
|
|
25
|
+
"compromiseWindowStart": "2026-05-20T00:00:00.000Z",
|
|
26
|
+
"compromiseWindowEnd": null,
|
|
27
|
+
"notes": "Seed IOC — compromised @antv/atool maintainer account. Update compromise window from threat intel."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"type": "packageScope",
|
|
31
|
+
"value": "@antv",
|
|
32
|
+
"maliciousVersionRanges": [],
|
|
33
|
+
"notes": "Blast radius: @antv/g2, @antv/g6, @antv/x6, @antv/l7, echarts-for-react, timeago.js. Seed IOC — update from threat intel."
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"wave3": {
|
|
38
|
+
"id": "nx-console-wave3",
|
|
39
|
+
"description": "Nx Console 18.95.0 VS Code extension compromise (May 18, 2026, CVE-2026-48027, TeamPCP) — contributor token stolen via TanStack wave1 (May 11), 7-day dwell, malicious extension published using npx to fetch 498KB obfuscated Bun payload from dangling orphan commit on nrwl/nx repo. ~3M installs exposed.",
|
|
40
|
+
"windowMinutes": 36,
|
|
41
|
+
"iocs": [
|
|
42
|
+
{
|
|
43
|
+
"type": "extensionId",
|
|
44
|
+
"value": "nrwl.angular-console",
|
|
45
|
+
"maliciousVersionRanges": ["18.95.0"],
|
|
46
|
+
"notes": "Nx Console v18.95.0 — malicious VS Code extension. CVE-2026-48027. Exposure window: 11 min on Marketplace, 36 min on Open VSX."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "publisherAccount",
|
|
50
|
+
"value": "nrwl",
|
|
51
|
+
"compromiseWindowStart": "2026-05-11T00:00:00.000Z",
|
|
52
|
+
"compromiseWindowEnd": "2026-05-18T13:09:00.000Z",
|
|
53
|
+
"notes": "Nx contributor token stolen via TanStack wave1 on May 11; 7-day dwell before publishing malicious extension on May 18."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"type": "packageScope",
|
|
57
|
+
"value": "@nx",
|
|
58
|
+
"maliciousVersionRanges": [],
|
|
59
|
+
"notes": "NX_CONSOLE_DOWNSTREAM: npm packages under @nx scope deployed by compromised Nx contributor. Check for versions published within 7 days of 2026-05-18."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"type": "packageScope",
|
|
63
|
+
"value": "nrwl",
|
|
64
|
+
"maliciousVersionRanges": [],
|
|
65
|
+
"notes": "NX_CONSOLE_DOWNSTREAM: nrwl-scoped npm packages — monitor for anomalous burst publishing."
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"iocs": [
|
|
71
|
+
{
|
|
72
|
+
"type": "sha512",
|
|
73
|
+
"value": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
74
|
+
"package": "@antv/g2",
|
|
75
|
+
"wave": 2,
|
|
76
|
+
"notes": "Placeholder sha512 — replace with actual SHA-512 integrity hash from npm dist.integrity of a confirmed malicious version."
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
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
|
+
};
|