@slicedearth/whoisleuth-cli 2.5.1 → 2.7.0

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.
Files changed (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -99,15 +99,19 @@ function withTimeout(promise, timeoutMs) {
99
99
  });
100
100
  }
101
101
  async function defaultAuthorityQuery(input) {
102
+ input.signal?.throwIfAborted();
102
103
  const resolver = new dns.Resolver({
103
104
  timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
104
105
  tries: 1,
105
106
  });
106
107
  resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
108
+ const cancelled = () => resolver.cancel();
109
+ input.signal?.addEventListener('abort', cancelled, { once: true });
107
110
  const [nameservers, soa] = await Promise.allSettled([
108
111
  resolver.resolveNs(input.domain),
109
112
  resolver.resolveSoa(input.domain),
110
- ]);
113
+ ]).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
114
+ input.signal?.throwIfAborted();
111
115
  const reason = nameservers.status === 'rejected'
112
116
  ? nameservers.reason
113
117
  : soa.status === 'rejected'
@@ -188,18 +192,21 @@ function normaliseAuthorityValues(type, value) {
188
192
  return normaliseAuthorityValueSet(type, value).values;
189
193
  }
190
194
  async function defaultAuthorityRecordQuery(input) {
195
+ input.signal?.throwIfAborted();
191
196
  const resolver = new dns.Resolver({
192
197
  timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
193
198
  tries: 1,
194
199
  });
195
200
  resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
201
+ const cancelled = () => resolver.cancel();
202
+ input.signal?.addEventListener('abort', cancelled, { once: true });
196
203
  const queries = [
197
204
  ['A', resolver.resolve4(input.domain)],
198
205
  ['AAAA', resolver.resolve6(input.domain)],
199
206
  ['CAA', resolver.resolveCaa(input.domain)],
200
207
  ['MX', resolver.resolveMx(input.domain)],
201
208
  ];
202
- return Promise.all(queries.map(async ([type, promise]) => {
209
+ const result = await Promise.all(queries.map(async ([type, promise]) => {
203
210
  try {
204
211
  const response = await withTimeout(promise, input.timeoutMs);
205
212
  const normalised = normaliseAuthorityValueSet(type, response);
@@ -224,7 +231,9 @@ async function defaultAuthorityRecordQuery(input) {
224
231
  discarded: 0,
225
232
  };
226
233
  }
227
- }));
234
+ })).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
235
+ input.signal?.throwIfAborted();
236
+ return result;
228
237
  }
229
238
  function authorityRecordMatrix(authorities) {
230
239
  const types = ['A', 'AAAA', 'CAA', 'MX'];
@@ -318,6 +327,7 @@ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks we
318
327
  };
319
328
  }
320
329
  async function collectDnsDelegationHealth(domainValue, parentQueryValue, options = {}) {
330
+ options.signal?.throwIfAborted();
321
331
  const domain = hostname(domainValue);
322
332
  if (!domain)
323
333
  return skippedDnsDelegationHealth('The domain was not eligible for authoritative delegation checks.');
@@ -338,7 +348,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
338
348
  const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
339
349
  const now = options.now || Date.now;
340
350
  const started = now();
341
- const authorities = await Promise.all(candidates.map(async (nameserver) => {
351
+ const pending = candidates.map(async (nameserver) => {
352
+ options.signal?.throwIfAborted();
342
353
  const glue = registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses ?? [];
343
354
  const resolved = glue.length
344
355
  ? glue
@@ -350,10 +361,11 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
350
361
  : [])
351
362
  .map(publicAddress)
352
363
  .filter((address) => address !== null);
364
+ options.signal?.throwIfAborted();
353
365
  const addresses = [...new Set(resolved)].slice(0, MAX_AUTHORITY_ADDRESSES);
354
366
  const attempts = await Promise.all(addresses.map(async (address) => {
355
367
  try {
356
- const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs }), timeoutMs);
368
+ const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs);
357
369
  const query = record(raw);
358
370
  const code = boundedText(query.errorCode, 40);
359
371
  const error = boundedText(query.error, MAX_ERROR_LENGTH);
@@ -384,6 +396,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
384
396
  };
385
397
  }
386
398
  }));
399
+ options.signal?.throwIfAborted();
387
400
  const successful = attempts.find((attempt) => attempt.state === 'success');
388
401
  const partial = attempts.find((attempt) => attempt.state === 'partial');
389
402
  const retained = successful ?? partial;
@@ -391,7 +404,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
391
404
  const recordAddress = retained?.address ?? addresses[0];
