muaddib-scanner 2.11.35 → 2.11.36

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.35",
3
+ "version": "2.11.36",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-05-24T21:03:08.335Z",
3
+ "timestamp": "2026-05-24T21:02:11.478Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -10,7 +10,7 @@ const { buildIntentPairs } = require('../intent-graph.js');
10
10
  const { debugLog } = require('../utils.js');
11
11
  const { getPackageMetadata } = require('../scanner/npm-registry.js');
12
12
  const { checkReleaseZero } = require('../scanner/release-zero.js');
13
- const { checkUnclaimedMaintainerEmail } = require('../scanner/email-domain.js');
13
+ const { checkUnclaimedMaintainerEmail, checkCompromisedDomain } = require('../scanner/email-domain.js');
14
14
 
15
15
  // Auto-sandbox compound trigger : optional out-of-tree dependency. Lazy-load
16
16
  // it so the pipeline still works when the file is absent (some dev machines
@@ -228,6 +228,10 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
228
228
  // HIGH × confidence medium = 8.5 points isolated → composite-only signal.
229
229
  // Skipped automatically when MUADDIB_NO_REGISTRY_FETCH=1 (no meta available)
230
230
  // or MUADDIB_EMAIL_DOMAIN_CHECK=0 (explicit opt-out).
231
+ //
232
+ // F1 — RDAP compromised email domain. Same best-effort + silent contract.
233
+ // Severity HIGH × confidence high = 10 points isolated → composite-only.
234
+ // Opt-out via MUADDIB_RDAP_CHECK=0.
231
235
  if (_pkgMeta && _pkgMeta.npmRegistryMeta) {
232
236
  try {
233
237
  const emailThreats = await checkUnclaimedMaintainerEmail(_pkgMeta.npmRegistryMeta);
@@ -235,6 +239,12 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
235
239
  } catch (err) {
236
240
  debugLog('[EMAIL-DOMAIN] check failed: ' + err.message);
237
241
  }
242
+ try {
243
+ const rdapThreats = await checkCompromisedDomain(_pkgMeta.npmRegistryMeta);
244
+ for (const t of rdapThreats) deduped.push(t);
245
+ } catch (err) {
246
+ debugLog('[RDAP] check failed: ' + err.message);
247
+ }
238
248
  }
239
249
 
240
250
  // Cross-scanner compound: detached_process + suspicious_dataflow in same file
@@ -1512,6 +1512,19 @@ const RULES = {
1512
1512
  ],
1513
1513
  mitre: 'T1556'
1514
1514
  },
1515
+ compromised_email_domain: {
1516
+ id: 'MUADDIB-MAINTAINER-006',
1517
+ name: 'Compromised Maintainer Email Domain',
1518
+ severity: 'HIGH',
1519
+ confidence: 'high',
1520
+ description: 'Le domaine de l\'email du mainteneur a ete enregistre APRES la premiere publication du package (marge 30j). Pattern de rachat de domaine expire: l\'attaquant reprend le mail, declenche un reset de mot de passe npm, prend le compte. Signal composite-only (HIGH x high = 10 pts isole, sous T1).',
1521
+ references: [
1522
+ 'https://github.com/DataDog/guarddog/blob/main/guarddog/analyzer/metadata/npm/potentially_compromised_email_domain.py',
1523
+ 'https://attack.mitre.org/techniques/T1556/',
1524
+ 'https://datatracker.ietf.org/doc/html/rfc7480'
1525
+ ],
1526
+ mitre: 'T1556'
1527
+ },
1515
1528
 
1516
1529
  // Canary token detections
