@lateos/npm-scan 0.15.0 → 0.15.2
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.md +8 -2
- package/backend/detectors/atk-002-obfusc.js +184 -12
- package/backend/detectors/index.js +3 -1
- package/backend/detectors/megalodon/d1-workflow-scan.js +147 -0
- package/backend/detectors/megalodon/d2-credential-harvest.js +61 -0
- package/backend/detectors/megalodon/d3-publish-velocity.js +67 -0
- package/backend/detectors/megalodon/d4-publisher-drift.js +124 -0
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -0
- package/backend/detectors/megalodon/d6-date-anachronism.js +3 -0
- package/backend/detectors/megalodon/index.js +80 -0
- package/backend/detectors/megalodon/types.js +9 -0
- package/backend/fetch.js +6 -1
- package/backend/policy.js +75 -10
- package/cli/cli.js +2 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@lateos/npm-scan)
|
|
4
4
|
[](LICENSING.md)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](https://github.com/lateos-ai/npm-scan)
|
|
7
7
|
[](https://github.com/lateos-ai/npm-scan)
|
|
8
8
|
[](https://hub.docker.com/r/lateos/npm-scan)
|
|
9
9
|
[](https://github.com/lateos-ai/npm-scan/actions/workflows/publish.yml)
|
|
@@ -24,6 +24,8 @@ The 2025–2026 wave of npm supply chain attacks proved that traditional tooling
|
|
|
24
24
|
|
|
25
25
|
Attackers have moved past simple typosquatting. They now ship **obfuscated preinstall hooks**, **credential harvesters hidden behind environment detection**, **dormant backdoors with time-based activation**, and **worm-style transitive propagation** that spreads through peer dependencies.
|
|
26
26
|
|
|
27
|
+
The **Megalodon campaign** (2026) alone compromised 5,500+ repositories via fake GitHub PRs, malicious workflow injection, and cloud credential exfiltration — all coordinated through a single actor automating the entire kill chain. **@lateos/npm-scan** now detects artifacts of this campaign out of the box.
|
|
28
|
+
|
|
27
29
|
**npm audit** checks known CVEs. **Snyk** scans for vulnerabilities. **Socket** looks at package behavior. None of them were designed for the generation of attacks that emerged in 2025 — attacks that look benign until they reach production.
|
|
28
30
|
|
|
29
31
|
**@lateos/npm-scan** was built for this moment.
|
|
@@ -42,6 +44,7 @@ Attackers have moved past simple typosquatting. They now ship **obfuscated prein
|
|
|
42
44
|
| Conditional trigger detection (ATK-009) | ❌ | ❌ | ❌ | ✅ |
|
|
43
45
|
| Sandbox evasion detection (ATK-010) | ❌ | ❌ | ❌ | ✅ |
|
|
44
46
|
| Transitive worm propagation (ATK-011) | ❌ | ❌ | ❌ | ✅ |
|
|
47
|
+
| Campaign detection (Megalodon CI/CD) | ❌ | ❌ | ❌ | ✅ |
|
|
45
48
|
| Attack taxonomy (ATK series) | ❌ | ❌ | ❌ | ✅ |
|
|
46
49
|
| SBOM output (CycloneDX + SPDX) | ❌ | ✅ | ❌ | ✅ |
|
|
47
50
|
| SARIF v2.1 (GitHub Code Scanning) | ❌ | ❌ | ❌ | ✅ |
|
|
@@ -279,8 +282,10 @@ npm-scan report --pdf # all scans (premium)
|
|
|
279
282
|
| **ATK-009** | Conditional/dormant triggers (CI detection, time-based) | Behavioral | 🔴 high | SR-9.2 |
|
|
280
283
|
| **ATK-010** | Sandbox evasion / anti-analysis | Behavioral | 🟠 medium | SR-10.3 |
|
|
281
284
|
| **ATK-011** | Transitive propagation (worm-style lateral spread) | Behavioral | 🔴 high | SR-11.4 |
|
|
285
|
+
| **MEGALODON** | Megalodon CI/CD campaign — workflow C2 exfil, credential harvest, publish velocity spike, publisher drift | Static + Registry | ⚫ critical | SR-3.1, SR-7.5 |
|
|
282
286
|
|
|
283
287
|
> **How evasive attacks are caught:** ATK-009 detects packages that check `process.env.CI`, probe hostnames, or use time-based activation. ATK-010 flags `debugger` statements, `os.hostname()` probes, and env fingerprinting. ATK-011 traces peer dependency graphs to detect worm-like propagation patterns.
|
|
288
|
+
> **MEGALODON** campaign detection analyzes bundled `.github/workflows/` files for C2 co-occurrence and base64 decode chains, scans tarball files for credential + outbound network patterns, detects version publish velocity spikes via npm registry metadata, and identifies publisher account drift — all without any network calls beyond the initial package fetch.
|
|
284
289
|
> See [`docs/attack-taxonomy.md`](docs/attack-taxonomy.md) for full evasion surface documentation and PoC examples.
|
|
285
290
|
|
|
286
291
|
---
|
|
@@ -627,7 +632,7 @@ See the [Docker quick-start section](#-run-lateosnpm-scan-anywhere-with-docker--
|
|
|
627
632
|
|
|
628
633
|
### Free tier (shipped)
|
|
629
634
|
|
|
630
|
-
- All 11 ATK detectors
|
|
635
|
+
- All 11 ATK detectors + **MEGALODON** CI/CD campaign detection (D1–D6)
|
|
631
636
|
- SBOM output (CycloneDX + SPDX)
|
|
632
637
|
- HTML, text, and compliance reports (NIST + EU CRA)
|
|
633
638
|
- Policy-as-code engine (YAML)
|
|
@@ -686,6 +691,7 @@ node --test test/detectors-corpus.test.js
|
|
|
686
691
|
|
|
687
692
|
**Test structure:**
|
|
688
693
|
- `test/fixtures/mock-data.js` — shared mock scans, packages, and code snippets
|
|
694
|
+
- `test/megalodon.test.js` — 30 Megalodon campaign detection tests (D1–D4 + aggregator + runAll integration)
|
|
689
695
|
- `test/db.test.js` — database CRUD (save, query, persist)
|
|
690
696
|
- `test/detectors-edge-cases.test.js` — per-detector boundary tests (no-ops, clean clears, severity)
|
|
691
697
|
- `test/detectors-corpus.test.js` — 33 malicious + 50 clean tarball integration (offline)
|
|
@@ -1,3 +1,123 @@
|
|
|
1
|
+
const DIST_BUILD_PATTERNS = [/\/dist\//, /\/build\//, /\/bundle/, /\/min\//, /\.min\.js$/, /\.bundled?\.js$/];
|
|
2
|
+
const TEST_FIXTURE_PATTERNS = [/\/test\//, /\/tests\//, /\/__tests__\//, /\/spec\//, /\.test\.js$/, /\.spec\.js$/, /fixtures?/];
|
|
3
|
+
const KNOWN_SAFE_DOMAINS = [
|
|
4
|
+
'registry.npmjs.org', 'cdn.jsdelivr.net', 'unpkg.com', 'cdn.skypack.dev',
|
|
5
|
+
'esm.sh', 'deno.land', 'raw.githubusercontent.com', 'github.com',
|
|
6
|
+
'npmjs.com', 'nodejs.org', 'v8.dev', 'typescriptlang.org'
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const LIFECYCLE_SCRIPT_NAMES = ['install', 'postinstall', 'preinstall', 'prepare', 'prepack', 'postpack'];
|
|
10
|
+
|
|
11
|
+
function extractUrlDomain(code) {
|
|
12
|
+
const urlMatch = code.match(/https?:\/\/([^/'"\s]+)/);
|
|
13
|
+
return urlMatch ? urlMatch[1] : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isDistOrBuild(filePath) {
|
|
17
|
+
return DIST_BUILD_PATTERNS.some(p => p.test(filePath));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isTestOrFixture(filePath) {
|
|
21
|
+
return TEST_FIXTURE_PATTERNS.some(p => p.test(filePath));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isKnownSafeDomain(domain) {
|
|
25
|
+
if (!domain) return false;
|
|
26
|
+
return KNOWN_SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function locateLine(code, pattern) {
|
|
30
|
+
const lines = code.split('\n');
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
if (pattern.test(lines[i])) return i + 1;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function decodePreview(code) {
|
|
38
|
+
const b64Match = code.match(/atob\(['"]([A-Za-z0-9+/=]{10,})['"]\)/);
|
|
39
|
+
if (b64Match) {
|
|
40
|
+
try {
|
|
41
|
+
const decoded = atob(b64Match[1]);
|
|
42
|
+
return decoded.length > 80 ? decoded.slice(0, 80) + '...' : decoded;
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hexMatch = code.match(/Buffer\.from\(['"]([0-9a-fA-F]+)['"],\s*['"]hex['"]\)/);
|
|
47
|
+
if (hexMatch) {
|
|
48
|
+
try {
|
|
49
|
+
const decoded = Buffer.from(hexMatch[1], 'hex').toString();
|
|
50
|
+
return decoded.length > 80 ? decoded.slice(0, 80) + '...' : decoded;
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const btoaMatch = code.match(/btoa\(['"]([A-Za-z0-9+/=]{10,})['"]\)/);
|
|
55
|
+
if (btoaMatch) {
|
|
56
|
+
try {
|
|
57
|
+
const decoded = atob(btoaMatch[1]);
|
|
58
|
+
return decoded.length > 80 ? decoded.slice(0, 80) + '...' : decoded;
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function detectEncodingType(code) {
|
|
66
|
+
if (/Buffer\.from\(['"][0-9a-fA-F]+['"],\s*['"]hex['"]\)/.test(code)) return 'hex';
|
|
67
|
+
if (/atob\(/.test(code)) return 'base64';
|
|
68
|
+
if (/btoa\(/.test(code)) return 'base64';
|
|
69
|
+
if (/Buffer\.from\([A-Za-z0-9+/=]{10,}/.test(code)) return 'base64';
|
|
70
|
+
if (/String\.fromCharCode\(/.test(code)) return 'charcode';
|
|
71
|
+
if (/btoa\(.*btoa\(|atob\(.*atob\(/.test(code)) return 'double-base64';
|
|
72
|
+
return 'unknown';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isFileInLifecycleScript(filePath, pkgJson) {
|
|
76
|
+
if (!pkgJson?.scripts) return false;
|
|
77
|
+
|
|
78
|
+
const scripts = pkgJson.scripts;
|
|
79
|
+
const fileName = filePath.split('/').pop();
|
|
80
|
+
const normalizedPath = filePath.replace(/^node_modules\//, '').replace(/^dist\//, '').replace(/^build\//, '');
|
|
81
|
+
|
|
82
|
+
for (const scriptName of LIFECYCLE_SCRIPT_NAMES) {
|
|
83
|
+
const scriptValue = scripts[scriptName];
|
|
84
|
+
if (!scriptValue) continue;
|
|
85
|
+
|
|
86
|
+
if (scriptValue.includes(filePath)) return true;
|
|
87
|
+
if (scriptValue.includes(fileName)) return true;
|
|
88
|
+
if (scriptValue.includes(normalizedPath)) return true;
|
|
89
|
+
|
|
90
|
+
const scriptFileMatch = scriptValue.match(/[^\s'"]+\.js$/);
|
|
91
|
+
if (scriptFileMatch && filePath.endsWith(scriptFileMatch[0])) return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isLikelyLifecycleFileName(filePath) {
|
|
98
|
+
const name = filePath.split('/').pop().replace(/\.js$/, '');
|
|
99
|
+
return LIFECYCLE_SCRIPT_NAMES.includes(name) ||
|
|
100
|
+
name === 'setup' ||
|
|
101
|
+
name === 'install-helper';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createEvidence(code, filePath, pattern, pkgJson) {
|
|
105
|
+
const encodingType = detectEncodingType(code);
|
|
106
|
+
const line = locateLine(code, pattern);
|
|
107
|
+
const decodedPreview = decodePreview(code);
|
|
108
|
+
const destinationHost = extractUrlDomain(code);
|
|
109
|
+
const lifecycleHook = isFileInLifecycleScript(filePath, pkgJson) || isLikelyLifecycleFileName(filePath);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
file: filePath,
|
|
113
|
+
line: line,
|
|
114
|
+
lifecycle_hook: lifecycleHook,
|
|
115
|
+
decoded_preview: decodedPreview,
|
|
116
|
+
encoding_type: encodingType,
|
|
117
|
+
destination_host: destinationHost,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
1
121
|
export async function scan(pkgJson, files = []) {
|
|
2
122
|
const findings = [];
|
|
3
123
|
const pkgName = pkgJson?.name || '';
|
|
@@ -5,6 +125,12 @@ export async function scan(pkgJson, files = []) {
|
|
|
5
125
|
|
|
6
126
|
for (const f of files) {
|
|
7
127
|
const code = f.content;
|
|
128
|
+
const filePath = f.path;
|
|
129
|
+
|
|
130
|
+
const isDistBuild = isDistOrBuild(filePath);
|
|
131
|
+
const isTestFixture = isTestOrFixture(filePath);
|
|
132
|
+
const urlDomain = extractUrlDomain(code);
|
|
133
|
+
const isSafeDomain = isKnownSafeDomain(urlDomain);
|
|
8
134
|
|
|
9
135
|
const hasEval = /eval\(|new Function\(|\bFunction\('/.test(code);
|
|
10
136
|
|
|
@@ -14,12 +140,21 @@ export async function scan(pkgJson, files = []) {
|
|
|
14
140
|
const b64UrlDecode = /try\s*\{[^}]*atob\s*\(/s.test(code) || /btoa\(.*\)\s*[^;]*\.replace\(/s.test(code);
|
|
15
141
|
|
|
16
142
|
if (hexDecode || b64Decode || b64UrlDecode) {
|
|
143
|
+
const evidence = createEvidence(code, filePath, /eval\(|new Function\(|\bFunction\('/, pkgJson);
|
|
17
144
|
findings.push({
|
|
18
145
|
id: 'ATK-002',
|
|
19
146
|
severity: 'medium',
|
|
20
147
|
title: 'Obfuscated payload',
|
|
21
148
|
description: hexDecode ? 'Eval with hex-decoded payload' : 'Eval with base64-decoded payload',
|
|
22
|
-
evidence:
|
|
149
|
+
evidence: evidence,
|
|
150
|
+
context: {
|
|
151
|
+
file_path: filePath,
|
|
152
|
+
is_dist_build: isDistBuild,
|
|
153
|
+
is_test_fixture: isTestFixture,
|
|
154
|
+
is_lifecycle_hook: evidence.lifecycle_hook,
|
|
155
|
+
url_domain: urlDomain,
|
|
156
|
+
is_known_safe_domain: isSafeDomain,
|
|
157
|
+
},
|
|
23
158
|
});
|
|
24
159
|
return findings;
|
|
25
160
|
}
|
|
@@ -27,12 +162,22 @@ export async function scan(pkgJson, files = []) {
|
|
|
27
162
|
if (btoa(btoa('x')) === 'eDuke'.padEnd(5)) {
|
|
28
163
|
const nested = /atob\([^)]*atob\(/s.test(code) || /btoa\([^)]*btoa\(/s.test(code);
|
|
29
164
|
if (nested) {
|
|
165
|
+
const evidence = createEvidence(code, filePath, /btoa\(/, pkgJson);
|
|
30
166
|
findings.push({
|
|
31
167
|
id: 'ATK-002',
|
|
32
168
|
severity: 'high',
|
|
33
169
|
title: 'Obfuscated payload',
|
|
34
170
|
description: 'Double-encoded nested payload',
|
|
35
|
-
evidence:
|
|
171
|
+
evidence: { ...evidence, is_multi_layer: true },
|
|
172
|
+
context: {
|
|
173
|
+
file_path: filePath,
|
|
174
|
+
is_dist_build: isDistBuild,
|
|
175
|
+
is_test_fixture: isTestFixture,
|
|
176
|
+
is_lifecycle_hook: evidence.lifecycle_hook,
|
|
177
|
+
url_domain: urlDomain,
|
|
178
|
+
is_known_safe_domain: isSafeDomain,
|
|
179
|
+
is_multi_layer: true,
|
|
180
|
+
},
|
|
36
181
|
});
|
|
37
182
|
return findings;
|
|
38
183
|
}
|
|
@@ -43,43 +188,70 @@ export async function scan(pkgJson, files = []) {
|
|
|
43
188
|
const isNetworkObfusc = /atob\(.*(https?:\/\/|\\x|http).*\)/s.test(code) ||
|
|
44
189
|
/Buffer\.from\(['"`][0-9a-f]+['"`],\s*['"]hex['"].*fetch\(|fetch\(.*atob\(/s.test(code);
|
|
45
190
|
if (isNetworkObfusc) {
|
|
191
|
+
const evidence = createEvidence(code, filePath, /atob\(|Buffer\.from/, pkgJson);
|
|
46
192
|
findings.push({
|
|
47
193
|
id: 'ATK-002',
|
|
48
194
|
severity: 'medium',
|
|
49
195
|
title: 'Obfuscated payload',
|
|
50
196
|
description: 'Decoded string containing URL/fetch call',
|
|
51
|
-
evidence:
|
|
197
|
+
evidence: evidence,
|
|
198
|
+
context: {
|
|
199
|
+
file_path: filePath,
|
|
200
|
+
is_dist_build: isDistBuild,
|
|
201
|
+
is_test_fixture: isTestFixture,
|
|
202
|
+
is_lifecycle_hook: evidence.lifecycle_hook,
|
|
203
|
+
url_domain: urlDomain,
|
|
204
|
+
is_known_safe_domain: isSafeDomain,
|
|
205
|
+
},
|
|
52
206
|
});
|
|
53
207
|
return findings;
|
|
54
208
|
}
|
|
55
209
|
}
|
|
56
210
|
|
|
57
211
|
if (/String\.fromCharCode\(.{20,}\)/.test(code) && hasEval) {
|
|
212
|
+
const evidence = createEvidence(code, filePath, /String\.fromCharCode\(/, pkgJson);
|
|
58
213
|
findings.push({
|
|
59
214
|
id: 'ATK-002',
|
|
60
215
|
severity: 'medium',
|
|
61
216
|
title: 'Obfuscated payload',
|
|
62
217
|
description: 'Eval with String.fromCharCode obfuscation',
|
|
63
|
-
evidence:
|
|
218
|
+
evidence: evidence,
|
|
219
|
+
context: {
|
|
220
|
+
file_path: filePath,
|
|
221
|
+
is_dist_build: isDistBuild,
|
|
222
|
+
is_test_fixture: isTestFixture,
|
|
223
|
+
is_lifecycle_hook: evidence.lifecycle_hook,
|
|
224
|
+
url_domain: urlDomain,
|
|
225
|
+
is_known_safe_domain: isSafeDomain,
|
|
226
|
+
},
|
|
64
227
|
});
|
|
65
228
|
return findings;
|
|
66
229
|
}
|
|
67
230
|
|
|
68
231
|
const shellPatterns = [
|
|
69
|
-
/eval\s*\(\s*process\.env\.[A-Z_]{4,}/,
|
|
70
|
-
/exec\s*\(\s*Buffer\.from\(/,
|
|
71
|
-
/new Function\s*\(\s*(?:atob|process\.env)/,
|
|
72
|
-
/eval\s*\(\s*(?:require|import\s*\()/,
|
|
73
|
-
/Function\s*\(\s*'use\s*strict'\s*;?\s*(?:atob|require)/,
|
|
232
|
+
{ regex: /eval\s*\(\s*process\.env\.[A-Z_]{4,}/, name: 'env-eval' },
|
|
233
|
+
{ regex: /exec\s*\(\s*Buffer\.from\(/, name: 'exec-buffer' },
|
|
234
|
+
{ regex: /new Function\s*\(\s*(?:atob|process\.env)/, name: 'function-eval' },
|
|
235
|
+
{ regex: /eval\s*\(\s*(?:require|import\s*\()/, name: 'require-eval' },
|
|
236
|
+
{ regex: /Function\s*\(\s*'use\s*strict'\s*;?\s*(?:atob|require)/, name: 'strict-eval' },
|
|
74
237
|
];
|
|
75
238
|
for (const p of shellPatterns) {
|
|
76
|
-
if (p.test(code)) {
|
|
239
|
+
if (p.regex.test(code)) {
|
|
240
|
+
const evidence = createEvidence(code, filePath, p.regex, pkgJson);
|
|
77
241
|
findings.push({
|
|
78
242
|
id: 'ATK-002',
|
|
79
243
|
severity: 'high',
|
|
80
244
|
title: 'Obfuscated payload',
|
|
81
245
|
description: 'Shell-code obfuscation pattern',
|
|
82
|
-
evidence: p.
|
|
246
|
+
evidence: { ...evidence, pattern: p.name },
|
|
247
|
+
context: {
|
|
248
|
+
file_path: filePath,
|
|
249
|
+
is_dist_build: isDistBuild,
|
|
250
|
+
is_test_fixture: isTestFixture,
|
|
251
|
+
is_lifecycle_hook: evidence.lifecycle_hook,
|
|
252
|
+
url_domain: urlDomain,
|
|
253
|
+
is_known_safe_domain: isSafeDomain,
|
|
254
|
+
},
|
|
83
255
|
});
|
|
84
256
|
return findings;
|
|
85
257
|
}
|
|
@@ -87,4 +259,4 @@ export async function scan(pkgJson, files = []) {
|
|
|
87
259
|
}
|
|
88
260
|
|
|
89
261
|
return findings;
|
|
90
|
-
}
|
|
262
|
+
}
|
|
@@ -9,8 +9,9 @@ import * as atk008 from './atk-008-tarball-tamper.js';
|
|
|
9
9
|
import * as atk009 from './atk-009-dormant-trigger.js';
|
|
10
10
|
import * as atk010 from './atk-010-sandbox-evasion.js';
|
|
11
11
|
import * as atk011 from './atk-011-transitive-prop.js';
|
|
12
|
+
import { scanAll as megalodonScan } from './megalodon/index.js';
|
|
12
13
|
|
|
13
|
-
export async function runAll(pkgJson, files = []) {
|
|
14
|
+
export async function runAll(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
14
15
|
const findings = [];
|
|
15
16
|
findings.push(...await atk001.scan(pkgJson, files));
|
|
16
17
|
findings.push(...await atk002.scan(pkgJson, files));
|
|
@@ -23,5 +24,6 @@ export async function runAll(pkgJson, files = []) {
|
|
|
23
24
|
findings.push(...await atk009.scan(pkgJson, files));
|
|
24
25
|
findings.push(...await atk010.scan(pkgJson, files));
|
|
25
26
|
findings.push(...await atk011.scan(pkgJson, files));
|
|
27
|
+
findings.push(...await megalodonScan(pkgJson, allFiles || files, registryMeta));
|
|
26
28
|
return findings.sort((a, b) => b.severity.localeCompare(a.severity));
|
|
27
29
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
|
|
4
|
+
const C2_EXFIL_RE = /curl\s+.*?https?:\/\/(?!github\.com|githubusercontent\.com|raw\.githubusercontent\.com)[^\s'"]+/i;
|
|
5
|
+
const SECRETS_REF_RE = /\$\{\{?\s*secrets\.\w+/;
|
|
6
|
+
const B64_DECODE_CHAIN_RE = /base64\s+-d\s*[|>]\s*(ba)?sh/;
|
|
7
|
+
|
|
8
|
+
function isWorkflowFile(f) {
|
|
9
|
+
const p = f.path.replace(/\\/g, '/');
|
|
10
|
+
return /\.github\/workflows\/.+\.(yml|yaml)$/i.test(p);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function countExecutableLines(text) {
|
|
14
|
+
return text.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractRunBlocks(parsed) {
|
|
18
|
+
const runs = [];
|
|
19
|
+
if (!parsed || typeof parsed !== 'object') return runs;
|
|
20
|
+
|
|
21
|
+
const walk = (obj) => {
|
|
22
|
+
if (!obj || typeof obj !== 'object') return;
|
|
23
|
+
if (Array.isArray(obj)) { obj.forEach(walk); return; }
|
|
24
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
25
|
+
if (k === 'run' && typeof v === 'string') {
|
|
26
|
+
runs.push(v);
|
|
27
|
+
}
|
|
28
|
+
if (k === 'env' && typeof v === 'object' && v !== null) {
|
|
29
|
+
runs.push({ _env: v });
|
|
30
|
+
}
|
|
31
|
+
walk(v);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
walk(parsed);
|
|
35
|
+
return runs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractRunBlocksRaw(text) {
|
|
39
|
+
const runs = [];
|
|
40
|
+
const runMatch = text.match(/run:\s*[|>]\s*\n(\s{2,}.*(?:\n\s{2,}.*)*)/g);
|
|
41
|
+
if (runMatch) runs.push(...runMatch.map(m => m.replace(/^run:\s*[|>]\s*\n/, '')));
|
|
42
|
+
|
|
43
|
+
const inlineRe = /run:\s*['"](.+?)['"]\s*$/gm;
|
|
44
|
+
let m;
|
|
45
|
+
while ((m = inlineRe.exec(text)) !== null) runs.push(m[1]);
|
|
46
|
+
|
|
47
|
+
const envRe = /env:\s*\n((?:\s{2,}\w+:\s*.+\n?)*)/g;
|
|
48
|
+
let em;
|
|
49
|
+
while ((em = envRe.exec(text)) !== null) runs.push({ _env: em[1] });
|
|
50
|
+
return runs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runInStepHasBoth(step, signal) {
|
|
54
|
+
const runVal = step.run;
|
|
55
|
+
const envVals = step.env ? Object.values(step.env).filter(v => typeof v === 'string').join(' ') : '';
|
|
56
|
+
const combined = typeof runVal === 'string' ? `${runVal} ${envVals}` : '';
|
|
57
|
+
|
|
58
|
+
if (signal === 'exfil') {
|
|
59
|
+
return C2_EXFIL_RE.test(combined) && SECRETS_REF_RE.test(combined);
|
|
60
|
+
}
|
|
61
|
+
if (signal === 'decode') {
|
|
62
|
+
return B64_DECODE_CHAIN_RE.test(combined);
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function scan(allFiles) {
|
|
68
|
+
const evidence = [];
|
|
69
|
+
const workflowFiles = allFiles.filter(isWorkflowFile);
|
|
70
|
+
|
|
71
|
+
for (const f of workflowFiles) {
|
|
72
|
+
if (f.content.length > 512 * 1024) continue;
|
|
73
|
+
|
|
74
|
+
let parsed = null;
|
|
75
|
+
let parseError = null;
|
|
76
|
+
try {
|
|
77
|
+
parsed = yaml.load(f.content);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
parseError = e;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rawRunBlocks = parsed ? extractRunBlocks(parsed) : extractRunBlocksRaw(f.content);
|
|
83
|
+
const runStrings = rawRunBlocks.filter(r => typeof r === 'string');
|
|
84
|
+
const envBlocks = rawRunBlocks.filter(r => typeof r === 'object' && r._env);
|
|
85
|
+
|
|
86
|
+
let exfilTriggered = false;
|
|
87
|
+
let decodeTriggered = false;
|
|
88
|
+
|
|
89
|
+
for (const runStr of runStrings) {
|
|
90
|
+
if (!exfilTriggered && C2_EXFIL_RE.test(runStr) && SECRETS_REF_RE.test(runStr)) {
|
|
91
|
+
exfilTriggered = true;
|
|
92
|
+
evidence.push({
|
|
93
|
+
signal: MegalodonSignal.WORKFLOW_C2_EXFIL,
|
|
94
|
+
file: f.path,
|
|
95
|
+
excerpt: runStr.slice(0, 120),
|
|
96
|
+
detail: 'C2 outbound call co-occurs with credentials reference in run block',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!decodeTriggered && B64_DECODE_CHAIN_RE.test(runStr)) {
|
|
101
|
+
decodeTriggered = true;
|
|
102
|
+
evidence.push({
|
|
103
|
+
signal: MegalodonSignal.WORKFLOW_DECODE_CHAIN,
|
|
104
|
+
file: f.path,
|
|
105
|
+
excerpt: runStr.slice(0, 120),
|
|
106
|
+
detail: 'Base64 decode pipe to shell — obfuscated payload execution',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
112
|
+
const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap(j => j.steps || []) : [];
|
|
113
|
+
for (const step of steps) {
|
|
114
|
+
if (!exfilTriggered && runInStepHasBoth(step, 'exfil')) {
|
|
115
|
+
exfilTriggered = true;
|
|
116
|
+
const runVal = step.run || '';
|
|
117
|
+
evidence.push({
|
|
118
|
+
signal: MegalodonSignal.WORKFLOW_C2_EXFIL,
|
|
119
|
+
file: f.path,
|
|
120
|
+
excerpt: runVal.slice(0, 120),
|
|
121
|
+
detail: 'C2 outbound call co-occurs with secrets reference in same step',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (!decodeTriggered && runInStepHasBoth(step, 'decode')) {
|
|
125
|
+
decodeTriggered = true;
|
|
126
|
+
const runVal = step.run || '';
|
|
127
|
+
evidence.push({
|
|
128
|
+
signal: MegalodonSignal.WORKFLOW_DECODE_CHAIN,
|
|
129
|
+
file: f.path,
|
|
130
|
+
excerpt: runVal.slice(0, 120),
|
|
131
|
+
detail: 'Base64 decode pipe to shell — obfuscated payload execution',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const lineCount = countExecutableLines(f.content);
|
|
138
|
+
if ((exfilTriggered || decodeTriggered) && lineCount >= 100 && lineCount <= 120) {
|
|
139
|
+
const found = evidence.find(e => e.signal === MegalodonSignal.WORKFLOW_C2_EXFIL || e.signal === MegalodonSignal.WORKFLOW_DECODE_CHAIN);
|
|
140
|
+
if (found) {
|
|
141
|
+
found.detail += ` | Matches ${lineCount}-line Megalodon payload footprint`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return evidence;
|
|
147
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
|
|
3
|
+
const CRED_PATTERNS = [
|
|
4
|
+
{ pattern: /\bAWS_(SECRET_ACCESS_KEY|ACCESS_KEY_ID|SESSION_TOKEN)\b/, label: 'AWS credential' },
|
|
5
|
+
{ pattern: /\bGOOGLE_APPLICATION_CREDENTIALS\b/, label: 'GCP credential' },
|
|
6
|
+
{ pattern: /\bAZURE_(CLIENT_SECRET|TENANT_ID|CLIENT_ID|SUBSCRIPTION_ID)\b/, label: 'Azure credential' },
|
|
7
|
+
{ pattern: /\bGH_(TOKEN|PAT)\b/, label: 'GitHub PAT' },
|
|
8
|
+
{ pattern: /\bGITHUB_TOKEN\b/, label: 'GitHub token' },
|
|
9
|
+
{ pattern: /\bNPM_TOKEN\b/, label: 'npm token' },
|
|
10
|
+
{ pattern: /\bDISCORD_TOKEN\b/, label: 'Discord token' },
|
|
11
|
+
{ pattern: /\bSLACK_TOKEN\b/, label: 'Slack token' },
|
|
12
|
+
{ pattern: /\bSTRIPE_(SECRET|PUBLISHABLE)_KEY\b/, label: 'Stripe key' },
|
|
13
|
+
{ pattern: /\bTWILIO_(ACCOUNT_SID|AUTH_TOKEN)\b/, label: 'Twilio credential' },
|
|
14
|
+
{ pattern: /\bDB_(USERNAME|PASSWORD|URL)\b/, label: 'Database credential' },
|
|
15
|
+
{ pattern: /\bMONGO_(URI|URL|CONNECTION)\b/, label: 'MongoDB connection' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const OUTBOUND_NET_RE = /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
|
+
|
|
20
|
+
const TARGET_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml', '.js'];
|
|
21
|
+
|
|
22
|
+
function isTargetFile(f) {
|
|
23
|
+
const ext = f.path.slice(f.path.lastIndexOf('.')).toLowerCase();
|
|
24
|
+
return TARGET_EXTENSIONS.includes(ext);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function scan(allFiles) {
|
|
28
|
+
const evidence = [];
|
|
29
|
+
const targetFiles = allFiles.filter(isTargetFile);
|
|
30
|
+
|
|
31
|
+
for (const f of targetFiles) {
|
|
32
|
+
const content = f.content;
|
|
33
|
+
let score = 0;
|
|
34
|
+
const matched = [];
|
|
35
|
+
|
|
36
|
+
for (const cp of CRED_PATTERNS) {
|
|
37
|
+
const re = new RegExp(cp.pattern.source, 'gi');
|
|
38
|
+
let m;
|
|
39
|
+
while ((m = re.exec(content)) !== null) {
|
|
40
|
+
if (!matched.some(ex => ex.label === cp.label)) {
|
|
41
|
+
matched.push({ label: cp.label, match: m[0] });
|
|
42
|
+
}
|
|
43
|
+
score += 3;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (score > 0) {
|
|
48
|
+
const hasNetwork = OUTBOUND_NET_RE.test(content);
|
|
49
|
+
if (hasNetwork) {
|
|
50
|
+
evidence.push({
|
|
51
|
+
signal: MegalodonSignal.CREDENTIAL_HARVEST,
|
|
52
|
+
file: f.path,
|
|
53
|
+
excerpt: matched.map(m => m.label).join(', ').slice(0, 120),
|
|
54
|
+
detail: `Credential env vars (${matched.map(m => m.label).join(', ')}) co-occur with outbound network call (score: ${score})`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return evidence;
|
|
61
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
|
|
3
|
+
export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
|
|
4
|
+
const filtered = {};
|
|
5
|
+
for (const [v, t] of Object.entries(times)) {
|
|
6
|
+
if (v === 'created' || v === 'modified') continue;
|
|
7
|
+
filtered[v] = t;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const entries = Object.entries(filtered)
|
|
11
|
+
.filter(([, t]) => t)
|
|
12
|
+
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
13
|
+
.filter(([, ts]) => !Number.isNaN(ts))
|
|
14
|
+
.sort((a, b) => a[1] - b[1]);
|
|
15
|
+
|
|
16
|
+
if (entries.length === 0) {
|
|
17
|
+
return { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const windowMs = windowHours * 3_600_000;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < entries.length; i++) {
|
|
23
|
+
const windowStart = entries[i][1];
|
|
24
|
+
const windowEnd = windowStart + windowMs;
|
|
25
|
+
const inWindow = [];
|
|
26
|
+
|
|
27
|
+
for (let j = i; j < entries.length; j++) {
|
|
28
|
+
if (entries[j][1] <= windowEnd) {
|
|
29
|
+
inWindow.push(entries[j][0]);
|
|
30
|
+
} else {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (inWindow.length >= threshold) {
|
|
36
|
+
let display = inWindow.slice(0, 10);
|
|
37
|
+
let suffix = '';
|
|
38
|
+
if (inWindow.length > 10) {
|
|
39
|
+
suffix = ` +${inWindow.length - 10} more`;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
triggered: true,
|
|
43
|
+
versionsInWindow: display.join(', ') + suffix,
|
|
44
|
+
windowStartISO: new Date(windowStart).toISOString(),
|
|
45
|
+
_allVersions: inWindow,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function scan(registryMeta) {
|
|
54
|
+
const times = registryMeta?.time || {};
|
|
55
|
+
const result = detectVelocitySpike(times);
|
|
56
|
+
|
|
57
|
+
if (!result.triggered) return [];
|
|
58
|
+
|
|
59
|
+
return [{
|
|
60
|
+
signal: MegalodonSignal.PUBLISH_VELOCITY,
|
|
61
|
+
file: 'registry.npmjs.org',
|
|
62
|
+
excerpt: result.versionsInWindow,
|
|
63
|
+
detail: `Version publish velocity spike: ${result.versionsInWindow} versions in window starting ${result.windowStartISO}`,
|
|
64
|
+
_windowStartISO: result.windowStartISO,
|
|
65
|
+
_allVersions: result._allVersions,
|
|
66
|
+
}];
|
|
67
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
|
|
3
|
+
export async function scan(registryMeta, velocityResult) {
|
|
4
|
+
const evidence = [];
|
|
5
|
+
const versions = registryMeta?.versions || {};
|
|
6
|
+
const timeMap = registryMeta?.time || {};
|
|
7
|
+
|
|
8
|
+
const filteredTimes = {};
|
|
9
|
+
for (const [v, t] of Object.entries(timeMap)) {
|
|
10
|
+
if (v === 'created' || v === 'modified') continue;
|
|
11
|
+
if (t) filteredTimes[v] = t;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sortedVersions = Object.entries(filteredTimes)
|
|
15
|
+
.filter(([, t]) => t && !Number.isNaN(new Date(t).getTime()))
|
|
16
|
+
.sort((a, b) => new Date(a[1]).getTime() - new Date(b[1]).getTime())
|
|
17
|
+
.map(([v]) => v);
|
|
18
|
+
|
|
19
|
+
if (sortedVersions.length === 0) return [];
|
|
20
|
+
|
|
21
|
+
if (velocityResult?.triggered) {
|
|
22
|
+
const windowStartISO = velocityResult.windowStartISO;
|
|
23
|
+
const allInWindow = velocityResult._allVersions || [];
|
|
24
|
+
|
|
25
|
+
const priorPublishers = new Set();
|
|
26
|
+
for (const v of sortedVersions) {
|
|
27
|
+
if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime()) break;
|
|
28
|
+
const user = versions[v]?._npmUser?.name;
|
|
29
|
+
if (user) priorPublishers.add(user);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (priorPublishers.size === 0 && allInWindow.length > 0) {
|
|
33
|
+
const firstUser = versions[allInWindow[0]]?._npmUser?.name;
|
|
34
|
+
if (firstUser) priorPublishers.add(firstUser);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const suspiciousPublishers = [];
|
|
38
|
+
const affectedVersions = [];
|
|
39
|
+
for (const v of allInWindow) {
|
|
40
|
+
const user = versions[v]?._npmUser?.name;
|
|
41
|
+
if (user && !priorPublishers.has(user)) {
|
|
42
|
+
if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
|
|
43
|
+
if (!affectedVersions.includes(v)) affectedVersions.push(v);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (suspiciousPublishers.length > 0) {
|
|
48
|
+
const detail = `Drift detected: known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in versions [${affectedVersions.join(', ')}]`;
|
|
49
|
+
|
|
50
|
+
const firstSuspiciousVer = allInWindow.find(v => affectedVersions.includes(v));
|
|
51
|
+
let ageNote = '';
|
|
52
|
+
if (firstSuspiciousVer && suspiciousPublishers[0]) {
|
|
53
|
+
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[firstSuspiciousVer]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
evidence.push({
|
|
57
|
+
signal: MegalodonSignal.PUBLISHER_DRIFT,
|
|
58
|
+
file: 'registry.npmjs.org',
|
|
59
|
+
excerpt: `publisher drift: ${suspiciousPublishers.join(', ')}`,
|
|
60
|
+
detail: detail + (ageNote ? ' | ' + ageNote : ''),
|
|
61
|
+
_severityHint: 'HIGH',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
if (sortedVersions.length < 4) return [];
|
|
66
|
+
|
|
67
|
+
const last3 = sortedVersions.slice(-3);
|
|
68
|
+
const prior = sortedVersions.slice(0, -3);
|
|
69
|
+
|
|
70
|
+
const priorPublishers = new Set();
|
|
71
|
+
for (const v of prior) {
|
|
72
|
+
const user = versions[v]?._npmUser?.name;
|
|
73
|
+
if (user) priorPublishers.add(user);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const suspiciousPublishers = [];
|
|
77
|
+
const affectedVersions = [];
|
|
78
|
+
for (const v of last3) {
|
|
79
|
+
const user = versions[v]?._npmUser?.name;
|
|
80
|
+
if (user && !priorPublishers.has(user)) {
|
|
81
|
+
if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
|
|
82
|
+
if (!affectedVersions.includes(v)) affectedVersions.push(v);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (suspiciousPublishers.length > 0) {
|
|
87
|
+
const detail = `Drift (fallback): known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in last 3 versions [${affectedVersions.join(', ')}]`;
|
|
88
|
+
|
|
89
|
+
let ageNote = '';
|
|
90
|
+
if (suspiciousPublishers[0] && affectedVersions[0]) {
|
|
91
|
+
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[affectedVersions[0]]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
evidence.push({
|
|
95
|
+
signal: MegalodonSignal.PUBLISHER_DRIFT,
|
|
96
|
+
file: 'registry.npmjs.org',
|
|
97
|
+
excerpt: `publisher drift: ${suspiciousPublishers.join(', ')}`,
|
|
98
|
+
detail: detail + (ageNote ? ' | ' + ageNote : ''),
|
|
99
|
+
_severityHint: 'MEDIUM',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return evidence;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function checkAccountAge(npmUser, firstSuspiciousTime) {
|
|
108
|
+
try {
|
|
109
|
+
const url = `https://registry.npmjs.org/-/user/org.couchdb.user/${encodeURIComponent(npmUser)}`;
|
|
110
|
+
const res = await fetch(url);
|
|
111
|
+
if (!res.ok) return '';
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
const created = data?.date;
|
|
114
|
+
if (!created) return '';
|
|
115
|
+
const createdDate = new Date(created).getTime();
|
|
116
|
+
const firstPub = new Date(firstSuspiciousTime).getTime();
|
|
117
|
+
const daysDiff = (firstPub - createdDate) / (1000 * 60 * 60 * 24);
|
|
118
|
+
if (!Number.isNaN(daysDiff) && daysDiff >= 0 && daysDiff <= 30) {
|
|
119
|
+
return `Publisher account created ${Math.round(daysDiff)} days before first suspicious publish`;
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
import { scan as scanD1 } from './d1-workflow-scan.js';
|
|
3
|
+
import { scan as scanD2 } from './d2-credential-harvest.js';
|
|
4
|
+
import { scan as scanD3 } from './d3-publish-velocity.js';
|
|
5
|
+
import { scan as scanD4 } from './d4-publisher-drift.js';
|
|
6
|
+
import { scan as scanD5 } from './d5-bot-commit-identity.js';
|
|
7
|
+
import { scan as scanD6 } from './d6-date-anachronism.js';
|
|
8
|
+
|
|
9
|
+
const SIGNAL_SEVERITY = {
|
|
10
|
+
[MegalodonSignal.WORKFLOW_C2_EXFIL]: 5,
|
|
11
|
+
[MegalodonSignal.WORKFLOW_DECODE_CHAIN]: 4,
|
|
12
|
+
[MegalodonSignal.PUBLISH_VELOCITY]: 4,
|
|
13
|
+
[MegalodonSignal.PUBLISHER_DRIFT]: 4,
|
|
14
|
+
[MegalodonSignal.CREDENTIAL_HARVEST]: 3,
|
|
15
|
+
[MegalodonSignal.BOT_COMMIT_IDENTITY]: 2,
|
|
16
|
+
[MegalodonSignal.DATE_ANACHRONISM]: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical', 'critical'];
|
|
20
|
+
|
|
21
|
+
function resolveSeverity(signals, d4Evidence) {
|
|
22
|
+
let maxScore = 0;
|
|
23
|
+
for (const s of signals) {
|
|
24
|
+
maxScore = Math.max(maxScore, SIGNAL_SEVERITY[s] || 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const d4Hint = d4Evidence.find(e => e._severityHint);
|
|
28
|
+
if (d4Hint) {
|
|
29
|
+
const hintScore = d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
|
|
30
|
+
maxScore = Math.max(maxScore, hintScore);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return SEVERITY_LABELS[maxScore] || 'none';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function scanAll(pkgJson, allFiles = [], registryMeta = {}) {
|
|
37
|
+
const allEvidence = [];
|
|
38
|
+
|
|
39
|
+
const d1Ev = await scanD1(allFiles);
|
|
40
|
+
allEvidence.push(...d1Ev);
|
|
41
|
+
|
|
42
|
+
const d2Ev = await scanD2(allFiles);
|
|
43
|
+
allEvidence.push(...d2Ev);
|
|
44
|
+
|
|
45
|
+
const d3Ev = await scanD3(registryMeta);
|
|
46
|
+
allEvidence.push(...d3Ev);
|
|
47
|
+
|
|
48
|
+
const velocityResult = d3Ev.length > 0 ? {
|
|
49
|
+
triggered: true,
|
|
50
|
+
windowStartISO: d3Ev[0]._windowStartISO || null,
|
|
51
|
+
versionsInWindow: d3Ev[0].excerpt || '',
|
|
52
|
+
_allVersions: d3Ev[0]._allVersions || [],
|
|
53
|
+
} : { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
54
|
+
|
|
55
|
+
const d4Ev = await scanD4(registryMeta, velocityResult);
|
|
56
|
+
allEvidence.push(...d4Ev);
|
|
57
|
+
|
|
58
|
+
allEvidence.push(...await scanD5(registryMeta));
|
|
59
|
+
allEvidence.push(...await scanD6(pkgJson, registryMeta));
|
|
60
|
+
|
|
61
|
+
const signals = [...new Set(allEvidence.map(e => e.signal).filter(Boolean))];
|
|
62
|
+
|
|
63
|
+
if (signals.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
const severity = resolveSeverity(signals, d4Ev);
|
|
66
|
+
|
|
67
|
+
const cleaned = allEvidence.map(({ _windowStartISO, _allVersions, _severityHint, ...rest }) => rest);
|
|
68
|
+
|
|
69
|
+
return [{
|
|
70
|
+
id: 'MEGALODON',
|
|
71
|
+
severity,
|
|
72
|
+
title: 'Megalodon CI/CD attack campaign',
|
|
73
|
+
description: `${signals.length} signal(s): ${signals.join(', ')}`,
|
|
74
|
+
evidence: JSON.stringify({
|
|
75
|
+
campaign: 'MEGALODON',
|
|
76
|
+
signals,
|
|
77
|
+
evidence: cleaned,
|
|
78
|
+
}),
|
|
79
|
+
}];
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const MegalodonSignal = Object.freeze({
|
|
2
|
+
WORKFLOW_C2_EXFIL: 'D1_WORKFLOW_C2_EXFIL',
|
|
3
|
+
WORKFLOW_DECODE_CHAIN: 'D1_WORKFLOW_DECODE_CHAIN',
|
|
4
|
+
CREDENTIAL_HARVEST: 'D2_CREDENTIAL_HARVEST',
|
|
5
|
+
PUBLISH_VELOCITY: 'D3_PUBLISH_VELOCITY',
|
|
6
|
+
PUBLISHER_DRIFT: 'D4_PUBLISHER_DRIFT',
|
|
7
|
+
BOT_COMMIT_IDENTITY: 'D5_BOT_COMMIT_IDENTITY',
|
|
8
|
+
DATE_ANACHRONISM: 'D6_DATE_ANACHRONISM',
|
|
9
|
+
});
|
package/backend/fetch.js
CHANGED
|
@@ -150,7 +150,12 @@ async function extractTarball(buffer, tmpDir) {
|
|
|
150
150
|
content: fs.readFileSync(p, 'utf8')
|
|
151
151
|
}));
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
const allFiles = walkFiles(tmpDir, '').map(p => ({
|
|
154
|
+
path: p,
|
|
155
|
+
content: fs.readFileSync(p, 'utf8')
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
return { pkgJson, jsFiles, allFiles, tmpDir };
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
function walkFiles(dir, ext) {
|
package/backend/policy.js
CHANGED
|
@@ -4,10 +4,77 @@ import { load as yamlLoad } from 'js-yaml';
|
|
|
4
4
|
const SEVERITY_ORDER = ['none', 'low', 'medium', 'high', 'critical'];
|
|
5
5
|
const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
|
|
6
6
|
|
|
7
|
+
const KNOWN_REPUTABLE_PACKAGES = new Set([
|
|
8
|
+
'react', 'react-dom', 'vue', 'angular', 'next', 'nuxt',
|
|
9
|
+
'express', 'fastify', 'hono', 'koa', 'connect',
|
|
10
|
+
'webpack', 'vite', 'rollup', 'esbuild', 'typescript', 'babel-core',
|
|
11
|
+
'lodash', 'ramda', 'underscore',
|
|
12
|
+
'axios', 'node-fetch', 'got', 'superagent',
|
|
13
|
+
'sequelize', 'prisma', 'typeorm', 'mongoose',
|
|
14
|
+
'jest', 'mocha', 'vitest', 'ava',
|
|
15
|
+
'prettier', 'eslint', 'stylelint',
|
|
16
|
+
'socket.io', 'ws',
|
|
17
|
+
'rimraf', 'glob', 'minimatch', 'fs-extra',
|
|
18
|
+
]);
|
|
19
|
+
|
|
7
20
|
function severityIndex(s) {
|
|
8
21
|
return SEVERITY_ORDER.indexOf(s);
|
|
9
22
|
}
|
|
10
23
|
|
|
24
|
+
function matchesFilePath(filePath, pattern) {
|
|
25
|
+
if (!pattern) return false;
|
|
26
|
+
if (pattern === '*') return true;
|
|
27
|
+
const regexPattern = pattern
|
|
28
|
+
.replace(/\./g, '\\.')
|
|
29
|
+
.replace(/\*\*/g, '___DOUBLE_STAR___')
|
|
30
|
+
.replace(/\*/g, '[^/]*')
|
|
31
|
+
.replace(/___DOUBLE_STAR___/g, '.*');
|
|
32
|
+
return new RegExp(`^${regexPattern}$`).test(filePath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchesContext(finding, rule) {
|
|
36
|
+
const ctx = finding.context;
|
|
37
|
+
if (!ctx) return false;
|
|
38
|
+
|
|
39
|
+
if (rule.context?.is_dist_build === true && !ctx.is_dist_build) return false;
|
|
40
|
+
if (rule.context?.is_dist_build === false && ctx.is_dist_build) return false;
|
|
41
|
+
if (rule.context?.is_test_fixture === true && !ctx.is_test_fixture) return false;
|
|
42
|
+
if (rule.context?.is_test_fixture === false && ctx.is_test_fixture) return false;
|
|
43
|
+
if (rule.context?.is_lifecycle_hook === true && !ctx.is_lifecycle_hook) return false;
|
|
44
|
+
if (rule.context?.is_lifecycle_hook === false && ctx.is_lifecycle_hook) return false;
|
|
45
|
+
if (rule.context?.is_known_safe_domain === true && !ctx.is_known_safe_domain) return false;
|
|
46
|
+
if (rule.context?.is_known_safe_domain === false && ctx.is_known_safe_domain) return false;
|
|
47
|
+
|
|
48
|
+
if (rule.context?.file_path && !matchesFilePath(ctx.file_path, rule.context.file_path)) return false;
|
|
49
|
+
if (rule.context?.url_domain) {
|
|
50
|
+
if (!ctx.url_domain) return false;
|
|
51
|
+
const domainPattern = rule.context.url_domain.replace(/\*/g, '.*');
|
|
52
|
+
if (!new RegExp(`^${domainPattern}$`).test(ctx.url_domain)) return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getPackageReputationTier(pkgName) {
|
|
59
|
+
const name = pkgName?.replace(/^@/, '').replace(/\/.*/, '') || '';
|
|
60
|
+
if (KNOWN_REPUTABLE_PACKAGES.has(name)) return 'trusted';
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function matchesSuppressRule(finding, pkgName, rule) {
|
|
65
|
+
if (rule.atk_id !== (finding.atk_id || finding.id)) return false;
|
|
66
|
+
if (rule.package && rule.package !== '*' && rule.package !== pkgName) return false;
|
|
67
|
+
|
|
68
|
+
if (rule.context && !matchesContext(finding, rule)) return false;
|
|
69
|
+
|
|
70
|
+
if (rule.reputation_tier) {
|
|
71
|
+
const tier = getPackageReputationTier(pkgName);
|
|
72
|
+
if (rule.reputation_tier !== tier && !(rule.reputation_tier === '*' || rule.reputation_tier === 'any')) return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
11
78
|
function loadPolicy(path) {
|
|
12
79
|
const raw = readFileSync(path, 'utf8').trim();
|
|
13
80
|
let policy;
|
|
@@ -63,6 +130,8 @@ function sanitizePolicy(policy) {
|
|
|
63
130
|
atk_id: r.atk_id,
|
|
64
131
|
package: r.package || '*',
|
|
65
132
|
reason: r.reason || '',
|
|
133
|
+
context: r.context || null,
|
|
134
|
+
reputation_tier: r.reputation_tier || null,
|
|
66
135
|
})),
|
|
67
136
|
};
|
|
68
137
|
}
|
|
@@ -73,19 +142,15 @@ function isAllowed(packageName, policy) {
|
|
|
73
142
|
return policy.allow.packages.some(p => p === packageName || p === nameOnly);
|
|
74
143
|
}
|
|
75
144
|
|
|
76
|
-
function matchesSuppressRule(finding, pkgName, rule) {
|
|
77
|
-
if (rule.atk_id !== (finding.atk_id || finding.id)) return false;
|
|
78
|
-
if (rule.package === '*') return true;
|
|
79
|
-
return rule.package === pkgName;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
145
|
function applyPolicy(findings, packageName, policy) {
|
|
83
146
|
let filtered = [...findings];
|
|
84
147
|
|
|
85
148
|
if (policy.suppress.length) {
|
|
86
|
-
filtered = filtered.filter(f =>
|
|
87
|
-
|
|
88
|
-
|
|
149
|
+
filtered = filtered.filter(f => {
|
|
150
|
+
if (f.context?.is_lifecycle_hook) return true;
|
|
151
|
+
if (f.context?.is_multi_layer) return true;
|
|
152
|
+
return !policy.suppress.some(r => matchesSuppressRule(f, packageName, r));
|
|
153
|
+
});
|
|
89
154
|
}
|
|
90
155
|
|
|
91
156
|
filtered = filtered.map(f => {
|
|
@@ -108,4 +173,4 @@ function checkFailOn(findings, policy) {
|
|
|
108
173
|
return findings.some(f => severityIndex(f.severity) >= threshold);
|
|
109
174
|
}
|
|
110
175
|
|
|
111
|
-
export { loadPolicy, applyPolicy, isAllowed };
|
|
176
|
+
export { loadPolicy, applyPolicy, isAllowed, getPackageReputationTier, matchesContext };
|
package/cli/cli.js
CHANGED
|
@@ -67,11 +67,11 @@ program
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const { pkgJson, jsFiles, tmpDir } = options.file
|
|
70
|
+
const { pkgJson, jsFiles, allFiles, tmpDir, meta } = options.file
|
|
71
71
|
? await import('../backend/fetch.js').then(m => m.scanLocalTarball(options.file))
|
|
72
72
|
: await import('../backend/fetch.js').then(m => m.fetchPackage(target, fetchOptions));
|
|
73
73
|
const pkgName = target || pkgJson.name || 'unknown';
|
|
74
|
-
const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles));
|
|
74
|
+
const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles, meta, allFiles));
|
|
75
75
|
const { saveScan } = await import('../backend/db.js');
|
|
76
76
|
const scanId = await saveScan(pkgName, 'latest', findings);
|
|
77
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Modern npm supply chain security scanner — detects obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation. 11 attack types, SBOM, NIST/EU CRA compliance reporting.",
|
|
5
5
|
"main": "backend/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"corpus": "node tests/corpus/run.js"
|
|
35
35
|
},
|
|
36
36
|
"lint-staged": {
|
|
37
|
-
"**/package{,-lock}.json": "node cli/cli.js scan-lockfile --fail-on high",
|
|
38
|
-
"**/yarn.lock": "node cli/cli.js scan-lockfile --fail-on high --yarn",
|
|
39
|
-
"**/pnpm-lock.yaml": "node cli/cli.js scan-lockfile --fail-on high --pnpm"
|
|
37
|
+
"**/package{,-lock}.json": "sh -c 'node cli/cli.js scan-lockfile --fail-on high'",
|
|
38
|
+
"**/yarn.lock": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --yarn'",
|
|
39
|
+
"**/pnpm-lock.yaml": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --pnpm'"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|