@slicedearth/whoisleuth-cli 1.43.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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  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 +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -1,9 +1,6 @@
1
1
  export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
2
- export const LOOKUP_EVIDENCE_DENSITIES = Object.freeze([
3
- Object.freeze({ id: 'summary', label: 'Essentials', detail: 'A compact assessment, key observations, unresolved evidence, and one summary for each evidence family.' }),
4
- Object.freeze({ id: 'standard', label: 'Evidence', detail: 'Settled evidence is grouped by task while source detail and raw evidence stay collapsed until you inspect them.' }),
5
- Object.freeze({ id: 'full', label: 'All evidence', detail: 'Every settled evidence family remains available, including advanced and bounded raw evidence.' }),
6
- ]);
2
+ export const MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES = 1_024;
3
+ export const MAX_LOOKUP_URL_QUERY_LENGTH = 4_096;
7
4
  export const LOOKUP_TASK_VIEWS = Object.freeze([
8
5
  Object.freeze({ id: 'general', label: 'General investigation' }),
9
6
  Object.freeze({ id: 'acquisition', label: 'Acquisition review' }),
@@ -11,24 +8,84 @@ export const LOOKUP_TASK_VIEWS = Object.freeze([
11
8
  Object.freeze({ id: 'incident', label: 'Incident response' }),
12
9
  Object.freeze({ id: 'owned', label: 'Owned-domain posture' }),
13
10
  ]);
14
- const DENSITIES = new Set(LOOKUP_EVIDENCE_DENSITIES.map((option) => option.id));
15
11
  const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
16
- export function normalizeLookupEvidenceDensity(value) {
17
- return typeof value === 'string' && DENSITIES.has(value)
18
- ? value
19
- : 'summary';
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;
20
17
  }
21
18
  export function normalizeLookupTaskView(value) {
22
19
  return typeof value === 'string' && TASKS.has(value)
23
20
  ? value
24
21
  : 'general';
25
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
+ }
26
78
  export function readLookupPresentation(storage) {
27
79
  try {
28
- const stored = JSON.parse(storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY) || 'null');
80
+ const serialized = storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY);
81
+ if (serialized === null
82
+ || serialized.length > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES
83
+ || new TextEncoder().encode(serialized).byteLength > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES) {
84
+ return { task: 'general' };
85
+ }
86
+ const stored = JSON.parse(serialized);
29
87
  if (stored?.version === 1) {
30
88
  return {
31
- density: normalizeLookupEvidenceDensity(stored.density),
32
89
  task: normalizeLookupTaskView(stored.task),
33
90
  };
34
91
  }
@@ -36,13 +93,12 @@ export function readLookupPresentation(storage) {
36
93
  catch {
37
94
  // Invalid or unavailable browser storage falls back to the stable defaults.
38
95
  }
39
- return { density: 'summary', task: 'general' };
96
+ return { task: 'general' };
40
97
  }
41
98
  export function writeLookupPresentation(storage, state) {
42
99
  try {
43
100
  storage.setItem(LOOKUP_PRESENTATION_STORAGE_KEY, JSON.stringify({
44
101
  version: 1,
45
- density: normalizeLookupEvidenceDensity(state.density),
46
102
  task: normalizeLookupTaskView(state.task),
47
103
  }));
48
104
  }
@@ -1,3 +1,4 @@
1
+ import { BoundedJsonResponseError, requestJsonCapped, } from "../bounded-json-response.js";
1
2
  export const LOOKUP_SOURCE_REFRESH_VERSION = 1;
2
3
  export const LOOKUP_SOURCE_STALE_AFTER_DAYS = 7;
3
4
  export const LOOKUP_FRESHNESS_POLICY_VERSION = 1;
@@ -141,47 +142,6 @@ function record(value) {
141
142
  ? value
142
143
  : {};
143
144
  }
144
- async function boundedJson(response) {
145
- const declaredLength = Number(response.headers.get('content-length'));
146
- if (Number.isFinite(declaredLength) && declaredLength > MAX_LOOKUP_SOURCE_REFRESH_BYTES) {
147
- throw new Error('oversized');
148
- }
149
- if (!response.body)
150
- return {};
151
- const reader = response.body.getReader();
152
- const chunks = [];
153
- let total = 0;
154
- try {
155
- while (true) {
156
- const { done, value } = await reader.read();
157
- if (done)
158
- break;
159
- if (!value)
160
- continue;
161
- total += value.byteLength;
162
- if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
163
- throw new Error('oversized');
164
- chunks.push(value);
165
- }
166
- }
167
- finally {
168
- if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
169
- await reader.cancel().catch(() => { });
170
- reader.releaseLock();
171
- }
172
- const bytes = new Uint8Array(total);
173
- let offset = 0;
174
- for (const chunk of chunks) {
175
- bytes.set(chunk, offset);
176
- offset += chunk.byteLength;
177
- }
178
- try {
179
- return JSON.parse(new TextDecoder().decode(bytes));
180
- }
181
- catch {
182
- return {};
183
- }
184
- }
185
145
  function text(value, maximum = 180) {
186
146
  return String(value ?? '')
187
147
  .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
@@ -194,6 +154,7 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
194
154
  version: LOOKUP_SOURCE_REFRESH_VERSION,
195
155
  id: plan.id,
196
156
  observedAt,
157
+ attemptedAt: observedAt,
197
158
  reason: plan.reason,
198
159
  evidenceIds: plan.evidenceIds.slice(0, 12),
199
160
  supersedesObservedAt,
@@ -208,7 +169,6 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
208
169
  detail: complete
209
170
  ? `Registry RDAP returned a validated ${upstreamStatus} response.`
210
171
  : `Registry RDAP returned ${Number.isFinite(upstreamStatus) ? `HTTP ${upstreamStatus}` : 'no complete structured record'}.`,
211
- observedAt,
212
172
  };
213
173
  }
214
174
  if (plan.id === 'whois') {
@@ -221,7 +181,6 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
221
181
  detail: complete
222
182
  ? `WHOIS returned a complete ${chain.length}-hop referral chain.`
223
183
  : `WHOIS returned ${chain.length} hop${chain.length === 1 ? '' : 's'} with ${chainStatus || 'unknown'} chain status.`,
224
- observedAt,
225
184
  };
226
185
  }
227
186
  const state = text(body.state, 40) || 'unknown';
@@ -234,7 +193,19 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
234
193
  ...base,
235
194
  state: complete ? 'complete' : 'limited',
236
195
  detail: `Domain evidence returned ${state}${sourceStates.length ? `; DNS, HTTP, and TLS states: ${sourceStates.join(', ')}` : ''}.`,
237
- observedAt,
196
+ };
197
+ }
198
+ function unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, detail) {
199
+ return {
200
+ version: LOOKUP_SOURCE_REFRESH_VERSION,
201
+ id: plan.id,
202
+ state: 'unavailable',
203
+ detail,
204
+ observedAt: null,
205
+ attemptedAt,
206
+ reason: plan.reason,
207
+ evidenceIds: plan.evidenceIds.slice(0, 12),
208
+ supersedesObservedAt,
238
209
  };
239
210
  }
