@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 (90) hide show
  1. package/README.md +23 -4
  2. package/bin/whoisleuth.mjs +31 -9
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +72 -39
  7. package/cli/bulk-command-runner.mjs +34 -4
  8. package/cli/bulk.mjs +18 -7
  9. package/cli/case-pack.mjs +136 -0
  10. package/cli/ci-report.mjs +73 -0
  11. package/cli/collection-preflight.mjs +59 -0
  12. package/cli/command-reference.mjs +395 -0
  13. package/cli/completion.mjs +67 -13
  14. package/cli/config-profile.mjs +183 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-observation-snapshot.mjs +8 -14
  17. package/cli/discovery-scan-command-runner.mjs +29 -2
  18. package/cli/discovery-snapshot.mjs +8 -15
  19. package/cli/domain-control-monitor.mjs +104 -0
  20. package/cli/domain-control-observations.mjs +235 -0
  21. package/cli/errors.mjs +7 -1
  22. package/cli/evidence-command-arguments.mjs +11 -0
  23. package/cli/evidence-command-runner.mjs +1 -0
  24. package/cli/external-observation-mapping.mjs +162 -0
  25. package/cli/fail-policy.mjs +64 -0
  26. package/cli/formatters/html.mjs +1 -1
  27. package/cli/formatters/json.mjs +12 -4
  28. package/cli/formatters/terminal.mjs +1 -1
  29. package/cli/http.mjs +2 -1
  30. package/cli/investigation-manifest.mjs +113 -0
  31. package/cli/investigation-run.mjs +112 -0
  32. package/cli/lookup-brief.mjs +116 -0
  33. package/cli/lookup-command-runner.mjs +8 -1
  34. package/cli/lookup-diff.mjs +17 -19
  35. package/cli/lookup-reconcile.mjs +1 -1
  36. package/cli/mail-review.mjs +5 -5
  37. package/cli/manual.mjs +1 -1
  38. package/cli/network-command-runner.mjs +4 -1
  39. package/cli/offline-evidence-review.mjs +105 -8
  40. package/cli/open-asset-model-bridge.mjs +169 -0
  41. package/cli/registry-cohort.mjs +121 -0
  42. package/cli/registry-doctor.mjs +106 -11
  43. package/cli/registry-fixture-scaffold.mjs +74 -0
  44. package/cli/runner.mjs +388 -315
  45. package/docs/cli-reference.md +266 -21
  46. package/docs/cli.md +43 -7
  47. package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
  48. package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
  49. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  50. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  51. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  52. package/frontend/src/lib/analysis/case-report.js +573 -0
  53. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  54. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  55. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  56. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  57. package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
  58. package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
  59. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  60. package/frontend/src/lib/analysis/scoring.js +1 -1
  61. package/frontend/src/lib/analysis/utils.js +1 -1
  62. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  63. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  64. package/frontend/src/lib/analysis/workspace-archive.js +5 -7
  65. package/lib/abusech-auth.mjs +1 -1
  66. package/lib/availability.mjs +1 -1
  67. package/lib/bounded-contract-normalizers.mjs +33 -0
  68. package/lib/bounded-file.mjs +69 -0
  69. package/lib/bounded-json-response.mjs +157 -0
  70. package/lib/browser-library-profile.mjs +167 -38
  71. package/lib/dns-convergence-review.mjs +183 -0
  72. package/lib/dns-delegation-health.mjs +196 -3
  73. package/lib/domain-change-packet.mjs +133 -0
  74. package/lib/domain-change-review.mjs +307 -0
  75. package/lib/domain-control-flight-recorder.mjs +268 -0
  76. package/lib/domain-portfolio-review.mjs +151 -0
  77. package/lib/evidence-export.mjs +1 -1
  78. package/lib/html-signals.mjs +5 -11
  79. package/lib/lookup-cache.mjs +5 -1
  80. package/lib/nameserver-preflight-review.mjs +159 -0
  81. package/lib/page-language-signals.mjs +124 -0
  82. package/lib/rdap-transport.mjs +6 -6
  83. package/lib/risk-scoring.mjs +42 -1
  84. package/lib/safe-fetch.mjs +37 -32
  85. package/lib/static-html-analysis.mjs +29 -1
  86. package/lib/threat-intelligence-runtime.mjs +1 -2
  87. package/lib/trust-store-comparison.mjs +146 -0
  88. package/lib/website-technology.mjs +22 -12
  89. package/lib/zone-intent-review.mjs +534 -0
  90. package/package.json +1 -1
