muaddib-scanner 2.11.4 → 2.11.7

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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * FPR plan Chantier 6 - multi-tier confidence classification.
3
+ *
4
+ * Socket and Endor distinguish "AI-detected potential" from "Known Malware"
5
+ * (post-revue) ; muad-dib has only severity (CRITICAL/HIGH/MEDIUM/LOW), which
6
+ * collapses two very different signals - a deterministic IOC match and a
7
+ * heuristic obfuscation guess - into the same bucket. This module adds a
8
+ * second dimension : confidenceTier in {verified, high, medium, low}.
9
+ *
10
+ * Tier semantics :
11
+ *
12
+ * - verified : the threat is observed against a known-malicious artifact
13
+ * (IOC match, known hash, known package, Shai-Hulud marker). Zero
14
+ * residual doubt ; never decays through any FP-reduction path.
15
+ *
16
+ * - high : compound or co-occurrence evidence, intent-graph proven flows,
17
+ * HIGH_CONFIDENCE_MALICE_TYPES. Multi-signal correlation that no benign
18
+ * pattern reproduces ; manual review almost always confirms.
19
+ *
20
+ * - medium : a single deterministic rule fires (eval, dangerous_exec,
21
+ * dynamic_require). Sometimes legitimate (build tools, plugin loaders)
22
+ * so the FP rate is non-trivial but the signal is exact when triggered.
23
+ *
24
+ * - low : heuristic (possible_obfuscation, high_entropy_string), count-
25
+ * based (>N occurrences of a type), or a single sensitive-string match.
26
+ * Useful for triage but should not drive automated alerts.
27
+ *
28
+ * Output filtering : the CLI shows verified+high by default. JSON / SARIF
29
+ * always include all tiers (no information loss). The evaluate.js corpus
30
+ * reports two FPR numbers : "FPR all" (status quo) and "FPR perceived"
31
+ * (verified + high only) - the latter is the metric Socket / Endor publish.
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ const { HIGH_CONFIDENCE_MALICE_TYPES } = require('../monitor/classify.js');
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Type-to-tier mapping. Each set is mutually exclusive ; a threat type that
40
+ // appears in VERIFIED never appears in HIGH, etc. Order of evaluation is
41
+ // VERIFIED > HIGH > LOW > (default by severity).
42
+ // ---------------------------------------------------------------------------
43
+
44
+ // Deterministic match against a known artifact - never decays.
45
+ const VERIFIED_TYPES = new Set([
46
+ 'ioc_match',
47
+ 'ioc_string_match',
48
+ 'known_malicious_hash',
49
+ 'known_malicious_package',
50
+ 'pypi_malicious_package',
51
+ 'shai_hulud_marker',
52
+ 'dependency_ioc_match'
53
+ ]);
54
+
55
+ // Multi-signal compound evidence + proven flows. These are unambiguous when
56
+ // they fire because each requires multiple correlated signals or a static
57
+ // dataflow proof. The set extends HIGH_CONFIDENCE_MALICE_TYPES with intent
58
+ // graph results and the scoring compound types.
59
+ const HIGH_TIER_EXTRA = new Set([
60
+ // Intent graph (intra-file dataflow proven)
61
+ 'intent_credential_exfil',
62
+ 'intent_command_exfil',
63
+ // Scoring compounds (require co-occurrence)
64
+ 'crypto_staged_payload',
65
+ 'lifecycle_typosquat',
66
+ 'lifecycle_inline_exec',
67
+ 'lifecycle_remote_require',
68
+ 'lifecycle_dataflow',
69
+ 'lifecycle_dangerous_exec',
70
+ 'obfuscated_lifecycle_env',
71
+ 'lifecycle_file_exec',
72
+ // Cross-file proven dataflow
73
+ 'cross_file_dataflow',
74
+ // Single-fire critical types from classify.js
75
+ 'reverse_shell',
76
+ 'fetch_decrypt_exec',
77
+ 'download_exec_binary',
78
+ 'staged_eval_decode',
79
+ 'function_constructor_require',
80
+ 'self_destruct_eval',
81
+ 'curl_env_exfil',
82
+ 'function_runtime_args',
83
+ 'external_tarball_dep',
84
+ // Worm propagation patterns
85
+ 'node_modules_write',
86
+ 'npm_publish_worm',
87
+ // Sandbox correlated signals
88
+ 'sandbox_network_after_sensitive_read',
89
+ 'sandbox_known_exfil_domain',
90
+ 'canary_exfiltration',
91
+ // Known-bad lifecycle anomalies
92
+ 'lifecycle_added_critical',
93
+ 'systemd_persistence',
94
+ 'npm_token_steal',
95
+ 'root_filesystem_wipe',
96
+ 'proc_mem_scan',
97
+ 'trusted_new_unknown_dependency',
98
+ 'newsletter_auto_follow',
99
+ // Anti-forensic signatures
100
+ 'anti_forensic_xor_autodelete',
101
+ 'detached_credential_exfil',
102
+ // Workflow injection
103
+ 'workflow_write'
104
+ ]);
105
+
106
+ // Heuristic / count-based / weak-signal types. Always low tier regardless
107
+ // of severity to honor the plan's metric definition. If the same threat is
108
+ // also in VERIFIED or HIGH it does not reach this set - the order of checks
109
+ // in getConfidenceTier prevents that.
110
+ const LOW_TIER_TYPES = new Set([
111
+ 'possible_obfuscation',
112
+ 'sensitive_string',
113
+ 'high_entropy_string',
114
+ 'large_string_array',
115
+ 'string_mutation_obfuscation',
116
+ 'js_obfuscation_pattern'
117
+ ]);
118
+
119
+ /**
120
+ * Resolve the confidence tier of a threat. Pure function : takes only the
121
+ * fields we care about so it can be unit-tested without a full threat shape.
122
+ *
123
+ * - threatType : the threat.type string (required).
124
+ * - severity : 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'. Used as the fallback
125
+ * signal when the type is not classified explicitly.
126
+ * - flags : optional { isCountDowngrade, isUnreachable } extra hints.
127
+ * isCountDowngrade=true forces the threat to LOW even if its
128
+ * type would otherwise be MEDIUM (count thresholds typically
129
+ * signal benign frameworks). isUnreachable=true forces LOW
130
+ * (dead-code findings carry no execution risk).
131
+ */
132
+ function getConfidenceTier(threatType, severity, flags) {
133
+ if (typeof threatType !== 'string' || threatType.length === 0) return 'low';
134
+ if (VERIFIED_TYPES.has(threatType)) return 'verified';
135
+ if (HIGH_TIER_EXTRA.has(threatType)) return 'high';
136
+ if (HIGH_CONFIDENCE_MALICE_TYPES.has(threatType)) return 'high';
137
+ if (LOW_TIER_TYPES.has(threatType)) return 'low';
138
+
139
+ if (flags) {
140
+ if (flags.isCountDowngrade) return 'low';
141
+ if (flags.isUnreachable) return 'low';
142
+ }
143
+
144
+ // Fall back to severity. CRITICAL/HIGH single-rule heuristics get medium ;
145
+ // MEDIUM/LOW heuristics get low.
146
+ if (severity === 'CRITICAL' || severity === 'HIGH') return 'medium';
147
+ return 'low';
148
+ }
149
+
150
+ /**
151
+ * Decorate a threats array in place with t.confidenceTier. Idempotent :
152
+ * threats already carrying a tier are not overwritten so callers can fix up
153
+ * specific findings before this is invoked.
154
+ */
155
+ function annotateConfidenceTiers(threats) {
156
+ if (!Array.isArray(threats)) return 0;
157
+ let annotated = 0;
158
+ for (const t of threats) {
159
+ if (!t || typeof t !== 'object') continue;
160
+ if (typeof t.confidenceTier === 'string' && t.confidenceTier.length > 0) continue;
161
+ const isCountDowngrade = Array.isArray(t.reductions) &&
162
+ t.reductions.some(r => r && /count|threshold/.test(r.rule || ''));
163
+ const isUnreachable = t.unreachable === true || !!t.unreachableFunction;
164
+ t.confidenceTier = getConfidenceTier(t.type, t.severity, {
165
+ isCountDowngrade,
166
+ isUnreachable
167
+ });
168
+ annotated++;
169
+ }
170
+ return annotated;
171
+ }
172
+
173
+ const TIER_ORDER = { verified: 4, high: 3, medium: 2, low: 1 };
174
+
175
+ function tierAtLeast(tier, minTier) {
176
+ return (TIER_ORDER[tier] || 0) >= (TIER_ORDER[minTier] || 0);
177
+ }
178
+
179
+ module.exports = {
180
+ getConfidenceTier,
181
+ annotateConfidenceTiers,
182
+ tierAtLeast,
183
+ VERIFIED_TYPES,
184
+ HIGH_TIER_EXTRA,
185
+ LOW_TIER_TYPES,
186
+ TIER_ORDER
187
+ };
@@ -261,6 +261,18 @@ const RULES = {
261
261
  ],
