@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
@@ -4,6 +4,7 @@
4
4
  import { htmlTreeEvents, isHtmlElement, parseBoundedHtml, MAX_STATIC_HTML_CHARS, MAX_STATIC_HTML_TAGS, MAX_STATIC_HTML_TREE_EVENTS, } from "./bounded-html-document.mjs";
5
5
  import { MAX_CSP_META_POLICIES, MAX_RESPONSE_POLICY_HEADER_BYTES, } from "./response-policy.mjs";
6
6
  import { MAX_FAVICON_BYTES, MAX_FAVICON_CANDIDATES } from "./outbound-request-bounds.mjs";
7
+ import { CREDENTIAL_CATEGORY_NAMES, MAX_CREDENTIAL_FORMS, MAX_CREDENTIAL_INPUTS } from "../packages/evidence/credential-form-attribution.mjs";
7
8
  import { MAX_PAGE_PUBLICATION_DECLARATIONS, MAX_PAGE_PUBLICATION_META_ELEMENTS, MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES, } from "./homepage-metadata-contract.mjs";
8
9
  const MAX_STATIC_STRUCTURE_TOKENS = MAX_STATIC_HTML_TREE_EVENTS;
9
10
  const MAX_TAG_LENGTH = 4_096;
@@ -14,8 +15,8 @@ const MAX_SCRIPT_MEDIA_TYPE_LENGTH = 120;
14
15
  const MAX_INLINE_SCRIPT_TOTAL_CHARS = 65_536;
15
16
  const MAX_INLINE_SCRIPT_CHARS = MAX_INLINE_SCRIPT_TOTAL_CHARS;
16
17
  const MAX_STATIC_VISIBLE_TEXT_CHARS = MAX_STATIC_HTML_CHARS;
17
- const MAX_STATIC_FORMS = 50;
18
- const MAX_STATIC_INPUTS = 500;
18
+ const MAX_STATIC_FORMS = MAX_CREDENTIAL_FORMS;
19
+ const MAX_STATIC_INPUTS = MAX_CREDENTIAL_INPUTS;
19
20
  const MAX_STATIC_PUBLICATION_META_ELEMENTS = MAX_PAGE_PUBLICATION_META_ELEMENTS;
20
21
  const MAX_STATIC_PUBLICATION_DECLARATIONS = MAX_PAGE_PUBLICATION_DECLARATIONS;
21
22
  const MAX_STATIC_ROBOTS_DIRECTIVES = MAX_PAGE_PUBLICATION_ROBOTS_DIRECTIVES;
@@ -197,6 +198,29 @@ function stylesheetCandidate(attributes) {
197
198
  truncated: rel.truncated || href.truncated || disabled.truncated || media.truncated,
198
199
  };
199
200
  }
