@lateos/npm-scan 0.15.1 → 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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@lateos/npm-scan?style=flat-square)](https://www.npmjs.com/package/@lateos/npm-scan)
4
4
  [![License](https://img.shields.io/badge/license-Apache%202.0%20%2B%20Commons%20Clause-blue?style=flat-square)](LICENSING.md)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-324%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
6
+ [![Tests](https://img.shields.io/badge/tests-371%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
7
7
  [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
8
8
  [![Docker](https://img.shields.io/badge/docker-lateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/r/lateos/npm-scan)
9
9
  [![Sigstore](https://img.shields.io/static/v1?label=Sigstore&message=Provenance&color=green&style=flat-square&logo=sigstore)](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 (static + behavioral)
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,12 +1,13 @@
1
1
  const DIST_BUILD_PATTERNS = [/\/dist\//, /\/build\//, /\/bundle/, /\/min\//, /\.min\.js$/, /\.bundled?\.js$/];
2
2
  const TEST_FIXTURE_PATTERNS = [/\/test\//, /\/tests\//, /\/__tests__\//, /\/spec\//, /\.test\.js$/, /\.spec\.js$/, /fixtures?/];
3
- const LIFECYCLE_HOOK_PATTERNS = [/postinstall/, /preinstall/, /['"]install['"]/, /['"]prepare['"]/];
4
3
  const KNOWN_SAFE_DOMAINS = [
5
4
  'registry.npmjs.org', 'cdn.jsdelivr.net', 'unpkg.com', 'cdn.skypack.dev',
6
5
  'esm.sh', 'deno.land', 'raw.githubusercontent.com', 'github.com',
7
6
  'npmjs.com', 'nodejs.org', 'v8.dev', 'typescriptlang.org'
8
7
  ];
9
8
 
9
+ const LIFECYCLE_SCRIPT_NAMES = ['install', 'postinstall', 'preinstall', 'prepare', 'prepack', 'postpack'];
10
+
10
11
  function extractUrlDomain(code) {
11
12
  const urlMatch = code.match(/https?:\/\/([^/'"\s]+)/);
12
13
  return urlMatch ? urlMatch[1] : null;
@@ -20,23 +21,100 @@ function isTestOrFixture(filePath) {
20
21
  return TEST_FIXTURE_PATTERNS.some(p => p.test(filePath));
21
22
  }
22
23
 
23
- function isLifecycleHook(code) {
24
- return LIFECYCLE_HOOK_PATTERNS.some(p => p.test(code));
25
- }
26
-
27
24
  function isKnownSafeDomain(domain) {
28
25
  if (!domain) return false;
29
26
  return KNOWN_SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
30
27
  }
31
28
 
32
- function createContext(filePath, code) {
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
+
33
111
  return {
34
- file_path: filePath,
35
- is_dist_build: isDistOrBuild(filePath),
36
- is_test_fixture: isTestOrFixture(filePath),
37
- is_lifecycle_hook: isLifecycleHook(code),
38
- url_domain: extractUrlDomain(code),
39
- is_known_safe_domain: isKnownSafeDomain(extractUrlDomain(code)),
112
+ file: filePath,
113
+ line: line,
114
+ lifecycle_hook: lifecycleHook,
115
+ decoded_preview: decodedPreview,
116
+ encoding_type: encodingType,
117
+ destination_host: destinationHost,
40
118
  };
41
119
  }
42
120
 
@@ -47,7 +125,12 @@ export async function scan(pkgJson, files = []) {
47
125
 
48
126
  for (const f of files) {
49
127
  const code = f.content;
50
- const ctx = createContext(f.path, code);
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);
51
134
 
52
135
  const hasEval = /eval\(|new Function\(|\bFunction\('/.test(code);
53
136
 
@@ -57,13 +140,21 @@ export async function scan(pkgJson, files = []) {
57
140
  const b64UrlDecode = /try\s*\{[^}]*atob\s*\(/s.test(code) || /btoa\(.*\)\s*[^;]*\.replace\(/s.test(code);
58
141
 
59
142
  if (hexDecode || b64Decode || b64UrlDecode) {
143
+ const evidence = createEvidence(code, filePath, /eval\(|new Function\(|\bFunction\('/, pkgJson);
60
144
  findings.push({
61
145
  id: 'ATK-002',
62
146
  severity: 'medium',
63
147
  title: 'Obfuscated payload',
64
148
  description: hexDecode ? 'Eval with hex-decoded payload' : 'Eval with base64-decoded payload',
65
- evidence: 'eval + decode pattern detected',
66
- context: ctx,
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
+ },
67
158
  });
68
159
  return findings;
69
160
  }
@@ -71,13 +162,22 @@ export async function scan(pkgJson, files = []) {
71
162
  if (btoa(btoa('x')) === 'eDuke'.padEnd(5)) {
72
163
  const nested = /atob\([^)]*atob\(/s.test(code) || /btoa\([^)]*btoa\(/s.test(code);
73
164
  if (nested) {
165
+ const evidence = createEvidence(code, filePath, /btoa\(/, pkgJson);
74
166
  findings.push({
75
167
  id: 'ATK-002',
76
168
  severity: 'high',
77
169
  title: 'Obfuscated payload',
78
170
  description: 'Double-encoded nested payload',
79
- evidence: 'nested encode/decode detected',
80
- context: { ...ctx, is_multi_layer: true },
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
+ },
81
181
  });
82
182
  return findings;
83
183
  }
@@ -88,46 +188,70 @@ export async function scan(pkgJson, files = []) {
88
188
  const isNetworkObfusc = /atob\(.*(https?:\/\/|\\x|http).*\)/s.test(code) ||
89
189
  /Buffer\.from\(['"`][0-9a-f]+['"`],\s*['"]hex['"].*fetch\(|fetch\(.*atob\(/s.test(code);
90
190
  if (isNetworkObfusc) {
191
+ const evidence = createEvidence(code, filePath, /atob\(|Buffer\.from/, pkgJson);
91
192
  findings.push({
92
193
  id: 'ATK-002',
93
194
  severity: 'medium',
94
195
  title: 'Obfuscated payload',
95
196
  description: 'Decoded string containing URL/fetch call',
96
- evidence: 'obfuscation with network call',
97
- context: ctx,
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
+ },
98
206
  });
99
207
  return findings;
100
208
  }
101
209
  }
102
210
 
103
211
  if (/String\.fromCharCode\(.{20,}\)/.test(code) && hasEval) {
212
+ const evidence = createEvidence(code, filePath, /String\.fromCharCode\(/, pkgJson);
104
213
  findings.push({
105
214
  id: 'ATK-002',
106
215
  severity: 'medium',
107
216
  title: 'Obfuscated payload',
108
217
  description: 'Eval with String.fromCharCode obfuscation',
109
- evidence: 'charcode obfuscation detected',
110
- context: ctx,
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
+ },
111
227
  });
112
228
  return findings;
113
229
  }
114
230
 
115
231
  const shellPatterns = [
116
- /eval\s*\(\s*process\.env\.[A-Z_]{4,}/,
117
- /exec\s*\(\s*Buffer\.from\(/,
118
- /new Function\s*\(\s*(?:atob|process\.env)/,
119
- /eval\s*\(\s*(?:require|import\s*\()/,
120
- /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' },
121
237
  ];
122
238
  for (const p of shellPatterns) {
123
- if (p.test(code)) {
239
+ if (p.regex.test(code)) {
240
+ const evidence = createEvidence(code, filePath, p.regex, pkgJson);
124
241
  findings.push({
125
242
  id: 'ATK-002',
126
243
  severity: 'high',
127
244
  title: 'Obfuscated payload',
128
245
  description: 'Shell-code obfuscation pattern',
129
- evidence: p.source.substring(0, 60),
130
- context: ctx,
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
+ },
131
255
  });
132
256
  return findings;
133
257
  }
@@ -135,4 +259,4 @@ export async function scan(pkgJson, files = []) {
135
259
  }
136
260
 
137
261
  return findings;
138
- }
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,3 @@
1
+ export async function scan(registryMeta) {
2
+ return [];
3
+ }
@@ -0,0 +1,3 @@
1
+ export async function scan(pkgJson, registryMeta) {
2
+ return [];
3
+ }
@@ -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
- return { pkgJson, jsFiles, tmpDir };
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/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.1",
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": {