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 +3 -1
- package/src/integrations/registry-signals.js +216 -0
- package/src/pipeline/processor.js +190 -13
- package/src/response/playbooks.js +31 -0
- package/src/rules/confidence-tiers.js +187 -0
- package/src/rules/index.js +75 -0
- package/src/runtime/serve.js +59 -2
- 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 +363 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.11.
|
|
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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
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]
|
|
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, {
|
|
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
|
|
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) {
|