@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
3
  import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { exactKeys } from "./bounded-contract-normalizers.mjs";
4
5
  export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
5
6
  export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
6
7
  export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
@@ -9,6 +10,18 @@ export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
9
10
  export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
10
11
  export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
11
12
  export const MAX_DOMAIN_CONTROL_RECORDS = 32;
13
+ const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
14
+ const MANIFEST_ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
15
+ const MANIFEST_INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
16
+ const MANIFEST_INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
17
+ const REVIEW_INPUT_KEYS = new Set(['schema', 'version', 'manifest', 'observations']);
18
+ const OBSERVATION_KEYS = new Set(['domain', 'fields']);
19
+ const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
20
+ const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
21
+ const DOMAIN_CONTROL_LIMITATIONS = Object.freeze([
22
+ 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
23
+ 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
24
+ ]);
12
25
  function record(value) {
13
26
  return value && typeof value === 'object' && !Array.isArray(value)
14
27
  ? value
@@ -89,6 +102,7 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
89
102
  if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
90
103
  throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
91
104
  }
105
+ exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
92
106
  const generatedAt = timestamp(generatedAtValue);
93
107
  const expiresAt = timestamp(source.expiresAt);
94
108
  if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
@@ -97,7 +111,10 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
97
111
  if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
98
112
  throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
99
113
  }
100
- const entries = source.entries.map(normalizeEntry);
114
+ const entries = source.entries.map((entry, index) => {
115
+ exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest input entry ${index + 1}`);
116
+ return normalizeEntry(entry);
117
+ });
101
118
  if (entries.some((entry) => entry === null))
102
119
  throw new TypeError('Domain control manifest contains an invalid entry.');
103
120
  const normalizedEntries = entries;
@@ -110,10 +127,7 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
110
127
  generatedAt,
111
128
  expiresAt,
112
129
  entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
113
- limitations: Object.freeze([
114
- 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
115
- 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
116
- ]),
130
+ limitations: DOMAIN_CONTROL_LIMITATIONS,
117
131
  });
118
132
  return Object.freeze({
119
133
  ...base,
@@ -134,6 +148,8 @@ export function verifyDomainControlManifest(value) {
134
148
  || source.entries.length < 1
135
149
  || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
136
150
  || !Array.isArray(source.limitations)
151
+ || source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
152
+ || source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
137
153
  || !integrity
138
154
  || integrity.algorithm !== 'SHA-256'
139
155
  || integrity.canonicalization !== 'sorted-json-v1'
@@ -141,18 +157,41 @@ export function verifyDomainControlManifest(value) {
141
157
  || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
142
158
  throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
143
159
  }
160
+ exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
161
+ exactKeys(integrity, MANIFEST_INTEGRITY_KEYS, 'Domain control manifest integrity');
162
+ for (const [index, entry] of source.entries.entries()) {
163
+ exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest entry ${index + 1}`);
164
+ }
144
165
  const generatedAt = timestamp(source.generatedAt);
145
166
  const expiresAt = timestamp(source.expiresAt);
146
167
  const entries = source.entries.map(normalizeEntry);
147
168
  if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
148
- throw new TypeError('Domain control manifest has invalid normalized content.');
169
+ throw new TypeError('Domain control manifest has invalid normalised content.');
149
170
  }
150
171
  const normalizedEntries = entries;
151
172
  if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
152
173
  throw new TypeError('Domain control manifest entries must use unique domains.');
153
174
  }
154
175
  const { integrity: _integrity, ...unsigned } = source;