@@ -0,0 +1,151 @@
1
+ import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
+ export const DOMAIN_PORTFOLIO_INPUT_SCHEMA = 'whoisleuth.domain-portfolio.input';
3
+ export const DOMAIN_PORTFOLIO_REVIEW_SCHEMA = 'whoisleuth.domain-portfolio.review';
4
+ export const DOMAIN_PORTFOLIO_REVIEW_VERSION = 1;
5
+ export const MAX_PORTFOLIO_ASSETS = 500;
6
+ const ROOT_KEYS = new Set(['schema', 'version', 'portfolioLabel', 'assets']);
7
+ const ASSET_KEYS = new Set([
8
+ 'domain', 'criticality', 'registrar', 'registrarAccount', 'expiresAt', 'autoRenew', 'dnsProviders', 'mailProviders',
9
+ 'certificateProviders', 'recoveryDomains', 'reviewedAt',
10
+ ]);
11
+ function text(value, label, maximum = 160) {
12
+ return requireBoundedString(value, label, maximum);
13
+ }
14
+ function optionalText(value, label, maximum = 160) {
15
+ if (value === null || value === undefined || value === '')
16
+ return null;
17
+ return text(value, label, maximum);
18
+ }
19
+ function timestamp(value, label, optional = false) {
20
+ if (optional && (value === null || value === undefined || value === ''))
21
+ return null;
22
+ return requireIsoTimestamp(value, label);
23
+ }
24
+ function domain(value, label) {
25
+ return requireDomainName(value, label);
26
+ }
27
+ function textList(value, label) {
28
+ if (!Array.isArray(value) || value.length > 16)
29
+ throw new TypeError(`${label} must contain no more than 16 labels.`);
30
+ return [...new Set(value.map((item, index) => text(item, `${label}[${index}]`, 120)))].sort();
31
+ }
32
+ function domainList(value, label) {
33
+ if (!Array.isArray(value) || value.length > 16)
34
+ throw new TypeError(`${label} must contain no more than 16 domains.`);
35
+ return [...new Set(value.map((item, index) => domain(item, `${label}[${index}]`)))].sort();
36
+ }
37
+ function nullableBoolean(value, label) {
38
+ if (value === null || value === undefined)
39
+ return null;
40
+ if (typeof value !== 'boolean')
41
+ throw new TypeError(`${label} must be true, false, or null.`);
42
+ return value;
43
+ }
44
+ export function reviewDomainPortfolio(inputRaw, generatedAtValue = new Date().toISOString()) {
45
+ const input = requireRecord(inputRaw, 'Domain portfolio input');
46
+ if (input.schema !== DOMAIN_PORTFOLIO_INPUT_SCHEMA || input.version !== 1)
47
+ throw new TypeError(`Domain portfolio input must use ${DOMAIN_PORTFOLIO_INPUT_SCHEMA} version 1.`);
48
+ exactKeys(input, ROOT_KEYS, 'Domain portfolio input');
49
+ if (!Array.isArray(input.assets) || input.assets.length < 1 || input.assets.length > MAX_PORTFOLIO_ASSETS) {
50
+ throw new TypeError(`Domain portfolio input must contain from 1 to ${MAX_PORTFOLIO_ASSETS} assets.`);
51
+ }
52
+ const portfolioLabel = text(input.portfolioLabel, 'portfolioLabel', 120);
53
+ const assets = input.assets.map((raw, index) => {
54
+ const item = requireRecord(raw, `assets[${index}]`);
55
+ exactKeys(item, ASSET_KEYS, `assets[${index}]`);
56
+ const criticality = item.criticality;
57
+ if (criticality !== 'low' && criticality !== 'standard' && criticality !== 'high' && criticality !== 'critical') {
58
+ throw new TypeError(`assets[${index}].criticality is unsupported.`);
59
+ }
60
+ return Object.freeze({
61
+ domain: domain(item.domain, `assets[${index}].domain`),
62
+ criticality,
63
+ registrar: optionalText(item.registrar, `assets[${index}].registrar`, 120),
64
+ registrarAccount: optionalText(item.registrarAccount, `assets[${index}].registrarAccount`, 120),
65
+ expiresAt: timestamp(item.expiresAt, `assets[${index}].expiresAt`, true),
66
+ autoRenew: nullableBoolean(item.autoRenew, `assets[${index}].autoRenew`),
67
+ dnsProviders: Object.freeze(textList(item.dnsProviders ?? [], `assets[${index}].dnsProviders`)),
68
+ mailProviders: Object.freeze(textList(item.mailProviders ?? [], `assets[${index}].mailProviders`)),
69
+ certificateProviders: Object.freeze(textList(item.certificateProviders ?? [], `assets[${index}].certificateProviders`)),
70
+ recoveryDomains: Object.freeze(domainList(item.recoveryDomains ?? [], `assets[${index}].recoveryDomains`)),
71
+ reviewedAt: timestamp(item.reviewedAt, `assets[${index}].reviewedAt`),
72
+ });
73
+ }).sort((left, right) => left.domain.localeCompare(right.domain));
74
+ if (new Set(assets.map((asset) => asset.domain)).size !== assets.length)
75
+ throw new TypeError('Domain portfolio assets must use unique domains.');
76
+ const dependencyRows = [];
77
+ for (const asset of assets) {
78
+ const critical = asset.criticality === 'critical' || asset.criticality === 'high';
79
+ if (asset.registrar)
80
+ dependencyRows.push({ type: 'registrar', provider: asset.registrar, domain: asset.domain, critical });
81
+ for (const provider of asset.dnsProviders)
82
+ dependencyRows.push({ type: 'dns', provider, domain: asset.domain, critical });
83
+ for (const provider of asset.mailProviders)
84
+ dependencyRows.push({ type: 'mail', provider, domain: asset.domain, critical });
85
+ for (const provider of asset.certificateProviders)
86
+ dependencyRows.push({ type: 'certificate', provider, domain: asset.domain, critical });
87
+ for (const provider of asset.recoveryDomains)
88
+ dependencyRows.push({ type: 'recovery', provider, domain: asset.domain, critical });
89
+ }
90
+ const grouped = new Map();
91
+ for (const row of dependencyRows) {
92
+ const key = `${row.type}\u0000${row.provider.toLowerCase()}`;
93
+ grouped.set(key, [...(grouped.get(key) ?? []), row]);
94
+ }
95
+ const simulations = [...grouped.values()].map((rows) => Object.freeze({
96
+ dependencyType: rows[0].type,
97
+ provider: rows[0].provider,
98
+ affectedDomains: Object.freeze([...new Set(rows.map((row) => row.domain))].sort()),
99
+ affectedCriticalDomains: Object.freeze([...new Set(rows.filter((row) => row.critical).map((row) => row.domain))].sort()),
100
+ share: Math.round((new Set(rows.map((row) => row.domain)).size / assets.length) * 1000) / 10,
101
+ })).sort((left, right) => (right.affectedDomains.length - left.affectedDomains.length
102
+ || left.dependencyType.localeCompare(right.dependencyType)
103
+ || left.provider.localeCompare(right.provider)));
104
+ const now = Date.parse(timestamp(generatedAtValue, 'generatedAt'));
105
+ const renewalQueue = assets.flatMap((asset) => {
106
+ const expiry = asset.expiresAt ? Date.parse(asset.expiresAt) : Number.NaN;
107
+ const days = Number.isFinite(expiry) ? Math.ceil((expiry - now) / 86_400_000) : null;
108
+ const reasons = [
109
+ ...(days === null ? ['Expiry is unavailable.'] : days <= 90 ? [`Expiry is ${days < 0 ? `${Math.abs(days)} days overdue` : `due in ${days} days`}.`] : []),
110
+ ...(asset.autoRenew === false ? ['Auto-renew is recorded as disabled.'] : asset.autoRenew === null ? ['Auto-renew state is unavailable.'] : []),
111
+ ...(!asset.registrarAccount ? ['Registrar account label is unavailable.'] : []),
112
+ ];
113
+ return reasons.length ? [Object.freeze({ domain: asset.domain, criticality: asset.criticality, expiresAt: asset.expiresAt, daysUntilExpiry: days, reasons: Object.freeze(reasons) })] : [];
114
+ }).sort((left, right) => ((left.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER) - (right.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER)
115
+ || left.domain.localeCompare(right.domain)));
116
+ const owned = new Set(assets.map((asset) => asset.domain));
117
+ const recoveryCycles = assets.flatMap((asset) => asset.recoveryDomains
118
+ .filter((recoveryDomain) => owned.has(recoveryDomain))
119
+ .map((recoveryDomain) => Object.freeze({
120
+ protectedDomain: asset.domain,
121
+ recoveryDomain,
122
+ reciprocal: assets.find((candidate) => candidate.domain === recoveryDomain)?.recoveryDomains.includes(asset.domain) ?? false,
123
+ })));
124
+ const unknownCounts = Object.freeze({
125
+ registrar: assets.filter((asset) => !asset.registrar).length,
126
+ registrarAccount: assets.filter((asset) => !asset.registrarAccount).length,
127
+ expiry: assets.filter((asset) => !asset.expiresAt).length,
128
+ autoRenew: assets.filter((asset) => asset.autoRenew === null).length,
129
+ dns: assets.filter((asset) => !asset.dnsProviders.length).length,
130
+ mail: assets.filter((asset) => !asset.mailProviders.length).length,
131
+ certificate: assets.filter((asset) => !asset.certificateProviders.length).length,
132
+ recovery: assets.filter((asset) => !asset.recoveryDomains.length).length,
133
+ });
134
+ return Object.freeze({
135
+ schema: DOMAIN_PORTFOLIO_REVIEW_SCHEMA,
136
+ version: DOMAIN_PORTFOLIO_REVIEW_VERSION,
137
+ generatedAt: new Date(now).toISOString(),
138
+ portfolioLabel,
139
+ assets: Object.freeze(assets),
140
+ simulations: Object.freeze(simulations),
141
+ renewalQueue: Object.freeze(renewalQueue),
142
+ recoveryCycles: Object.freeze(recoveryCycles),
143
+ unknownCounts,
144
+ limitations: Object.freeze([
145
+ 'This local review uses analyst-supplied portfolio assertions and makes no registrar, DNS, mail, certificate, account, or recovery request.',
146
+ 'Provider and account labels are not proof of current configuration, ownership, control, availability, or contractual responsibility.',
147
+ 'Failure simulations show exact supplied dependency concentration only; they do not predict an outage or establish that providers share infrastructure.',
148
+ 'Do not include credentials, recovery codes, personal contact details, account identifiers, or other secrets in this input.',
149
+ ]),
150
+ });
151
+ }
@@ -3,7 +3,7 @@ import { buildRegistryInsights } from "./registry-insights.mjs";
3
3
  import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
