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.
- package/README.md +10 -7
- package/package.json +3 -1
- package/src/integrations/registry-signals.js +216 -0
- package/src/pipeline/processor.js +190 -13
- package/src/response/playbooks.js +34 -0
- package/src/rules/confidence-tiers.js +187 -0
- package/src/rules/index.js +89 -2
- package/src/runtime/monitor-feed.js +241 -0
- package/src/runtime/serve.js +59 -2
- package/src/sandbox/compound-triggers.js +232 -0
- package/src/scanner/ast-detectors/handle-assignment-expression.js +7 -2
- package/src/scanner/ast.js +18 -0
- package/src/scanner/npm-registry.js +31 -1
- package/src/scanner/reachability.js +603 -1
- package/src/scanner/typosquat.js +6 -2
- package/src/scoring/delta-multiplier.js +294 -0
- package/src/scoring.js +387 -4
|
@@ -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
|
+
};
|
package/src/rules/index.js
CHANGED
|
@@ -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
|
|
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
|
+
};
|
package/src/runtime/serve.js
CHANGED
|
@@ -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;
|