@slicedearth/whoisleuth-cli 1.39.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +15 -0
  2. package/bin/whoisleuth.mjs +13 -2
  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 +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/completion.mjs +33 -10
  13. package/cli/config-profile.mjs +180 -0
  14. package/cli/ct-event-intake.mjs +159 -0
  15. package/cli/discovery-scan-command-runner.mjs +15 -1
  16. package/cli/domain-control-monitor.mjs +104 -0
  17. package/cli/domain-control-observations.mjs +235 -0
  18. package/cli/evidence-command-arguments.mjs +11 -0
  19. package/cli/evidence-command-runner.mjs +1 -0
  20. package/cli/external-observation-mapping.mjs +162 -0
  21. package/cli/fail-policy.mjs +64 -0
  22. package/cli/formatters/html.mjs +1 -1
  23. package/cli/investigation-manifest.mjs +113 -0
  24. package/cli/investigation-run.mjs +112 -0
  25. package/cli/lookup-brief.mjs +116 -0
  26. package/cli/lookup-command-runner.mjs +8 -1
  27. package/cli/lookup-diff.mjs +17 -19
  28. package/cli/network-command-runner.mjs +4 -1
  29. package/cli/offline-evidence-review.mjs +105 -8
  30. package/cli/open-asset-model-bridge.mjs +169 -0
  31. package/cli/registry-cohort.mjs +121 -0
  32. package/cli/registry-doctor.mjs +106 -11
  33. package/cli/registry-fixture-scaffold.mjs +73 -0
  34. package/cli/runner.mjs +368 -306
  35. package/docs/cli-reference.md +240 -14
  36. package/docs/cli.md +42 -7
  37. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  38. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  39. package/frontend/src/lib/analysis/case-report.js +573 -0
  40. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  41. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  42. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  43. package/frontend/src/lib/analysis/lookup-decision-support.js +7 -0
  44. package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
  45. package/frontend/src/lib/analysis/scoring.js +1 -1
  46. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  47. package/frontend/src/lib/analysis/workspace-archive.js +2 -0
  48. package/lib/abusech-auth.mjs +1 -1
  49. package/lib/availability.mjs +1 -1
  50. package/lib/bounded-contract-normalizers.mjs +33 -0
  51. package/lib/dns-convergence-review.mjs +183 -0
  52. package/lib/dns-delegation-health.mjs +196 -3
  53. package/lib/domain-change-packet.mjs +133 -0
  54. package/lib/domain-change-review.mjs +307 -0
  55. package/lib/domain-control-flight-recorder.mjs +268 -0
  56. package/lib/domain-portfolio-review.mjs +151 -0
  57. package/lib/evidence-export.mjs +1 -1
  58. package/lib/html-signals.mjs +5 -11
  59. package/lib/nameserver-preflight-review.mjs +159 -0
  60. package/lib/page-language-signals.mjs +124 -0
  61. package/lib/risk-scoring.mjs +42 -1
  62. package/lib/static-html-analysis.mjs +29 -1
  63. package/lib/trust-store-comparison.mjs +146 -0
  64. package/lib/website-technology.mjs +24 -10
  65. package/lib/zone-intent-review.mjs +534 -0
  66. 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
+ }
@@ -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,8 +1,8 @@
1
1
  export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
2
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.' }),
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
6
  ]);
7
7
  export const LOOKUP_TASK_VIEWS = Object.freeze([
8
8
  Object.freeze({ id: 'general', label: 'General investigation' }),
@@ -16,7 +16,7 @@ const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
16
16
  export function normalizeLookupEvidenceDensity(value) {
17
17
  return typeof value === 'string' && DENSITIES.has(value)
18
18
  ? value
19
- : 'standard';
19
+ : 'summary';
20
20
  }
21
21
  export function normalizeLookupTaskView(value) {
22
22
  return typeof value === 'string' && TASKS.has(value)
@@ -36,7 +36,7 @@ export function readLookupPresentation(storage) {
36
36
  catch {
37
37
  // Invalid or unavailable browser storage falls back to the stable defaults.
38
38
  }
39
- return { density: 'standard', task: 'general' };
39
+ return { density: 'summary', task: 'general' };
40
40
  }
41
41
  export function writeLookupPresentation(storage, state) {
42
42
  try {
@@ -51,22 +51,12 @@ export function writeLookupPresentation(storage, state) {
51
51
  }
52
52
  }
53
53
  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']),
54
+ general: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
55
+ acquisition: Object.freeze(['overview', 'registry', 'relationships-history', 'web-evidence', 'source-quality', 'case-response', 'advanced-evidence']),
56
+ brand: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'registry', 'source-quality', 'case-response', 'advanced-evidence']),
57
+ incident: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'advanced-evidence', 'registry', 'source-quality', 'case-response']),
58
+ owned: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
59
59
  });
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
60
  export function prioritizeLookupSectionLinks(links, task) {
71
61
  const order = PRIORITY[normalizeLookupTaskView(task)];
72
62
  const rank = new Map(order.map((id, index) => [`#${id}`, index]));
@@ -1,4 +1,4 @@
1
- export { RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD, computeRiskScore, explainRiskScore, formatScoreBreakdown, normalizeRiskModelVersion, riskTone, } from "../../../../lib/risk-scoring.mjs";
1
+ export { RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD, buildRiskScoreSensitivity, computeRiskScore, explainRiskScore, formatScoreBreakdown, normalizeRiskModelVersion, riskTone, } from "../../../../lib/risk-scoring.mjs";
2
2
  export { OPPORTUNITY_MODEL_VERSION, OPPORTUNITY_REVIEW_THRESHOLD, computeOpportunityScore, explainOpportunityScore, normalizeOpportunityModelVersion, opportunityTone, } from "../../../../lib/opportunity-scoring.mjs";
3
3
  export function fmtAge(value) {
4
4
  if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
@@ -214,8 +214,7 @@ export function projectScoreFactors(rawFactors) {
214
214
  id: `${boundedId(factor?.label) || 'factor'}-${index}`,
215
215
  label: boundedText(factor?.label, 56) || `Factor ${index + 1}`,
216
216
  delta: boundedNumber(factor?.delta, -100, 100),
217
- }))
218
- .filter((factor) => factor.delta !== 0);
217
+ }));
219
218
  const factors = candidates.slice(0, MAX_SCORE_FACTORS);