262
262
  mitre: 'T1195.002'
263
263
  },
264
+ staged_remote_loader: {
265
+ id: 'MUADDIB-COMPOUND-012',
266
+ name: 'Staged Remote Loader (Function.constructor + shadowed process)',
267
+ severity: 'CRITICAL',
268
+ confidence: 'high',
269
+ description: 'Compound: new Function.constructor("require", body) co-occurs with `const process = {...}` shadowing in the same file. Pattern observed in the chai-* / poxios-chain / express-guardrail / justenv campaign (semaine 2026-05-04 a 2026-05-09): fork de pino avec caller.js qui decode une URL base64 (jsonkeeper.com), fetch le payload distant via axios, et l\'execute via Function.constructor en passant require comme parametre. Le tarball npm ne contient aucun code malveillant statique — la charge utile est externalisee sur un pastebin.',
270
+ references: [
271
+ 'project_detection_gap_chai_staged_loader memory entry',
272
+ 'data/security-review-2026-05-04-10.md'
273
+ ],
274
+ mitre: 'T1059.007'
275
+ },
264
276
  lifecycle_script_dependency: {
265
277
  id: 'MUADDIB-DEP-004',
266
278
  name: 'Lifecycle Script in Dependency',
@@ -1117,6 +1129,81 @@ const RULES = {
1117
1129
  mitre: 'T1041'
1118
1130
  },
1119
1131
 
1132
+ // Honey-trap and 2026 supply chain runtime signals
1133
+ sandbox_honey_read: {
1134
+ id: 'MUADDIB-SANDBOX-017',
1135
+ name: 'Sandbox: Honeypot Decoy File Read',
1136
+ severity: 'CRITICAL',
1137
+ confidence: 'high',
1138
+ description: 'Le package lit un fichier decoy de credentials plante par la sandbox (.npmrc-decoy, .ssh/id_rsa-decoy, wallet decoy, etc.). Aucun outil legitime ne lit ces chemins. Capture les zero-days qui scannent aveuglement les chemins de credentials connus.',
1139
+ references: [
1140
+ 'https://attack.mitre.org/techniques/T1552/001/',
1141
+ 'https://blog.phylum.io/shai-hulud-npm-worm'
1142
+ ],
1143
+ mitre: 'T1552.001'
1144
+ },
1145
+ sandbox_credential_target_read: {
1146
+ id: 'MUADDIB-SANDBOX-018',
1147
+ name: 'Sandbox: Credential Target File Read',
1148
+ severity: 'HIGH',
1149
+ confidence: 'high',
1150
+ description: 'Le package lit un fichier de credentials cible des malwares 2026 (cloud creds, wallets, browser data, GPG, kubernetes config). Pattern PhantomRaven, Shai-Hulud.',
1151
+ references: [
1152
+ 'https://attack.mitre.org/techniques/T1552/001/',
1153
+ 'https://attack.mitre.org/techniques/T1555/'
1154
+ ],
1155
+ mitre: 'T1555'
1156
+ },
1157
+ sandbox_persistence_write: {
1158
+ id: 'MUADDIB-SANDBOX-019',
1159
+ name: 'Sandbox: Persistence File Write',
1160
+ severity: 'CRITICAL',
1161
+ confidence: 'high',
1162
+ description: 'Le package ecrit dans un emplacement de persistance (.bashrc, .zshrc, autostart, cron, systemd user, LaunchAgents, registry Run keys). Aucun cas legitime en npm install.',
1163
+ references: [
1164
+ 'https://attack.mitre.org/techniques/T1547/',
1165
+ 'https://attack.mitre.org/techniques/T1546/004/'
1166
+ ],
1167
+ mitre: 'T1547'
1168
+ },
1169
+ sandbox_execve_chain_depth: {
1170
+ id: 'MUADDIB-SANDBOX-020',
1171
+ name: 'Sandbox: Suspicious Execve Chain Depth',
1172
+ severity: 'HIGH',
1173
+ confidence: 'medium',
1174
+ description: 'Chaine de processus depuis npm install au-dela de la profondeur attendue (npm install -> script -> binaire externe). Pattern Shai-Hulud preinstall worm avec curl/wget/bash final.',
1175
+ references: [
1176
+ 'https://unit42.paloaltonetworks.com/npm-supply-chain-attack/',
1177
+ 'https://attack.mitre.org/techniques/T1059/'
1178
+ ],
1179
+ mitre: 'T1059'
1180
+ },
1181
+ sandbox_npm_self_invoke: {
1182
+ id: 'MUADDIB-SANDBOX-021',
1183
+ name: 'Sandbox: npm CLI Self-Invocation',
1184
+ severity: 'CRITICAL',
1185
+ confidence: 'high',
1186
+ description: 'Le package invoque npm publish/deprecate/owner/token/access (ou yarn publish) depuis l\'arborescence npm install. Pattern CanisterWorm self-propagation. Aucun cas legitime.',
1187
+ references: [
1188
+ 'https://www.stepsecurity.io/blog/canisterworm-how-a-self-propagating-npm-worm-is-spreading-backdoors-across-the-ecosystem/',
1189
+ 'https://attack.mitre.org/techniques/T1195.002/'
1190
+ ],
1191
+ mitre: 'T1195.002'
1192
+ },
1193
+ sandbox_runtime_deobfuscation_executed: {
1194
+ id: 'MUADDIB-SANDBOX-022',
1195
+ name: 'Sandbox: Runtime Deobfuscation Executed',
1196
+ severity: 'HIGH',
1197
+ confidence: 'high',
1198
+ description: 'new Function() ou eval() execute avec un body de plus de 500 octets, derive d\'une string source obfusquee (high entropy ou taille >1KB). Pattern Axios 2026 OrDeR_7077: XOR + base64 decoded at runtime then executed.',
1199
+ references: [
1200
+ 'https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/',
1201
+ 'https://attack.mitre.org/techniques/T1027/',
1202
+ 'https://attack.mitre.org/techniques/T1059.007/'
1203
+ ],
1204
+ mitre: 'T1027'
1205
+ },
1206
+
1120
1207
  // Entropy detections
1121
1208
  high_entropy_string: {
1122
1209
  id: 'MUADDIB-ENTROPY-001',
@@ -1723,7 +1810,7 @@ const RULES = {
1723
1810
  },
1724
1811
  dangerous_constructor: {
1725
1812
  id: 'MUADDIB-AST-057',
1726
- name: 'Prototype Chain Constructor Access',
1813
+ name: 'AsyncFunction/GeneratorFunction Constructor via Prototype Chain',
1727
1814
  severity: 'CRITICAL',
1728
1815
  confidence: 'high',
1729
1816
  description: 'Acces au constructeur AsyncFunction ou GeneratorFunction via Object.getPrototypeOf(). Technique d\'evasion permettant d\'executer du code arbitraire sans reference directe a eval() ou Function().',
@@ -2237,7 +2324,7 @@ const RULES = {
2237
2324
  },
2238
2325
  prototype_chain_constructor: {
2239
2326
  id: 'MUADDIB-AST-081',
2240
- name: 'Prototype Chain Constructor Access',
2327
+ name: 'Prototype Chain Constructor Access via Variable',
2241
2328
  severity: 'CRITICAL',
2242
2329
  confidence: 'high',
2243
2330
  description: 'Object.getPrototypeOf(variable).constructor extrait dans une variable — traversee de la chaine de prototypes pour atteindre le constructeur Function et executer du code arbitraire.',
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * monitor-feed.js — Aggregator for /monitor HTTP endpoints.
5
+ *
6
+ * Reads the same persistent files the monitor writes to data/ and exposes
7
+ * three views consumed by muad-api -> muad-front:
8
+ * - buildMonitorDaily() today's stats from daily-stats.json
9
+ * - buildMonitorWindow(range) per-day rollup from scan-stats.json
10
+ * - buildMonitorAll() all-time totals + detection breakdown
11
+ *
12
+ * Defensive: every read is wrapped — missing files yield zeros, never throws.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const {
19
+ DAILY_STATS_FILE,
20
+ SCAN_STATS_FILE,
21
+ STATE_FILE,
22
+ LAST_DAILY_REPORT_FILE,
23
+ loadScanStats,
24
+ loadStateRaw,
25
+ loadLastDailyReportDate,
26
+ getDetectionStats,
27
+ getParisDateString
28
+ } = require('../monitor/state.js');
29
+
30
+ const pkg = require('../../package.json');
31
+
32
+ const SUPPORTED_RANGES = new Set(['7d', '30d', 'all']);
33
+ const RANGE_DAYS = { '7d': 7, '30d': 30 };
34
+
35
+ function safeReadJson(file) {
36
+ try {
37
+ if (!fs.existsSync(file)) return null;
38
+ const raw = fs.readFileSync(file, 'utf8');
39
+ return JSON.parse(raw);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function emptyToday(date) {
46
+ return {
47
+ date,
48
+ scanned: 0,
49
+ clean: 0,
50
+ suspect: 0,
51
+ suspectByTier: { t1: 0, t1a: 0, t1b: 0, t2: 0, t3: 0 },
52
+ errors: 0,
53
+ errorsByType: { too_large: 0, tar_failed: 0, http_error: 0, timeout: 0, static_timeout: 0, other: 0 },
54
+ totalTimeMs: 0,
55
+ mlFiltered: 0,
56
+ llmAnalyzed: 0,
57
+ llmSuppressed: 0,
58
+ changesStreamPackages: 0
59
+ };
60
+ }
61
+
62
+ function readToday() {
63
+ const date = getParisDateString();
64
+ const data = safeReadJson(DAILY_STATS_FILE);
65
+ if (!data || typeof data.scanned !== 'number') return emptyToday(date);
66
+ return {
67
+ date,
68
+ scanned: data.scanned || 0,
69
+ clean: data.clean || 0,
70
+ suspect: data.suspect || 0,
71
+ suspectByTier: {
72
+ t1: (data.suspectByTier && data.suspectByTier.t1) || 0,
73
+ t1a: (data.suspectByTier && data.suspectByTier.t1a) || 0,
74
+ t1b: (data.suspectByTier && data.suspectByTier.t1b) || 0,
75
+ t2: (data.suspectByTier && data.suspectByTier.t2) || 0,
76
+ t3: (data.suspectByTier && data.suspectByTier.t3) || 0
77
+ },
78
+ errors: data.errors || 0,
79
+ errorsByType: {
80
+ too_large: (data.errorsByType && data.errorsByType.too_large) || 0,
81
+ tar_failed: (data.errorsByType && data.errorsByType.tar_failed) || 0,
82
+ http_error: (data.errorsByType && data.errorsByType.http_error) || 0,
83
+ timeout: (data.errorsByType && data.errorsByType.timeout) || 0,
84
+ static_timeout: (data.errorsByType && data.errorsByType.static_timeout) || 0,
85
+ other: (data.errorsByType && data.errorsByType.other) || 0
86
+ },
87
+ totalTimeMs: data.totalTimeMs || 0,
88
+ mlFiltered: data.mlFiltered || 0,
89
+ llmAnalyzed: data.llmAnalyzed || 0,
90
+ llmSuppressed: data.llmSuppressed || 0,
91
+ changesStreamPackages: data.changesStreamPackages || 0
92
+ };
93
+ }
94
+
95
+ function readLastReportAt() {
96
+ const fromFile = safeReadJson(LAST_DAILY_REPORT_FILE);
97
+ if (fromFile && typeof fromFile.lastReportDate === 'string') return fromFile.lastReportDate;
98
+ const date = loadLastDailyReportDate();
99
+ return date || null;
100
+ }
101
+
102
+ function readMonitorState() {
103
+ try {
104
+ return loadStateRaw() || {};
105
+ } catch {
106
+ return {};
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Build the /monitor/daily payload.
112
+ */
113
+ function buildMonitorDaily() {
114
+ const today = readToday();
115
+ const lastReportAt = readLastReportAt();
116
+ const monitorState = readMonitorState();
117
+
118
+ return {
119
+ generated_at: new Date().toISOString(),
120
+ engineVersion: pkg.version,
121
+ today,
122
+ lastReportAt,
123
+ monitor: {
124
+ npmLastPackage: monitorState.npmLastPackage || null,
125
+ pypiLastPackage: monitorState.pypiLastPackage || null,
126
+ lastDailyReportDate: monitorState.lastDailyReportDate || null
127
+ }
128
+ };
129
+ }
130
+
131
+ function emptyDayEntry(date) {
132
+ return {
133
+ date,
134
+ scanned: 0,
135
+ clean: 0,
136
+ suspect: 0,
137
+ false_positive: 0,
138
+ confirmed: 0,
139
+ sandbox_inconclusive: 0,
140
+ fp_rate: 0
141
+ };
142
+ }
143
+
144
+ function aggregateDays(days) {
145
+ const totals = { scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed: 0, sandbox_inconclusive: 0 };
146
+ let fpRateSum = 0;
147
+ let fpRateCount = 0;
148
+ for (const d of days) {
149
+ totals.scanned += d.scanned || 0;
150
+ totals.clean += d.clean || 0;
151
+ totals.suspect += d.suspect || 0;
152
+ totals.false_positive += d.false_positive || 0;
153
+ totals.confirmed += d.confirmed || 0;
154
+ totals.sandbox_inconclusive += d.sandbox_inconclusive || 0;
155
+ if (typeof d.fp_rate === 'number' && d.fp_rate >= 0) {
156
+ fpRateSum += d.fp_rate;
157
+ fpRateCount++;
158
+ }
159
+ }
160
+ const fp_rate_avg = fpRateCount > 0 ? fpRateSum / fpRateCount : 0;
161
+ return { ...totals, fp_rate_avg: Math.round(fp_rate_avg * 1000) / 1000 };
162
+ }
163
+
164
+ /**
165
+ * Build the /monitor/window payload for a given range ('7d' | '30d').
166
+ */
167
+ function buildMonitorWindow(range) {
168
+ if (!SUPPORTED_RANGES.has(range) || range === 'all') {
169
+ throw new Error(`Unsupported range: ${range}. Use 7d or 30d.`);
170
+ }
171
+ const days = RANGE_DAYS[range];
172
+ const data = loadScanStats();
173
+ const allDaily = Array.isArray(data.daily) ? data.daily : [];
174
+
175
+ const today = getParisDateString();
176
+ const todayMs = Date.parse(`${today}T00:00:00Z`);
177
+ const cutoffMs = todayMs - (days - 1) * 24 * 60 * 60 * 1000;
178
+
179
+ const inRange = allDaily.filter(d => {
180
+ if (!d || typeof d.date !== 'string') return false;
181
+ const ms = Date.parse(`${d.date}T00:00:00Z`);
182
+ return Number.isFinite(ms) && ms >= cutoffMs && ms <= todayMs;
183
+ });
184
+
185
+ const dateIndex = new Map(inRange.map(d => [d.date, d]));
186
+ const byDay = [];
187
+ for (let i = days - 1; i >= 0; i--) {
188
+ const ms = todayMs - i * 24 * 60 * 60 * 1000;
189
+ const dateStr = new Date(ms).toISOString().slice(0, 10);
190
+ byDay.push(dateIndex.get(dateStr) || emptyDayEntry(dateStr));
191
+ }
192
+
193
+ return {
194
+ generated_at: new Date().toISOString(),
195
+ engineVersion: pkg.version,
196
+ range,
197
+ from: byDay[0].date,
198
+ to: byDay[byDay.length - 1].date,
199
+ totals: aggregateDays(byDay),
200
+ byDay
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Build the /monitor/stats payload (all-time totals + detection breakdown).
206
+ */
207
+ function buildMonitorAll() {
208
+ const data = loadScanStats();
209
+ const stats = data.stats || {};
210
+ let detection = { total: 0, bySeverity: {}, byEcosystem: {}, leadTime: null };
211
+ try {
212
+ detection = getDetectionStats();
213
+ } catch {
214
+ // keep defaults
215
+ }
216
+ return {
217
+ generated_at: new Date().toISOString(),
218
+ engineVersion: pkg.version,
219
+ allTime: {
220
+ total_scanned: stats.total_scanned || 0,
221
+ clean: stats.clean || 0,
222
+ suspect: stats.suspect || 0,
223
+ false_positive: stats.false_positive || 0,
224
+ confirmed_malicious: stats.confirmed_malicious || 0,
225
+ sandbox_inconclusive: stats.sandbox_inconclusive || 0,
226
+ sandbox_unconfirmed: stats.sandbox_unconfirmed || 0
227
+ },
228
+ detectionStats: detection
229
+ };
230
+ }
231
+
232
+ module.exports = {
233
+ buildMonitorDaily,
234
+ buildMonitorWindow,
235
+ buildMonitorAll,
236
+ SUPPORTED_RANGES,
237
+ // exported for tests
238
+ _safeReadJson: safeReadJson,
239
+ _readToday: readToday,
240
+ _aggregateDays: aggregateDays
241
+ };
@@ -3,6 +3,40 @@
3
3
  const http = require('http');
4
4
  const url = require('url');
5
5
  const { getFeed } = require('../threat-feed.js');
6
+ // Monitor-feed routes : optional out-of-tree dependency. Lazy-load so the
7
+ // /feed and /health routes still work when the file is absent. Missing-file
8
+ // case returns 503 on /monitor/* routes instead of crashing module load.
9
+ let _monitorFeedCache = null;
10
+ function _loadMonitorFeed() {
11
+ if (_monitorFeedCache !== null) return _monitorFeedCache;
12
+ try {
13
+ _monitorFeedCache = require('./monitor-feed.js');
14
+ } catch {
15
+ _monitorFeedCache = {
16
+ buildMonitorDaily: null,
17
+ buildMonitorWindow: null,
18
+ buildMonitorAll: null,
19
+ SUPPORTED_RANGES: new Set(['7d', '30d'])
20
+ };
21
+ }
22
+ return _monitorFeedCache;
23
+ }
24
+ function buildMonitorDaily() {
25
+ const m = _loadMonitorFeed();
26
+ if (!m.buildMonitorDaily) throw new Error('monitor-feed module not installed');
27
+ return m.buildMonitorDaily();
28
+ }
29
+ function buildMonitorWindow(range) {
30
+ const m = _loadMonitorFeed();
31
+ if (!m.buildMonitorWindow) throw new Error('monitor-feed module not installed');
32
+ return m.buildMonitorWindow(range);
33
+ }
34
+ function buildMonitorAll() {
35
+ const m = _loadMonitorFeed();
36
+ if (!m.buildMonitorAll) throw new Error('monitor-feed module not installed');
37
+ return m.buildMonitorAll();
38
+ }
39
+ const SUPPORTED_RANGES = _loadMonitorFeed().SUPPORTED_RANGES;
6
40
  const pkg = require('../../package.json');
7
41
 
8
42
  const SECURITY_HEADERS = {
@@ -108,16 +142,39 @@ function startServer(options = {}) {
108
142
 
109
143
  const result = getFeed(feedOptions);
110
144
  sendJson(res, 200, result);
145
+ } else if (pathname === '/monitor/daily') {
146
+ try {
147
+ sendJson(res, 200, buildMonitorDaily());
148
+ } catch (err) {
149
+ sendJson(res, 500, { error: 'monitor_daily_failed', message: err.message });
150
+ }
151
+ } else if (pathname === '/monitor/window') {
152
+ const range = (parsed.query && parsed.query.range) ? String(parsed.query.range) : '7d';
153
+ if (!SUPPORTED_RANGES.has(range) || range === 'all') {
154
+ sendJson(res, 400, { error: 'invalid_range', message: 'range must be one of: 7d, 30d' });
155
+ return;
156
+ }
157
+ try {
158
+ sendJson(res, 200, buildMonitorWindow(range));
159
+ } catch (err) {
160
+ sendJson(res, 500, { error: 'monitor_window_failed', message: err.message });
161
+ }
162
+ } else if (pathname === '/monitor/stats') {
163
+ try {
164
+ sendJson(res, 200, buildMonitorAll());
165
+ } catch (err) {
166
+ sendJson(res, 500, { error: 'monitor_stats_failed', message: err.message });
167
+ }
111
168
  } else if (pathname === '/health') {
112
169
  sendJson(res, 200, { status: 'ok', version: pkg.version });
113
170
  } else {
114
- sendJson(res, 404, { error: 'Not found. Available: GET /feed, GET /health' });
171
+ sendJson(res, 404, { error: 'Not found. Available: GET /feed, GET /monitor/daily, GET /monitor/window?range=7d|30d, GET /monitor/stats, GET /health' });
115
172
  }
116
173
  });
117
174
 
118
175
  server.listen(port, '127.0.0.1', () => {
119
176
  console.log(`[SERVE] Threat feed server listening on http://127.0.0.1:${port}`);
120
- console.log(`[SERVE] Endpoints: GET /feed, GET /health`);
177
+ console.log(`[SERVE] Endpoints: GET /feed, GET /monitor/daily, GET /monitor/window?range=7d|30d, GET /monitor/stats, GET /health`);
121
178
  });
122
179
 
123
180
  return server;