@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4

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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -0,0 +1,280 @@
1
+ export const LOOKUP_CLAIM_READINESS_VERSION = 2;
2
+ export const LOOKUP_CLAIM_IDS = [
3
+ 'registration-state',
4
+ 'current-web-observation',
5
+ 'brand-resemblance',
6
+ 'controlled-change',
7
+ 'incident-response',
8
+ 'network-context',
9
+ ];
10
+ export const LOOKUP_CLAIM_REQUIREMENT_IDS = [
11
+ 'authority-aware-availability',
12
+ 'registry-rdap',
13
+ 'registry-whois',
14
+ 'registry-control-selection',
15
+ 'dns-observation',
16
+ 'http-observation',
17
+ 'tls-observation',
18
+ 'page-identity-observation',
19
+ 'reviewed-brand-profile',
20
+ 'reviewed-case-recipient',
21
+ 'authoritative-rdap',
22
+ ];
23
+ const MAX_TEXT = 320;
24
+ const MAX_DIAGNOSTICS = 12;
25
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
26
+ const LIMITED_STATES = new Set(['partial', 'unavailable', 'unknown']);
27
+ const ABSENT_STATES = new Set(['skipped', 'unsupported']);
28
+ export const LOOKUP_CLAIM_REQUIREMENTS = Object.freeze({
29
+ 'authority-aware-availability': Object.freeze({ id: 'authority-aware-availability', label: 'Authority-aware availability decision', evidenceId: 'availability', mode: 'network_collection', href: '#registry' }),
30
+ 'registry-rdap': Object.freeze({ id: 'registry-rdap', label: 'Registry RDAP evidence', evidenceId: 'rdap', mode: 'network_collection', href: '#registry' }),
31
+ 'registry-whois': Object.freeze({ id: 'registry-whois', label: 'Registry WHOIS evidence', evidenceId: 'whois', mode: 'network_collection', href: '#registry' }),
32
+ 'registry-control-selection': Object.freeze({ id: 'registry-control-selection', label: 'Registry control evidence selected by the availability authority', evidenceId: null, mode: 'network_collection', href: '#registry' }),
33
+ 'dns-observation': Object.freeze({ id: 'dns-observation', label: 'DNS observation', evidenceId: 'dns', mode: 'network_collection', href: '#evidence-dns' }),
34
+ 'http-observation': Object.freeze({ id: 'http-observation', label: 'HTTP observation', evidenceId: 'http', mode: 'network_collection', href: '#evidence-http' }),
35
+ 'tls-observation': Object.freeze({ id: 'tls-observation', label: 'TLS observation', evidenceId: 'tls', mode: 'network_collection', href: '#evidence-tls' }),
36
+ 'page-identity-observation': Object.freeze({ id: 'page-identity-observation', label: 'Page identity observation', evidenceId: 'page-identity', mode: 'network_collection', href: '#evidence-page' }),
37
+ 'reviewed-brand-profile': Object.freeze({ id: 'reviewed-brand-profile', label: 'Reviewed Brand Profile', evidenceId: null, mode: 'local_review', href: '#case-response' }),
38
+ 'reviewed-case-recipient': Object.freeze({ id: 'reviewed-case-recipient', label: 'Reviewed case and recipient route', evidenceId: null, mode: 'local_review', href: '#case-response' }),
39
+ 'authoritative-rdap': Object.freeze({ id: 'authoritative-rdap', label: 'Authoritative RDAP evidence', evidenceId: 'rdap', mode: 'network_collection', href: '#registry' }),
40
+ });
41
+ function record(value) {
42
+ return value && typeof value === 'object' && !Array.isArray(value)
43
+ ? value
44
+ : {};
45
+ }
46
+ function text(value, maximum = MAX_TEXT) {
47
+ return String(value ?? '')
48
+ .replace(CONTROL_CHARACTERS, ' ')
49
+ .replace(/\s+/gu, ' ')
50
+ .trim()
51
+ .slice(0, maximum);
52
+ }
53
+ function timestamp(value) {
54
+ if (typeof value !== 'string')
55
+ return null;
56
+ const parsed = Date.parse(value);
57
+ return Number.isFinite(parsed) ? parsed : null;
58
+ }
59
+ function evaluateRequirements(requirements) {
60
+ const missing = requirements.filter((requirement) => requirement.state !== 'complete');
61
+ const limitations = missing.flatMap((requirement) => {
62
+ const detail = requirement.limitations[0] || requirement.state.replaceAll('_', ' ');
63
+ return [`${requirement.label}: ${detail}`];
64
+ }).slice(0, 8);
65
+ if (!missing.length) {
66
+ return {
67
+ state: 'ready',
68
+ requiredEvidence: requirements.map((item) => item.label),
69
+ missingEvidence: [],
70
+ requiredEvidenceIds: requirements.map((item) => item.id),
71
+ missingEvidenceIds: [],
72
+ limitations,
73
+ requirements,
74
+ };
75
+ }
76
+ const observedCount = requirements.filter((requirement) => !ABSENT_STATES.has(requirement.state) && requirement.state !== 'unknown').length;
77
+ const hasLimited = requirements.some((requirement) => LIMITED_STATES.has(requirement.state));
78
+ return {
79
+ state: observedCount > 0 && hasLimited ? 'limited' : 'not_ready',
80
+ requiredEvidence: requirements.map((item) => item.label),
81
+ missingEvidence: missing.map((item) => item.label),
82
+ requiredEvidenceIds: requirements.map((item) => item.id),
83
+ missingEvidenceIds: missing.map((item) => item.id),
84
+ limitations,
85
+ requirements,
86
+ };
87
+ }
88
+ function requirement(id, coverage, override) {
89
+ const definition = LOOKUP_CLAIM_REQUIREMENTS[id];
90
+ const evidence = definition.evidenceId ? coverage.get(definition.evidenceId) : undefined;
91
+ const state = override?.state ?? evidence?.state ?? 'unknown';
92
+ const limitations = override?.limitation
93
+ ? [override.limitation]
94
+ : evidence?.limitations.length
95
+ ? evidence.limitations.slice(0, 8)
96
+ : state === 'complete'
97
+ ? []
98
+ : [evidence?.statusLabel || 'Not supplied'];
99
+ return Object.freeze({ ...definition, state, limitations: Object.freeze(limitations) });
100
+ }
101
+ function readinessEntry(input) {
102
+ const evaluated = evaluateRequirements(input.requirements);
103
+ return { id: input.id, label: input.label, conclusion: input.conclusion, href: input.href, ...evaluated };
104
+ }
105
+ function comparisonDiagnostics(comparison, sourceLabel, observedAt) {
106
+ const source = record(comparison);
107
+ const fields = Array.isArray(source.fields) ? source.fields.slice(0, 32) : [];
108
+ const registryTime = timestamp(observedAt.registry);
109
+ const comparedTime = timestamp(observedAt.compared);
110
+ const timeGapHours = registryTime !== null && comparedTime !== null
111
+ ? Math.round(Math.abs(registryTime - comparedTime) / 3_600_000)
112
+ : null;
113
+ const output = [];
114
+ for (const item of fields) {
115
+ const field = record(item);
116
+ if (text(field.status, 64) !== 'conflict')
117
+ continue;
118
+ const label = text(field.label, 100) || 'Registration field';
119
+ const normalizedLabel = label.toLowerCase();
120
+ let hypothesis = 'Different source publication scope or update timing';
121
+ let detail = `Registry RDAP and ${sourceLabel} published different normalized values. Review the exact values and source timestamps.`;
122
+ const basis = ['Material normalized-value difference'];
123
+ if (timeGapHours !== null && timeGapHours >= 1) {
124
+ hypothesis = 'Collection-time or publication-lag difference';
125
+ detail = `The sources were observed about ${timeGapHours} hour${timeGapHours === 1 ? '' : 's'} apart. A change or publication delay could explain the difference, but this is not established.`;
126
+ basis.push('Different observation times');
127
+ }
128
+ else if (/contact|registrant|administrative|technical|abuse/u.test(normalizedLabel)) {
129
+ hypothesis = 'Disclosure or source-role difference';
130
+ detail = `Registration contact publications can differ by source role, disclosure policy, privacy service, or update timing. The available evidence does not identify which explanation applies.`;
131
+ basis.push('Contact-related field');
132
+ }
133
+ else if (/status|nameserver|name server|dnssec/u.test(normalizedLabel)) {
134
+ hypothesis = 'Registry and delegated-service publication difference';
135
+ detail = `Operational and registry publications can update at different times or represent different layers. Directly review the current authoritative evidence before treating this as a configuration change.`;
136
+ basis.push('Operational registration field');
137
+ }
138
+ else if (/registrar|sponsor/u.test(normalizedLabel)) {
139
+ hypothesis = 'Sponsorship publication or transfer timing difference';
140
+ detail = `A transfer, reseller relationship, source normalisation, or publication delay could explain the difference. None is proven by this comparison alone.`;
141
+ basis.push('Registrar-related field');
142
+ }
143
+ output.push({
144
+ id: `${sourceLabel === 'WHOIS' ? 'whois' : 'registrar-rdap'}-${normalizedLabel.replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '') || output.length}`,
145
+ field: label,
146
+ hypothesis,
147
+ detail,
148
+ basis,
149
+ });
150
+ }
151
+ return output;
152
+ }
153
+ export function buildLookupClaimReadiness(input) {
154
+ const coverage = new Map(input.coverage.entries.map((entry) => [entry.id, entry]));
155
+ const targetType = text(input.targetType, 20);
156
+ const settledAvailability = !['', 'unknown', 'inconclusive', 'error'].includes(text(input.availabilityState, 40));
157
+ const observedAvailability = requirement('authority-aware-availability', coverage);
158
+ const registrationRequirements = [settledAvailability
159
+ ? observedAvailability
160
+ : Object.freeze({
161
+ ...observedAvailability,
162
+ state: observedAvailability.state === 'complete' ? 'partial' : observedAvailability.state,
163
+ limitations: Object.freeze([
164
+ ...observedAvailability.limitations,
165
+ 'The authority-aware availability state is not settled.',
166
+ ].slice(0, 8)),
167
+ })];
168
+ const availabilitySource = text(input.availabilitySource, 24).toLowerCase();
169
+ const controlledRegistrationRequirements = availabilitySource === 'rdap'
170
+ ? [requirement('registry-rdap', coverage)]
171
+ : availabilitySource === 'whois'
172
+ ? [requirement('registry-whois', coverage)]
173
+ : [requirement('registry-control-selection', coverage, {
174
+ state: 'unknown',
175
+ limitation: 'The availability authority did not select a registry control source.',
176
+ })];
177
+ const webRequirements = [requirement('http-observation', coverage), requirement('tls-observation', coverage)];
178
+ const entries = [];
179
+ if (targetType === 'domain') {
180
+ entries.push(readinessEntry({
181
+ id: 'registration-state',
182
+ label: 'Registration-state statement',
183
+ conclusion: 'Whether an authority-aware registered, available, or inconclusive statement can be made.',
184
+ requirements: registrationRequirements,
185
+ href: '#registry',
186
+ }));
187
+ entries.push(readinessEntry({
188
+ id: 'current-web-observation',
189
+ label: 'Current website statement',
190
+ conclusion: 'Whether the current HTTP and TLS observation is complete enough to describe what responded.',
191
+ requirements: webRequirements,
192
+ href: '#web-evidence',
193
+ }));
194
+ if (input.task === 'brand' || input.hasActiveProfile) {
195
+ entries.push(readinessEntry({
196
+ id: 'brand-resemblance',
197
+ label: 'Brand-resemblance assessment',
198
+ conclusion: 'Whether reviewed brand context and page identity evidence support a bounded resemblance assessment.',
199
+ requirements: [
200
+ requirement('page-identity-observation', coverage),
201
+ requirement('reviewed-brand-profile', coverage, {
202
+ state: input.profileSourceState === 'unavailable'
203
+ ? 'unavailable'
204
+ : input.hasActiveProfile === true
205
+ ? 'complete'
206
+ : 'unknown',
207
+ ...(input.profileSourceState === 'unavailable'
208
+ ? { limitation: 'The browser-local Brand Profile source is unavailable.' }
209
+ : input.hasActiveProfile === true
210
+ ? {}
211
+ : { limitation: 'No reviewed Brand Profile is active.' }),
212
+ }),
213
+ ],
214
+ href: '#evidence-page',
215
+ }));
216
+ }
217
+ if (input.task === 'owned' || input.task === 'acquisition') {
218
+ entries.push(readinessEntry({
219
+ id: 'controlled-change',
220
+ label: 'Controlled-change planning',
221
+ conclusion: 'Whether registration and DNS evidence are complete enough to prepare a reviewed change plan.',
222
+ requirements: [...registrationRequirements, ...controlledRegistrationRequirements, requirement('dns-observation', coverage)],
223
+ href: '#evidence-dns',
224
+ }));
225
+ }
226
+ if (input.task === 'incident') {
227
+ entries.push(readinessEntry({
228
+ id: 'incident-response',
229
+ label: 'Incident response handoff',
230
+ conclusion: 'Whether current web evidence and a reviewed recipient route are present for a response packet.',
231
+ requirements: [
232
+ requirement('http-observation', coverage),
233
+ requirement('page-identity-observation', coverage),
234
+ requirement('reviewed-case-recipient', coverage, {
235
+ state: input.hasCaseSection === true && (input.responseRecipientCount ?? 0) > 0 ? 'complete' : 'unknown',
236
+ ...(input.hasCaseSection === true && (input.responseRecipientCount ?? 0) > 0
237
+ ? {}
238
+ : { limitation: 'A reviewed case and recipient route are not both available.' }),
239
+ }),
240
+ ],
241
+ href: '#case-response',
242
+ }));
243
+ }
244
+ }
245
+ else if (targetType === 'ip' || targetType === 'ipv4' || targetType === 'ipv6' || targetType === 'asn') {
246
+ entries.push(readinessEntry({
247
+ id: 'network-context',
248
+ label: 'Network registration statement',
249
+ conclusion: 'Whether the observed network registration context is complete enough to describe the resource.',
250
+ requirements: [requirement('authoritative-rdap', coverage)],
251
+ href: '#registry',
252
+ }));
253
+ }
254
+ const disagreements = [
255
+ ...comparisonDiagnostics(input.registryComparison, 'WHOIS', {
256
+ registry: input.observedAt?.registry,
257
+ compared: input.observedAt?.whois,
258
+ }),
259
+ ...comparisonDiagnostics(input.registrarPublicationComparison, 'Registrar RDAP', {
260
+ registry: input.observedAt?.registry,
261
+ compared: input.observedAt?.registrar,
262
+ }),
263
+ ].slice(0, MAX_DIAGNOSTICS);
264
+ const counts = {
265
+ ready: 0,
266
+ limited: 0,
267
+ not_ready: 0,
268
+ };
269
+ for (const entry of entries)
270
+ counts[entry.state] += 1;
271
+ return {
272
+ version: LOOKUP_CLAIM_READINESS_VERSION,
273
+ entries,
274
+ disagreements,
275
+ counts,
276
+ limitation: input.decisionSupport.counts.conflicts
277
+ ? 'Readiness describes evidence sufficiency, not truth. Registration-source explanations are bounded hypotheses and require review of the original observations.'
278
+ : 'Readiness describes evidence sufficiency, not truth, ownership, intent, safety, or eligibility.',
279
+ };
280
+ }
@@ -2,6 +2,21 @@ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
2
2
  const MAX_TEXT = 320;