220
219
  const maximum = Math.max(1, ...factors.map((factor) => Math.abs(factor.delta)));
221
220
  const x = scaleLinear().domain([-maximum, maximum]).range([220, 860]).clamp(true);
@@ -231,7 +230,7 @@ export function projectScoreFactors(rawFactors) {
231
230
  return {
232
231
  ...factor,
233
232
  x: Math.min(zeroX, valueX),
234
- width: Math.max(2, Math.abs(valueX - zeroX)),
233
+ width: factor.delta === 0 ? 0 : Math.max(2, Math.abs(valueX - zeroX)),
235
234
  y: 24 + index * rowHeight,
236
235
  };
237
236
  }),
@@ -340,6 +340,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
340
340
  }
341
341
  throw new Error(`Expected workspace archive schema 1, 2, 3, 4, or ${WORKSPACE_ARCHIVE_VERSION}.`);
342
342
  }
343
+ const sourceVersion = value.version;
343
344
  const { bytes } = ensureArchiveBudget(value);
344
345
  const manifest = record(value.manifest);
345
346
  const sectionValues = record(value.sections);
@@ -394,6 +395,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
394
395
  return {
395
396
  schema: WORKSPACE_ARCHIVE_SCHEMA,
396
397
  version: WORKSPACE_ARCHIVE_VERSION,
398
+ sourceVersion,
397
399
  generatedAt: timestamp(value.generatedAt),
398
400
  bytes,
399
401
  sections,
@@ -16,4 +16,4 @@ function abusechAuthKey(env = process.env) {
16
16
  return normalizeAbusechAuthKey(source.ABUSECH_AUTH_KEY)
17
17
  || normalizeAbusechAuthKey(source.URLHAUS_AUTH_KEY);
18
18
  }
19
- export { MAX_ABUSECH_AUTH_KEY_LENGTH, normalizeAbusechAuthKey, abusechAuthKey, };
19
+ export { abusechAuthKey, };
@@ -237,7 +237,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed } = {}) {
237
237
  technologyHeaders: {
238
238
  'cf-ray': res.headers.get('cf-ray') || '',
239
239
  'x-drupal-cache': res.headers.get('x-drupal-cache') || '',
240
- 'x-fastly-request-id': res.headers.get('x-fastly-request-id') || '',
240
+ 'x-served-by': res.headers.get('x-served-by') || '',
241
241
  'x-powered-by': res.headers.get('x-powered-by') || '',
242
242
  'x-shopify-stage': res.headers.get('x-shopify-stage') || '',
243
243
  'x-sorting-hat-podid': res.headers.get('x-sorting-hat-podid') || '',
@@ -1,9 +1,16 @@
1
1
  // Shared strict primitives for internal evidence contracts. These helpers do
2
2
  // not infer missing values and keep every string, object, enum, and counter
3
3
  // bounded before a provider or connector result reaches its public model.
4
+ import { domainToASCII } from 'node:url';
4
5
  export function isRecord(value) {
5
6
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6
7
  }
8
+ export function recordOrNull(value) {
9
+ return isRecord(value) ? value : null;
10
+ }
11
+ export function recordOrEmpty(value) {
12
+ return isRecord(value) ? value : {};
13
+ }
7
14
  export function boundedString(value, maxLength) {
8
15
  if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
9
16
  return null;
@@ -59,3 +66,29 @@ export function boundedInteger(value, minimum, maximum, label) {
59
66
  }
60
67
  return value;
61
68
  }
69
+ export function requireRecord(value, label) {
70
+ if (!isRecord(value))
71
+ throw new TypeError(`${label} must be an object.`);
72
+ return value;
73
+ }
74
+ export function requireBoundedString(value, label, maxLength) {
75
+ const normalised = strictBoundedString(value, maxLength);
76
+ if (!normalised) {
77
+ throw new TypeError(`${label} must contain from 1 to ${maxLength} characters without control characters.`);
78
+ }
79
+ return normalised;
80
+ }
81
+ export function requireIsoTimestamp(value, label) {
82
+ const normalised = isoTimestamp(value);
83
+ if (!normalised)
84
+ throw new TypeError(`${label} must be a valid timestamp.`);
85
+ return normalised;
86
+ }
87
+ export function requireDomainName(value, label, noun = 'domain name') {
88
+ const supplied = requireBoundedString(value, label, 253).toLowerCase().replace(/\.$/u, '');
89
+ const ascii = domainToASCII(supplied);
90
+ if (!ascii || !ascii.includes('.') || ascii.length > 253 || ascii.split('.').some((part) => (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part)))) {
91
+ throw new TypeError(`${label} must be a valid ${noun}.`);
92
+ }
93
+ return ascii;
94
+ }