155
- if (integrityDigest(unsigned) !== integrity.digestSha256) {
176
+ const canonical = Object.freeze({
177
+ schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
178
+ version: DOMAIN_CONTROL_MANIFEST_VERSION,
179
+ generatedAt,
180
+ expiresAt,
181
+ entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
182
+ limitations: DOMAIN_CONTROL_LIMITATIONS,
183
+ });
184
+ let suppliedCanonical;
185
+ try {
186
+ suppliedCanonical = canonicalArtifactJson(unsigned);
187
+ }
188
+ catch {
189
+ throw new TypeError('Domain control manifest has invalid normalised content.');
190
+ }
191
+ if (suppliedCanonical !== canonicalArtifactJson(canonical)) {
192
+ throw new TypeError('Domain control manifest must use its canonical normalised content.');
193
+ }
194
+ if (`sha256:${createHash('sha256').update(suppliedCanonical).digest('hex')}` !== integrity.digestSha256) {
156
195
  throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
157
196
  }
158
197
  return source;
@@ -176,11 +215,14 @@ function normalizeObservation(value) {
176
215
  const rawFields = record(source?.fields);
177
216
  if (!source || !normalizedDomain || !rawFields)
178
217
  return null;
218
+ exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
219
+ exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
179
220
  const fields = {};
180
221
  for (const field of ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']) {
181
222
  const raw = record(rawFields[field]);
182
223
  if (!raw)
183
224
  continue;
225
+ exactKeys(raw, OBSERVATION_FIELD_KEYS, `Domain control observation field ${field}`);
184
226
  const state = raw.state;
185
227
  if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
186
228
  return null;
@@ -260,6 +302,7 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
260
302
  if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
261
303
  throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
262
304
  }
305
+ exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
263
306
  const generatedAt = timestamp(generatedAtValue);
264
307
  if (!generatedAt)
265
308
  throw new TypeError('Domain control review time is invalid.');
@@ -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
+ }
@@ -94,7 +94,7 @@ function spfCheck(query, expansion) {
94
94
  const parsed = parseSpfRecords(query.records);
95
95
  if (parsed.records.length === 0) {
96
96
  return check('spf', 'SPF', 'warning', 'No SPF policy published', {
97
- remediation: 'Publish one SPF TXT record that authorizes every legitimate sender and ends in -all once verified.',
97
+ remediation: 'Publish one SPF TXT record that authorises every legitimate sender and ends in -all once verified.',
98
98
  });
99
99
  }
100
100
  if (!parsed.valid) {
@@ -119,7 +119,7 @@ function spfCheck(query, expansion) {
119
119
  ...parsed.issues,
120
120
  ];
121
121
  if (parsed.terminalPolicy === 'pass') {
122
- return check('spf', 'SPF', 'danger', 'Policy authorizes every sender (+all)', {
122
+ return check('spf', 'SPF', 'danger', 'Policy authorises every sender (+all)', {
123
123
  detail: details.join(' '), records: parsed.records,
124
124
  remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
125
125
  });
@@ -143,7 +143,7 @@ function spfCheck(query, expansion) {
143
143
  return check('spf', 'SPF', 'warning', `Policy ends in ${parsed.terminalPolicy || 'an unknown result'}`, {
144
144
  detail: details.join(' '), records: parsed.records,
145
145
  remediation: parsed.terminalPolicy === 'softfail'
146
- ? 'Move from ~all to -all after confirming every legitimate sending service is authorized.'
146
+ ? 'Move from ~all to -all after confirming every legitimate sending service is authorised.'
147
147
  : 'Add a restrictive -all terminal policy after confirming every legitimate sending service.',
148
148
  });
149
149
  }
@@ -198,7 +198,7 @@ function dmarcCheck(query, authorizations = []) {
198
198
  return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; external reporting authorization is incomplete`, {
199
199
  detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
200
200
  records: parsed.records,
201
- remediation: 'Publish the required external reporting authorization record or remove the unavailable destination.',
201
+ remediation: 'Publish the required external reporting authorisation record or remove the unavailable destination.',
202
202
  });
203
203
  }
204
204
  if (!parsed.aggregateReporting) {
@@ -252,7 +252,7 @@ function dnssecCheck(input) {
252
252
  function dnssecDelegationConsistencyCheck(input, registry) {
253
253
  if (registry.error) {
254
254
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Registry evidence is unavailable', {
255
- detail: 'DNSSEC delegation and retained DS records could not be compared because normalized registry evidence was unavailable.',
255
+ detail: 'DNSSEC delegation and retained DS records could not be compared because normalised registry evidence was unavailable.',
256
256
  });
257
257
  }
258
258
  if (input.error) {
@@ -274,7 +274,7 @@ function dnssecDelegationConsistencyCheck(input, registry) {
274
274
  }
275
275
  if (registry.dsDataTruncated) {
276
276
  return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
277
- detail: 'The normalized registry response reported truncated DS data, so apparent disagreement is inconclusive.',
277
+ detail: 'The normalised registry response reported truncated DS data, so apparent disagreement is inconclusive.',
278
278
  });
279
279
  }
280
280
  if (value === 'signed' && !hasDsRecords) {
@@ -414,7 +414,7 @@ function bimiCheck(query, dmarcQuery) {
414
414
  && (dmarc.legacyPct === null || dmarc.legacyPct === 100);
415
415
  if (!enforcementReady) {
416
416
  return check('bimi', 'BIMI', 'warning', 'Record exists but DMARC is not BIMI-ready', {
417
- detail: 'BIMI display generally requires enforced DMARC for the organizational domain and subdomains. Mailbox providers apply additional requirements.',
417
+ detail: 'BIMI display generally requires enforced DMARC for the organisational domain and subdomains. Mailbox providers apply additional requirements.',
418
418
  records: parsed.records,
419
419
  remediation: 'Enforce DMARC at quarantine or reject for the domain and subdomains before relying on BIMI.',
420
420
  });
@@ -534,7 +534,7 @@ function registrationLockCheck(registry) {
534
534
  const statuses = registry.statuses.map((status) => status.toLowerCase().replace(/[^a-z]/gu, ''));
535
535
  if (statuses.length === 0) {
536
536
  return check('registration_lock', 'Registration controls', 'info', 'Registry lock state is unavailable', {
537
- detail: 'No normalized EPP status was returned. This does not describe registrar account security.',
537
+ detail: 'No normalised EPP status was returned. This does not describe registrar account security.',
538
538
  });
539
539
  }
540
540
  const transferLocks = statuses.filter((status) => ['clienttransferprohibited', 'servertransferprohibited'].includes(status));
@@ -706,7 +706,7 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
706
706
  nameservers: [],
707
707
  dsRecordCount: 0,
708
708
  dsDataTruncated: false,
709
- error: rdap && 'error' in rdap ? rdap.error : 'RDAP did not return normalized domain evidence.',
709
+ error: rdap && 'error' in rdap ? rdap.error : 'RDAP did not return normalised domain evidence.',
710
710
  };
711
711
  const externalDependencies = buildExternalDependencies({
712
712
  domain,
@@ -75,7 +75,7 @@ function planEncryptedDnsQuery(adapter, input) {
75
75
  recipient: new URL(adapter.endpoint).hostname,
76
76
  queryRetention: adapter.queryRetention,
77
77
  }),
78
- limitation: 'This plan does not execute a request. Provider availability, answer authority, validation, filtering, retention, and regional behavior remain untested.',
78
+ limitation: 'This plan does not execute a request. Provider availability, answer authority, validation, filtering, retention, and regional behaviour remain untested.',
79
79
  });
80
80
  }
81
81
  export { ENCRYPTED_DNS_CONTRACT_SCHEMA, ENCRYPTED_DNS_CONTRACT_VERSION, normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, };
@@ -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
  ]);
@@ -38,7 +38,7 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
38
38
  lines.push('', '## Registry-source comparison');
39
39
  appendFields(lines, report.comparison.health);
40
40
  if (!report.comparison.fields.length) {
41
- lines.push('- No comparable normalized fields were published.');
41
+ lines.push('- No comparable normalised fields were published.');
42
42
  }
43
43
  else {
44
44
  for (const field of report.comparison.fields) {
@@ -378,7 +378,7 @@ function buildLookupEvidenceReport(document, options = {}) {
378
378
  ...registryAccessFields,
379
379
  ],
380
380
  limitations: [
381
- 'This report summarizes point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
381
+ 'This report summarises point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
382
382
  'Missing, skipped, partial, unsupported, or failed sources are inconclusive rather than negative evidence.',
383
383
  '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.',
384
384
  'Raw registry payloads and full WHOIS referral responses are available only in the JSON evidence package and may contain public contact data.',