@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,6 +1,6 @@
1
1
  import { normalizeDomain, openOrCreateCase, updateCase, } from "./case-model.js";
2
2
  export const EXTERNAL_FINDINGS_SCHEMA = 'whoisleuth.external-findings';
3
- export const EXTERNAL_FINDINGS_VERSION = 3;
3
+ export const EXTERNAL_FINDINGS_VERSION = 4;
4
4
  export const MAX_EXTERNAL_FINDINGS_IMPORT_BYTES = 384 * 1024;
5
5
  export const MAX_EXTERNAL_FINDINGS = 100;
6
6
  export const MAX_EXTERNAL_FINDINGS_PER_DOMAIN = 20;
@@ -38,12 +38,18 @@ const STRUCTURED_OBSERVATION_KEYS = new Set([
38
38
  'value',
39
39
  'issuer',
40
40
  'notAfter',
41
+ 'eventId',
42
+ 'logId',
43
+ 'certificateSha256',
44
+ 'dnsNameCount',
45
+ 'namesComplete',
41
46
  ]);
42
47
  const STRUCTURED_OBSERVATION_SCHEMAS = new Set([
43
48
  'whoisleuth.domain-observation-rows',
44
49
  'whoisleuth.dns-observation-rows',
45
50
  'whoisleuth.certificate-observation-rows',
46
51
  ]);