392
405
  if (queryAuthorityRecords && recordAddress) {
393
406
  try {
394
- recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs }), timeoutMs))
407
+ recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs))
395
408
  .filter((item) => ['A', 'AAAA', 'CAA', 'MX'].includes(item.type))
396
409
  .slice(0, 4)
397
410
  .map((item) => {
@@ -428,6 +441,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
428
441
  }));
429
442
  }
430
443
  }
444
+ options.signal?.throwIfAborted();
431
445
  return {
432
446
  nameserver,
433
447
  addressSource: glue.length ? 'registry_glue' : 'recursive_address',
@@ -445,7 +459,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
445
459
  recordSets,
446
460
  attempts,
447
461
  };
448
- }));
462
+ });
463
+ const authorities = await Promise.all(pending).finally(() => Promise.allSettled(pending));
464
+ options.signal?.throwIfAborted();
449
465
  const successfulAuthorities = authorities.filter((authority) => authority.state === 'success');
450
466
  const partialAuthorities = authorities.filter((authority) => authority.state === 'partial');
451
467
  const lameAuthorities = authorities.filter((authority) => authority.state === 'lame');
@@ -5,6 +5,7 @@ import { requireDmarcDomain, walkDmarcTree } from "./dmarc-discovery.mjs";
5
5
  import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
6
6
  import { defaultTcpExchange } from "./service-binding-dns.mjs";
7
7
  import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
8
+ import { normalizeDmarcPolicyContext } from "../packages/evidence/dmarc-policy-context.mjs";
8
9
  const MAX_PARENT_SERVERS = 2;
9
10
  const MAX_NS = 16;
10
11
  const QUERY_TIMEOUT_MS = 2200;
@@ -35,7 +36,19 @@ export async function reviewInheritedDmarc(domain, exact, resolve) {
35
36
  ? `Published policy: ${selected.parsed.policy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}.`
36
37
  : `For an existing name: ${selected.parsed.subdomainPolicy}. For a nonexistent name: ${selected.parsed.nonexistentSubdomainPolicy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}. Name existence is not inferred from this review.`
37
38
  : 'Invalid or unavailable records do not establish absence or a usable inherited policy.';
38
- return check('dmarc_inheritance', 'Inherited DMARC policy', incomplete || !selected ? 'warning' : 'info', summary, `${detail} Discovery follows RFC 9989 with at most eight owners. This is publication evidence, not message authentication or a guarantee of receiver behaviour.`, rows.map(row => row.record));
39
+ const effective = !incomplete ? selected : null;
40
+ return {
41
+ ...check('dmarc_inheritance', 'Inherited DMARC policy', incomplete || !selected ? 'warning' : 'info', summary, `${detail} Discovery follows RFC 9989 with at most eight owners. This is publication evidence, not message authentication or a guarantee of receiver behaviour.`, rows.map(row => row.record)),
42
+ dmarcPolicyContext: normalizeDmarcPolicyContext({
43
+ version: 1, source: 'recursive_dns_txt', complete: !incomplete,
44
+ relation: effective ? effective.owner === domain ? 'exact' : 'inherited' : 'undetermined',
45
+ owner: effective?.owner ?? null, observedAt: effective?.observedAt ?? null,
46
+ policy: effective?.parsed.policy ?? null, subdomainPolicy: effective?.parsed.subdomainPolicy ?? null,
47
+ nonexistentPolicy: effective?.parsed.nonexistentSubdomainPolicy ?? null,
48
+ psd: effective?.psd ?? null, testMode: effective?.parsed.testMode ?? null,
49
+ discovery: rows.map(row => ({ owner: row.owner, state: row.state, observedAt: row.observedAt })),
50
+ }, domain),
51
+ };
39
52
  }
