muaddib-scanner 2.10.33 → 2.10.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.33",
3
+ "version": "2.10.35",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -223,6 +223,37 @@ function formatDiscord(results) {
223
223
  });
224
224
  }
225
225
 
226
+ // Add LLM Detective field if LLM analysis was performed
227
+ if (results.llm && results.llm.verdict) {
228
+ const verdictEmoji = results.llm.verdict === 'malicious' ? '\u274C'
229
+ : results.llm.verdict === 'benign' ? '\u2705' : '\u2753';
230
+ const modeTag = results.llm.mode === 'shadow' ? ' [shadow]' : '';
231
+ let llmValue = `${verdictEmoji} **${results.llm.verdict}** (${Math.round(results.llm.confidence * 100)}% confidence)${modeTag}`;
232
+ if (results.llm.attack_type) {
233
+ llmValue += `\nType: ${results.llm.attack_type}`;
234
+ }
235
+ if (results.llm.iocs_found && results.llm.iocs_found.length > 0) {
236
+ llmValue += `\nIOCs: ${results.llm.iocs_found.join(', ')}`;
237
+ }
238
+ if (results.llm.reasoning) {
239
+ llmValue += `\n${results.llm.reasoning}`;
240
+ }
241
+ fields.push({
242
+ name: 'LLM Analysis',
243
+ value: llmValue.slice(0, 1024),
244
+ inline: false
245
+ });
246
+ // Show investigation steps as a separate field if present (structured reasoning)
247
+ if (results.llm.investigation_steps && results.llm.investigation_steps.length > 0) {
248
+ const stepsText = results.llm.investigation_steps.map(s => `- ${s}`).join('\n');
249
+ fields.push({
250
+ name: 'Investigation Steps',
251
+ value: stepsText.slice(0, 1024),
252
+ inline: false
253
+ });
254
+ }
255
+ }
256
+
226
257
  const titlePrefix = emoji ? `${emoji} ` : '';
227
258
  const prioritySuffix = priority && priority.level ? ` [${priority.level}]` : '';
228
259
  const ts = results.timestamp ? new Date(results.timestamp) : new Date();