4
4
  import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
5
5
  export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
6
- export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 24;
6
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 25;
7
7
  const REGISTRAR_RDAP_STATUSES = new Set([
8
8
  'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
9
9
  ]);
@@ -9,13 +9,13 @@ import { analyzeCredentialSurfaceProfile } from "./credential-surface-profile.mj
9
9
  import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
10
10
  import { createObservation } from "./observation.mjs";
11
11
  import { createPageFingerprints } from "./page-fingerprints.mjs";
12
+ import { detectPageLanguageSignal } from "./page-language-signals.mjs";
12
13
  import { analyzePageRole } from "./page-role-profile.mjs";
13
14
  import { analyzeCspMetaPolicies } from "./response-policy.mjs";
14
15
  import { analyzeStaticHtml } from "./static-html-analysis.mjs";
15
16
  import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
16
17
  import { analyzeWebsiteTechnology } from "./website-technology.mjs";
17
18
  const MAX_TITLE_LENGTH = 200;
18
- const MAX_PHISHING_MATCH_LENGTH = 200;
19
19
  const MAX_EXTERNAL_ASSET_HOSTS = 20;
20
20
  const MAX_IDENTITY_TAGS = 512;
21
21
  const MAX_IDENTITY_TAG_LENGTH = 4096;
@@ -40,12 +40,6 @@ const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
40
40
  // A password input is the single strongest static-HTML tell that a page is
41
41
  // asking for credentials, regardless of what it claims to be.
42
42
  const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
43
- // Common urgency/social-engineering phrasing used to pressure a visitor
44
- // into entering credentials - not exhaustive, and legitimate sites
45
- // occasionally use similar language too, so this is one signal among
46
- // several, not a verdict on its own (same framing as FOR_SALE_TEXT_RE in
47
- // availability.js).
48
- const PHISHING_LANGUAGE_RE = /(verify your account|confirm your identity|unusual (?:sign-?in|login) activity|account has been (?:suspended|limited|locked|restricted)|your account will be (?:suspended|closed|locked|terminated)|click here to (?:verify|confirm|update|restore)|security alert|immediate action required|re-?activate your account|unauthorized access detected|update your (?:payment|billing) (?:information|details)|confirm your password|your password (?:has expired|will expire soon)|connect your wallet|enter your (?:seed|recovery) phrase|verify your (?:seed|recovery) phrase|enter your private key)/i;
49
43
  // <img>/<script>/<link> tags loading a resource from an absolute, external