240
211
  export async function requestLookupSourceRefresh(plan, query, depth, options = {}) {
@@ -244,19 +215,24 @@ export async function requestLookupSourceRefresh(plan, query, depth, options = {
244
215
  const timeoutMs = Number.isFinite(options.timeoutMs)
245
216
  ? Math.max(1, Math.min(LOOKUP_SOURCE_REFRESH_TIMEOUT_MS, Math.round(Number(options.timeoutMs))))
246
217
  : LOOKUP_SOURCE_REFRESH_TIMEOUT_MS;
247
- const controller = new AbortController();
248
- const timeout = setTimeout(() => controller.abort('timeout'), timeoutMs);
218
+ const attemptedAt = options.now?.() ?? new Date().toISOString();
219
+ const supersedesObservedAt = options.supersedesObservedAt === undefined
220
+ ? plan.supersedesObservedAt
221
+ : options.supersedesObservedAt;
249
222
  try {
250
223
  const url = `${plan.endpoint}?q=${encodeURIComponent(normalizedQuery)}${plan.id === 'availability' && depth === 'fast' ? '&fast=true' : ''}`;
251
- const response = await (options.fetchImpl ?? fetch)(url, {
224
+ const { response, body: rawBody } = await requestJsonCapped(url, {
252
225
  credentials: 'same-origin',
253
- signal: controller.signal,
226
+ }, {
227
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
228
+ maximumBytes: MAX_LOOKUP_SOURCE_REFRESH_BYTES,
229
+ timeoutMs,
254
230
  });
255
- const body = record(await boundedJson(response));
231
+ const body = record(rawBody);
256
232
  if (!response.ok) {
257
233
  return {
258
- ok: false,
259
- message: text(body.error, 240) || `Source refresh failed with HTTP ${response.status}.`,
234
+ ok: true,
235
+ value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, text(body.error, 240) || `The source returned HTTP ${response.status}; no new observation was recorded.`),
260
236
  };
261
237
  }
262
238
  if (Object.keys(body).length > MAX_LOOKUP_SOURCE_REFRESH_KEYS) {
@@ -264,23 +240,20 @@ export async function requestLookupSourceRefresh(plan, query, depth, options = {
264
240
  }
265
241
  return {
266
242
  ok: true,
267
- value: summarizeSource(plan, body, options.now?.() ?? new Date().toISOString(), depth, options.supersedesObservedAt === undefined
268
- ? plan.supersedesObservedAt
269
- : options.supersedesObservedAt),
243
+ value: summarizeSource(plan, body, attemptedAt, depth, supersedesObservedAt),
270
244
  };
271
245
  }
272
246
  catch (cause) {
273
- if (cause instanceof Error && cause.message === 'oversized') {
274
- return { ok: false, message: 'Source refresh returned an oversized response.' };
247
+ if (cause instanceof BoundedJsonResponseError && cause.code === 'response_too_large') {
248
+ return { ok: true, value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, 'The source response exceeded the local limit; no new observation was recorded.') };
275
249
  }
276
250
  return {
277
- ok: false,
278
- message: controller.signal.aborted ? 'Source refresh timed out.' : 'Source refresh could not be completed.',
251
+ ok: true,
252
+ value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, cause instanceof BoundedJsonResponseError && cause.code === 'timeout'
253
+ ? 'The source refresh timed out; no new observation was recorded.'
254
+ : 'The source refresh could not be completed; no new observation was recorded.'),
279
255
  };
280
256
  }
281
- finally {
282
- clearTimeout(timeout);
283
- }
284
257
  }
285
258
  export function mergeLookupSourceRefreshLedger(current, result) {
286
259
  const candidates = [
@@ -289,10 +262,10 @@ export function mergeLookupSourceRefreshLedger(current, result) {
289
262
  ];
290
263
  const byObservation = new Map();
291
264
  for (const entry of candidates.slice(-MAX_LOOKUP_SOURCE_REFRESH_HISTORY * 4)) {
292
- byObservation.set(`${entry.id}:${entry.observedAt}`, entry);
265
+ byObservation.set(`${entry.id}:${entry.attemptedAt}`, entry);
293
266
  }
294
267
  const ordered = [...byObservation.values()]
295
- .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt));
268
+ .sort((left, right) => Date.parse(left.attemptedAt) - Date.parse(right.attemptedAt));
296
269
  const truncated = Boolean(current?.truncated)
297
270
  || ordered.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY
298
271
  || candidates.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY;
@@ -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
+ }
@@ -5,7 +5,7 @@ import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } fr
5
5
  // this only gates whether a WHOIS/RDAP-sourced string is safe to drop into a
6
6
  // mailto: URI as the recipient, not a general email validator. mailto:
7
7
  // treats a comma as an additional-recipient separator (RFC 6068), so a
8
- // registrant/abuse-contact field containing e.g. "victim@x.com,cc@evil.com"
8
+ // registrant/abuse-contact field containing e.g. "person@example.test,cc@hostile.example"
9
9
  // would silently add a second recipient to the outreach/abuse draft the user
10
10
  // opens - rejecting anything outside a single plain address closes that off.
11
11
  const SIMPLE_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
@@ -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
  }
@@ -2,7 +2,14 @@ import { scaleBand } from 'd3-scale';
2
2
  import { boundedVisualizationId as boundedId, boundedVisualizationText as boundedText, } from "./visualization-bounds.js";
3
3
  export const MAX_VISUAL_MATRIX_ROWS = 24;
4
4
  export const MAX_VISUAL_MATRIX_COLUMNS = 6;
5
- function normalizedMatrixState(value) {
5
+ // A row is one source-pair lane, so it can publish at most one cell for each
6
+ // allowed column. This keeps output at 24 × 6 = 144 cells and prevents hostile
7
+ // duplicate inputs from creating sub-pixel marks or unbounded cell arrays.
8
+ export const MAX_VISUAL_MATRIX_CELLS = MAX_VISUAL_MATRIX_ROWS * MAX_VISUAL_MATRIX_COLUMNS;
9
+ const MAX_VISUAL_MATRIX_COLUMN_INPUTS = MAX_VISUAL_MATRIX_COLUMNS * 4;
10
+ const MAX_VISUAL_MATRIX_ROW_INPUTS = MAX_VISUAL_MATRIX_ROWS * 4;
11
+ const MAX_VISUAL_MATRIX_CELL_INPUTS_PER_ROW = MAX_VISUAL_MATRIX_COLUMNS * 4;
12
+ function normalizedMatrixTone(value) {
6
13
  const state = boundedText(value, 30).toLowerCase();
7
14
  if (state === 'equal' || state === 'equivalent' || state === 'same')
8
15
  return 'equal';
@@ -22,45 +29,79 @@ function normalizedMatrixState(value) {
22
29
  return 'unknown';
23
30
  }
24
31
  export function projectEvidenceMatrix(rawColumns, rawRows) {
25
- const columns = [...new Set((Array.isArray(rawColumns) ? rawColumns : [])
26
- .map((column) => boundedText(column, 40))
27
- .filter(Boolean))]
28
- .slice(0, MAX_VISUAL_MATRIX_COLUMNS);
32
+ const columnInputs = Array.isArray(rawColumns) ? rawColumns : [];
33
+ const columns = [];
34
+ const seenColumns = new Set();
35
+ for (const rawColumn of columnInputs.slice(0, MAX_VISUAL_MATRIX_COLUMN_INPUTS)) {
36
+ const column = boundedText(rawColumn, 40);
37
+ if (!column || seenColumns.has(column) || columns.length >= MAX_VISUAL_MATRIX_COLUMNS)
38
+ continue;
39
+ seenColumns.add(column);
40
+ columns.push(column);
41
+ }
29
42
  const columnSet = new Set(columns);
30
43
  const seenRows = new Set();
31
- const candidates = (Array.isArray(rawRows) ? rawRows : [])
32
- .map((row, index) => {
44
+ const rowInputs = Array.isArray(rawRows) ? rawRows : [];
45
+ const rows = [];
46
+ let discardedCellInputs = 0;
47
+ const scannedRows = rowInputs.slice(0, MAX_VISUAL_MATRIX_ROW_INPUTS);
48
+ for (const [index, row] of scannedRows.entries()) {
49
+ if (rows.length >= MAX_VISUAL_MATRIX_ROWS)
50
+ break;
33
51
  const id = boundedId(row?.id) || `row-${index}`;
34
52
  const label = boundedText(row?.label, 56);
35
53
  if (!label || seenRows.has(id))
36
- return null;
54
+ continue;
37
55
  seenRows.add(id);
38
56
  const rowCells = (Array.isArray(row.cells) ? row.cells : []);
39
- const byColumn = new Map(rowCells
40
- .map((cell) => {
57
+ const scannedCells = rowCells.slice(0, MAX_VISUAL_MATRIX_CELL_INPUTS_PER_ROW);
58
+ discardedCellInputs += Math.max(0, rowCells.length - scannedCells.length);
59
+ const byColumn = new Map();
60
+ for (const cell of scannedCells) {
41
61
  const column = boundedText(cell?.column, 40);
42
- return [column, {
43
- state: normalizedMatrixState(cell?.state),
44
- detail: boundedText(cell?.detail, 120),
62
+ if (!columnSet.has(column) || byColumn.has(column)) {
63
+ discardedCellInputs += 1;
64
+ continue;
65
+ }
66
+ const exactState = boundedText(cell?.state, 40).toLowerCase() || 'unknown';
67
+ byColumn.set(column, {
68
+ column,
69
+ state: exactState,
70
+ tone: normalizedMatrixTone(cell?.tone ?? exactState),
71
+ detail: boundedText(cell?.detail, 320),
72
+ });
73
+ }
74
+ const cells = columns.flatMap((column) => {
75
+ const value = byColumn.get(column);
76
+ if (value)
77
+ return [value];
78
+ if (row.sparse === true)
79
+ return [];
80
+ return [{
81
+ column,
82
+ state: 'not_collected',
83
+ tone: 'not_collected',
84
+ detail: '',
45
85
  }];
46
- })
47
- .filter(([column]) => columnSet.has(column)));
48
- return {
86
+ });
87
+ rows.push({
49
88
  id,
50
89
  label,
51
- cells: columns.map((column) => ({
52
- column,
53
- state: byColumn.get(column)?.state ?? 'not_collected',
54
- detail: byColumn.get(column)?.detail ?? '',
55
- })),
56
- };
57
- })
58
- .filter((row) => Boolean(row));
59
- const rows = candidates.slice(0, MAX_VISUAL_MATRIX_ROWS);
90
+ context: boundedText(row.context, 90),
91
+ status: boundedText(row.status, 40).toLowerCase() || 'unknown',
92
+ assessment: boundedText(row.assessment, 80),
93
+ cells,
94
+ });
95
+ }
96
+ const discarded = {
97
+ columnInputs: Math.max(0, columnInputs.length - columns.length),
98
+ rowInputs: Math.max(0, rowInputs.length - rows.length),
99
+ cellInputs: discardedCellInputs,
100
+ };
60
101
  const width = 900;
61
102
  const top = 54;
62
- const left = 210;
63
- const rowHeight = 30;
103
+ const left = 250;
104
+ const rowHeight = 40;
64
105
  const x = scaleBand().domain(columns).range([left, 870]).padding(0.12);
65
106
  const y = scaleBand().domain(rows.map((row) => row.id))
66
107
  .range([top, top + Math.max(1, rows.length) * rowHeight])
@@ -83,6 +124,8 @@ export function projectEvidenceMatrix(rawColumns, rawRows) {
83
124
  width: x.bandwidth(),
84
125
  })),
85
126
  })),
86
- truncated: candidates.length > rows.length || rawColumns.length > columns.length,
127
+ discarded,
128
+ projectedCellCount: rows.reduce((sum, row) => sum + row.cells.length, 0),
129
+ truncated: discarded.columnInputs > 0 || discarded.rowInputs > 0 || discarded.cellInputs > 0,
87
130
  };
88
131
  }