muaddib-scanner 2.11.34 → 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
|
@@ -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, checkCompromisedDomain } = 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,30 @@ 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
|
+
//
|
|
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.
|
|
235
|
+
if (_pkgMeta && _pkgMeta.npmRegistryMeta) {
|
|
236
|
+
try {
|
|
237
|
+
const emailThreats = await checkUnclaimedMaintainerEmail(_pkgMeta.npmRegistryMeta);
|
|
238
|
+
for (const t of emailThreats) deduped.push(t);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
debugLog('[EMAIL-DOMAIN] check failed: ' + err.message);
|
|
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
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
225
250
|
// Cross-scanner compound: detached_process + suspicious_dataflow in same file
|
|
226
251
|
// Catches cases where credential flow is detected by dataflow scanner, not AST scanner
|
|
227
252
|
{
|
package/src/rules/index.js
CHANGED
|
@@ -1500,6 +1500,31 @@ 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
|
+
},
|
|
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
|
+
},
|
|
1503
1528
|
|
|
1504
1529
|
// Canary token detections
|
|
1505
1530
|
canary_exfiltration: {
|
|
@@ -0,0 +1,304 @@
|
|
|
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
|
+
// =============================================================================
|
|
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
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
checkUnclaimedMaintainerEmail,
|
|
290
|
+
extractDomain,
|
|
291
|
+
uniqueDomains,
|
|
292
|
+
hasMxRecord,
|
|
293
|
+
_resetCache,
|
|
294
|
+
MX_TIMEOUT_MS,
|
|
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
|
|
304
|
+
};
|
|
@@ -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
|