52
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
47
53
  function record(value) {
48
54
  return value && typeof value === 'object' && !Array.isArray(value)
49
55
  ? value
@@ -86,7 +92,7 @@ function limitations(value, index) {
86
92
  unique.add(requiredText(item, 240, `Finding ${index + 1} limitation`));
87
93
  return [...unique];
88
94
  }
89
- function structuredObservation(value, index) {
95
+ function structuredObservation(value, index, documentVersion) {
90
96
  if (value === undefined || value === null)
91
97
  return null;
92
98
  const item = record(value);
@@ -104,9 +110,44 @@ function structuredObservation(value, index) {
104
110
  const issuer = optionalText(item.issuer, 160, `Finding ${index + 1} certificate issuer`);
105
111
  const notAfter = iso(item.notAfter, `Finding ${index + 1} certificate expiry`, true);
106
112
  const certificateSchema = item.sourceSchema === 'whoisleuth.certificate-observation-rows';
113
+ if (certificateSchema && (field === 'certificateSha256' || field === 'fingerprintSha256') && !SHA256_RE.test(observationValue.toLowerCase())) {
114
+ throw new Error(`Finding ${index + 1} certificate observation value must be a SHA-256 hexadecimal digest.`);
115
+ }
107
116
  if (!certificateSchema && (issuer !== null || notAfter !== null)) {
108
117
  throw new Error(`Finding ${index + 1} non-certificate observation cannot declare certificate metadata.`);
109
118
  }
119
+ const declaresEventMetadata = ['eventId', 'logId', 'certificateSha256', 'dnsNameCount', 'namesComplete']
120
+ .some((key) => item[key] !== undefined && item[key] !== null);
121
+ let eventId = null;
122
+ let logId = null;
123
+ let certificateSha256 = null;
124
+ let dnsNameCount = null;
125
+ let namesComplete = null;
126
+ if (declaresEventMetadata) {
127
+ if (!certificateSchema || documentVersion < 4) {
128
+ throw new Error(`Finding ${index + 1} certificate-event metadata requires external-findings schema version 4.`);
129
+ }
130
+ eventId = requiredText(item.eventId, 64, `Finding ${index + 1} certificate event id`);
131
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(eventId)) {
132
+ throw new Error(`Finding ${index + 1} certificate event id is invalid.`);
133
+ }
134
+ logId = requiredText(item.logId, 200, `Finding ${index + 1} certificate log id`);
135
+ certificateSha256 = requiredText(item.certificateSha256, 64, `Finding ${index + 1} certificate digest`).toLowerCase();
136
+ if (!SHA256_RE.test(certificateSha256)) {
137
+ throw new Error(`Finding ${index + 1} certificate event digest must be a SHA-256 hexadecimal digest.`);
138
+ }
139
+ if (certificateSha256 !== observationValue.toLowerCase()) {
140
+ throw new Error(`Finding ${index + 1} certificate event digest must match the structured observation value.`);
141
+ }
142
+ if (!Number.isSafeInteger(item.dnsNameCount) || Number(item.dnsNameCount) < 1 || Number(item.dnsNameCount) > 100) {
143
+ throw new Error(`Finding ${index + 1} certificate DNS name count is invalid.`);
144
+ }
145
+ dnsNameCount = Number(item.dnsNameCount);
146
+ if (typeof item.namesComplete !== 'boolean') {
147
+ throw new Error(`Finding ${index + 1} certificate name completeness is invalid.`);
148
+ }
149
+ namesComplete = item.namesComplete;
150
+ }
110
151
  return {
111
152
  sourceSchema: item.sourceSchema,
112
153
  sourceVersion: 1,
@@ -114,6 +155,11 @@ function structuredObservation(value, index) {
114
155
  value: observationValue,
115
156
  issuer,
116
157
  notAfter,
158
+ eventId,
159
+ logId,
160
+ certificateSha256,
161
+ dnsNameCount,
162
+ namesComplete,
117
163
  };
118
164
  }
119
165
  function findingKey(finding, sourceName) {
@@ -125,6 +171,7 @@ function findingKey(finding, sourceName) {
125
171
  finding.observedAt,
126
172
  finding.completeness,
127
173
  sourceName,
174
+ finding.structuredObservation?.eventId ?? '',
128
175
  ].join('\u0000');
129
176
  }
130
177
  export function parseExternalFindingsDocument(value) {
@@ -133,8 +180,8 @@ export function parseExternalFindingsDocument(value) {
133
180
  throw new Error('External findings must use the documented object shape without additional top-level fields.');
134
181
  }
135
182
  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}.`);
183
+ || ![1, 2, 3, EXTERNAL_FINDINGS_VERSION].includes(Number(root.schemaVersion))) {
184
+ throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 1, 2, 3, or ${EXTERNAL_FINDINGS_VERSION}.`);
138
185
  }
139
186
  const sourceValue = record(root.source);
140
187
  if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
@@ -181,7 +228,7 @@ export function parseExternalFindingsDocument(value) {
181
228
  completeness: item.completeness,
182
229
  limitations: limitations(item.limitations, index),
183
230
  reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
184
- structuredObservation: structuredObservation(item.structuredObservation, index),
231
+ structuredObservation: structuredObservation(item.structuredObservation, index, Number(root.schemaVersion)),
185
232
  };
186
233
  const key = findingKey(finding, source.name);
187
234
  if (seen.has(key))
@@ -229,12 +276,28 @@ function existingPinKey(recordValue, finding, sourceName) {
229
276
  && (!observation || (pin.field === observation.field
230
277
  && pin.sourceSchema?.collection === 'external_observations'
231
278
  && pin.sourceSchema.schema === observation.sourceSchema
232
- && pin.sourceSchema.version === observation.sourceVersion)))) ? findingKey(finding, sourceName) : '';
279
+ && pin.sourceSchema.version === observation.sourceVersion
280
+ && (observation.eventId === null || pin.certificateObservation?.eventId === observation.eventId))))) ? findingKey(finding, sourceName) : '';
233
281
  }