@@ -0,0 +1,582 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * LLM Detective — Claude Haiku-based package analysis for FP reduction.
5
+ *
6
+ * Reads ALL source code from a suspect package and asks Haiku to determine
7
+ * if it's real malware or a false positive. Operates in two modes:
8
+ * - shadow (default): log verdict, don't affect webhook flow
9
+ * - active: suppress webhook for high-confidence benign, enrich for malicious
10
+ *
11
+ * No external dependency — uses native fetch() against the Anthropic Messages API.
12
+ *
13
+ * Security: NEVER sends sandboxResult (contains honey tokens).
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { findFiles } = require('../utils.js');
19
+
20
+ // ── Constants ──
21
+
22
+ const API_URL = 'https://api.anthropic.com/v1/messages';
23
+ const MODEL_ID = 'claude-haiku-4-5-20251001';
24
+ const MAX_CONTEXT_BYTES = 100 * 1024; // 100KB cap for source code in prompt
25
+ const LLM_TIMEOUT_MS = 60000; // 60s timeout per API call
26
+ const LLM_CONCURRENCY_MAX = 2; // max simultaneous API calls
27
+ const LLM_DAILY_LIMIT_DEFAULT = 100;
28
+ const MAX_SINGLE_FILE_BYTES = 512 * 1024; // skip individual files > 512KB
29
+
30
+ // Extensions to collect from packages
31
+ const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.json', '.py'];
32
+
33
+ // ── Semaphore (pattern: src/shared/http-limiter.js) ──
34
+
35
+ const _semaphore = { active: 0, queue: [] };
36
+
37
+ function acquireLlmSlot() {
38
+ if (_semaphore.active < LLM_CONCURRENCY_MAX) {
39
+ _semaphore.active++;
40
+ return Promise.resolve();
41
+ }
42
+ return new Promise(resolve => {
43
+ _semaphore.queue.push(resolve);
44
+ });
45
+ }
46
+
47
+ function releaseLlmSlot() {
48
+ if (_semaphore.queue.length > 0) {
49
+ const next = _semaphore.queue.shift();
50
+ next();
51
+ } else {
52
+ _semaphore.active--;
53
+ }
54
+ }
55
+
56
+ // ── Daily quota counter (in-memory, resets at midnight UTC) ──
57
+
58
+ const _dailyCounter = { count: 0, resetDate: null };
59
+
60
+ function getTodayUTC() {
61
+ return new Date().toISOString().slice(0, 10);
62
+ }
63
+
64
+ function isDailyQuotaAvailable() {
65
+ const today = getTodayUTC();
66
+ if (_dailyCounter.resetDate !== today) {
67
+ _dailyCounter.count = 0;
68
+ _dailyCounter.resetDate = today;
69
+ }
70
+ return _dailyCounter.count < getDailyLimit();
71
+ }
72
+
73
+ function incrementDailyCounter() {
74
+ const today = getTodayUTC();
75
+ if (_dailyCounter.resetDate !== today) {
76
+ _dailyCounter.count = 0;
77
+ _dailyCounter.resetDate = today;
78
+ }
79
+ _dailyCounter.count++;
80
+ }
81
+
82
+ function getDailyLimit() {
83
+ return Math.max(1, parseInt(process.env.MUADDIB_LLM_DAILY_LIMIT, 10) || LLM_DAILY_LIMIT_DEFAULT);
84
+ }
85
+
86
+ function getDailyCount() {
87
+ return _dailyCounter.count;
88
+ }
89
+
90
+ function resetDailyCounter() {
91
+ _dailyCounter.count = 0;
92
+ _dailyCounter.resetDate = null;
93
+ }
94
+
95
+ // ── Feature flags ──
96
+
97
+ function isLlmEnabled() {
98
+ if (!process.env.ANTHROPIC_API_KEY) return false;
99
+ const env = process.env.MUADDIB_LLM_ENABLED;
100
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
101
+ return true;
102
+ }
103
+
104
+ function getLlmMode() {
105
+ const env = process.env.MUADDIB_LLM_MODE;
106
+ if (env && env.toLowerCase() === 'active') return 'active';
107
+ return 'shadow';
108
+ }
109
+
110
+ // ── Stats ──
111
+
112
+ const _stats = { analyzed: 0, malicious: 0, benign: 0, uncertain: 0, errors: 0, skipped: 0 };
113
+
114
+ function getStats() {
115
+ return { ..._stats };
116
+ }
117
+
118
+ function resetStats() {
119
+ _stats.analyzed = 0;
120
+ _stats.malicious = 0;
121
+ _stats.benign = 0;
122
+ _stats.uncertain = 0;
123
+ _stats.errors = 0;
124
+ _stats.skipped = 0;
125
+ }
126
+
127
+ // ── Source context collection ──
128
+
129
+ /**
130
+ * Collect source files from an extracted package directory.
131
+ * Respects MAX_CONTEXT_BYTES cap. If over, falls back to priority files.
132
+ *
133
+ * @param {string} extractedDir - path to extracted package
134
+ * @param {Object} scanResult - scan result with threats[].file for priority
135
+ * @returns {{ files: Array<{path: string, content: string}>, truncated: boolean, totalBytes: number }}
136
+ */
137
+ function collectSourceContext(extractedDir, scanResult) {
138
+ const allFiles = findFiles(extractedDir, {
139
+ extensions: SOURCE_EXTENSIONS,
140
+ maxFiles: 200
141
+ });
142
+
143
+ const fileEntries = [];
144
+ let totalBytes = 0;
145
+ let truncated = false;
146
+
147
+ // Try to include all files
148
+ for (const filePath of allFiles) {
149
+ try {
150
+ const stat = fs.statSync(filePath);
151
+ if (stat.size > MAX_SINGLE_FILE_BYTES) continue;
152
+ if (totalBytes + stat.size > MAX_CONTEXT_BYTES) {
153
+ truncated = true;
154
+ break;
155
+ }
156
+ const content = fs.readFileSync(filePath, 'utf8');
157
+ const relPath = path.relative(extractedDir, filePath).replace(/\\/g, '/');
158
+ totalBytes += Buffer.byteLength(content, 'utf8');
159
+ fileEntries.push({ path: relPath, content });
160
+ } catch {
161
+ // Skip unreadable files
162
+ }
163
+ }
164
+
165
+ // If truncated, restart with priority files only
166
+ if (truncated) {
167
+ fileEntries.length = 0;
168
+ totalBytes = 0;
169
+ truncated = true;
170
+
171
+ const flaggedFiles = new Set(
172
+ ((scanResult && scanResult.threats) || [])
173
+ .map(t => t.file)
174
+ .filter(Boolean)
175
+ );
176
+
177
+ // Priority order: package.json, flagged files, entry point, README
178
+ const priorityRelPaths = new Set();
179
+ priorityRelPaths.add('package.json');
180
+ for (const f of flaggedFiles) priorityRelPaths.add(f);
181
+
182
+ // Read package.json to find entry point
183
+ try {
184
+ const pkgJsonPath = path.join(extractedDir, 'package.json');
185
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
186
+ if (pkgJson.main) priorityRelPaths.add(pkgJson.main);
187
+ if (pkgJson.bin) {
188
+ const bins = typeof pkgJson.bin === 'string' ? [pkgJson.bin] : Object.values(pkgJson.bin || {});
189
+ for (const b of bins) if (b) priorityRelPaths.add(b);
190
+ }
191
+ } catch {}
192
+
193
+ priorityRelPaths.add('README.md');
194
+ priorityRelPaths.add('readme.md');
195
+
196
+ for (const relPath of priorityRelPaths) {
197
+ const absPath = path.join(extractedDir, relPath);
198
+ try {
199
+ if (!fs.existsSync(absPath)) continue;
200
+ const stat = fs.statSync(absPath);
201
+ if (stat.size > MAX_SINGLE_FILE_BYTES) continue;
202
+ if (totalBytes + stat.size > MAX_CONTEXT_BYTES) continue;
203
+ const content = fs.readFileSync(absPath, 'utf8');
204
+ totalBytes += Buffer.byteLength(content, 'utf8');
205
+ fileEntries.push({ path: relPath.replace(/\\/g, '/'), content });
206
+ } catch {}
207
+ }
208
+ }
209
+
210
+ return { files: fileEntries, truncated, totalBytes };
211
+ }
212
+
213
+ // ── Prompt construction ──
214
+
215
+ const SYSTEM_PROMPT = `You are a senior supply-chain security analyst performing the SAME investigation a human would do manually. You receive source code of a suspect package and static scanner results.
216
+
217
+ CRITICAL: The scanner findings are SIGNALS, not truth. Your job is to INDEPENDENTLY determine if this package is malicious by reading the code yourself. Many scanner findings are false positives — a CLI tool using child_process is not malware.
218
+
219
+ ## YOUR INVESTIGATION METHOD
220
+
221
+ Do exactly what a human analyst would:
222
+
223
+ Step 1 — DECLARED PURPOSE: Read package.json. What does this package claim to do? Is the name/description/repo coherent?
224
+
225
+ Step 2 — CODE REALITY: Read ALL the code. Does it actually do what the description says? A "color picker" with child_process.exec is suspicious. A "CLI wrapper" with child_process.exec is normal.
226
+
227
+ Step 3 — DATA FLOW INTENT: When code accesses process.env or credentials:
228
+ - Is it CONFIGURING itself (reading DATABASE_URL, API_KEY for its own backend)? → BENIGN
229
+ - Is it COLLECTING and SENDING data to a third-party domain? → MALICIOUS
230
+ Follow the data: where does it GO?
231
+
232
+ Step 4 — DESTINATION CHECK: If data is sent somewhere:
233
+ - To the package's own documented API/backend? → BENIGN
234
+ - To a raw IP, ngrok/serveo tunnel, or unrelated domain? → MALICIOUS
235
+ - To nowhere (data is only read, never exfiltrated)? → BENIGN
236
+
237
+ Step 5 — COHERENCE: Does the complexity match the purpose?
238
+ - 3-file package with postinstall downloading binaries? → SUSPICIOUS
239
+ - Build tool with postinstall compiling native addon? → NORMAL
240
+ - Obfuscated code in a 10-line utility? → SUSPICIOUS
241
+ - Minified dist/ in a large framework? → NORMAL
242
+
243
+ ## GOLDEN RULE
244
+
245
+ If sensitive data (env vars, credentials, keys) is only READ for self-configuration and never SENT to an external third-party, the package is BENIGN regardless of what the scanner says.
246
+
247
+ If sensitive data is COLLECTED and EXFILTRATED to a domain unrelated to the package's stated purpose, it is MALICIOUS.
248
+
249
+ ## REFERENCE EXAMPLES
250
+
251
+ EXAMPLE 1 — TRUE MALWARE:
252
+ Package "slopex-cli" claims to be a "continuity patcher for OpenAI Codex". Postinstall downloads a binary from a personal GitHub repo and REPLACES the real Codex binary. The binary is not part of the described functionality — it's a trojan replacing a trusted tool.
253
+ → Verdict: MALICIOUS (backdoor, confidence 0.97)
254
+
255
+ EXAMPLE 2 — FALSE POSITIVE:
256
+ Package "@yeaft/webchat-agent" is a "remote agent for WebChat connecting worker machines". Code uses execSync to locate the Claude CLI binary, process.env to read PATH configuration. Scanner flags "detached_credential_exfil" (CRITICAL) — but the code is just spawning a documented CLI tool and reading PATH. No data is sent to any external domain. The functionality matches the description.
257
+ → Verdict: BENIGN (confidence 0.92)
258
+
259
+ EXAMPLE 3 — TRUE MALWARE:
260
+ Package "event-stream" (compromised via flatmap-stream dependency). Obfuscated code hidden in a nested dependency decrypts a payload targeting Bitcoin wallet data from Copay. The obfuscation has no legitimate reason — the parent package is a simple stream utility. The decrypted code specifically targets cryptocurrency credentials.
261
+ → Verdict: MALICIOUS (credential_exfil, confidence 0.98)
262
+
263
+ EXAMPLE 4 — FALSE POSITIVE:
264
+ A web framework reads process.env.DATABASE_URL, process.env.API_KEY for configuration. It uses fetch() to call its own documented API endpoint. It uses dynamic require() to load user-configured plugins. Scanner flags env_access, dynamic_require, network_require — but all these are standard framework patterns. No data leaves the application boundary.
265
+ → Verdict: BENIGN (confidence 0.95)
266
+
267
+ ## KEY QUESTIONS TO ANSWER
268
+
269
+ 1. "Do sensitive data (env vars, credentials) LEAVE the package to a third party?"
270
+ 2. "Does the code do something HIDDEN that the description doesn't mention?"
271
+ 3. "Is obfuscation justified (build tool output) or suspicious (tiny package, no build step)?"
272
+ 4. "Does the postinstall relate to the declared functionality?"
273
+ 5. "Could a reasonable developer have written this code for the stated purpose?"
274
+
275
+ ## COMMON FALSE POSITIVE PATTERNS (do NOT flag these)
276
+
277
+ - CLI tools/wrappers using exec/spawn to run other CLI tools (their stated purpose)
278
+ - SDK packages reading API keys from env vars (standard configuration)
279
+ - Build tools with postinstall that compile native addons (node-gyp, prebuild)
280
+ - Packages reading process.env for feature flags, logging config, or database URLs
281
+ - Monorepo tooling with dynamic require for loading workspace packages
282
+ - Test frameworks that use eval() or vm.runInContext for sandboxed test execution
283
+
284
+ RESPOND IN STRICT JSON ONLY (nothing else):
285
+ {
286
+ "verdict": "malicious" | "benign" | "uncertain",
287
+ "confidence": 0.0-1.0,
288
+ "investigation_steps": ["Step 1: ...", "Step 2: ...", "Step 3: ..."],
289
+ "reasoning": "Final summary of your analysis",
290
+ "iocs_found": ["domain.com", "1.2.3.4"],
291
+ "attack_type": "credential_exfil" | "reverse_shell" | "crypto_miner" | "backdoor" | "typosquat" | "protestware" | null,
292
+ "recommendation": "block" | "monitor" | "safe"
293
+ }`;
294
+
295
+ /**
296
+ * Build the messages array for the Anthropic API call.
297
+ *
298
+ * @param {string} name - package name
299
+ * @param {string} version - package version
300
+ * @param {string} ecosystem - 'npm' or 'pypi'
301
+ * @param {{ files: Array, truncated: boolean, totalBytes: number }} sourceContext
302
+ * @param {Array} threats - scan findings
303
+ * @param {Object} npmRegistryMeta - registry metadata (optional)
304
+ * @returns {{ system: string, messages: Array }}
305
+ */
306
+ function buildPrompt(name, version, ecosystem, sourceContext, threats, npmRegistryMeta) {
307
+ let userContent = `## Package: ${name}@${version} (${ecosystem})\n\n`;
308
+
309
+ // Registry metadata
310
+ if (npmRegistryMeta) {
311
+ userContent += `## Registry Metadata\n`;
312
+ if (npmRegistryMeta.age_days !== undefined) userContent += `- Age: ${npmRegistryMeta.age_days} days\n`;
313
+ if (npmRegistryMeta.weekly_downloads !== undefined) userContent += `- Weekly downloads: ${npmRegistryMeta.weekly_downloads}\n`;
314
+ if (npmRegistryMeta.version_count !== undefined) userContent += `- Version count: ${npmRegistryMeta.version_count}\n`;
315
+ if (npmRegistryMeta.author_package_count !== undefined) userContent += `- Author package count: ${npmRegistryMeta.author_package_count}\n`;
316
+ if (npmRegistryMeta.has_repository !== undefined) userContent += `- Has repository: ${npmRegistryMeta.has_repository}\n`;
317
+ userContent += '\n';
318
+ }
319
+
320
+ // Static scanner findings — framed as signals to challenge
321
+ if (threats && threats.length > 0) {
322
+ userContent += `## Static Scanner Signals (${threats.length} total — these are SIGNALS to investigate, not confirmed threats)\n`;
323
+ for (const t of threats.slice(0, 30)) {
324
+ const loc = t.file ? ` in ${t.file}${t.line ? ':' + t.line : ''}` : '';
325
+ userContent += `- [${t.severity}] ${t.type}${loc}: ${t.message || ''}\n`;
326
+ }
327
+ if (threats.length > 30) {
328
+ userContent += `... and ${threats.length - 30} more signals\n`;
329
+ }
330
+ userContent += '\n';
331
+ }
332
+
333
+ // Source code
334
+ userContent += `## Source Code (${sourceContext.files.length} files, ${sourceContext.totalBytes} bytes${sourceContext.truncated ? ', TRUNCATED — only priority files shown' : ''})\n\n`;
335
+ for (const file of sourceContext.files) {
336
+ userContent += `### ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n\n`;
337
+ }
338
+
339
+ return {
340
+ system: SYSTEM_PROMPT,
341
+ messages: [{ role: 'user', content: userContent }]
342
+ };
343
+ }
344
+
345
+ // ── Anthropic API call ──
346
+
347
+ /**
348
+ * Call the Anthropic Messages API with retry on 429/5xx.
349
+ *
350
+ * @param {string} system - system prompt
351
+ * @param {Array} messages - conversation messages
352
+ * @returns {Promise<string>} response text content
353
+ */
354
+ async function callAnthropicAPI(system, messages) {
355
+ const apiKey = process.env.ANTHROPIC_API_KEY;
356
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
357
+
358
+ const body = JSON.stringify({
359
+ model: MODEL_ID,
360
+ max_tokens: 2048,
361
+ system,
362
+ messages
363
+ });
364
+
365
+ const maxAttempts = 2;
366
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
367
+ const controller = new AbortController();
368
+ const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
369
+
370
+ try {
371
+ const response = await fetch(API_URL, {
372
+ method: 'POST',
373
+ headers: {
374
+ 'Content-Type': 'application/json',
375
+ 'x-api-key': apiKey,
376
+ 'anthropic-version': '2023-06-01'
377
+ },
378
+ body,
379
+ signal: controller.signal
380
+ });
381
+
382
+ clearTimeout(timeout);
383
+
384
+ if (response.ok) {
385
+ const data = await response.json();
386
+ if (data.content && data.content[0] && data.content[0].text) {
387
+ return data.content[0].text;
388
+ }
389
+ throw new Error('Unexpected response format');
390
+ }
391
+
392
+ // Retry on 429 or 5xx
393
+ if ((response.status === 429 || response.status >= 500) && attempt < maxAttempts - 1) {
394
+ const delay = 2000 * (attempt + 1);
395
+ console.log(`[LLM] API ${response.status}, retrying in ${delay}ms...`);
396
+ await new Promise(r => setTimeout(r, delay));
397
+ continue;
398
+ }
399
+
400
+ const errorText = await response.text().catch(() => '');
401
+ throw new Error(`API ${response.status}: ${errorText.slice(0, 200)}`);
402
+ } catch (err) {
403
+ clearTimeout(timeout);
404
+ if (err.name === 'AbortError') {
405
+ throw new Error(`API timeout (${LLM_TIMEOUT_MS}ms)`);
406
+ }
407
+ if (attempt < maxAttempts - 1 && err.message && /ECONNRESET|ETIMEDOUT|ENOTFOUND/.test(err.message)) {
408
+ await new Promise(r => setTimeout(r, 2000));
409
+ continue;
410
+ }
411
+ throw err;
412
+ }
413
+ }
414
+ }
415
+
416
+ // ── Response parsing ──
417
+
418
+ /**
419
+ * Parse LLM response text into structured verdict object.
420
+ * Handles raw JSON and markdown-fenced JSON.
421
+ *
422
+ * @param {string} text - raw response text
423
+ * @returns {{ verdict: string, confidence: number, reasoning: string, iocs_found: string[], attack_type: string|null, recommendation: string }}
424
+ */
425
+ function parseResponse(text) {
426
+ const fallback = {
427
+ verdict: 'uncertain',
428
+ confidence: 0,
429
+ investigation_steps: [],
430
+ reasoning: 'Failed to parse LLM response',
431
+ iocs_found: [],
432
+ attack_type: null,
433
+ recommendation: 'monitor'
434
+ };
435
+
436
+ if (!text || typeof text !== 'string') return fallback;
437
+
438
+ let parsed;
439
+ try {
440
+ parsed = JSON.parse(text.trim());
441
+ } catch {
442
+ // Try extracting JSON from markdown fence
443
+ const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
444
+ if (fenceMatch) {
445
+ try {
446
+ parsed = JSON.parse(fenceMatch[1].trim());
447
+ } catch {
448
+ return fallback;
449
+ }
450
+ } else {
451
+ // Try finding first { ... } block
452
+ const start = text.indexOf('{');
453
+ const end = text.lastIndexOf('}');
454
+ if (start !== -1 && end > start) {
455
+ try {
456
+ parsed = JSON.parse(text.substring(start, end + 1));
457
+ } catch {
458
+ return fallback;
459
+ }
460
+ } else {
461
+ return fallback;
462
+ }
463
+ }
464
+ }
465
+
466
+ // Validate and normalize
467
+ const validVerdicts = ['malicious', 'benign', 'uncertain'];
468
+ const verdict = validVerdicts.includes(parsed.verdict) ? parsed.verdict : 'uncertain';
469
+
470
+ let confidence = parseFloat(parsed.confidence);
471
+ if (isNaN(confidence)) confidence = 0;
472
+ confidence = Math.max(0, Math.min(1, confidence));
473
+
474
+ const validRecommendations = ['block', 'monitor', 'safe'];
475
+ const recommendation = validRecommendations.includes(parsed.recommendation) ? parsed.recommendation : 'monitor';
476
+
477
+ return {
478
+ verdict,
479
+ confidence: Math.round(confidence * 1000) / 1000,
480
+ investigation_steps: Array.isArray(parsed.investigation_steps) ? parsed.investigation_steps.filter(x => typeof x === 'string').slice(0, 10) : [],
481
+ reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
482
+ iocs_found: Array.isArray(parsed.iocs_found) ? parsed.iocs_found.filter(x => typeof x === 'string').slice(0, 20) : [],
483
+ attack_type: typeof parsed.attack_type === 'string' ? parsed.attack_type : null,
484
+ recommendation
485
+ };
486
+ }
487
+
488
+ // ── Main entry point ──
489
+
490
+ /**
491
+ * Investigate a suspect package with Claude Haiku.
492
+ *
493
+ * @param {string} extractedDir - path to extracted package source
494
+ * @param {Object} scanResult - static scan result with threats[] and summary
495
+ * @param {Object} options - { name, version, ecosystem, registryMeta, npmRegistryMeta, tier }
496
+ * @returns {Promise<Object|null>} verdict object or null on skip/error
497
+ */
498
+ async function investigatePackage(extractedDir, scanResult, options = {}) {
499
+ const { name, version, ecosystem, npmRegistryMeta, tier } = options;
500
+
501
+ // Guard rails
502
+ if (!isLlmEnabled()) {
503
+ _stats.skipped++;
504
+ return null;
505
+ }
506
+
507
+ if (!isDailyQuotaAvailable()) {
508
+ _stats.skipped++;
509
+ console.log(`[LLM] Daily quota exhausted (${_dailyCounter.count}/${getDailyLimit()}) — skipping ${name}@${version}`);
510
+ return null;
511
+ }
512
+
513
+ await acquireLlmSlot();
514
+ try {
515
+ incrementDailyCounter();
516
+
517
+ // Collect source files
518
+ const sourceContext = collectSourceContext(extractedDir, scanResult);
519
+ if (sourceContext.files.length === 0) {
520
+ _stats.skipped++;
521
+ console.log(`[LLM] No source files found in ${name}@${version} — skipping`);
522
+ return null;
523
+ }
524
+
525
+ // Build prompt
526
+ const threats = (scanResult && scanResult.threats) || [];
527
+ const { system, messages } = buildPrompt(
528
+ name || 'unknown', version || '0.0.0', ecosystem || 'npm',
529
+ sourceContext, threats, npmRegistryMeta
530
+ );
531
+
532
+ // Call API
533
+ const responseText = await callAnthropicAPI(system, messages);
534
+
535
+ // Parse response
536
+ const result = parseResponse(responseText);
537
+ result.mode = getLlmMode();
538
+
539
+ // Update stats
540
+ _stats.analyzed++;
541
+ if (result.verdict === 'malicious') _stats.malicious++;
542
+ else if (result.verdict === 'benign') _stats.benign++;
543
+ else _stats.uncertain++;
544
+
545
+ return result;
546
+ } catch (err) {
547
+ _stats.errors++;
548
+ console.error(`[LLM] Investigation error for ${name}@${version}: ${err.message}`);
549
+ return null;
550
+ } finally {
551
+ releaseLlmSlot();
552
+ }
553
+ }
554
+
555
+ // ── Reset for testing ──
556
+
557
+ function resetLlmLimiter() {
558
+ _semaphore.active = 0;
559
+ _semaphore.queue.length = 0;
560
+ }
561
+
562
+ module.exports = {
563
+ investigatePackage,
564
+ isLlmEnabled,
565
+ getLlmMode,
566
+ getDailyLimit,
567
+ getDailyCount,
568
+ isDailyQuotaAvailable,
569
+ incrementDailyCounter,
570
+ getStats,
571
+ resetStats,
572
+ resetDailyCounter,
573
+ resetLlmLimiter,
574
+ // Exported for testing
575
+ collectSourceContext,
576
+ buildPrompt,
577
+ parseResponse,
578
+ // Constants for testing
579
+ MAX_CONTEXT_BYTES,
580
+ LLM_CONCURRENCY_MAX,
581
+ LLM_DAILY_LIMIT_DEFAULT
582
+ };
@@ -250,6 +250,19 @@ function isCanaryEnabled() {
250
250
  return true;
251
251
  }