40
53
  function nameservers(value) {
41
54
  if (!Array.isArray(value) || value.length > MAX_NS)
@@ -416,6 +416,7 @@ function observedPresence(result) {
416
416
  return result.records.length > 0 ? true : completeQuery(result) ? false : null;
417
417
  }
418
418
  async function collectEffectiveCaaPolicy(domain, options = {}) {
419
+ options.signal?.throwIfAborted();
419
420
  const normalizedDomain = normalizeHostname(domain);
420
421
  const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
421
422
  const now = options.now || Date.now;
@@ -455,9 +456,11 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
455
456
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
456
457
  const queries = [];
457
458
  for (const [index, owner] of owners.entries()) {
459
+ options.signal?.throwIfAborted();
458
460
  const result = index === 0 && options.directResult
459
461
  ? options.directResult
460
462
  : await query(() => resolver(owner), normalizeCaa, timeoutMs);
463
+ options.signal?.throwIfAborted();
461
464
  queries.push({
462
465
  owner,
463
466
  status: result.status,
@@ -582,6 +585,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
582
585
  };
583
586
  }
584
587
  async function collectDnsIntelligence(domain, options = {}) {
588
+ options.signal?.throwIfAborted();
585
589
  const registrationDomain = options.registrationDomain ?? domain;
586
590
  if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
587
591
  throw new TypeError('DNS registration context must match the hostname being observed.');
@@ -592,7 +596,10 @@ async function collectDnsIntelligence(domain, options = {}) {
592
596
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
593
597
  const now = options.now || Date.now;
594
598
  const started = now();
595
- const invoke = (name, fallback, value = domain) => () => (resolvers[name] || fallback)(value);
599
+ const invoke = (name, fallback, value = domain) => () => {
600
+ options.signal?.throwIfAborted();
601
+ return (resolvers[name] || fallback)(value);
602
+ };
596
603
  const aPromise = query(invoke('resolve4', dns.resolve4), (records) => normalizeAddresses(records, 4), timeoutMs);
597
604
  const aaaaPromise = query(invoke('resolve6', dns.resolve6), (records) => normalizeAddresses(records, 6), timeoutMs);
598
605
  const cnamePromise = query(invoke('resolveCname', dns.resolveCname), normalizeHostnames, timeoutMs);
@@ -607,6 +614,7 @@ async function collectDnsIntelligence(domain, options = {}) {
607
614
  directResult,
608
615
  timeoutMs,
609
616
  now,
617
+ ...(options.signal ? { signal: options.signal } : {}),
610
618
  ...(options.observedAt ? { observedAt: options.observedAt } : {}),
611
619
  }))
612
620
  : Promise.resolve(null);
@@ -614,7 +622,7 @@ async function collectDnsIntelligence(domain, options = {}) {
614
622
  ? query(invoke('resolveSoa', dns.resolveSoa), normalizeSoa, timeoutMs)
615
623
  : Promise.resolve(null);
616
624
  const httpsPromise = includeExtendedContext
617
- ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
625
+ ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs, ...(options.signal ? { signal: options.signal } : {}) })), normalizeServiceBindings, timeoutMs)
618
626
  : Promise.resolve(null);
619
627
  const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
620
628
  ? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
@@ -627,10 +635,11 @@ async function collectDnsIntelligence(domain, options = {}) {
627
635
  ...(options.queryAuthority ? { queryAuthority: options.queryAuthority } : {}),
628
636
  timeoutMs,
629
637
  now,
638
+ ...(options.signal ? { signal: options.signal } : {}),
630
639
  ...(options.observedAt ? { observedAt: options.observedAt } : {}),
631
640
  }))
632
641
  : Promise.resolve(null);
