@lateos/npm-scan 1.0.0 → 1.1.1
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 +3 -98
- package/README.fr.md +3 -98
- package/README.ja.md +3 -98
- package/README.md +2 -122
- package/README.zh.md +3 -98
- package/backend/cra.js +113 -21
- 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 +48 -3
- 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 +181 -34
- package/backend/detectors/lib/ast-patterns.js +4 -1
- package/backend/detectors/lib/entropy-analyzer.js +12 -4
- 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 +109 -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 +45 -17
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +1 -1
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +77 -41
- 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 +78 -19
- 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 +14 -8
- package/backend/scripts/analyze-validation.js +27 -21
- package/backend/scripts/detect-false-positives.js +20 -10
- package/backend/scripts/fetch-top-packages.js +197 -49
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +26 -17
- 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 +13 -12
- package/backend/tests-d6-version-anomaly.test.js +17 -8
- package/backend/tests-d6.test.js +24 -14
- package/backend/tests-d6c.test.js +27 -14
- package/backend/tests-d7-obfuscation.test.js +9 -12
- package/backend/tests.test.js +182 -83
- 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 +12 -3
|
@@ -29,9 +29,15 @@ import { scan as tier1MultistagePostinstallScan } from './tier1-multistage-posti
|
|
|
29
29
|
import { scan as tier1VersionAnomalyScan } from './tier1-version-anomaly.js';
|
|
30
30
|
import { scan as tier1ObfuscationHeuristicsScan } from './tier1-obfuscation-heuristics.js';
|
|
31
31
|
import { scan as tier1SlsaAttestationScan } from './tier1-slsa-attestation.js';
|
|
32
|
+
import { scan as tier1SelfPropagationScan } from './tier1-self-propagation.js';
|
|
33
|
+
import { scan as tier1EncryptedC2Scan } from './tier1-encrypted-c2.js';
|
|
34
|
+
import { scan as tier1TransitiveDepsScan } from './tier1-transitive-deps.js';
|
|
35
|
+
import { scan as tier1MaintainerCompromiseScan } from './tier1-maintainer-compromise.js';
|
|
32
36
|
|
|
33
37
|
function timeout(ms) {
|
|
34
|
-
return new Promise((_, reject) =>
|
|
38
|
+
return new Promise((_, reject) =>
|
|
39
|
+
setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
|
|
40
|
+
);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
async function runTier1(name, scanFn, pkgJson, files, registryMeta, allFiles) {
|
|
@@ -43,7 +49,9 @@ async function runTier1(name, scanFn, pkgJson, files, registryMeta, allFiles) {
|
|
|
43
49
|
const fileCount = allFiles && allFiles.length > 0 ? allFiles.length : files.length;
|
|
44
50
|
if (fileCount >= 10 && result.length > 0) {
|
|
45
51
|
const hitRate = result.length / fileCount;
|
|
46
|
-
if (hitRate > 0.8)
|
|
52
|
+
if (hitRate > 0.8) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
47
55
|
}
|
|
48
56
|
return result;
|
|
49
57
|
} catch {
|
|
@@ -53,36 +61,175 @@ async function runTier1(name, scanFn, pkgJson, files, registryMeta, allFiles) {
|
|
|
53
61
|
|
|
54
62
|
export async function runAll(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
55
63
|
const findings = [];
|
|
56
|
-
findings.push(...await atk001.scan(pkgJson, files));
|
|
57
|
-
findings.push(...await atk002.scan(pkgJson, files));
|
|
58
|
-
findings.push(...await atk003.scan(pkgJson, files));
|
|
59
|
-
findings.push(...await atk004.scan(pkgJson, files));
|
|
60
|
-
findings.push(...await atk005.scan(pkgJson, files));
|
|
61
|
-
findings.push(...await atk006.scan(pkgJson, files));
|
|
62
|
-
findings.push(...await atk007.scan(pkgJson, files));
|
|
63
|
-
findings.push(...await atk008.scan(pkgJson, files));
|
|
64
|
-
findings.push(...await atk009.scan(pkgJson, files));
|
|
65
|
-
findings.push(...await atk010.scan(pkgJson, files));
|
|
66
|
-
findings.push(...await atk011.scan(pkgJson, files));
|
|
67
|
-
findings.push(...await megalodonScan(pkgJson, allFiles || files, registryMeta));
|
|
68
|
-
findings.push(...await hfScan(pkgJson, files, registryMeta, allFiles || files));
|
|
69
|
-
findings.push(...await miniShaiHuludScan(pkgJson, files, registryMeta, allFiles || files));
|
|
70
|
-
findings.push(...await badhostScan(pkgJson, files, registryMeta, allFiles || files));
|
|
71
|
-
findings.push(...await trapdoorScan(pkgJson, files, registryMeta, allFiles || files));
|
|
72
|
-
findings.push(...await nodeIpcScan(pkgJson, files, registryMeta, allFiles || files));
|
|
73
|
-
findings.push(...await mshSupplementScan(pkgJson, files, registryMeta, allFiles || files));
|
|
74
|
-
findings.push(...await typosquatScan(pkgJson, files, registryMeta, allFiles || files));
|
|
75
|
-
findings.push(...await axiosPoisoningScan(pkgJson, files, registryMeta, allFiles || files));
|
|
76
|
-
findings.push(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
findings.push(
|
|
64
|
+
findings.push(...(await atk001.scan(pkgJson, files)));
|
|
65
|
+
findings.push(...(await atk002.scan(pkgJson, files)));
|
|
66
|
+
findings.push(...(await atk003.scan(pkgJson, files)));
|
|
67
|
+
findings.push(...(await atk004.scan(pkgJson, files)));
|
|
68
|
+
findings.push(...(await atk005.scan(pkgJson, files)));
|
|
69
|
+
findings.push(...(await atk006.scan(pkgJson, files)));
|
|
70
|
+
findings.push(...(await atk007.scan(pkgJson, files)));
|
|
71
|
+
findings.push(...(await atk008.scan(pkgJson, files)));
|
|
72
|
+
findings.push(...(await atk009.scan(pkgJson, files)));
|
|
73
|
+
findings.push(...(await atk010.scan(pkgJson, files)));
|
|
74
|
+
findings.push(...(await atk011.scan(pkgJson, files)));
|
|
75
|
+
findings.push(...(await megalodonScan(pkgJson, allFiles || files, registryMeta)));
|
|
76
|
+
findings.push(...(await hfScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
77
|
+
findings.push(...(await miniShaiHuludScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
78
|
+
findings.push(...(await badhostScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
79
|
+
findings.push(...(await trapdoorScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
80
|
+
findings.push(...(await nodeIpcScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
81
|
+
findings.push(...(await mshSupplementScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
82
|
+
findings.push(...(await typosquatScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
83
|
+
findings.push(...(await axiosPoisoningScan(pkgJson, files, registryMeta, allFiles || files)));
|
|
84
|
+
findings.push(
|
|
85
|
+
...(await runTier1(
|
|
86
|
+
'tier1-typosquat',
|
|
87
|
+
tier1TyposquatScan,
|
|
88
|
+
pkgJson,
|
|
89
|
+
files,
|
|
90
|
+
registryMeta,
|
|
91
|
+
allFiles || files
|
|
92
|
+
))
|
|
93
|
+
);
|
|
94
|
+
findings.push(
|
|
95
|
+
...(await runTier1(
|
|
96
|
+
'tier1-infostealer',
|
|
97
|
+
tier1InfostealerScan,
|
|
98
|
+
pkgJson,
|
|
99
|
+
files,
|
|
100
|
+
registryMeta,
|
|
101
|
+
allFiles || files
|
|
102
|
+
))
|
|
103
|
+
);
|
|
104
|
+
findings.push(
|
|
105
|
+
...(await runTier1(
|
|
106
|
+
'tier1-lifecycle-hook',
|
|
107
|
+
tier1LifecycleHookScan,
|
|
108
|
+
pkgJson,
|
|
109
|
+
files,
|
|
110
|
+
registryMeta,
|
|
111
|
+
allFiles || files
|
|
112
|
+
))
|
|
113
|
+
);
|
|
114
|
+
findings.push(
|
|
115
|
+
...(await runTier1(
|
|
116
|
+
'tier1-binary-embed',
|
|
117
|
+
tier1BinaryEmbedScan,
|
|
118
|
+
pkgJson,
|
|
119
|
+
files,
|
|
120
|
+
registryMeta,
|
|
121
|
+
allFiles || files
|
|
122
|
+
))
|
|
123
|
+
);
|
|
124
|
+
findings.push(
|
|
125
|
+
...(await runTier1(
|
|
126
|
+
'tier1-metadata-spoof',
|
|
127
|
+
tier1MetadataSpoofScan,
|
|
128
|
+
pkgJson,
|
|
129
|
+
files,
|
|
130
|
+
registryMeta,
|
|
131
|
+
allFiles || files
|
|
132
|
+
))
|
|
133
|
+
);
|
|
134
|
+
findings.push(
|
|
135
|
+
...(await runTier1(
|
|
136
|
+
'tier1-version-confusion',
|
|
137
|
+
tier1VersionConfusionScan,
|
|
138
|
+
pkgJson,
|
|
139
|
+
files,
|
|
140
|
+
registryMeta,
|
|
141
|
+
allFiles || files
|
|
142
|
+
))
|
|
143
|
+
);
|
|
144
|
+
findings.push(
|
|
145
|
+
...(await runTier1(
|
|
146
|
+
'tier1-cloud-imds',
|
|
147
|
+
tier1CloudImdsScan,
|
|
148
|
+
pkgJson,
|
|
149
|
+
files,
|
|
150
|
+
registryMeta,
|
|
151
|
+
allFiles || files
|
|
152
|
+
))
|
|
153
|
+
);
|
|
154
|
+
findings.push(
|
|
155
|
+
...(await runTier1(
|
|
156
|
+
'tier1-multistage-postinstall',
|
|
157
|
+
tier1MultistagePostinstallScan,
|
|
158
|
+
pkgJson,
|
|
159
|
+
files,
|
|
160
|
+
registryMeta,
|
|
161
|
+
allFiles || files
|
|
162
|
+
))
|
|
163
|
+
);
|
|
164
|
+
findings.push(
|
|
165
|
+
...(await runTier1(
|
|
166
|
+
'tier1-version-anomaly',
|
|
167
|
+
tier1VersionAnomalyScan,
|
|
168
|
+
pkgJson,
|
|
169
|
+
files,
|
|
170
|
+
registryMeta,
|
|
171
|
+
allFiles || files
|
|
172
|
+
))
|
|
173
|
+
);
|
|
174
|
+
findings.push(
|
|
175
|
+
...(await runTier1(
|
|
176
|
+
'tier1-obfuscation-heuristics',
|
|
177
|
+
tier1ObfuscationHeuristicsScan,
|
|
178
|
+
pkgJson,
|
|
179
|
+
files,
|
|
180
|
+
registryMeta,
|
|
181
|
+
allFiles || files
|
|
182
|
+
))
|
|
183
|
+
);
|
|
184
|
+
findings.push(
|
|
185
|
+
...(await runTier1(
|
|
186
|
+
'tier1-slsa-attestation',
|
|
187
|
+
tier1SlsaAttestationScan,
|
|
188
|
+
pkgJson,
|
|
189
|
+
files,
|
|
190
|
+
registryMeta,
|
|
191
|
+
allFiles || files
|
|
192
|
+
))
|
|
193
|
+
);
|
|
194
|
+
findings.push(
|
|
195
|
+
...(await runTier1(
|
|
196
|
+
'tier1-self-propagation',
|
|
197
|
+
tier1SelfPropagationScan,
|
|
198
|
+
pkgJson,
|
|
199
|
+
files,
|
|
200
|
+
registryMeta,
|
|
201
|
+
allFiles || files
|
|
202
|
+
))
|
|
203
|
+
);
|
|
204
|
+
findings.push(
|
|
205
|
+
...(await runTier1(
|
|
206
|
+
'tier1-encrypted-c2',
|
|
207
|
+
tier1EncryptedC2Scan,
|
|
208
|
+
pkgJson,
|
|
209
|
+
files,
|
|
210
|
+
registryMeta,
|
|
211
|
+
allFiles || files
|
|
212
|
+
))
|
|
213
|
+
);
|
|
214
|
+
findings.push(
|
|
215
|
+
...(await runTier1(
|
|
216
|
+
'tier1-transitive-deps',
|
|
217
|
+
tier1TransitiveDepsScan,
|
|
218
|
+
pkgJson,
|
|
219
|
+
files,
|
|
220
|
+
registryMeta,
|
|
221
|
+
allFiles || files
|
|
222
|
+
))
|
|
223
|
+
);
|
|
224
|
+
findings.push(
|
|
225
|
+
...(await runTier1(
|
|
226
|
+
'tier1-maintainer-compromise',
|
|
227
|
+
tier1MaintainerCompromiseScan,
|
|
228
|
+
pkgJson,
|
|
229
|
+
files,
|
|
230
|
+
registryMeta,
|
|
231
|
+
allFiles || files
|
|
232
|
+
))
|
|
233
|
+
);
|
|
87
234
|
return findings.sort((a, b) => b.severity.localeCompare(a.severity));
|
|
88
|
-
}
|
|
235
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const PATTERNS = [
|
|
2
2
|
{ id: 'EVAL_USAGE', re: /\beval\s*\(/ },
|
|
3
3
|
{ id: 'FUNCTION_CONSTRUCTOR', re: /Function\s*\(/ },
|
|
4
|
-
{
|
|
4
|
+
{
|
|
5
|
+
id: 'STRING_REVERSAL_CHAIN',
|
|
6
|
+
re: /\.split\s*\(\s*['"]\s*['"]\s*\)\s*\.reverse\s*\(\s*\)\s*\.join\s*\(/,
|
|
7
|
+
},
|
|
5
8
|
{ id: 'XOR_CIPHER', re: /charCodeAt\s*\([^)]*\)\s*\^\s*\w+/ },
|
|
6
9
|
{ id: 'BITWISE_LOOP', re: /for\s*\([^;]+;[^;]+\)\s*\{[^}]{20,}\^[^}]*\}/ },
|
|
7
10
|
{ id: 'DYNAMIC_REQUIRE', re: /require\s*\(\s*(?:Buffer\.from|atob|decodeURIComponent)/ },
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export function shannonEntropy(str) {
|
|
2
2
|
const len = str.length;
|
|
3
|
-
if (len === 0)
|
|
3
|
+
if (len === 0) {
|
|
4
|
+
return 0;
|
|
5
|
+
}
|
|
4
6
|
const freq = {};
|
|
5
7
|
for (const ch of str) {
|
|
6
8
|
freq[ch] = (freq[ch] || 0) + 1;
|
|
@@ -14,11 +16,17 @@ export function shannonEntropy(str) {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function isMinified(code) {
|
|
17
|
-
if (code.length < 100)
|
|
19
|
+
if (code.length < 100) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
18
22
|
const lines = code.split('\n');
|
|
19
|
-
if (lines.length <= 3 && code.length > 1000)
|
|
23
|
+
if (lines.length <= 3 && code.length > 1000) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
20
26
|
const tokens = code.match(/\b[a-zA-Z_$][\w$]*\b/g) || [];
|
|
21
|
-
if (tokens.length < 10)
|
|
27
|
+
if (tokens.length < 10) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
22
30
|
const avgLen = tokens.reduce((s, t) => s + t.length, 0) / tokens.length;
|
|
23
31
|
return avgLen < 3;
|
|
24
32
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { MegalodonSignal } from './types.js';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
|
|
4
|
-
const C2_EXFIL_RE =
|
|
4
|
+
const C2_EXFIL_RE =
|
|
5
|
+
/curl\s+.*?https?:\/\/(?!github\.com|githubusercontent\.com|raw\.githubusercontent\.com)[^\s'"]+/i;
|
|
5
6
|
const SECRETS_REF_RE = /\$\{\{?\s*secrets\.\w+/;
|
|
6
7
|
const B64_DECODE_CHAIN_RE = /base64\s+-d\s*[|>]\s*(ba)?sh/;
|
|
7
8
|
|
|
@@ -11,16 +12,23 @@ function isWorkflowFile(f) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
function countExecutableLines(text) {
|
|
14
|
-
return text.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).length;
|
|
15
|
+
return text.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#')).length;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
function extractRunBlocks(parsed) {
|
|
18
19
|
const runs = [];
|
|
19
|
-
if (!parsed || typeof parsed !== 'object')
|
|
20
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
21
|
+
return runs;
|
|
22
|
+
}
|
|
20
23
|
|
|
21
24
|
const walk = (obj) => {
|
|
22
|
-
if (!obj || typeof obj !== 'object')
|
|
23
|
-
|
|
25
|
+
if (!obj || typeof obj !== 'object') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(obj)) {
|
|
29
|
+
obj.forEach(walk);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
24
32
|
for (const [k, v] of Object.entries(obj)) {
|
|
25
33
|
if (k === 'run' && typeof v === 'string') {
|
|
26
34
|
runs.push(v);
|
|
@@ -38,21 +46,31 @@ function extractRunBlocks(parsed) {
|
|
|
38
46
|
function extractRunBlocksRaw(text) {
|
|
39
47
|
const runs = [];
|
|
40
48
|
const runMatch = text.match(/run:\s*[|>]\s*\n(\s{2,}.*(?:\n\s{2,}.*)*)/g);
|
|
41
|
-
if (runMatch)
|
|
49
|
+
if (runMatch) {
|
|
50
|
+
runs.push(...runMatch.map((m) => m.replace(/^run:\s*[|>]\s*\n/, '')));
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
const inlineRe = /run:\s*['"](.+?)['"]\s*$/gm;
|
|
44
54
|
let m;
|
|
45
|
-
while ((m = inlineRe.exec(text)) !== null)
|
|
55
|
+
while ((m = inlineRe.exec(text)) !== null) {
|
|
56
|
+
runs.push(m[1]);
|
|
57
|
+
}
|
|
46
58
|
|
|
47
59
|
const envRe = /env:\s*\n((?:\s{2,}\w+:\s*.+\n?)*)/g;
|
|
48
60
|
let em;
|
|
49
|
-
while ((em = envRe.exec(text)) !== null)
|
|
61
|
+
while ((em = envRe.exec(text)) !== null) {
|
|
62
|
+
runs.push({ _env: em[1] });
|
|
63
|
+
}
|
|
50
64
|
return runs;
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
function runInStepHasBoth(step, signal) {
|
|
54
68
|
const runVal = step.run;
|
|
55
|
-
const envVals = step.env
|
|
69
|
+
const envVals = step.env
|
|
70
|
+
? Object.values(step.env)
|
|
71
|
+
.filter((v) => typeof v === 'string')
|
|
72
|
+
.join(' ')
|
|
73
|
+
: '';
|
|
56
74
|
const combined = typeof runVal === 'string' ? `${runVal} ${envVals}` : '';
|
|
57
75
|
|
|
58
76
|
if (signal === 'exfil') {
|
|
@@ -69,19 +87,21 @@ export async function scan(allFiles) {
|
|
|
69
87
|
const workflowFiles = allFiles.filter(isWorkflowFile);
|
|
70
88
|
|
|
71
89
|
for (const f of workflowFiles) {
|
|
72
|
-
if (f.content.length > 512 * 1024)
|
|
90
|
+
if (f.content.length > 512 * 1024) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
73
93
|
|
|
74
94
|
let parsed = null;
|
|
75
|
-
let
|
|
95
|
+
let _parseError = null;
|
|
76
96
|
try {
|
|
77
97
|
parsed = yaml.load(f.content);
|
|
78
98
|
} catch (e) {
|
|
79
|
-
|
|
99
|
+
_parseError = e;
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
const rawRunBlocks = parsed ? extractRunBlocks(parsed) : extractRunBlocksRaw(f.content);
|
|
83
|
-
const runStrings = rawRunBlocks.filter(r => typeof r === 'string');
|
|
84
|
-
const
|
|
103
|
+
const runStrings = rawRunBlocks.filter((r) => typeof r === 'string');
|
|
104
|
+
const _envBlocks = rawRunBlocks.filter((r) => typeof r === 'object' && r._env);
|
|
85
105
|
|
|
86
106
|
let exfilTriggered = false;
|
|
87
107
|
let decodeTriggered = false;
|
|
@@ -109,7 +129,7 @@ export async function scan(allFiles) {
|
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
112
|
-
const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap(j => j.steps || []) : [];
|
|
132
|
+
const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap((j) => j.steps || []) : [];
|
|
113
133
|
for (const step of steps) {
|
|
114
134
|
if (!exfilTriggered && runInStepHasBoth(step, 'exfil')) {
|
|
115
135
|
exfilTriggered = true;
|
|
@@ -136,7 +156,11 @@ export async function scan(allFiles) {
|
|
|
136
156
|
|
|
137
157
|
const lineCount = countExecutableLines(f.content);
|
|
138
158
|
if ((exfilTriggered || decodeTriggered) && lineCount >= 100 && lineCount <= 120) {
|
|
139
|
-
const found = evidence.find(
|
|
159
|
+
const found = evidence.find(
|
|
160
|
+
(e) =>
|
|
161
|
+
e.signal === MegalodonSignal.WORKFLOW_C2_EXFIL ||
|
|
162
|
+
e.signal === MegalodonSignal.WORKFLOW_DECODE_CHAIN
|
|
163
|
+
);
|
|
140
164
|
if (found) {
|
|
141
165
|
found.detail += ` | Matches ${lineCount}-line Megalodon payload footprint`;
|
|
142
166
|
}
|
|
@@ -3,7 +3,10 @@ import { MegalodonSignal } from './types.js';
|
|
|
3
3
|
const CRED_PATTERNS = [
|
|
4
4
|
{ pattern: /\bAWS_(SECRET_ACCESS_KEY|ACCESS_KEY_ID|SESSION_TOKEN)\b/, label: 'AWS credential' },
|
|
5
5
|
{ pattern: /\bGOOGLE_APPLICATION_CREDENTIALS\b/, label: 'GCP credential' },
|
|
6
|
-
{
|
|
6
|
+
{
|
|
7
|
+
pattern: /\bAZURE_(CLIENT_SECRET|TENANT_ID|CLIENT_ID|SUBSCRIPTION_ID)\b/,
|
|
8
|
+
label: 'Azure credential',
|
|
9
|
+
},
|
|
7
10
|
{ pattern: /\bGH_(TOKEN|PAT)\b/, label: 'GitHub PAT' },
|
|
8
11
|
{ pattern: /\bGITHUB_TOKEN\b/, label: 'GitHub token' },
|
|
9
12
|
{ pattern: /\bNPM_TOKEN\b/, label: 'npm token' },
|
|
@@ -15,7 +18,8 @@ const CRED_PATTERNS = [
|
|
|
15
18
|
{ pattern: /\bMONGO_(URI|URL|CONNECTION)\b/, label: 'MongoDB connection' },
|
|
16
19
|
];
|
|
17
20
|
|
|
18
|
-
const OUTBOUND_NET_RE =
|
|
21
|
+
const OUTBOUND_NET_RE =
|
|
22
|
+
/curl\s+|wget\s+|fetch\s*\(|https?\.request\s*\(|http\.request\s*\(|got\s*\(|axios\s*\.|request\s*\(|node-fetch|\.post\s*\(|\.get\s*\(/i;
|
|
19
23
|
|
|
20
24
|
const TARGET_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml', '.js'];
|
|
21
25
|
|
|
@@ -37,7 +41,7 @@ export async function scan(allFiles) {
|
|
|
37
41
|
const re = new RegExp(cp.pattern.source, 'gi');
|
|
38
42
|
let m;
|
|
39
43
|
while ((m = re.exec(content)) !== null) {
|
|
40
|
-
if (!matched.some(ex => ex.label === cp.label)) {
|
|
44
|
+
if (!matched.some((ex) => ex.label === cp.label)) {
|
|
41
45
|
matched.push({ label: cp.label, match: m[0] });
|
|
42
46
|
}
|
|
43
47
|
score += 3;
|
|
@@ -50,8 +54,11 @@ export async function scan(allFiles) {
|
|
|
50
54
|
evidence.push({
|
|
51
55
|
signal: MegalodonSignal.CREDENTIAL_HARVEST,
|
|
52
56
|
file: f.path,
|
|
53
|
-
excerpt: matched
|
|
54
|
-
|
|
57
|
+
excerpt: matched
|
|
58
|
+
.map((m) => m.label)
|
|
59
|
+
.join(', ')
|
|
60
|
+
.slice(0, 120),
|
|
61
|
+
detail: `Credential env vars (${matched.map((m) => m.label).join(', ')}) co-occur with outbound network call (score: ${score})`,
|
|
55
62
|
});
|
|
56
63
|
}
|
|
57
64
|
}
|
|
@@ -3,7 +3,9 @@ import { MegalodonSignal } from './types.js';
|
|
|
3
3
|
export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
|
|
4
4
|
const filtered = {};
|
|
5
5
|
for (const [v, t] of Object.entries(times)) {
|
|
6
|
-
if (v === 'created' || v === 'modified')
|
|
6
|
+
if (v === 'created' || v === 'modified') {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
7
9
|
filtered[v] = t;
|
|
8
10
|
}
|
|
9
11
|
|
|
@@ -33,7 +35,7 @@ export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
if (inWindow.length >= threshold) {
|
|
36
|
-
|
|
38
|
+
const display = inWindow.slice(0, 10);
|
|
37
39
|
let suffix = '';
|
|
38
40
|
if (inWindow.length > 10) {
|
|
39
41
|
suffix = ` +${inWindow.length - 10} more`;
|
|
@@ -54,14 +56,18 @@ export async function scan(registryMeta) {
|
|
|
54
56
|
const times = registryMeta?.time || {};
|
|
55
57
|
const result = detectVelocitySpike(times);
|
|
56
58
|
|
|
57
|
-
if (!result.triggered)
|
|
59
|
+
if (!result.triggered) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
58
62
|
|
|
59
|
-
return [
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
signal: MegalodonSignal.PUBLISH_VELOCITY,
|
|
66
|
+
file: 'registry.npmjs.org',
|
|
67
|
+
excerpt: result.versionsInWindow,
|
|
68
|
+
detail: `Version publish velocity spike: ${result.versionsInWindow} versions in window starting ${result.windowStartISO}`,
|
|
69
|
+
_windowStartISO: result.windowStartISO,
|
|
70
|
+
_allVersions: result._allVersions,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
67
73
|
}
|
|
@@ -7,8 +7,12 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
7
7
|
|
|
8
8
|
const filteredTimes = {};
|
|
9
9
|
for (const [v, t] of Object.entries(timeMap)) {
|
|
10
|
-
if (v === 'created' || v === 'modified')
|
|
11
|
-
|
|
10
|
+
if (v === 'created' || v === 'modified') {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (t) {
|
|
14
|
+
filteredTimes[v] = t;
|
|
15
|
+
}
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
const sortedVersions = Object.entries(filteredTimes)
|
|
@@ -16,7 +20,9 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
16
20
|
.sort((a, b) => new Date(a[1]).getTime() - new Date(b[1]).getTime())
|
|
17
21
|
.map(([v]) => v);
|
|
18
22
|
|
|
19
|
-
if (sortedVersions.length === 0)
|
|
23
|
+
if (sortedVersions.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
20
26
|
|
|
21
27
|
if (velocityResult?.triggered) {
|
|
22
28
|
const windowStartISO = velocityResult.windowStartISO;
|
|
@@ -24,14 +30,20 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
24
30
|
|
|
25
31
|
const priorPublishers = new Set();
|
|
26
32
|
for (const v of sortedVersions) {
|
|
27
|
-
if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime())
|
|
33
|
+
if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime()) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
28
36
|
const user = versions[v]?._npmUser?.name;
|
|
29
|
-
if (user)
|
|
37
|
+
if (user) {
|
|
38
|
+
priorPublishers.add(user);
|
|
39
|
+
}
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
if (priorPublishers.size === 0 && allInWindow.length > 0) {
|
|
33
43
|
const firstUser = versions[allInWindow[0]]?._npmUser?.name;
|
|
34
|
-
if (firstUser)
|
|
44
|
+
if (firstUser) {
|
|
45
|
+
priorPublishers.add(firstUser);
|
|
46
|
+
}
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
const suspiciousPublishers = [];
|
|
@@ -39,15 +51,19 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
39
51
|
for (const v of allInWindow) {
|
|
40
52
|
const user = versions[v]?._npmUser?.name;
|
|
41
53
|
if (user && !priorPublishers.has(user)) {
|
|
42
|
-
if (!suspiciousPublishers.includes(user))
|
|
43
|
-
|
|
54
|
+
if (!suspiciousPublishers.includes(user)) {
|
|
55
|
+
suspiciousPublishers.push(user);
|
|
56
|
+
}
|
|
57
|
+
if (!affectedVersions.includes(v)) {
|
|
58
|
+
affectedVersions.push(v);
|
|
59
|
+
}
|
|
44
60
|
}
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
if (suspiciousPublishers.length > 0) {
|
|
48
64
|
const detail = `Drift detected: known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in versions [${affectedVersions.join(', ')}]`;
|
|
49
65
|
|
|
50
|
-
const firstSuspiciousVer = allInWindow.find(v => affectedVersions.includes(v));
|
|
66
|
+
const firstSuspiciousVer = allInWindow.find((v) => affectedVersions.includes(v));
|
|
51
67
|
let ageNote = '';
|
|
52
68
|
if (firstSuspiciousVer && suspiciousPublishers[0]) {
|
|
53
69
|
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[firstSuspiciousVer]);
|
|
@@ -62,7 +78,9 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
62
78
|
});
|
|
63
79
|
}
|
|
64
80
|
} else {
|
|
65
|
-
if (sortedVersions.length < 4)
|
|
81
|
+
if (sortedVersions.length < 4) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
66
84
|
|
|
67
85
|
const last3 = sortedVersions.slice(-3);
|
|
68
86
|
const prior = sortedVersions.slice(0, -3);
|
|
@@ -70,7 +88,9 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
70
88
|
const priorPublishers = new Set();
|
|
71
89
|
for (const v of prior) {
|
|
72
90
|
const user = versions[v]?._npmUser?.name;
|
|
73
|
-
if (user)
|
|
91
|
+
if (user) {
|
|
92
|
+
priorPublishers.add(user);
|
|
93
|
+
}
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
const suspiciousPublishers = [];
|
|
@@ -78,8 +98,12 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
78
98
|
for (const v of last3) {
|
|
79
99
|
const user = versions[v]?._npmUser?.name;
|
|
80
100
|
if (user && !priorPublishers.has(user)) {
|
|
81
|
-
if (!suspiciousPublishers.includes(user))
|
|
82
|
-
|
|
101
|
+
if (!suspiciousPublishers.includes(user)) {
|
|
102
|
+
suspiciousPublishers.push(user);
|
|
103
|
+
}
|
|
104
|
+
if (!affectedVersions.includes(v)) {
|
|
105
|
+
affectedVersions.push(v);
|
|
106
|
+
}
|
|
83
107
|
}
|
|
84
108
|
}
|
|
85
109
|
|
|
@@ -88,7 +112,10 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
88
112
|
|
|
89
113
|
let ageNote = '';
|
|
90
114
|
if (suspiciousPublishers[0] && affectedVersions[0]) {
|
|
91
|
-
ageNote = await checkAccountAge(
|
|
115
|
+
ageNote = await checkAccountAge(
|
|
116
|
+
suspiciousPublishers[0],
|
|
117
|
+
filteredTimes[affectedVersions[0]]
|
|
118
|
+
);
|
|
92
119
|
}
|
|
93
120
|
|
|
94
121
|
evidence.push({
|
|
@@ -108,10 +135,14 @@ async function checkAccountAge(npmUser, firstSuspiciousTime) {
|
|
|
108
135
|
try {
|
|
109
136
|
const url = `https://registry.npmjs.org/-/user/org.couchdb.user/${encodeURIComponent(npmUser)}`;
|
|
110
137
|
const res = await fetch(url);
|
|
111
|
-
if (!res.ok)
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
112
141
|
const data = await res.json();
|
|
113
142
|
const created = data?.date;
|
|
114
|
-
if (!created)
|
|
143
|
+
if (!created) {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
115
146
|
const createdDate = new Date(created).getTime();
|
|
116
147
|
const firstPub = new Date(firstSuspiciousTime).getTime();
|
|
117
148
|
const daysDiff = (firstPub - createdDate) / (1000 * 60 * 60 * 24);
|
|
@@ -119,6 +150,7 @@ async function checkAccountAge(npmUser, firstSuspiciousTime) {
|
|
|
119
150
|
return `Publisher account created ${Math.round(daysDiff)} days before first suspicious publish`;
|
|
120
151
|
}
|
|
121
152
|
} catch {
|
|
153
|
+
/* ignore fetch errors */
|
|
122
154
|
}
|
|
123
155
|
return '';
|
|
124
156
|
}
|