252
252
 
253
+ function isLlmDetectiveEnabled() {
254
+ if (!process.env.ANTHROPIC_API_KEY) return false;
255
+ const env = process.env.MUADDIB_LLM_ENABLED;
256
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
257
+ return true;
258
+ }
259
+
260
+ function getLlmDetectiveMode() {
261
+ const env = process.env.MUADDIB_LLM_MODE;
262
+ if (env && env.toLowerCase() === 'active') return 'active';
263
+ return 'shadow';
264
+ }
265
+
253
266
  /** @deprecated See comment above verboseMode. */
254
267
  function isVerboseMode() {
255
268
  if (verboseMode) return true;
@@ -348,6 +361,8 @@ module.exports = {
348
361
  formatFindings,
349
362
  isSandboxEnabled,
350
363
  isCanaryEnabled,
364
+ isLlmDetectiveEnabled,
365
+ getLlmDetectiveMode,
351
366
  isVerboseMode,
352
367
  setVerboseMode,
353
368
  quickTyposquatCheck,
@@ -3,7 +3,7 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
5
  const { isDockerAvailable, SANDBOX_CONCURRENCY_MAX } = require('../sandbox/index.js');
6
- const { setVerboseMode, isSandboxEnabled, isCanaryEnabled } = require('./classify.js');
6
+ const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled, getLlmDetectiveMode } = require('./classify.js');
7
7
  const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour } = require('./state.js');
