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,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
|
+
};
|