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 +1 -1
- package/src/integrations/webhook.js +31 -0
- package/src/ml/llm-detective.js +582 -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 +43 -7
package/package.json
CHANGED
|
@@ -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
|
+
};
|
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,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;
|