8
8
  const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
9
9
  const { pendingGrouped, flushScopeGroup, sendDailyReport, DAILY_REPORT_HOUR } = require('./webhook.js');
@@ -119,6 +119,16 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
119
119
  console.log('[MONITOR] Canary tokens disabled (MUADDIB_MONITOR_CANARY=false)');
120
120
  }
121
121
 
122
+ // LLM Detective status
123
+ if (isLlmDetectiveEnabled()) {
124
+ const llmMode = getLlmDetectiveMode();
125
+ const llmLimit = parseInt(process.env.MUADDIB_LLM_DAILY_LIMIT, 10) || 100;
126
+ console.log(`[MONITOR] LLM Detective enabled — mode: ${llmMode}, daily limit: ${llmLimit}, model: claude-haiku-4-5`);
127
+ } else {
128
+ const reason = !process.env.ANTHROPIC_API_KEY ? 'no ANTHROPIC_API_KEY' : 'MUADDIB_LLM_ENABLED=false';
129
+ console.log(`[MONITOR] LLM Detective disabled (${reason})`);
130
+ }
131
+
122
132
  // Temporal analysis status
123
133
  if (isTemporalEnabled()) {
124
134
  console.log('[MONITOR] Temporal lifecycle analysis enabled — detecting sudden lifecycle script changes');
@@ -676,11 +676,44 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
676
676
  } else if (ecosystem === 'npm' && hasHighConfidenceThreat(result)) {
677
677
  console.log(`[MONITOR] REPUTATION BYPASS: ${name} has high-confidence threat — using raw score`);
678
678
  }