1517
1530
  canary_exfiltration: {
@@ -132,6 +132,159 @@ async function checkUnclaimedMaintainerEmail(meta, options = {}) {
132
132
  // Exposed for tests
133
133
  function _resetCache() { _mxCache.clear(); }
134
134
 
135
+ // =============================================================================
136
+ // F1 — RDAP-based compromised email domain detection.
137
+ //
138
+ // Threat model: an attacker waits for the maintainer's email domain to expire,
139
+ // re-registers it, takes the mailbox, triggers an npm password-reset, takes
140
+ // over the account. The signal: the domain's `registration` event date is
141
+ // AFTER the package was first published.
142
+ //
143
+ // Why RDAP and not WHOIS:
144
+ // - RDAP is the IETF replacement for WHOIS (RFC 7480-7483), returns JSON
145
+ // - HTTP/HTTPS — works with Node's built-in fetch, no external dep
146
+ // - rdap.org is a community redirector that forwards to TLD-specific RDAP
147
+ // servers. Best-effort: many ccTLDs (.ru, .cn, .tk, .io) have no RDAP at
148
+ // all → we MUST skip silently on 404/timeout (no log spam in prod).
149
+ //
150
+ // Design constraints (same as F3 + plan):
151
+ // - HIGH × confidence_high = 10 points → composite-only (sub-T1=20).
152
+ // - Network failures SILENT (debug-only). No retries.
153
+ // - 30-day cache for RDAP responses (they don't change often).
154
+ // - 30-day margin on the comparison: alert iff
155
+ // creation_date > package_first_publish - 30j
156
+ // The -30j absorbs registration-vs-publish timing edges (e.g., maintainer
157
+ // bought the domain a few weeks before shipping their first version).
158
+ // - Opt-out via MUADDIB_RDAP_CHECK=0 (default ON).
159
+ //
160
+ // Inspired by GuardDog's npm/potentially_compromised_email_domain.py (which
161
+ // uses python-whois). We replace the WHOIS dependency with an RDAP HTTP call
162
+ // to satisfy the CLAUDE.md "no external runtime deps" rule.
163
+ // =============================================================================
164
+
165
+ const RDAP_TIMEOUT_MS = 5000;
166
+ const RDAP_CACHE_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
167
+ const RDAP_BASE_URL = 'https://rdap.org/domain/';
168
+ // 30-day margin on creation-vs-publish comparison (see above).
169
+ const COMPROMISE_MARGIN_MS = 30 * 24 * 60 * 60 * 1000;
170
+
171
+ // In-process cache: domain → { creationDate: ISO|null, fetchedAt: ms }
172
+ const _rdapCache = new Map();
173
+
174
+ /**
175
+ * Query the RDAP service for a domain's registration date.
176
+ * Returns { creationDate: ISO string } or null on any error/missing data.
177
+ * SILENT on failure — debug log only.
178
+ */
179
+ async function fetchRdap(domain, options = {}) {
180
+ const timeoutMs = options.timeoutMs || RDAP_TIMEOUT_MS;
181
+ const cached = _rdapCache.get(domain);
182
+ if (cached && (Date.now() - cached.fetchedAt) < RDAP_CACHE_TTL) {
183
+ return cached.creationDate ? { creationDate: cached.creationDate } : null;
184
+ }
185
+
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
188
+
189
+ try {
190
+ const response = await fetch(RDAP_BASE_URL + encodeURIComponent(domain), {
191
+ signal: controller.signal,
192
+ redirect: 'follow',
193
+ headers: { 'Accept': 'application/rdap+json' }
194
+ });
195
+ if (!response.ok) {
196
+ // 404 = no RDAP for this TLD, or unknown domain. Other errors transient.
197
+ // Drain body to free resources.
198
+ try { await response.text(); } catch { /* ignore */ }
199
+ _rdapCache.set(domain, { creationDate: null, fetchedAt: Date.now() });
200
+ return null;
201
+ }
202
+ let data;
203
+ try {
204
+ data = await response.json();
205
+ } catch {
206
+ return null; // malformed JSON
207
+ }
208
+ if (!data || !Array.isArray(data.events)) {
209
+ _rdapCache.set(domain, { creationDate: null, fetchedAt: Date.now() });
210
+ return null;
211
+ }
212
+ const reg = data.events.find(e =>
213
+ e && typeof e.eventAction === 'string' &&
214
+ e.eventAction.toLowerCase() === 'registration'
215
+ );
216
+ const creationDate = reg && typeof reg.eventDate === 'string' ? reg.eventDate : null;
217
+ _rdapCache.set(domain, { creationDate, fetchedAt: Date.now() });
218
+ return creationDate ? { creationDate } : null;
219
+ } catch (err) {
220
+ debugLog('[RDAP] fetch failed for ' + domain + ': ' + (err.code || err.message));
221
+ return null;
222
+ } finally {
223
+ clearTimeout(timer);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Returns true if the domain registration came AFTER the package was first
229
+ * published (with a 30-day margin to absorb timing edges).
230
+ */
231
+ function isCompromisedDomain(creationDateISO, packageCreatedAtISO) {
232
+ if (!creationDateISO || !packageCreatedAtISO) return false;
233
+ const cDate = new Date(creationDateISO).getTime();
234
+ const rDate = new Date(packageCreatedAtISO).getTime();
235
+ if (isNaN(cDate) || isNaN(rDate)) return false;
236
+ return cDate > (rDate - COMPROMISE_MARGIN_MS);
237
+ }
238
+
239
+ /**
240
+ * F1 entry point.
241
+ * @param {object|null} meta - Digested metadata. Reads maintainer_emails + created_at.
242
+ * @param {object} options - { fetchRdap } for tests to inject a mock.
243
+ * @returns {Promise<Array>} threats array
244
+ */
245
+ async function checkCompromisedDomain(meta, options = {}) {
246
+ if (globalThis.process.env.MUADDIB_RDAP_CHECK === '0') return [];
247
+ if (!meta || !Array.isArray(meta.maintainer_emails) || meta.maintainer_emails.length === 0) {
248
+ return [];
249
+ }
250
+ if (!meta.created_at) return []; // need a package publish date to compare against
251
+
252
+ const fetchFn = options.fetchRdap || fetchRdap;
253
+ const domains = uniqueDomains(meta.maintainer_emails);
254
+ if (domains.length === 0) return [];
255
+
256
+ const threats = [];
257
+ for (const domain of domains) {
258
+ let rdap;
259
+ try {
260
+ rdap = await fetchFn(domain);
261
+ } catch (err) {
262
+ debugLog('[RDAP] unexpected error for ' + domain + ': ' + err.message);
263
+ continue;
264
+ }
265
+ if (!rdap || !rdap.creationDate) continue;
266
+ if (isCompromisedDomain(rdap.creationDate, meta.created_at)) {
267
+ const cd = rdap.creationDate.slice(0, 10);
268
+ const pd = meta.created_at.slice(0, 10);
269
+ threats.push({
270
+ type: 'compromised_email_domain',
271
+ severity: 'HIGH',
272
+ message: 'Maintainer email domain "' + domain + '" was registered on ' + cd
273
+ + ' AFTER the package was first published on ' + pd
274
+ + ' — likely domain takeover / account compromise indicator.',
275
+ file: 'package.json',
276
+ count: 1,
277
+ domain,
278
+ creation_date: rdap.creationDate,
279
+ package_created_at: meta.created_at
280
+ });
281
+ }
282
+ }
283
+ return threats;
284
+ }
285
+
286
+ function _resetRdapCache() { _rdapCache.clear(); }
287
+
135
288
  module.exports = {
136
289
  checkUnclaimedMaintainerEmail,
137
290
  extractDomain,
@@ -139,5 +292,13 @@ module.exports = {
139
292
  hasMxRecord,
140
293
  _resetCache,
141
294
  MX_TIMEOUT_MS,
142
- MX_CACHE_TTL
295
+ MX_CACHE_TTL,
296
+ // F1 exports
297
+ checkCompromisedDomain,
298
+ fetchRdap,
299
+ isCompromisedDomain,
300
+ _resetRdapCache,
301
+ RDAP_TIMEOUT_MS,
302
+ RDAP_CACHE_TTL,
303
+ COMPROMISE_MARGIN_MS
143
304
  };