muaddib-scanner 2.11.85 → 2.11.87
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/bin/muaddib.js
CHANGED
|
@@ -646,6 +646,19 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
646
646
|
console.log(` FP rate: ${globalFpRate}`);
|
|
647
647
|
console.log('');
|
|
648
648
|
process.exit(0);
|
|
649
|
+
} else if (command === 'fpr-live') {
|
|
650
|
+
const { runFprLive } = require('../src/commands/fpr-live.js');
|
|
651
|
+
const fprOpts = { json: jsonOutput };
|
|
652
|
+
for (let i = 0; i < options.length; i++) {
|
|
653
|
+
if (options[i] === '--since' && options[i + 1]) { fprOpts.since = options[i + 1]; i++; }
|
|
654
|
+
else if (options[i] === '--trend-days' && options[i + 1]) { fprOpts.trendDays = parseInt(options[i + 1], 10); i++; }
|
|
655
|
+
}
|
|
656
|
+
runFprLive(fprOpts).then(() => {
|
|
657
|
+
process.exit(0);
|
|
658
|
+
}).catch(err => {
|
|
659
|
+
console.error('[ERROR]', err.message);
|
|
660
|
+
process.exit(1);
|
|
661
|
+
});
|
|
649
662
|
} else if (command === 'evaluate') {
|
|
650
663
|
if (wantHelp) showHelp('evaluate');
|
|
651
664
|
const { evaluate } = require('../src/commands/evaluate.js');
|
package/package.json
CHANGED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// muaddib fpr-live — honest operational alert-rate / FPR report, computed entirely
|
|
4
|
+
// from data already on disk (no corpus, no re-download):
|
|
5
|
+
// - data/scan-stats.json : lifetime cumulative counters + per-day history
|
|
6
|
+
// - data/scan-ledger.jsonl : recent rolling window with per-package detail
|
|
7
|
+
// (outcome, score, firing-rule `types[]`, ecosystem)
|
|
8
|
+
//
|
|
9
|
+
// HONEST METRIC NOTE: `alertRate = alerted / scanned` is NOT the curated FPR (1.10%
|
|
10
|
+
// on famous packages with reputation suppression). On the live firehose, which is
|
|
11
|
+
// dominated by new / low-reputation packages, alertRate is the operational reality
|
|
12
|
+
// and — because confirmed malware is a vanishingly small fraction of alerts — it is
|
|
13
|
+
// a tight UPPER BOUND on the true FPR. Turning it into a precise FPR needs the
|
|
14
|
+
// alerts triaged into TP/FP (independent adjudication). We report it as what it is,
|
|
15
|
+
// never dressed up.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const readline = require('readline');
|
|
20
|
+
|
|
21
|
+
const { computeLedgerRollup, SCAN_LEDGER_FILE } = require('../monitor/state.js');
|
|
22
|
+
|
|
23
|
+
const SCAN_STATS_FILE = path.join(__dirname, '..', '..', 'data', 'scan-stats.json');
|
|
24
|
+
const OUT_FILE = path.join(__dirname, '..', '..', 'data', 'fpr-live.json');
|
|
25
|
+
|
|
26
|
+
function _pct(n, d) {
|
|
27
|
+
if (!d || d <= 0) return null;
|
|
28
|
+
return n / d;
|
|
29
|
+
}
|
|
30
|
+
function _fmtPct(r) {
|
|
31
|
+
return r === null || r === undefined ? 'N/A' : (r * 100).toFixed(2) + '%';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _loadScanStats() {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(SCAN_STATS_FILE, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Streaming pass over the ledger for the two dimensions computeLedgerRollup does not
|
|
43
|
+
// carry: score buckets and the firing-rule histogram. Bounded memory (readline, plus
|
|
44
|
+
// a rule map whose key-count is bounded by the rule catalogue ~260).
|
|
45
|
+
async function _ledgerDetail(ledgerFile) {
|
|
46
|
+
const buckets = { '20-29': 0, '30-49': 0, '50-74': 0, '75-100': 0 };
|
|
47
|
+
const ruleCounts = Object.create(null);
|
|
48
|
+
let alertsScored = 0;
|
|
49
|
+
let alertsUnscored = 0;
|
|
50
|
+
|
|
51
|
+
if (!fs.existsSync(ledgerFile)) {
|
|
52
|
+
return { buckets, topRules: [], alertsScored, alertsUnscored, available: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rl = readline.createInterface({
|
|
56
|
+
input: fs.createReadStream(ledgerFile, { encoding: 'utf8' }),
|
|
57
|
+
crlfDelay: Infinity
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
for await (const line of rl) {
|
|
61
|
+
if (!line) continue;
|
|
62
|
+
let e;
|
|
63
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
64
|
+
if (!e || e.outcome === 'dropped') continue;
|
|
65
|
+
// An "alert" is a suspect/confirmed outcome — the same numerator as alertRate.
|
|
66
|
+
if (e.outcome !== 'suspect' && e.outcome !== 'confirmed') continue;
|
|
67
|
+
|
|
68
|
+
const score = typeof e.score === 'number' ? e.score : null;
|
|
69
|
+
if (score === null) {
|
|
70
|
+
alertsUnscored++;
|
|
71
|
+
} else {
|
|
72
|
+
alertsScored++;
|
|
73
|
+
if (score >= 75) buckets['75-100']++;
|
|
74
|
+
else if (score >= 50) buckets['50-74']++;
|
|
75
|
+
else if (score >= 30) buckets['30-49']++;
|
|
76
|
+
else if (score >= 20) buckets['20-29']++;
|
|
77
|
+
// scores < 20 with a suspect outcome are anomalies; ignore for the FP buckets.
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(e.types)) {
|
|
80
|
+
for (const t of e.types) ruleCounts[t] = (ruleCounts[t] || 0) + 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const topRules = Object.entries(ruleCounts)
|
|
85
|
+
.sort((a, b) => b[1] - a[1])
|
|
86
|
+
.slice(0, 15)
|
|
87
|
+
.map(([type, count]) => ({ type, count }));
|
|
88
|
+
|
|
89
|
+
return { buckets, topRules, alertsScored, alertsUnscored, available: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Pure builder (no I/O side effects beyond reading) — returns the full report object.
|
|
93
|
+
async function buildFprLiveReport(opts = {}) {
|
|
94
|
+
const ledgerFile = opts.ledgerFile || SCAN_LEDGER_FILE;
|
|
95
|
+
const stats = _loadScanStats();
|
|
96
|
+
|
|
97
|
+
// 1. Lifetime + daily trend, from scan-stats.json cumulative counters.
|
|
98
|
+
let lifetime = null;
|
|
99
|
+
let trend = [];
|
|
100
|
+
if (stats && stats.stats) {
|
|
101
|
+
const s = stats.stats;
|
|
102
|
+
lifetime = {
|
|
103
|
+
total_scanned: s.total_scanned || 0,
|
|
104
|
+
suspect: s.suspect || 0,
|
|
105
|
+
confirmed_malicious: s.confirmed_malicious || 0,
|
|
106
|
+
// alertRate ≈ FPR upper bound: of everything scanned, the fraction flagged.
|
|
107
|
+
alertRate: _pct(s.suspect || 0, s.total_scanned || 0)
|
|
108
|
+
};
|
|
109
|
+
const days = Array.isArray(stats.daily) ? stats.daily : [];
|
|
110
|
+
const window = typeof opts.trendDays === 'number' ? opts.trendDays : 14;
|
|
111
|
+
trend = days.slice(-window).map(d => ({
|
|
112
|
+
date: d.date,
|
|
113
|
+
scanned: d.scanned || 0,
|
|
114
|
+
suspect: d.suspect || 0,
|
|
115
|
+
confirmed: d.confirmed || 0,
|
|
116
|
+
alertRate: _pct(d.suspect || 0, d.scanned || 0)
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. Recent rolling window detail, from the ledger.
|
|
121
|
+
const rollup = computeLedgerRollup(opts.since || null, { file: ledgerFile });
|
|
122
|
+
const detail = await _ledgerDetail(ledgerFile);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
generatedAt: rollup.generatedAt,
|
|
126
|
+
note: 'alertRate is the operational alert load (alerted/scanned). It is an UPPER BOUND on FPR — not the curated 1.10%. Precise FPR requires alert triage (TP/FP labelling).',
|
|
127
|
+
lifetime,
|
|
128
|
+
trend,
|
|
129
|
+
recentWindow: {
|
|
130
|
+
windowStart: rollup.windowStart,
|
|
131
|
+
windowEnd: rollup.windowEnd,
|
|
132
|
+
scanned: rollup.scanned,
|
|
133
|
+
dropped: rollup.dropped,
|
|
134
|
+
alerted: rollup.alerted,
|
|
135
|
+
alertRate: rollup.alertRate,
|
|
136
|
+
byEcosystem: rollup.byEcosystem,
|
|
137
|
+
scoreBuckets: detail.buckets,
|
|
138
|
+
alertsScored: detail.alertsScored,
|
|
139
|
+
alertsUnscored: detail.alertsUnscored,
|
|
140
|
+
topFiringRules: detail.topRules
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _printReport(r) {
|
|
146
|
+
console.log('\n MUAD\'DIB — Operational FPR / Alert-Rate (live)\n');
|
|
147
|
+
console.log(' ' + r.note + '\n');
|
|
148
|
+
|
|
149
|
+
if (r.lifetime) {
|
|
150
|
+
const l = r.lifetime;
|
|
151
|
+
console.log(' Lifetime (scan-stats.json cumulative)');
|
|
152
|
+
console.log(' ' + '-'.repeat(58));
|
|
153
|
+
console.log(` Total scanned: ${l.total_scanned.toLocaleString()}`);
|
|
154
|
+
console.log(` Flagged (suspect ≥20): ${l.suspect.toLocaleString()}`);
|
|
155
|
+
console.log(` Confirmed malware: ${l.confirmed_malicious.toLocaleString()}`);
|
|
156
|
+
console.log(` Alert rate (≈FPR↑): ${_fmtPct(l.alertRate)}`);
|
|
157
|
+
console.log('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (r.trend && r.trend.length) {
|
|
161
|
+
console.log(' Daily trend (alert rate)');
|
|
162
|
+
console.log(' ' + '-'.repeat(58));
|
|
163
|
+
console.log(' Date Scanned Suspect Confirmed AlertRate');
|
|
164
|
+
for (const d of r.trend) {
|
|
165
|
+
console.log(
|
|
166
|
+
` ${d.date} ${String(d.scanned).padStart(6)} ${String(d.suspect).padStart(6)} ` +
|
|
167
|
+
`${String(d.confirmed).padStart(8)} ${_fmtPct(d.alertRate).padStart(8)}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
console.log('');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const w = r.recentWindow;
|
|
174
|
+
console.log(' Recent window detail (scan-ledger.jsonl)');
|
|
175
|
+
console.log(' ' + '-'.repeat(58));
|
|
176
|
+
console.log(` Window: ${w.windowStart || 'n/a'} → ${w.windowEnd || 'n/a'}`);
|
|
177
|
+
console.log(` Scanned: ${w.scanned.toLocaleString()} (dropped: ${w.dropped.toLocaleString()})`);
|
|
178
|
+
console.log(` Alerted: ${w.alerted.toLocaleString()} Alert rate: ${_fmtPct(w.alertRate)}`);
|
|
179
|
+
console.log('');
|
|
180
|
+
console.log(' By ecosystem:');
|
|
181
|
+
for (const [eco, n] of Object.entries(w.byEcosystem)) {
|
|
182
|
+
const ar = _pct(n.alerted, n.scanned);
|
|
183
|
+
console.log(` ${eco.padEnd(8)} scanned=${String(n.scanned).padStart(6)} alerted=${String(n.alerted).padStart(6)} rate=${_fmtPct(ar)}`);
|
|
184
|
+
}
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log(' Alert score distribution:');
|
|
187
|
+
for (const [b, n] of Object.entries(w.scoreBuckets)) {
|
|
188
|
+
console.log(` ${b.padEnd(7)} ${String(n).padStart(6)}`);
|
|
189
|
+
}
|
|
190
|
+
if (w.alertsUnscored) console.log(` (unscored alerts: ${w.alertsUnscored})`);
|
|
191
|
+
console.log('');
|
|
192
|
+
console.log(' Top firing rules on alerts (the FP-load drivers):');
|
|
193
|
+
for (const r2 of w.topFiringRules) {
|
|
194
|
+
console.log(` ${String(r2.count).padStart(6)} ${r2.type}`);
|
|
195
|
+
}
|
|
196
|
+
console.log('');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function runFprLive(opts = {}) {
|
|
200
|
+
const report = await buildFprLiveReport(opts);
|
|
201
|
+
|
|
202
|
+
if (opts.json) {
|
|
203
|
+
console.log(JSON.stringify(report, null, 2));
|
|
204
|
+
} else {
|
|
205
|
+
_printReport(report);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Persist for trend tracking / future anomaly detection (best-effort).
|
|
209
|
+
try {
|
|
210
|
+
fs.writeFileSync(OUT_FILE, JSON.stringify(report, null, 2));
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (!opts.json) console.log(' [warn] could not persist fpr-live.json: ' + e.message);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return report;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { runFprLive, buildFprLiveReport };
|
package/src/monitor/classify.js
CHANGED
|
@@ -175,11 +175,29 @@ function isSuspectClassification(result) {
|
|
|
175
175
|
if (hasLifecycleWithIntent(result)) {
|
|
176
176
|
return { suspect: true, tier: '1a' };
|
|
177
177
|
}
|
|
178
|
+
// IOC / known-malicious matches (known_malicious_package/hash, pypi_malicious_package,
|
|
179
|
+
// shai_hulud_marker/backdoor) are definite malware → mandatory sandbox, unconditionally.
|
|
180
|
+
// Promotes them out of the (now corroboration-gated) tier-1b zone so the tightening
|
|
181
|
+
// below can never drop a confirmed IOC hit, regardless of score.
|
|
182
|
+
if (hasIOCMatch(result)) {
|
|
183
|
+
return { suspect: true, tier: '1a' };
|
|
184
|
+
}
|
|
178
185
|
|
|
179
|
-
// Tier 1b: HIGH/CRITICAL severity without HC type or TIER1_TYPES
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
186
|
+
// Tier 1b: HIGH/CRITICAL severity without HC type or TIER1_TYPES — the heuristic
|
|
187
|
+
// FP zone (a lone non-HC HIGH heuristic like compromised_email_domain /
|
|
188
|
+
// prototype_pollution / trusted_new_dependency flips a package to suspect even at
|
|
189
|
+
// score ~3). FPR audit 2026-06 (200-pkg blind adjudication, ~99% FP): a SINGLE
|
|
190
|
+
// non-HC HIGH finding made ~half of all "suspect" packages, almost all FP. It is
|
|
191
|
+
// no longer sufficient on its own — require corroboration: a real alert score
|
|
192
|
+
// (>=20), a compound, or >=2 DISTINCT HIGH/CRITICAL types. HC types / TIER1_TYPES /
|
|
193
|
+
// lifecycle+intent already returned tier 1a above, and Track R floors confirmed
|
|
194
|
+
// malice at 20, so detection is preserved; lone-heuristic packages fall through to
|
|
195
|
+
// the 2+-distinct-type tier 2/3 logic (and to CLEAN when they carry one finding).
|
|
196
|
+
const _hcSevere = result.threats.filter(t => t.severity === 'HIGH' || t.severity === 'CRITICAL');
|
|
197
|
+
const _highCritTypes = new Set(_hcSevere.map(t => t.type));
|
|
198
|
+
const _hasCompound = result.threats.some(t => t.compound === true);
|
|
199
|
+
const _score = (result.summary && typeof result.summary.riskScore === 'number') ? result.summary.riskScore : 0;
|
|
200
|
+
if (_hcSevere.length > 0 && (_score >= 20 || _hasCompound || _highCritTypes.size >= 2)) {
|
|
183
201
|
return { suspect: true, tier: '1b' };
|
|
184
202
|
}
|
|
185
203
|
|