679
- await trySendWebhook(name, version, ecosystem, adjustedResult, sandboxResult, mlResult);
679
+ // LLM Detective: AI-powered analysis for T1a/T1b suspects
680
+ let llmResult = null;
681
+ if ((tier === '1a' || tier === '1b') && (adjustedResult.summary.riskScore || 0) >= 25) {
682
+ try {
683
+ const { investigatePackage, isLlmEnabled, getLlmMode } = require('../ml/llm-detective.js');
684
+ if (isLlmEnabled()) {
685
+ llmResult = await investigatePackage(extractedDir, result, {
686
+ name, version, ecosystem,
687
+ registryMeta: meta,
688
+ npmRegistryMeta,
689
+ tier
690
+ });
691
+ if (llmResult) {
692
+ const llmMode = getLlmMode();
693
+ console.log(`[LLM] ${name}@${version}: verdict=${llmResult.verdict} confidence=${llmResult.confidence} mode=${llmMode}`);
694
+ stats.llmAnalyzed = (stats.llmAnalyzed || 0) + 1;
695
+
696
+ if (llmMode === 'active' && llmResult.verdict === 'benign' && llmResult.confidence > 0.85) {
697
+ console.log(`[LLM] SUPPRESS: ${name}@${version} cleared (benign, confidence=${llmResult.confidence})`);
698
+ stats.llmSuppressed = (stats.llmSuppressed || 0) + 1;
699
+ stats.scanned++;
700
+ stats.totalTimeMs += Date.now() - startTime;
701
+ updateScanStats('llm_benign');
702
+ recordTrainingSample(result, { name, version, ecosystem, label: 'llm_benign', tier, registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
703
+ return { sandboxResult, llmResult, tier, staticScore: result.summary.riskScore || 0 };
704
+ }
705
+ }
706
+ }
707
+ } catch (err) {
708
+ console.error(`[LLM] Error for ${name}@${version}: ${err.message}`);
709
+ }
710
+ }
711
+
712
+ await trySendWebhook(name, version, ecosystem, adjustedResult, sandboxResult, mlResult, llmResult);
680
713
  const staticScore = result.summary.riskScore || 0;
681
714
  const hasHCThreats = hasHighConfidenceThreat(result);
682
715
  const isDormant = sandboxResult && sandboxResult.score === 0 && (result.summary.riskScore || 0) >= 20;
683
- return { sandboxResult, staticClean: false, tier, staticScore, hasHCThreats, isDormant };
716
+ return { sandboxResult, llmResult, staticClean: false, tier, staticScore, hasHCThreats, isDormant };
684
717
  }
685
718
  }
