@slicedearth/whoisleuth-cli 2.2.0 → 2.3.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 (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +12 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +60 -11
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +6 -4
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +171 -51
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +3 -2
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
@@ -119,6 +119,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
119
119
  'scanMode', 'durationMs', 'complete', 'truncated', 'limitations',
120
120
  'diagnostics', 'detail', 'error', 'count', 'total', 'discarded', 'summary',
121
121
  'findings', 'id', 'label', 'tone', 'category', 'confidence', 'compatibility', 'role', 'roles', 'evidence',
122
+ 'description',
122
123
  'value', 'values', 'name', 'type', 'types', 'mode', 'owner', 'target',
123
124
  'url', 'uri', 'href',
124
125
  'priority', 'ttl', 'records',
@@ -147,7 +148,8 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
147
148
  'chain', 'chainTruncated', 'standardName', 'size', 'subject', 'issuer',
148
149
  'serialNumber', 'validFrom', 'validTo', 'fingerprintSha1',
149
150
  'fingerprintSha256', 'isCertificateAuthority', 'subjectAltNames',
150
- 'dnsNames', 'ipAddresses', 'otherNames', 'publicKey', 'bits', 'curve',
151
+ 'dnsNames', 'ipAddresses', 'otherNames', 'classes', 'dns', 'ip', 'directoryName',
152
+ 'registeredId', 'otherName', 'publicKey', 'bits', 'curve',
151
153
  'signature', 'oid', 'extendedKeyUsage', 'authorityInformationAccess',
152
154
  'ocsp', 'caIssuers', 'unknownMethods', 'extensionProfile', 'parsed', 'partial',
153
155
  'certificatePolicies', 'crlDistributionPoints', 'commonNames',
@@ -161,7 +163,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
161
163
  'externalOrigins', 'embeddedOrigins', 'contactDomains', 'downloads',
162
164
  'explicitCount', 'riskyCount', 'riskyFileTypes', 'trackingIdentifiers',
163
165
  'fingerprints', 'fingerprintVersion', 'exact', 'normalizedHtml',
164
- 'visibleText', 'domStructure', 'formStructure', 'resourceHosts',
166
+ 'visibleText', 'domStructure', 'similarity', 'formStructure', 'resourceHosts',
165
167
  'identifiers', 'featureCount', 'nodeCount', 'formCount', 'controlCount',
166
168
  'parser', 'tokenCount', 'tagsExamined', 'discardedUrls', 'formsObserved',
167
169
  'relationshipTagsExamined', 'relationshipUrlsDiscarded',
@@ -197,6 +199,9 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
197
199
  const LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS = new Set([
198
200
  'password', 'email', 'username', 'one_time_code', 'payment',
199
201
  ]);
202
+ const LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS = new Set([
203
+ 'dns', 'ip', 'email', 'uri', 'directoryName', 'registeredId', 'otherName', 'unclassified',
204
+ ]);
200
205
  const LOOKUP_AVAILABILITY_VALUE_PATHS = new Set([
201
206
  'dns.records.caa',
202
207
  'dns.caaPolicy.records',
@@ -328,12 +333,12 @@ function projectLookupEvidencePrivacySafeTreeValue(value, state, depth) {
328
333
  export function projectLookupEvidencePrivacySafeTree(value) {
329
334
  return projectLookupEvidencePrivacySafeTreeValue(value, { entries: 0 }, 0);
330
335
  }
331
- function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, depth) {
336
+ function projectLookupEvidenceAvailabilityPublicValue(value, path, state, depth) {
332
337
  consumePortableProjectionEntry(state, depth);
333
338
  if (value === null || typeof value === 'boolean' || typeof value === 'number')
334
339
  return value;
335
340
  if (typeof value === 'string') {
336
- const normalizedParent = normalizedEvidenceKey(parentKey || '');
341
+ const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
337
342
  if (PORTABLE_URL_KEYS.has(normalizedParent))
338
343
  return portableUri(value);
339
344
  if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
@@ -342,7 +347,7 @@ function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, d
342
347
  }
343
348
  if (Array.isArray(value)) {
344
349
  return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
345
- .map((item) => projectLookupEvidenceAvailabilityPublicValue(item, parentKey, state, depth + 1));
350
+ .map((item) => projectLookupEvidenceAvailabilityPublicValue(item, path, state, depth + 1));
346
351
  }
347
352
  const source = recordOrNull(value);
348
353
  if (!source)
@@ -352,7 +357,31 @@ function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, d
352
357
  if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
353
358
  || privateEvidenceKey(key, item))
354
359
  continue;
355
- output[key] = projectLookupEvidenceAvailabilityPublicValue(item, key, state, depth + 1);
360
+ const parentPath = path.join('.');
361
+ const sanClassCount = parentPath === 'tls.certificate.subjectAltNames.classes'
362
+ && LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
363
+ && Number.isSafeInteger(item)
364
+ && Number(item) >= 0
365
+ && Number(item) <= LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS;
366
+ if (['dns', 'ip', 'directoryName', 'registeredId', 'otherName'].includes(key)
367
+ && !sanClassCount)
368
+ continue;
369
+ if (parentPath === 'tls.certificate.subjectAltNames.classes'
370
+ && LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
371
+ && !sanClassCount)
372
+ continue;
373
+ if (key === 'classes'
374
+ && (parentPath !== 'tls.certificate.subjectAltNames' || !recordOrNull(item)))
375
+ continue;
376
+ if (key === 'similarity'
377
+ && (parentPath !== 'pageIdentity.fingerprints.domStructure'
378
+ || (item !== null && !recordOrNull(item))))
379
+ continue;
380
+ if (key === 'description'
381
+ && (parentPath !== 'technologyProfile.findings.evidence'
382
+ || typeof item !== 'string'))
383
+ continue;
384
+ output[key] = projectLookupEvidenceAvailabilityPublicValue(item, [...path, key], state, depth + 1);
356
385
  }
357
386
  return output;
358
387
  }
@@ -381,11 +410,31 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
381
410
  || privateEvidenceKey(key, item))
382
411
  continue;
383
412
  const parentPath = path.join('.');
413
+ const boundedCount = Number.isSafeInteger(item)
414
+ && Number(item) >= 0
415
+ && Number(item) <= LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS;
416
+ const credentialCategoryCount = parentPath === 'credentialSurfaceProfile.inputs.categories'
417
+ && boundedCount;
418
+ const sanClassCount = parentPath === 'tls.certificate.subjectAltNames.classes'
419
+ && LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
420
+ && boundedCount;
384
421
  if (LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS.has(key)
385
- && (parentPath !== 'credentialSurfaceProfile.inputs.categories'
386
- || !Number.isSafeInteger(item)
387
- || Number(item) < 0
388
- || Number(item) > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS))
422
+ && !credentialCategoryCount
423
+ && !sanClassCount)
424
+ continue;
425
+ if (['dns', 'ip', 'directoryName', 'registeredId', 'otherName'].includes(key)
426
+ && !sanClassCount)
427
+ continue;
428
+ if (key === 'classes'
429
+ && (parentPath !== 'tls.certificate.subjectAltNames' || !recordOrNull(item)))
430
+ continue;
431
+ if (key === 'similarity'
432
+ && (parentPath !== 'pageIdentity.fingerprints.domStructure'
433
+ || (item !== null && !recordOrNull(item))))
434
+ continue;
435
+ if (key === 'description'
436
+ && (parentPath !== 'technologyProfile.findings.evidence'
437
+ || typeof item !== 'string'))
389
438
  continue;
