@slicedearth/whoisleuth-cli 1.39.1 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +23 -4
  2. package/bin/whoisleuth.mjs +31 -9
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +72 -39
  7. package/cli/bulk-command-runner.mjs +34 -4
  8. package/cli/bulk.mjs +18 -7
  9. package/cli/case-pack.mjs +136 -0
  10. package/cli/ci-report.mjs +73 -0
  11. package/cli/collection-preflight.mjs +59 -0
  12. package/cli/command-reference.mjs +395 -0
  13. package/cli/completion.mjs +67 -13
  14. package/cli/config-profile.mjs +183 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-observation-snapshot.mjs +8 -14
  17. package/cli/discovery-scan-command-runner.mjs +29 -2
  18. package/cli/discovery-snapshot.mjs +8 -15
  19. package/cli/domain-control-monitor.mjs +104 -0
  20. package/cli/domain-control-observations.mjs +235 -0
  21. package/cli/errors.mjs +7 -1
  22. package/cli/evidence-command-arguments.mjs +11 -0
  23. package/cli/evidence-command-runner.mjs +1 -0
  24. package/cli/external-observation-mapping.mjs +162 -0
  25. package/cli/fail-policy.mjs +64 -0
  26. package/cli/formatters/html.mjs +1 -1
  27. package/cli/formatters/json.mjs +12 -4
  28. package/cli/formatters/terminal.mjs +1 -1
  29. package/cli/http.mjs +2 -1
  30. package/cli/investigation-manifest.mjs +113 -0
  31. package/cli/investigation-run.mjs +112 -0
  32. package/cli/lookup-brief.mjs +116 -0
  33. package/cli/lookup-command-runner.mjs +8 -1
  34. package/cli/lookup-diff.mjs +17 -19
  35. package/cli/lookup-reconcile.mjs +1 -1
  36. package/cli/mail-review.mjs +5 -5
  37. package/cli/manual.mjs +1 -1
  38. package/cli/network-command-runner.mjs +4 -1
  39. package/cli/offline-evidence-review.mjs +105 -8
  40. package/cli/open-asset-model-bridge.mjs +169 -0
  41. package/cli/registry-cohort.mjs +121 -0
  42. package/cli/registry-doctor.mjs +106 -11
  43. package/cli/registry-fixture-scaffold.mjs +74 -0
  44. package/cli/runner.mjs +388 -315
  45. package/docs/cli-reference.md +266 -21
  46. package/docs/cli.md +43 -7
  47. package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
  48. package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
  49. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  50. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  51. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  52. package/frontend/src/lib/analysis/case-report.js +573 -0
  53. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  54. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  55. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  56. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  57. package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
  58. package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
  59. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  60. package/frontend/src/lib/analysis/scoring.js +1 -1
  61. package/frontend/src/lib/analysis/utils.js +1 -1
  62. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  63. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  64. package/frontend/src/lib/analysis/workspace-archive.js +5 -7
  65. package/lib/abusech-auth.mjs +1 -1
  66. package/lib/availability.mjs +1 -1
  67. package/lib/bounded-contract-normalizers.mjs +33 -0
  68. package/lib/bounded-file.mjs +69 -0
  69. package/lib/bounded-json-response.mjs +157 -0
  70. package/lib/browser-library-profile.mjs +167 -38
  71. package/lib/dns-convergence-review.mjs +183 -0
  72. package/lib/dns-delegation-health.mjs +196 -3
  73. package/lib/domain-change-packet.mjs +133 -0
  74. package/lib/domain-change-review.mjs +307 -0
  75. package/lib/domain-control-flight-recorder.mjs +268 -0
  76. package/lib/domain-portfolio-review.mjs +151 -0
  77. package/lib/evidence-export.mjs +1 -1
  78. package/lib/html-signals.mjs +5 -11
  79. package/lib/lookup-cache.mjs +5 -1
  80. package/lib/nameserver-preflight-review.mjs +159 -0
  81. package/lib/page-language-signals.mjs +124 -0
  82. package/lib/rdap-transport.mjs +6 -6
  83. package/lib/risk-scoring.mjs +42 -1
  84. package/lib/safe-fetch.mjs +37 -32
  85. package/lib/static-html-analysis.mjs +29 -1
  86. package/lib/threat-intelligence-runtime.mjs +1 -2
  87. package/lib/trust-store-comparison.mjs +146 -0
  88. package/lib/website-technology.mjs +22 -12
  89. package/lib/zone-intent-review.mjs +534 -0
  90. package/package.json +1 -1