234
282
  function structuredPinFields(finding) {
235
283
  const observation = finding.structuredObservation;
236
284
  if (!observation)
237
285
  return {};
286
+ const certificateObservation = observation.eventId
287
+ && observation.logId
288
+ && observation.certificateSha256
289
+ && observation.dnsNameCount !== null
290
+ && observation.namesComplete !== null
291
+ ? {
292
+ eventId: observation.eventId,
293
+ logId: observation.logId,
294
+ certificateSha256: observation.certificateSha256,
295
+ issuer: observation.issuer,
296
+ notAfter: observation.notAfter,
297
+ dnsNameCount: observation.dnsNameCount,
298
+ namesComplete: observation.namesComplete,
299
+ }
300
+ : null;
238
301
  return {
239
302
  field: observation.field,
240
303
  category: finding.category,
@@ -243,6 +306,7 @@ function structuredPinFields(finding) {
243
306
  schema: observation.sourceSchema,
244
307
  version: observation.sourceVersion,
245
308
  },
309
+ certificateObservation,
246
310
  };
247
311
  }
248
312
  export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
@@ -1,6 +1,7 @@
1
- import { canonicalArtifactJson, sha256ArtifactDigest } from "./artifact-integrity.js";
1
+ import { canonicalArtifactJson, canonicalArtifactJsonV2, sha256ArtifactDigest, sha256ArtifactDigestV2, SORTED_JSON_V2, } from "./artifact-integrity.js";
2
2
  export const INVESTIGATION_CAPSULE_SCHEMA = 'whoisleuth.investigation-capsule';
3
- export const INVESTIGATION_CAPSULE_VERSION = 1;
3
+ export const INVESTIGATION_CAPSULE_VERSION = 2;
4
+ export const LEGACY_INVESTIGATION_CAPSULE_VERSION = 1;
4
5
  function safeVersion(value) {
5
6
  return typeof value === 'string' && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value)
6
7
  ? value