686
719
  } catch (err) {
@@ -680,6 +680,8 @@ function loadDailyStats(stats, dailyAlerts) {
680
680
  }
681
681
  stats.totalTimeMs = data.totalTimeMs || 0;
682
682
  stats.mlFiltered = data.mlFiltered || 0;
683
+ stats.llmAnalyzed = data.llmAnalyzed || 0;
684
+ stats.llmSuppressed = data.llmSuppressed || 0;
683
685
  if (Array.isArray(data.dailyAlerts)) {
684
686
  dailyAlerts.length = 0;
685
687
  dailyAlerts.push(...data.dailyAlerts);
@@ -704,6 +706,8 @@ function saveDailyStats(stats, dailyAlerts) {
704
706
  errorsByType: { ...stats.errorsByType },
705
707
  totalTimeMs: stats.totalTimeMs,
706
708
  mlFiltered: stats.mlFiltered,
709
+ llmAnalyzed: stats.llmAnalyzed || 0,
710
+ llmSuppressed: stats.llmSuppressed || 0,
707
711
  dailyAlerts: dailyAlerts.slice()
708
712
  };
709
713
  atomicWriteFileSync(DAILY_STATS_FILE, JSON.stringify(data, null, 2));
@@ -355,7 +355,7 @@ function computeAlertPriority(result, sandboxResult) {
355
355
  return { level: 'P3', reason: 'default' };
356
356
  }
357
357
 
358
- function buildAlertData(name, version, ecosystem, result, sandboxResult) {
358
+ function buildAlertData(name, version, ecosystem, result, sandboxResult, llmResult) {
359
359
  const priority = computeAlertPriority(result, sandboxResult);
360
360
  const webhookData = {
361
361
  target: `${ecosystem}/${name}@${version}`,
@@ -375,10 +375,21 @@ function buildAlertData(name, version, ecosystem, result, sandboxResult) {
375
375
  severity: sandboxResult.severity
376
376
  };
377
377
  }
378
+ if (llmResult && llmResult.verdict) {
379
+ webhookData.llm = {
380
+ verdict: llmResult.verdict,
381
+ confidence: llmResult.confidence,
382
+ investigation_steps: (llmResult.investigation_steps || []).slice(0, 5),
383
+ reasoning: (llmResult.reasoning || '').slice(0, 200),
384
+ attack_type: llmResult.attack_type || null,
385
+ iocs_found: (llmResult.iocs_found || []).slice(0, 5),
386
+ mode: llmResult.mode || 'shadow'
387
+ };
388
+ }
378
389
  return webhookData;
379
390
  }
380
391
 
381
- async function trySendWebhook(name, version, ecosystem, result, sandboxResult, mlResult) {
392
+ async function trySendWebhook(name, version, ecosystem, result, sandboxResult, mlResult, llmResult) {
382
393
  if (!shouldSendWebhook(result, sandboxResult, mlResult)) {
383
394
  if (mlResult && mlResult.prediction !== 'clean' && mlResult.probability >= 0.90
384
395
  && !hasHighOrCritical(result)) {
@@ -443,13 +454,13 @@ async function trySendWebhook(name, version, ecosystem, result, sandboxResult, m
443
454
  // Scope grouping: buffer scoped npm packages for grouped webhook
444
455
  const scope = extractScope(name);
445
456
  if (scope && ecosystem === 'npm') {
446
- bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult);
457
+ bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult, llmResult);
447
458
  return;
448
459
  }
449
460
 
450
461
  // Non-scoped: send immediately (existing behavior)
451
462
  const url = getWebhookUrl();
452
- const webhookData = buildAlertData(name, version, ecosystem, result, sandboxResult);
463
+ const webhookData = buildAlertData(name, version, ecosystem, result, sandboxResult, llmResult);
453
464
  try {
454
465
  await sendWebhook(url, webhookData);
455
466
  console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
@@ -473,12 +484,13 @@ function extractScope(name) {
473
484
  * Multiple packages from the same scope published within SCOPE_GROUP_WINDOW_MS
474
485
  * are grouped into a single webhook (monorepo noise reduction).
475
486
  */
476
- function bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult) {
487
+ function bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult, llmResult) {
477
488
  const entry = {
478
489
  name, version,
479
490
  score: (result && result.summary) ? (result.summary.riskScore || 0) : 0,
480
491
  threats: result.threats || [],
481
- sandboxResult
492
+ sandboxResult,
493
+ llmResult: llmResult || null
482
494
  };
483
495
 
484
496
  const existing = pendingGrouped.get(scope);
@@ -521,7 +533,7 @@ async function flushScopeGroup(scope) {
521
533
  threats: pkg.threats,
522
534
  summary: { riskScore: pkg.score, critical, high, medium, low, total: pkg.threats.length }
523
535
  };
524
- const webhookData = buildAlertData(pkg.name, pkg.version, group.ecosystem, result, pkg.sandboxResult);
536
+ const webhookData = buildAlertData(pkg.name, pkg.version, group.ecosystem, result, pkg.sandboxResult, pkg.llmResult);
525
537
  try {
526
538
  await sendWebhook(url, webhookData);
527
539
  console.log(`[MONITOR] Webhook sent for ${pkg.name}@${pkg.version} (scope group flush, single)`);
@@ -834,6 +846,23 @@ function buildDailyReportEmbed(stats, dailyAlerts) {
834
846
  mlText = 'No model loaded';
835
847
  }
836
848
 
849
+ // --- LLM Detective stats ---
850
+ let llmText;
851
+ try {
852
+ const { isLlmEnabled, getStats: getLlmStats } = require('../ml/llm-detective.js');
853
+ if (isLlmEnabled()) {
854
+ const ls = getLlmStats();
855
+ llmText = `${ls.analyzed} analyzed (${ls.malicious} mal, ${ls.benign} ben, ${ls.uncertain} unc, ${ls.errors} err)`;
856
+ if ((stats.llmSuppressed || 0) > 0) {
857
+ llmText += ` | ${stats.llmSuppressed} suppressed`;
858
+ }
859
+ } else {
860
+ llmText = 'Disabled';
861
+ }
862
+ } catch {
863
+ llmText = 'Not loaded';
864
+ }
865
+
837
866
  // --- System health ---
838
867
  const uptimeSec = Math.floor(process.uptime());
839
868
  const uptimeH = Math.floor(uptimeSec / 3600);
@@ -863,6 +892,7 @@ function buildDailyReportEmbed(stats, dailyAlerts) {
863
892
  { name: 'Timeouts', value: timeoutText, inline: true },
864
893
  { name: 'vs Yesterday', value: trendsText, inline: false },
865
894
  { name: 'ML', value: mlText, inline: true },
895
+ { name: 'LLM Detective', value: llmText, inline: true },
866
896
  { name: 'Top Suspects', value: top3Text, inline: false },
867
897
  { name: 'System', value: healthText, inline: false }
868
898
  ],
@@ -907,6 +937,8 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
907
937
  avgScanTimeMs: stats.scanned > 0 ? Math.round(stats.totalTimeMs / stats.scanned) : 0,
908
938
  suspectByTier: { ...stats.suspectByTier },
909
939
  mlFiltered: stats.mlFiltered || 0,
940
+ llmAnalyzed: stats.llmAnalyzed || 0,
941
+ llmSuppressed: stats.llmSuppressed || 0,
910
942
  changesStreamPackages: stats.changesStreamPackages || 0,
911
943
  topSuspects: dailyAlerts.slice().sort((a, b) => b.findingsCount - a.findingsCount).slice(0, 10)
912
944
  });
@@ -942,6 +974,10 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
942
974
  stats.errorsByType.other = 0;
943
975
  stats.totalTimeMs = 0;
944
976
  stats.mlFiltered = 0;
977
+ stats.llmAnalyzed = 0;
978
+ stats.llmSuppressed = 0;
979
+ // Reset LLM detective internal stats
980
+ try { require('../ml/llm-detective.js').resetStats(); } catch {}
945
981
  stats.changesStreamPackages = 0;
946
982
  stats.rssFallbackCount = 0;
947
983
  dailyAlerts.length = 0;