muaddib-scanner 2.11.34 → 2.11.35

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.34",
3
+ "version": "2.11.35",
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:02.984Z",
3
+ "timestamp": "2026-05-24T21:03:08.335Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -10,6 +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
14
 
14
15
  // Auto-sandbox compound trigger : optional out-of-tree dependency. Lazy-load
15
16
  // it so the pipeline still works when the file is absent (some dev machines
@@ -222,6 +223,20 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
222
223
  debugLog('[RELEASE-ZERO] check failed: ' + err.message);
223
224
  }
224
225
 
226
+ // F3 — unclaimed maintainer email domain (DNS MX). Best-effort, silent on
227
+ // network failure (per feedback_weak_signals_composite_scoring). Severity
228
+ // HIGH × confidence medium = 8.5 points isolated → composite-only signal.
229
+ // Skipped automatically when MUADDIB_NO_REGISTRY_FETCH=1 (no meta available)
230
+ // or MUADDIB_EMAIL_DOMAIN_CHECK=0 (explicit opt-out).
231
+ if (_pkgMeta && _pkgMeta.npmRegistryMeta) {
232
+ try {
233
+ const emailThreats = await checkUnclaimedMaintainerEmail(_pkgMeta.npmRegistryMeta);
234
+ for (const t of emailThreats) deduped.push(t);
235
+ } catch (err) {
236
+ debugLog('[EMAIL-DOMAIN] check failed: ' + err.message);
237
+ }
238
+ }
239
+
225
240
  // Cross-scanner compound: detached_process + suspicious_dataflow in same file
226
241
  // Catches cases where credential flow is detected by dataflow scanner, not AST scanner
227
242
  {
@@ -1500,6 +1500,18 @@ const RULES = {
1500
1500
  ],
1501
1501
  mitre: 'T1195.002'
1502
1502
  },
1503
+ unclaimed_maintainer_email: {
1504
+ id: 'MUADDIB-MAINTAINER-005',
1505
+ name: 'Unclaimed Maintainer Email Domain',
1506
+ severity: 'HIGH',
1507
+ confidence: 'medium',
1508
+ description: 'Le domaine de l\'email du mainteneur n\'a aucun MX record valide. Un attaquant peut enregistrer le domaine, creer la boite mail, declencher un reset de mot de passe npm, prendre le compte. Signal composite-only (HIGH x medium = 8.5 pts isole, sous T1).',
1509
+ references: [
1510
+ 'https://github.com/DataDog/guarddog/blob/main/guarddog/analyzer/metadata/npm/unclaimed_maintainer_email_domain.py',
1511
+ 'https://attack.mitre.org/techniques/T1556/'
1512
+ ],
1513
+ mitre: 'T1556'
1514
+ },
1503
1515
 
1504
1516
  // Canary token detections
