muaddib-scanner 2.10.33 → 2.10.34

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.34",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -223,6 +223,28 @@ 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
+ }
247
+
226
248
  const titlePrefix = emoji ? `${emoji} ` : '';
227
249
  const prioritySuffix = priority && priority.level ? ` [${priority.level}]` : '';
228
250
  const ts = results.timestamp ? new Date(results.timestamp) : new Date();
@@ -0,0 +1,538 @@
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. You receive the COMPLETE source code of a suspect npm/PyPI package and the results of a static threat scanner.
216
+
217
+ Your job: determine if this package is MALICIOUS or LEGITIMATE.
218
+
219
+ METHODICAL ANALYSIS:
220
+ 1. Read ALL the code, not just flagged files
221
+ 2. Look for exfiltration patterns: process.env -> HTTP/DNS/WebSocket to an external domain
222
+ 3. Look for persistence patterns: writes to ~/.bashrc, ~/.npmrc, crontab, systemd
223
+ 4. Look for obfuscation patterns: eval(atob(...)), Buffer.from(...,'base64'), String.fromCharCode chains
224
+ 5. Look for reverse shell patterns: child_process.exec + /bin/sh + net.Socket
225
+ 6. Check coherence: does the README match the code? Are declared dependencies actually used?
226
+ 7. Check lifecycle scripts: what does postinstall/preinstall actually do?
227
+
228
+ LEGITIMATE PATTERNS (DO NOT FLAG):
229
+ - CLI tools using child_process for documented commands
230
+ - Bundlers/transpilers doing dynamic require/import
231
+ - Web frameworks accessing process.env for configuration
232
+ - Build tools downloading native binaries from their own CDN/GitHub releases
233
+ - Packages with >1000 weekly downloads AND an active GitHub repo with stars
234
+
235
+ MALICIOUS PATTERNS:
236
+ - Code executed at postinstall unrelated to the described functionality
237
+ - Exfiltration of process.env, ~/.npmrc, ~/.ssh, ~/.aws to an external domain
238
+ - Obfuscation with no reason (a 10-line package doesn't need minification)
239
+ - Suspicious domains in code (raw IPs, recent domains, ngrok, serveo, etc.)
240
+ - Empty or copied README (typosquatting signal)
241
+ - Package created recently (<7 days) with 0 downloads and dangerous code
242
+
243
+ RESPOND IN STRICT JSON ONLY (nothing else):
244
+ {
245
+ "verdict": "malicious" | "benign" | "uncertain",
246
+ "confidence": 0.0-1.0,
247
+ "reasoning": "Detailed explanation of your analysis",
248
+ "iocs_found": ["domain.com", "1.2.3.4"],
249
+ "attack_type": "credential_exfil" | "reverse_shell" | "crypto_miner" | "backdoor" | "typosquat" | "protestware" | null,
250
+ "recommendation": "block" | "monitor" | "safe"
251
+ }`;
252
+
253
+ /**
254
+ * Build the messages array for the Anthropic API call.
255
+ *
256
+ * @param {string} name - package name
257
+ * @param {string} version - package version
258
+ * @param {string} ecosystem - 'npm' or 'pypi'
259
+ * @param {{ files: Array, truncated: boolean, totalBytes: number }} sourceContext
260
+ * @param {Array} threats - scan findings
261
+ * @param {Object} npmRegistryMeta - registry metadata (optional)
262
+ * @returns {{ system: string, messages: Array }}
263
+ */
264
+ function buildPrompt(name, version, ecosystem, sourceContext, threats, npmRegistryMeta) {
265
+ let userContent = `## Package: ${name}@${version} (${ecosystem})\n\n`;
266
+
267
+ // Registry metadata
268
+ if (npmRegistryMeta) {
269
+ userContent += `## Registry Metadata\n`;
270
+ if (npmRegistryMeta.age_days !== undefined) userContent += `- Age: ${npmRegistryMeta.age_days} days\n`;
271
+ if (npmRegistryMeta.weekly_downloads !== undefined) userContent += `- Weekly downloads: ${npmRegistryMeta.weekly_downloads}\n`;
272
+ if (npmRegistryMeta.version_count !== undefined) userContent += `- Version count: ${npmRegistryMeta.version_count}\n`;
273
+ if (npmRegistryMeta.author_package_count !== undefined) userContent += `- Author package count: ${npmRegistryMeta.author_package_count}\n`;
274
+ if (npmRegistryMeta.has_repository !== undefined) userContent += `- Has repository: ${npmRegistryMeta.has_repository}\n`;
275
+ userContent += '\n';
276
+ }
277
+
278
+ // Static scanner findings
279
+ if (threats && threats.length > 0) {
280
+ userContent += `## Static Scanner Findings (${threats.length} total)\n`;
281
+ for (const t of threats.slice(0, 30)) {
282
+ const loc = t.file ? ` in ${t.file}${t.line ? ':' + t.line : ''}` : '';
283
+ userContent += `- [${t.severity}] ${t.type}${loc}: ${t.message || ''}\n`;
284
+ }
285
+ if (threats.length > 30) {
286
+ userContent += `... and ${threats.length - 30} more findings\n`;
287
+ }
288
+ userContent += '\n';
289
+ }
290
+
291
+ // Source code
292
+ userContent += `## Source Code (${sourceContext.files.length} files, ${sourceContext.totalBytes} bytes${sourceContext.truncated ? ', TRUNCATED — only priority files shown' : ''})\n\n`;
293
+ for (const file of sourceContext.files) {
294
+ userContent += `### ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n\n`;
295
+ }
296
+
297
+ return {
298
+ system: SYSTEM_PROMPT,
299
+ messages: [{ role: 'user', content: userContent }]
300
+ };
301
+ }
302
+
303
+ // ── Anthropic API call ──
304
+
305
+ /**
306
+ * Call the Anthropic Messages API with retry on 429/5xx.
307
+ *
308
+ * @param {string} system - system prompt
309
+ * @param {Array} messages - conversation messages
310
+ * @returns {Promise<string>} response text content
311
+ */
312
+ async function callAnthropicAPI(system, messages) {
313
+ const apiKey = process.env.ANTHROPIC_API_KEY;
314
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
315
+
316
+ const body = JSON.stringify({
317
+ model: MODEL_ID,
318
+ max_tokens: 1024,
319
+ system,
320
+ messages
321
+ });
322
+
323
+ const maxAttempts = 2;
324
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
325
+ const controller = new AbortController();
326
+ const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
327
+
328
+ try {
329
+ const response = await fetch(API_URL, {
330
+ method: 'POST',
331
+ headers: {
332
+ 'Content-Type': 'application/json',
333
+ 'x-api-key': apiKey,
334
+ 'anthropic-version': '2023-06-01'
335
+ },
336
+ body,
337
+ signal: controller.signal
338
+ });
339
+
340
+ clearTimeout(timeout);
341
+
342
+ if (response.ok) {
343
+ const data = await response.json();
344
+ if (data.content && data.content[0] && data.content[0].text) {
345
+ return data.content[0].text;
346
+ }
347
+ throw new Error('Unexpected response format');
348
+ }
349
+
350
+ // Retry on 429 or 5xx
351
+ if ((response.status === 429 || response.status >= 500) && attempt < maxAttempts - 1) {
352
+ const delay = 2000 * (attempt + 1);
353
+ console.log(`[LLM] API ${response.status}, retrying in ${delay}ms...`);
354
+ await new Promise(r => setTimeout(r, delay));
355
+ continue;
356
+ }
357
+
358
+ const errorText = await response.text().catch(() => '');
359
+ throw new Error(`API ${response.status}: ${errorText.slice(0, 200)}`);
360
+ } catch (err) {
361
+ clearTimeout(timeout);
362
+ if (err.name === 'AbortError') {
363
+ throw new Error(`API timeout (${LLM_TIMEOUT_MS}ms)`);
364
+ }
365
+ if (attempt < maxAttempts - 1 && err.message && /ECONNRESET|ETIMEDOUT|ENOTFOUND/.test(err.message)) {
366
+ await new Promise(r => setTimeout(r, 2000));
367
+ continue;
368
+ }
369
+ throw err;
370
+ }
371
+ }
372
+ }
373
+
374
+ // ── Response parsing ──
375
+
376
+ /**
377
+ * Parse LLM response text into structured verdict object.
378
+ * Handles raw JSON and markdown-fenced JSON.
379
+ *
380
+ * @param {string} text - raw response text
381
+ * @returns {{ verdict: string, confidence: number, reasoning: string, iocs_found: string[], attack_type: string|null, recommendation: string }}
382
+ */
383
+ function parseResponse(text) {
384
+ const fallback = {
385
+ verdict: 'uncertain',
386
+ confidence: 0,
387
+ reasoning: 'Failed to parse LLM response',
388
+ iocs_found: [],
389
+ attack_type: null,
390
+ recommendation: 'monitor'
391
+ };
392
+
393
+ if (!text || typeof text !== 'string') return fallback;
394
+
395
+ let parsed;
396
+ try {
397
+ parsed = JSON.parse(text.trim());
398
+ } catch {
399
+ // Try extracting JSON from markdown fence
400
+ const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
401
+ if (fenceMatch) {
402
+ try {
403
+ parsed = JSON.parse(fenceMatch[1].trim());
404
+ } catch {
405
+ return fallback;
406
+ }
407
+ } else {
408
+ // Try finding first { ... } block
409
+ const start = text.indexOf('{');
410
+ const end = text.lastIndexOf('}');
411
+ if (start !== -1 && end > start) {
412
+ try {
413
+ parsed = JSON.parse(text.substring(start, end + 1));
414
+ } catch {
415
+ return fallback;
416
+ }
417
+ } else {
418
+ return fallback;
419
+ }
420
+ }
421
+ }
422
+
423
+ // Validate and normalize
424
+ const validVerdicts = ['malicious', 'benign', 'uncertain'];
425
+ const verdict = validVerdicts.includes(parsed.verdict) ? parsed.verdict : 'uncertain';
426
+
427
+ let confidence = parseFloat(parsed.confidence);
428
+ if (isNaN(confidence)) confidence = 0;
429
+ confidence = Math.max(0, Math.min(1, confidence));
430
+
431
+ const validRecommendations = ['block', 'monitor', 'safe'];
432
+ const recommendation = validRecommendations.includes(parsed.recommendation) ? parsed.recommendation : 'monitor';
433
+
434
+ return {
435
+ verdict,
436
+ confidence: Math.round(confidence * 1000) / 1000,
437
+ reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
438
+ iocs_found: Array.isArray(parsed.iocs_found) ? parsed.iocs_found.filter(x => typeof x === 'string').slice(0, 20) : [],
439
+ attack_type: typeof parsed.attack_type === 'string' ? parsed.attack_type : null,
440
+ recommendation
441
+ };
442
+ }
443
+
444
+ // ── Main entry point ──
445
+
446
+ /**
447
+ * Investigate a suspect package with Claude Haiku.
448
+ *
449
+ * @param {string} extractedDir - path to extracted package source
450
+ * @param {Object} scanResult - static scan result with threats[] and summary
451
+ * @param {Object} options - { name, version, ecosystem, registryMeta, npmRegistryMeta, tier }
452
+ * @returns {Promise<Object|null>} verdict object or null on skip/error
453
+ */
454
+ async function investigatePackage(extractedDir, scanResult, options = {}) {
455
+ const { name, version, ecosystem, npmRegistryMeta, tier } = options;
456
+
457
+ // Guard rails
458
+ if (!isLlmEnabled()) {
459
+ _stats.skipped++;
460
+ return null;
461
+ }
462
+
463
+ if (!isDailyQuotaAvailable()) {
464
+ _stats.skipped++;
465
+ console.log(`[LLM] Daily quota exhausted (${_dailyCounter.count}/${getDailyLimit()}) — skipping ${name}@${version}`);
466
+ return null;
467
+ }
468
+
469
+ await acquireLlmSlot();
470
+ try {
471
+ incrementDailyCounter();
472
+
473
+ // Collect source files
474
+ const sourceContext = collectSourceContext(extractedDir, scanResult);
475
+ if (sourceContext.files.length === 0) {
476
+ _stats.skipped++;
477
+ console.log(`[LLM] No source files found in ${name}@${version} — skipping`);
478
+ return null;
479
+ }
480
+
481
+ // Build prompt
482
+ const threats = (scanResult && scanResult.threats) || [];
483
+ const { system, messages } = buildPrompt(
484
+ name || 'unknown', version || '0.0.0', ecosystem || 'npm',
485
+ sourceContext, threats, npmRegistryMeta
486
+ );
487
+
488
+ // Call API
489
+ const responseText = await callAnthropicAPI(system, messages);
490
+
491
+ // Parse response
492
+ const result = parseResponse(responseText);
493
+ result.mode = getLlmMode();
494
+
495
+ // Update stats
496
+ _stats.analyzed++;
497
+ if (result.verdict === 'malicious') _stats.malicious++;
498
+ else if (result.verdict === 'benign') _stats.benign++;
499
+ else _stats.uncertain++;
500
+
501
+ return result;
502
+ } catch (err) {
503
+ _stats.errors++;
504
+ console.error(`[LLM] Investigation error for ${name}@${version}: ${err.message}`);
505
+ return null;
506
+ } finally {
507
+ releaseLlmSlot();
508
+ }
509
+ }
510
+
511
+ // ── Reset for testing ──
512
+
513
+ function resetLlmLimiter() {
514
+ _semaphore.active = 0;
515
+ _semaphore.queue.length = 0;
516
+ }
517
+
518
+ module.exports = {
519
+ investigatePackage,
520
+ isLlmEnabled,
521
+ getLlmMode,
522
+ getDailyLimit,
523
+ getDailyCount,
524
+ isDailyQuotaAvailable,
525
+ incrementDailyCounter,
526
+ getStats,
527
+ resetStats,
528
+ resetDailyCounter,
529
+ resetLlmLimiter,
530
+ // Exported for testing
531
+ collectSourceContext,
532
+ buildPrompt,
533
+ parseResponse,
534
+ // Constants for testing
535
+ MAX_CONTEXT_BYTES,
536
+ LLM_CONCURRENCY_MAX,
537
+ LLM_DAILY_LIMIT_DEFAULT
538
+ };
@@ -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,20 @@ 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
+ reasoning: (llmResult.reasoning || '').slice(0, 200),
383
+ attack_type: llmResult.attack_type || null,
384
+ iocs_found: (llmResult.iocs_found || []).slice(0, 5),
385
+ mode: llmResult.mode || 'shadow'
386
+ };
387
+ }
378
388
  return webhookData;
