@lateos/npm-scan 0.18.3 → 1.1.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/CHANGELOG.md +32 -0
- package/README.md +864 -826
- package/VALIDATION.md +92 -0
- package/backend/cra.js +113 -21
- package/backend/db/pg-schema.sql +155 -0
- package/backend/db.js +18 -10
- package/backend/detectors/atk-001-lifecycle.js +5 -5
- package/backend/detectors/atk-002-obfusc.js +126 -47
- package/backend/detectors/atk-003-creds.js +8 -4
- package/backend/detectors/atk-004-persist.js +3 -3
- package/backend/detectors/atk-005-exfil.js +8 -4
- package/backend/detectors/atk-006-depconf.js +3 -3
- package/backend/detectors/atk-007-typosquat.js +64 -10
- package/backend/detectors/atk-008-tarball-tamper.js +6 -6
- package/backend/detectors/atk-009-dormant-trigger.js +9 -5
- package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
- package/backend/detectors/atk-011-transitive-prop.js +14 -13
- package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
- package/backend/detectors/axios-poisoning/index.js +77 -60
- package/backend/detectors/config/thresholds.js +111 -0
- package/backend/detectors/config/whitelist.json +74 -0
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
- package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
- package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
- package/backend/detectors/hf-impersonation/index.js +94 -31
- package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
- package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
- package/backend/detectors/hf-impersonation/simhash.js +2 -2
- package/backend/detectors/index.js +184 -31
- package/backend/detectors/lib/ast-patterns.js +24 -0
- package/backend/detectors/lib/entropy-analyzer.js +32 -0
- package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
- package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
- package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
- package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
- package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
- package/backend/detectors/megalodon/index.js +35 -25
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
- package/backend/detectors/mini-shai-hulud/index.js +63 -26
- package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
- package/backend/detectors/msh-supplement/index.js +78 -63
- package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
- package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
- package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
- package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
- package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
- package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
- package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
- package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
- package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
- package/backend/detectors/node-ipc-compromise/index.js +21 -15
- package/backend/detectors/tier1-binary-embed.js +138 -41
- package/backend/detectors/tier1-cloud-imds.js +57 -37
- package/backend/detectors/tier1-encrypted-c2.js +198 -0
- package/backend/detectors/tier1-infostealer.js +121 -68
- package/backend/detectors/tier1-lifecycle-hook.js +63 -23
- package/backend/detectors/tier1-maintainer-compromise.js +157 -0
- package/backend/detectors/tier1-metadata-spoof.js +92 -42
- package/backend/detectors/tier1-multistage-postinstall.js +46 -19
- package/backend/detectors/tier1-obfuscation-heuristics.js +184 -0
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +12 -0
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +223 -0
- package/backend/detectors/tier1-version-confusion.js +79 -59
- package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
- package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
- package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
- package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
- package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
- package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
- package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
- package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
- package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
- package/backend/detectors/trapdoor/index.js +19 -14
- package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
- package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
- package/backend/detectors.test.js +147 -0
- package/backend/fetch.js +37 -29
- package/backend/index.js +1 -1
- package/backend/license.js +20 -4
- package/backend/lockfile.js +60 -36
- package/backend/pdf.js +107 -28
- package/backend/policy.js +183 -56
- package/backend/provenance.js +28 -3
- package/backend/report.js +136 -70
- package/backend/sbom.js +33 -27
- package/backend/scripts/analyze-false-positives.js +152 -0
- package/backend/scripts/analyze-validation.js +157 -0
- package/backend/scripts/detect-false-positives.js +103 -0
- package/backend/scripts/fetch-top-packages.js +277 -0
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +151 -0
- package/backend/siem/cef.js +23 -21
- package/backend/siem/ecs.js +3 -3
- package/backend/siem/index.js +1 -1
- package/backend/siem/qradar.js +3 -3
- package/backend/siem/sentinel.js +2 -2
- package/backend/tests-d5-enhanced.test.js +47 -0
- package/backend/tests-d6-version-anomaly.test.js +67 -0
- package/backend/tests-d6.test.js +126 -0
- package/backend/tests-d6c.test.js +119 -0
- package/backend/tests-d7-obfuscation.test.js +88 -0
- package/backend/tests.test.js +997 -0
- package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
- package/backend/vsix-scan/detectors/burst-publish.js +14 -7
- package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
- package/backend/vsix-scan/detectors/known-ioc.js +23 -8
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
- package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
- package/backend/vsix-scan/index.js +97 -41
- package/backend/vsix-scan/marketplace-client.js +29 -13
- package/cli/cli.js +154 -64
- package/package.json +36 -10
- package/.dockerignore +0 -20
- package/.husky/pre-commit +0 -1
- package/SECURITY.md +0 -73
- package/deploy/helm/npm-scan/Chart.yaml +0 -22
- package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
- package/deploy/helm/npm-scan/templates/api.yaml +0 -94
- package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
- package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
- package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
- package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
- package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
- package/deploy/helm/npm-scan/values.yaml +0 -103
- package/scripts/download-corpus.js +0 -30
- package/scripts/gen-mal-corpus.js +0 -35
- package/scripts/generate-campaign-fixtures.js +0 -170
- package/src/config/top-5000.json +0 -87
- package/test/fixtures/lockfiles/npm-lock.json +0 -69
- package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
- package/test/fixtures/lockfiles/yarn.lock +0 -104
- package/test/fixtures/mock-data.js +0 -69
|
@@ -11,7 +11,9 @@ const __dirname = dirname(__filename);
|
|
|
11
11
|
const IOC_PATH = join(__dirname, 'iocs.json');
|
|
12
12
|
|
|
13
13
|
function loadIOCData() {
|
|
14
|
-
if (iocsLoaded)
|
|
14
|
+
if (iocsLoaded) {
|
|
15
|
+
return iocsData;
|
|
16
|
+
}
|
|
15
17
|
iocsLoaded = true;
|
|
16
18
|
try {
|
|
17
19
|
iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
|
|
@@ -34,7 +36,9 @@ export function reloadIOCData() {
|
|
|
34
36
|
|
|
35
37
|
export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, timeMap = {}) {
|
|
36
38
|
const data = loadIOCData();
|
|
37
|
-
if (!data)
|
|
39
|
+
if (!data) {
|
|
40
|
+
return { triggered: false, matches: [] };
|
|
41
|
+
}
|
|
38
42
|
|
|
39
43
|
const matches = [];
|
|
40
44
|
const allIOCs = [];
|
|
@@ -44,7 +48,7 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
|
|
|
44
48
|
for (const waveKey of Object.keys(data.waves || {})) {
|
|
45
49
|
const wave = data.waves[waveKey];
|
|
46
50
|
const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
|
|
47
|
-
for (const ioc of
|
|
51
|
+
for (const ioc of wave.iocs || []) {
|
|
48
52
|
allIOCs.push({ ...ioc, wave: waveNum });
|
|
49
53
|
}
|
|
50
54
|
}
|
|
@@ -53,7 +57,11 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
|
|
|
53
57
|
switch (ioc.type) {
|
|
54
58
|
case 'packageName': {
|
|
55
59
|
if (ioc.value === pkgName) {
|
|
56
|
-
if (
|
|
60
|
+
if (
|
|
61
|
+
!ioc.maliciousVersions ||
|
|
62
|
+
ioc.maliciousVersions.length === 0 ||
|
|
63
|
+
ioc.maliciousVersions.includes(pkgVersion)
|
|
64
|
+
) {
|
|
57
65
|
matches.push({ type: 'packageName', value: pkgName, wave: ioc.wave });
|
|
58
66
|
}
|
|
59
67
|
}
|
|
@@ -14,7 +14,9 @@ const SUSPICIOUS_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
|
14
14
|
const MAX_SNIPPET_LENGTH = 200;
|
|
15
15
|
|
|
16
16
|
function truncateSnippet(text) {
|
|
17
|
-
if (text.length <= MAX_SNIPPET_LENGTH)
|
|
17
|
+
if (text.length <= MAX_SNIPPET_LENGTH) {
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
18
20
|
return text.slice(0, MAX_SNIPPET_LENGTH - 3) + '...';
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -24,7 +26,9 @@ export function checkTokenExfil(allFiles, pkgJson) {
|
|
|
24
26
|
|
|
25
27
|
for (const hook of SUSPICIOUS_SCRIPTS) {
|
|
26
28
|
const scriptContent = scripts[hook];
|
|
27
|
-
if (!scriptContent)
|
|
29
|
+
if (!scriptContent) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
28
32
|
|
|
29
33
|
for (const pattern of EXFIL_PATTERNS) {
|
|
30
34
|
if (pattern.test(scriptContent)) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { checkBurstPublish } from './d1-burst-publish.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
checkSiblingCompromise,
|
|
4
|
+
clearSiblingCache as _clearSiblingCache,
|
|
5
|
+
} from './d2-sibling-compromise.js';
|
|
3
6
|
import { checkSlsaMismatch } from './d3-slsa-mismatch.js';
|
|
4
7
|
import { checkMaintainerAnomaly } from './d4-maintainer-anomaly.js';
|
|
5
8
|
import { checkIOC } from './d5-ioc-check.js';
|
|
@@ -31,15 +34,31 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
31
34
|
const nxDownstreamResult = checkNxConsoleDownstream(pkgJson, allFiles || files);
|
|
32
35
|
|
|
33
36
|
const triggeredChecks = [];
|
|
34
|
-
if (burstResult.triggered)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
37
|
+
if (burstResult.triggered) {
|
|
38
|
+
triggeredChecks.push('D1_BURST');
|
|
39
|
+
}
|
|
40
|
+
if (siblingResult.triggered) {
|
|
41
|
+
triggeredChecks.push('D2_SIBLING');
|
|
42
|
+
}
|
|
43
|
+
if (slsaResult.triggered) {
|
|
44
|
+
triggeredChecks.push('D3_SLSA');
|
|
45
|
+
}
|
|
46
|
+
if (maintainerResult.triggered) {
|
|
47
|
+
triggeredChecks.push('D4_MAINTAINER');
|
|
48
|
+
}
|
|
49
|
+
if (iocResult.triggered) {
|
|
50
|
+
triggeredChecks.push('D5_IOC');
|
|
51
|
+
}
|
|
52
|
+
if (exfilResult.triggered) {
|
|
53
|
+
triggeredChecks.push('D6_EXFIL');
|
|
54
|
+
}
|
|
55
|
+
if (nxDownstreamResult.triggered) {
|
|
56
|
+
triggeredChecks.push('D7_NX_CONSOLE');
|
|
57
|
+
}
|
|
41
58
|
|
|
42
|
-
if (triggeredChecks.length === 0)
|
|
59
|
+
if (triggeredChecks.length === 0) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
43
62
|
|
|
44
63
|
let waveAttribution = 'unknown';
|
|
45
64
|
if (pkgName.startsWith('@tanstack')) {
|
|
@@ -49,9 +68,10 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
49
68
|
} else if (nxDownstreamResult.triggered) {
|
|
50
69
|
waveAttribution = 'wave3-nx-console';
|
|
51
70
|
} else if (iocResult.matches && iocResult.matches.length > 0) {
|
|
52
|
-
const waves = [...new Set(iocResult.matches.map(m => m.wave))];
|
|
71
|
+
const waves = [...new Set(iocResult.matches.map((m) => m.wave))];
|
|
53
72
|
if (waves.length === 1) {
|
|
54
|
-
waveAttribution =
|
|
73
|
+
waveAttribution =
|
|
74
|
+
waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
|
|
55
75
|
}
|
|
56
76
|
}
|
|
57
77
|
|
|
@@ -62,10 +82,14 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
62
82
|
waveAttribution,
|
|
63
83
|
triggeredChecks,
|
|
64
84
|
burstWindow: burstResult.triggered
|
|
65
|
-
? {
|
|
85
|
+
? {
|
|
86
|
+
start: burstResult.windowStart,
|
|
87
|
+
end: burstResult.windowEnd,
|
|
88
|
+
versionCount: burstResult.versionCount,
|
|
89
|
+
}
|
|
66
90
|
: null,
|
|
67
91
|
siblingPackages: siblingResult.triggered
|
|
68
|
-
? siblingResult.results.flatMap(r => r.siblingPackages)
|
|
92
|
+
? siblingResult.results.flatMap((r) => r.siblingPackages)
|
|
69
93
|
: null,
|
|
70
94
|
attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
|
|
71
95
|
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
@@ -75,25 +99,38 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
75
99
|
: null,
|
|
76
100
|
};
|
|
77
101
|
|
|
78
|
-
return [
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
id: 'MINI_SHAI_HULUD',
|
|
105
|
+
severity: isCritical ? 'critical' : 'high',
|
|
106
|
+
title: 'Mini Shai-Hulud worm campaign',
|
|
107
|
+
description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
|
|
108
|
+
evidence: JSON.stringify(evidence),
|
|
109
|
+
mitigation:
|
|
110
|
+
'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
|
|
111
|
+
},
|
|
112
|
+
];
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
function checkNxConsoleDownstream(pkgJson, allFiles) {
|
|
89
|
-
const deps = {
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
const deps = {
|
|
117
|
+
...pkgJson?.dependencies,
|
|
118
|
+
...pkgJson?.devDependencies,
|
|
119
|
+
...pkgJson?.peerDependencies,
|
|
120
|
+
};
|
|
121
|
+
const nxDeps = Object.keys(deps).filter((d) => d.startsWith('@nx/') || d.startsWith('nrwl/'));
|
|
122
|
+
if (nxDeps.length === 0) {
|
|
123
|
+
return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
|
|
124
|
+
}
|
|
92
125
|
|
|
93
126
|
let vsCodeExtensions = [];
|
|
94
127
|
if (allFiles && Array.isArray(allFiles)) {
|
|
95
128
|
for (const file of allFiles) {
|
|
96
|
-
if (
|
|
129
|
+
if (
|
|
130
|
+
file.path &&
|
|
131
|
+
(file.path.endsWith('.vscode/extensions.json') ||
|
|
132
|
+
file.path.endsWith('.vscode/extensions.json'))
|
|
133
|
+
) {
|
|
97
134
|
try {
|
|
98
135
|
const content = typeof file.content === 'string' ? file.content : '';
|
|
99
136
|
const parsed = JSON.parse(content);
|
|
@@ -101,7 +138,7 @@ function checkNxConsoleDownstream(pkgJson, allFiles) {
|
|
|
101
138
|
...(parsed.recommendations || []),
|
|
102
139
|
...(parsed.unwantedRecommendations || []),
|
|
103
140
|
];
|
|
104
|
-
const matched = allExts.filter(e => e.includes('nrwl.angular-console'));
|
|
141
|
+
const matched = allExts.filter((e) => e.includes('nrwl.angular-console'));
|
|
105
142
|
if (matched.length > 0) {
|
|
106
143
|
vsCodeExtensions = matched;
|
|
107
144
|
}
|
|
@@ -16,30 +16,48 @@ export function scanPersistence(pkgJson, files = []) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const code = files.map(f => f.content || '').join('\n');
|
|
20
|
-
const codeWithScripts = code + '\n' + allScripts.map(s => s.content).join('\n');
|
|
19
|
+
const code = files.map((f) => f.content || '').join('\n');
|
|
20
|
+
const codeWithScripts = code + '\n' + allScripts.map((s) => s.content).join('\n');
|
|
21
21
|
|
|
22
22
|
const detectedApis = [];
|
|
23
23
|
let hasCiGuard = false;
|
|
24
24
|
let hasDaemon = false;
|
|
25
25
|
|
|
26
|
-
if (DAEMON_RE.test(codeWithScripts))
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
26
|
+
if (DAEMON_RE.test(codeWithScripts)) {
|
|
27
|
+
detectedApis.push('daemon');
|
|
28
|
+
}
|
|
29
|
+
if (SPAWN_DETACHED_RE.test(codeWithScripts)) {
|
|
30
|
+
detectedApis.push('spawn_detached');
|
|
31
|
+
}
|
|
32
|
+
if (SYSTEMD_RE.test(codeWithScripts)) {
|
|
33
|
+
detectedApis.push('systemd');
|
|
34
|
+
}
|
|
35
|
+
if (CRON_RE.test(codeWithScripts)) {
|
|
36
|
+
detectedApis.push('cron');
|
|
37
|
+
}
|
|
38
|
+
if (LAUNCHD_RE.test(codeWithScripts)) {
|
|
39
|
+
detectedApis.push('launchd');
|
|
40
|
+
}
|
|
41
|
+
if (TASK_SCHED_RE.test(codeWithScripts)) {
|
|
42
|
+
detectedApis.push('task_scheduler');
|
|
43
|
+
}
|
|
44
|
+
if (CI_GUARD_RE.test(codeWithScripts)) {
|
|
45
|
+
hasCiGuard = true;
|
|
46
|
+
}
|
|
33
47
|
|
|
34
|
-
if (DAEMON_RE.test(codeWithScripts) || SPAWN_DETACHED_RE.test(codeWithScripts))
|
|
48
|
+
if (DAEMON_RE.test(codeWithScripts) || SPAWN_DETACHED_RE.test(codeWithScripts)) {
|
|
49
|
+
hasDaemon = true;
|
|
50
|
+
}
|
|
35
51
|
|
|
36
52
|
if (hasDaemon || detectedApis.length > 0) {
|
|
37
53
|
return {
|
|
38
54
|
triggered: true,
|
|
39
55
|
detectedApis,
|
|
40
56
|
hasCiGuard,
|
|
41
|
-
hooks: allScripts.map(s => s.hook),
|
|
42
|
-
context: hasCiGuard
|
|
57
|
+
hooks: allScripts.map((s) => s.hook),
|
|
58
|
+
context: hasCiGuard
|
|
59
|
+
? 'Spawns background process when CI env var absent'
|
|
60
|
+
: 'Suspicious persistence/detached process detected',
|
|
43
61
|
};
|
|
44
62
|
}
|
|
45
63
|
|
|
@@ -9,25 +9,37 @@ const TARGET_LOCALES = /ru_RU|be_BY|uk_UA/;
|
|
|
9
9
|
const SILENT_EXIT_RE = /process\.exit\s*\(\s*0\s*\)/;
|
|
10
10
|
|
|
11
11
|
export function scanGeoKillswitch(files = []) {
|
|
12
|
-
const code = files.map(f => f.content || '').join('\n');
|
|
13
|
-
if (!code)
|
|
12
|
+
const code = files.map((f) => f.content || '').join('\n');
|
|
13
|
+
if (!code) {
|
|
14
|
+
return { triggered: false, targetedLocales: [], triggerBehavior: null };
|
|
15
|
+
}
|
|
14
16
|
|
|
15
|
-
const hasLocaleCheck = LOCALE_CHECKS.some(re => re.test(code));
|
|
16
|
-
if (!hasLocaleCheck)
|
|
17
|
+
const hasLocaleCheck = LOCALE_CHECKS.some((re) => re.test(code));
|
|
18
|
+
if (!hasLocaleCheck) {
|
|
19
|
+
return { triggered: false, targetedLocales: [], triggerBehavior: null };
|
|
20
|
+
}
|
|
17
21
|
|
|
18
22
|
const hasTargetLocale = TARGET_LOCALES.test(code);
|
|
19
23
|
const hasSilentExit = SILENT_EXIT_RE.test(code);
|
|
20
24
|
|
|
21
25
|
if (hasTargetLocale || hasSilentExit) {
|
|
22
26
|
const matchedLocales = [];
|
|
23
|
-
if (/ru_RU/.test(code))
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
if (/ru_RU/.test(code)) {
|
|
28
|
+
matchedLocales.push('ru_RU');
|
|
29
|
+
}
|
|
30
|
+
if (/be_BY/.test(code)) {
|
|
31
|
+
matchedLocales.push('be_BY');
|
|
32
|
+
}
|
|
33
|
+
if (/uk_UA/.test(code)) {
|
|
34
|
+
matchedLocales.push('uk_UA');
|
|
35
|
+
}
|
|
26
36
|
|
|
27
37
|
return {
|
|
28
38
|
triggered: true,
|
|
29
39
|
targetedLocales: matchedLocales.length > 0 ? matchedLocales : ['ru_RU', 'be_BY'],
|
|
30
|
-
triggerBehavior: hasSilentExit
|
|
40
|
+
triggerBehavior: hasSilentExit
|
|
41
|
+
? 'Silent exit'
|
|
42
|
+
: 'Locale/timezone match with conditional behavior',
|
|
31
43
|
};
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -5,13 +5,19 @@ const GITHUB_TOKEN_ACCESS_RE = /process\.env\.(?:GH_TOKEN|GITHUB_TOKEN|GITHUB_AC
|
|
|
5
5
|
const COMMIT_PARSE_LOOP_RE = /commits?\s*\.\s*(?:map|filter|forEach|for\s*\(|while\s*\()/;
|
|
6
6
|
|
|
7
7
|
export function scanC2DeadDrop(files = []) {
|
|
8
|
-
const code = files.map(f => f.content || '').join('\n');
|
|
9
|
-
if (!code)
|
|
8
|
+
const code = files.map((f) => f.content || '').join('\n');
|
|
9
|
+
if (!code) {
|
|
10
|
+
return { triggered: false, matches: [] };
|
|
11
|
+
}
|
|
10
12
|
|
|
11
13
|
const matches = [];
|
|
12
14
|
|
|
13
15
|
if (OHNO_WHATS_GOING_ON_RE.test(code)) {
|
|
14
|
-
matches.push({
|
|
16
|
+
matches.push({
|
|
17
|
+
type: 'ioc_keyword',
|
|
18
|
+
value: 'OhNoWhatsGoingOnWithGitHub',
|
|
19
|
+
attackVector: 'GitHub commit scraping for token recovery',
|
|
20
|
+
});
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
const hasTokenAccess = GITHUB_TOKEN_ACCESS_RE.test(code);
|
|
@@ -19,11 +25,19 @@ export function scanC2DeadDrop(files = []) {
|
|
|
19
25
|
const hasCommitParseLoop = COMMIT_PARSE_LOOP_RE.test(code);
|
|
20
26
|
|
|
21
27
|
if (hasTokenAccess && hasGithubApi) {
|
|
22
|
-
matches.push({
|
|
28
|
+
matches.push({
|
|
29
|
+
type: 'token_exfil_github_api',
|
|
30
|
+
value: 'Credential access followed by GitHub API call',
|
|
31
|
+
attackVector: 'Credential/token extraction followed by GitHub API calls',
|
|
32
|
+
});
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
if (hasCommitParseLoop && hasGithubApi) {
|
|
26
|
-
matches.push({
|
|
36
|
+
matches.push({
|
|
37
|
+
type: 'commit_scraping',
|
|
38
|
+
value: 'Commit message parsing with GitHub API',
|
|
39
|
+
attackVector: 'Commit scraping for secret detection',
|
|
40
|
+
});
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
return {
|
|
@@ -15,48 +15,57 @@ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'none'];
|
|
|
15
15
|
|
|
16
16
|
function highestSeverity(severities) {
|
|
17
17
|
for (const s of SEVERITY_ORDER) {
|
|
18
|
-
if (severities.includes(s))
|
|
18
|
+
if (severities.includes(s)) {
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
19
21
|
}
|
|
20
22
|
return 'none';
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export async function scan(pkgJson, files = [],
|
|
25
|
+
export async function scan(pkgJson, files = [], _registryMeta = null, allFiles = null) {
|
|
24
26
|
const fileList = allFiles || files || [];
|
|
25
27
|
const pkgName = pkgJson?.name || 'unknown';
|
|
26
28
|
const pkgVersion = pkgJson?.version || '0.0.0';
|
|
27
29
|
|
|
28
30
|
const d1Results = scanCtfScramble(fileList);
|
|
29
31
|
if (d1Results.stopCondition) {
|
|
30
|
-
const evidence = attachProvenance(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
32
|
+
const evidence = attachProvenance(
|
|
33
|
+
{
|
|
34
|
+
rule: 'MSH-OBF-001',
|
|
35
|
+
campaign: 'MINI_SHAI_HULUD',
|
|
36
|
+
triggeredChecks: ['D1'],
|
|
37
|
+
filePath: d1Results.filePath,
|
|
38
|
+
patternMatched: d1Results.patternMatched,
|
|
39
|
+
action: 'BLOCK_IMMEDIATELY',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
ruleId: 'MSH-OBF-001',
|
|
43
|
+
ruleName: 'ctf-scramble-v2 Obfuscation Detection',
|
|
44
|
+
campaignName: 'Mini Shai-Hulud',
|
|
45
|
+
pkgName,
|
|
46
|
+
pkgVersion,
|
|
47
|
+
triggered: true,
|
|
48
|
+
severity: 'critical',
|
|
49
|
+
indicators: [{ type: 'obfuscation_found', value: d1Results.patternMatched }],
|
|
50
|
+
ruleProvenanceUrl:
|
|
51
|
+
'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/d1-obfuscation.js',
|
|
52
|
+
campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
|
|
53
|
+
}
|
|
54
|
+
);
|
|
50
55
|
|
|
51
|
-
return [
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
id: 'MINI_SHAI_HULUD',
|
|
59
|
+
severity: 'critical',
|
|
60
|
+
title: 'Mini Shai-Hulud worm campaign — ctf-scramble-v2 malware obfuscation detected',
|
|
61
|
+
description:
|
|
62
|
+
'HALT: ctf-scramble-v2 obfuscation layer detected. Package is compromised. Block install immediately.',
|
|
63
|
+
evidence: JSON.stringify(evidence),
|
|
64
|
+
mitigation:
|
|
65
|
+
'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.',
|
|
66
|
+
stopCondition: true,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
const results = {
|
|
@@ -69,39 +78,45 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
69
78
|
.filter(([_, r]) => r.triggered)
|
|
70
79
|
.map(([id]) => id);
|
|
71
80
|
|
|
72
|
-
if (triggered.length === 0)
|
|
81
|
+
if (triggered.length === 0) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
73
84
|
|
|
74
|
-
const severity = highestSeverity(triggered.map(id => RULE_SEVERITY[id]));
|
|
85
|
+
const severity = highestSeverity(triggered.map((id) => RULE_SEVERITY[id]));
|
|
75
86
|
|
|
76
|
-
const evidence = attachProvenance(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
Object.entries(results).filter(([_, r]) => r.triggered)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
87
|
+
const evidence = attachProvenance(
|
|
88
|
+
{
|
|
89
|
+
campaign: 'MINI_SHAI_HULUD',
|
|
90
|
+
triggeredChecks: triggered,
|
|
91
|
+
details: Object.fromEntries(Object.entries(results).filter(([_, r]) => r.triggered)),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
ruleId: 'MSH-SUPPLEMENT',
|
|
95
|
+
ruleName: 'Mini Shai-Hulud Supplement Detection',
|
|
96
|
+
campaignName: 'Mini Shai-Hulud',
|
|
97
|
+
pkgName,
|
|
98
|
+
pkgVersion,
|
|
99
|
+
triggered: true,
|
|
100
|
+
severity,
|
|
101
|
+
indicators: triggered.map((id) => ({
|
|
102
|
+
type: `rule_${id}`,
|
|
103
|
+
value: RULE_SEVERITY[id],
|
|
104
|
+
})),
|
|
105
|
+
ruleProvenanceUrl:
|
|
106
|
+
'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/',
|
|
107
|
+
campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
|
|
108
|
+
}
|
|
109
|
+
);
|
|
98
110
|
|
|
99
|
-
return [
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
id: 'MINI_SHAI_HULUD',
|
|
114
|
+
severity,
|
|
115
|
+
title: 'Mini Shai-Hulud worm campaign — supplement indicators',
|
|
116
|
+
description: `${triggered.length} signal(s): ${triggered.join(', ')}`,
|
|
117
|
+
evidence: JSON.stringify(evidence),
|
|
118
|
+
mitigation:
|
|
119
|
+
'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.',
|
|
120
|
+
},
|
|
121
|
+
];
|
|
107
122
|
}
|
|
@@ -6,9 +6,11 @@ const SAFE_PINS = {
|
|
|
6
6
|
'12.0.1': '12.0.0',
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
export function scanVersionBlocklist(pkgJson,
|
|
9
|
+
export function scanVersionBlocklist(pkgJson, _registryMeta) {
|
|
10
10
|
const pkgName = pkgJson?.name || '';
|
|
11
|
-
if (pkgName !== 'node-ipc')
|
|
11
|
+
if (pkgName !== 'node-ipc') {
|
|
12
|
+
return { triggered: false };
|
|
13
|
+
}
|
|
12
14
|
|
|
13
15
|
const version = pkgJson?.version || '';
|
|
14
16
|
if (BLOCKED_VERSIONS.has(version)) {
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
export function scanUnauthorizedPublisher(pkgJson, registryMeta) {
|
|
2
2
|
const pkgName = pkgJson?.name || '';
|
|
3
|
-
if (pkgName !== 'node-ipc')
|
|
3
|
+
if (pkgName !== 'node-ipc') {
|
|
4
|
+
return { triggered: false };
|
|
5
|
+
}
|
|
4
6
|
|
|
5
|
-
const publisherAccount =
|
|
6
|
-
|
|
7
|
-
||
|
|
7
|
+
const publisherAccount =
|
|
8
|
+
registryMeta?.versions?.[pkgJson?.version]?._npmUser?.name ||
|
|
9
|
+
registryMeta?.versions?.[Object.keys(registryMeta.versions || {})[0]]?._npmUser?.name ||
|
|
10
|
+
null;
|
|
8
11
|
|
|
9
12
|
if (publisherAccount === 'atiertant') {
|
|
10
13
|
return {
|
|
11
14
|
triggered: true,
|
|
12
15
|
publisher: publisherAccount,
|
|
13
16
|
package: pkgName,
|
|
14
|
-
detail:
|
|
17
|
+
detail:
|
|
18
|
+
'Account atiertant has no prior release history on node-ipc — account recovery via expired email domain takeover',
|
|
15
19
|
};
|
|
16
20
|
}
|
|
17
21
|
|
|
@@ -16,12 +16,16 @@ export function scanBlastRadius(allFiles) {
|
|
|
16
16
|
|
|
17
17
|
for (const file of allFiles) {
|
|
18
18
|
const path = file.path?.replace(/\\/g, '/') || '';
|
|
19
|
-
const isLockfile = LOCKFILE_PATTERNS.some(p => p.test(path));
|
|
20
|
-
if (!isLockfile)
|
|
19
|
+
const isLockfile = LOCKFILE_PATTERNS.some((p) => p.test(path));
|
|
20
|
+
if (!isLockfile) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
21
23
|
|
|
22
24
|
const content = file.content || '';
|
|
23
25
|
const hasNodeIpc = /\bnode-ipc\b/i.test(content);
|
|
24
|
-
if (!hasNodeIpc)
|
|
26
|
+
if (!hasNodeIpc) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
25
29
|
|
|
26
30
|
for (const [badVersion, info] of Object.entries(COMPROMISED_VERSIONS)) {
|
|
27
31
|
const versionInQuotes = `"${badVersion}"`;
|
|
@@ -11,7 +11,9 @@ export function scanTarballHash(allFiles) {
|
|
|
11
11
|
|
|
12
12
|
for (const file of allFiles) {
|
|
13
13
|
const path = file.path || '';
|
|
14
|
-
if (!path.endsWith('.tgz') && !path.endsWith('.tar.gz'))
|
|
14
|
+
if (!path.endsWith('.tgz') && !path.endsWith('.tar.gz')) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
15
17
|
|
|
16
18
|
const content = file.content || '';
|
|
17
19
|
const hash = createHash('sha256').update(content, 'utf8').digest('hex');
|
|
@@ -20,9 +22,12 @@ export function scanTarballHash(allFiles) {
|
|
|
20
22
|
matches.push({
|
|
21
23
|
file: path,
|
|
22
24
|
sha256: hash,
|
|
23
|
-
version:
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
version:
|
|
26
|
+
hash === '449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e'
|
|
27
|
+
? '9.1.6'
|
|
28
|
+
: hash === 'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea'
|
|
29
|
+
? '9.2.3'
|
|
30
|
+
: '12.0.1',
|
|
26
31
|
});
|
|
27
32
|
}
|
|
28
33
|
}
|