201
+ export function staticControlDisabled(elements, index, firstLegends) {
202
+ const hasDisabled = (element) => element.attributes.some(attribute => attribute.name === 'disabled');
203
+ if (hasDisabled(elements[index]))
204
+ return true;
205
+ for (let parent = elements[index]?.parent ?? null; parent !== null; parent = elements[parent]?.parent ?? null) {
206
+ const fieldset = elements[parent];
207
+ if (!fieldset.html || fieldset.name !== 'fieldset' || !hasDisabled(fieldset))
208
+ continue;
209
+ const legend = firstLegends.get(parent);
210
+ let underLegend = false;
211
+ for (let current = index; current !== null; current = elements[current]?.parent ?? null) {
212
+ if (current === legend) {
213
+ underLegend = true;
214
+ break;
215
+ }
216
+ if (current === parent)
217
+ break;
218
+ }
219
+ if (!underLegend)
220
+ return true;
221
+ }
222
+ return false;
223
+ }
200
224
  function analyzeStaticHtml(value, options = {}) {
201
225
  const parsed = parseBoundedHtml(value);
202
226
  const { inputLimitReached } = parsed;
@@ -209,6 +233,7 @@ function analyzeStaticHtml(value, options = {}) {
209
233
  const iconCounts = [0, 0];
210
234
  const tokens = [];
211
235
  const elementStack = [];
236
+ const firstLegends = new Map();
212
237
  const visibleTextParts = [];
213
238
  const structureTokens = [];
214
239
  const scripts = [];
@@ -386,6 +411,8 @@ function analyzeStaticHtml(value, options = {}) {
386
411
  };
387
412
  elementStack.push(elements.length);
388
413
  elements.push(element);
414
+ if (htmlElement && tagName === 'legend' && element.parent !== null && !firstLegends.has(element.parent))
415
+ firstLegends.set(element.parent, elements.length - 1);
389
416
  appendToken({ kind: 'start', element });
390
417
  // Foreign drawing attributes are not HTML technology, form or resource
391
418
  // inputs. Their per-element omission still reaches fingerprinting, but
@@ -628,7 +655,8 @@ function analyzeStaticHtml(value, options = {}) {
628
655
  }
629
656
  else {
630
657
  forms.inputsObserved += 1;
631
- const categories = inputCategories(token.attrs);
658
+ const categories = staticControlDisabled(elements, elements.length - 1, firstLegends)
659
+ ? { values: [], truncated: false } : inputCategories(token.attrs);
632
660
  if (categories.truncated)
633
661
  forms.truncated = true;
634
662
  if (categories.values.length)
@@ -716,4 +744,4 @@ function analyzeStaticHtml(value, options = {}) {
716
744
  inlineCharactersExamined,
717
745
  };
718
746
  }
719
- export { MAX_ATTRIBUTES_PER_TAG, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_SCRIPT_REFERENCE_LENGTH, MAX_SCRIPT_MEDIA_TYPE_LENGTH, MAX_STATIC_FORMS, MAX_STATIC_HTML_CHARS, MAX_STATIC_INPUTS, MAX_STATIC_PUBLICATION_META_ELEMENTS, MAX_STATIC_PUBLICATION_DECLARATIONS, MAX_STATIC_ROBOTS_DIRECTIVES, MAX_STATIC_HTML_TAGS, MAX_STATIC_STRUCTURE_TOKENS, MAX_STATIC_VISIBLE_TEXT_CHARS, MAX_TAG_LENGTH, analyzeStaticHtml, };
747
+ export { MAX_ATTRIBUTES_PER_TAG, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_SCRIPT_REFERENCE_LENGTH, MAX_SCRIPT_MEDIA_TYPE_LENGTH, MAX_STATIC_FORMS, MAX_STATIC_HTML_CHARS, MAX_STATIC_INPUTS, MAX_STATIC_PUBLICATION_META_ELEMENTS, MAX_STATIC_PUBLICATION_DECLARATIONS, MAX_STATIC_ROBOTS_DIRECTIVES, MAX_STATIC_HTML_TAGS, MAX_STATIC_STRUCTURE_TOKENS, MAX_STATIC_VISIBLE_TEXT_CHARS, MAX_TAG_LENGTH, analyzeStaticHtml, inputCategories as classifyStaticCredentialInput, };
@@ -1,51 +1 @@
1
- const TECHNOLOGY_EVIDENCE_ROLE_ORDER = Object.freeze([
2
- 'observed_edge',
3
- 'application_platform',
4
- 'framework_runtime',
5
- 'embedded_dependency',
6
- ]);
7
- const TECHNOLOGY_EVIDENCE_ROLE_SET = new Set(TECHNOLOGY_EVIDENCE_ROLE_ORDER);
8
- function record(value) {
9
- return value && typeof value === 'object' && !Array.isArray(value)
10
- ? value
11
- : {};
12
- }
13
- function technologyEvidenceRoles(value) {
14
- const finding = record(value);
15
- const roles = new Set();
16
- let hasExplicitRole = false;
17
- for (const role of Array.isArray(finding.roles) ? finding.roles.slice(0, 4) : []) {
18
- if (typeof role === 'string' && TECHNOLOGY_EVIDENCE_ROLE_SET.has(role)) {
19
- roles.add(role);
20
- hasExplicitRole = true;
21
- }
22
- }
23
- const evidenceValues = Array.isArray(finding.evidence) ? finding.evidence.slice(0, 4) : [];
24
- for (const evidenceValue of evidenceValues) {
25
- const evidence = record(evidenceValue);
26
- if (typeof evidence.role === 'string'
27
- && TECHNOLOGY_EVIDENCE_ROLE_SET.has(evidence.role)) {
28
- roles.add(evidence.role);
29
- hasExplicitRole = true;
30
- }
31
- }
32
- if (!hasExplicitRole) {
33
- const resourceOriginOnly = evidenceValues.length > 0
34
- && evidenceValues.every((evidence) => (String(record(evidence).source).toLowerCase() === 'resource origin'));
35
- if (evidenceValues.some((evidence) => String(record(evidence).source).toLowerCase() === 'resource origin')) {
36
- roles.add('embedded_dependency');
37
- }
38
- if (!resourceOriginOnly) {
39
- const category = String(finding.category).toLowerCase();
40
- if (category === 'delivery platform')
41
- roles.add('observed_edge');
42
- else if (['application runtime', 'web framework', 'static site generator', 'web server'].includes(category)) {
43
- roles.add('framework_runtime');
44
- }
45
- else
46
- roles.add('application_platform');
47
- }
48
- }
49
- return TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => roles.has(role));
50
- }
51
- export { TECHNOLOGY_EVIDENCE_ROLE_ORDER, technologyEvidenceRoles, };
1
+ export { TECHNOLOGY_EVIDENCE_ROLE_ORDER, technologyEvidenceRoles, } from "../packages/evidence/technology-evidence-role.mjs";
@@ -705,6 +705,7 @@ function skippedTlsObservation(detail = 'TLS evidence collection is disabled by
705
705
  };
706
706
  }