@@ -46,14 +47,14 @@ function analystProjection(record) {
46
47
  export async function buildInvestigationCapsule(input) {
47
48
  const analystRecords = input.includeAnalystRecords ? analystProjection(input.caseRecord) : null;
48
49
  const [evidenceDigest, briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
49
- sha256ArtifactDigest(input.lookupEvidence),
50
- sha256ArtifactDigest(input.brief),
51
- sha256ArtifactDigest(input.graph),
52
- analystRecords ? sha256ArtifactDigest(analystRecords) : Promise.resolve(null),
50
+ sha256ArtifactDigestV2(input.lookupEvidence),
51
+ sha256ArtifactDigestV2(input.brief),
52
+ sha256ArtifactDigestV2(input.graph),
53
+ analystRecords ? sha256ArtifactDigestV2(analystRecords) : Promise.resolve(null),
53
54
  ]);
54
55
  const evidenceSchema = safeSchema(input.lookupEvidence.schema);
55
56
  const evidenceVersion = safeSchemaVersion(input.lookupEvidence.schemaVersion);
56
- return {
57
+ const unsigned = {
57
58
  schema: INVESTIGATION_CAPSULE_SCHEMA,
58
59
  schemaVersion: INVESTIGATION_CAPSULE_VERSION,
59
60
  generatedAt: iso(input.generatedAt),
@@ -70,7 +71,6 @@ export async function buildInvestigationCapsule(input) {
70
71
  investigationBrief: input.brief,
71
72
  graphSnapshot: input.graph,
72
73
  analystRecords,
73
- integrity: { algorithm: 'SHA-256', briefDigest, graphDigest, analystRecordsDigest },
74
74
  limitations: [
75
75
  'The Lookup evidence file is linked by digest but is not embedded; keep or share that exact file separately when verification is required.',
76
76
  'Digest verification can detect changed content but does not establish who created the capsule. No digital signature is applied.',
@@ -80,12 +80,36 @@ export async function buildInvestigationCapsule(input) {
80
80
  : 'Analyst decisions, assertions, notes, contacts, actions, and raw source payloads are excluded.',
81
81
  ],
82
82
  };
83
+ return {
84
+ ...unsigned,
85
+ integrity: {
86
+ algorithm: 'SHA-256',
87
+ canonicalization: SORTED_JSON_V2,
88
+ scope: 'capsule excluding integrity',
89
+ briefDigest,
90
+ graphDigest,
91
+ analystRecordsDigest,
92
+ digestSha256: await sha256ArtifactDigestV2(unsigned),
93
+ },
94
+ };
83
95
  }
84
96
  export async function verifyInvestigationCapsule(capsule) {
97
+ const current = capsule.schemaVersion === INVESTIGATION_CAPSULE_VERSION;
98
+ const legacy = capsule.schemaVersion === LEGACY_INVESTIGATION_CAPSULE_VERSION;
99
+ const integrityRecord = capsule.integrity;
100
+ if ((!current && !legacy)
101
+ || (current && (integrityRecord.canonicalization !== SORTED_JSON_V2
102
+ || integrityRecord.scope !== 'capsule excluding integrity'))
103
+ || (legacy && (Object.hasOwn(integrityRecord, 'canonicalization')
104
+ || Object.hasOwn(integrityRecord, 'scope')
105
+ || Object.hasOwn(integrityRecord, 'digestSha256')))) {
106
+ return { valid: false, brief: false, graph: false, analystRecords: null, whole: current ? false : null };
107
+ }
108
+ const projectionDigest = current ? sha256ArtifactDigestV2 : sha256ArtifactDigest;
85
109
  const [briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
86
- sha256ArtifactDigest(capsule.investigationBrief),
87
- sha256ArtifactDigest(capsule.graphSnapshot),
88
- capsule.analystRecords ? sha256ArtifactDigest(capsule.analystRecords) : Promise.resolve(null),
110
+ projectionDigest(capsule.investigationBrief),
111
+ projectionDigest(capsule.graphSnapshot),
112
+ capsule.analystRecords ? projectionDigest(capsule.analystRecords) : Promise.resolve(null),
89
113
  ]);
90
114
  const brief = briefDigest === capsule.integrity.briefDigest;
91
115
  const graph = graphDigest === capsule.integrity.graphDigest;
@@ -94,12 +118,22 @@ export async function verifyInvestigationCapsule(capsule) {
94
118
  : capsule.integrity.analystRecordsDigest === null
95
119
  ? null
96
120
  : false;
97
- return { valid: brief && graph && analystRecords !== false, brief, graph, analystRecords };
121
+ let whole = null;
122
+ if (current) {
123
+ const { integrity, ...unsigned } = capsule;
124
+ whole = integrity.algorithm === 'SHA-256'
125
+ && integrity.canonicalization === SORTED_JSON_V2
126
+ && integrity.scope === 'capsule excluding integrity'
127
+ && integrity.digestSha256 === await sha256ArtifactDigestV2(unsigned);
128
+ }
129
+ return { valid: brief && graph && analystRecords !== false && whole !== false, brief, graph, analystRecords, whole };
98
130
  }
99
131
  export function investigationCapsuleFilename(capsule) {
100
132
  const target = capsule.target.value.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 80) || 'lookup';
101
133
  return `whoisleuth-investigation-capsule-${target}-${capsule.generatedAt.slice(0, 10)}.json`;
102
134
  }
103
135
  export function serializeInvestigationCapsule(capsule) {
104
- return `${canonicalArtifactJson(capsule)}\n`;
136
+ return `${capsule.schemaVersion === INVESTIGATION_CAPSULE_VERSION
137
+ ? canonicalArtifactJsonV2(capsule)
138
+ : canonicalArtifactJson(capsule)}\n`;
105
139
  }
@@ -1,10 +1,10 @@
1
1
  import { normalizeDomain } from "./case-model.js";
2
2
  import { parse } from 'tldts';
3
3
  export const INVESTIGATION_GUIDE_SCHEMA = 'whoisleuth.investigation-recipe';
4
- export const INVESTIGATION_GUIDE_VERSION = 4;
4
+ export const INVESTIGATION_GUIDE_VERSION = 5;
5
5
  export const INVESTIGATION_GUIDE_LEGACY_VERSION = 1;
6
- export const INVESTIGATION_GUIDE_EXPORT_VERSION = 3;
7
- export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, INVESTIGATION_GUIDE_VERSION];
6
+ export const INVESTIGATION_GUIDE_EXPORT_VERSION = 4;
7
+ export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, 4, INVESTIGATION_GUIDE_VERSION];
8
8
  export const INVESTIGATION_GUIDE_EXPORT_SCHEMA = 'whoisleuth.investigation-recipe-summary';