@@ -245,6 +245,42 @@ export function evaluateRuleSet(records, rawRules) {
245
245
  return [];
246
246
  return records.slice(0, 500).map((record) => evaluateDetectionRules(record, rawRules));
247
247
  }
248
+ function ruleSignature(rule) {
249
+ return JSON.stringify({
250
+ match: rule.match,
251
+ conditions: [...rule.conditions]
252
+ .map((condition) => ({ ...condition }))
253
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
254
+ });
255
+ }
256
+ export function previewDetectionRule(records, existingRules, rawCandidate) {
257
+ const normalized = normalizeDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
258
+ if (!normalized?.id)
259
+ return null;
260
+ const candidate = { ...normalized, id: normalized.id };
261
+ const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
262
+ const evaluations = evaluateRuleSet(sourceRecords, [candidate]).filter((item) => item.matchedRules.length > 0);
263
+ const recordsById = new Map(sourceRecords.map((item) => [String(record(item).id ?? ''), record(item)]));
264
+ const affectedDispositionCounts = {};
265
+ for (const evaluation of evaluations) {
266
+ const disposition = String(recordsById.get(evaluation.caseId)?.disposition ?? 'unreviewed');
267
+ affectedDispositionCounts[disposition] = (affectedDispositionCounts[disposition] ?? 0) + 1;
268
+ }
269
+ const signature = ruleSignature(candidate);
270
+ const collisionRuleIds = normalizeDetectionRuleStore(existingRules).rules
271
+ .filter((rule) => rule.name.toLowerCase() === candidate.name.toLowerCase() || ruleSignature(rule) === signature)
272
+ .map((rule) => rule.id)
273
+ .slice(0, MAX_DETECTION_RULES);
274
+ return Object.freeze({
275
+ candidate: Object.freeze(candidate),
276
+ matchCount: evaluations.length,
277
+ affectedCaseIds: Object.freeze(evaluations.map((item) => item.caseId).filter(Boolean)),
278
+ affectedDispositionCounts: Object.freeze(affectedDispositionCounts),
279
+ unreviewedMatchCount: affectedDispositionCounts.unreviewed ?? 0,
280
+ collisionRuleIds: Object.freeze(collisionRuleIds),
281
+ limitation: 'This preview evaluates the latest bounded evidence already retained in local cases. It does not save the rule, change a score, refresh evidence, or establish whether a match is correct.',
282
+ });
283
+ }
248
284
  export function mergeDetectionRules(localRaw, importedRaw) {
249
285
  const importedRecord = record(importedRaw);
250
286
  if (typeof importedRecord.schema === 'string' && importedRecord.schema !== DETECTION_RULE_SCHEMA) {
@@ -0,0 +1,320 @@
1
+ import { normalizeDomain, openOrCreateCase, updateCase, } from "./case-model.js";
2
+ export const EXTERNAL_FINDINGS_SCHEMA = 'whoisleuth.external-findings';
3
+ export const EXTERNAL_FINDINGS_VERSION = 3;
4
+ export const MAX_EXTERNAL_FINDINGS_IMPORT_BYTES = 384 * 1024;
5
+ export const MAX_EXTERNAL_FINDINGS = 100;
6
+ export const MAX_EXTERNAL_FINDINGS_PER_DOMAIN = 20;
7
+ export const MAX_EXTERNAL_FINDING_DOMAINS = 25;
8
+ export const EXTERNAL_FINDING_CATEGORIES = [
9
+ 'certificate',
10
+ 'dns',
11
+ 'http',
12
+ 'malware',
13
+ 'other',
14
+ 'page',
15
+ 'registration',
16
+ 'reputation',
17
+ ];
18
+ const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
19
+ const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
20
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
21
+ const ROOT_KEYS = new Set(['schema', 'schemaVersion', 'source', 'findings']);
22
+ const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
23
+ const FINDING_KEYS = new Set([
24
+ 'domain',
25
+ 'category',
26
+ 'evidenceClass',
27
+ 'summary',
28
+ 'observedAt',
29
+ 'completeness',
30
+ 'limitations',
31
+ 'reference',
32
+ 'structuredObservation',
33
+ ]);
34
+ const STRUCTURED_OBSERVATION_KEYS = new Set([
35
+ 'sourceSchema',
36
+ 'sourceVersion',
37
+ 'field',
38
+ 'value',
39
+ 'issuer',
40
+ 'notAfter',
41
+ ]);
42
+ const STRUCTURED_OBSERVATION_SCHEMAS = new Set([
43
+ 'whoisleuth.domain-observation-rows',
44
+ 'whoisleuth.dns-observation-rows',
45
+ 'whoisleuth.certificate-observation-rows',
46
+ ]);
47
+ function record(value) {
48
+ return value && typeof value === 'object' && !Array.isArray(value)
49
+ ? value
50
+ : null;
51
+ }
52
+ function hasOnlyKeys(value, allowed) {
53
+ return Object.keys(value).every((key) => allowed.has(key));
54
+ }
55
+ function requiredText(value, maximum, label) {
56
+ if (typeof value !== 'string'
57
+ || !value.trim()
58
+ || value.length > maximum
59
+ || CONTROL_RE.test(value)) {
60
+ throw new Error(`${label} must be non-empty, bounded text without control characters.`);
61
+ }
62
+ return value.trim();
63
+ }
64
+ function optionalText(value, maximum, label) {
65
+ if (value === undefined || value === null || value === '')
66
+ return null;
67
+ return requiredText(value, maximum, label);
68
+ }
69
+ function iso(value, label, optional = false) {
70
+ if (optional && (value === undefined || value === null || value === ''))
71
+ return null;
72
+ const text = requiredText(value, 64, label);
73
+ const parsed = Date.parse(text);
74
+ if (!Number.isFinite(parsed))
75
+ throw new Error(`${label} must be a valid date and time.`);
76
+ return new Date(parsed).toISOString();
77
+ }
78
+ function limitations(value, index) {
79
+ if (value === undefined)
80
+ return [];
81
+ if (!Array.isArray(value) || value.length > 8) {
82
+ throw new Error(`Finding ${index + 1} limitations must be an array with no more than 8 entries.`);
83
+ }
84
+ const unique = new Set();
85
+ for (const item of value)
86
+ unique.add(requiredText(item, 240, `Finding ${index + 1} limitation`));
87
+ return [...unique];
88
+ }
89
+ function structuredObservation(value, index) {
90
+ if (value === undefined || value === null)
91
+ return null;
92
+ const item = record(value);
93
+ if (!item || !hasOnlyKeys(item, STRUCTURED_OBSERVATION_KEYS)) {
94
+ throw new Error(`Finding ${index + 1} structured observation has an invalid shape or additional fields.`);
95
+ }
96
+ if (typeof item.sourceSchema !== 'string' || !STRUCTURED_OBSERVATION_SCHEMAS.has(item.sourceSchema)) {
97
+ throw new Error(`Finding ${index + 1} structured observation source schema is unsupported.`);
98
+ }
99
+ if (item.sourceVersion !== 1) {
100
+ throw new Error(`Finding ${index + 1} structured observation source version is unsupported.`);
101
+ }
102
+ const field = requiredText(item.field, 40, `Finding ${index + 1} structured observation field`);
103
+ const observationValue = requiredText(item.value, 500, `Finding ${index + 1} structured observation value`);
104
+ const issuer = optionalText(item.issuer, 160, `Finding ${index + 1} certificate issuer`);
105
+ const notAfter = iso(item.notAfter, `Finding ${index + 1} certificate expiry`, true);
106
+ const certificateSchema = item.sourceSchema === 'whoisleuth.certificate-observation-rows';
107
+ if (!certificateSchema && (issuer !== null || notAfter !== null)) {
108
+ throw new Error(`Finding ${index + 1} non-certificate observation cannot declare certificate metadata.`);
109
+ }
110
+ return {
111
+ sourceSchema: item.sourceSchema,
112
+ sourceVersion: 1,
113
+ field,
114
+ value: observationValue,
115
+ issuer,
116
+ notAfter,
117
+ };
118
+ }
119
+ function findingKey(finding, sourceName) {
120
+ return [
121
+ finding.domain,
122
+ finding.category,
123
+ finding.evidenceClass,
124
+ finding.summary,
125
+ finding.observedAt,
126
+ finding.completeness,
127
+ sourceName,
128
+ ].join('\u0000');
129
+ }
130
+ export function parseExternalFindingsDocument(value) {
131
+ const root = record(value);
132
+ if (!root || !hasOnlyKeys(root, ROOT_KEYS)) {
133
+ throw new Error('External findings must use the documented object shape without additional top-level fields.');
134
+ }
135
+ if (root.schema !== EXTERNAL_FINDINGS_SCHEMA
136
+ || ![1, 2, EXTERNAL_FINDINGS_VERSION].includes(Number(root.schemaVersion))) {
137
+ throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 1, 2, or ${EXTERNAL_FINDINGS_VERSION}.`);
138
+ }
139
+ const sourceValue = record(root.source);
140
+ if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
141
+ throw new Error('External findings require a bounded source object without additional fields.');
142
+ }
143
+ const source = {
144
+ name: requiredText(sourceValue.name, 80, 'Source name'),
145
+ reference: optionalText(sourceValue.reference, 500, 'Source reference'),
146
+ collectedAt: iso(sourceValue.collectedAt, 'Source collection time', true),
147
+ };
148
+ if (!Array.isArray(root.findings) || !root.findings.length || root.findings.length > MAX_EXTERNAL_FINDINGS) {
149
+ throw new Error(`External findings must contain between 1 and ${MAX_EXTERNAL_FINDINGS} findings.`);
150
+ }
151
+ const normalized = [];
152
+ const seen = new Set();
153
+ const domainCounts = new Map();
154
+ for (const [index, raw] of root.findings.entries()) {
155
+ const item = record(raw);
156
+ if (!item || !hasOnlyKeys(item, FINDING_KEYS)) {
157
+ throw new Error(`Finding ${index + 1} has an invalid shape or additional fields.`);
158
+ }
159
+ const domainInput = requiredText(item.domain, 253, `Finding ${index + 1} domain`);
160
+ const domain = normalizeDomain(domainInput);
161
+ if (!domain)
162
+ throw new Error(`Finding ${index + 1} domain is invalid.`);
163
+ if (typeof item.category !== 'string' || !CATEGORIES.has(item.category)) {
164
+ throw new Error(`Finding ${index + 1} category is unsupported.`);
165
+ }
166
+ if (typeof item.completeness !== 'string' || !COMPLETENESS.has(item.completeness)) {
167
+ throw new Error(`Finding ${index + 1} completeness is unsupported.`);
168
+ }
169
+ const evidenceClass = item.evidenceClass === undefined && root.schemaVersion === 1
170
+ ? 'provider_report'
171
+ : item.evidenceClass;
172
+ if (evidenceClass !== 'deployment_observation' && evidenceClass !== 'provider_report') {
173
+ throw new Error(`Finding ${index + 1} evidence class is unsupported.`);
174
+ }
175
+ const finding = {
176
+ domain,
177
+ category: item.category,
178
+ evidenceClass,
179
+ summary: requiredText(item.summary, 900, `Finding ${index + 1} summary`),
180
+ observedAt: iso(item.observedAt, `Finding ${index + 1} observation time`),
181
+ completeness: item.completeness,
182
+ limitations: limitations(item.limitations, index),
183
+ reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
184
+ structuredObservation: structuredObservation(item.structuredObservation, index),
185
+ };
186
+ const key = findingKey(finding, source.name);
187
+ if (seen.has(key))
188
+ continue;
189
+ seen.add(key);
190
+ const count = (domainCounts.get(domain) ?? 0) + 1;
191
+ if (count > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
192
+ throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-finding per-domain limit.`);
193
+ }
194
+ domainCounts.set(domain, count);
195
+ if (domainCounts.size > MAX_EXTERNAL_FINDING_DOMAINS) {
196
+ throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
197
+ }
198
+ normalized.push(finding);
199
+ }
200
+ if (!normalized.length)
201
+ throw new Error('External findings did not contain a unique valid finding.');
202
+ return {
203
+ schema: EXTERNAL_FINDINGS_SCHEMA,
204
+ schemaVersion: EXTERNAL_FINDINGS_VERSION,
205
+ source,
206
+ findings: normalized,
207
+ };
208
+ }
209
+ function pinValue(finding) {
210
+ return finding.reference
211
+ ? `${finding.summary} Reference: ${finding.reference}`
212
+ : finding.summary;
213
+ }
214
+ function importedSourceLabel(finding, sourceName) {
215
+ return finding.evidenceClass === 'deployment_observation'
216
+ ? `Deployment observation: ${sourceName}`
217
+ : `Provider report: ${sourceName}`;
218
+ }
219
+ function existingPinKey(recordValue, finding, sourceName) {
220
+ const expectedLabel = `External ${finding.category} finding`;
221
+ const expectedValue = finding.structuredObservation?.value ?? pinValue(finding);
222
+ const expectedSource = importedSourceLabel(finding, sourceName);
223
+ const observation = finding.structuredObservation;
224
+ return recordValue.evidencePins.some((pin) => (pin.label === expectedLabel
225
+ && pin.value === expectedValue
226
+ && pin.source === expectedSource
227
+ && pin.observedAt === finding.observedAt
228
+ && pin.completeness === finding.completeness
229
+ && (!observation || (pin.field === observation.field
230
+ && pin.sourceSchema?.collection === 'external_observations'
231
+ && pin.sourceSchema.schema === observation.sourceSchema
232
+ && pin.sourceSchema.version === observation.sourceVersion)))) ? findingKey(finding, sourceName) : '';
233
+ }
234
+ function structuredPinFields(finding) {
235
+ const observation = finding.structuredObservation;
236
+ if (!observation)
237
+ return {};
238
+ return {
239
+ field: observation.field,
240
+ category: finding.category,
241
+ sourceSchema: {
242
+ collection: 'external_observations',
243
+ schema: observation.sourceSchema,
244
+ version: observation.sourceVersion,
245
+ },
246
+ };
247
+ }
248
+ export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
249
+ let cases = [...current];
250
+ const createdDomains = new Set();
251
+ const updatedDomains = new Set();
252
+ let findingsAdded = 0;
253
+ let duplicatesSkipped = 0;
254
+ for (const finding of document.findings) {
255
+ const existing = cases.find((candidate) => candidate.domain === finding.domain) ?? null;
256
+ const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
257
+ cases = opened.cases;
258
+ const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
259
+ if (existingPinKey(target, finding, document.source.name)) {
260
+ duplicatesSkipped += 1;
261
+ continue;
262
+ }
263
+ const sourceLimitations = [
264
+ finding.evidenceClass === 'deployment_observation'
265
+ ? `Imported as an observation made by ${document.source.name}; this browser session did not collect or independently verify it.`
266
+ : `Reported by ${document.source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
267
+ ...(document.source.reference ? [`Source reference: ${document.source.reference}`] : []),
268
+ ...finding.limitations,
269
+ ];
270
+ const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
271
+ const updated = updateCase(cases, target.id, {
272
+ evidencePin: {
273
+ ...structuredPinFields(finding),
274
+ label: `External ${finding.category} finding`,
275
+ value: finding.structuredObservation?.value ?? pinValue(finding),
276
+ source: importedSourceLabel(finding, document.source.name),
277
+ observedAt: finding.observedAt,
278
+ completeness: finding.completeness,
279
+ limitations: sourceLimitations,
280
+ },
281
+ }, now);
282
+ cases = updated.cases;
283
+ const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
284
+ if (addedPin) {
285
+ cases = updateCase(cases, updated.record.id, {
286
+ sighting: {
287
+ state: finding.evidenceClass === 'deployment_observation'
288
+ ? 'observed_by_deployment'
289
+ : 'reported_by_provider',
290
+ category: finding.category === 'dns'
291
+ ? 'delegation'
292
+ : finding.category === 'certificate'
293
+ ? 'certificate'
294
+ : finding.category === 'registration'
295
+ ? 'registration'
296
+ : finding.category === 'http' || finding.category === 'page'
297
+ ? 'website'
298
+ : 'other',
299
+ source: document.source.name,
300
+ observedAt: finding.observedAt,
301
+ completeness: finding.completeness,
302
+ evidencePinId: addedPin.id,
303
+ limitations: sourceLimitations,
304
+ },
305
+ }, now).cases;
306
+ }
307
+ findingsAdded += 1;
308
+ if (existing)
309
+ updatedDomains.add(finding.domain);
310
+ else
311
+ createdDomains.add(finding.domain);
312
+ }
313
+ return {
314
+ cases,
315
+ casesCreated: createdDomains.size,
316
+ casesUpdated: updatedDomains.size,
317
+ findingsAdded,
318
+ duplicatesSkipped,
319
+ };
320
+ }
@@ -372,7 +372,7 @@ export function buildLookupAssetGraph(input) {
372
372
  completeness: sourceCompleteness(networkContext),
373
373
  limitations: limitations(networkContext.limitations),
374
374
  lenses: ['all'],
375
- href: '#evidence-network-context',
375
+ href: '#evidence-network',
376
376
  });
377
377
  }
378
378
  const networkLabel = text(network.name || network.holder || network.handle, 180);
@@ -390,7 +390,7 @@ export function buildLookupAssetGraph(input) {
390
390
  completeness: sourceCompleteness(networkContext),
391
391
  limitations: limitations(networkContext.limitations),
392
392
  lenses: ['all'],
393
- href: '#evidence-network-context',
393
+ href: '#evidence-network',
394
394
  });
395
395
  }
396
396
  for (const cidr of textList(network.cidrs, 8)) {
@@ -407,7 +407,7 @@ export function buildLookupAssetGraph(input) {
407
407
  completeness: sourceCompleteness(networkContext),
408
408
  limitations: limitations(networkContext.limitations),
409
409
  lenses: ['all'],
410
- href: '#evidence-network-context',
410
+ href: '#evidence-network',
411
411
  });
412
412
  }
413
413
  }
@@ -437,7 +437,7 @@ export function buildLookupAssetGraph(input) {
437
437
  completeness: sourceCompleteness(input.pageIdentity),
438
438
  limitations: limitations(record(input.pageIdentity).limitations),
439
439
  lenses: ['identity'],
440
- href: '#evidence-page-identity',
440
+ href: '#evidence-page',
441
441
  boundary: trustBoundary(canonicalHost, finalHost || target),
442
442
  }, 'Publisher-declared canonical origin', identitySource);
443
443
  }
@@ -451,7 +451,7 @@ export function buildLookupAssetGraph(input) {
451
451
  completeness: sourceCompleteness(input.pageIdentity),
452
452
  limitations: limitations(record(input.pageIdentity).limitations),
453
453
  lenses: ['identity'],
454
- href: '#evidence-page-identity',
454
+ href: '#evidence-page',
455
455
  boundary: trustBoundary(openGraphHost, finalHost || target),
456
456
  }, 'Publisher-declared Open Graph origin', identitySource);
457
457
  }
@@ -466,7 +466,7 @@ export function buildLookupAssetGraph(input) {
466
466
  completeness: sourceCompleteness(input.pageIdentity),
467
467
  limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
468
468
  lenses: ['identity'],
469
- href: '#evidence-page-identity',
469
+ href: '#evidence-page',
470
470
  boundary: trustBoundary(host, finalHost || target),
471
471
  }, 'External form-action origin', identitySource);
472
472
  }
@@ -482,7 +482,7 @@ export function buildLookupAssetGraph(input) {
482
482
  completeness: sourceCompleteness(input.pageIdentity),
483
483
  limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
484
484
  lenses: ['identity'],
485
- href: '#evidence-page-identity',
485
+ href: '#evidence-page',
486
486
  boundary: trustBoundary(host, finalHost || target),
487
487
  }, 'External resource origin', identitySource);
488
488
  }
@@ -498,7 +498,7 @@ export function buildLookupAssetGraph(input) {
498
498
  completeness: sourceCompleteness(input.pageIdentity),
499
499
  limitations: ['A shared identifier is a relationship lead and does not establish common ownership or control.'],
500
500
  lenses: ['identity'],
501
- href: '#evidence-page-identity',
501
+ href: '#evidence-page',
502
502
  }, text(identifier.type, 80), identitySource);
503
503
  }
504
504
  }
@@ -275,7 +275,7 @@ function identityEntries(input) {
275
275
  title: 'The declared canonical origin differs from the observed final origin',
276
276
  detail: `${finalHost} served the page while the document declared ${canonicalHost} as canonical.`,
277
277
  sources: ['HTTP', 'HTML'],
278
- href: '#evidence-page-identity',
278
+ href: '#evidence-page',
279
279
  });
280
280
  }
281
281
  if (finalHost && openGraphHost && !sameRegistrableScope(finalHost, openGraphHost, registrableDomain)) {
@@ -286,7 +286,7 @@ function identityEntries(input) {
286
286
  title: 'The Open Graph URL points outside the observed final origin',
287
287
  detail: `${finalHost} served the page while Open Graph metadata declared ${openGraphHost}. Publisher metadata is a claim, not identity proof.`,
288
288
  sources: ['HTTP', 'HTML'],
289
- href: '#evidence-page-identity',
289
+ href: '#evidence-page',
290
290
  });
291
291
  }
292
292
  return output;
@@ -357,6 +357,7 @@ export function buildLookupDecisionSupport(input) {
357
357
  id: 'review-priority-conflict',
358
358
  label: 'Review the highest-priority disagreement',
359
359
  reason: firstConflict.title,
360
+ expectedOutcome: 'Establish which separately attributed observation is current, authoritative, or still unresolved.',
360
361
  href: firstConflict.href,
361
362
  priority: 'high',
362
363
  });
@@ -366,6 +367,7 @@ export function buildLookupDecisionSupport(input) {
366
367
  id: 'review-refresh-options',
367
368
  label: 'Review limited or stale sources',
368
369
  reason: `${input.refreshPlan.items.length} deliberate source refresh option${input.refreshPlan.items.length === 1 ? ' is' : 's are'} available.`,
370
+ expectedOutcome: 'Determine whether the limitation is transient, persistent, or an expected source constraint.',
369
371
  href: '#evidence-quality',
370
372
  priority: 'medium',
371
373
  });
@@ -376,6 +378,7 @@ export function buildLookupDecisionSupport(input) {
376
378
  id: 'inspect-limited-source',
377
379
  label: `Inspect ${firstLimited.label}`,
378
380
  reason: `${firstLimited.statusLabel} evidence may limit downstream conclusions.`,
381
+ expectedOutcome: `Clarify which conclusions ${firstLimited.label} can and cannot support in its current state.`,
379
382
  href: '#evidence-quality',
380
383
  priority: 'medium',
381
384
  });
@@ -385,6 +388,7 @@ export function buildLookupDecisionSupport(input) {
385
388
  id: 'review-page-identity',
386
389
  label: 'Review identity and credential evidence',
387
390
  reason: 'Compare declared identity, forms, redirects, and external destinations before making a brand assessment.',
391
+ expectedOutcome: 'Separate observed identity signals from publisher claims, shared services, and unresolved resemblance leads.',
388
392
  href: '#web-evidence',
389
393
  priority: 'medium',
390
394
  });
@@ -394,6 +398,7 @@ export function buildLookupDecisionSupport(input) {
394
398
  id: 'review-acquisition-dependencies',
395
399
  label: 'Review transfer dependencies',
396
400
  reason: 'Registration dates alone do not describe the DNS, mail, certificate, and website services that require transition.',
401
+ expectedOutcome: 'Identify dependencies that must be retained, transferred, replaced, or deliberately retired.',
397
402
  href: '#web-evidence',
398
403
  priority: 'medium',
399
404
  });
@@ -403,6 +408,7 @@ export function buildLookupDecisionSupport(input) {
403
408
  id: 'review-owned-posture',
404
409
  label: 'Review delegation and posture evidence',
405
410
  reason: 'Confirm registry publication, authoritative responses, mail, and certificate state together.',
411
+ expectedOutcome: 'Identify whether observed control state aligns with the reviewed owned-domain baseline.',
406
412
  href: '#web-evidence',
407
413
  priority: 'medium',
408
414
  });
@@ -412,6 +418,7 @@ export function buildLookupDecisionSupport(input) {
412
418
  id: 'review-case-handoff',
413
419
  label: 'Record or hand off reviewed evidence',
414
420
  reason: 'Keep observed facts, hypotheses, unknowns, and analyst decisions separate in the case workflow.',
421
+ expectedOutcome: 'Preserve a reviewable record of what is known, inferred, unresolved, and recommended next.',
415
422
  href: '#case-response',
416
423
  priority: 'low',
417
424
  });
@@ -1,9 +1,5 @@
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: 'Summary', detail: 'Overview and source-state ledger, with detailed sections reduced to headings.' }),
4
- Object.freeze({ id: 'standard', label: 'Standard', detail: 'Evidence sections remain available while the raw response stays collapsed until you choose to inspect it.' }),
5
- Object.freeze({ id: 'full', label: 'Full', detail: 'Every settled evidence section, including the bounded raw response view.' }),
6
- ]);
2
+ export const MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES = 1_024;
7
3
  export const LOOKUP_TASK_VIEWS = Object.freeze([
8
4
  Object.freeze({ id: 'general', label: 'General investigation' }),
9
5
  Object.freeze({ id: 'acquisition', label: 'Acquisition review' }),
@@ -11,13 +7,7 @@ export const LOOKUP_TASK_VIEWS = Object.freeze([
11
7
  Object.freeze({ id: 'incident', label: 'Incident response' }),
12
8
  Object.freeze({ id: 'owned', label: 'Owned-domain posture' }),
13
9
  ]);
14
- const DENSITIES = new Set(LOOKUP_EVIDENCE_DENSITIES.map((option) => option.id));
15
10
  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
- : 'standard';
20
- }
21
11
  export function normalizeLookupTaskView(value) {
22
12
  return typeof value === 'string' && TASKS.has(value)
23
13
  ? value
@@ -25,10 +15,15 @@ export function normalizeLookupTaskView(value) {
25
15
  }
26
16
  export function readLookupPresentation(storage) {
27
17
  try {
28
- const stored = JSON.parse(storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY) || 'null');
18
+ const serialized = storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY);
19
+ if (serialized === null
20
+ || serialized.length > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES
21
+ || new TextEncoder().encode(serialized).byteLength > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES) {
22
+ return { task: 'general' };
23
+ }
24
+ const stored = JSON.parse(serialized);
29
25
  if (stored?.version === 1) {
30
26
  return {
31
- density: normalizeLookupEvidenceDensity(stored.density),
32
27
  task: normalizeLookupTaskView(stored.task),
33
28
  };
34
29
  }
@@ -36,13 +31,12 @@ export function readLookupPresentation(storage) {
36
31
  catch {
37
32
  // Invalid or unavailable browser storage falls back to the stable defaults.
38
33
  }
39
- return { density: 'standard', task: 'general' };
34
+ return { task: 'general' };
40
35
  }
41
36
  export function writeLookupPresentation(storage, state) {
42
37
  try {
43
38
  storage.setItem(LOOKUP_PRESENTATION_STORAGE_KEY, JSON.stringify({
44
39
  version: 1,
45
- density: normalizeLookupEvidenceDensity(state.density),
46
40
  task: normalizeLookupTaskView(state.task),
47
41
  }));
48
42
  }
@@ -51,22 +45,12 @@ export function writeLookupPresentation(storage, state) {
51
45
  }
52
46
  }
53
47
  const PRIORITY = Object.freeze({
54
- general: Object.freeze(['overview', 'web-evidence', 'registry', 'external-intelligence', 'case-response', 'raw-data']),
55
- acquisition: Object.freeze(['overview', 'registry', 'web-evidence', 'case-response', 'external-intelligence', 'raw-data']),
56
- brand: Object.freeze(['overview', 'web-evidence', 'external-intelligence', 'registry', 'case-response', 'raw-data']),
57
- incident: Object.freeze(['overview', 'external-intelligence', 'web-evidence', 'registry', 'case-response', 'raw-data']),
58
- owned: Object.freeze(['overview', 'registry', 'web-evidence', 'case-response', 'external-intelligence', 'raw-data']),
48
+ general: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
49
+ acquisition: Object.freeze(['overview', 'registry', 'relationships-history', 'web-evidence', 'source-quality', 'case-response', 'advanced-evidence']),
50
+ brand: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'registry', 'source-quality', 'case-response', 'advanced-evidence']),
51
+ incident: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'advanced-evidence', 'registry', 'source-quality', 'case-response']),
52
+ owned: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
59
53
  });
60
- const INITIAL_PANELS = {
61
- general: [],
62
- acquisition: ['dns', 'network-context', 'tls'],
63
- brand: ['http', 'page-identity', 'credential-surface', 'structured-identity', 'technology'],
64
- incident: ['http', 'tls', 'credential-surface', 'security-posture'],
65
- owned: ['dns', 'network-context', 'tls', 'security-posture'],
66
- };
67
- export function lookupTaskInitiallyExpands(task, panel) {
68
- return INITIAL_PANELS[normalizeLookupTaskView(task)].includes(panel);
69
- }
70
54
  export function prioritizeLookupSectionLinks(links, task) {
71
55
  const order = PRIORITY[normalizeLookupTaskView(task)];
72
56
  const rank = new Map(order.map((id, index) => [`#${id}`, index]));