@slicedearth/whoisleuth-cli 2.0.0 → 2.1.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 (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +17 -10
  37. package/docs/cli.md +5 -3
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/project-metadata.mjs +1 -1
  59. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  60. package/lib/registrar-standing-contract.mjs +300 -0
  61. package/lib/registrar-standing.mjs +269 -0
  62. package/lib/registry-insights.mjs +2 -8
  63. package/lib/risk-scoring.mjs +26 -10
  64. package/lib/sslbl-certificates.generated.mjs +138 -136
  65. package/lib/structured-data-identity.mjs +1 -3
  66. package/lib/technology-evidence-role.mjs +51 -0
  67. package/lib/threat-intelligence-runtime.mjs +3 -3
  68. package/lib/threat-intelligence-types.mjs +13 -0
  69. package/lib/threatfox-intelligence.mjs +1 -3
  70. package/lib/tls-intelligence.mjs +1 -2
  71. package/lib/urlhaus-intelligence.mjs +1 -3
  72. package/lib/urlscan-intelligence.mjs +1 -3
  73. package/lib/website-security-posture.mjs +1 -2
  74. package/lib/website-technology.mjs +72 -23
  75. package/lib/whois-common-formats.mjs +276 -0
  76. package/lib/whois-dialects.mjs +534 -0
  77. package/lib/whois-parser-support.mjs +305 -0
  78. package/lib/whois-parser.mjs +135 -0
  79. package/lib/whois.mjs +6 -1187
  80. package/package.json +3 -3
  81. package/packages/cases/case-evidence-model.mjs +9 -0
  82. package/packages/cases/case-migration-model.mjs +17 -11
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +227 -63
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  93. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  94. package/packages/workspace/brand-profile-model.mjs +1 -1
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
@@ -1,7 +1,8 @@
1
1
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
2
2
  import { safeTerminalValue } from "./formatters/terminal.mjs";
3
+ import { compareRdapPublications, compareRegistrySources } from "../lib/registry-comparison.mjs";
3
4
  export const CLI_LOOKUP_BRIEF_SCHEMA = 'whoisleuth.cli.lookup-brief';
4
- export const CLI_LOOKUP_BRIEF_VERSION = 2;
5
+ export const CLI_LOOKUP_BRIEF_VERSION = 3;
5
6
  function record(value) {
6
7
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
7
8
  }
@@ -18,41 +19,145 @@ function text(value, maximum = 300) {
18
19
  function sourceState(value) {
19
20
  return text(value, 40)?.toLowerCase() ?? 'unavailable';
20
21
  }
22
+ function joinedText(value, maximumItems = 8) {
23
+ if (!Array.isArray(value))
24
+ return null;
25
+ const retained = value.slice(0, maximumItems).flatMap((item) => {
26
+ const normalized = text(item, 253);
27
+ return normalized ? [normalized] : [];
28
+ });
29
+ if (!retained.length)
30
+ return null;
31
+ return `${retained.join(', ')}${value.length > retained.length ? ` (+${value.length - retained.length} more)` : ''}`;
32
+ }
33
+ function observedBoolean(value, positive, negative) {
34
+ return value === true ? positive : value === false ? negative : null;
35
+ }
36
+ function sourceObservation(id, label, stateValue, ...timeValues) {
37
+ return Object.freeze({
38
+ id,
39
+ label,
40
+ state: sourceState(stateValue),
41
+ observedAt: timeValues.map((value) => text(value, 64)).find(Boolean) ?? null,
42
+ });
43
+ }
44
+ function contradictionRows(value, left, right, leftStateKey, rightStateKey, leftDisplayKey, rightDisplayKey) {
45
+ const fields = Array.isArray(record(value).fields) ? record(value).fields : [];
46
+ return fields.flatMap((fieldValue) => {
47
+ const field = record(fieldValue);
48
+ if (field.status !== 'conflict')
49
+ return [];
50
+ const label = text(field.label, 120) ?? 'Registration publication field';
51
+ const observations = [
52
+ Object.freeze({
53
+ source: left,
54
+ state: sourceState(field[leftStateKey] ?? left.state),
55
+ value: text(field[leftDisplayKey], 300) ?? 'Value not retained',
56
+ }),
57
+ Object.freeze({
58
+ source: right,
59
+ state: sourceState(field[rightStateKey] ?? right.state),
60
+ value: text(field[rightDisplayKey], 300) ?? 'Value not retained',
61
+ }),
62
+ ];
63
+ return [Object.freeze({
64
+ field: label,
65
+ detail: `${left.label} and ${right.label} publish different values for ${label}.`,
66
+ observations: Object.freeze(observations),
67
+ })];
68
+ });
69
+ }
21
70
  export function buildCliLookupBrief(input, generatedAt = new Date().toISOString()) {
22
71
  const document = parseSavedLookupDocument(input, { label: 'Lookup brief input' });
23
72
  const diagnostics = record(document.diagnostics);
24
73
  const availability = record(document.availability);
74
+ const rdapDiagnostics = record(diagnostics.rdap);
75
+ const registrarRdapDiagnostics = record(rdapDiagnostics.registrar);
76
+ const whoisDiagnostics = record(diagnostics.whois);
77
+ const availabilityDiagnostics = record(diagnostics.availability);
25
78
  const rdap = record(record(document.rdap).parsed);
26
79
  const whois = record(record(document.whois).parsed);
80
+ const registrarRdap = record(record(record(document.rdap).registrarRdap).parsed);
27
81
  const preferred = Object.keys(rdap).length ? rdap : whois;
28
- const sources = ['rdap', 'whois', 'availability', 'dns', 'http', 'tls'].map((id) => {
29
- const nested = id === 'dns' || id === 'http' || id === 'tls' ? record(availability[id]) : record(diagnostics[id]);
30
- return Object.freeze({ id, state: sourceState(nested.status ?? nested.state), observedAt: text(nested.observedAt ?? nested.fetchedAt ?? nested.queriedAt, 64) });
31
- });
82
+ const rdapSource = sourceObservation('rdap', 'Registry RDAP', rdapDiagnostics.status, rdapDiagnostics.observedAt, rdapDiagnostics.fetchedAt, record(document.rdap).observedAt, record(document.rdap).fetchedAt);
83
+ const registrarRdapSource = sourceObservation('registrar_rdap', 'Registrar RDAP', registrarRdapDiagnostics.status, registrarRdapDiagnostics.observedAt, registrarRdapDiagnostics.fetchedAt);
84
+ const whoisSource = sourceObservation('whois', 'WHOIS', whoisDiagnostics.status, whoisDiagnostics.observedAt, whoisDiagnostics.queriedAt, record(document.whois).observedAt, record(document.whois).queriedAt);
85
+ const availabilitySource = sourceObservation('availability', 'Authority-aware availability', availabilityDiagnostics.status ?? availability.status, availabilityDiagnostics.observedAt, availability.observedAt);
86
+ const dns = record(availability.dns);
87
+ const http = record(availability.http);
88
+ const tls = record(availability.tls);
89
+ const dnsSource = sourceObservation('dns', 'DNS', dns.status ?? dns.state, dns.observedAt, dns.checkedAt);
90
+ const httpSource = sourceObservation('http', 'HTTP and static page identity', http.status ?? http.state, http.observedAt, http.checkedAt, record(availability.pageIdentity).observedAt);
91
+ const tlsSource = sourceObservation('tls', 'TLS', tls.status ?? tls.state, tls.observedAt, tls.checkedAt);
92
+ const pageIdentity = record(availability.pageIdentity);
93
+ const technology = record(availability.technologyProfile);
94
+ const sslbl = record(document.sslbl);
95
+ const pageAnalysisSource = sourceObservation('page_analysis', 'Static page analysis', pageIdentity.status ?? technology.status, pageIdentity.observedAt, technology.observedAt, http.observedAt);
96
+ const sslblSource = sourceObservation('sslbl', 'Local SSLBL certificate snapshot', sslbl.status, sslbl.observedAt, record(sslbl.snapshot).sourceUpdatedAt);
97
+ const sources = [
98
+ rdapSource,
99
+ registrarRdapSource,
100
+ whoisSource,
101
+ availabilitySource,
102
+ dnsSource,
103
+ httpSource,
104
+ tlsSource,
105
+ pageAnalysisSource,
106
+ ...(Object.keys(sslbl).length ? [sslblSource] : []),
107
+ ];
108
+ const sourceById = new Map(sources.map((source) => [source.id, source]));
109
+ const preferredRegistrationSource = Object.keys(rdap).length ? rdapSource : whoisSource;
110
+ const externalFormActions = record(pageIdentity.forms).externalActionOrigins;
32
111
  const facts = [
33
- ['Registration state', availability.state, 'Authority-aware availability'],
34
- ['Registrar', record(preferred.registrar).name ?? preferred.registrar, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
35
- ['Created', record(preferred.lifecycle).createdIso ?? preferred.createdDateIso, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
36
- ['Expires', record(preferred.lifecycle).expiryIso ?? preferred.expiryDateIso, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
37
- ['Website activity', availability.activityStatus, 'HTTP'],
38
- ['Page title', availability.pageTitle, 'Static page identity'],
39
- ].flatMap(([label, value, source]) => {
112
+ ['registration-state', 'Registration state', availability.state, availabilitySource.id],
113
+ ['registrar', 'Registrar', record(preferred.registrar).name ?? preferred.registrar, preferredRegistrationSource.id],
114
+ ['created', 'Created', record(preferred.lifecycle).createdIso ?? preferred.createdDateIso, preferredRegistrationSource.id],
115
+ ['expires', 'Expires', record(preferred.lifecycle).expiryIso ?? preferred.expiryDateIso, preferredRegistrationSource.id],
116
+ ['name-servers', 'Name servers', joinedText(preferred.nameservers), preferredRegistrationSource.id],
117
+ ['website-activity', 'Website activity', availability.activityStatus, httpSource.id],
118
+ ['page-title', 'Page title', availability.pageTitle, httpSource.id],
119
+ ['dnssec', 'DNSSEC publication', availability.dnssec, dnsSource.id],
120
+ ['mail-exchanger', 'Mail exchanger', observedBoolean(availability.hasMx, 'Observed', 'Not observed in retained DNS evidence'), dnsSource.id],
121
+ ['spf', 'SPF publication', observedBoolean(availability.hasSpf, 'Observed', 'Not observed in retained DNS evidence'), dnsSource.id],
122
+ ['dmarc', 'DMARC publication', observedBoolean(availability.hasDmarc, 'Observed', 'Not observed in retained DNS evidence'), dnsSource.id],
123
+ ['password-input', 'Password input', observedBoolean(availability.hasPasswordField, 'Observed in static HTML', 'Not observed in retained static HTML'), pageAnalysisSource.id],
124
+ ['external-form-actions', 'External form actions', Array.isArray(externalFormActions)
125
+ ? `${externalFormActions.length} retained origin${externalFormActions.length === 1 ? '' : 's'}`
126
+ : null, pageAnalysisSource.id],
127
+ ['page-role', 'Page role', record(availability.pageRoleProfile).primaryRole, pageAnalysisSource.id],
128
+ ['technology-indicators', 'Technology indicators', joinedText(Array.isArray(technology.findings)
129
+ ? technology.findings.map((finding) => record(finding).name ?? record(finding).id)
130
+ : null), pageAnalysisSource.id],
131
+ ['certificate-warning-data', 'Certificate warning data', sslbl.verdict === 'listed'
132
+ ? 'Listed certificate review lead'
133
+ : sslbl.verdict === 'not_listed' && sslbl.status === 'success' && sslbl.complete === true
134
+ ? 'No match in retained local snapshot'
135
+ : Object.keys(sslbl).length
136
+ ? 'Inconclusive'
137
+ : null, sslblSource.id],
138
+ ].flatMap(([id, label, value, sourceId]) => {
40
139
  const normalized = text(value);
41
- return normalized ? [Object.freeze({ label: String(label), value: normalized, source: String(source) })] : [];
140
+ const source = sourceById.get(String(sourceId));
141
+ return normalized && source ? [Object.freeze({ id: String(id), label: String(label), value: normalized, source })] : [];
42
142
  });
43
143
  const incomplete = sources.filter((item) => !['complete', 'success'].includes(item.state));
44
- const analysis = record(document.analysis);
45
- const contradictions = [analysis.registryComparison, document.registryComparison, document.registrarPublicationComparison]
46
- .flatMap((value) => Array.isArray(record(value).fields) ? record(value).fields : [])
47
- .flatMap((value) => {
48
- const item = record(value);
49
- return item.status === 'conflict' ? [text(item.label, 120) ?? 'Registration publication conflict'] : [];
50
- }).slice(0, 16);
144
+ const registryComparison = compareRegistrySources(rdap, whois, {
145
+ rdapStatus: rdapDiagnostics.status,
146
+ whoisStatus: whoisDiagnostics.status,
147
+ });
148
+ const registrarComparison = compareRdapPublications(rdap, registrarRdap, {
149
+ registryStatus: rdapDiagnostics.status,
150
+ registrarStatus: registrarRdapDiagnostics.status,
151
+ });
152
+ const contradictions = [
153
+ ...contradictionRows(registryComparison, rdapSource, whoisSource, 'rdapState', 'whoisState', 'rdapDisplay', 'whoisDisplay'),
154
+ ...contradictionRows(registrarComparison, rdapSource, registrarRdapSource, 'registryState', 'registrarState', 'registryDisplay', 'registrarDisplay'),
155
+ ].slice(0, 16);
51
156
  const actionPlan = [
52
157
  ...(contradictions.length ? [{
53
158
  id: 'registration-publication-review',
54
159
  action: 'Review the separately attributed registration publications before relying on the conflicting fields.',
55
- reason: `${contradictions.length} conflicting publication field${contradictions.length === 1 ? '' : 's'} were retained.`,
160
+ reason: `${contradictions.length} conflicting publication field${contradictions.length === 1 ? '' : 's'} were retained with separate source values.`,
56
161
  expectedOutcome: 'Establish which observation is current, authoritative, or still unresolved.',
57
162
  evidence: 'registry publication comparison',
58
163
  }] : []),
@@ -70,6 +175,13 @@ export function buildCliLookupBrief(input, generatedAt = new Date().toISOString(
70
175
  expectedOutcome: 'Collect the explicitly selected additional source classes without implying that unavailable evidence is absent.',
71
176
  evidence: 'collection mode',
72
177
  }] : []),
178
+ ...(sslbl.verdict === 'listed' ? [{
179
+ id: 'certificate-warning-review',
180
+ action: 'Review the matching certificate fingerprint alongside current TLS, page, and infrastructure evidence.',
181
+ reason: 'The retained local warning-data snapshot contains the observed certificate fingerprint as a review lead.',
182
+ expectedOutcome: 'Determine whether the listing is relevant and current without treating it alone as a maliciousness verdict.',
183
+ evidence: 'local SSLBL certificate snapshot',
184
+ }] : []),
73
185
  {
74
186
  id: 'case-evidence-review',
75
187
  action: 'Pin only the facts needed for the decision and keep analyst hypotheses separate from observations.',
@@ -105,8 +217,14 @@ export function formatCliLookupBrief(document) {
105
217
  `Observed ${safeTerminalValue(document.observedAt)}`,
106
218
  `Mode ${safeTerminalValue(document.mode)}`,
107
219
  '',
108
- 'Verified facts',
109
- ...(document.facts.length ? document.facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)} (${safeTerminalValue(item.source)})`) : [' No bounded fact was available.']),
220
+ 'Source-attributed facts',
221
+ ...(document.facts.length ? document.facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)} (${safeTerminalValue(item.source.label)}; ${safeTerminalValue(item.source.state)}; ${item.source.observedAt ? `observed ${safeTerminalValue(item.source.observedAt)}` : 'source time not recorded'})`) : [' No bounded fact was available.']),
222
+ '',
223
+ 'Contradictions',
224
+ ...(document.contradictions.length ? document.contradictions.flatMap((item) => [
225
+ ` ${safeTerminalValue(item.field)}: ${safeTerminalValue(item.detail)}`,
226
+ ...item.observations.map((observation) => ` ${safeTerminalValue(observation.source.label)}: ${safeTerminalValue(observation.value)} (${safeTerminalValue(observation.state)}; ${observation.source.observedAt ? `observed ${safeTerminalValue(observation.source.observedAt)}` : 'source time not recorded'})`),
227
+ ]) : [' No conflicting publication field was retained.']),
110
228
  '',
111
229
  'Unknown or incomplete',
112
230
  ...(document.unknowns.length ? document.unknowns.map((item) => ` ${safeTerminalValue(item)}`) : [' No incomplete source state was identified.']),
@@ -0,0 +1,289 @@
1
+ import { formatTerminalLookup, safeTerminalValue } from "./formatters/terminal.mjs";
2
+ import { presentTerminalOutput, wrapTerminalOutput, } from "./terminal-presentation.mjs";
3
+ const MIN_LOOKUP_BROWSER_HEIGHT = 12;
4
+ const MAX_LOOKUP_BROWSER_HEIGHT = 80;
5
+ const MAX_LOOKUP_BROWSER_PANELS = 9;
6
+ const MAX_LOOKUP_BROWSER_SEARCH_BYTES = 256;
7
+ const MAX_LOOKUP_BROWSER_SEARCH_SCALARS = 80;
8
+ const MAX_LOOKUP_BROWSER_SEARCH_MATCHES = 256;
9
+ const ANSI_SEQUENCE_RE = /\u001b\[[0-?]*[ -/]*[@-~]/gu;
10
+ function boundedHeight(value) {
11
+ const height = Number(value);
12
+ if (!Number.isSafeInteger(height))
13
+ return 24;
14
+ return Math.max(MIN_LOOKUP_BROWSER_HEIGHT, Math.min(MAX_LOOKUP_BROWSER_HEIGHT, height));
15
+ }
16
+ function panelId(value, index) {
17
+ const normalized = value.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '');
18
+ return normalized || `panel-${index + 1}`;
19
+ }
20
+ function terminalCharacterWidth(character) {
21
+ const codePoint = character.codePointAt(0) || 0;
22
+ if (/\p{Mark}/u.test(character)
23
+ || codePoint === 0x200d
24
+ || codePoint >= 0xfe00 && codePoint <= 0xfe0f
25
+ || codePoint >= 0xe0100 && codePoint <= 0xe01ef)
26
+ return 0;
27
+ return codePoint >= 0x1100 && (codePoint <= 0x115f
28
+ || codePoint === 0x2329
29
+ || codePoint === 0x232a
30
+ || codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f
31
+ || codePoint >= 0xac00 && codePoint <= 0xd7a3
32
+ || codePoint >= 0xf900 && codePoint <= 0xfaff
33
+ || codePoint >= 0xfe10 && codePoint <= 0xfe19
34
+ || codePoint >= 0xfe30 && codePoint <= 0xfe6f
35
+ || codePoint >= 0xff00 && codePoint <= 0xff60
36
+ || codePoint >= 0xffe0 && codePoint <= 0xffe6
37
+ || codePoint >= 0x1f300 && codePoint <= 0x1faff
38
+ || codePoint >= 0x20000 && codePoint <= 0x3fffd) ? 2 : 1;
39
+ }
40
+ function terminalDisplayWidth(value) {
41
+ return Array.from(value.replace(ANSI_SEQUENCE_RE, ''))
42
+ .reduce((total, character) => total + terminalCharacterWidth(character), 0);
43
+ }
44
+ function hardWrapTerminalLine(line, width) {
45
+ if (!line || terminalDisplayWidth(line) <= width)
46
+ return [line];
47
+ const lines = [];
48
+ let current = '';
49
+ let currentWidth = 0;
50
+ for (const character of line) {
51
+ const characterWidth = terminalCharacterWidth(character);
52
+ if (current && currentWidth + characterWidth > width) {
53
+ lines.push(current);
54
+ current = '';
55
+ currentWidth = 0;
56
+ }
57
+ current += character;
58
+ currentWidth += characterWidth;
59
+ }
60
+ if (current || !lines.length)
61
+ lines.push(current);
62
+ return lines;
63
+ }
64
+ function clipTerminalLine(line, width) {
65
+ if (terminalDisplayWidth(line) <= width)
66
+ return line;
67
+ const maximumContentWidth = Math.max(1, width - 1);
68
+ let clipped = '';
69
+ let clippedWidth = 0;
70
+ for (const character of line) {
71
+ const characterWidth = terminalCharacterWidth(character);
72
+ if (clippedWidth + characterWidth > maximumContentWidth)
73
+ break;
74
+ clipped += character;
75
+ clippedWidth += characterWidth;
76
+ }
77
+ return `${clipped}…`;
78
+ }
79
+ function wrapLookupBrowserLines(value, width) {
80
+ const softlyWrapped = wrapTerminalOutput(`${value}\n`, width).replace(/\n$/u, '').split('\n');
81
+ return softlyWrapped.flatMap((line) => hardWrapTerminalLine(line, width));
82
+ }
83
+ function buildLookupBrowserPanels(document) {
84
+ const blocks = formatTerminalLookup(document, { detail: 'verbose' })
85
+ .trimEnd()
86
+ .split(/\n\n/gu)
87
+ .slice(0, MAX_LOOKUP_BROWSER_PANELS);
88
+ return blocks.flatMap((block, index) => {
89
+ const [heading, ...lines] = block.split('\n');
90
+ if (!heading?.endsWith(':') || lines.length === 0)
91
+ return [];
92
+ const label = heading.slice(0, -1);
93
+ return [Object.freeze({
94
+ id: panelId(label, index),
95
+ label,
96
+ lines: Object.freeze(lines),
97
+ })];
98
+ });
99
+ }
100
+ function browserTabLine(panels, activeIndex, width) {
101
+ const labels = panels.map((panel, index) => index === activeIndex
102
+ ? `[${index + 1} ${panel.label}]`
103
+ : `${index + 1} ${panel.label}`);
104
+ const complete = labels.join(' ');
105
+ if (terminalDisplayWidth(complete) <= width)
106
+ return complete;
107
+ const visible = [activeIndex - 1, activeIndex, activeIndex + 1]
108
+ .filter((index) => index >= 0 && index < panels.length)
109
+ .map((index) => labels[index]);
110
+ const windowed = `${activeIndex > 1 ? '‹ ' : ''}${visible.join(' ')}${activeIndex + 2 < panels.length ? ' ›' : ''}`;
111
+ return clipTerminalLine(windowed, width);
112
+ }
113
+ function boundedTargetLine(query, width) {
114
+ return clipTerminalLine(`Target ${query}`, width);
115
+ }
116
+ function boundedBrowserDimensions(options) {
117
+ return {
118
+ width: Math.max(40, Math.min(160, Number.isSafeInteger(options.width) ? Number(options.width) : 100)),
119
+ height: boundedHeight(options.height),
120
+ };
121
+ }
122
+ function boundedSearchText(value) {
123
+ const text = typeof value === 'string' ? value : '';
124
+ if (Buffer.byteLength(text, 'utf8') > MAX_LOOKUP_BROWSER_SEARCH_BYTES
125
+ || Array.from(text).length > MAX_LOOKUP_BROWSER_SEARCH_SCALARS
126
+ || /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u.test(text)) {
127
+ throw new RangeError(`Lookup browser search is limited to ${MAX_LOOKUP_BROWSER_SEARCH_SCALARS} characters and ${MAX_LOOKUP_BROWSER_SEARCH_BYTES} UTF-8 bytes.`);
128
+ }
129
+ return text;
130
+ }
131
+ function findLookupBrowserMatches(panels, query) {
132
+ const normalized = boundedSearchText(query).trim().toLocaleLowerCase('en-US');
133
+ if (!normalized)
134
+ return { matches: [], truncated: false };
135
+ const matches = [];
136
+ let truncated = false;
137
+ outer: for (let panelIndex = 0; panelIndex < panels.length; panelIndex += 1) {
138
+ const panel = panels[panelIndex];
139
+ const lines = [panel.label, ...panel.lines];
140
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
141
+ if (!String(lines[lineIndex] || '').toLocaleLowerCase('en-US').includes(normalized))
142
+ continue;
143
+ if (matches.length >= MAX_LOOKUP_BROWSER_SEARCH_MATCHES) {
144
+ truncated = true;
145
+ break outer;
146
+ }
147
+ matches.push(Object.freeze({ panelIndex, lineIndex: Math.max(0, lineIndex - 1) }));
148
+ }
149
+ }
150
+ return { matches, truncated };
151
+ }
152
+ function renderedPanelLineOffset(panel, lineIndex, width) {
153
+ if (lineIndex <= 0)
154
+ return 0;
155
+ return panel.lines
156
+ .slice(0, Math.min(lineIndex, panel.lines.length))
157
+ .reduce((total, line) => total + wrapLookupBrowserLines(line, width).length, 0);
158
+ }
159
+ function renderStandaloneBrowserFrame(lines, options) {
160
+ const { width, height } = boundedBrowserDimensions(options);
161
+ const clipped = lines.slice(0, height).map((line) => clipTerminalLine(line, width));
162
+ return presentTerminalOutput(`${clipped.join('\n')}\n`, {
163
+ color: options.color === true,
164
+ interactive: true,
165
+ palette: options.palette || 'auto',
166
+ width: null,
167
+ });
168
+ }
169
+ function buildLookupBrowserHelpFrame(document, options = {}) {
170
+ const query = safeTerminalValue(document?.query ?? options.query, 'unknown target');
171
+ const mode = safeTerminalValue(document?.mode ?? options.mode, 'unknown mode');
172
+ return renderStandaloneBrowserFrame([
173
+ 'WHOISleuth evidence browser help',
174
+ boundedTargetLine(query, boundedBrowserDimensions(options).width),
175
+ `Mode ${mode}`,
176
+ '',
177
+ options.collecting ? 'Collection' : 'Panels',
178
+ options.collecting
179
+ ? ' q / Ctrl-C cancel the active collection'
180
+ : ' ←/→ h/l Tab move panels · 1-9 select',
181
+ options.collecting
182
+ ? ' progress is transient and is never retained'
183
+ : ' ↑/↓ j/k PgUp/PgDn Home/End scroll',
184
+ options.collecting ? ' ? / Esc return to collection progress' : ' / search rendered panels · n/N matches',
185
+ ' ? / Esc return · q close',
186
+ ], options);
187
+ }
188
+ function buildLookupBrowserSearchFrame(document, query, notice, options = {}) {
189
+ return renderStandaloneBrowserFrame([
190
+ 'WHOISleuth evidence browser search',
191
+ boundedTargetLine(safeTerminalValue(document.query, 'unknown target'), boundedBrowserDimensions(options).width),
192
+ `Mode ${safeTerminalValue(document.mode, 'unknown mode')}`,
193
+ '',
194
+ `Search ${query || 'type a bounded term'}`,
195
+ notice || 'Search covers only the bounded text rendered in the evidence panels.',
196
+ '',
197
+ 'Enter apply · Backspace edit · Esc cancel',
198
+ ], options);
199
+ }
200
+ function buildLookupCollectionFrame(queryValue, modeValue, sources, options = {}) {
201
+ const { width, height } = boundedBrowserDimensions(options);
202
+ const query = safeTerminalValue(queryValue, 'unknown target');
203
+ const mode = modeValue === 'deep' ? 'deep' : 'fast';
204
+ const header = [
205
+ 'WHOISleuth evidence browser',
206
+ boundedTargetLine(query, width),
207
+ `Mode ${mode}`,
208
+ 'Collection in progress:',
209
+ ];
210
+ const footer = ['? help · progress is transient', 'q/Esc/Ctrl-C cancel'];
211
+ const bodyCapacity = Math.max(1, height - header.length - footer.length);
212
+ const body = mode === 'fast'
213
+ ? [
214
+ 'Registration evidence collecting',
215
+ 'Fast mode waits for the final bounded result.',
216
+ 'Per-source states are not fabricated.',
217
+ ]
218
+ : sources.flatMap((source) => {
219
+ const state = source.state === 'pending'
220
+ ? 'pending'
221
+ : `${source.state}${source.complete ? ' · complete' : ' · incomplete'}${source.truncated ? ' · truncated' : ''}`;
222
+ const limitation = source.state !== 'pending' && source.limitation
223
+ ? ` · ${source.limitation}`
224
+ : '';
225
+ return [clipTerminalLine(`${source.source.replaceAll('_', ' ')} ${state}${limitation}`, width)];
226
+ });
227
+ const visible = body.slice(0, bodyCapacity);
228
+ if (body.length > visible.length && visible.length) {
229
+ visible[visible.length - 1] = `+${body.length - visible.length + 1} source states not visible at this height`;
230
+ }
231
+ return renderStandaloneBrowserFrame([...header, ...visible, ...footer], {
232
+ ...options,
233
+ width,
234
+ height,
235
+ });
236
+ }
237
+ function buildLookupBrowserFrame(document, options = {}) {
238
+ const panels = buildLookupBrowserPanels(document);
239
+ const { width, height } = boundedBrowserDimensions(options);
240
+ if (!panels.length)
241
+ return Object.freeze({
242
+ value: `${clipTerminalLine('WHOISleuth evidence browser', width)}\n${clipTerminalLine('No bounded terminal panels were available.', width)}\n`,
243
+ scrollOffset: 0,
244
+ maximumScrollOffset: 0,
245
+ pageSize: 0,
246
+ totalBodyLines: 0,
247
+ });
248
+ const activeIndex = Math.max(0, Math.min(panels.length - 1, Number.isSafeInteger(options.activeIndex) ? Number(options.activeIndex) : 0));
249
+ const panel = panels[activeIndex];
250
+ const query = safeTerminalValue(document.query, 'unknown target');
251
+ const mode = safeTerminalValue(document.mode, 'unknown mode');
252
+ const header = [
253
+ 'WHOISleuth evidence browser',
254
+ boundedTargetLine(query, width),
255
+ `Mode ${clipTerminalLine(mode, Math.max(1, width - 8))}`,
256
+ browserTabLine(panels, activeIndex, width),
257
+ `${panel.label}:`,
258
+ ];
259
+ const footerLineCount = 2;
260
+ const body = wrapLookupBrowserLines(panel.lines.join('\n'), width);
261
+ const bodyCapacity = Math.max(1, height - header.length - footerLineCount);
262
+ const maximumScrollOffset = Math.max(0, body.length - bodyCapacity);
263
+ const requestedScrollOffset = Number.isSafeInteger(options.scrollOffset) ? Number(options.scrollOffset) : 0;
264
+ const scrollOffset = Math.max(0, Math.min(maximumScrollOffset, requestedScrollOffset));
265
+ const visibleBody = body.slice(scrollOffset, scrollOffset + bodyCapacity);
266
+ const firstVisibleLine = body.length ? scrollOffset + 1 : 0;
267
+ const lastVisibleLine = body.length ? scrollOffset + visibleBody.length : 0;
268
+ const footer = [
269
+ options.searchStatus || `View ${activeIndex + 1}/${panels.length} · lines ${firstVisibleLine}-${lastVisibleLine}/${body.length}`,
270
+ '←/→ panels · ↑/↓ scroll · / search · ? help · q close',
271
+ ].map((line) => clipTerminalLine(line, width));
272
+ const value = [...header, ...visibleBody, ...footer].slice(0, height).join('\n');
273
+ return Object.freeze({
274
+ value: presentTerminalOutput(`${value}\n`, {
275
+ color: options.color === true,
276
+ interactive: true,
277
+ palette: options.palette || 'auto',
278
+ width: null,
279
+ }),
280
+ scrollOffset,
281
+ maximumScrollOffset,
282
+ pageSize: bodyCapacity,
283
+ totalBodyLines: body.length,
284
+ });
285
+ }
286
+ function renderLookupBrowser(document, options = {}) {
287
+ return buildLookupBrowserFrame(document, options).value;
288
+ }
289
+ export { MIN_LOOKUP_BROWSER_HEIGHT, MAX_LOOKUP_BROWSER_PANELS, MAX_LOOKUP_BROWSER_SEARCH_BYTES, MAX_LOOKUP_BROWSER_SEARCH_MATCHES, MAX_LOOKUP_BROWSER_SEARCH_SCALARS, boundedBrowserDimensions, boundedSearchText, buildLookupBrowserHelpFrame, buildLookupBrowserFrame, buildLookupBrowserPanels, buildLookupBrowserSearchFrame, buildLookupCollectionFrame, findLookupBrowserMatches, renderedPanelLineOffset, renderLookupBrowser, terminalDisplayWidth, };