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 +1 -1
- package/src/integrations/webhook.js +22 -0
- package/src/ml/llm-detective.js +538 -0
- package/src/monitor/classify.js +15 -0
- package/src/monitor/daemon.js +11 -1
- package/src/monitor/queue.js +35 -2
- package/src/monitor/state.js +4 -0
- package/src/monitor/webhook.js +42 -7
package/package.json
CHANGED
|
@@ -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
|
+
};
|
package/src/monitor/classify.js
CHANGED
|
@@ -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,
|
package/src/monitor/daemon.js
CHANGED
|
@@ -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');
|
package/src/monitor/queue.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/monitor/state.js
CHANGED
|
@@ -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));
|
package/src/monitor/webhook.js
CHANGED
|
@@ -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;
|