@lateos/npm-scan 0.15.6 → 0.16.4
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/README.de.md +46 -3
- package/README.fr.md +49 -6
- package/README.ja.md +45 -2
- package/README.md +43 -2
- package/README.zh.md +45 -2
- 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/index.js +10 -0
- 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/node-ipc-compromise/d1-version-blocklist.js +24 -0
- package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +19 -0
- package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +40 -0
- package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +31 -0
- package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +73 -0
- package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +37 -0
- package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +49 -0
- package/backend/detectors/node-ipc-compromise/d6-bootstrap-resolver.js +40 -0
- package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +42 -0
- package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +27 -0
- package/backend/detectors/node-ipc-compromise/d9-temp-artifact.js +20 -0
- package/backend/detectors/node-ipc-compromise/index.js +93 -0
- package/backend/detectors/node-ipc-compromise/iocs.json +59 -0
- package/backend/detectors/trapdoor/d1-campaign-marker.js +20 -0
- package/backend/detectors/trapdoor/d2-payload-fingerprint.js +22 -0
- package/backend/detectors/trapdoor/d3-publisher-blocklist.js +10 -0
- package/backend/detectors/trapdoor/d4-gists-exfil.js +34 -0
- package/backend/detectors/trapdoor/d5-ai-poisoning.js +35 -0
- package/backend/detectors/trapdoor/d6-lure-name.js +42 -0
- package/backend/detectors/trapdoor/d7-crypto-primitives.js +22 -0
- package/backend/detectors/trapdoor/d8-xor-key.js +15 -0
- package/backend/detectors/trapdoor/d9-cred-validation.js +32 -0
- package/backend/detectors/trapdoor/index.js +77 -0
- package/backend/detectors/trapdoor/iocs.json +51 -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/provenance.js +79 -0
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const KNOWN_DECOYS = ['plain-crypto-js'];
|
|
2
|
+
|
|
3
|
+
export function scanDecoyDependency(pkgJson) {
|
|
4
|
+
const deps = { ...pkgJson?.dependencies, ...pkgJson?.devDependencies, ...pkgJson?.peerDependencies };
|
|
5
|
+
const findings = [];
|
|
6
|
+
|
|
7
|
+
for (const depName of KNOWN_DECOYS) {
|
|
8
|
+
if (deps[depName]) {
|
|
9
|
+
findings.push({
|
|
10
|
+
injectedDependency: depName,
|
|
11
|
+
pattern: 'Pre-staged decoy for supply chain attack',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (findings.length > 0) {
|
|
17
|
+
return {
|
|
18
|
+
triggered: true,
|
|
19
|
+
findings,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { triggered: false, findings: [] };
|
|
24
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const SUSPICIOUS_HOOKS = ['postinstall', 'install', 'preinstall'];
|
|
2
|
+
const TEMP_DIR_RE = /(?:os\.tmpdir|tmpdir|temp|%TEMP%|\/tmp|\/var\/tmp)/;
|
|
3
|
+
const POWERSHELL_RE = /powershell|pwsh|cmd\.exe|Invoke-Expression|IEX\s*\(/;
|
|
4
|
+
const LAUNCHD_RE = /\/Library\/Launch(Daemons|Agents)\/|launchctl\s+(load|start|submit)/;
|
|
5
|
+
const SYSTEMD_SERVICE_RE = /\/etc\/systemd\/system\/|systemctl\s+(enable|start|daemon-reload)/;
|
|
6
|
+
const CRON_PERSIST_RE = /crontab\s+-[ei]|@reboot\s+|@daily\s+|@hourly\s+/;
|
|
7
|
+
const DLL_LOAD_RE = /LoadLibrary|dlopen|LoadLibraryEx|lib\.(?:LoadLibrary|dlopen)/;
|
|
8
|
+
const PROCESS_INJECT_RE = /CreateRemoteThread|VirtualAllocEx|WriteProcessMemory|NtCreateThreadEx/;
|
|
9
|
+
const NET_CALLBACK_RE = /(?:https?:\/\/|wss?:\/\/|ws:\/\/)(?:[^\s'"]*\.[^\s'"]{2,})/;
|
|
10
|
+
const BINARY_DROP_RE = /(?:fs\.writeFileSync|writeFile|writeFileSync)\s*\([^)]*(?:\.exe|\.dll|\.bin|\.bat|\.ps1)/;
|
|
11
|
+
|
|
12
|
+
const SUSPICIOUS_HOOK_PATTERNS = [
|
|
13
|
+
/curl|wget|fetch|https?:\/\//,
|
|
14
|
+
/powershell|cmd\.exe|bash\b|sh\b/,
|
|
15
|
+
/process\.exit|fs\.chmod|exec(?:Sync)?\s*\(/,
|
|
16
|
+
/spawn|fork|detached/,
|
|
17
|
+
/systemd|launchctl|crontab|schtasks/,
|
|
18
|
+
/LoadLibrary|dlopen/,
|
|
19
|
+
/eval|Function\s*\(/,
|
|
20
|
+
/__dirname|__filename/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function scanPostinstallRAT(pkgJson, files = []) {
|
|
24
|
+
const scripts = pkgJson?.scripts || {};
|
|
25
|
+
const code = files.map(f => f.content || '').join('\n');
|
|
26
|
+
|
|
27
|
+
const activeHooks = [];
|
|
28
|
+
for (const hook of SUSPICIOUS_HOOKS) {
|
|
29
|
+
if (scripts[hook]) {
|
|
30
|
+
activeHooks.push({ hook, command: scripts[hook] });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (activeHooks.length === 0) {
|
|
35
|
+
return { triggered: false, platforms: [], c2Indicators: [], payloadType: null, hooks: [], hasBinaryDrop: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const combined = code + '\n' + activeHooks.map(h => h.command).join('\n');
|
|
39
|
+
|
|
40
|
+
const hasSuspiciousCode = SUSPICIOUS_HOOK_PATTERNS.some(p => p.test(combined));
|
|
41
|
+
|
|
42
|
+
if (activeHooks.length > 0 && !hasSuspiciousCode) {
|
|
43
|
+
return { triggered: false, platforms: [], c2Indicators: [], payloadType: null, hooks: [], hasBinaryDrop: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const platforms = [];
|
|
47
|
+
let c2Indicators = [];
|
|
48
|
+
let hasBinaryDrop = false;
|
|
49
|
+
|
|
50
|
+
if (POWERSHELL_RE.test(combined)) platforms.push('windows');
|
|
51
|
+
if (LAUNCHD_RE.test(combined)) platforms.push('macos');
|
|
52
|
+
if (SYSTEMD_SERVICE_RE.test(combined) || CRON_PERSIST_RE.test(combined)) platforms.push('linux');
|
|
53
|
+
if (TEMP_DIR_RE.test(combined) && (POWERSHELL_RE.test(combined) || BINARY_DROP_RE.test(combined))) {
|
|
54
|
+
if (!platforms.includes('windows')) platforms.push('windows');
|
|
55
|
+
if (!platforms.includes('linux')) platforms.push('linux');
|
|
56
|
+
if (!platforms.includes('macos')) platforms.push('macos');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (DLL_LOAD_RE.test(combined)) platforms.push('windows');
|
|
60
|
+
if (PROCESS_INJECT_RE.test(combined)) platforms.push('windows');
|
|
61
|
+
|
|
62
|
+
if (NET_CALLBACK_RE.test(combined)) {
|
|
63
|
+
const urls = combined.match(NET_CALLBACK_RE);
|
|
64
|
+
c2Indicators = urls ? [...new Set(urls.map(u => u.replace(/['")]/g, '')))] : ['Network callback to external server'];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (BINARY_DROP_RE.test(combined)) hasBinaryDrop = true;
|
|
68
|
+
|
|
69
|
+
let payloadType = null;
|
|
70
|
+
if (platforms.length >= 2 && c2Indicators.length > 0 && hasBinaryDrop) {
|
|
71
|
+
payloadType = 'cross_platform_RAT';
|
|
72
|
+
} else if (hasBinaryDrop && c2Indicators.length > 0) {
|
|
73
|
+
payloadType = 'network_backdoor';
|
|
74
|
+
} else if (hasBinaryDrop && platforms.length > 0) {
|
|
75
|
+
payloadType = 'platform_persistence';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (payloadType) {
|
|
79
|
+
return {
|
|
80
|
+
triggered: true,
|
|
81
|
+
payloadType,
|
|
82
|
+
platforms: [...new Set(platforms)],
|
|
83
|
+
c2Indicators,
|
|
84
|
+
hooks: activeHooks.map(h => h.hook),
|
|
85
|
+
hasBinaryDrop,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { triggered: false, platforms: [], c2Indicators: [], payloadType: null, hooks: [], hasBinaryDrop: false };
|
|
90
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { scanVersionBlocklist } from './d1-version-fingerprint.js';
|
|
2
|
+
import { scanDecoyDependency } from './d2-decoy-dep.js';
|
|
3
|
+
import { scanPostinstallRAT } from './d3-postinstall-rat.js';
|
|
4
|
+
import { attachProvenance } from '../../provenance.js';
|
|
5
|
+
|
|
6
|
+
const RULE_SEVERITY = { D1: 'critical', D2: 'critical', D3: 'critical' };
|
|
7
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'none'];
|
|
8
|
+
|
|
9
|
+
function highestSeverity(severities) {
|
|
10
|
+
for (const s of SEVERITY_ORDER) if (severities.includes(s)) return s;
|
|
11
|
+
return 'none';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
15
|
+
const pkgName = pkgJson?.name || 'unknown';
|
|
16
|
+
const pkgVersion = pkgJson?.version || '0.0.0';
|
|
17
|
+
const fileList = allFiles || files || [];
|
|
18
|
+
|
|
19
|
+
const d1Result = scanVersionBlocklist(pkgJson);
|
|
20
|
+
if (d1Result.stopCondition) {
|
|
21
|
+
const evidence = attachProvenance({
|
|
22
|
+
rule: 'AXS-VER-001',
|
|
23
|
+
campaign: 'AXIOS_POISONING',
|
|
24
|
+
triggeredChecks: ['D1'],
|
|
25
|
+
matchedVersion: d1Result.matchedVersion,
|
|
26
|
+
action: 'BLOCK_IMMEDIATELY',
|
|
27
|
+
remediation: `Upgrade to axios@1.14.2 or later, or use pinned safe version`,
|
|
28
|
+
}, {
|
|
29
|
+
ruleId: 'AXS-VER-001',
|
|
30
|
+
ruleName: 'Compromised Axios Version Fingerprinting',
|
|
31
|
+
severity: 'CRITICAL',
|
|
32
|
+
campaignName: 'Axios Registry Poisoning',
|
|
33
|
+
pkgName,
|
|
34
|
+
pkgVersion,
|
|
35
|
+
triggered: true,
|
|
36
|
+
severity: 'critical',
|
|
37
|
+
indicators: [{ type: 'known_malicious_version', value: `${pkgName}@${pkgVersion}` }],
|
|
38
|
+
ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/axios-poisoning/d1-version-fingerprint.js',
|
|
39
|
+
campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return [{
|
|
43
|
+
id: 'AXIOS_POISONING',
|
|
44
|
+
severity: 'critical',
|
|
45
|
+
title: 'Axios Registry Poisoning campaign',
|
|
46
|
+
description: `HALT: ${pkgName}@${pkgVersion} is a known compromised version in the Axios registry poisoning campaign. Block install immediately.`,
|
|
47
|
+
evidence: JSON.stringify(evidence),
|
|
48
|
+
mitigation: d1Result.reason ? `BLOCK IMMEDIATELY. ${d1Result.reason}. Upgrade to axios@1.14.2 or later, or use pinned safe version.` : 'BLOCK IMMEDIATELY. Upgrade to a safe version.',
|
|
49
|
+
stopCondition: true,
|
|
50
|
+
}];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const d2Result = scanDecoyDependency(pkgJson);
|
|
54
|
+
const d3Result = scanPostinstallRAT(pkgJson, fileList);
|
|
55
|
+
|
|
56
|
+
const results = { D1: d1Result, D2: d2Result, D3: d3Result };
|
|
57
|
+
|
|
58
|
+
const triggered = Object.entries(results)
|
|
59
|
+
.filter(([_, r]) => r.triggered)
|
|
60
|
+
.map(([id]) => id);
|
|
61
|
+
|
|
62
|
+
if (triggered.length === 0) return [];
|
|
63
|
+
|
|
64
|
+
const severity = highestSeverity(triggered.map(id => RULE_SEVERITY[id]));
|
|
65
|
+
|
|
66
|
+
const evidence = attachProvenance({
|
|
67
|
+
campaign: 'AXIOS_POISONING',
|
|
68
|
+
triggeredChecks: triggered,
|
|
69
|
+
details: Object.fromEntries(
|
|
70
|
+
Object.entries(results).filter(([_, r]) => r.triggered)
|
|
71
|
+
),
|
|
72
|
+
}, {
|
|
73
|
+
ruleId: 'AXIOS_POISONING',
|
|
74
|
+
ruleName: 'Axios Registry Poisoning Detection',
|
|
75
|
+
severity: severity.toUpperCase(),
|
|
76
|
+
campaignName: 'Axios Registry Poisoning',
|
|
77
|
+
pkgName,
|
|
78
|
+
pkgVersion,
|
|
79
|
+
triggered: true,
|
|
80
|
+
severity,
|
|
81
|
+
indicators: triggered.map(id => ({ type: `rule_${id}`, value: RULE_SEVERITY[id] })),
|
|
82
|
+
ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/axios-poisoning/',
|
|
83
|
+
campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return [{
|
|
87
|
+
id: 'AXIOS_POISONING',
|
|
88
|
+
severity,
|
|
89
|
+
title: 'Axios Registry Poisoning campaign',
|
|
90
|
+
description: `${triggered.length} signal(s): ${triggered.join(', ')}`,
|
|
91
|
+
evidence: JSON.stringify(evidence),
|
|
92
|
+
mitigation: 'If decoy dependency detected: verify all axios dependencies are legitimate. If RAT payload detected: run full malware scan on the system, rotate all credentials, check for unauthorized network connections. Upgrade to axios@1.14.2+ or pin to a known safe version.',
|
|
93
|
+
}];
|
|
94
|
+
}
|
|
@@ -13,6 +13,11 @@ import { scanAll as megalodonScan } from './megalodon/index.js';
|
|
|
13
13
|
import { scan as hfScan } from './hf-impersonation/index.js';
|
|
14
14
|
import { scan as miniShaiHuludScan } from './mini-shai-hulud/index.js';
|
|
15
15
|
import { scan as badhostScan } from './cve-2026-48710-badhost/index.js';
|
|
16
|
+
import { scan as trapdoorScan } from './trapdoor/index.js';
|
|
17
|
+
import { scan as nodeIpcScan } from './node-ipc-compromise/index.js';
|
|
18
|
+
import { scan as mshSupplementScan } from './msh-supplement/index.js';
|
|
19
|
+
import { scan as typosquatScan } from './typosquat-vpmdhaj/index.js';
|
|
20
|
+
import { scan as axiosPoisoningScan } from './axios-poisoning/index.js';
|
|
16
21
|
|
|
17
22
|
export async function runAll(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
18
23
|
const findings = [];
|
|
@@ -31,5 +36,10 @@ export async function runAll(pkgJson, files = [], registryMeta = null, allFiles
|
|
|
31
36
|
findings.push(...await hfScan(pkgJson, files, registryMeta, allFiles || files));
|
|
32
37
|
findings.push(...await miniShaiHuludScan(pkgJson, files, registryMeta, allFiles || files));
|
|
33
38
|
findings.push(...await badhostScan(pkgJson, files, registryMeta, allFiles || files));
|
|
39
|
+
findings.push(...await trapdoorScan(pkgJson, files, registryMeta, allFiles || files));
|
|
40
|
+
findings.push(...await nodeIpcScan(pkgJson, files, registryMeta, allFiles || files));
|
|
41
|
+
findings.push(...await mshSupplementScan(pkgJson, files, registryMeta, allFiles || files));
|
|
42
|
+
findings.push(...await typosquatScan(pkgJson, files, registryMeta, allFiles || files));
|
|
43
|
+
findings.push(...await axiosPoisoningScan(pkgJson, files, registryMeta, allFiles || files));
|
|
34
44
|
return findings.sort((a, b) => b.severity.localeCompare(a.severity));
|
|
35
45
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const CTF_SCRAMBLE_RE = /require\(['"](ctf-scramble-v2|ctf-scramble-v\d+)['"]\)/;
|
|
2
|
+
const CTF_SCRAMBLE_ESM_RE = /(?:from|import)\s+['"](ctf-scramble-v2|ctf-scramble-v\d+)['"]/;
|
|
3
|
+
|
|
4
|
+
export function scanCtfScramble(files = []) {
|
|
5
|
+
for (const file of files) {
|
|
6
|
+
const content = file.content || '';
|
|
7
|
+
if (CTF_SCRAMBLE_RE.test(content) || CTF_SCRAMBLE_ESM_RE.test(content)) {
|
|
8
|
+
const match = content.match(CTF_SCRAMBLE_RE) || content.match(CTF_SCRAMBLE_ESM_RE);
|
|
9
|
+
return {
|
|
10
|
+
triggered: true,
|
|
11
|
+
stopCondition: true,
|
|
12
|
+
filePath: file.path,
|
|
13
|
+
patternMatched: match ? match[1] : 'ctf-scramble-v2',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { triggered: false, stopCondition: false };
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const DAEMON_RE = /\b(daemon|fork)\s*\(/;
|
|
2
|
+
const SPAWN_DETACHED_RE = /spawn\s*\([^)]*detached\s*:\s*true/;
|
|
3
|
+
const SYSTEMD_RE = /\/etc\/systemd\/system\/|systemctl\s+(enable|start)/;
|
|
4
|
+
const CRON_RE = /crontab\s+-e|\/etc\/cron\b/;
|
|
5
|
+
const LAUNCHD_RE = /\/Library\/LaunchDaemons\/|launchctl\s+(load|start)/;
|
|
6
|
+
const TASK_SCHED_RE = /schtasks\.exe|New-ScheduledTask|Register-ScheduledJob/;
|
|
7
|
+
const CI_GUARD_RE = /!process\.env\.CI|process\.env\.CI\s*===?\s*undefined/;
|
|
8
|
+
|
|
9
|
+
export function scanPersistence(pkgJson, files = []) {
|
|
10
|
+
const allScripts = [];
|
|
11
|
+
const hooks = ['preinstall', 'install', 'postinstall', 'preuninstall', 'postuninstall'];
|
|
12
|
+
for (const hook of hooks) {
|
|
13
|
+
const script = pkgJson?.scripts?.[hook];
|
|
14
|
+
if (script) {
|
|
15
|
+
allScripts.push({ hook, content: script });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const code = files.map(f => f.content || '').join('\n');
|
|
20
|
+
const codeWithScripts = code + '\n' + allScripts.map(s => s.content).join('\n');
|
|
21
|
+
|
|
22
|
+
const detectedApis = [];
|
|
23
|
+
let hasCiGuard = false;
|
|
24
|
+
let hasDaemon = false;
|
|
25
|
+
|
|
26
|
+
if (DAEMON_RE.test(codeWithScripts)) detectedApis.push('daemon');
|
|
27
|
+
if (SPAWN_DETACHED_RE.test(codeWithScripts)) detectedApis.push('spawn_detached');
|
|
28
|
+
if (SYSTEMD_RE.test(codeWithScripts)) detectedApis.push('systemd');
|
|
29
|
+
if (CRON_RE.test(codeWithScripts)) detectedApis.push('cron');
|
|
30
|
+
if (LAUNCHD_RE.test(codeWithScripts)) detectedApis.push('launchd');
|
|
31
|
+
if (TASK_SCHED_RE.test(codeWithScripts)) detectedApis.push('task_scheduler');
|
|
32
|
+
if (CI_GUARD_RE.test(codeWithScripts)) hasCiGuard = true;
|
|
33
|
+
|
|
34
|
+
if (DAEMON_RE.test(codeWithScripts) || SPAWN_DETACHED_RE.test(codeWithScripts)) hasDaemon = true;
|
|
35
|
+
|
|
36
|
+
if (hasDaemon || detectedApis.length > 0) {
|
|
37
|
+
return {
|
|
38
|
+
triggered: true,
|
|
39
|
+
detectedApis,
|
|
40
|
+
hasCiGuard,
|
|
41
|
+
hooks: allScripts.map(s => s.hook),
|
|
42
|
+
context: hasCiGuard ? 'Spawns background process when CI env var absent' : 'Suspicious persistence/detached process detected',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { triggered: false, detectedApis: [], hasCiGuard: false, hooks: [] };
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const LOCALE_CHECKS = [
|
|
2
|
+
/process\.env\.LANG/,
|
|
3
|
+
/process\.env\.LC_ALL/,
|
|
4
|
+
/process\.env\.LC_MESSAGES/,
|
|
5
|
+
/Intl\.DateTimeFormat\(\)\.resolvedOptions\(\)\.timeZone/,
|
|
6
|
+
/Intl\.DateTimeFormat\.resolvedOptions\b/,
|
|
7
|
+
];
|
|
8
|
+
const TARGET_LOCALES = /ru_RU|be_BY|uk_UA/;
|
|
9
|
+
const SILENT_EXIT_RE = /process\.exit\s*\(\s*0\s*\)/;
|
|
10
|
+
|
|
11
|
+
export function scanGeoKillswitch(files = []) {
|
|
12
|
+
const code = files.map(f => f.content || '').join('\n');
|
|
13
|
+
if (!code) return { triggered: false, targetedLocales: [], triggerBehavior: null };
|
|
14
|
+
|
|
15
|
+
const hasLocaleCheck = LOCALE_CHECKS.some(re => re.test(code));
|
|
16
|
+
if (!hasLocaleCheck) return { triggered: false, targetedLocales: [], triggerBehavior: null };
|
|
17
|
+
|
|
18
|
+
const hasTargetLocale = TARGET_LOCALES.test(code);
|
|
19
|
+
const hasSilentExit = SILENT_EXIT_RE.test(code);
|
|
20
|
+
|
|
21
|
+
if (hasTargetLocale || hasSilentExit) {
|
|
22
|
+
const matchedLocales = [];
|
|
23
|
+
if (/ru_RU/.test(code)) matchedLocales.push('ru_RU');
|
|
24
|
+
if (/be_BY/.test(code)) matchedLocales.push('be_BY');
|
|
25
|
+
if (/uk_UA/.test(code)) matchedLocales.push('uk_UA');
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
triggered: true,
|
|
29
|
+
targetedLocales: matchedLocales.length > 0 ? matchedLocales : ['ru_RU', 'be_BY'],
|
|
30
|
+
triggerBehavior: hasSilentExit ? 'Silent exit' : 'Locale/timezone match with conditional behavior',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { triggered: false, targetedLocales: [], triggerBehavior: null };
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const OHNO_WHATS_GOING_ON_RE = /OhNoWhatsGoingOnWithGitHub/;
|
|
2
|
+
const GITHUB_COMMIT_SCRAPE_RE = /api\.github\.com\/repos\/[^/]+\/[^/]+\/commits/;
|
|
3
|
+
const GITHUB_GRAPHQL_RE = /api\.github\.com\/graphql/;
|
|
4
|
+
const GITHUB_TOKEN_ACCESS_RE = /process\.env\.(?:GH_TOKEN|GITHUB_TOKEN|GITHUB_ACTOR)/;
|
|
5
|
+
const COMMIT_PARSE_LOOP_RE = /commits?\s*\.\s*(?:map|filter|forEach|for\s*\(|while\s*\()/;
|
|
6
|
+
|
|
7
|
+
export function scanC2DeadDrop(files = []) {
|
|
8
|
+
const code = files.map(f => f.content || '').join('\n');
|
|
9
|
+
if (!code) return { triggered: false, matches: [] };
|
|
10
|
+
|
|
11
|
+
const matches = [];
|
|
12
|
+
|
|
13
|
+
if (OHNO_WHATS_GOING_ON_RE.test(code)) {
|
|
14
|
+
matches.push({ type: 'ioc_keyword', value: 'OhNoWhatsGoingOnWithGitHub', attackVector: 'GitHub commit scraping for token recovery' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hasTokenAccess = GITHUB_TOKEN_ACCESS_RE.test(code);
|
|
18
|
+
const hasGithubApi = GITHUB_COMMIT_SCRAPE_RE.test(code) || GITHUB_GRAPHQL_RE.test(code);
|
|
19
|
+
const hasCommitParseLoop = COMMIT_PARSE_LOOP_RE.test(code);
|
|
20
|
+
|
|
21
|
+
if (hasTokenAccess && hasGithubApi) {
|
|
22
|
+
matches.push({ type: 'token_exfil_github_api', value: 'Credential access followed by GitHub API call', attackVector: 'Credential/token extraction followed by GitHub API calls' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (hasCommitParseLoop && hasGithubApi) {
|
|
26
|
+
matches.push({ type: 'commit_scraping', value: 'Commit message parsing with GitHub API', attackVector: 'Commit scraping for secret detection' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
triggered: matches.length > 0,
|
|
31
|
+
matches,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { scanCtfScramble } from './d1-obfuscation.js';
|
|
2
|
+
import { scanPersistence } from './d2-persistence.js';
|
|
3
|
+
import { scanGeoKillswitch } from './d3-geo-killswitch.js';
|
|
4
|
+
import { scanC2DeadDrop } from './d4-c2-deaddrop.js';
|
|
5
|
+
import { attachProvenance } from '../../provenance.js';
|
|
6
|
+
|
|
7
|
+
const RULE_SEVERITY = {
|
|
8
|
+
D1: 'critical',
|
|
9
|
+
D2: 'critical',
|
|
10
|
+
D3: 'high',
|
|
11
|
+
D4: 'critical',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'none'];
|
|
15
|
+
|
|
16
|
+
function highestSeverity(severities) {
|
|
17
|
+
for (const s of SEVERITY_ORDER) {
|
|
18
|
+
if (severities.includes(s)) return s;
|
|
19
|
+
}
|
|
20
|
+
return 'none';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
24
|
+
const fileList = allFiles || files || [];
|
|
25
|
+
const pkgName = pkgJson?.name || 'unknown';
|
|
26
|
+
const pkgVersion = pkgJson?.version || '0.0.0';
|
|
27
|
+
|
|
28
|
+
const d1Results = scanCtfScramble(fileList);
|
|
29
|
+
if (d1Results.stopCondition) {
|
|
30
|
+
const evidence = attachProvenance({
|
|
31
|
+
rule: 'MSH-OBF-001',
|
|
32
|
+
campaign: 'MINI_SHAI_HULUD',
|
|
33
|
+
triggeredChecks: ['D1'],
|
|
34
|
+
filePath: d1Results.filePath,
|
|
35
|
+
patternMatched: d1Results.patternMatched,
|
|
36
|
+
action: 'BLOCK_IMMEDIATELY',
|
|
37
|
+
}, {
|
|
38
|
+
ruleId: 'MSH-OBF-001',
|
|
39
|
+
ruleName: 'ctf-scramble-v2 Obfuscation Detection',
|
|
40
|
+
severity: 'CRITICAL',
|
|
41
|
+
campaignName: 'Mini Shai-Hulud',
|
|
42
|
+
pkgName,
|
|
43
|
+
pkgVersion,
|
|
44
|
+
triggered: true,
|
|
45
|
+
severity: 'critical',
|
|
46
|
+
indicators: [{ type: 'obfuscation_found', value: d1Results.patternMatched }],
|
|
47
|
+
ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/d1-obfuscation.js',
|
|
48
|
+
campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return [{
|
|
52
|
+
id: 'MINI_SHAI_HULUD',
|
|
53
|
+
severity: 'critical',
|
|
54
|
+
title: 'Mini Shai-Hulud worm campaign — ctf-scramble-v2 malware obfuscation detected',
|
|
55
|
+
description: 'HALT: ctf-scramble-v2 obfuscation layer detected. Package is compromised. Block install immediately.',
|
|
56
|
+
evidence: JSON.stringify(evidence),
|
|
57
|
+
mitigation: 'BLOCK IMMEDIATELY. Do not install this package version. Revoke any npm tokens exposed to this package. Rotate all CI/CD secrets. Run full malware scan on any system that processed this package.',
|
|
58
|
+
stopCondition: true,
|
|
59
|
+
}];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const results = {
|
|
63
|
+
D2: scanPersistence(pkgJson, fileList),
|
|
64
|
+
D3: scanGeoKillswitch(fileList),
|
|
65
|
+
D4: scanC2DeadDrop(fileList),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const triggered = Object.entries(results)
|
|
69
|
+
.filter(([_, r]) => r.triggered)
|
|
70
|
+
.map(([id]) => id);
|
|
71
|
+
|
|
72
|
+
if (triggered.length === 0) return [];
|
|
73
|
+
|
|
74
|
+
const severity = highestSeverity(triggered.map(id => RULE_SEVERITY[id]));
|
|
75
|
+
|
|
76
|
+
const evidence = attachProvenance({
|
|
77
|
+
campaign: 'MINI_SHAI_HULUD',
|
|
78
|
+
triggeredChecks: triggered,
|
|
79
|
+
details: Object.fromEntries(
|
|
80
|
+
Object.entries(results).filter(([_, r]) => r.triggered)
|
|
81
|
+
),
|
|
82
|
+
}, {
|
|
83
|
+
ruleId: 'MSH-SUPPLEMENT',
|
|
84
|
+
ruleName: 'Mini Shai-Hulud Supplement Detection',
|
|
85
|
+
severity: severity.toUpperCase(),
|
|
86
|
+
campaignName: 'Mini Shai-Hulud',
|
|
87
|
+
pkgName,
|
|
88
|
+
pkgVersion,
|
|
89
|
+
triggered: true,
|
|
90
|
+
severity,
|
|
91
|
+
indicators: triggered.map(id => ({
|
|
92
|
+
type: `rule_${id}`,
|
|
93
|
+
value: RULE_SEVERITY[id],
|
|
94
|
+
})),
|
|
95
|
+
ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/',
|
|
96
|
+
campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return [{
|
|
100
|
+
id: 'MINI_SHAI_HULUD',
|
|
101
|
+
severity,
|
|
102
|
+
title: 'Mini Shai-Hulud worm campaign — supplement indicators',
|
|
103
|
+
description: `${triggered.length} signal(s): ${triggered.join(', ')}`,
|
|
104
|
+
evidence: JSON.stringify(evidence),
|
|
105
|
+
mitigation: 'If daemonization detected: revoke npm tokens and rotate CI/CD secrets. If geographic killswitch detected: verify running in expected region; attacker may be avoiding certain locales. If C2 dead-drop detected: check for unauthorized GitHub API access and token exfiltration. Review recent version publish history for anomalous bursts.',
|
|
106
|
+
}];
|
|
107
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const BLOCKED_VERSIONS = new Set(['9.1.6', '9.2.3', '12.0.1']);
|
|
2
|
+
|
|
3
|
+
const SAFE_PINS = {
|
|
4
|
+
'9.1.6': '9.1.5',
|
|
5
|
+
'9.2.3': '9.1.5',
|
|
6
|
+
'12.0.1': '12.0.0',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function scanVersionBlocklist(pkgJson, registryMeta) {
|
|
10
|
+
const pkgName = pkgJson?.name || '';
|
|
11
|
+
if (pkgName !== 'node-ipc') return { triggered: false };
|
|
12
|
+
|
|
13
|
+
const version = pkgJson?.version || '';
|
|
14
|
+
if (BLOCKED_VERSIONS.has(version)) {
|
|
15
|
+
return {
|
|
16
|
+
triggered: true,
|
|
17
|
+
version,
|
|
18
|
+
safePin: SAFE_PINS[version],
|
|
19
|
+
maliciousVersions: ['9.1.6', '9.2.3', '12.0.1'],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { triggered: false, version };
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function scanUnauthorizedPublisher(pkgJson, registryMeta) {
|
|
2
|
+
const pkgName = pkgJson?.name || '';
|
|
3
|
+
if (pkgName !== 'node-ipc') return { triggered: false };
|
|
4
|
+
|
|
5
|
+
const publisherAccount = registryMeta?.versions?.[pkgJson?.version]?._npmUser?.name
|
|
6
|
+
|| registryMeta?.versions?.[Object.keys(registryMeta.versions || {})[0]]?._npmUser?.name
|
|
7
|
+
|| null;
|
|
8
|
+
|
|
9
|
+
if (publisherAccount === 'atiertant') {
|
|
10
|
+
return {
|
|
11
|
+
triggered: true,
|
|
12
|
+
publisher: publisherAccount,
|
|
13
|
+
package: pkgName,
|
|
14
|
+
detail: 'Account atiertant has no prior release history on node-ipc — account recovery via expired email domain takeover',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { triggered: false, publisher: publisherAccount };
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const COMPROMISED_VERSIONS = {
|
|
2
|
+
'9.1.6': { safePin: '9.1.5', ranges: ['~9.1.x', '^9.1', '^9'] },
|
|
3
|
+
'9.2.3': { safePin: '9.1.5', ranges: ['~9.2.x', '^9.2'] },
|
|
4
|
+
'12.0.1': { safePin: '12.0.0', ranges: ['~12.0.x', '^12'] },
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const LOCKFILE_PATTERNS = [
|
|
8
|
+
/package-lock\.json$/i,
|
|
9
|
+
/yarn\.lock$/i,
|
|
10
|
+
/pnpm-lock\.yaml$/i,
|
|
11
|
+
/pnpm-lock\.yml$/i,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function scanBlastRadius(allFiles) {
|
|
15
|
+
const matches = [];
|
|
16
|
+
|
|
17
|
+
for (const file of allFiles) {
|
|
18
|
+
const path = file.path?.replace(/\\/g, '/') || '';
|
|
19
|
+
const isLockfile = LOCKFILE_PATTERNS.some(p => p.test(path));
|
|
20
|
+
if (!isLockfile) continue;
|
|
21
|
+
|
|
22
|
+
const content = file.content || '';
|
|
23
|
+
const hasNodeIpc = /\bnode-ipc\b/i.test(content);
|
|
24
|
+
if (!hasNodeIpc) continue;
|
|
25
|
+
|
|
26
|
+
for (const [badVersion, info] of Object.entries(COMPROMISED_VERSIONS)) {
|
|
27
|
+
const versionInQuotes = `"${badVersion}"`;
|
|
28
|
+
if (content.includes(versionInQuotes)) {
|
|
29
|
+
matches.push({
|
|
30
|
+
file: path,
|
|
31
|
+
compromisedVersion: badVersion,
|
|
32
|
+
safePin: info.safePin,
|
|
33
|
+
detail: `node-ipc resolved to compromised version ${badVersion} in lockfile. Pin to ${info.safePin}.`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { triggered: matches.length > 0, matches };
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const MALICIOUS_HASHES = new Set([
|
|
4
|
+
'449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e',
|
|
5
|
+
'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea',
|
|
6
|
+
'78a82d93b4f580835f5823b85a3d9ee1f03a15ee6f0e01b4eac86252a7002981',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function scanTarballHash(allFiles) {
|
|
10
|
+
const matches = [];
|
|
11
|
+
|
|
12
|
+
for (const file of allFiles) {
|
|
13
|
+
const path = file.path || '';
|
|
14
|
+
if (!path.endsWith('.tgz') && !path.endsWith('.tar.gz')) continue;
|
|
15
|
+
|
|
16
|
+
const content = file.content || '';
|
|
17
|
+
const hash = createHash('sha256').update(content, 'utf8').digest('hex');
|
|
18
|
+
|
|
19
|
+
if (MALICIOUS_HASHES.has(hash)) {
|
|
20
|
+
matches.push({
|
|
21
|
+
file: path,
|
|
22
|
+
sha256: hash,
|
|
23
|
+
version: hash === '449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e'
|
|
24
|
+
? '9.1.6' : hash === 'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea'
|
|
25
|
+
? '9.2.3' : '12.0.1',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { triggered: matches.length > 0, matches };
|
|
31
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const IIFE_END_PATTERN = /}\)\(\);\s*$/;
|
|
2
|
+
const SIZE_DIFFERENTIAL_THRESHOLD = 50 * 1024;
|
|
3
|
+
|
|
4
|
+
export function scanCjsPayloadInjection(allFiles) {
|
|
5
|
+
const matches = [];
|
|
6
|
+
|
|
7
|
+
let cjsContent = null;
|
|
8
|
+
let mjsContent = null;
|
|
9
|
+
let cjsPath = null;
|
|
10
|
+
let mjsPath = null;
|
|
11
|
+
|
|
12
|
+
for (const file of allFiles) {
|
|
13
|
+
const path = file.path?.replace(/\\/g, '/') || '';
|
|
14
|
+
if (path.endsWith('node-ipc.cjs')) {
|
|
15
|
+
cjsContent = file.content || '';
|
|
16
|
+
cjsPath = path;
|
|
17
|
+
}
|
|
18
|
+
if (path.endsWith('node-ipc.mjs')) {
|
|
19
|
+
mjsContent = file.content || '';
|
|
20
|
+
mjsPath = path;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (cjsContent && !mjsContent) {
|
|
25
|
+
matches.push({
|
|
26
|
+
file: cjsPath,
|
|
27
|
+
finding: 'cjs-present-no-esm',
|
|
28
|
+
detail: 'node-ipc.cjs present but node-ipc.mjs not found — unable to cross-reference size',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (cjsContent && mjsContent) {
|
|
33
|
+
const cjsSize = Buffer.byteLength(cjsContent, 'utf8');
|
|
34
|
+
const mjsSize = Buffer.byteLength(mjsContent, 'utf8');
|
|
35
|
+
const sizeDiff = cjsSize - mjsSize;
|
|
36
|
+
|
|
37
|
+
if (sizeDiff > SIZE_DIFFERENTIAL_THRESHOLD) {
|
|
38
|
+
matches.push({
|
|
39
|
+
file: cjsPath,
|
|
40
|
+
finding: 'size-anomaly',
|
|
41
|
+
cjsSize,
|
|
42
|
+
mjsSize,
|
|
43
|
+
sizeDiff,
|
|
44
|
+
detail: `CJS (${cjsSize} bytes) exceeds ESM (${mjsSize} bytes) by ${sizeDiff} bytes — potential injected payload`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (IIFE_END_PATTERN.test(cjsContent.trim())) {
|
|
49
|
+
const trimmed = cjsContent.trim();
|
|
50
|
+
const iifeMatch = trimmed.match(IIFE_END_PATTERN);
|
|
51
|
+
if (iifeMatch) {
|
|
52
|
+
matches.push({
|
|
53
|
+
file: cjsPath,
|
|
54
|
+
finding: 'iife-suffix',
|
|
55
|
+
detail: 'node-ipc.cjs ends with IIFE pattern — potential obfuscated payload appended after module closure',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (cjsContent && IIFE_END_PATTERN.test(cjsContent.trim())) {
|
|
62
|
+
const alreadyReported = matches.some(m => m.finding === 'iife-suffix');
|
|
63
|
+
if (!alreadyReported) {
|
|
64
|
+
matches.push({
|
|
65
|
+
file: cjsPath,
|
|
66
|
+
finding: 'iife-suffix',
|
|
67
|
+
detail: 'node-ipc.cjs ends with IIFE pattern — potential obfuscated payload appended after module closure',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { triggered: matches.length > 0, matches };
|
|
73
|
+
}
|