50
44
  // URL - a common phishing-kit tell is hotlinking the real brand's own logo/
51
45
  // CSS/JS instead of copying it. Relative URLs (the common case, same
@@ -625,7 +619,6 @@ function extractPageIdentity(html, domain, options = {}) {
625
619
  };
626
620
  }
627
621
  function extractHtmlSignals(html, domain, options = {}) {
628
- const phishingMatch = html.match(PHISHING_LANGUAGE_RE);
629
622
  const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, options);
630
623
  const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
631
624
  const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
@@ -633,12 +626,13 @@ function extractHtmlSignals(html, domain, options = {}) {
633
626
  const includeDerivedPageProfiles = Boolean(pageIdentity);
634
627
  const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
635
628
  const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
636
- ? analyzeStaticHtml(html, { baseUrl })
629
+ ? analyzeStaticHtml(html, { baseUrl, includeVisibleText: true })
637
630
  : null;
631
+ const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis ?? undefined);
638
632
  return {
639
633
  pageTitle: extractPageTitle(html),
640
634
  hasPasswordField: PASSWORD_FIELD_RE.test(html),
641
- phishingLanguageMatch: phishingMatch ? boundedHtmlText(phishingMatch[0], MAX_PHISHING_MATCH_LENGTH) : null,
635
+ phishingLanguageMatch: pageLanguageSignal?.label ?? null,
642
636
  hasExternalFormAction: pageIdentity
643
637
  ? pageIdentity.forms.externalActionOrigins.length > 0
644
638
  : null,
@@ -681,4 +675,4 @@ function extractHtmlSignals(html, domain, options = {}) {
681
675
  }) : null,
682
676
  };
683
677
  }
684
- export { PAGE_IDENTITY_VERSION, MAX_IDENTITY_TAGS, MAX_FORMS, MAX_FORM_ACTION_ORIGINS, MAX_RESOURCE_TAGS, MAX_RESOURCE_ORIGINS, MAX_EMBEDDED_ORIGINS, MAX_CONTACT_DOMAINS, MAX_TRACKING_IDENTIFIERS, extractHtmlSignals, extractPageIdentity, extractPageRelationships, };
678
+ export { PAGE_IDENTITY_VERSION, MAX_IDENTITY_TAGS, MAX_FORMS, MAX_FORM_ACTION_ORIGINS, MAX_RESOURCE_TAGS, MAX_RESOURCE_ORIGINS, MAX_EMBEDDED_ORIGINS, MAX_CONTACT_DOMAINS, MAX_TRACKING_IDENTIFIERS, extractHtmlSignals, extractPageIdentity, };
@@ -42,7 +42,7 @@ function approxByteSize(value) {
42
42
  return Buffer.byteLength(JSON.stringify(value));
43
43
  }
44
44
  catch {
45
- return 0;
45
+ return null;
46
46
  }
47
47
  }