379
389
  }
380
390
 
381
- async function trySendWebhook(name, version, ecosystem, result, sandboxResult, mlResult) {
391
+ async function trySendWebhook(name, version, ecosystem, result, sandboxResult, mlResult, llmResult) {
382
392
  if (!shouldSendWebhook(result, sandboxResult, mlResult)) {
383
393
  if (mlResult && mlResult.prediction !== 'clean' && mlResult.probability >= 0.90
384
394
  && !hasHighOrCritical(result)) {
@@ -443,13 +453,13 @@ async function trySendWebhook(name, version, ecosystem, result, sandboxResult, m
443
453
  // Scope grouping: buffer scoped npm packages for grouped webhook
444
454
  const scope = extractScope(name);
445
455
  if (scope && ecosystem === 'npm') {
446
- bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult);
456
+ bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult, llmResult);
447
457
  return;
448
458
  }
449
459
 
450
460
  // Non-scoped: send immediately (existing behavior)
451
461
  const url = getWebhookUrl();
452
- const webhookData = buildAlertData(name, version, ecosystem, result, sandboxResult);
462
+ const webhookData = buildAlertData(name, version, ecosystem, result, sandboxResult, llmResult);
453
463
  try {
454
464
  await sendWebhook(url, webhookData);
455
465
  console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
@@ -473,12 +483,13 @@ function extractScope(name) {
473
483
  * Multiple packages from the same scope published within SCOPE_GROUP_WINDOW_MS
474
484
  * are grouped into a single webhook (monorepo noise reduction).
475
485
  */
476
- function bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult) {
486
+ function bufferScopedWebhook(scope, name, version, ecosystem, result, sandboxResult, llmResult) {
477
487
  const entry = {
478
488
  name, version,
479
489
  score: (result && result.summary) ? (result.summary.riskScore || 0) : 0,
480
490
  threats: result.threats || [],
481
- sandboxResult
491
+ sandboxResult,
492
+ llmResult: llmResult || null
482
493
  };
483
494
 
484
495
  const existing = pendingGrouped.get(scope);
@@ -521,7 +532,7 @@ async function flushScopeGroup(scope) {
521
532
  threats: pkg.threats,
522
533
  summary: { riskScore: pkg.score, critical, high, medium, low, total: pkg.threats.length }
523
534
  };
524
- const webhookData = buildAlertData(pkg.name, pkg.version, group.ecosystem, result, pkg.sandboxResult);
535
+ const webhookData = buildAlertData(pkg.name, pkg.version, group.ecosystem, result, pkg.sandboxResult, pkg.llmResult);
525
536
  try {
526
537
  await sendWebhook(url, webhookData);
527
538
  console.log(`[MONITOR] Webhook sent for ${pkg.name}@${pkg.version} (scope group flush, single)`);
@@ -834,6 +845,23 @@ function buildDailyReportEmbed(stats, dailyAlerts) {
834
845
  mlText = 'No model loaded';
835
846
  }
836
847
 
848
+ // --- LLM Detective stats ---
849
+ let llmText;
850
+ try {
851
+ const { isLlmEnabled, getStats: getLlmStats } = require('../ml/llm-detective.js');
852
+ if (isLlmEnabled()) {
853
+ const ls = getLlmStats();
854
+ llmText = `${ls.analyzed} analyzed (${ls.malicious} mal, ${ls.benign} ben, ${ls.uncertain} unc, ${ls.errors} err)`;
855
+ if ((stats.llmSuppressed || 0) > 0) {
856
+ llmText += ` | ${stats.llmSuppressed} suppressed`;
857
+ }
858
+ } else {
859
+ llmText = 'Disabled';
860
+ }
861
+ } catch {
862
+ llmText = 'Not loaded';
863
+ }
864
+
837
865
  // --- System health ---
838
866
  const uptimeSec = Math.floor(process.uptime());
839
867
  const uptimeH = Math.floor(uptimeSec / 3600);
@@ -863,6 +891,7 @@ function buildDailyReportEmbed(stats, dailyAlerts) {
863
891
  { name: 'Timeouts', value: timeoutText, inline: true },
864
892
  { name: 'vs Yesterday', value: trendsText, inline: false },
865
893
  { name: 'ML', value: mlText, inline: true },
894
+ { name: 'LLM Detective', value: llmText, inline: true },
866
895
  { name: 'Top Suspects', value: top3Text, inline: false },
867
896
  { name: 'System', value: healthText, inline: false }
868
897
  ],
@@ -907,6 +936,8 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
907
936
  avgScanTimeMs: stats.scanned > 0 ? Math.round(stats.totalTimeMs / stats.scanned) : 0,
908
937
  suspectByTier: { ...stats.suspectByTier },
909
938
  mlFiltered: stats.mlFiltered || 0,
939
+ llmAnalyzed: stats.llmAnalyzed || 0,
940
+ llmSuppressed: stats.llmSuppressed || 0,
910
941
  changesStreamPackages: stats.changesStreamPackages || 0,
911
942
  topSuspects: dailyAlerts.slice().sort((a, b) => b.findingsCount - a.findingsCount).slice(0, 10)
912
943
  });
@@ -942,6 +973,10 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
942
973
  stats.errorsByType.other = 0;
943
974
  stats.totalTimeMs = 0;
944
975
  stats.mlFiltered = 0;
976
+ stats.llmAnalyzed = 0;
977
+ stats.llmSuppressed = 0;
978
+ // Reset LLM detective internal stats
979
+ try { require('../ml/llm-detective.js').resetStats(); } catch {}
945
980
  stats.changesStreamPackages = 0;
946
981
  stats.rssFallbackCount = 0;
947
982
  dailyAlerts.length = 0;