9
9
  export const MAX_INVESTIGATION_GUIDE_DOMAIN_LENGTH = 253;
10
10
  export const MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS = 25;
@@ -16,6 +16,7 @@ const CONTROL_RE = /[\x00-\x1f\x7f]/u;
16
16
  const SAFE_TEMPLATE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
17
17
  const GUIDE_STATUSES = new Set(['active', 'paused']);
18
18
  const GUIDE_OUTCOMES = new Set(['pending', 'complete', 'partial', 'skipped']);
19
+ const PRE_RESPONSE_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
19
20
  function stage(id, workspace, label, detail, expectedEvidence, requestImpact, prerequisite, completionCriteria, instructions, requiresApproval) {
20
21
  return Object.freeze({
21
22
  id,
@@ -91,6 +92,39 @@ export const INVESTIGATION_RECIPES = Object.freeze([
91
92
  stage('monitor', 'monitor', 'Record disposition', 'Review the bounded domain set carried from Bulk and create or update only the cases that need an analyst decision or follow-up plan.', 'A bounded review queue plus case records with dispositions, notes, evidence history, and optional monitoring intent.', 'Local case editing makes no request. Rescans and hosted-monitor changes remain separate explicit actions.', 'Review source provenance, limitations, and any scoring explanation before deciding.', 'The analyst records the required dispositions or explicitly skips retention; the recipe never decides automatically.', ['Review the domains carried from Bulk and open only the cases that need retention.', 'Record a disposition and any concise evidence-based follow-up for each retained case.', 'Mark the step reviewed when the retained cases reflect your decision.'], false),
92
93
  ]),
93
94
  }),
95
+ Object.freeze({
96
+ id: 'credential_impersonation_response',
97
+ label: 'Credential impersonation response',
98
+ summary: 'Review observed impersonation evidence, confirm the affected brand boundary, and prepare a local response packet from explicitly recorded routes.',
99
+ targetLabel: 'Domain under review',
100
+ stages: Object.freeze([
101
+ stage('lookup', 'lookup', 'Review impersonation evidence', 'Collect separately attributed deep evidence for the domain without treating visual or naming similarity as attribution.', 'Current registry, DNS, certificate, HTTP, page-identity, and source-health observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the target is in scope and do not enter a credential-bearing URL or secret.', 'Observed facts and missing sources are reviewed without making an ownership, intent, or harm conclusion.', ['Run the pre-filled Lookup in Deep mode.', 'Review exact page, redirect, certificate, and registration observations with their source states.', 'Create or update a Case only when the retained observations need reviewed follow-up.'], true),
102
+ stage('brands', 'brands', 'Confirm affected brand boundary', 'Review the saved Brand Profile that defines official and allowlisted references; do not infer an association from similarity.', 'An analyst-selected Brand Profile boundary, or an explicit decision to continue without one.', 'Local-only. Opening Brands makes no analysis request.', 'Know which saved profile, if any, is relevant to the affected party.', 'The relevant profile boundary is reviewed, or the stage is marked partial or skipped without creating an association.', ['Open the saved Brand Profiles list.', 'Review the official references and allowlists for the affected party.', 'Return to the guide without adding a profile association unless you deliberately selected one in Monitor.'], false),
103
+ stage('monitor', 'monitor', 'Prepare reviewed response', 'Open the Case response workspace, record only analyst-selected published recipient routes, and review packet preflight.', 'A Case action with recipient provenance and limitations plus an optional local draft packet that remains unsubmitted.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Retain supporting facts and reasoning before recording an external route; confirm the route manually.', 'The reviewed action and packet preflight are recorded, or the stage is partial or skipped without implying submission.', ['Open or create the pre-filled Case, then review its evidence and reasoning.', 'Record an analyst-selected published route with its source and limitations; do not claim it is reachable.', 'Open response preflight and prepare a local draft only if its required facts are complete.'], false),
104
+ ]),
105
+ }),
106
+ Object.freeze({
107
+ id: 'mail_abuse_response',
108
+ label: 'Mail abuse response',
109
+ summary: 'Review official mail posture and observed domain evidence before preparing a source-qualified, unsent response packet.',
110
+ targetLabel: 'Domain under review',
111
+ stages: Object.freeze([
112
+ stage('brands', 'brands', 'Review official mail posture', 'Review the affected Brand Profile and retained mail posture without treating a missing control as proof of abuse.', 'Analyst-selected official-domain, mail, and Brand Profile context with explicit availability states.', 'Local-only unless the analyst separately starts a Brand posture refresh.', 'Identify the affected profile and distinguish official infrastructure from the domain under review.', 'Relevant retained posture is reviewed, or unavailable context is marked partial rather than absent.', ['Open the saved Brand Profiles list.', 'Review official MX, SPF, DMARC, and approved-domain context where retained.', 'Do not refresh or change a profile unless that separate action is necessary and approved.'], false),
113
+ stage('lookup', 'lookup', 'Review domain and mail evidence', 'Collect current deep evidence for the domain and keep DNS, mail, registration, and page observations separately attributed.', 'Authority-aware availability plus bounded DNS, mail, TLS, HTTP, and page observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the domain is in scope; email content and mailbox data are outside this workflow.', 'Current observations and source failures are reviewed without claiming message origin, sender control, or maliciousness.', ['Run the pre-filled Lookup in Deep mode.', 'Review MX, SPF, DMARC, registration, certificate, HTTP, and page source states.', 'Retain only the evidence needed for a reviewed Case.'], true),
114
+ stage('monitor', 'monitor', 'Prepare mail-abuse response', 'Record a reviewed recipient route and use Case packet preflight without sending or claiming delivery.', 'A bounded Case action and optional local response draft with exact recipient provenance and limitations.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Confirm the intended recipient and retain the observed facts and limitations that support review.', 'The current action state and packet preflight are recorded, or the stage remains partial without a delivery conclusion.', ['Open or create the pre-filled Case and review its retained evidence.', 'Record only a deliberately selected published route, its source, and known limitations.', 'Use the response preflight to prepare a local draft; submission remains a separate manual action recorded later.'], false),
115
+ ]),
116
+ }),
117
+ Object.freeze({
118
+ id: 'domain_control_change_response',
119
+ label: 'Domain-control change response',
120
+ summary: 'Review retained control posture and current authority-aware evidence before documenting a bounded internal or provider response.',
121
+ targetLabel: 'Official domain',
122
+ stages: Object.freeze([
123
+ stage('brands', 'brands', 'Review retained control posture', 'Review the saved domain-control passport, posture matrix, and source limitations before interpreting a change.', 'Retained official-domain control observations with separately attributed registry, DNS, certificate, mail, and web states.', 'Local-only unless the analyst separately starts a posture refresh.', 'Confirm the domain belongs to the reviewed Brand Profile; a saved association is analyst-selected, not inferred.', 'The retained baseline and its completeness are understood, or the stage is partial when required context is unavailable.', ['Open the relevant saved Brand Profile.', 'Review the domain-control passport, portfolio matrix, and observation times.', 'Identify which source actually supports the suspected change and preserve unavailable states.'], false),
124
+ stage('lookup', 'lookup', 'Confirm current domain evidence', 'Collect a deliberate deep Lookup so current authority-aware evidence remains distinct from the retained baseline.', 'Current registry, registrar, DNS, certificate, HTTP, and page observations with explicit source health.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm a fresh collection is necessary and review its request implications.', 'The current result is reviewed against the retained baseline without converting incomplete evidence into removal or compromise.', ['Run the pre-filled Lookup in Deep mode.', 'Compare current observations with the retained control evidence source by source.', 'Record only facts and limitations that need a Case response.'], true),
125
+ stage('monitor', 'monitor', 'Document control-change response', 'Record an internal or provider action and review an optional response packet without automatic enforcement or submission.', 'A bounded Case action, due or follow-up dates, recipient provenance when external, and an optional unsubmitted packet.', 'Local Case edits and packet generation make no request. WHOISleuth never changes DNS, registrar, hosting, mail, or defensive systems.', 'Document supporting evidence and confirm any recipient route or internal owner manually.', 'The reviewed action state is recorded, or the stage is partial without a compromise, delivery, or remediation claim.', ['Open or create the pre-filled Case and retain the relevant comparison facts.', 'Record the internal owner or analyst-selected provider route, source, limitations, and follow-up dates.', 'Review packet preflight when a draft is useful; record submission or resolution only after it actually occurs.'], false),
126
+ ]),
127
+ }),
94
128
  ]);