1505
1517
  canary_exfiltration: {
@@ -0,0 +1,143 @@
1
+ // F3 — Unclaimed maintainer email domain detection.
2
+ //
3
+ // Threat model: if the maintainer's email domain has no valid MX record, the
4
+ // domain is "unclaimed" for mail. An attacker can register the domain, create
5
+ // the mailbox, trigger an npm password-reset, take over the account.
6
+ //
7
+ // Design constraints:
8
+ // - HIGH × confidence_medium = 8.5 points → composite-only (sub-T1).
9
+ // This signal MUST never trigger an alert in isolation; it only contributes
10
+ // to scoring alongside other indicators.
11
+ // - Network failures (timeout, ESERVFAIL, etc.) are SILENT (debug-only logs).
12
+ // No retries. rdap.org-style community redirectors are best-effort and the
13
+ // scan must not block or spam logs on flaky ccTLD DNS.
14
+ // - 30-day in-process cache (positive AND negative) keyed by domain.
15
+ //
16
+ // Inspired by GuardDog's npm/unclaimed_maintainer_email_domain.py.
17
+
18
+ const dns = require('dns');
19
+ const { debugLog } = require('../utils.js');
20
+
21
+ const MX_TIMEOUT_MS = 3000;
22
+ const MX_CACHE_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
23
+
24
+ // In-process cache: domain → { hasMx: bool|null, fetchedAt: ms }
25
+ // hasMx === null = uncertain (transient error), don't cache long-term — but
26
+ // we DO cache it short-term to avoid re-querying within the same scan batch.
27
+ const _mxCache = new Map();
28
+
29
+ function extractDomain(email) {
30
+ if (!email || typeof email !== 'string') return null;
31
+ const at = email.lastIndexOf('@');
32
+ if (at <= 0 || at >= email.length - 1) return null;
33
+ const domain = email.slice(at + 1).toLowerCase().trim();
34
+ // Basic sanity: must contain a dot, no whitespace, reasonable length
35
+ if (!domain.includes('.') || /\s/.test(domain) || domain.length > 253) return null;
36
+ return domain;
37
+ }
38
+
39
+ function uniqueDomains(emails) {
40
+ const set = new Set();
41
+ for (const e of emails || []) {
42
+ const d = extractDomain(e);
43
+ if (d) set.add(d);
44
+ }
45
+ return Array.from(set);
46
+ }
47
+
48
+ async function resolveMxWithTimeout(resolveMx, domain, timeoutMs) {
49
+ let timer = null;
50
+ try {
51
+ return await Promise.race([
52
+ resolveMx(domain),
53
+ new Promise((_, reject) => {
54
+ timer = setTimeout(() => reject(Object.assign(new Error('DNS_TIMEOUT'), { code: 'DNS_TIMEOUT' })), timeoutMs);
55
+ })
56
+ ]);
57
+ } finally {
58
+ if (timer) clearTimeout(timer);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Returns true if the domain has at least one MX record, false if it
64
+ * definitively has none (ENOTFOUND/ENODATA), null on transient/uncertain
65
+ * errors (timeout/ESERVFAIL/etc — treat as "skip silently").
66
+ */
67
+ async function hasMxRecord(resolveMx, domain) {
68
+ const cached = _mxCache.get(domain);
69
+ if (cached && (Date.now() - cached.fetchedAt) < MX_CACHE_TTL) {
70
+ return cached.hasMx;
71
+ }
72
+ let hasMx;
73
+ try {
74
+ const records = await resolveMxWithTimeout(resolveMx, domain, MX_TIMEOUT_MS);
75
+ hasMx = Array.isArray(records) && records.length > 0;
76
+ } catch (err) {
77
+ const code = err && err.code;
78
+ if (code === 'ENOTFOUND' || code === 'ENODATA') {
79
+ hasMx = false;
80
+ } else {
81
+ // Timeout, ESERVFAIL, EREFUSED, network-down: uncertain → skip silently.
82
+ debugLog('[EMAIL-DOMAIN] MX lookup uncertain for ' + domain + ': ' + (code || err.message));
83
+ // Short-cache the uncertainty so we don't re-query during the same scan
84
+ _mxCache.set(domain, { hasMx: null, fetchedAt: Date.now() });
85
+ return null;
86
+ }
87
+ }
88
+ _mxCache.set(domain, { hasMx, fetchedAt: Date.now() });
89
+ return hasMx;
90
+ }
91
+
92
+ /**
93
+ * F3 entry point.
94
+ * @param {object|null} meta - Digested metadata from getPackageMetadata.
95
+ * Reads meta.maintainer_emails (string[]).
96
+ * @param {object} options - { resolveMx } for tests to inject a mock resolver.
97
+ * @returns {Promise<Array>} threats array (empty when disabled, offline, or no email)
98
+ */
99
+ async function checkUnclaimedMaintainerEmail(meta, options = {}) {
100
+ // Opt-out for offline / air-gapped scans
101
+ if (globalThis.process.env.MUADDIB_EMAIL_DOMAIN_CHECK === '0') return [];
102
+ if (!meta || !Array.isArray(meta.maintainer_emails) || meta.maintainer_emails.length === 0) {
103
+ return [];
104
+ }
105
+ const resolveMx = options.resolveMx || dns.promises.resolveMx;
106
+ const domains = uniqueDomains(meta.maintainer_emails);
107
+ if (domains.length === 0) return [];
108
+
109
+ const threats = [];
110
+ for (const domain of domains) {
111
+ let hasMx;
112
+ try {
113
+ hasMx = await hasMxRecord(resolveMx, domain);
114
+ } catch (err) {
115
+ debugLog('[EMAIL-DOMAIN] unexpected error for ' + domain + ': ' + err.message);
116
+ continue;
117
+ }
118
+ if (hasMx === false) {
119
+ threats.push({
120
+ type: 'unclaimed_maintainer_email',
121
+ severity: 'HIGH',
122
+ message: 'Maintainer email domain "' + domain + '" has no MX record — unclaimed mailbox, attacker can register the domain to receive a password-reset and take over the account.',
123
+ file: 'package.json',
124
+ count: 1,
125
+ domain
126
+ });
127
+ }
128
+ }
129
+ return threats;
130
+ }
131
+
132
+ // Exposed for tests
133
+ function _resetCache() { _mxCache.clear(); }
134
+
135
+ module.exports = {
136
+ checkUnclaimedMaintainerEmail,
137
+ extractDomain,
138
+ uniqueDomains,
139
+ hasMxRecord,
140
+ _resetCache,
141
+ MX_TIMEOUT_MS,
142
+ MX_CACHE_TTL
143
+ };
@@ -117,6 +117,21 @@ async function getPackageMetadata(packageName) {
117
117
  || meta.maintainers?.[0]?.name
118
118
  || null;
119
119
 
120
+ // F3 — extract ALL maintainer emails (latest version + top-level merged,
121
+ // deduped) for unclaimed-domain MX check downstream.
122
+ const maintainerEmails = (() => {
123
+ const out = new Set();
124
+ const sources = [
125
+ ...(Array.isArray(latestMeta?.maintainers) ? latestMeta.maintainers : []),
126
+ ...(Array.isArray(meta.maintainers) ? meta.maintainers : [])
127
+ ];
128
+ for (const m of sources) {
129
+ const e = m && typeof m === 'object' ? m.email : null;
130
+ if (typeof e === 'string' && e.includes('@')) out.add(e.toLowerCase().trim());
131
+ }
132
+ return Array.from(out);
133
+ })();
134
+
120
135
  const readmeText = meta.readme || '';
121
136
  const hasReadme = readmeText.length > 100;
122
137
 
@@ -182,6 +197,9 @@ async function getPackageMetadata(packageName) {
182
197
  // / pinned-old / vendored versions bypass the cap so we don't mask attacks
183
198
  // captured in static fixtures (e.g. eslint-scope 3.7.2, chalk 5.6.1).
184
199
  latest_version: latestVersion || null,
200
+ // F3 : list of maintainer email addresses (lowercased, unique) for DNS
201
+ // MX / RDAP downstream checks. Empty array if no emails published.
202
+ maintainer_emails: maintainerEmails,
185
203
  // C3 : per-version publish timestamps for delta-mode selectPriorVersions.
186
204
  time: versionTimes,
187
205
  ...advancedSignals