@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.
- 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 -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 +75 -44
- 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/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/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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
2
|
+
|
|
3
|
+
const BINARY_DIRS = ['bin/', 'native/'];
|
|
4
|
+
const BINARY_EXTS = ['.exe', '.dll', '.so', '.dylib', '.wasm', '.node', '.o', '.a'];
|
|
5
|
+
const BINARY_FILENAMES = ['bun', 'deno', 'go', 'rustc', 'python', 'python3', 'ruby', 'php'];
|
|
6
|
+
|
|
7
|
+
const CHILD_PROC_RE = /\b(?:spawn|exec|execSync|spawnSync|fork)\s*\(/g;
|
|
8
|
+
const FS_CHMOD_RE = /fs\.chmod\s*\(/g;
|
|
9
|
+
|
|
10
|
+
function detectMagicBytes(content) {
|
|
11
|
+
if (!content || content.length < 4) return null;
|
|
12
|
+
|
|
13
|
+
const c0 = content.charCodeAt(0);
|
|
14
|
+
const c1 = content.charCodeAt(1);
|
|
15
|
+
const c2 = content.charCodeAt(2);
|
|
16
|
+
const c3 = content.charCodeAt(3);
|
|
17
|
+
|
|
18
|
+
if (c0 === 0x7f && content.slice(1, 4) === 'ELF') return 'elf_embedded';
|
|
19
|
+
if (c0 === 0x4d && c1 === 0x5a) return 'pe_embedded';
|
|
20
|
+
if (c0 === 0x00 && content.slice(1, 4) === 'asm') return 'wasm_embedded';
|
|
21
|
+
|
|
22
|
+
const machO = (c0 === 0xfe && c1 === 0xed && c2 === 0xfa && (c3 === 0xce || c3 === 0xcf)) ||
|
|
23
|
+
(c0 === 0xce && c1 === 0xfa && c2 === 0xed && (c3 === 0xfe || c3 === 0xcf)) ||
|
|
24
|
+
(c0 === 0xcf && c1 === 0xfa && c2 === 0xed && c3 === 0xfe);
|
|
25
|
+
if (machO) return 'macho_embedded';
|
|
26
|
+
|
|
27
|
+
const universal = c0 === 0xca && c1 === 0xfe && c2 === 0xba && c3 === 0xbe;
|
|
28
|
+
if (universal) return 'macho_embedded';
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isInBinaryDir(filePath) {
|
|
34
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
35
|
+
return BINARY_DIRS.some(dir => normalized.includes(`/${dir}`) || normalized.startsWith(dir));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasBinaryExt(filePath) {
|
|
39
|
+
const lower = filePath.toLowerCase();
|
|
40
|
+
return BINARY_EXTS.some(ext => lower.endsWith(ext));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isKnownBinaryName(fileName) {
|
|
44
|
+
const base = fileName.replace(/\.\w+$/, '').toLowerCase();
|
|
45
|
+
return BINARY_FILENAMES.includes(base);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isDeclared(pkgJson, fileName) {
|
|
49
|
+
if (!pkgJson) return false;
|
|
50
|
+
const baseName = fileName.split(/[/\\]/).pop();
|
|
51
|
+
|
|
52
|
+
if (pkgJson.bin) {
|
|
53
|
+
if (typeof pkgJson.bin === 'string' && pkgJson.bin === baseName) return true;
|
|
54
|
+
if (typeof pkgJson.bin === 'object' && Object.values(pkgJson.bin).some(v => v === baseName || v.endsWith(`/${baseName}`))) return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (pkgJson.optionalDependencies) {
|
|
58
|
+
for (const [name, val] of Object.entries(pkgJson.optionalDependencies)) {
|
|
59
|
+
if (name === baseName) return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (pkgJson.gypfile === true || pkgJson.scripts?.install?.includes('node-gyp') || pkgJson.scripts?.install?.includes('node-pre-gyp')) {
|
|
64
|
+
if (baseName.endsWith('.node')) return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const name = 'tier1-binary-embed';
|
|
71
|
+
|
|
72
|
+
export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
73
|
+
const pkgName = pkgJson?.name;
|
|
74
|
+
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
|
|
75
|
+
|
|
76
|
+
if (!allFiles || allFiles.length === 0) return [];
|
|
77
|
+
|
|
78
|
+
if (pkgName && (
|
|
79
|
+
pkgName === 'electron' || pkgName === 'puppeteer' || pkgName === 'sharp' ||
|
|
80
|
+
pkgName === 'esbuild' || pkgName === 'node-gyp' || pkgName === 'node-pre-gyp' ||
|
|
81
|
+
pkgName === '@mapbox/node-pre-gyp'
|
|
82
|
+
)) return [];
|
|
83
|
+
|
|
84
|
+
const binaries = [];
|
|
85
|
+
|
|
86
|
+
for (const f of allFiles) {
|
|
87
|
+
const content = f.content || '';
|
|
88
|
+
const filePath = f.path || f.name || '';
|
|
89
|
+
const fileName = filePath.split(/[/\\]/).pop();
|
|
90
|
+
const fileSize = content.length;
|
|
91
|
+
|
|
92
|
+
const magic = detectMagicBytes(content);
|
|
93
|
+
const inBinDir = isInBinaryDir(filePath);
|
|
94
|
+
const hasExt = hasBinaryExt(filePath);
|
|
95
|
+
const knownName = isKnownBinaryName(fileName);
|
|
96
|
+
const largeFile = fileSize > 100000000;
|
|
97
|
+
|
|
98
|
+
if (magic || inBinDir || hasExt || knownName) {
|
|
99
|
+
const declared = isDeclared(pkgJson, filePath);
|
|
100
|
+
|
|
101
|
+
binaries.push({
|
|
102
|
+
file: filePath,
|
|
103
|
+
size: fileSize,
|
|
104
|
+
magic,
|
|
105
|
+
inBinDir,
|
|
106
|
+
hasExt,
|
|
107
|
+
knownName,
|
|
108
|
+
declared,
|
|
109
|
+
largeFile,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (binaries.length === 0) return [];
|
|
115
|
+
|
|
116
|
+
const jsCode = (jsFiles || []).map(f => f.content || '').join('\n');
|
|
117
|
+
const invoked = CHILD_PROC_RE.test(jsCode) || FS_CHMOD_RE.test(jsCode);
|
|
118
|
+
|
|
119
|
+
const invokedFiles = [];
|
|
120
|
+
if (jsFiles && invoked) {
|
|
121
|
+
for (const f of jsFiles) {
|
|
122
|
+
const c = f.content || '';
|
|
123
|
+
CHILD_PROC_RE.lastIndex = 0;
|
|
124
|
+
FS_CHMOD_RE.lastIndex = 0;
|
|
125
|
+
if (CHILD_PROC_RE.test(c) || FS_CHMOD_RE.test(c)) {
|
|
126
|
+
invokedFiles.push(f.path || f.name || 'unknown.js');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const findings = [];
|
|
132
|
+
|
|
133
|
+
for (const bin of binaries) {
|
|
134
|
+
let baseScore;
|
|
135
|
+
let subtype;
|
|
136
|
+
|
|
137
|
+
if (bin.magic === 'elf_embedded') {
|
|
138
|
+
baseScore = 95;
|
|
139
|
+
subtype = 'elf_embedded';
|
|
140
|
+
} else if (bin.magic === 'pe_embedded') {
|
|
141
|
+
baseScore = 95;
|
|
142
|
+
subtype = 'pe_embedded';
|
|
143
|
+
} else if (bin.magic === 'macho_embedded') {
|
|
144
|
+
baseScore = 95;
|
|
145
|
+
subtype = 'macho_embedded';
|
|
146
|
+
} else if (bin.magic === 'wasm_embedded') {
|
|
147
|
+
baseScore = 60;
|
|
148
|
+
subtype = 'wasm_embedded';
|
|
149
|
+
} else {
|
|
150
|
+
baseScore = 60;
|
|
151
|
+
subtype = 'magic_byte_unknown';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let score = baseScore;
|
|
155
|
+
|
|
156
|
+
if (bin.inBinDir) score += 15;
|
|
157
|
+
|
|
158
|
+
if (!bin.declared) score += 50;
|
|
159
|
+
|
|
160
|
+
if (invoked && invokedFiles.length > 0) score += 25;
|
|
161
|
+
|
|
162
|
+
const confidenceScore = Math.max(50, Math.min(100, score));
|
|
163
|
+
|
|
164
|
+
function severityLabel(sc) {
|
|
165
|
+
if (sc >= 90) return 'critical';
|
|
166
|
+
if (sc >= 70) return 'high';
|
|
167
|
+
return 'medium';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function confidenceLabel(sc) {
|
|
171
|
+
if (sc >= 95) return 'CRITICAL';
|
|
172
|
+
if (sc >= 80) return 'HIGH';
|
|
173
|
+
if (sc >= 60) return 'MEDIUM';
|
|
174
|
+
return 'LOW';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const evidence = [
|
|
178
|
+
`binary: ${bin.file.split(/[/\\]/).pop()}${bin.magic ? ` (${bin.magic.toUpperCase().replace('_EMBEDDED', '')})` : ''}`,
|
|
179
|
+
`path: ${bin.file}`,
|
|
180
|
+
`declared: ${bin.declared}`,
|
|
181
|
+
];
|
|
182
|
+
if (invoked && invokedFiles.length > 0) {
|
|
183
|
+
evidence.push(`invoked: child_process usage in ${invokedFiles.length} file(s)`);
|
|
184
|
+
evidence.push(`invoked_file: ${invokedFiles[0]}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const locations = [
|
|
188
|
+
{ file: bin.file, size: bin.size },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
if (invokedFiles.length > 0) {
|
|
192
|
+
locations.push({ file: invokedFiles[0], line: 0 });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let message;
|
|
196
|
+
if (!bin.declared) {
|
|
197
|
+
message = `Undeclared binary detected: ${bin.file.split(/[/\\]/).pop()}`;
|
|
198
|
+
} else if (invoked) {
|
|
199
|
+
message = `Binary ${bin.file.split(/[/\\]/).pop()} invoked from JavaScript`;
|
|
200
|
+
} else {
|
|
201
|
+
message = `Binary embedded in package: ${bin.file.split(/[/\\]/).pop()}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
findings.push({
|
|
205
|
+
detector: 'tier1-binary-embed',
|
|
206
|
+
id: 'TIER1-BINARY-EMBED',
|
|
207
|
+
severity: severityLabel(confidenceScore),
|
|
208
|
+
confidence: confidenceLabel(confidenceScore),
|
|
209
|
+
confidenceScore,
|
|
210
|
+
subtype,
|
|
211
|
+
message,
|
|
212
|
+
evidence,
|
|
213
|
+
locations,
|
|
214
|
+
reference: 'Campaign 2',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return findings;
|
|
219
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
2
|
+
import * as acorn from 'acorn';
|
|
3
|
+
|
|
4
|
+
const FS_READ_RE = /fs\.(?:readFile|readFileSync|readdir|readdirSync)\s*\(/g;
|
|
5
|
+
const HTTP_FETCH_RE = /\b(?:fetch|axios|got|superagent|request)\s*\(/g;
|
|
6
|
+
const CURL_WGET_RE = /\b(?:curl|wget|powershell)\s+/gi;
|
|
7
|
+
const CHILD_PROC_RE = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\s*\(/g;
|
|
8
|
+
const DOMAIN_EXTRACT_RE = /https?:\/\/([^'"\s)\];,\n\r]+)/gi;
|
|
9
|
+
const GITHUB_DOMAIN_RE = /github\.com/i;
|
|
10
|
+
const NPMJS_DOMAIN_RE = /npmjs\.(?:com|org)/i;
|
|
11
|
+
|
|
12
|
+
const AWS_KEY_RE = /AKIA[0-9A-Z]{16}/g;
|
|
13
|
+
const NPM_TOKEN_RE = /npm_[a-zA-Z0-9]{36}/g;
|
|
14
|
+
const GH_TOKEN_RE = /ghp_[a-zA-Z0-9]{30,40}/g;
|
|
15
|
+
const GH_OLD_TOKEN_RE = /gho_[a-zA-Z0-9]{36}/g;
|
|
16
|
+
const GITLAB_TOKEN_RE = /glpat-[a-zA-Z0-9_-]{20,}/g;
|
|
17
|
+
|
|
18
|
+
const ENV_DUMP_RE = /process\.env\.(?:AWS_[A-Z_]+|NPM_TOKEN|NPM_AUTH_TOKEN|GIT_TOKEN|SSH_KEY)/g;
|
|
19
|
+
|
|
20
|
+
const EVAL_RE = /\beval\s*\(/g;
|
|
21
|
+
const FUNCTION_CTOR_RE = /\bFunction\s*\(/g;
|
|
22
|
+
const B64_STRING_RE = /['"`]([A-Za-z0-9+/]{40,}={0,2})['"`]/g;
|
|
23
|
+
|
|
24
|
+
function shannonEntropy(s) {
|
|
25
|
+
const len = s.length;
|
|
26
|
+
if (len === 0) return 0;
|
|
27
|
+
const freq = {};
|
|
28
|
+
for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
|
|
29
|
+
let entropy = 0;
|
|
30
|
+
for (const count of Object.values(freq)) {
|
|
31
|
+
const p = count / len;
|
|
32
|
+
entropy -= p * Math.log2(p);
|
|
33
|
+
}
|
|
34
|
+
return entropy;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isMinified(content) {
|
|
38
|
+
const identifiers = content.match(/\b[a-zA-Z_$][\w$]*\b/g);
|
|
39
|
+
if (identifiers && identifiers.length > 0) {
|
|
40
|
+
const avgLen = identifiers.reduce((s, id) => s + id.length, 0) / identifiers.length;
|
|
41
|
+
if (avgLen < 3) return true;
|
|
42
|
+
}
|
|
43
|
+
return shannonEntropy(content) > 5.5;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractDomains(content) {
|
|
47
|
+
const domains = [];
|
|
48
|
+
let match;
|
|
49
|
+
DOMAIN_EXTRACT_RE.lastIndex = 0;
|
|
50
|
+
while ((match = DOMAIN_EXTRACT_RE.exec(content)) !== null) {
|
|
51
|
+
domains.push(match[1]);
|
|
52
|
+
}
|
|
53
|
+
return domains;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractCredentials(content) {
|
|
57
|
+
const creds = [];
|
|
58
|
+
let match;
|
|
59
|
+
AWS_KEY_RE.lastIndex = 0;
|
|
60
|
+
while ((match = AWS_KEY_RE.exec(content)) !== null) {
|
|
61
|
+
creds.push({ type: 'cred_regex_aws', value: match[0], index: match.index });
|
|
62
|
+
}
|
|
63
|
+
NPM_TOKEN_RE.lastIndex = 0;
|
|
64
|
+
while ((match = NPM_TOKEN_RE.exec(content)) !== null) {
|
|
65
|
+
creds.push({ type: 'cred_regex_npm_token', value: match[0], index: match.index });
|
|
66
|
+
}
|
|
67
|
+
GH_TOKEN_RE.lastIndex = 0;
|
|
68
|
+
while ((match = GH_TOKEN_RE.exec(content)) !== null) {
|
|
69
|
+
creds.push({ type: 'cred_regex_gh_token', value: match[0], index: match.index });
|
|
70
|
+
}
|
|
71
|
+
return creds;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getLineColumn(content, index) {
|
|
75
|
+
const lines = content.slice(0, index).split('\n');
|
|
76
|
+
return { line: lines.length, column: lines[lines.length - 1].length + 1 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function patternMatcher(f, content) {
|
|
80
|
+
const file = f.path || f.name || 'unknown';
|
|
81
|
+
const result = {
|
|
82
|
+
file,
|
|
83
|
+
hasPattern: false,
|
|
84
|
+
patterns: [],
|
|
85
|
+
locations: [],
|
|
86
|
+
evidence: [],
|
|
87
|
+
domainsFound: [],
|
|
88
|
+
credsFound: [],
|
|
89
|
+
isObfuscated: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (!content) return result;
|
|
93
|
+
|
|
94
|
+
result.isObfuscated = isMinified(content) || EVAL_RE.test(content) || FUNCTION_CTOR_RE.test(content);
|
|
95
|
+
|
|
96
|
+
FS_READ_RE.lastIndex = 0;
|
|
97
|
+
HTTP_FETCH_RE.lastIndex = 0;
|
|
98
|
+
CHILD_PROC_RE.lastIndex = 0;
|
|
99
|
+
CURL_WGET_RE.lastIndex = 0;
|
|
100
|
+
|
|
101
|
+
const hasFsRead = FS_READ_RE.test(content);
|
|
102
|
+
const hasHttpFetch = HTTP_FETCH_RE.test(content);
|
|
103
|
+
const hasChildProc = CHILD_PROC_RE.test(content);
|
|
104
|
+
const hasCurlWget = CURL_WGET_RE.test(content);
|
|
105
|
+
|
|
106
|
+
const domains = extractDomains(content);
|
|
107
|
+
const externalDomains = domains.filter(d => !NPMJS_DOMAIN_RE.test(d));
|
|
108
|
+
const gitHubDomains = domains.filter(d => GITHUB_DOMAIN_RE.test(d) && !NPMJS_DOMAIN_RE.test(d));
|
|
109
|
+
|
|
110
|
+
if (hasFsRead && hasHttpFetch) {
|
|
111
|
+
const isGithubOnly = gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
|
|
112
|
+
result.hasPattern = true;
|
|
113
|
+
result.patterns.push({ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil', baseScore: 80 });
|
|
114
|
+
result.domainsFound.push(...domains);
|
|
115
|
+
FS_READ_RE.lastIndex = 0;
|
|
116
|
+
const fsMatch = FS_READ_RE.exec(content);
|
|
117
|
+
if (fsMatch) {
|
|
118
|
+
const lc = getLineColumn(content, fsMatch.index);
|
|
119
|
+
result.locations.push({ file, line: lc.line, column: lc.column });
|
|
120
|
+
}
|
|
121
|
+
result.evidence.push(isGithubOnly
|
|
122
|
+
? 'pattern: fs.readFile + network to GitHub'
|
|
123
|
+
: 'pattern: fs.readFile + external fetch');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (hasFsRead && (hasChildProc || hasCurlWget)) {
|
|
127
|
+
const isGithubOnly = gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
|
|
128
|
+
result.hasPattern = true;
|
|
129
|
+
result.patterns.push({ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil', baseScore: 80 });
|
|
130
|
+
result.domainsFound.push(...domains);
|
|
131
|
+
FS_READ_RE.lastIndex = 0;
|
|
132
|
+
const fsMatch = FS_READ_RE.exec(content);
|
|
133
|
+
if (fsMatch) {
|
|
134
|
+
const lc = getLineColumn(content, fsMatch.index);
|
|
135
|
+
result.locations.push({ file, line: lc.line, column: lc.column });
|
|
136
|
+
}
|
|
137
|
+
result.evidence.push(isGithubOnly
|
|
138
|
+
? 'pattern: fs.readFile + child_process to GitHub'
|
|
139
|
+
: 'pattern: fs.readFile + child_process network');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const creds = extractCredentials(content);
|
|
143
|
+
if (creds.length > 0) {
|
|
144
|
+
result.hasPattern = true;
|
|
145
|
+
result.credsFound.push(...creds);
|
|
146
|
+
const primaryType = creds[0].type;
|
|
147
|
+
result.patterns.push({ subtype: primaryType, baseScore: 85 });
|
|
148
|
+
const lc = getLineColumn(content, creds[0].index);
|
|
149
|
+
result.locations.push({ file, line: lc.line, column: lc.column });
|
|
150
|
+
const typeNames = [...new Set(creds.map(c => c.type))];
|
|
151
|
+
result.evidence.push(`hardcoded_credentials: ${creds.length} (${typeNames.join(', ')})`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ENV_DUMP_RE.lastIndex = 0;
|
|
155
|
+
const envMatch = ENV_DUMP_RE.exec(content);
|
|
156
|
+
if (envMatch) {
|
|
157
|
+
result.hasPattern = true;
|
|
158
|
+
result.patterns.push({ subtype: 'env_dump', baseScore: 80 });
|
|
159
|
+
const lc = getLineColumn(content, envMatch.index);
|
|
160
|
+
result.locations.push({ file, line: lc.line, column: lc.column });
|
|
161
|
+
result.evidence.push('pattern: process.env.AWS_* dump');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const name = 'tier1-infostealer';
|
|
168
|
+
|
|
169
|
+
export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
170
|
+
const pkgName = pkgJson?.name;
|
|
171
|
+
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
|
|
172
|
+
|
|
173
|
+
const files = jsFiles || [];
|
|
174
|
+
if (files.length === 0) return [];
|
|
175
|
+
|
|
176
|
+
let parseFailCount = 0;
|
|
177
|
+
|
|
178
|
+
for (const f of files) {
|
|
179
|
+
const content = f.content || '';
|
|
180
|
+
if (!content) continue;
|
|
181
|
+
try {
|
|
182
|
+
acorn.parse(content, { ecmaVersion: 'latest' });
|
|
183
|
+
} catch {
|
|
184
|
+
parseFailCount++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (files.length >= 20 && parseFailCount / files.length >= 0.1) return [];
|
|
189
|
+
|
|
190
|
+
const perFile = files.map(f => patternMatcher(f, f.content || ''));
|
|
191
|
+
const filesWithPatterns = perFile.filter(p => p.hasPattern);
|
|
192
|
+
|
|
193
|
+
if (filesWithPatterns.length === 0) return [];
|
|
194
|
+
|
|
195
|
+
let highestBase = 0;
|
|
196
|
+
let mainSubtype = '';
|
|
197
|
+
let isObfuscated = false;
|
|
198
|
+
const allEvidence = [];
|
|
199
|
+
const allLocations = [];
|
|
200
|
+
const involvedFiles = [];
|
|
201
|
+
const hasCreds = false;
|
|
202
|
+
|
|
203
|
+
for (const f of filesWithPatterns) {
|
|
204
|
+
if (!involvedFiles.includes(f.file)) involvedFiles.push(f.file);
|
|
205
|
+
allLocations.push(...f.locations);
|
|
206
|
+
allEvidence.push(...f.evidence);
|
|
207
|
+
if (f.isObfuscated) isObfuscated = true;
|
|
208
|
+
for (const p of f.patterns) {
|
|
209
|
+
if (p.baseScore > highestBase) {
|
|
210
|
+
highestBase = p.baseScore;
|
|
211
|
+
mainSubtype = p.subtype;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let baseScore = highestBase;
|
|
217
|
+
|
|
218
|
+
const anyCredPattern = filesWithPatterns.some(f => f.patterns.some(p => p.subtype.startsWith('cred_')));
|
|
219
|
+
if (anyCredPattern) {
|
|
220
|
+
baseScore = Math.min(100, Math.round(baseScore * 2.5));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isObfuscated) baseScore += 15;
|
|
224
|
+
|
|
225
|
+
if (involvedFiles.length > 1) {
|
|
226
|
+
baseScore = Math.min(100, Math.round(baseScore * 1.3));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const confidenceScore = Math.max(50, Math.min(100, baseScore));
|
|
230
|
+
|
|
231
|
+
function confidenceLabel(score) {
|
|
232
|
+
if (score >= 95) return 'CRITICAL';
|
|
233
|
+
if (score >= 80) return 'HIGH';
|
|
234
|
+
return 'MEDIUM';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const evidenceSet = new Set(allEvidence);
|
|
238
|
+
const evidence = [...evidenceSet].slice(0, 10);
|
|
239
|
+
|
|
240
|
+
const locationMap = new Map();
|
|
241
|
+
for (const loc of allLocations) {
|
|
242
|
+
const key = `${loc.file}:${loc.line}:${loc.column}`;
|
|
243
|
+
if (!locationMap.has(key)) locationMap.set(key, loc);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const isCritical = anyCredPattern;
|
|
247
|
+
const severity = isCritical ? 'critical' : confidenceScore >= 80 ? 'high' : 'medium';
|
|
248
|
+
|
|
249
|
+
const domainSummary = filesWithPatterns
|
|
250
|
+
.flatMap(f => f.domainsFound)
|
|
251
|
+
.filter(Boolean)
|
|
252
|
+
.slice(0, 3);
|
|
253
|
+
|
|
254
|
+
const credCount = filesWithPatterns.reduce((s, f) => s + f.credsFound.length, 0);
|
|
255
|
+
|
|
256
|
+
let message;
|
|
257
|
+
if (anyCredPattern) {
|
|
258
|
+
message = `Hardcoded credentials detected (${credCount} found)`;
|
|
259
|
+
} else if (involvedFiles.length > 1) {
|
|
260
|
+
message = `Cross-file exfiltration detected across ${involvedFiles.length} files`;
|
|
261
|
+
} else if (mainSubtype === 'env_dump') {
|
|
262
|
+
message = 'Environment variable harvesting detected';
|
|
263
|
+
} else {
|
|
264
|
+
message = 'Filesystem exfiltration to external domain detected';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return [{
|
|
268
|
+
detector: 'tier1-infostealer',
|
|
269
|
+
id: 'TIER1-INFOSTEALER',
|
|
270
|
+
severity,
|
|
271
|
+
confidence: confidenceLabel(confidenceScore),
|
|
272
|
+
confidenceScore,
|
|
273
|
+
subtype: mainSubtype || 'fs_exfil',
|
|
274
|
+
message,
|
|
275
|
+
evidence,
|
|
276
|
+
locations: [...locationMap.values()],
|
|
277
|
+
crossFiles: [...new Set(involvedFiles)],
|
|
278
|
+
reference: 'Campaign 2 & 3',
|
|
279
|
+
}];
|
|
280
|
+
}
|