muaddib-scanner 2.11.4 → 2.11.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.4",
3
+ "version": "2.11.6",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -9,6 +9,8 @@
9
9
  "scripts": {
10
10
  "test": "node tests/run-tests.js",
11
11
  "test:integration": "node tests/run-tests-integration.js",
12
+ "test:regression-check": "node scripts/regression-check.js",
13
+ "fp-clusters": "node scripts/analyze-fp-clusters.js",
12
14
  "scan": "node bin/muaddib.js scan .",
13
15
  "update": "node bin/muaddib.js update",
14
16
  "lint": "eslint src bin --ext .js",
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Advanced npm registry signals for the FPR plan, Chantier 4.
3
+ *
4
+ * Computes four categorical signals on top of the basic metadata bundle :
5
+ *
6
+ * - maintainer_change_recent : a maintainer was added or replaced in the
7
+ * last 30 days (boost ; matches Shai-Hulud /
8
+ * Axios 2026 takeover patterns).
9
+ * - maintainer_change_within_days : days since the last maintainer change,
10
+ * or null if not detected.
11
+ * - publish_cadence_anomaly : the latest inter-publish gap is more than
12
+ * 3 sigma off the historical cadence (boost).
13
+ * - stable_ownership_2y : the latest maintainer set has been the same
14
+ * for at least 2 years and the package has
15
+ * > 100 versions (suppression douce).
16
+ *
17
+ * These are intended to be consumed by `_factorFromMetadata` in src/scoring.js.
18
+ * The first three are *boosts* (used as a safety net so suppressions in
19
+ * Chantier 5 do not mask a recent account takeover). The fourth is the only
20
+ * structural suppression and only fires on packages with substantial publish
21
+ * history.
22
+ *
23
+ * Pure functions on the npm registry packument shape ; no network IO. Caller
24
+ * passes the same `meta` object as `npm-registry.getPackageMetadata` already
25
+ * fetches, so we never double-fetch.
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const MILLIS_PER_DAY = 24 * 60 * 60 * 1000;
31
+ const RECENT_CHANGE_DAYS = 30;
32
+ const STABLE_OWNERSHIP_DAYS = 2 * 365;
33
+ const STABLE_OWNERSHIP_MIN_VERSIONS = 100;
34
+ const CADENCE_MIN_VERSIONS = 6;
35
+ const CADENCE_SIGMA = 3;
36
+
37
+ function _daysBetween(a, b) {
38
+ if (!(a instanceof Date) || !(b instanceof Date)) return null;
39
+ return Math.floor((a.getTime() - b.getTime()) / MILLIS_PER_DAY);
40
+ }
41
+
42
+ function _toDate(value) {
43
+ if (!value) return null;
44
+ const d = new Date(value);
45
+ return isNaN(d.getTime()) ? null : d;
46
+ }
47
+
48
+ /**
49
+ * Extract maintainer names for a version object.
50
+ * Returns a sorted lowercased array (set semantics) so two versions with the
51
+ * same maintainers compared equal regardless of declaration order.
52
+ */
53
+ function _maintainersFor(versionData) {
54
+ if (!versionData) return [];
55
+ const list = Array.isArray(versionData.maintainers) ? versionData.maintainers : [];
56
+ const names = list
57
+ .map(m => (m && typeof m.name === 'string') ? m.name.toLowerCase().trim() : null)
58
+ .filter(Boolean);
59
+ names.sort();
60
+ return names;
61
+ }
62
+
63
+ function _equalMaintainerSets(a, b) {
64
+ if (a.length !== b.length) return false;
65
+ for (let i = 0; i < a.length; i++) {
66
+ if (a[i] !== b[i]) return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Order all version entries by publish time descending.
73
+ * Returns array of { version, time: Date, maintainers: string[] }.
74
+ */
75
+ function _orderedVersions(meta) {
76
+ if (!meta || !meta.versions || !meta.time) return [];
77
+ const out = [];
78
+ for (const [version, versionData] of Object.entries(meta.versions)) {
79
+ const t = _toDate(meta.time[version]);
80
+ if (!t) continue;
81
+ out.push({ version, time: t, maintainers: _maintainersFor(versionData) });
82
+ }
83
+ out.sort((a, b) => b.time.getTime() - a.time.getTime());
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Detects whether a maintainer set changed (added or removed names) within
89
+ * `windowDays` of the latest publish.
90
+ *
91
+ * Returns { changed, daysSinceChange } - daysSinceChange is null when no
92
+ * change is detected within the window or when there is insufficient history.
93
+ */
94
+ function detectRecentMaintainerChange(meta, windowDays = RECENT_CHANGE_DAYS) {
95
+ const ordered = _orderedVersions(meta);
96
+ if (ordered.length < 2) return { changed: false, daysSinceChange: null };
97
+
98
+ const latest = ordered[0];
99
+ if (latest.maintainers.length === 0) return { changed: false, daysSinceChange: null };
100
+
101
+ for (let i = 1; i < ordered.length; i++) {
102
+ const prev = ordered[i];
103
+ const days = _daysBetween(latest.time, prev.time);
104
+ if (days === null) continue;
105
+ if (days > windowDays) {
106
+ // No change within the window
107
+ return { changed: false, daysSinceChange: null };
108
+ }
109
+ if (!_equalMaintainerSets(latest.maintainers, prev.maintainers)) {
110
+ return { changed: true, daysSinceChange: days };
111
+ }
112
+ }
113
+ // All within-window versions had identical maintainers
114
+ return { changed: false, daysSinceChange: null };
115
+ }
116
+
117
+ /**
118
+ * Detects whether the publish cadence has changed dramatically. Computes the
119
+ * mean and stddev of historical inter-publish gaps (in days), then flags an
120
+ * anomaly when the latest gap is more than `sigma` standard deviations from
121
+ * the mean.
122
+ *
123
+ * Returns { anomaly, latestGapDays, meanGapDays, sigmaCount }.
124
+ * Insufficient history (< CADENCE_MIN_VERSIONS) -> anomaly=false.
125
+ */
126
+ function detectPublishCadenceAnomaly(meta, sigma = CADENCE_SIGMA) {
127
+ const ordered = _orderedVersions(meta);
128
+ if (ordered.length < CADENCE_MIN_VERSIONS) {
129
+ return { anomaly: false, latestGapDays: null, meanGapDays: null, sigmaCount: null };
130
+ }
131
+
132
+ const gaps = [];
133
+ for (let i = 0; i < ordered.length - 1; i++) {
134
+ const days = _daysBetween(ordered[i].time, ordered[i + 1].time);
135
+ if (days !== null && days >= 0) gaps.push(days);
136
+ }
137
+ if (gaps.length < CADENCE_MIN_VERSIONS - 1) {
138
+ return { anomaly: false, latestGapDays: null, meanGapDays: null, sigmaCount: null };
139
+ }
140
+
141
+ const latestGap = gaps[0];
142
+ const historical = gaps.slice(1); // exclude latest from the baseline
143
+ const mean = historical.reduce((s, x) => s + x, 0) / historical.length;
144
+ const variance = historical.reduce((s, x) => s + (x - mean) * (x - mean), 0) / historical.length;
145
+ const stddev = Math.sqrt(variance);
146
+ if (stddev === 0) {
147
+ return { anomaly: false, latestGapDays: latestGap, meanGapDays: mean, sigmaCount: 0 };
148
+ }
149
+ const sigmaCount = Math.abs(latestGap - mean) / stddev;
150
+ return {
151
+ anomaly: sigmaCount > sigma,
152
+ latestGapDays: latestGap,
153
+ meanGapDays: mean,
154
+ sigmaCount
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Detects whether the package has stable ownership for >= 2 years and > 100
160
+ * versions. This is the only structural suppression introduced in Chantier 4 -
161
+ * paired with C5's mature stable cap, it lets us cap mature, well-owned
162
+ * packages at MEDIUM while still surfacing maintainer change boosts above.
163
+ */
164
+ function detectStableOwnership(meta, minDays = STABLE_OWNERSHIP_DAYS, minVersions = STABLE_OWNERSHIP_MIN_VERSIONS) {
165
+ const ordered = _orderedVersions(meta);
166
+ if (ordered.length < minVersions) return { stable: false, sinceDays: null };
167
+
168
+ const latest = ordered[0];
169
+ if (latest.maintainers.length === 0) return { stable: false, sinceDays: null };
170
+
171
+ // Walk backwards until we find a version whose maintainer set differs
172
+ // OR we exhaust history. Stable if the same set persists for >= minDays.
173
+ let oldestSameSet = latest;
174
+ for (let i = 1; i < ordered.length; i++) {
175
+ if (_equalMaintainerSets(latest.maintainers, ordered[i].maintainers)) {
176
+ oldestSameSet = ordered[i];
177
+ continue;
178
+ }
179
+ break;
180
+ }
181
+ const sinceDays = _daysBetween(latest.time, oldestSameSet.time);
182
+ return {
183
+ stable: sinceDays !== null && sinceDays >= minDays,
184
+ sinceDays
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Computes the four advanced signals from a registry packument and returns
190
+ * a flat object suitable for merging into the basic metadata bundle.
191
+ */
192
+ function computeAdvancedRegistrySignals(meta) {
193
+ const change = detectRecentMaintainerChange(meta);
194
+ const cadence = detectPublishCadenceAnomaly(meta);
195
+ const stable = detectStableOwnership(meta);
196
+ return {
197
+ maintainer_change_recent: change.changed,
198
+ maintainer_change_within_days: change.daysSinceChange,
199
+ publish_cadence_anomaly: cadence.anomaly,
200
+ publish_cadence_sigma: cadence.sigmaCount,
201
+ stable_ownership_2y: stable.stable,
202
+ stable_ownership_since_days: stable.sinceDays
203
+ };
204
+ }
205
+
206
+ module.exports = {
207
+ computeAdvancedRegistrySignals,
208
+ detectRecentMaintainerChange,
209
+ detectPublishCadenceAnomaly,
210
+ detectStableOwnership,
211
+ RECENT_CHANGE_DAYS,
212
+ STABLE_OWNERSHIP_DAYS,
213
+ STABLE_OWNERSHIP_MIN_VERSIONS,
214
+ CADENCE_MIN_VERSIONS,
215
+ CADENCE_SIGMA
216
+ };
@@ -2,10 +2,29 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { getRule } = require('../rules/index.js');
4
4
  const { getPlaybook } = require('../response/playbooks.js');
5
- const { computeReachableFiles } = require('../scanner/reachability.js');
6
- const { applyFPReductions, applyCompoundBoosts, calculateRiskScore, getSeverityWeights, applyContextualFPCaps, applySingleFireCriticalFloor, applyReputationFactor } = require('../scoring.js');
5
+ const { computeReachableFiles, computeReachableFunctions } = require('../scanner/reachability.js');
6
+ const { applyFPReductions, applyCompoundBoosts, calculateRiskScore, getSeverityWeights, applyContextualFPCaps, applySingleFireCriticalFloor, applyReputationFactor, applyMatureStableCap, applySandboxVerdict, applyDeltaMultiplier } = require('../scoring.js');
7
+ const { loadPriorVersionSignatures, computeSignatures, saveCachedSignatures } = require('../scoring/delta-multiplier.js');
8
+ const { annotateConfidenceTiers, tierAtLeast } = require('../rules/confidence-tiers.js');
7
9
  const { buildIntentPairs } = require('../intent-graph.js');
8
10
  const { debugLog } = require('../utils.js');
11
+ const { getPackageMetadata } = require('../scanner/npm-registry.js');
12
+
13
+ // Auto-sandbox compound trigger : optional out-of-tree dependency. Lazy-load
14
+ // it so the pipeline still works when the file is absent (some dev machines
15
+ // have it untracked, CI does not). When missing, evaluateSandboxTrigger
16
+ // degrades to a no-op {shouldRun:false} so the auto-sandbox branch skips.
17
+ let _sandboxTriggerCache = null;
18
+ function evaluateSandboxTrigger(threats, prelimScore) {
19
+ if (_sandboxTriggerCache === null) {
20
+ try {
21
+ _sandboxTriggerCache = require('../sandbox/compound-triggers.js').evaluateSandboxTrigger;
22
+ } catch {
23
+ _sandboxTriggerCache = () => ({ shouldRun: false });
24
+ }
25
+ }
26
+ return _sandboxTriggerCache(threats, prelimScore);
27
+ }
9
28
 
10
29
  /**
11
30
  * Process raw threats: sandbox integration, dedup, compounds, FP reductions,
@@ -18,32 +37,48 @@ const { debugLog } = require('../utils.js');
18
37
  * @returns {Promise<{result: object, deduped: Array, enrichedThreats: Array, sandboxData: object|null, pythonInfo: object|null, breakdown: Array, mostSuspiciousFile: string|null, maxFileScore: number, packageScore: number, globalRiskScore: number, scannerErrors: Array}>}
19
38
  */
20
39
  async function process(threats, targetPath, options, pythonDeps, warnings, scannerErrors) {
21
- // Auto-sandbox: trigger sandbox analysis when static scan detects threats.
22
- // Preliminary score estimate: count CRITICAL/HIGH threats as a quick heuristic.
23
- // Only when --auto-sandbox flag is set, no explicit sandboxResult, and Docker available.
40
+ // Auto-sandbox: surgical trigger only when a sandbox-friendly compound
41
+ // matches AND the preliminary score is in the borderline window [15, 35].
42
+ // See src/sandbox/compound-triggers.js for the 6 compounds and rationale.
43
+ // Score < 15 = clean, no need to run; score > 35 = already definitive,
44
+ // no second-tier verdict needed. The verdict is then applied below via
45
+ // applySandboxVerdict (floor at 75/60 for malicious, -8 for clean).
24
46
  if (options.autoSandbox && !options.sandboxResult) {
25
47
  const critCount = threats.filter(t => t.severity === 'CRITICAL').length;
26
48
  const highCount = threats.filter(t => t.severity === 'HIGH').length;
27
49
  const prelimScore = Math.min(100, critCount * 25 + highCount * 10);
28
- if (prelimScore >= 20) {
50
+ const sandboxTrigger = evaluateSandboxTrigger(threats, prelimScore);
51
+ if (sandboxTrigger.shouldRun) {
29
52
  try {
30
53
  const { isDockerAvailable, buildSandboxImage, runSandbox } = require('../sandbox/index.js');
31
54
  if (isDockerAvailable()) {
32
- console.log(`\n[AUTO-SANDBOX] Preliminary score ~${prelimScore} >= 20 — triggering sandbox analysis...`);
55
+ console.log(`\n[AUTO-SANDBOX] Compound "${sandboxTrigger.compound}" matched (score ~${prelimScore}) - triggering sandbox analysis...`);
33
56
  const built = await buildSandboxImage();
34
57
  if (built) {
35
- const sbResult = await runSandbox(targetPath, { local: true, strict: false });
58
+ const sbResult = await runSandbox(targetPath, {
59
+ local: true,
60
+ strict: false,
61
+ compound: sandboxTrigger.compound,
62
+ watchpoints: sandboxTrigger.watchpoints
63
+ });
36
64
  if (sbResult && Array.isArray(sbResult.findings)) {
65
+ if (sbResult.meta) {
66
+ sbResult.meta.compound = sandboxTrigger.compound;
67
+ sbResult.meta.watchpoints = sandboxTrigger.watchpoints;
68
+ } else {
69
+ sbResult.meta = { compound: sandboxTrigger.compound, watchpoints: sandboxTrigger.watchpoints };
70
+ }
37
71
  options.sandboxResult = sbResult;
38
72
  }
39
73
  }
40
74
  } else {
41
- debugLog('[AUTO-SANDBOX] Docker not available skipping sandbox');
75
+ debugLog('[AUTO-SANDBOX] Docker not available - skipping sandbox');
42
76
  }
43
77
  } catch (e) {
44
78
  debugLog('[AUTO-SANDBOX] Error:', e && e.message);
45
- // Graceful fallback — sandbox is best-effort
46
79
  }
80
+ } else {
81
+ debugLog('[AUTO-SANDBOX] No compound matched (score ~' + prelimScore + ') - ' + sandboxTrigger.reason);
47
82
  }
48
83
  }
49
84
 
@@ -85,6 +120,7 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
85
120
 
86
121
  // Reachability analysis: determine which files are reachable from entry points
87
122
  let reachableFiles = null;
123
+ let reachableFunctions = null; // FPR plan C2 : intra-file fn-level reachability
88
124
  if (!options.noReachability) {
89
125
  try {
90
126
  const reachability = computeReachableFiles(targetPath);
@@ -95,11 +131,24 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
95
131
  debugLog('[REACHABILITY] error:', e?.message);
96
132
  // Graceful fallback — treat all files as reachable
97
133
  }
134
+ // FPR plan C2 : function-level reachability sits behind a flag while we
135
+ // measure FPR delta on the corpus. Off by default so production scans stay
136
+ // identical until the flag is flipped. Activated only when file-level
137
+ // reachability succeeded (otherwise no entry-point context to seed from).
138
+ if (reachableFiles && globalThis.process.env.MUADDIB_FN_REACHABILITY === '1') {
139
+ try {
140
+ reachableFunctions = computeReachableFunctions(targetPath, reachableFiles);
141
+ } catch (e) {
142
+ debugLog('[FN-REACHABILITY] error:', e?.message);
143
+ reachableFunctions = null;
144
+ }
145
+ }
98
146
  }
99
147
 
100
148
  // Read package name and dependencies for FP reduction heuristics
101
149
  let packageName = null;
102
150
  let packageDeps = null;
151
+ let packageVersion = null;
103
152
  let _pkgMeta = null; // v2.10.97: full pkg metadata for contextual FP caps
104
153
  try {
105
154
  const pkgPath = path.join(targetPath, 'package.json');
@@ -107,8 +156,10 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
107
156
  const pkgData = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
108
157
  packageName = pkgData.name || null;
109
158
  packageDeps = pkgData.dependencies || null;
159
+ packageVersion = (typeof pkgData.version === 'string') ? pkgData.version : null;
110
160
  _pkgMeta = {
111
161
  name: pkgData.name,
162
+ version: packageVersion,
112
163
  scripts: pkgData.scripts || {},
113
164
  description: pkgData.description || '',
114
165
  homepage: pkgData.homepage || (typeof pkgData.repository === 'string' ? pkgData.repository : (pkgData.repository && pkgData.repository.url) || ''),
@@ -118,6 +169,39 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
118
169
  }
119
170
  } catch { /* graceful fallback */ }
120
171
 
172
+ // FPR plan Chantier 4 + 5 wiring : when a package name is known and at least
173
+ // one of the metadata-driven gates is ON, fetch the npm registry packument
174
+ // and attach it as _pkgMeta.npmRegistryMeta. Without this, applyReputation-
175
+ // Factor and applyMatureStableCap cannot fire outside the monitor's own
176
+ // queue.js (which already pre-fetches the metadata bundle). getPackageMeta-
177
+ // data has its own in-process cache, so repeated scans of the same package
178
+ // hit the cache and never re-fetch. Network failure / unknown package -> the
179
+ // call returns null and both downstream functions degrade gracefully.
180
+ if (
181
+ packageName &&
182
+ _pkgMeta &&
183
+ (
184
+ globalThis.process.env.MUADDIB_METADATA_FACTOR === '1' ||
185
+ globalThis.process.env.MUADDIB_MATURE_CAP === '1' ||
186
+ globalThis.process.env.MUADDIB_DELTA_MODE === '1'
187
+ )
188
+ ) {
189
+ try {
190
+ const meta = await getPackageMetadata(packageName);
191
+ if (meta) {
192
+ // Attach the scanned version so applyMatureStableCap can require
193
+ // scan_version === latest_version. Without this gate, scanning a
194
+ // historical compromised version (e.g. eslint-scope 3.7.2, chalk
195
+ // 5.6.1) would inherit the live registry's "stable" reputation and
196
+ // mask the attack.
197
+ meta.scan_version = packageVersion;
198
+ _pkgMeta.npmRegistryMeta = meta;
199
+ }
200
+ } catch (err) {
201
+ debugLog('[REGISTRY-META] fetch failed for ' + packageName + ': ' + err.message);
202
+ }
203
+ }
204
+
121
205
  // Cross-scanner compound: detached_process + suspicious_dataflow in same file
122
206
  // Catches cases where credential flow is detected by dataflow scanner, not AST scanner
123
207
  {
@@ -206,11 +290,30 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
206
290
 
207
291
  // FP reduction: legitimate frameworks produce high volumes of certain threat types.
208
292
  // A malware package typically has 1-3 occurrences, not dozens.
209
- applyFPReductions(deduped, reachableFiles, packageName, packageDeps);
293
+ applyFPReductions(deduped, reachableFiles, packageName, packageDeps, reachableFunctions);
294
+
295
+ // FPR plan Chantier 3 - delta-aware decay. Threats present in the last 3
296
+ // published versions (and not HC/IOC) decay to LOW. Off by default until
297
+ // the cache is warm and we've measured the FPR delta on the corpus.
298
+ let _deltaResult = null;
299
+ if (
300
+ packageName && packageVersion &&
301
+ _pkgMeta && _pkgMeta.npmRegistryMeta &&
302
+ globalThis.process.env.MUADDIB_DELTA_MODE === '1'
303
+ ) {
304
+ try {
305
+ const packument = _pkgMeta.npmRegistryMeta.packument || _pkgMeta.npmRegistryMeta;
306
+ const priorSigs = loadPriorVersionSignatures(packageName, packageVersion, packument);
307
+ _deltaResult = applyDeltaMultiplier(deduped, priorSigs);
308
+ } catch (e) {
309
+ debugLog('[DELTA] error:', e?.message);
310
+ _deltaResult = null;
311
+ }
312
+ }
210
313
 
211
314
  // Compound scoring: inject synthetic CRITICAL threats when co-occurring types
212
315
  // indicate unambiguous malice. Applied AFTER FP reductions to recover signals
213
- // that were individually downgraded (count-based, dist, reachability).
316
+ // that were individually downgraded (count-based, dist, reachability, delta).
214
317
  applyCompoundBoosts(deduped);
215
318
 
216
319
  // Intent coherence analysis: detect source→sink pairs within files
@@ -231,6 +334,13 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
231
334
  }
232
335
  }
233
336
 
337
+ // FPR plan Chantier 6 - tag every threat with its confidence tier so the
338
+ // CLI / JSON / SARIF formatters can filter to verified+high by default and
339
+ // evaluate.js can report a "FPR perceived" headline alongside "FPR all".
340
+ // Annotation reads severity AFTER all FP reductions, so reductions trail
341
+ // (count_threshold, unreachable, delta_stable, ...) influences the tier.
342
+ annotateConfidenceTiers(deduped);
343
+
234
344
  // Enrich each threat with rules
235
345
  const enrichedThreats = deduped.map(t => {
236
346
  const rule = getRule(t.type);
@@ -241,6 +351,7 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
241
351
  rule_id: rule.id || t.type,
242
352
  rule_name: rule.name || t.type,
243
353
  confidence: rule.confidence || 'medium',
354
+ confidenceTier: t.confidenceTier || 'medium',
244
355
  references: rule.references || [],
245
356
  mitre: t.mitre || rule.mitre,
246
357
  playbook: getPlaybook(t.type),
@@ -284,6 +395,16 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
284
395
  threats: threats.filter(t => t.type === 'pypi_malicious_package' || t.type === 'pypi_typosquat_detected').length
285
396
  } : null;
286
397
 
398
+ // FPR plan Chantier 6 - tier counts let downstream metrics report FPR
399
+ // perceived (verified + high) alongside FPR all. The CLI reads these to
400
+ // decide whether to print a finding by default vs hide behind --show-low.
401
+ const tierCounts = { verified: 0, high: 0, medium: 0, low: 0 };
402
+ for (const t of deduped) {
403
+ const tier = t.confidenceTier || 'medium';
404
+ if (tierCounts[tier] !== undefined) tierCounts[tier]++;
405
+ }
406
+ const perceivedFlagged = tierCounts.verified + tierCounts.high;
407
+
287
408
  const result = {
288
409
  target: targetPath,
289
410
  timestamp: new Date().toISOString(),
@@ -303,7 +424,10 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
303
424
  mostSuspiciousFile,
304
425
  fileScores,
305
426
  fileSizes,
306
- breakdown
427
+ breakdown,
428
+ // C6 : confidence tier rollup
429
+ tierCounts,
430
+ perceivedFlagged
307
431
  },
308
432
  sandbox: sandboxData,
309
433
  warnings: warnings.length > 0 ? warnings : undefined,
@@ -319,6 +443,20 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
319
443
  ' → score=' + result.summary.riskScore);
320
444
  }
321
445
 
446
+ // FPR plan Chantier 5 : mature stable cap — caps mature, well-owned, high-
447
+ // traffic packages at MEDIUM unless an HC type or IOC is present. Sits
448
+ // BETWEEN the contextual caps (which it composes with) and the single-fire
449
+ // floor (which can override on hard signals). Gated behind
450
+ // MUADDIB_MATURE_CAP=1 until measured against the full evaluation corpus.
451
+ if (globalThis.process.env.MUADDIB_MATURE_CAP === '1') {
452
+ const matureCap = applyMatureStableCap(result, _pkgMeta && _pkgMeta.npmRegistryMeta);
453
+ if (matureCap && matureCap.applied) {
454
+ debugLog('[MATURE-CAP] ' + (packageName || targetPath) + ': ' +
455
+ matureCap.oldScore + ' -> ' + matureCap.newScore + ' (' +
456
+ Object.entries(matureCap.reasons).map(([k, v]) => k + '=' + v).join(', ') + ')');
457
+ }
458
+ }
459
+
322
460
  // Hybrid v3 Phase 1: single-fire critical floor — applied AFTER contextual
323
461
  // caps so a deterministic IOC match (known_malicious_hash, lifecycle_shell_pipe…)
324
462
  // stays CRITICAL even if the package also matches a benign FP cluster.
@@ -345,6 +483,45 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
345
483
  }
346
484
  }
347
485
 
486
+ // Sandbox verdict: meta-layer applied after every other scoring step.
487
+ // MALICIOUS_CONFIRMED floors the score at 75 (any honey READ correlated
488
+ // outbound, or critical preload signal). MALICIOUS_CHAIN floors at 60
489
+ // (>=2 high preload signals). CLEAN_HIGH_CONFIDENCE applies a -8 delta when
490
+ // the sandbox completed cleanly with no fingerprint detected. INCONCLUSIVE
491
+ // leaves the score unchanged with a warning attached.
492
+ if (options.sandboxResult) {
493
+ const verdict = applySandboxVerdict(result, options.sandboxResult);
494
+ if (verdict) {
495
+ debugLog('[SANDBOX-VERDICT] ' + (packageName || targetPath) + ': ' +
496
+ verdict.verdict + ' ' + verdict.oldScore + ' -> ' + verdict.newScore +
497
+ (verdict.signals.length > 0 ? ' [' + verdict.signals.slice(0, 3).join(', ') + ']' : ''));
498
+ }
499
+ }
500
+
501
+ // FPR plan Chantier 3 : persist this version's signature set so future scans
502
+ // (or future versions) can use it as a baseline for delta decay. Best-effort
503
+ // and idempotent ; cache misses on read are silent so a missed write never
504
+ // blocks scoring. Only write when the user opted in to delta-mode AND we
505
+ // have a concrete package@version pair.
506
+ if (
507
+ globalThis.process.env.MUADDIB_DELTA_MODE === '1' &&
508
+ packageName && packageVersion
509
+ ) {
510
+ try {
511
+ const sigs = computeSignatures(deduped);
512
+ saveCachedSignatures(packageName, packageVersion, sigs);
513
+ debugLog('[DELTA] cached ' + sigs.size + ' signatures for ' + packageName + '@' + packageVersion);
514
+ } catch (e) {
515
+ debugLog('[DELTA] cache write failed:', e?.message);
516
+ }
517
+ }
518
+
519
+ if (_deltaResult && _deltaResult.downgraded > 0) {
520
+ debugLog('[DELTA] ' + (packageName || targetPath) + ': ' +
521
+ _deltaResult.downgraded + ' threats decayed to LOW (baseline=' +
522
+ _deltaResult.baselineSize + ', new=' + _deltaResult.newThreats + ')');
523
+ }
524
+
348
525
  return {
349
526
  result,
350
527
  deduped,
@@ -917,6 +917,37 @@ const PLAYBOOKS = {
917
917
  'CRITIQUE: Dependance declaree avec URL tarball (.tgz/.tar.gz) hebergee hors des registres npm legitimes (github.com, gitlab.com, bitbucket.org, registry.npmjs.org). ' +
918
918
  'Pattern ltidi chain attack (avril 2026): le stub publie sur npm n\'a aucun install hook visible, la charge utile est hebergee sur un cloud storage (GCS, S3, CDN) et contourne entierement l\'audit du registre npm. ' +
919
919
  'Verifier le contenu de la tarball distante avant toute installation. Supprimer le package. Signaler au registre npm.',
920
+
921
+ // Sandbox 2026: honey traps + persistence + chain analysis
922
+ sandbox_honey_read:
923
+ 'CRITIQUE: Le package a lu un fichier decoy plante par la sandbox (.npmrc-decoy, .ssh/id_rsa-decoy, wallet decoy, etc.). ' +
924
+ 'Aucun outil legitime ne lit ces chemins decoy. Indicateur fort de scan aveugle de credentials, meme pour des malwares zero-day. ' +
925
+ 'Isoler la machine. Supprimer le package. Si un decoy a ete exfiltre via HTTP, le canary token apparaitra dans les logs reseau.',
926
+
927
+ sandbox_credential_target_read:
928
+ 'ELEVE: Le package a lu un fichier de credentials connu (cloud creds, wallets, browser data, .gnupg, .kube/config). ' +
929
+ 'Pattern PhantomRaven, Shai-Hulud. Verifier la correlation avec une connexion sortante non-registre dans la meme run. ' +
930
+ 'Si le decoy a ete exfiltre (canary detection): rotation immediate de tous les secrets correspondants.',
931
+
932
+ sandbox_persistence_write:
933
+ 'CRITIQUE: Le package a ecrit dans un emplacement de persistance (.bashrc, .zshrc, autostart, cron, systemd user, LaunchAgents). ' +
934
+ 'Aucun cas legitime en npm install. Implant probable. ' +
935
+ 'Inspecter le contenu ecrit, le supprimer, isoler la machine, regenerer la session shell.',
936
+
937
+ sandbox_execve_chain_depth:
938
+ 'ELEVE: La chaine de processus depasse la profondeur attendue depuis npm install (npm install -> script -> binaire externe). ' +
939
+ 'Pattern Shai-Hulud preinstall worm: install script lance node/sh qui lance curl|wget|bash. ' +
940
+ 'Tracer chaque processus de la chaine, supprimer le package, verifier les fichiers crees dans /tmp et le home.',
941
+
942
+ sandbox_npm_self_invoke:
943
+ 'CRITIQUE: Le package invoque npm publish/deprecate/owner/token/access depuis l\'arborescence npm install. ' +
944
+ 'Pattern CanisterWorm self-propagation: le malware utilise le token npm de la machine pour publier des versions backdoorees d\'autres packages mainteneurs par l\'utilisateur. ' +
945
+ 'Isoler immediatement. Revoquer tous les tokens npm. Auditer les versions publiees recemment depuis le compte.',
946
+
947
+ sandbox_runtime_deobfuscation_executed:
948
+ 'ELEVE: new Function() ou eval() a execute un body de >500 octets derive d\'une string source obfusquee. ' +
949
+ 'Pattern Axios 2026 OrDeR_7077: XOR + base64 decoded a l\'install puis execute en memoire. ' +
950
+ 'Le statique voit l\'obfuscation, la sandbox confirme l\'execution effective. Lire le contenu deobfusque dans les logs preload, isoler la machine.',
920
951
  };
921
952
 
922
953
  function getPlaybook(threatType) {