95
129
  function record(value) {
96
130
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
@@ -239,10 +273,15 @@ export function investigationGuideHref(stageId, domain, recipeId = 'new_domain_t
239
273
  return `/discover?q=${encodeURIComponent(registrableGuideDomain(normalized))}#discovery-seed`;
240
274
  if (stageDefinition.workspace === 'bulk')
241
275
  return `/bulk?investigation=${encodeURIComponent(normalized)}#domains`;
242
- if (stageDefinition.workspace === 'monitor')
243
- return `/monitor?view=cases&investigation=1&domain=${encodeURIComponent(workingDomain)}#case-review-queue`;
244
- if (stageDefinition.workspace === 'brands')
245
- return `/brands?new=1&domain=${encodeURIComponent(normalized)}#official-domains`;
276
+ if (stageDefinition.workspace === 'monitor') {
277
+ const response = typeof recipeId === 'string' && recipeId.endsWith('_response') ? '&response=1' : '';
278
+ return `/monitor?view=cases&investigation=1${response}&domain=${encodeURIComponent(workingDomain)}#case-review-queue`;
279
+ }
280
+ if (stageDefinition.workspace === 'brands') {
281
+ return typeof recipeId === 'string' && recipeId.endsWith('_response')
282
+ ? '/brands'
283
+ : `/brands?new=1&domain=${encodeURIComponent(normalized)}#official-domains`;
284
+ }
246
285
  return stageDefinition.path;
247
286
  }
248
287
  export function investigationGuideApprovedHref(guide, stageId) {
@@ -324,9 +363,10 @@ export function parseInvestigationGuide(value) {
324
363
  const domain = normalizeInvestigationGuideDomain(input.domain);
325
364
  const createdAt = timestamp(input.createdAt);
326
365
  const updatedAt = timestamp(input.updatedAt);
327
- if (!recipe || !domain || !createdAt || !updatedAt)
366
+ if (!recipe || !domain || !createdAt || !updatedAt
367
+ || (input.version !== INVESTIGATION_GUIDE_VERSION && !PRE_RESPONSE_RECIPE_IDS.has(recipe.id)))
328
368
  return null;
329
- const supportsTemplate = input.version === 3 || input.version === INVESTIGATION_GUIDE_VERSION;
369
+ const supportsTemplate = input.version === 3 || input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION;
330
370
  const template = supportsTemplate && input.template !== null && input.template !== undefined
331
371
  ? normalizeInvestigationGuideTemplateSnapshot(input.template, recipe.id)
332
372
  : null;
@@ -361,7 +401,9 @@ export function parseInvestigationGuide(value) {
361
401
  outcome: guideOutcome(item?.outcome),
362
402
  approvedAt: nullableTimestamp(item?.approvedAt),
363
403
  openedAt: nullableTimestamp(item?.openedAt),
364
- reviewNote: input.version === INVESTIGATION_GUIDE_VERSION ? boundedReviewNote(item?.reviewNote) : null,
404
+ reviewNote: input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION
405
+ ? boundedReviewNote(item?.reviewNote)
406
+ : null,
365
407
  updatedAt: timestamp(item?.updatedAt) || updatedAt,
366
408
  };
367
409
  }),
@@ -1,10 +1,12 @@
1
1
  import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.js";
2
2
  export const INVESTIGATION_TEMPLATE_SCHEMA = 'whoisleuth.investigation-templates';
3
- export const INVESTIGATION_TEMPLATE_VERSION = 1;
3
+ export const INVESTIGATION_TEMPLATE_VERSION = 2;
4
+ export const INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS = [1, INVESTIGATION_TEMPLATE_VERSION];
4
5
  export const MAX_INVESTIGATION_TEMPLATES = 20;
5
6
  export const MAX_INVESTIGATION_TEMPLATE_STORE_BYTES = 256 * 1024;
6
7
  export const MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES = 384 * 1024;
7
8
  const CONTROL_RE = /[\x00-\x1f\x7f]/u;
9
+ const LEGACY_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
8
10
  function record(value) {
9
11
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
10
12
  }
@@ -17,12 +19,12 @@ function timestamp(value) {
17
19
  function byteLength(value) {
18
20
  return new TextEncoder().encode(value).byteLength;
19
21
  }
20
- function template(raw) {
22
+ function template(raw, sourceVersion = INVESTIGATION_TEMPLATE_VERSION) {
21
23
  const value = record(raw);
22
24
  const snapshot = normalizeInvestigationGuideTemplateSnapshot(value);
23
25
  const createdAt = timestamp(value?.createdAt);
24
26
  const updatedAt = timestamp(value?.updatedAt);
25
- return snapshot && createdAt && updatedAt
27
+ return snapshot && createdAt && updatedAt && (sourceVersion !== 1 || LEGACY_RECIPE_IDS.has(snapshot.recipeId))
26
28
  ? { ...snapshot, createdAt, updatedAt }
27
29
  : null;
28
30
  }
@@ -35,11 +37,12 @@ export function normalizeInvestigationTemplateStore(raw) {
35
37
  && Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
36
38
  throw new Error('This investigation-template collection uses a newer schema and cannot be read safely.');
37
39
  }
40
+ const sourceVersion = Array.isArray(raw) ? INVESTIGATION_TEMPLATE_VERSION : Number(value?.version) || INVESTIGATION_TEMPLATE_VERSION;
38
41
  const source = Array.isArray(raw) ? raw : Array.isArray(value?.templates) ? value.templates : [];
39
42
  const templates = [];
40
43
  const seen = new Set();
41
44
  for (const candidate of source.slice(0, MAX_INVESTIGATION_TEMPLATES * 2)) {
42
- const normalized = template(candidate);
45
+ const normalized = template(candidate, sourceVersion);
43
46
  if (!normalized || seen.has(normalized.id))
44
47
  continue;
45
48
  seen.add(normalized.id);
@@ -115,8 +118,8 @@ function strictImport(raw) {
115
118
  if (Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
116
119
  throw new Error('This investigation-template export uses a newer schema and cannot be imported safely.');
117
120
  }
118
- if (value.version !== INVESTIGATION_TEMPLATE_VERSION) {
119
- throw new Error(`Expected investigation-template schema ${INVESTIGATION_TEMPLATE_VERSION}.`);
121
+ if (!INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS.includes(Number(value.version))) {
122
+ throw new Error(`Expected investigation-template schema 1 or ${INVESTIGATION_TEMPLATE_VERSION}.`);
120
123
  }
121
124
  return normalizeInvestigationTemplateStore(value).templates;
122
125
  }
@@ -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
  }