707
707
  async function collectTlsIntelligence(hostname, options = {}) {
708
+ options.signal?.throwIfAborted();
708
709
  const normalizedHostname = normalizeTlsHostname(hostname);
709
710
  const now = options.now || Date.now;
710
711
  const observedAt = options.observedAt || (() => new Date().toISOString());
@@ -725,9 +726,11 @@ async function collectTlsIntelligence(hostname, options = {}) {
725
726
  resolutionDeadline = setTimer(() => reject(new Error('TLS resolution timed out')), timeoutMs);
726
727
  }),
727
728
  ]);
729
+ options.signal?.throwIfAborted();
728
730
  records = normalizePublicAddressRecords(resolved);
729
731
  }
730
732
  catch (error) {
733
+ options.signal?.throwIfAborted();
731
734
  return failedTlsObservation(error, { sniHost: normalizedHostname, observedAt: observedAt(), durationMs: now() - started });
732
735
  }
733
736
  finally {
@@ -755,18 +758,29 @@ async function collectTlsIntelligence(hostname, options = {}) {
755
758
  durationMs: now() - started,
756
759
  });
757
760
  }
758
- return new Promise((resolve) => {
761
+ return new Promise((resolve, reject) => {
759
762
  let socket;
760
763
  let deadline;
761
764
  let settled = false;
762
- const finish = (result) => {
763
- if (settled)
764
- return;
765
- settled = true;
765
+ const cleanup = () => {
766
766
  if (deadline !== undefined)
767
767
  clearTimer(deadline);
768
+ options.signal?.removeEventListener('abort', cancelled);
768
769
  if (socket && typeof socket.destroy === 'function')
769
770
  socket.destroy();
771
+ };
772
+ const cancelled = () => {
773
+ if (settled)
774
+ return;
775
+ settled = true;
776
+ cleanup();
777
+ reject(options.signal.reason);
778
+ };
779
+ const finish = (result) => {
780
+ if (settled)
781
+ return;
782
+ settled = true;
783
+ cleanup();
770
784
  resolve(result);
771
785
  };
772
786
  const fail = (error) => finish(failedTlsObservation(error, {
@@ -776,6 +790,11 @@ async function collectTlsIntelligence(hostname, options = {}) {
776
790
  observedAt: observedAt(),
777
791
  durationMs: now() - started,
778
792
  }));
793
+ options.signal?.addEventListener('abort', cancelled, { once: true });
794
+ if (options.signal?.aborted) {
795
+ cancelled();
796
+ return;
797
+ }
779
798
  try {
780
799
  socket = connect({
781
800
  host: selected.address,
@@ -787,6 +806,8 @@ async function collectTlsIntelligence(hostname, options = {}) {
787
806
  checkServerIdentity: () => undefined,
788
807
  ALPNProtocols: ['h2', 'http/1.1'],
789
808
  }, () => {
809
+ if (settled)
810
+ return;
790
811
  try {
791
812
  const peerCertificate = socket.getPeerCertificate(true);
792
813
  let hostnameError = null;
@@ -816,6 +837,10 @@ async function collectTlsIntelligence(hostname, options = {}) {
816
837
  fail(error);
817
838
  }
818
839
  });
840
+ if (settled) {
841
+ socket.destroy();
842
+ return;
843
+ }
819
844
  socket.once('error', fail);
820
845
  deadline = setTimer(() => fail(new Error('TLS handshake timed out')), remainingMs);
821
846
  if (settled && deadline !== undefined)
@@ -1,5 +1,6 @@
1
1
  // Bounded website and network enrichment, independent of registration authority.
2
2
  import { capturedWebCollectionQuality } from "../packages/evidence/collection-quality.mjs";
3
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
3
4
  import { recordOrEmpty as errorRecord } from "./json-record.mjs";
4
5
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
5
6
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
@@ -21,26 +22,34 @@ import { HOMEPAGE_FETCH_TIMEOUT_MS, MAX_HOMEPAGE_BYTES, } from "./outbound-reque
21
22
  // what's checkable without credentials is the parking/landing-page
22
23
  // nameservers and homepage copy these services actually use, which is
23
24
  // broader coverage of the same signal, not a live cross-marketplace lookup.
24
- const PARKING_NS_PATTERNS = [
25
- /sedoparking\.com$/i,
26
- /sedo\.com$/i,
27
- /above\.com$/i,
28
- /bodis\.com$/i,
29
- /parkingcrew\.net$/i,
30
- /dan\.com$/i,
31
- /hugedomains\.com$/i,
32
- /uniregistry/i,
33
- /squadhelp/i,
34
- /afternic/i,
35
- /voodoo\.com$/i,
36
- /fabulous\.com$/i,
37
- /namedrive/i,
38
- /smartname\.com$/i,
39
- /domainsponsor\.com$/i,
40
- /undeveloped\.com$/i,
41
- /trafficz\.com$/i,
42
- /dsredirection\.com$/i,
25
+ const PARKING_NS_DOMAINS = [
26
+ 'sedoparking.com',
27
+ 'sedo.com',
28
+ 'above.com',
29
+ 'bodis.com',
30
+ 'parkingcrew.net',
31
+ 'dan.com',
32
+ 'hugedomains.com',
33
+ // https://www.atom.com/help/article/how-to-verify-your-standard-premium-domains
34
+ 'squadhelp.com',
35
+ // https://blog.afternic.com/lander-selection-redefining-your-use-of-afternic-landing-pages/
36
+ 'afternic.com',
37
+ 'voodoo.com',
38
+ 'fabulous.com',
39
+ 'smartname.com',
40
+ 'domainsponsor.com',
41
+ 'undeveloped.com',
42
+ 'trafficz.com',
43
+ 'dsredirection.com',
43
44
  ];
45
+ function isParkingNameserver(value) {
46
+ const hostname = value.trim().replace(/\.$/u, '');
47
+ // Validate before case folding: non-ASCII letters can lowercase to ASCII.
48
+ if (!isValidAsciiHostname(hostname))
49
+ return false;
50
+ const normalised = hostname.toLowerCase();
51
+ return PARKING_NS_DOMAINS.some(domain => normalised === domain || normalised.endsWith(`.${domain}`));
52
+ }
44
53
  const FOR_SALE_PATH_RE = /\/(?:premium-)?domains?-for-sale(?:\/|$)/i;
45
54
  function forSaleRedirectSignal(httpObservation) {
46
55
  if (!httpObservation || typeof httpObservation !== 'object')
@@ -180,6 +189,7 @@ function deriveWebsiteActivity(homepageStatus, hasFavicon, alreadyParked = false
180
189
  return 'unreachable';
181
190
  }
182
191
  async function enrichWebsite(context, options, featurePolicy) {
192
+ options.signal?.throwIfAborted();
183
193
  const { domain, observationHostname, selectedUrl, registration, registryDnsEvidence, nameservers, dnssec } = context;
184
194
  const websiteProbeEnabled = featureDecision('website_probe', featurePolicy).enabled;
185
195
  const collectDns = options.collectDnsIntelligence || collectDnsIntelligence;
@@ -194,7 +204,7 @@ async function enrichWebsite(context, options, featurePolicy) {
194
204
  // deep mode only), and check for a configured mail exchanger as a
195
205
  // phishing-risk signal (a lookalike domain that can receive/send mail is
196
206
  // capable of running credential-harvesting or BEC campaigns).
197
- const nsSignal = registration.state === 'registered' ? nameservers.find((ns) => PARKING_NS_PATTERNS.some((re) => re.test(ns))) : undefined;
207
+ const nsSignal = registration.state === 'registered' ? nameservers.find(isParkingNameserver) : undefined;
198
208
  let forSaleSignal = nsSignal ? `parking nameserver (${nsSignal})` : null;
199
209
  let activityStatus = nsSignal && observationHostname === domain ? 'parked' : 'unknown';
200
210
  // Homepage + bounded DNS evidence resolve in parallel; the favicon fetch is sequenced after
@@ -202,7 +212,7 @@ async function enrichWebsite(context, options, featurePolicy) {
202
212
  // sites serve no /favicon.ico and only point to a CDN PNG this way). One
203
213
  // extra round-trip on the already-slow deep path, in exchange for finding
204
214
  // favicons the bare /favicon.ico probe would miss.
205
- const [homepage, dnsIntelligence, tlsIntelligence] = await Promise.all([
215
+ const pending = [
206
216
  websiteProbeEnabled ? fetchHomepageForDomain(observationHostname, {
207
217
  ...(selectedUrl ? { selectedUrl } : {}), ...(options.signal ? { signal: options.signal } : {}),
208
218
  }).catch((err) => ({
@@ -221,6 +231,7 @@ async function enrichWebsite(context, options, featurePolicy) {
221
231
  }),
222
232
  dnsIntelligenceEnabled
223
233
  ? collectDns(observationHostname, {
234
+ ...(options.signal ? { signal: options.signal } : {}),
224
235
  ...(options.dnsResolvers ? { resolvers: options.dnsResolvers } : {}),
225
236
  includeExtendedContext: options.includeExtendedDnsContext === true,
226
237
  includeInheritedCaa: options.includeInheritedCaa === true,
@@ -232,9 +243,11 @@ async function enrichWebsite(context, options, featurePolicy) {
232
243
  includeInheritedCaa: options.includeInheritedCaa === true,
233
244
  })),
234
245
  tlsIntelligenceEnabled
235
- ? collectTls(observationHostname)
246
+ ? collectTls(observationHostname, { ...(options.signal ? { signal: options.signal } : {}) })
236
247
  : Promise.resolve(skippedTlsObservation()),
237
- ]);
248
+ ];
249
+ const [homepage, dnsIntelligence, tlsIntelligence] = await Promise.all(pending)
250
+ .finally(() => Promise.allSettled(pending));
238
251
  options.signal?.throwIfAborted();
239
252
  const page = homepage.text;
240
253
  const pageBaseUrl = homepage.analysisBaseUrl ?? (typeof homepage.http?.finalUrl === 'string' ? homepage.http.finalUrl : `https://${observationHostname}/`);
@@ -243,6 +256,7 @@ async function enrichWebsite(context, options, featurePolicy) {
243
256
  domainSaleSignal: null,
244
257
  pageTitle: null,
245
258
  hasPasswordField: false,
259
+ hasExternalPasswordForm: null,
246
260
  phishingLanguageMatch: null,
247
261
  hasExternalFormAction: null,
248
262
  externalAssetHosts: [],
@@ -312,8 +326,10 @@ async function enrichWebsite(context, options, featurePolicy) {
312
326
  const favicon = websiteProbeEnabled
313
327
  ? await fetchFaviconForDomain(observationHostname, {
314
328
  baseUrl: pageBaseUrl, ...(faviconEvidence ? { htmlAnalysis: faviconEvidence } : {}),
329
+ ...(options.signal ? { signal: options.signal } : {}),
315
330
  }).catch(() => null)
316
331
  : null;
332
+ options.signal?.throwIfAborted();
317
333
  const faviconHash = favicon ? favicon.hash : null;
318
334
  const faviconPHash = favicon ? favicon.phash : null;
319
335
  const responsePolicy = qualifyResponsePolicyWithCspMeta(homepage.responsePolicy, htmlSignals.cspMetaPolicy);
@@ -2,6 +2,7 @@
2
2
  // registry response referred by IANA can decide existence; later registrar
3
3
  // output is diagnostic and cannot replace an inconclusive registry result.
4
4
  import { normalizeWhoisChain } from "./whois-normalization.mjs";
5
+ import { MAX_WHOIS_BYTES } from "./whois-transport.mjs";
5
6
  import { domainToASCII } from 'node:url';
6
7
  import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
7
8
  import { parseIndentedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
@@ -133,6 +134,52 @@ function ianaRegistryReferral(hop) {
133
134
  }
134
135
  return null;
135
136
  }
137
+ /** A complete, target-bound delegation record can establish missing service;
138
+ * merely reaching the end of an unrecognised response cannot. */
139
+ function ianaDiscoveryHasNoWhoisService(chain, hop, requestedDomain) {
140
+ const expectedRoot = requestedDomain === undefined ? null : canonicalObjectName(requestedDomain)?.split('.').at(-1);
141
+ if (!expectedRoot || normalizedWhoisServer(hop.server) !== 'whois.iana.org')
142
+ return false;
143
+ if (!Array.isArray(chain) || chain.length !== 1)
144
+ return false;
145
+ const original = chain[0]?.response;
146
+ // Normalisation bounds imported text. Do not interpret a clipped prefix as
147
+ // an entire discovery record with no referral in its omitted remainder.
148
+ if (typeof original !== 'string' || original.length > MAX_WHOIS_BYTES
149
+ || Buffer.byteLength(original, 'utf8') > MAX_WHOIS_BYTES)
150
+ return false;
151
+ const text = hop.response ?? '';
152
+ if (/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(text)
153
+ || classifyHopEvidence(hop, 0) !== 'inconclusive')
154
+ return false;
155
+ const fields = new Map();
156
+ const emptyServiceFields = new Set();
157
+ let previousServiceField = false;
158
+ for (const line of text.split('\n')) {
159
+ if (previousServiceField && /^[ \t]+\S/u.test(line))
160
+ return false;
161
+ previousServiceField = false;
162
+ if (/^[ \t]*(?:refer|ReferralServer|whois)\b/iu.test(line)) {
163
+ const service = line.match(/^[ \t]*(refer|ReferralServer|whois):[ \t]*$/iu)?.[1]?.toLowerCase();
164
+ if (!service || emptyServiceFields.has(service))
165
+ return false;
166
+ emptyServiceFields.add(service);
167
+ previousServiceField = true;
168
+ }
169
+ const field = line.match(/^[ \t]*(domain|organisation|status|source):[ \t]*(.*)$/iu);
170
+ if (!field)
171
+ continue;
172
+ const key = field[1].toLowerCase();
173
+ if (fields.has(key))
174
+ return false;
175
+ fields.set(key, field[2].trim());
176
+ }
177
+ return emptyServiceFields.size > 0
178
+ && fields.get('domain')?.toLowerCase() === expectedRoot
179
+ && Boolean(fields.get('organisation'))
180
+ && fields.get('status')?.toUpperCase() === 'ACTIVE'
181
+ && fields.get('source')?.toUpperCase() === 'IANA';
182
+ }
136
183
  export function analyzeWhoisChainAuthority(chain, requestedDomain) {
137
184
  const source = normalizeWhoisChain(chain);
138
185
  const evidence = source.map((hop, index) => ({
@@ -177,7 +224,15 @@ export function whoisCollectionStatus(chain, requestedDomain) {
177
224
  const source = normalizeWhoisChain(chain);
178
225
  if (!source.length || source[0]?.error)
179
226
  return 'error';
227
+ const first = source[0];
228
+ if (first.server === 'registry capability policy') {
229
+ if (source.length === 1 && first.queryProfile === 'not-issued' && first.response === undefined
230
+ && first.address === undefined && first.queriedAt === undefined
231
+ && (first.status === 'unsupported' || first.status === 'skipped'))
232
+ return first.status;
233
+ return 'error';
234
+ }
180
235
  if (source.length === 1)
181
- return 'unsupported';
236
+ return ianaDiscoveryHasNoWhoisService(chain, first, requestedDomain) ? 'unsupported' : 'partial';
182
237
  return analyzeWhoisChainAuthority(source, requestedDomain).chainStatus;
183
238
  }
@@ -65,7 +65,8 @@ export async function buildWhoisChainUncached(query, options = {}) {
65
65
  server: 'registry capability policy',
66
66
  queryProfile: 'not-issued',
67
67
  responseEncoding: 'utf-8',
68
- error: admission.state === 'permission_required'
68
+ status: admission.state === 'permission_required' ? 'skipped' : 'unsupported',
69
+ detail: admission.state === 'permission_required'
69
70
  ? 'WHOIS collection requires registry permission or source authorisation; no socket was opened.'
70
71
  : 'IANA publishes no domain WHOIS service for this suffix; no socket was opened.',
71
72
  }];
@@ -146,7 +147,16 @@ export async function buildWhoisChainUncached(query, options = {}) {
146
147
  return chain;
147
148
  }
148
149
  export async function buildWhoisChain(query, options = {}) {
149
- if (options.signal)
150
- return buildWhoisChainUncached(query, options);
151
- return cached(`whois:${query.toLowerCase()}`, () => buildWhoisChainUncached(query));
150
+ let ownedCollection;
151
+ try {
152
+ return await cached(`whois:${query.toLowerCase()}`, () => {
153
+ ownedCollection = buildWhoisChainUncached(query, options);
154
+ return ownedCollection;
155
+ }, options.signal);
156
+ }
157
+ finally {
158
+ // Cache cancellation rejects promptly, but a started transport still belongs
159
+ // to this lookup lease. Drain it before its caller can release that lease.
160
+ await ownedCollection?.catch(() => { });
161
+ }
152
162
  }
@@ -245,7 +245,9 @@ function applyWhoisCommonFormats(text, isRootHop, dialects, context) {
245
245
  // sometimes just a hostname, sometimes "hostname ip.addr" - rather
246
246
  // than a per-line "Name Server:" label. Only meaningful on non-root hops.
247
247
  if (!isRootHop && nameservers.size === 0) {
248
- const headerMatch = text.match(/^[ \t*]*(?:Name ?[Ss]ervers|Domain Servers|DNS servers)[ \t.]*:?[ \t]*$/mi);
248
+ // Whitespace after a colon belongs to that colon: two independent runs
249
+ // around an optional colon allow quadratic backtracking on invalid lines.
250
+ const headerMatch = text.match(/^[ \t*]*(?:Name ?[Ss]ervers|Domain Servers|DNS servers)[ \t.]*(?::[ \t]*)?$/mi);
249
251
  if (headerMatch) {
250
252
  let found = 0;
251
253
  for (const line of text.slice((headerMatch.index ?? 0) + headerMatch[0].length).split('\n')) {
@@ -30,7 +30,9 @@ export function resolveIsnicRole(text, handle) {
30
30
  if (!handle)
31
31
  return null;
32
32
  const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
- const headerMatch = text.match(new RegExp(`^[ \\t]*role:[ \\t]*(.+)\\r?\\n[ \\t]*nic-hdl:[ \\t]*${escaped}[ \\t]*$`, 'im'));
33
+ // Capture the whole role value, including leading whitespace. A separate
34
+ // whitespace run overlaps this capture and backtracks when the handle fails.
35
+ const headerMatch = text.match(new RegExp(`^[ \\t]*role:(.+)\\r?\\n[ \\t]*nic-hdl:[ \\t]*${escaped}[ \\t]*$`, 'im'));
34
36
  if (!headerMatch)
35
37
  return null;
36
38
  const lines = text
@@ -143,7 +145,7 @@ export function parseIndentedContactBlock(text, headerRe) {
143
145
  if (lines.length === 0)
144
146
  return null;
145
147
  const remaining = [...lines];
146
- const emailIndex = remaining.findIndex((line) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(line));
148
+ const emailIndex = remaining.findIndex(isIndentedContactEmail);
147
149
  const email = emailIndex !== -1
148
150
  ? remaining.splice(emailIndex, 1)[0]
149
151
  : null;
@@ -161,3 +163,12 @@ export function parseIndentedContactBlock(text, headerRe) {
161
163
  truncated: allLines.length > lines.length,
162
164
  };
163
165
  }
166
+ // Preserve the legacy contact classifier (not a mailbox validator), without
167
+ // repeatedly trying a different separator in a long, dot-heavy failed match.
168
+ function isIndentedContactEmail(line) {
169
+ const at = line.indexOf('@');
170
+ if (at <= 0 || line.indexOf('@', at + 1) !== -1 || /\s/.test(line))
171
+ return false;
172
+ const dot = line.indexOf('.', at + 2);
173
+ return dot !== -1 && dot < line.length - 1;
174
+ }
@@ -2,3 +2,5 @@
2
2
  // consumers can depend on the record shape without importing network code.
3
3
  export const MAX_WHOIS_STATUSES = 100;
4
4
  export const MAX_WHOIS_QUERY_HOPS = 6;
5
+ // A stopped referral remains evidence even when no further request is issued.
6
+ export const MAX_WHOIS_CHAIN_HOPS = MAX_WHOIS_QUERY_HOPS + 1;
@@ -2,14 +2,12 @@
2
2
  // Keeping this separate lets authority analysis and field parsing share the
3
3
  // exact same defensive input boundary.
4
4
  import { MAX_WHOIS_BYTES } from "./whois-transport.mjs";
5
- // Collection performs at most six network queries and may retain one seventh,
6
- // non-issued hop explaining a loop or hop-limit termination.
7
- const MAX_WHOIS_HOPS = 7;
5
+ import { MAX_WHOIS_CHAIN_HOPS } from "./whois-contracts.mjs";
8
6
  export function normalizeWhoisChain(value) {
9
7
  if (!Array.isArray(value))
10
8
  return [];
11
9
  const normalized = [];
12
- for (const hopValue of value.slice(0, MAX_WHOIS_HOPS)) {
10
+ for (const hopValue of value.slice(0, MAX_WHOIS_CHAIN_HOPS)) {
13
11
  if (!hopValue || typeof hopValue !== 'object' || Array.isArray(hopValue))
14
12
  continue;
15
13
  const hop = hopValue;
@@ -25,6 +23,8 @@ export function normalizeWhoisChain(value) {
25
23
  // original hop retains its wire text. Lone and embedded controls remain.
26
24
  ...(typeof hop.response === 'string' ? { response: hop.response.slice(0, MAX_WHOIS_BYTES).replace(/\r\n/g, '\n') } : {}),
27
25
  ...(typeof hop.error === 'string' ? { error: hop.error.slice(0, 1000) } : {}),
26
+ ...(hop.status === 'unsupported' || hop.status === 'skipped' ? { status: hop.status } : {}),
27
+ ...(typeof hop.detail === 'string' ? { detail: hop.detail.slice(0, 1000) } : {}),
28
28
  });
29
29
  }
30
30
  return normalized;
package/package.json CHANGED
@@ -29,24 +29,24 @@
29
29
  "type": "git",
30
30
  "url": "git+https://github.com/slicedearth/whoisleuth.git"
31
31
  },
32
- "homepage": "https://www.whoisleuth.com/",
32
+ "homepage": "https://www.whoisleuth.com",
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "2.5.1",
36
+ "version": "2.7.0",
37
37
  "dependencies": {
38
38
  "@nuintun/qrcode": "5.0.3",
39
- "@peculiar/x509": "2.1.0",
39
+ "@peculiar/x509": "2.2.0",
40
40
  "fast-png": "8.0.0",
41
41
  "fflate": "0.8.3",
42
42
  "maxmind": "5.0.7",
43
43
  "parse5": "8.0.1",
44
- "pdfjs-dist": "6.3.289",
45
- "postal-mime": "3.0.0",
44
+ "pdfjs-dist": "6.4.299",
45
+ "postal-mime": "4.0.7",
46
46
  "reflect-metadata": "0.2.2",
47
47
  "saxes": "6.0.0",
48
- "tldts": "7.4.14",
49
- "undici": "8.11.0"
48
+ "tldts": "7.4.18",
49
+ "undici": "8.12.2"
50
50
  },
51
51
  "files": [
52
52
  "bin/**/*.mjs",
@@ -58,6 +58,8 @@
58
58
  "docs/cli.md",
59
59
  "docs/cli-reference.md",
60
60
  "docs/contextual-reviews.md",
61
+ "docs/offline-intake-context.md",
62
+ "docs/infrastructure-observations.md",
61
63
  "DISCLOSURE",
62
64
  "LICENSE",
63
65
  "NOTICE",
@@ -127,7 +127,7 @@ export function assertBoundedJsonStructure(value, label = 'JSON value', limits =
127
127
  };
128
128
  visit(value, 0);
129
129
  }
130
- export function scanBoundedJson(raw, limits = {}, observeString) {
130
+ export function scanBoundedJson(raw, limits = {}, observeString, observeStringLocation) {
131
131
  const maximumDepth = limits.maximumDepth ?? MAX_BOUNDED_JSON_DEPTH;
132
132
  const maximumKeys = limits.maximumKeys ?? MAX_BOUNDED_JSON_KEYS;
133
133
  const maximumValues = limits.maximumValues ?? MAX_BOUNDED_JSON_VALUES;
@@ -136,6 +136,7 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
136
136
  let keys = 0;
137
137
  let values = 0;
138
138
  let stringCodeUnits = 0;
139
+ const path = [];
139
140
  const whitespace = () => {
140
141
  while (index < raw.length) {
141
142
  const code = raw.charCodeAt(index);
@@ -213,6 +214,7 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
213
214
  if (character === '"') {
214
215
  const parsedString = stringToken();
215
216
  observeString?.({ ...parsedString, kind: 'value', ...(propertyKey === undefined ? {} : { propertyKey }) });
217
+ observeStringLocation?.({ ...parsedString, end: index, path: [...path] });
216
218
  return;
217
219
  }
218
220
  if (character === '{') {
@@ -245,7 +247,11 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
245
247
  if (raw[index] !== ':')
246
248
  syntaxError();
247
249
  index += 1;
250
+ if (observeStringLocation)
251
+ path.push(key);
248
252
  value(depth + 1, key);
253
+ if (observeStringLocation)
254
+ path.pop();
249
255
  whitespace();
250
256
  if (raw[index] === '}') {
251
257
  index += 1;
@@ -271,7 +277,11 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
271
277
  if (items > maximumContainerItems) {
272
278
  throw new TypeError(`Artefact JSON contains a container with more than ${maximumContainerItems} items.`);
273
279
  }
280
+ if (observeStringLocation)
281
+ path.push(items - 1);
274
282
  value(depth + 1);
283
+ if (observeStringLocation)
284
+ path.pop();
275
285
  whitespace();
276
286
  if (raw[index] === ']') {
277
287
  index += 1;
@@ -312,7 +322,7 @@ export function parseBoundedJson(raw, options) {
312
322
  if (bytes < 1 || bytes > options.maximumBytes) {
313
323
  throw new TypeError(`${label} must be between 1 byte and ${options.maximumBytes} bytes.`);
314
324
  }
315
- scanBoundedJson(raw, options.limits);
325
+ scanBoundedJson(raw, options.limits, undefined, options.observeStringLocation);
316
326
  try {
317
327
  return JSON.parse(raw);
318
328
  }