muaddib-scanner 2.11.4 → 2.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ /**
2
+ * FPR plan Chantier 3 - delta-aware scanning.
3
+ *
4
+ * Diff-aware multiplier inspired by Aikido & Socket : compare the threat
5
+ * signatures of the current version against the last 3 published versions.
6
+ * A threat present in N-3..N-1 is a *stable pattern* of the package, not a
7
+ * new attack ; downgrade to LOW (unless it's an HC type or an IOC, which
8
+ * never decay through staleness). A threat new in N is left untouched and
9
+ * marked deltaNew so consumers can boost its visibility.
10
+ *
11
+ * Caching :
12
+ * .muaddib-cache/version-deltas/<sha-key>.json
13
+ * { package, version, signatures, cachedAt }
14
+ * TTL 90 days, bounded at 50K entries (CLAUDE.md "bounded resources").
15
+ *
16
+ * The cache is read-only at scoring time : signatures are populated by the
17
+ * monitor (or by a delta-mode CLI scan that opts-in to fetch + scan prior
18
+ * versions). When fewer than 3 prior signatures are available the multiplier
19
+ * is a no-op - we never risk a TPR regression on a partial baseline.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+
28
+ const { HIGH_CONFIDENCE_MALICE_TYPES } = require('../monitor/classify.js');
29
+
30
+ const CACHE_DIR = path.join(process.cwd(), '.muaddib-cache', 'version-deltas');
31
+ const CACHE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
32
+ const CACHE_MAX_ENTRIES = 50000;
33
+ const MIN_PRIOR_VERSIONS_FOR_DECAY = 3;
34
+
35
+ // IOC / always-malicious types that never decay through delta. Mirrors the
36
+ // MATURE_CAP_IOC_TYPES set in scoring.js but local-scoped to make this module
37
+ // importable without circularity (scoring.js imports delta-multiplier).
38
+ const DELTA_IOC_EXEMPT = new Set([
39
+ 'ioc_match',
40
+ 'ioc_string_match',
41
+ 'known_malicious_hash',
42
+ 'known_malicious_package',
43
+ 'pypi_malicious_package',
44
+ 'shai_hulud_marker',
45
+ 'dependency_ioc_match',
46
+ // Lifecycle additions and modifications are version-specific by definition
47
+ 'lifecycle_added_critical',
48
+ 'lifecycle_added_high',
49
+ 'lifecycle_modified',
50
+ 'trusted_new_unknown_dependency'
51
+ ]);
52
+
53
+ function _safeKey(packageName, version) {
54
+ return crypto.createHash('sha256')
55
+ .update(`${packageName}@${version}`)
56
+ .digest('hex')
57
+ .slice(0, 24);
58
+ }
59
+
60
+ function _ensureCacheDir() {
61
+ try {
62
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
63
+ return true;
64
+ } catch { return false; }
65
+ }
66
+
67
+ function _cachePath(packageName, version) {
68
+ return path.join(CACHE_DIR, _safeKey(packageName, version) + '.json');
69
+ }
70
+
71
+ /**
72
+ * Normalize a threat into a stable signature for cross-version comparison.
73
+ * Two threats from different versions of the same package compare equal when
74
+ * they share the same normalized signature.
75
+ *
76
+ * type:file_pattern
77
+ *
78
+ * file_pattern is the threat.file with hex hashes (>=8 hex chars) collapsed
79
+ * to "HEX" and semver triplets collapsed to "VER". This way a webpack chunk
80
+ * `dist/main.a3f2b1c.js` does not look distinct from `dist/main.0d4e5f6.js`.
81
+ */
82
+ function buildThreatSignature(threat) {
83
+ if (!threat || typeof threat.type !== 'string') return null;
84
+ let file = (typeof threat.file === 'string') ? threat.file : '';
85
+ // Forward-slash normalize
86
+ file = file.replace(/\\/g, '/');
87
+ // Collapse hex hashes (chunk hashes, sha256 fragments)
88
+ file = file.replace(/[a-f0-9]{8,}/gi, 'HEX');
89
+ // Collapse semver-like triplets in the path
90
+ file = file.replace(/\d+\.\d+\.\d+/g, 'VER');
91
+ // Numeric run collapse (chunk numbers)
92
+ file = file.replace(/\b\d{2,}\b/g, 'N');
93
+ return `${threat.type}:${file}`;
94
+ }
95
+
96
+ /**
97
+ * Compute the signature set for a list of threats, used both to populate the
98
+ * cache after scanning a prior version and to compare the current scan
99
+ * against the cached priors.
100
+ */
101
+ function computeSignatures(threats) {
102
+ const out = new Set();
103
+ if (!Array.isArray(threats)) return out;
104
+ for (const t of threats) {
105
+ const sig = buildThreatSignature(t);
106
+ if (sig) out.add(sig);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Loads the cached signature set for a given package@version. Returns null
113
+ * when the cache file is missing, expired, or unparseable.
114
+ */
115
+ function loadCachedSignatures(packageName, version) {
116
+ if (!packageName || !version) return null;
117
+ const file = _cachePath(packageName, version);
118
+ if (!fs.existsSync(file)) return null;
119
+ try {
120
+ const stat = fs.statSync(file);
121
+ if (Date.now() - stat.mtimeMs > CACHE_TTL_MS) return null;
122
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
123
+ if (data.package !== packageName || data.version !== version) return null;
124
+ if (!Array.isArray(data.signatures)) return null;
125
+ return new Set(data.signatures);
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Persists the signature set for a package@version. Append-only: a missed
133
+ * write or crash is recoverable on next scan because we never overwrite
134
+ * unrelated entries. Honour the entry cap so the cache cannot grow without
135
+ * bound (CLAUDE.md "bounded resources").
136
+ */
137
+ function saveCachedSignatures(packageName, version, signatures) {
138
+ if (!packageName || !version) return false;
139
+ if (!_ensureCacheDir()) return false;
140
+ try {
141
+ // Evict oldest entries when over cap
142
+ const entries = fs.readdirSync(CACHE_DIR).filter(f => f.endsWith('.json'));
143
+ if (entries.length >= CACHE_MAX_ENTRIES) {
144
+ const stats = entries
145
+ .map(f => {
146
+ const p = path.join(CACHE_DIR, f);
147
+ try { return { p, mtime: fs.statSync(p).mtimeMs }; } catch { return null; }
148
+ })
149
+ .filter(Boolean)
150
+ .sort((a, b) => a.mtime - b.mtime);
151
+ const toEvict = stats.slice(0, Math.max(1, Math.floor(CACHE_MAX_ENTRIES * 0.1)));
152
+ for (const e of toEvict) { try { fs.unlinkSync(e.p); } catch { /* ignore */ } }
153
+ }
154
+
155
+ const file = _cachePath(packageName, version);
156
+ const sigList = signatures instanceof Set ? [...signatures] : (Array.isArray(signatures) ? signatures : []);
157
+ fs.writeFileSync(file, JSON.stringify({
158
+ package: packageName,
159
+ version,
160
+ signatures: sigList,
161
+ cachedAt: new Date().toISOString()
162
+ }));
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Apply the delta multiplier to a result. Mutates threats in place :
171
+ *
172
+ * - threat.deltaPresentInPrior : count of prior versions sharing the
173
+ * signature (0..priorVersionSignatures.size).
174
+ * - threat.deltaStable : true when present in >= 3 priors AND
175
+ * not HC/IOC AND severity gets lowered to LOW.
176
+ * - threat.deltaNew : true when absent from N-1 (= the most
177
+ * recent prior), so the threat is genuinely new in the current version.
178
+ *
179
+ * The riskScore on result.summary is recomputed by the caller (scoring.js)
180
+ * via calculateRiskScore() so we don't reach into summary here.
181
+ *
182
+ * @param {Array} threats The current scan's threat list.
183
+ * @param {Map<string,Set<string>>} priorVersionSignatures Map<version,sigs>,
184
+ * typically built by computePriorSignatures(). Versions should be ordered
185
+ * from most recent to oldest but the function does not rely on order.
186
+ * @returns {{ downgraded:number, newThreats:number, baselineSize:number }|null}
187
+ */
188
+ function applyDeltaMultiplier(threats, priorVersionSignatures) {
189
+ if (!Array.isArray(threats)) return null;
190
+ if (!priorVersionSignatures || typeof priorVersionSignatures.size !== 'number') return null;
191
+ const baselineSize = priorVersionSignatures.size;
192
+ if (baselineSize < MIN_PRIOR_VERSIONS_FOR_DECAY) {
193
+ return { downgraded: 0, newThreats: 0, baselineSize };
194
+ }
195
+
196
+ const orderedVersions = [...priorVersionSignatures.keys()];
197
+ const mostRecentPrior = orderedVersions[0];
198
+ const mostRecentSigs = priorVersionSignatures.get(mostRecentPrior);
199
+
200
+ let downgraded = 0;
201
+ let newThreats = 0;
202
+ for (const t of threats) {
203
+ if (!t || typeof t !== 'object') continue;
204
+ if (HIGH_CONFIDENCE_MALICE_TYPES.has(t.type)) continue;
205
+ if (DELTA_IOC_EXEMPT.has(t.type)) continue;
206
+
207
+ const sig = buildThreatSignature(t);
208
+ if (!sig) continue;
209
+
210
+ let presentCount = 0;
211
+ for (const sigSet of priorVersionSignatures.values()) {
212
+ if (sigSet.has(sig)) presentCount++;
213
+ }
214
+ t.deltaPresentInPrior = presentCount;
215
+
216
+ if (mostRecentSigs && !mostRecentSigs.has(sig)) {
217
+ t.deltaNew = true;
218
+ newThreats++;
219
+ }
220
+
221
+ if (presentCount >= MIN_PRIOR_VERSIONS_FOR_DECAY) {
222
+ // Stable pattern - downgrade severity. The reductions trail records the
223
+ // decision so output formatters can show why the score dropped.
224
+ if (t.severity !== 'LOW') {
225
+ t.reductions = Array.isArray(t.reductions) ? t.reductions : [];
226
+ t.reductions.push({ rule: 'delta_stable', from: t.severity, to: 'LOW' });
227
+ t.severity = 'LOW';
228
+ t.deltaStable = true;
229
+ downgraded++;
230
+ }
231
+ }
232
+ }
233
+ return { downgraded, newThreats, baselineSize };
234
+ }
235
+
236
+ /**
237
+ * From a raw npm packument (or anything with a `time` map keyed by version),
238
+ * return the 3 most recently published versions strictly older than
239
+ * currentVersion, ordered most-recent-first. Versions whose timestamp is
240
+ * unparseable are skipped.
241
+ */
242
+ function selectPriorVersions(packument, currentVersion, max = MIN_PRIOR_VERSIONS_FOR_DECAY) {
243
+ if (!packument || !packument.time || typeof packument.time !== 'object') return [];
244
+ const cur = packument.time[currentVersion];
245
+ const cutoff = cur ? new Date(cur).getTime() : null;
246
+
247
+ const ordered = [];
248
+ for (const [version, ts] of Object.entries(packument.time)) {
249
+ if (version === currentVersion) continue;
250
+ if (version === 'created' || version === 'modified') continue;
251
+ if (typeof ts !== 'string') continue;
252
+ const t = new Date(ts).getTime();
253
+ if (isNaN(t)) continue;
254
+ if (cutoff !== null && t >= cutoff) continue;
255
+ ordered.push({ version, t });
256
+ }
257
+ ordered.sort((a, b) => b.t - a.t);
258
+ return ordered.slice(0, max).map(e => e.version);
259
+ }
260
+
261
+ /**
262
+ * Build the priorVersionSignatures map for applyDeltaMultiplier by reading
263
+ * the cache for each of the prior versions. Versions whose cache entry is
264
+ * missing or expired are silently skipped so the caller still sees a useful
265
+ * baseline as soon as the cache is partially populated.
266
+ *
267
+ * Returns Map<version, Set<signature>> ordered most-recent-first.
268
+ */
269
+ function loadPriorVersionSignatures(packageName, currentVersion, packument) {
270
+ const out = new Map();
271
+ if (!packageName || !currentVersion || !packument) return out;
272
+ const versions = selectPriorVersions(packument, currentVersion);
273
+ for (const version of versions) {
274
+ const sigs = loadCachedSignatures(packageName, version);
275
+ if (sigs && sigs.size >= 0) out.set(version, sigs);
276
+ }
277
+ return out;
278
+ }
279
+
280
+ module.exports = {
281
+ applyDeltaMultiplier,
282
+ buildThreatSignature,
283
+ computeSignatures,
284
+ loadCachedSignatures,
285
+ saveCachedSignatures,
286
+ selectPriorVersions,
287
+ loadPriorVersionSignatures,
288
+ // Constants exported for tests
289
+ CACHE_DIR,
290
+ CACHE_TTL_MS,
291
+ CACHE_MAX_ENTRIES,
292
+ MIN_PRIOR_VERSIONS_FOR_DECAY,
293
+ DELTA_IOC_EXEMPT
294
+ };