48
48
  function deleteEntry(key) {
@@ -76,6 +76,10 @@ function setCached(key, value) {
76
76
  sweepExpiredEntries(Date.now());
77
77
  deleteEntry(key); // avoid double-counting bytes if this key is already cached
78
78
  const size = approxByteSize(value);
79
+ // A value whose serialized size cannot be measured cannot participate in a
80
+ // byte-bounded cache. Return it to the current caller without retaining it.
81
+ if (size === null)
82
+ return;
79
83
  store.set(key, { value, expiresAt: Date.now() + TTL_MS, size });
80
84
  totalBytes += size;
81
85
  while (store.size > 0 && (store.size > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES)) {
@@ -0,0 +1,159 @@
1
+ import { isIP } from 'node:net';
2
+ import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
3
+ import { isPrivateAddress } from "./safe-fetch.mjs";
4
+ export const NAMESERVER_PREFLIGHT_INPUT_SCHEMA = 'whoisleuth.nameserver-preflight.input';
5
+ export const NAMESERVER_PREFLIGHT_REVIEW_SCHEMA = 'whoisleuth.nameserver-preflight.review';
6
+ export const NAMESERVER_PREFLIGHT_REVIEW_VERSION = 1;
7
+ export const MAX_PREFLIGHT_NAMESERVERS = 8;
8
+ export const MAX_PREFLIGHT_ADDRESSES = 4;
9
+ const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'intendedNameservers', 'observations']);
10
+ const OBSERVATION_KEYS = new Set(['nameserver', 'state', 'source', 'observedAt', 'addresses', 'authoritative', 'servedNameservers', 'soaPrimary', 'soaSerial']);
11
+ function text(value, label, maximum = 240) {
12
+ return requireBoundedString(value, label, maximum);
13
+ }
14
+ function hostname(value, label) {
15
+ return requireDomainName(value, label, 'hostname');
16
+ }
17
+ function timestamp(value, label) {
18
+ return requireIsoTimestamp(value, label);
19
+ }
20
+ function state(value, label) {
21
+ if (value !== 'observed' && value !== 'partial' && value !== 'unavailable')
22
+ throw new TypeError(`${label} is unsupported.`);
23
+ return value;
24
+ }
25
+ function uniqueHostnames(value, label, minimum = 1) {
26
+ if (!Array.isArray(value) || value.length < minimum || value.length > MAX_PREFLIGHT_NAMESERVERS) {
27
+ throw new TypeError(`${label} must contain from ${minimum} to ${MAX_PREFLIGHT_NAMESERVERS} hostnames.`);
28
+ }
29
+ const names = value.map((item, index) => hostname(item, `${label}[${index}]`));
30
+ if (new Set(names).size !== names.length)
31
+ throw new TypeError(`${label} must not contain duplicate hostnames.`);
32
+ return Object.freeze(names.sort());
33
+ }
34
+ function addresses(value, label) {
35
+ if (!Array.isArray(value) || value.length > MAX_PREFLIGHT_ADDRESSES)
36
+ throw new TypeError(`${label} must contain no more than ${MAX_PREFLIGHT_ADDRESSES} addresses.`);
37
+ const normalised = value.map((item, index) => {
38
+ const supplied = text(item, `${label}[${index}]`, 80).toLowerCase();
39
+ if (!isIP(supplied) || supplied.includes('%'))
40
+ throw new TypeError(`${label}[${index}] must be an IPv4 or IPv6 address without a zone identifier.`);
41
+ return supplied;
42
+ });
43
+ return Object.freeze([...new Set(normalised)].sort());
44
+ }
45
+ function sameSet(left, right) {
46
+ return left.length === right.length && left.every((item, index) => item === right[index]);
47
+ }
48
+ export function reviewNameserverPreflight(inputRaw, generatedAtValue = new Date().toISOString()) {
49
+ const input = requireRecord(inputRaw, 'Nameserver preflight input');
50
+ if (input.schema !== NAMESERVER_PREFLIGHT_INPUT_SCHEMA || input.version !== 1) {
51
+ throw new TypeError(`Nameserver preflight input must use ${NAMESERVER_PREFLIGHT_INPUT_SCHEMA} version 1.`);
52
+ }
53
+ exactKeys(input, ROOT_KEYS, 'Nameserver preflight input');
54
+ const domain = hostname(input.domain, 'domain');
55
+ const intendedNameservers = uniqueHostnames(input.intendedNameservers, 'intendedNameservers');
56
+ if (!Array.isArray(input.observations) || input.observations.length > MAX_PREFLIGHT_NAMESERVERS) {
57
+ throw new TypeError(`observations must contain no more than ${MAX_PREFLIGHT_NAMESERVERS} entries.`);
58
+ }
59
+ const observations = input.observations.map((raw, index) => {
60
+ const item = requireRecord(raw, `observations[${index}]`);
61
+ exactKeys(item, OBSERVATION_KEYS, `observations[${index}]`);
62
+ const observationState = state(item.state, `observations[${index}].state`);
63
+ const nameserver = hostname(item.nameserver, `observations[${index}].nameserver`);
64
+ const observedAddresses = addresses(item.addresses ?? [], `observations[${index}].addresses`);
65
+ const servedNameservers = item.servedNameservers === undefined || item.servedNameservers === null
66
+ ? Object.freeze([])
67
+ : uniqueHostnames(item.servedNameservers, `observations[${index}].servedNameservers`, 0);
68
+ const authoritative = item.authoritative === null || item.authoritative === undefined
69
+ ? null
70
+ : typeof item.authoritative === 'boolean'
71
+ ? item.authoritative
72
+ : (() => { throw new TypeError(`observations[${index}].authoritative must be true, false, or null.`); })();
73
+ const soaPrimary = item.soaPrimary === null || item.soaPrimary === undefined ? null : hostname(item.soaPrimary, `observations[${index}].soaPrimary`);
74
+ const soaSerial = item.soaSerial === null || item.soaSerial === undefined
75
+ ? null
76
+ : Number.isSafeInteger(item.soaSerial) && Number(item.soaSerial) >= 0 && Number(item.soaSerial) <= 0xffff_ffff
77
+ ? Number(item.soaSerial)
78
+ : (() => { throw new TypeError(`observations[${index}].soaSerial is outside its supported range.`); })();
79
+ if (observationState === 'unavailable' && (observedAddresses.length || servedNameservers.length || authoritative !== null || soaPrimary !== null || soaSerial !== null)) {
80
+ throw new TypeError(`observations[${index}] cannot contain observed values when unavailable.`);
81
+ }
82
+ return Object.freeze({
83
+ nameserver,
84
+ state: observationState,
85
+ source: text(item.source, `observations[${index}].source`, 240),
86
+ observedAt: timestamp(item.observedAt, `observations[${index}].observedAt`),
87
+ addresses: observedAddresses,
88
+ authoritative,
89
+ servedNameservers,
90
+ soaPrimary,
91
+ soaSerial,
92
+ });
93
+ });
94
+ if (new Set(observations.map((item) => item.nameserver)).size !== observations.length)
95
+ throw new TypeError('observations must use unique nameservers.');
96
+ if (observations.some((item) => !intendedNameservers.includes(item.nameserver)))
97
+ throw new TypeError('observations can describe only intended nameservers.');
98
+ const rows = intendedNameservers.map((nameserver) => {
99
+ const observation = observations.find((item) => item.nameserver === nameserver) ?? null;
100
+ const inBailiwick = nameserver === domain || nameserver.endsWith(`.${domain}`);
101
+ const publicAddresses = observation?.addresses.filter((address) => !isPrivateAddress(address)) ?? [];
102
+ const addressState = !observation || observation.state === 'unavailable'
103
+ ? 'unknown'
104
+ : !observation.addresses.length
105
+ ? inBailiwick ? 'missing' : 'not_supplied'
106
+ : publicAddresses.length ? 'ready' : 'non_public';
107
+ const authorityState = !observation || observation.state !== 'observed' || observation.authoritative === null
108
+ ? 'unknown'
109
+ : observation.authoritative ? 'authoritative' : 'not_authoritative';
110
+ const servedSetState = !observation || observation.state !== 'observed' || !observation.servedNameservers.length
111
+ ? 'unknown'
112
+ : sameSet(observation.servedNameservers, intendedNameservers) ? 'aligned' : 'different';
113
+ const soaState = !observation || observation.state !== 'observed' || !observation.soaPrimary || observation.soaSerial === null
114
+ ? 'unknown' : 'observed';
115
+ const ready = observation?.state === 'observed'
116
+ && authorityState === 'authoritative'
117
+ && servedSetState === 'aligned'
118
+ && soaState === 'observed'
119
+ && (!inBailiwick || addressState === 'ready');
120
+ return Object.freeze({
121
+ nameserver,
122
+ inBailiwick,
123
+ observationState: observation?.state ?? 'not_supplied',
124
+ addressState,
125
+ authorityState,
126
+ servedSetState,
127
+ soaState,
128
+ publicAddresses: Object.freeze(publicAddresses),
129
+ ready,
130
+ source: observation?.source ?? null,
131
+ observedAt: observation?.observedAt ?? null,
132
+ });
133
+ });
134
+ const reasons = rows.flatMap((row) => [
135
+ ...(!row.ready && row.observationState !== 'observed' ? [`${row.nameserver} evidence is ${row.observationState}.`] : []),
136
+ ...(row.addressState === 'missing' ? [`${row.nameserver} is in-bailiwick but no glue address was supplied.`] : []),
137
+ ...(row.addressState === 'non_public' ? [`${row.nameserver} has no observed public address suitable for delegation.`] : []),
138
+ ...(row.authorityState !== 'authoritative' ? [`${row.nameserver} authoritative service is ${row.authorityState}.`] : []),
139
+ ...(row.servedSetState !== 'aligned' ? [`${row.nameserver} served NS set is ${row.servedSetState}.`] : []),
140
+ ...(row.soaState !== 'observed' ? [`${row.nameserver} SOA evidence is ${row.soaState}.`] : []),
141
+ ]);
142
+ const pass = rows.every((row) => row.ready);
143
+ return Object.freeze({
144
+ schema: NAMESERVER_PREFLIGHT_REVIEW_SCHEMA,
145
+ version: NAMESERVER_PREFLIGHT_REVIEW_VERSION,
146
+ generatedAt: timestamp(generatedAtValue, 'generatedAt'),
147
+ domain,
148
+ state: pass ? 'ready' : 'review',
149
+ intendedNameservers,
150
+ rows: Object.freeze(rows),
151
+ gate: Object.freeze({ pass, reasons: Object.freeze(reasons) }),
152
+ limitations: Object.freeze([
153
+ 'This local preflight uses only analyst-supplied observations and makes no DNS or registry request.',
154
+ 'A ready result describes the supplied collection moment; it does not apply a delegation, publish glue, or prove continued reachability.',
155
+ 'Partial, unavailable, and omitted observations remain unknown and cannot satisfy the preflight gate.',
156
+ 'Only in-bailiwick nameservers require supplied public glue evidence in this review; registry-specific policy must be checked separately.',
157
+ ]),
158
+ });
159
+ }
@@ -0,0 +1,124 @@
1
+ // Reviewed, bounded language packs for static homepage text already retained by
2
+ // Deep Lookup. Matching emits fixed labels only: no page phrase, surrounding
3
+ // text, or arbitrary markup is returned or stored.
4
+ import { analyzeStaticHtml, MAX_STATIC_HTML_CHARS, } from "./static-html-analysis.mjs";
5
+ export const PAGE_LANGUAGE_SIGNAL_VERSION = 1;
6
+ export const MAX_PAGE_LANGUAGE_SIGNAL_CHARS = MAX_STATIC_HTML_CHARS;
7
+ const CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
8
+ const CATEGORY_LABELS = Object.freeze({
9
+ account_verification: 'account-verification',
10
+ account_restriction: 'account-restriction',
11
+ payment_update: 'payment-update',
12
+ recovery_secret: 'wallet or recovery-secret',
13
+ urgent_action: 'urgent-action',
14
+ });
15
+ const PAGE_LANGUAGE_PACKS = Object.freeze([
16
+ {
17
+ id: 'en-reviewed-v1', language: 'en', label: 'English', patterns: [
18
+ { category: 'account_verification', expression: /(?:verify your account|confirm your identity|unusual (?:sign-?in|login) activity|click here to (?:verify|confirm|update|restore)|confirm your password)/iu },
19
+ { category: 'account_restriction', expression: /(?:account has been (?:suspended|limited|locked|restricted)|your account will be (?:suspended|closed|locked|terminated)|re-?activate your account|unauthorized access detected|your password (?:has expired|will expire soon))/iu },
20
+ { category: 'urgent_action', expression: /(?:security alert|immediate action required)/iu },
21
+ { category: 'payment_update', expression: /update your (?:payment|billing) (?:information|details)/iu },
22
+ { category: 'recovery_secret', expression: /(?:(?:connect|verify) your wallet|(?:enter|verify) your (?:seed|recovery) phrase|enter your private key)/iu },
23
+ ],
24
+ },
25
+ {
26
+ id: 'es-reviewed-v1', language: 'es', label: 'Spanish', patterns: [
27
+ { category: 'account_verification', expression: /(?:verifica (?:tu|su) cuenta|confirma (?:tu|su) identidad)/iu },
28
+ { category: 'account_restriction', expression: /(?:tu|su) cuenta (?:ha sido|será) (?:suspendida|bloqueada|limitada|restringida)/iu },
29
+ { category: 'urgent_action', expression: /(?:acción inmediata requerida|alerta de seguridad)/iu },
30
+ { category: 'payment_update', expression: /actualiza (?:tu|su) (?:información|datos) de pago/iu },
31
+ { category: 'recovery_secret', expression: /(?:introduce|ingrese) (?:tu|su) frase de recuperación/iu },
32
+ ],
33
+ },
34
+ {
35
+ id: 'fr-reviewed-v1', language: 'fr', label: 'French', patterns: [
36
+ { category: 'account_verification', expression: /(?:vérifiez votre compte|confirmez votre identité|veuillez confirmer votre identité)/iu },
37
+ { category: 'account_restriction', expression: /votre compte (?:a été|sera) (?:suspendu|bloqué|limité|restreint)/iu },
38
+ { category: 'urgent_action', expression: /(?:action immédiate requise|alerte de sécurité)/iu },
39
+ { category: 'payment_update', expression: /mettez à jour vos (?:informations|coordonnées) de paiement/iu },
40
+ { category: 'recovery_secret', expression: /saisissez votre phrase de récupération/iu },
41
+ ],
42
+ },
43
+ {
44
+ id: 'de-reviewed-v1', language: 'de', label: 'German', patterns: [
45
+ { category: 'account_verification', expression: /(?:bestätigen sie ihr konto|bestätigen sie ihre identität)/iu },
46
+ { category: 'account_restriction', expression: /ihr konto wurde (?:gesperrt|eingeschränkt|suspendiert)/iu },
47
+ { category: 'urgent_action', expression: /(?:sofortiges handeln erforderlich|sicherheitswarnung)/iu },
48
+ { category: 'payment_update', expression: /aktualisieren sie ihre zahlungsdaten/iu },
49
+ { category: 'recovery_secret', expression: /geben sie ihre wiederherstellungsphrase ein/iu },
50
+ ],
51
+ },
52
+ {
53
+ id: 'pt-reviewed-v1', language: 'pt', label: 'Portuguese', patterns: [
54
+ { category: 'account_verification', expression: /(?:verifique sua conta|confirme sua identidade)/iu },
55
+ { category: 'account_restriction', expression: /sua conta (?:foi|será) (?:suspensa|bloqueada|limitada|restrita)/iu },
56
+ { category: 'urgent_action', expression: /(?:ação imediata necessária|alerta de segurança)/iu },
57
+ { category: 'payment_update', expression: /atualize suas informações de pagamento/iu },
58
+ { category: 'recovery_secret', expression: /insira sua frase de recuperação/iu },
59
+ ],
60
+ },
61
+ {
62
+ id: 'it-reviewed-v1', language: 'it', label: 'Italian', patterns: [
63
+ { category: 'account_verification', expression: /(?:verifica il tuo account|conferma la tua identità)/iu },
64
+ { category: 'account_restriction', expression: /il tuo account (?:è stato|sarà) (?:sospeso|bloccato|limitato)/iu },
65
+ { category: 'urgent_action', expression: /(?:azione immediata richiesta|avviso di sicurezza)/iu },
66
+ { category: 'payment_update', expression: /aggiorna (?:i tuoi dati|le tue informazioni) di pagamento/iu },
67
+ { category: 'recovery_secret', expression: /inserisci la (?:tua )?frase di recupero/iu },
68
+ ],
69
+ },
70
+ {
71
+ id: 'nl-reviewed-v1', language: 'nl', label: 'Dutch', patterns: [
72
+ { category: 'account_verification', expression: /(?:verifieer uw account|bevestig uw identiteit)/iu },
73
+ { category: 'account_restriction', expression: /uw account (?:is|wordt) (?:geblokkeerd|beperkt|opgeschort)/iu },
74
+ { category: 'urgent_action', expression: /(?:onmiddellijke actie vereist|beveiligingswaarschuwing)/iu },
75
+ { category: 'payment_update', expression: /werk uw betalingsgegevens bij/iu },
76
+ { category: 'recovery_secret', expression: /voer uw herstelzin in/iu },
77
+ ],
78
+ },
79
+ ]);
80
+ function preferredPacks(documentLanguage) {
81
+ if (typeof documentLanguage !== 'string')
82
+ return PAGE_LANGUAGE_PACKS;
83
+ const primary = documentLanguage.trim().toLowerCase().split('-', 1)[0];
84
+ if (!primary)
85
+ return PAGE_LANGUAGE_PACKS;
86
+ const declared = PAGE_LANGUAGE_PACKS.filter((pack) => pack.language === primary);
87
+ return declared.length
88
+ ? Object.freeze([...declared, ...PAGE_LANGUAGE_PACKS.filter((pack) => pack.language !== primary)])
89
+ : PAGE_LANGUAGE_PACKS;
90
+ }
91
+ export function detectPageLanguageSignal(input, documentLanguage, suppliedAnalysis) {
92
+ if (typeof input !== 'string' || !input || input.length > MAX_PAGE_LANGUAGE_SIGNAL_CHARS || CONTROL_CHARACTER_RE.test(input)) {
93
+ return null;
94
+ }
95
+ const analysis = suppliedAnalysis ?? analyzeStaticHtml(input, { includeVisibleText: true });
96
+ if (analysis.inputLimitReached || analysis.visibleTextLimitReached)
97
+ return null;
98
+ const visibleText = analysis.visibleText
99
+ .replace(/\s+/gu, ' ')
100
+ .trim();
101
+ if (!visibleText)
102
+ return null;
103
+ for (const pack of preferredPacks(documentLanguage)) {
104
+ for (const pattern of pack.patterns) {
105
+ if (!pattern.expression.test(visibleText))
106
+ continue;
107
+ return Object.freeze({
108
+ version: PAGE_LANGUAGE_SIGNAL_VERSION,
109
+ packId: pack.id,
110
+ language: pack.language,
111
+ category: pattern.category,
112
+ label: `Reviewed ${pack.label} ${CATEGORY_LABELS[pattern.category]} language`,
113
+ });
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ export function pageLanguagePackCatalogue() {
119
+ return Object.freeze(PAGE_LANGUAGE_PACKS.map((pack) => Object.freeze({
120
+ id: pack.id,
121
+ language: pack.language,
122
+ categories: Object.freeze([...new Set(pack.patterns.map((pattern) => pattern.category))]),
123
+ })));
124
+ }
@@ -3,12 +3,12 @@
3
3
  // lib/rdap.mts.
4
4
  import { readTextCapped, safeFetch, safeFetchDetailed } from "./safe-fetch.mjs";
5
5
  const MAX_RDAP_BYTES = 2000000;
6
- async function fetchRdapWithTimeout(url, options, timeoutMs) {
6
+ async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {}) {
7
7
  const controller = new AbortController();
8
8
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
9
9
  try {
10
- const response = await safeFetch(url, { ...options, signal: controller.signal });
11
- const { text, truncated } = await readTextCapped(response, MAX_RDAP_BYTES);
10
+ const response = await (dependencies.fetch ?? safeFetch)(url, { ...options, signal: controller.signal });
11
+ const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES);
12
12
  if (truncated)
13
13
  throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
14
14
  return { status: response.status, ok: response.ok, text };
@@ -17,12 +17,12 @@ async function fetchRdapWithTimeout(url, options, timeoutMs) {
17
17
  clearTimeout(timeout);
18
18
  }
19
19
  }
20
- async function fetchRdapDetailedWithTimeout(url, options, timeoutMs) {
20
+ async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencies = {}) {
21
21
  const controller = new AbortController();
22
22
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
23
23
  try {
24
- const result = await safeFetchDetailed(url, { ...options, signal: controller.signal });
25
- const { text, truncated } = await readTextCapped(result.response, MAX_RDAP_BYTES);
24
+ const result = await (dependencies.fetchDetailed ?? safeFetchDetailed)(url, { ...options, signal: controller.signal });
25
+ const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES);
26
26
  if (truncated)
27
27
  throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
28
28
  return {
@@ -23,6 +23,14 @@ const FAMILY_CAPS = Object.freeze({
23
23
  'external-intelligence': 18,
24
24
  'operational-support': 12,
25
25
  });
26
+ const REMOVABLE_RISK_FAMILY_MAP = Object.freeze({
27
+ 'brand-presentation': true,
28
+ 'credential-lure': true,
29
+ 'domain-resemblance': true,
30
+ 'external-intelligence': true,
31
+ 'operational-support': true,
32
+ });
33
+ const REMOVABLE_RISK_FAMILIES = Object.freeze(Object.keys(REMOVABLE_RISK_FAMILY_MAP));
26
34
  const HIGH_CONTEXT_MUTATIONS = new Set([
27
35
  'unicode_homoglyph',
28
36
  'unicode_homoglyph_depth_2',
@@ -105,13 +113,15 @@ function scoreQuality(input) {
105
113
  }
106
114
  // Risk prioritizes a registered lookalike/typosquat domain for analyst review.
107
115
  // It is a heuristic indicator, never a maliciousness or safety verdict.
108
- export function explainRiskScore(input) {
116
+ function explainRiskScoreInternal(input, excludedFamily) {
109
117
  const state = input.availability ?? input.state;
110
118
  if (typeof state !== 'string' || !RISK_STATES.has(state))
111
119
  return null;
112
120
  const factors = [];
113
121
  const familyTotals = new Map();
114
122
  const add = (family, label, requestedDelta) => {
123
+ if (family === excludedFamily)
124
+ return 0;
115
125
  const current = familyTotals.get(family) ?? 0;
116
126
  const cap = FAMILY_CAPS[family] ?? Number.POSITIVE_INFINITY;
117
127
  const delta = Math.max(0, Math.min(requestedDelta, Math.max(0, cap - current)));
@@ -193,6 +203,37 @@ export function explainRiskScore(input) {
193
203
  evidenceQuality: scoreQuality(input),
194
204
  };
195
205
  }
206
+ export function explainRiskScore(input) {
207
+ return explainRiskScoreInternal(input, null);
208
+ }
209
+ export function buildRiskScoreSensitivity(input) {
210
+ const baseline = explainRiskScoreInternal(input, null);
211
+ if (!baseline)
212
+ return null;
213
+ const removableFamilies = REMOVABLE_RISK_FAMILIES
214
+ .filter((family) => baseline.families.some((item) => item.id === family && item.contribution > 0));
215
+ const scenarios = removableFamilies.map((excludedFamily) => {
216
+ const result = explainRiskScoreInternal(input, excludedFamily);
217
+ const score = result?.score ?? baseline.score;
218
+ return Object.freeze({ excludedFamily, score, difference: score - baseline.score });
219
+ }).sort((left, right) => left.score - right.score || left.excludedFamily.localeCompare(right.excludedFamily));
220
+ const minimumScenarioScore = scenarios[0]?.score ?? baseline.score;
221
+ const thresholdState = baseline.score < RISK_REVIEW_THRESHOLD
222
+ ? 'below'
223
+ : minimumScenarioScore < RISK_REVIEW_THRESHOLD ? 'crosses' : 'stable_above';
224
+ return Object.freeze({
225
+ version: 1,
226
+ baselineScore: baseline.score,
227
+ reviewThreshold: RISK_REVIEW_THRESHOLD,
228
+ minimumScenarioScore,
229
+ thresholdState,
230
+ scenarios: Object.freeze(scenarios),
231
+ limitations: Object.freeze([
232
+ 'Each scenario removes one observed evidence family and recalculates family caps and corroboration; it does not predict missing evidence.',
233
+ 'The sensitivity review qualifies prioritisation only and does not determine maliciousness, safety, ownership, or intent.',
234
+ ]),
235
+ });
236
+ }
196
237
  export function computeRiskScore(input) {
197
238
  return explainRiskScore(input)?.score ?? null;
198
239
  }