633
- const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all([
642
+ const pending = [
634
643
  aPromise,
635
644
  aaaaPromise,
636
645
  cnamePromise,
@@ -643,7 +652,12 @@ async function collectDnsIntelligence(domain, options = {}) {
643
652
  soaPromise,
644
653
  httpsPromise,
645
654
  delegationPromise,
646
- ]);
655
+ ];
656
+ // A rejected continuation must not release the lookup while sibling queries
657
+ // are still running. Their existing bounded resolver deadlines remain active.
658
+ const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all(pending)
659
+ .finally(() => Promise.allSettled(pending));
660
+ options.signal?.throwIfAborted();
647
661
  const queries = {
648
662
  a, aaaa, cname, ns, mx, spf, dmarc, caa,
649
663
  ...(soa ? { soa } : {}),
@@ -4,6 +4,48 @@
4
4
  import { parse as parseDomain } from 'tldts';
5
5
  import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
6
6
  import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
7
+ const SPF_PERMISSIVE_PATH_ISSUE = 'Observed permissive +all evidence on a potentially reachable positive SPF include or redirect path; review sender authorisation.';
8
+ // This is a conservative universal-result check for unconditional all/include
9
+ // chains, not a sender evaluator. Conditional mechanisms remain unknown.
10
+ function spfStaticResult(policy) {
11
+ const parsed = policy.parsed;
12
+ if (!parsed?.valid)
13
+ return { everySender: null, permissive: false };
14
+ let uncertain = false, permissive = false;
15
+ const finish = (result) => ({
16
+ everySender: !uncertain ? result : null,
17
+ permissive,
18
+ });
19
+ for (const mechanism of parsed.mechanisms) {
20
+ if (mechanism.kind === 'all') {
21
+ if (mechanism.qualifier === '+')
22
+ permissive = true;
23
+ return finish(mechanism.qualifier);
24
+ }
25
+ if (mechanism.kind === 'include') {
26
+ const child = policy.children.get(mechanism.includeIndex);
27
+ const result = child ? spfStaticResult(child) : { everySender: null, permissive: false };
28
+ if (mechanism.qualifier === '+' && result.permissive)
29
+ permissive = true;
30
+ if (result.everySender === '+')
31
+ return finish(mechanism.qualifier);
32
+ if (result.everySender !== null)
33
+ continue; // Only child pass matches include.
34
+ uncertain = true;
35
+ }
36
+ else {
37
+ // Without sender evaluation, even a literal mechanism remains conditional.
38
+ uncertain = true;
39
+ }
40
+ }
41
+ if (parsed.redirect) {
42
+ const child = policy.children.get(parsed.includes.length);
43
+ const result = child ? spfStaticResult(child) : { everySender: null, permissive: false };
44
+ permissive ||= result.permissive;
45
+ return result.everySender === null ? { everySender: null, permissive } : finish(result.everySender);
46
+ }
47
+ return { everySender: null, permissive };
48
+ }
7
49
  const SPF_LOOKUP_LIMIT = 10;
8
50
  const SPF_VOID_LOOKUP_LIMIT = 2;
9
51
  const SPF_MAX_DEPTH = 5;
@@ -41,6 +83,7 @@ function branchFor(item, state, terminalPolicy, dnsLookupTerms, issues) {
41
83
  };
42
84
  }
43
85
  async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
86
+ const rootPolicy = { parsed: null, children: new Map() };
44
87
  const queue = [{
45
88
  domain: rootDomain,
46
89
  parent: null,
@@ -49,6 +92,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
49
92
  path: [rootDomain],
50
93
  records: rootQuery.records,
51
94
  error: rootQuery.error,
95
+ policy: rootPolicy,
52
96
  }];
53
97
  const branches = [];
54
98
  const issues = [];
@@ -100,6 +144,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
100
144
  branches.push(branchFor(item, parsed.valid ? 'success' : 'invalid', parsed.terminalPolicy, parsed.dnsLookupTerms, parsed.issues));
101
145
  if (!parsed.valid)
102
146
  continue;
147
+ item.policy.parsed = parsed;
103
148
  if (dnsLookupTerms > SPF_LOOKUP_LIMIT) {
104
149
  boundReached = true;
105
150
  issues.push(`The expanded policy declares more than ${SPF_LOOKUP_LIMIT} DNS-querying terms.`);
@@ -111,7 +156,9 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
111
156
  ...parsed.includes.map((domain) => ({ relation: 'include', domain })),
112
157
  ...(parsed.redirect ? [{ relation: 'redirect', domain: parsed.redirect }] : []),
113
158
  ];
114
- for (const dependency of dependencies) {
159
+ for (const [dependencyIndex, dependency] of dependencies.entries()) {
160
+ const policy = { parsed: null, children: new Map() };
161
+ item.policy.children.set(dependencyIndex, policy);
115
162
  const normalized = strictHostname(dependency.domain);
116
163
  if (!normalized) {
117
164
  branches.push(branchFor({
@@ -120,6 +167,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
120
167
  relation: dependency.relation,
121
168
  depth: item.depth + 1,
122
169
  path: item.path,
170
+ policy,
123
171
  }, 'invalid', null, 0, ['The SPF dependency is not a literal public hostname; macros are not expanded by this audit.']));
124
172
  continue;
125
173
  }
@@ -129,6 +177,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
129
177
  relation: dependency.relation,
130
178
  depth: item.depth + 1,
131
179
  path: [...item.path, normalized],
180
+ policy,
132
181
  });
133
182
  }
134
183
  }
@@ -145,6 +194,8 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
145
194
  : incomplete
146
195
  ? 'partial'
147
196
  : 'complete';
197
+ if (rootPolicy.parsed?.terminalPolicy !== 'pass' && spfStaticResult(rootPolicy).permissive)
198
+ issues.unshift(SPF_PERMISSIVE_PATH_ISSUE);
148
199
  return {
149
200
  version: 1,
150
201
  state,
@@ -293,4 +344,4 @@ function buildExternalDependencies({ domain, nameservers, mx, spfExpansion, dmar
293
344
  || left.target.localeCompare(right.target)
294
345
  || left.source.localeCompare(right.source)));
295
346
  }