390
439
  if (key === 'owner'
391
440
  && (!LOOKUP_AVAILABILITY_OWNER_PATHS.has(parentPath) || typeof item !== 'string'))
@@ -419,7 +468,7 @@ function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryCont
419
468
  continue;
420
469
  output[key] = currentPrivacyRules
421
470
  ? projectLookupEvidenceAvailabilityValue(item, [key], state, 1)
422
- : projectLookupEvidenceAvailabilityPublicValue(item, key, state, 1);
471
+ : projectLookupEvidenceAvailabilityPublicValue(item, [key], state, 1);
423
472
  }
424
473
  if (registryContactsExcluded)
425
474
  output.registryContactsExcluded = true;
@@ -331,7 +331,7 @@ function buildLookupEvidenceReport(document, options = {}) {
331
331
  : [];
332
332
  return {
333
333
  title: cleanReportText(titleTarget),
334
- notice: 'Human-readable summary. The versioned JSON export remains the full-fidelity evidence package; raw RDAP JSON and WHOIS response bodies are deliberately omitted here.',
334
+ notice: 'Human-readable summary of the bounded, privacy-minimised JSON evidence package. Raw RDAP JSON, WHOIS response bodies, contact records, and other excluded collection data are not included in either artefact.',
335
335
  metadata: [
336
336
  reportField('Generated', report.generatedAt),
337
337
  reportField('Generator', application.version ? `${application.name || 'WHOISleuth'} ${application.version}` : application.name || 'WHOISleuth'),
@@ -432,7 +432,7 @@ function buildLookupEvidenceReport(document, options = {}) {
432
432
  'This report summarises point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
433
433
  'Missing, skipped, partial, unsupported, or failed sources are inconclusive rather than negative evidence.',
434
434
  'The Risk score is a versioned heuristic review priority, not a probability or determination of maliciousness. Review its listed factors and the underlying source states.',
435
- 'Raw registry payloads and full WHOIS referral responses are available only in the JSON evidence package and may contain public contact data.',
435
+ 'The JSON evidence package is a bounded, privacy-minimised projection. It excludes raw registry and WHOIS payloads, contact records, authentication and session material, and unreviewed fields.',
436
436
  ...(networkContext.contextVersion === 1 ? [
437
437
  'Observed network registration describes one point-in-time public endpoint. It may identify an edge or shared network rather than the origin host and does not prove control, ownership, intent, or maliciousness.',
438
438
  ] : []),
@@ -16,7 +16,6 @@ import { analyzeStaticHtml } from "./static-html-analysis.mjs";
16
16
  import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
17
17
  import { analyzeWebsiteTechnology } from "./website-technology.mjs";
18
18
  import { PAGE_PUBLICATION_LIMITATIONS, PAGE_PUBLICATION_METADATA_VERSION, } from "./homepage-metadata-contract.mjs";
19
- const MAX_TITLE_LENGTH = 200;
20
19
  const MAX_EXTERNAL_ASSET_HOSTS = 20;
21
20
  const MAX_IDENTITY_TAGS = 512;
22
21
  const MAX_IDENTITY_TAG_LENGTH = 4096;
@@ -37,10 +36,6 @@ const MAX_URLS_PER_TAG = 20;
37
36
  const PAGE_IDENTITY_VERSION = 3;
38
37
  const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu;
39
38
  const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
40
- const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
41
- // A password input is the single strongest static-HTML tell that a page is
42
- // asking for credentials, regardless of what it claims to be.
43
- const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
44
39
  // <img>/<script>/<link> tags loading a resource from an absolute, external
45
40
  // URL - a common phishing-kit tell is hotlinking the real brand's own logo/
46
41
  // CSS/JS instead of copying it. Relative URLs (the common case, same
@@ -102,12 +97,6 @@ function extractExternalAssetHosts(html, ownDomain) {
102
97
  }
103
98
  return [...hosts];
104
99
  }
105
- function extractPageTitle(html) {
106
- const match = html.match(TITLE_RE);
107
- if (!match)
108
- return null;
109
- return boundedHtmlText(match[1], MAX_TITLE_LENGTH, true);
110
- }
111
100
  function parseAttributes(tag) {
112
101
  const start = tag.search(/\s/);
113
102
  if (start === -1)
@@ -307,9 +296,10 @@ function trackingIdentifiers(html) {
307
296
  };
308
297
  }
309
298
  function extractPageRelationships(html, domain, options = {}) {
310
- const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
299
+ const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
300
+ const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? documentUrl);
311
301
  const markup = typeof options.tagMarkup === 'string' ? options.tagMarkup : markupForTagParsing(html);
312
- const baseOrigin = new URL(baseUrl).origin;
302
+ const baseOrigin = options.documentOrigin ?? new URL(documentUrl).origin;
313
303
  const resourceKeys = new Set();
314
304
  const resourceOrigins = new Set();
315
305
  const embeddedOrigins = new Set();
@@ -454,11 +444,16 @@ function metaRefreshTarget(content) {
454
444
  return match ? (match[1] ?? match[2] ?? match[3] ?? '').trim() : null;
455
445
  }
456
446
  function extractPageIdentity(html, domain, options = {}) {
457
- const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
447
+ const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
448
+ const baseAnalysis = options.effectiveBaseUrl === undefined
449
+ ? analyzeStaticHtml(html, { baseUrl: documentUrl })
450
+ : null;
451
+ const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? baseAnalysis?.effectiveBaseUrl ?? documentUrl);
452
+ const baseHrefState = options.baseHrefState ?? baseAnalysis?.baseHrefState ?? 'absent';
458
453
  const tagMarkup = markupForTagParsing(html);
459
- const parsedBase = new URL(baseUrl);
460
- const baseOrigin = parsedBase.origin;
461
- const baseUsesHttps = parsedBase.protocol === 'https:';
454
+ const parsedDocument = new URL(documentUrl);
455
+ const baseOrigin = options.documentOrigin ?? parsedDocument.origin;
456
+ const baseUsesHttps = parsedDocument.protocol === 'https:';
462
457
  const externalFormOrigins = new Set();
463
458
  let documentLanguage = null;
464
459
  let canonical = null;
@@ -556,20 +551,25 @@ function extractPageIdentity(html, domain, options = {}) {
556
551
  const queryOmitted = [canonical, metaRefresh, openGraphUrl].some((item) => item?.queryOmitted);
557
552
  const pathTruncated = [canonical, metaRefresh, openGraphUrl].some((item) => item?.pathTruncated);
558
553
  const sourceTruncated = options.sourceTruncated === true;
559
- const relationships = extractPageRelationships(html, domain, { baseUrl, tagMarkup });
554
+ const relationships = extractPageRelationships(html, domain, { baseUrl: documentUrl, effectiveBaseUrl: baseUrl, documentOrigin: baseOrigin, tagMarkup });
560
555
  const fingerprints = createPageFingerprints(html, {
561
- baseUrl,
556
+ // Retained fingerprint algorithms pre-date document-base handling. Keep
557
+ // their response-URL normalization stable until a versioned fingerprint
558
+ // migration is intentionally introduced.
559
+ baseUrl: documentUrl,
562
560
  exactBodyHash: options.exactBodyHash,
563
561
  sourceTruncated,
564
562
  resources: relationships.resources,
565
563
  trackingIdentifiers: relationships.trackingIdentifiers,
566
564
  identifiersTruncated: relationships.diagnostics.trackingIdentifiersTruncated === true,
567
565
  });
568
- const truncated = sourceTruncated || tagLimitReached || formLimitReached || originLimitReached
566
+ const truncated = sourceTruncated || baseHrefState === 'invalid' || tagLimitReached || formLimitReached || originLimitReached
569
567
  || pathTruncated || relationships.truncated || fingerprints.truncated;
570
568
  const limitations = ['Static HTML metadata only; JavaScript-rendered changes are not evaluated.'];
571
569
  if (sourceTruncated)
572
570
  limitations.push('Homepage body capture reached its byte limit; identity fields may be incomplete.');
571
+ if (baseHrefState === 'invalid')
572
+ limitations.push('The first bounded document base URL was invalid; relative URL relationships use the response URL and remain partial.');
573
573
  if (tagLimitReached)
574
574
  limitations.push(`Page identity parsing reached the ${MAX_IDENTITY_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
575
575
  if (formLimitReached)
@@ -697,33 +697,37 @@ function buildPagePublicationMetadata(analysis, sourceTruncated) {
697
697
  };
698
698
  }
699
699
  function extractHtmlSignals(html, domain, options = {}) {
700
- const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, options);
700
+ const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
701
+ const htmlAnalysis = analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
702
+ const effectiveBaseUrl = htmlAnalysis.effectiveBaseUrl ?? documentUrl;
703
+ const documentOrigin = new URL(documentUrl).origin;
704
+ const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, {
705
+ ...options,
706
+ baseUrl: documentUrl,
707
+ effectiveBaseUrl,
708
+ documentOrigin,
709
+ baseHrefState: htmlAnalysis.baseHrefState,
710
+ });
701
711
  const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
702
712
  const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
703
713
  const includeTechnologyProfile = pageIdentity && options.includeTechnologyProfile !== false;
704
714
  const includeDerivedPageProfiles = Boolean(pageIdentity);
705
- const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
706
- const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
707
- ? analyzeStaticHtml(html, { baseUrl, includeVisibleText: true })
708
- : null;
709
715
  const pageIdentityOutput = pageIdentity && htmlAnalysis && options.includePublicationMetadata !== false
710
716
  ? {
711
717
  ...pageIdentity,
712
718
  publicationMetadata: buildPagePublicationMetadata(htmlAnalysis.publicationMetadata, options.sourceTruncated === true),
713
719
  }
714
720
  : pageIdentity;
715
- const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis ?? undefined);
721
+ const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis);
716
722
  return {
717
- pageTitle: extractPageTitle(html),
718
- hasPasswordField: PASSWORD_FIELD_RE.test(html),
723
+ pageTitle: htmlAnalysis.title,
724
+ hasPasswordField: htmlAnalysis.forms.categories.password > 0,
719
725
  phishingLanguageMatch: pageLanguageSignal?.label ?? null,
720
726
  hasExternalFormAction: pageIdentity
721
727
  ? pageIdentity.forms.externalActionOrigins.length > 0
722
728
  : null,
723
729
  externalAssetHosts: extractExternalAssetHosts(html, domain),
724
- cspMetaPolicy: htmlAnalysis
725
- ? analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached)
726
- : null,
730
+ cspMetaPolicy: analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached),
727
731
  pageIdentity: pageIdentityOutput,
728
732
  credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
729
733
  htmlAnalysis,
@@ -732,7 +736,7 @@ function extractHtmlSignals(html, domain, options = {}) {
732
736
  }) : null,
733
737
  structuredDataIdentity: includeStructuredDataIdentity && htmlAnalysis ? analyzeStructuredDataIdentity({
734
738
  htmlAnalysis,
735
- baseUrl,
739
+ baseUrl: effectiveBaseUrl,
736
740
  observedAt: options.observedAt,
737
741
  sourceTruncated: options.sourceTruncated,
738
742
  }) : null,
@@ -742,12 +746,14 @@ function extractHtmlSignals(html, domain, options = {}) {
742
746
  httpServer: options.httpServer,
743
747
  responseHeaders: options.responseHeaders,
744
748
  resourceOrigins: pageIdentity.resources.externalOrigins,
749
+ effectiveBaseUrl,
750
+ documentOrigin,
745
751
  observedAt: options.observedAt,
746
752
  sourceTruncated: options.sourceTruncated,
747
753
  }) : null,
748
754
  pageRoleProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzePageRole({
749
755
  htmlAnalysis,
750
- pageTitle: extractPageTitle(html),
756
+ pageTitle: htmlAnalysis.title,
751
757
  activityStatus: options.activityStatus,
752
758
  observedAt: options.observedAt,
753
759
  sourceTruncated: options.sourceTruncated,
@@ -45,14 +45,30 @@ function buildLocalGeoIpDatabase(value) {
45
45
  }
46
46
  const truncated = rows.length > MAX_GEOIP_RECORDS;
47
47
  const normalized = rows.slice(0, MAX_GEOIP_RECORDS).map(normalizeGeoIpEntry);
48
- const records = normalized.filter((item) => item !== null)
49
- .sort((left, right) => right.parsedNetwork.length - left.parsedNetwork.length);
48
+ const byNetwork = new Map();
49
+ const sameAttribution = (left, right) => (left.countryCode === right.countryCode
50
+ && left.region === right.region
51
+ && left.city === right.city
52
+ && left.asn === right.asn
53
+ && left.asName === right.asName);
54
+ for (const item of normalized) {
55
+ if (!item)
56
+ continue;
57
+ const existing = byNetwork.get(item.network);
58
+ if (existing && !sameAttribution(existing, item)) {
59
+ throw new TypeError(`A local GeoIP database must not assign conflicting evidence to canonical prefix ${item.network}.`);
60
+ }
61
+ if (!existing)
62
+ byNetwork.set(item.network, item);
63
+ }
64
+ const records = [...byNetwork.values()]
65
+ .sort((left, right) => right.parsedNetwork.length - left.parsedNetwork.length || left.network.localeCompare(right.network));
50
66
  return Object.freeze({
51
67
  sourceLabel,
52
68
  databaseVersion,
53
69
  license,
54
70
  records: Object.freeze(records),
55
- rejectedCount: normalized.length - records.length + Math.max(0, rows.length - MAX_GEOIP_RECORDS),
71
+ rejectedCount: normalized.filter((item) => item === null).length + Math.max(0, rows.length - MAX_GEOIP_RECORDS),
56
72
  truncated,
57
73
  });
58
74
  }
@@ -124,11 +124,12 @@ async function buildUnifiedLookupResponse(context) {
124
124
  const result = availabilityResult.value;
125
125
  availability = {
126
126
  applicable: true,
127
- domain: classified.value,
128
- inputHostname: classified.inputHostname,
129
- registrableDomain: classified.registrableDomain,
130
- isSubdomain: classified.isSubdomain,
131
127
  ...result,
128
+ // Every observation nested in the availability envelope is collected
129
+ // against this registrable target. Request context remains on the
130
+ // top-level response; exact-host sources such as security.txt retain
131
+ // their own requested/final URL attribution.
132
+ domain: classified.value,
132
133
  };
133
134
  }
134
135
  else {
@@ -2,6 +2,7 @@
2
2
  // Lookup. These deliberately exclude raw RDAP/WHOIS bodies, contacts, URLs,
3
3
  // provider payloads, and request errors. The ordinary final Lookup response
4
4
  // remains the only authoritative and persistable result.
5
+ import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_RESULT_STATES, THREAT_INTELLIGENCE_SCHEMA, } from "./threat-intelligence-types.mjs";
5
6
  const RESULT_STATES = new Set([
6
7
  'available',
7
8
  'expiring',
@@ -19,6 +20,12 @@ const DIRECT_STATES = new Set([
19
20
  'unavailable',
20
21
  'rate_limited',
21
22
  ]);
23
+ const THREAT_INTELLIGENCE_SOURCES = new Set([
24
+ 'external_intelligence',
25
+ 'malware_host_intelligence',
26
+ 'malware_ioc_intelligence',
27
+ ]);
28
+ const THREAT_INTELLIGENCE_STATES = new Set(THREAT_INTELLIGENCE_RESULT_STATES);
22
29
  function record(value) {
23
30
  return value && typeof value === 'object' && !Array.isArray(value)
24
31
  ? value
@@ -63,6 +70,15 @@ function normalizedState(source, outcome, value) {
63
70
  if (source === 'domain_evidence') {
64
71
  return record(value).deepScanComplete === false ? 'partial' : 'success';
65
72
  }
73
+ if (THREAT_INTELLIGENCE_SOURCES.has(source)) {
74
+ const intelligence = record(value);
75
+ return intelligence.schema === THREAT_INTELLIGENCE_SCHEMA
76
+ && intelligence.version === THREAT_INTELLIGENCE_CONTRACT_VERSION
77
+ && typeof intelligence.state === 'string'
78
+ && THREAT_INTELLIGENCE_STATES.has(intelligence.state)
79
+ ? intelligence.state
80
+ : 'error';
81
+ }
66
82
  const status = record(value).status;
67
83
  if (typeof status === 'string' && DIRECT_STATES.has(status)) {
68
84
  return status;
@@ -76,15 +92,22 @@ function normalizedState(source, outcome, value) {
76
92
  function normalizeLookupSourceSettlement(source, outcome, value) {
77
93
  const state = normalizedState(source, outcome, value);
78
94
  const sourceRecord = record(value);
95
+ const threatObservation = THREAT_INTELLIGENCE_SOURCES.has(source)
96
+ ? record(sourceRecord.observation)
97
+ : null;
79
98
  const resultState = source === 'domain_evidence'
80
99
  && typeof sourceRecord.state === 'string'
81
100
  && RESULT_STATES.has(sourceRecord.state)
82
101
  ? sourceRecord.state
83
102
  : null;
84
- const complete = !['partial', 'error', 'unavailable', 'rate_limited'].includes(state);
85
- const truncated = sourceRecord.truncated === true
86
- || sourceRecord.recordsTruncated === true
87
- || sourceRecord.bodyTruncated === true;
103
+ const complete = threatObservation
104
+ ? state !== 'error' && threatObservation.complete === true
105
+ : !['partial', 'error', 'unavailable', 'rate_limited'].includes(state);
106
+ const truncated = threatObservation
107
+ ? threatObservation.truncated === true
108
+ : sourceRecord.truncated === true
109
+ || sourceRecord.recordsTruncated === true
110
+ || sourceRecord.bodyTruncated === true;
88
111
  const limitation = outcome === 'rejected'
89
112
  ? 'This source did not complete. No absence or safety conclusion was inferred.'
90
113
  : state === 'skipped'
@@ -116,6 +116,7 @@ export function reviewNameserverPreflight(inputRaw, generatedAtValue = new Date(
116
116
  && authorityState === 'authoritative'
117
117
  && servedSetState === 'aligned'
118
118
  && soaState === 'observed'
119
+ && addressState !== 'non_public'
119
120
  && (!inBailiwick || addressState === 'ready');
120
121
  return Object.freeze({
121
122
  nameserver,
@@ -245,7 +245,7 @@ export function inspectRdapCapabilities(parsedValue, status) {
245
245
  break;
246
246
  }
247
247
  const declarations = [...unique].sort().map((value) => {
248
- const entry = EXTENSION_CATALOG[value];
248
+ const entry = Object.hasOwn(EXTENSION_CATALOG, value) ? EXTENSION_CATALOG[value] : undefined;
249
249
  return entry
250
250
  ? {
251
251
  identifier: value,
@@ -137,21 +137,22 @@ function disclosure(source, parsedValue, status) {
137
137
  detail: 'The source completed without a usable registrant contact or an explicit disclosure marker.',
138
138
  };
139
139
  }
140
- function lifecycle(statusesValue) {
140
+ function lifecycle(statusesValue, evidenceState) {
141
141
  const rawStatuses = strings(statusesValue);
142
142
  const normalized = rawStatuses.map(normalizedStatus);
143
143
  const has = (value) => normalized.includes(value);
144
- const pendingDelete = has('pendingdelete');
145
- const redemption = has('redemptionperiod');
146
- const pendingTransfer = has('pendingtransfer');
147
- const serverHold = has('serverhold');
148
- const clientHold = has('clienthold');
144
+ const observedState = (value) => has(value) ? true : evidenceState === 'complete' ? false : null;
145
+ const pendingDelete = observedState('pendingdelete');
146
+ const redemption = observedState('redemptionperiod');
147
+ const pendingTransfer = observedState('pendingtransfer');
148
+ const serverHold = observedState('serverhold');
149
+ const clientHold = observedState('clienthold');
149
150
  const serverLocks = normalized.filter((value) => /^server(?:delete|renew|transfer|update)prohibited$/u.test(value));
150
151
  const clientLocks = normalized.filter((value) => /^client(?:delete|renew|transfer|update)prohibited$/u.test(value));
151
- let stage = 'registered';
152
- let label = 'Registered';
153
- let acquisitionPath = ['Continue monitoring registry status and expiry.'];
154
- if (pendingDelete) {
152
+ let stage = 'unknown';
153
+ let label = 'Lifecycle unavailable';
154
+ let acquisitionPath = ['Obtain a qualifying registry or WHOIS publication before interpreting lifecycle or lock state.'];
155
+ if (pendingDelete === true) {
155
156
  stage = 'pending_delete';
156
157
  label = 'Pending delete';
157
158
  acquisitionPath = [
@@ -160,7 +161,7 @@ function lifecycle(statusesValue) {
160
161
  'Use a registrar or registry-supported acquisition path when policy permits.',
161
162
  ];
162
163
  }
163
- else if (redemption) {
164
+ else if (redemption === true) {
164
165
  stage = 'redemption';
165
166
  label = 'Redemption period';
166
167
  acquisitionPath = [
@@ -169,16 +170,21 @@ function lifecycle(statusesValue) {
169
170
  'Do not treat the domain as available or guaranteed to drop.',
170
171
  ];
171
172
  }
172
- else if (pendingTransfer) {
173
+ else if (pendingTransfer === true) {
173
174
  stage = 'pending_transfer';
174
175
  label = 'Pending transfer';
175
176
  acquisitionPath = ['A registrar transfer is pending; this is not an availability signal.'];
176
177
  }
177
- else if (serverHold || clientHold) {
178
+ else if (serverHold === true || clientHold === true) {
178
179
  stage = 'hold';
179
180
  label = 'Registration hold';
180
181
  acquisitionPath = ['A hold affects delegation or publication; it does not mean the registration is available.'];
181
182
  }
183
+ else if (evidenceState === 'complete' || rawStatuses.length > 0) {
184
+ stage = 'registered';
185
+ label = 'Registered';
186
+ acquisitionPath = ['Continue monitoring registry status and expiry.'];
187
+ }
182
188
  return {
183
189
  stage,
184
190
  label,
@@ -188,8 +194,8 @@ function lifecycle(statusesValue) {
188
194
  pendingTransfer,
189
195
  hold: { client: clientHold, server: serverHold },
190
196
  locks: {
191
- client: clientLocks.length > 0,
192
- server: serverLocks.length > 0,
197
+ client: clientLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
198
+ server: serverLocks.length > 0 ? true : evidenceState === 'complete' ? false : null,
193
199
  clientStatuses: clientLocks,
194
200
  serverStatuses: serverLocks,
195
201
  },
@@ -271,7 +277,18 @@ export function buildRegistryInsights(input) {
271
277
  : counts.equivalent
272
278
  ? 'consistent'
273
279
  : 'unavailable';
274
- const statuses = strings(rdap.statuses).length ? rdap.statuses : whois.statuses;
280
+ const publications = [
281
+ publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
282
+ publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
283
+ publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
284
+ ];
285
+ const lifecycleCandidates = [
286
+ { statuses: strings(rdap.statuses), state: publications[0].state },
287
+ { statuses: strings(whois.statuses), state: publications[1].state },
288
+ ].filter((candidate) => candidate.state !== 'unavailable');
289
+ const lifecycleSource = lifecycleCandidates.find((candidate) => candidate.statuses.length)
290
+ ?? lifecycleCandidates[0]
291
+ ?? { statuses: [], state: 'unavailable' };
275
292
  const routes = [
276
293
  ...contactRoute('registry', rdap.abuse, 'Registry RDAP abuse entity'),
277
294
  ...contactRoute('registrar', rdap.registrar, 'Registry RDAP registrar entity'),
@@ -294,7 +311,7 @@ export function buildRegistryInsights(input) {
294
311
  whois: disclosure('whois', input.whoisParsed, input.whoisStatus),
295
312
  limitation: 'Disclosure state describes what each point-in-time source published. It does not infer the identity, intent, reachability, or legal status of a registrant.',
296
313
  },
297
- lifecycle: lifecycle(statuses),
314
+ lifecycle: lifecycle(lifecycleSource.statuses, lifecycleSource.state),
298
315
  reconciliation: {
299
316
  state: reconciliationState,
300
317
  conflictCount,
@@ -312,11 +329,7 @@ export function buildRegistryInsights(input) {
312
329
  ? 'The comparable fields are normalised as equivalent.'
313
330
  : 'No comparable fields were available.',
314
331
  },
315
- publications: [
316
- publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
317
- publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
318
- publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
319
- ],
332
+ publications,
320
333
  rdapCapabilities: {
321
334
  registry: inspectRdapCapabilities(input.rdapParsed, input.rdapStatus),
322
335
  registrar: inspectRdapCapabilities(input.registrarRdapParsed, input.registrarRdapStatus),
@@ -109,9 +109,7 @@ function thresholdMetrics(value, label, expectedThreshold, expectedTotal, expect
109
109
  const recall = ratio(truePositive, truePositive + falseNegative);
110
110
  const specificity = ratio(trueNegative, trueNegative + falsePositive);
111
111
  const falsePositiveRate = ratio(falsePositive, falsePositive + trueNegative);
112
- const f1 = precision === null || recall === null || precision + recall === 0
113
- ? null
114
- : Number(((2 * precision * recall) / (precision + recall)).toFixed(4));
112
+ const f1 = ratio(2 * truePositive, 2 * truePositive + falsePositive + falseNegative);
115
113
  const balancedAccuracy = recall === null || specificity === null
116
114
  ? null
117
115
  : Number(((recall + specificity) / 2).toFixed(4));
@@ -50,6 +50,38 @@ function normalizedPublishedUri(value, schemes) {
50
50
  return null;
51
51
  if (url.protocol === 'https:' && !url.hostname)
52
52
  return null;
53
+ if (url.protocol === 'mailto:') {
54
+ let recipients;
55
+ try {
56
+ recipients = decodeURIComponent(url.pathname).split(',');
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (!recipients.length || recipients.some((recipient) => {
62
+ const separator = recipient.lastIndexOf('@');
63
+ if (separator <= 0 || separator === recipient.length - 1)
64
+ return true;
65
+ const local = recipient.slice(0, separator);
66
+ const mailDomain = recipient.slice(separator + 1).toLowerCase();
67
+ return !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/iu.test(local)
68
+ || local.startsWith('.') || local.endsWith('.') || local.includes('..')
69
+ || !canonicalHostname(mailDomain);
70
+ }))
71
+ return null;
72
+ }
73
+ if (url.protocol === 'tel:') {
74
+ let number;
75
+ try {
76
+ number = decodeURIComponent(url.pathname);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (!/^\+?[0-9(). -]+(?:;[a-z0-9-]+=[a-z0-9.-]+)*$/iu.test(number)
82
+ || (number.match(/[0-9]/gu)?.length ?? 0) < 3)
83
+ return null;
84
+ }
53
85
  url.search = '';
54
86
  url.hash = '';
55
87
  return url.toString().slice(0, MAX_SECURITY_TXT_URL_LENGTH);
@@ -211,7 +243,7 @@ function parseSecurityTxt(text, options = {}) {
211
243
  const finalComparisonUrl = options.finalUrl ? normalizedCanonicalComparisonUrl(options.finalUrl) : null;
212
244
  const canonicalMatches = boundedCanonical.values.length && finalUrl && finalComparisonUrl
213
245
  ? canonicalComparison.slice(0, MAX_SECURITY_TXT_VALUES)
214
- .some((value) => value.toLowerCase() === finalComparisonUrl.toLowerCase())
246
+ .some((value) => value === finalComparisonUrl)
215
247
  : null;
216
248
  const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
217
249
  const requiredMalformed = boundedContacts.values.length === 0 || expires.length !== 1;