3
3
  const MAX_ENTRIES = 24;
4
4
  const MAX_ACTIONS = 6;
5
+ export const MAX_LOOKUP_PRESENTED_ACTIONS = 3;
6
+ const LOOKUP_TASK_EVIDENCE_KINDS = new Set([
7
+ 'delegation', 'dependency', 'dns', 'form', 'http', 'identity', 'mail', 'page',
8
+ 'posture', 'ptr', 'redirect', 'tls',
9
+ ]);
10
+ const TASK_ACTION_ID = Object.freeze({
11
+ acquisition: 'review-acquisition-dependencies',
12
+ brand: 'review-page-identity',
13
+ owned: 'review-owned-posture',
14
+ });
15
+ const ACTION_PRIORITY = Object.freeze({
16
+ high: 0,
17
+ medium: 1,
18
+ low: 2,
19
+ });
5
20
  const TASK_GUIDANCE = Object.freeze({
6
21
  general: Object.freeze({
7
22
  label: 'General investigation',
@@ -338,12 +353,40 @@ function prioritizeEntries(entries, task) {
338
353
  return [...entries].sort((left, right) => {
339
354
  const leftBoost = taskBoost[task].some((prefix) => left.id.startsWith(prefix)) ? -1 : 0;
340
355
  const rightBoost = taskBoost[task].some((prefix) => right.id.startsWith(prefix)) ? -1 : 0;
341
- return leftBoost - rightBoost
342
- || importance[left.importance] - importance[right.importance]
356
+ return importance[left.importance] - importance[right.importance]
357
+ || leftBoost - rightBoost
343
358
  || left.title.localeCompare(right.title);
344
359
  }).slice(0, 16);
345
360
  }
361
+ function normalizedTaskEvidenceKinds(values) {
362
+ return new Set((values ?? [])
363
+ .slice(0, 24)
364
+ .filter((value) => (typeof value === 'string' && LOOKUP_TASK_EVIDENCE_KINDS.has(value))));
365
+ }
366
+ function supportsDecisionDestination(href, evidence) {
367
+ if (href === '#evidence-tls')
368
+ return evidence.has('tls');
369
+ if (href === '#evidence-http')
370
+ return evidence.has('http') || evidence.has('redirect');
371
+ if (href === '#evidence-page') {
372
+ return evidence.has('page') || evidence.has('identity') || evidence.has('form');
373
+ }
374
+ if (href === '#evidence-certificate-policy')
375
+ return evidence.has('tls') || evidence.has('dns');
376
+ return true;
377
+ }
378
+ function supportsTaskAction(task, targetType, evidence) {
379
+ if (targetType !== 'domain')
380
+ return false;
381
+ const requiredKinds = {
382
+ acquisition: ['dns', 'mail', 'tls', 'http', 'page', 'dependency'],
383
+ brand: ['page', 'identity', 'form', 'redirect'],
384
+ owned: ['delegation', 'mail', 'tls', 'posture'],
385
+ };
386
+ return requiredKinds[task]?.some((kind) => evidence.has(kind)) ?? false;
387
+ }
346
388
  export function buildLookupDecisionSupport(input) {
389
+ const availableEvidence = normalizedTaskEvidenceKinds(input.availableEvidence);
347
390
  const entries = prioritizeEntries([
348
391
  ...comparisonEntries(input.registryComparison, 'registry-whois'),
349
392
  ...comparisonEntries(input.registrarPublicationComparison, 'registry-registrar'),
@@ -351,7 +394,8 @@ export function buildLookupDecisionSupport(input) {
351
394
  ...certificatePolicyEntries(input.certificatePolicyReview),
352
395
  ], input.task);
353
396
  const actions = [];
354
- const firstConflict = entries.find((entry) => entry.state === 'conflict');
397
+ const firstConflict = entries.find((entry) => entry.state === 'conflict'
398
+ && supportsDecisionDestination(entry.href, availableEvidence));
355
399
  if (firstConflict) {
356
400
  actions.push({
357
401
  id: 'review-priority-conflict',
@@ -383,7 +427,7 @@ export function buildLookupDecisionSupport(input) {
383
427
  priority: 'medium',
384
428
  });
385
429
  }
386
- if (input.task === 'brand') {
430
+ if (supportsTaskAction(input.task, input.targetType, availableEvidence) && input.task === 'brand') {
387
431
  actions.push({
388
432
  id: 'review-page-identity',
389
433
  label: 'Review identity and credential evidence',
@@ -393,7 +437,7 @@ export function buildLookupDecisionSupport(input) {
393
437
  priority: 'medium',
394
438
  });
395
439
  }
396
- else if (input.task === 'acquisition') {
440
+ else if (supportsTaskAction(input.task, input.targetType, availableEvidence) && input.task === 'acquisition') {
397
441
  actions.push({
398
442
  id: 'review-acquisition-dependencies',
399
443
  label: 'Review transfer dependencies',
@@ -403,7 +447,7 @@ export function buildLookupDecisionSupport(input) {
403
447
  priority: 'medium',
404
448
  });
405
449
  }
406
- else if (input.task === 'owned') {
450
+ else if (supportsTaskAction(input.task, input.targetType, availableEvidence) && input.task === 'owned') {
407
451
  actions.push({
408
452
  id: 'review-owned-posture',
409
453
  label: 'Review delegation and posture evidence',
@@ -435,6 +479,17 @@ export function buildLookupDecisionSupport(input) {
435
479
  },
436
480
  };
437
481
  }
482
+ export function projectLookupNextActions(actions, task) {
483
+ const taskActionId = TASK_ACTION_ID[task];
484
+ return actions
485
+ .slice(0, MAX_ACTIONS)
486
+ .map((action, index) => ({ action, index }))
487
+ .sort((left, right) => (ACTION_PRIORITY[left.action.priority] - ACTION_PRIORITY[right.action.priority]
488
+ || Number(right.action.id === taskActionId) - Number(left.action.id === taskActionId)
489
+ || left.index - right.index))
490
+ .slice(0, MAX_LOOKUP_PRESENTED_ACTIONS)
491
+ .map(({ action }) => action);
492
+ }
438
493
  function timingByEvidence(timing) {
439
494
  const output = new Map();
440
495
  if (!timing)
@@ -1,5 +1,6 @@
1
1
  export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
2
2
  export const MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES = 1_024;
3
+ export const MAX_LOOKUP_URL_QUERY_LENGTH = 4_096;
3
4
  export const LOOKUP_TASK_VIEWS = Object.freeze([
4
5
  Object.freeze({ id: 'general', label: 'General investigation' }),
5
6
  Object.freeze({ id: 'acquisition', label: 'Acquisition review' }),
@@ -8,11 +9,72 @@ export const LOOKUP_TASK_VIEWS = Object.freeze([
8
9
  Object.freeze({ id: 'owned', label: 'Owned-domain posture' }),
9
10
  ]);
10
11
  const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
12
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
13
+ export function parseLookupTaskContext(value) {
14
+ if (typeof value !== 'string' || value.length > 16)
15
+ return null;
16
+ return TASKS.has(value) ? value : null;
17
+ }
11
18
  export function normalizeLookupTaskView(value) {
12
19
  return typeof value === 'string' && TASKS.has(value)
13
20
  ? value
14
21
  : 'general';
15
22
  }
23
+ function lookupUrlQuery(value) {
24
+ if (value === null || value.length > MAX_LOOKUP_URL_QUERY_LENGTH || CONTROL_CHARACTERS.test(value))
25
+ return null;
26
+ const normalized = value.trim();
27
+ return normalized ? normalized : null;
28
+ }
29
+ function lookupUrlDepth(value) {
30
+ return value === 'fast' || value === 'deep' ? value : null;
31
+ }
32
+ function record(value) {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
34
+ ? value
35
+ : null;
36
+ }
37
+ export function lookupResultDepth(value, fallback = null) {
38
+ const result = record(value);
39
+ if (!result)
40
+ return fallback;
41
+ const availability = record(result.availability);
42
+ if (availability?.deepScanComplete === false)
43
+ return 'fast';
44
+ if (availability?.deepScanComplete === true)
45
+ return 'deep';
46
+ const whois = record(result.whois);
47
+ if (whois?.skipped === true
48
+ && typeof whois.detail === 'string'
49
+ && /fast rdap-only mode/iu.test(whois.detail))
50
+ return 'fast';
51
+ const scanModes = [
52
+ record(result.reverseDns)?.scanMode,
53
+ record(availability?.dns)?.scanMode,
54
+ record(availability?.http)?.scanMode,
55
+ ];
56
+ if (scanModes.includes('deep'))
57
+ return 'deep';
58
+ if (scanModes.includes('fast'))
59
+ return 'fast';
60
+ return fallback;
61
+ }
62
+ export function reconcileLookupUrlState(current, searchParams, preferredTask) {
63
+ const explicitQuery = lookupUrlQuery(searchParams.get('q'));
64
+ const explicitDepth = lookupUrlDepth(searchParams.get('depth'));
65
+ const queryChanged = explicitQuery !== null && explicitQuery !== current.query;
66
+ const depthChanged = explicitDepth !== null && explicitDepth !== current.depth;
67
+ const conflictsWithResult = current.result !== null && ((explicitQuery !== null && explicitQuery !== current.completedTarget)
68
+ || (explicitDepth !== null && explicitDepth !== current.retainedResultDepth));
69
+ return {
70
+ query: explicitQuery ?? current.query,
71
+ depth: explicitDepth ?? current.depth,
72
+ task: parseLookupTaskContext(searchParams.get('task')) ?? normalizeLookupTaskView(preferredTask),
73
+ result: conflictsWithResult ? null : current.result,
74
+ completedTarget: conflictsWithResult ? '' : current.completedTarget,
75
+ error: conflictsWithResult || queryChanged || depthChanged ? '' : current.error,
76
+ };
77
+ }
16
78
  export function readLookupPresentation(storage) {
17
79
  try {
18
80
  const serialized = storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY);
@@ -0,0 +1,9 @@
1
+ // Opaque browser-local identifiers shared by independently owned record
2
+ // models. Validation is intentionally exact: callers must not trim, case-fold,
3
+ // repair, resolve, or otherwise reinterpret an identifier.
4
+ export const OPAQUE_REFERENCE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
5
+ export function normalizeOpaqueReferenceId(value) {
6
+ return typeof value === 'string' && OPAQUE_REFERENCE_ID_PATTERN.test(value)
7
+ ? value
8
+ : null;
9
+ }
@@ -151,6 +151,10 @@ function splitDelimitedLine(line, delimiter = ',') {
151
151
  return result.map((s) => s.trim());
152
152
  }
153
153
  const DOMAIN_HEADER_NAMES = ['domain', 'domain_name', 'domain name', 'hostname', 'name'];
154
+ export const MAX_DOMAIN_INPUT_CHARACTERS = 2 * 1024 * 1024;
155
+ export const MAX_DOMAIN_INPUT_BYTES = 2 * 1024 * 1024;
156
+ export const MAX_DOMAIN_INPUT_LINES = 10_000;
157
+ export const MAX_DOMAIN_INPUT_SEPARATORS = 20_000;
154
158
  function detectDelimiter(line) {
155
159
  const candidates = [',', ';', '\t'];
156
160
  let best = null;
@@ -182,10 +186,29 @@ function looksLikeLookupToken(value) {
182
186
  }
183
187
  }
184
188
  export function parseDomainInput(text) {
185
- const normalized = String(text || '').replace(/^\uFEFF/, '');
189
+ const source = String(text || '');
190
+ if (source.length > MAX_DOMAIN_INPUT_CHARACTERS) {
191
+ return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
192
+ }
193
+ if (new TextEncoder().encode(source).byteLength > MAX_DOMAIN_INPUT_BYTES) {
194
+ return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
195
+ }
196
+ let lineCount = 1;
197
+ let separatorCount = 0;
198
+ for (let index = 0; index < source.length; index += 1) {
199
+ const character = source[index];
200
+ if (character === '\n')
201
+ lineCount += 1;
202
+ if (character === ',' || character === ';' || character === '\t')
203
+ separatorCount += 1;
204
+ if (lineCount > MAX_DOMAIN_INPUT_LINES || separatorCount > MAX_DOMAIN_INPUT_SEPARATORS) {
205
+ return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
206
+ }
207
+ }
208
+ const normalized = source.replace(/^\uFEFF/, '');
186
209
  const lines = normalized.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
187
210
  if (lines.length === 0)
188
- return { entries: [], duplicates: 0, usedHeader: false };
211
+ return { entries: [], duplicates: 0, usedHeader: false, tooLarge: false };
189
212
  const firstLine = lines[0] ?? '';
190
213
  const delimiter = detectDelimiter(firstLine);
191
214
  const firstCells = delimiter ? splitDelimitedLine(firstLine, delimiter) : [firstLine];
@@ -228,5 +251,5 @@ export function parseDomainInput(text) {
228
251
  seen.add(key);
229
252
  entries.push(value);
230
253
  }
231
- return { entries, duplicates, usedHeader };
254
+ return { entries, duplicates, usedHeader, tooLarge: false };
232
255
  }