296
- export { SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };
347
+ export { SPF_PERMISSIVE_PATH_ISSUE, SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };
@@ -48,6 +48,7 @@ function parseSpfRecords(records) {
48
48
  terminalPolicy: null,
49
49
  redirect: null,
50
50
  includes: [],
51
+ mechanisms: [],
51
52
  authorizingTerms: [],
52
53
  dnsLookupTerms: 0,
53
54
  issues: [],
@@ -175,6 +176,14 @@ function parseSpfRecords(records) {
175
176
  result.includes = terms
176
177
  .map((term) => term.match(/^[+?~-]?include:(.+)$/i)?.[1] || '')
177
178
  .filter(Boolean);
179
+ let includeIndex = 0;
180
+ result.mechanisms = terms.filter(term => !/^[+?~-]?[a-z][a-z0-9_.-]*=/iu.test(term)).map(term => {
181
+ const qualifier = (/^[+?~-]/u.test(term) ? term[0] : '+');
182
+ const body = /^[+?~-]/u.test(term) ? term.slice(1) : term;
183
+ const include = body.match(/^include:(.+)$/iu);
184
+ return { kind: /^all$/iu.test(body) ? 'all' : include ? 'include' : 'other', qualifier,
185
+ includeIndex: include ? includeIndex++ : null };
186
+ });
178
187
  if (terms.some((term) => /^[+?~-]?ptr(?::|$)/i.test(term))) {
179
188
  result.issues.push('The deprecated ptr mechanism should not be used.');
180
189
  }
@@ -12,7 +12,7 @@ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.
12
12
  import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECK_RECORDS, POSTURE_CHECK_SOURCES } from "../packages/evidence/domain-posture-context.mjs";
13
13
  import { canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
14
14
  import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
15
- import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
15
+ import { SPF_PERMISSIVE_PATH_ISSUE, buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
16
16
  const DNS_TIMEOUT_MS = 6000;
17
17
  const POLICY_TIMEOUT_MS = 7000;
18
18
  const MAX_POLICY_BYTES = 64 * 1024;
@@ -156,7 +156,8 @@ function spfCheck(query, expansion) {
156
156
  remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
157
157
  });
158
158
  }
159
- if (parsed.terminalPolicy === 'fail' && parsed.issues.length === 0 && (!expansion || expansion.state === 'complete')) {
159
+ const permissiveExpansion = expansion?.issues.includes(SPF_PERMISSIVE_PATH_ISSUE) ?? false;
160
+ if (parsed.terminalPolicy === 'fail' && parsed.issues.length === 0 && !permissiveExpansion && (!expansion || expansion.state === 'complete')) {
160
161
  return check('spf', 'SPF', 'pass', 'Restrictive fail-all policy', { detail: details.join(' '), records: parsed.records });
161
162
  }
162
163
  if (expansion && expansion.state !== 'complete') {
@@ -166,6 +167,12 @@ function spfCheck(query, expansion) {
166
167
  remediation: 'Review unresolved, invalid, cyclic, or budget-limited include and redirect branches before treating the policy as complete.',
167
168
  });
168
169
  }
170
+ if (permissiveExpansion) {
171
+ return check('spf', 'SPF', 'warning', 'SPF authorisation needs review', {
172
+ detail: details.join(' '), records: parsed.records,
173
+ remediation: 'Review the positive include or redirect path containing +all; complete collection does not establish restrictive sender authorisation.',
174
+ });
175
+ }
169
176
  if (parsed.terminalPolicy === 'redirect') {
170
177
  return check('spf', 'SPF', 'info', 'Policy delegates evaluation with redirect', {
171
178
  detail: details.join(' '), records: parsed.records,
@@ -301,6 +308,11 @@ function dnssecDelegationConsistencyCheck(input, registry) {
301
308
  }
302
309
  const value = String(input.value || '').toLowerCase();
303
310
  const hasDsRecords = registry.dsRecordCount > 0;
311
+ if (registry.dsDataTruncated) {
312
+ return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
313
+ detail: 'The normalised registry response reported truncated DS data, so delegation consistency is inconclusive.',
314
+ });
315
+ }
304
316
  if (value === 'signed' && hasDsRecords) {
305
317
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'pass', 'Signed state and retained DS records agree', {
306
318
  detail: `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} retained.`,
@@ -311,11 +323,6 @@ function dnssecDelegationConsistencyCheck(input, registry) {
311
323
  detail: 'This is a consistency result, not a recommendation to leave DNSSEC disabled.',
312
324
  });
313
325
  }
314
- if (registry.dsDataTruncated) {
315
- return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
316
- detail: 'The normalised registry response reported truncated DS data, so apparent disagreement is inconclusive.',
317
- });
318
- }
319
326
  if (value === 'signed' && !hasDsRecords) {
320
327
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'warning', 'Signed state reported without retained DS records', {
321
328
  detail: 'This can reflect registry publication differences or incomplete upstream data and should be reviewed before changing delegation.',
@@ -360,13 +367,25 @@ function matchesMtaPattern(host, pattern) {
360
367
  }
361
368
  return normalizedHost === normalizedPattern;
362
369
  }
370
+ function mailPolicyApplicability(mxQuery) {
371
+ if (mxQuery.error)
372
+ return { hasMx: false, detail: 'Receiving-mail applicability is unknown because the MX lookup failed.' };
373
+ const records = completeMxRecords(mxQuery.records);
374
+ if (!records)
375
+ return { hasMx: false, detail: 'Receiving-mail applicability is unknown because the MX evidence is incomplete.' };
376
+ const { hasMx, hasNullMx } = classifyMxRecords(records);
377
+ return { hasMx, detail: hasMx ? '' : hasNullMx
378
+ ? 'Null MX explicitly declares that this domain does not accept inbound mail.'
379
+ : 'No MX records were observed. This alone does not establish that the domain cannot receive mail.' };
380
+ }
363
381
  function mtaStsCheck(dnsQuery, policyFetch, mxQuery) {
364
382
  if (dnsQuery.error)
365
383
  return queryFailureCheck('mta_sts', 'MTA-STS', dnsQuery.error);
366
384
  const dnsPolicy = parseMtaStsDnsRecords(dnsQuery.records);
367
- const hasMx = !mxQuery.error && classifyMxRecords(asMxRecords(mxQuery.records)).hasMx;
368
385
  if (dnsPolicy.records.length === 0) {
386
+ const { hasMx, detail } = mailPolicyApplicability(mxQuery);
369
387
  return check('mta_sts', 'MTA-STS', hasMx ? 'warning' : 'info', 'No MTA-STS policy advertised', {
388
+ detail,
370
389
  remediation: hasMx ? 'Publish _mta-sts and serve a valid policy over HTTPS to require authenticated TLS for inbound mail.' : '',
371
390
  });
372
391
  }
@@ -436,9 +455,9 @@ function tlsRptCheck(query, mxQuery) {
436
455
  return queryFailureCheck('tls_rpt', 'TLS-RPT', query.error);
437
456
  const parsed = parseTlsRptRecords(query.records);
438
457
  if (parsed.records.length === 0) {
439
- const hasMx = !mxQuery.error && classifyMxRecords(asMxRecords(mxQuery.records)).hasMx;
458
+ const { hasMx, detail } = mailPolicyApplicability(mxQuery);
440
459
  return check('tls_rpt', 'TLS-RPT', hasMx ? 'warning' : 'info', 'No SMTP TLS reporting policy', {
441
- detail: hasMx ? '' : 'The domain has no receiving mail exchanger, so SMTP TLS reporting is not currently actionable.',
460
+ detail,
442
461
  remediation: hasMx ? 'Publish a v=TLSRPTv1 record at _smtp._tls with a monitored rua destination.' : '',
443
462
  });
444
463
  }
@@ -13,7 +13,7 @@ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
13
13
  'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
14
14
  'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
15
15
  'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
16
- 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
16
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'hasExternalPasswordForm', 'externalAssetHosts',
17
17
  'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
18
18
  'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
19
19
  'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
@@ -105,6 +105,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
105
105
  // Credential, structured identity, role, behaviour, technology, and posture
106
106
  // projections. Only fixed categories/counts and bounded descriptions enter.
107
107
  'credentialSurfaceVersion', 'methods', 'actions', 'missing', 'get', 'post',
108
+ 'formAttribution', 'unassociatedInputs', 'index', 'destinations', 'relationship', 'origin',
108
109
  'dialog', 'other', 'sameOrigin', 'external', 'cleartext', 'unclassified',
109
110
  'inputs', 'classifiedCount', 'categories', 'password', 'email', 'username',
110
111
  'one_time_code', 'payment', 'inputsObserved', 'classifiedInputs',
@@ -322,6 +323,8 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legac
322
323
  return value;
323
324
  if (typeof value === 'string') {
324
325
  const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
326
+ if (path.join('.') === 'credentialSurfaceProfile.formAttribution.forms.destinations.origin')
327
+ return portableOrigin(value);
325
328
  if (PORTABLE_URL_KEYS.has(normalizedParent))
326
329
  return portableUri(value, legacyUris);
327
330
  if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
@@ -344,7 +347,7 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legac
344
347
  const boundedCount = Number.isSafeInteger(item)
345
348
  && Number(item) >= 0
346
349
  && Number(item) <= LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS;
347
- const credentialCategoryCount = parentPath === 'credentialSurfaceProfile.inputs.categories'
350
+ const credentialCategoryCount = ['credentialSurfaceProfile.inputs.categories', 'credentialSurfaceProfile.formAttribution.forms.categories'].includes(parentPath)
348
351
  && boundedCount;
349
352
  const sanClassCount = parentPath === 'tls.certificate.subjectAltNames.classes'
350
353
  && LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
@@ -6,7 +6,7 @@ import { buildRegistryInsights } from "./registry-insights.mjs";
6
6
  import { buildPortableGeneratorMetadata } from "../packages/analysis/portable-generator.mjs";
7
7
  import { assertBoundedJsonStructure } from "../packages/analysis/bounded-json.mjs";
8
8
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
9
- import { LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../packages/contracts/lookup-evidence.mjs";
9
+ import { LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, AUTHORITY_AWARE_LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../packages/contracts/lookup-evidence.mjs";
10
10
  export * from "../packages/contracts/lookup-evidence.mjs";
11
11
  import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
12
12
  import { validLookupObservationScope } from "../packages/evidence/lookup-target.mjs";
@@ -705,6 +705,7 @@ const LOOKUP_WHOIS_PORTABLE_SCALARS = Object.freeze({
705
705
  failedHop: nullableText(253),
706
706
  conflictingHop: nullableText(253),
707
707
  });
708
+ const WHOIS_REGISTRATION_STATUS = enumeration(new Set(['registered', 'not_found', 'inconclusive']));
708
709
  const LOOKUP_REGISTRY_INSIGHT_PORTABLE_KEYS = new Set([
709
710
  'version', 'contactDisclosure', 'lifecycle', 'reconciliation', 'publications',
710
711
  'rdapCapabilities',
@@ -815,11 +816,16 @@ export function projectLookupEvidenceRdapPublication(value) {
815
816
  return output;
816
817
  }
817
818
  /** Positive portable projection for normalized WHOIS publication fields. */
818
- export function projectLookupEvidenceWhoisPublication(value) {
819
+ export function projectLookupEvidenceWhoisPublication(value, schemaVersion = LOOKUP_EVIDENCE_SCHEMA_VERSION) {
819
820
  const input = recordOrNull(value);
820
821
  if (!input)
821
822
  return null;
822
823
  const output = projectedKnownScalarRecord(input, LOOKUP_WHOIS_PORTABLE_SCALARS) || {};
824
+ if (schemaVersion >= AUTHORITY_AWARE_LOOKUP_EVIDENCE_SCHEMA_VERSION && Object.hasOwn(input, 'registrationStatus')) {
825
+ const status = WHOIS_REGISTRATION_STATUS(input.registrationStatus);
826
+ if (status !== undefined)
827
+ output.registrationStatus = status;
828
+ }
823
829
  if (Object.hasOwn(input, 'lifecycle')) {
824
830
  output.lifecycle = projectedKnownScalarRecord(input.lifecycle, LOOKUP_RDAP_LIFECYCLE_SCALARS);
825
831
  }
@@ -884,7 +890,7 @@ function projectedWhoisChainHop(value) {
884
890
  };
885
891
  }
886
892
  /** Exact positive projection for one current-schema WHOIS source wrapper. */
887
- export function projectLookupEvidenceWhoisSourcePublication(value) {
893
+ export function projectLookupEvidenceWhoisSourcePublication(value, schemaVersion = LOOKUP_EVIDENCE_SCHEMA_VERSION) {
888
894
  const source = recordOrNull(value);
889
895
  if (!source)
890
896
  return null;
@@ -895,7 +901,7 @@ export function projectLookupEvidenceWhoisSourcePublication(value) {
895
901
  error: boundedSourceError(source.error, 'WHOIS source reported an error.'),
896
902
  };
897
903
  const hasPublication = status === 'complete' || status === 'partial';
898
- const parsed = hasPublication ? projectLookupEvidenceWhoisPublication(source.parsed) : null;
904
+ const parsed = hasPublication ? projectLookupEvidenceWhoisPublication(source.parsed, schemaVersion) : null;
899
905
  if (status === 'complete' && parsed === null)
900
906
  return null;
901
907
  return {
@@ -1,4 +1,5 @@
1
1
  import { registryAccessProfileLabel } from "./registry-access.mjs";
2
+ import { isSupportedRegistryInsightsVersion } from "../packages/contracts/registry-insights.mjs";
2
3
  import { dnsAddressFamilyEvidence } from "../packages/evidence/dns-query-outcome.mjs";
3
4
  import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../packages/analysis/portable-generator.mjs";
4
5
  import { technologyEvidenceRoles, } from "./technology-evidence-role.mjs";
@@ -267,6 +268,7 @@ function buildLookupEvidenceReport(document, options = {}) {
267
268
  reportField('Redirects', http.redirectCount),
268
269
  reportField('Page title', availability.pageTitle),
269
270
  reportField('Password field observed', yesNoUnknown(availability.hasPasswordField)),
271
+ reportField('Password form declares external destination', yesNoUnknown(availability.hasExternalPasswordForm)),
270
272
  reportField('Authoritative nameserver evidence', listText(availability.nameservers)),
271
273
  reportField('Observed edge, CDN, reverse proxy or WAF', reportTechnologyNames(technologyFindings, 'observed_edge')),
272
274
  reportField('Application-platform indicators', reportTechnologyNames(technologyFindings, 'application_platform')),
@@ -385,7 +387,7 @@ function buildLookupEvidenceReport(document, options = {}) {
385
387
  reportField('Standing limitations', listText(registrarStanding.limitations)),
386
388
  reportField('Review steps', listText(registrarStanding.nextActions)),
387
389
  ] : [],
388
- registryInterpretation: registryInsights.version === 1 ? [
390
+ registryInterpretation: isSupportedRegistryInsightsVersion(registryInsights.version) ? [
389
391
  reportField('Lifecycle', displayLabel(registryLifecycle.label)),
390
392
  reportField('Raw lifecycle statuses', listText(registryLifecycle.rawStatuses)),
391
393
  reportField('Client registration lock observed', yesNoUnknown(registryLocks.client)),
package/lib/favicon.mjs CHANGED
@@ -146,13 +146,18 @@ function isFaviconImage(bytes) {
146
146
  }
147
147
  // Returns the favicon bytes for one candidate URL (a real fetch, or an inline
148
148
  // data: decode), or null if it can't be retrieved/used.
149
- async function fetchFaviconBytes(url, headers, fetcher, timeoutMs) {
149
+ async function fetchFaviconBytes(url, headers, fetcher, timeoutMs, signal) {
150
+ signal?.throwIfAborted();
150
151
  if (/^data:/i.test(url))
151
152
  return decodeDataUri(url);
152
153
  const controller = new AbortController();
153
154
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
154
155
  try {
155
- const res = await fetcher(url, { signal: controller.signal, headers });
156
+ const res = await fetcher(url, { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, headers });
157
+ if (signal?.aborted) {
158
+ await res.body?.cancel().catch(() => { });
159
+ signal.throwIfAborted();
160
+ }
156
161
  if (!res.ok) {
157
162
  // Not reading this body - release it explicitly instead of leaving an
158
163
  // unconsumed stream (and the connection it's tied to) open until
@@ -165,10 +170,12 @@ async function fetchFaviconBytes(url, headers, fetcher, timeoutMs) {
165
170
  // headers immediately and then trickle or stall the body forever, hanging
166
171
  // this worker with no deadline once disarmed.
167
172
  const { bytes, truncated } = await readBytesCapped(res, MAX_FAVICON_BYTES);
173
+ signal?.throwIfAborted();
168
174
  // A truncated or empty file can't be hashed meaningfully.
169
175
  return truncated || bytes.length === 0 ? null : bytes;
170
176
  }
171
177
  catch {
178
+ signal?.throwIfAborted();
172
179
  return null;
173
180
  }
174
181
  finally {
@@ -196,7 +203,8 @@ function buildFaviconCandidates(domain, html = '', options = {}) {
196
203
  add(`http://${domain}/favicon.svg`);
197
204
  return candidates.slice(0, MAX_FAVICON_CANDIDATES);
198
205
  }
199
- async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetcher = safeFetch, timeoutMs = FAVICON_FETCH_TIMEOUT_MS } = {}) {
206
+ async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetcher = safeFetch, timeoutMs = FAVICON_FETCH_TIMEOUT_MS, signal } = {}) {
207
+ signal?.throwIfAborted();
200
208
  const headers = whoisleuthRequestHeaders();
201
209
  const candidates = buildFaviconCandidates(domain, html, {
202
210
  ...(baseUrl !== undefined ? { baseUrl } : {}),
@@ -207,7 +215,8 @@ async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetc
207
215
  : FAVICON_FETCH_TIMEOUT_MS;
208
216
  for (const url of candidates) {
209
217
  // eslint-disable-next-line no-await-in-loop
210
- const bytes = await fetchFaviconBytes(url, headers, fetcher, requestTimeoutMs);
218
+ const bytes = await fetchFaviconBytes(url, headers, fetcher, requestTimeoutMs, signal);
219
+ signal?.throwIfAborted();
211
220
  if (bytes && isFaviconImage(bytes)) {
212
221
  return {
213
222
  hash: crypto.createHash('sha256').update(bytes).digest('hex'),