@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
package/docs/cli.md CHANGED
@@ -65,10 +65,14 @@ saved result without sending it anywhere:
65
65
  ```bash
66
66
  whoisleuth bulk domains.txt --csv
67
67
  whoisleuth discover example.com --preset common --jsonl
68
- whoisleuth verify-artifact lookup.json --json
68
+ whoisleuth verify-artifact lookup.json --json --strict-exit
69
+ whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit
70
+ whoisleuth interchange-report workspace.json --json
69
71
  whoisleuth compare lookup.json --json
72
+ whoisleuth diff earlier-bulk.json later-bulk.json --left-session session-1 --right-session session-2 --json
70
73
  whoisleuth brief lookup.json
71
74
  whoisleuth registry-doctor lookup.json --json
75
+ whoisleuth registry-cohort retained-cohorts.jsonl --json
72
76
  ```
73
77
 
74
78
  Only one query is accepted by `lookup`; multi-input processing belongs to
@@ -86,6 +90,7 @@ generated manual, and `commands --json`.
86
90
  | Deep Lookup | Explicitly requested bounded RDAP, WHOIS, DNS, website, TLS, registrar-RDAP, and observed-network work where applicable. |
87
91
  | Compact deep Bulk | Uses the shared Lookup orchestration but omits full-only response fields and collectors. |
88
92
  | Offline review | Reads only the named bounded artefact or analyst-authored input and makes no network request. |
93
+ | Isolated authorised action | Runs only when its dedicated command and per-run acknowledgement flags are supplied; it is never added to Lookup, Bulk, monitoring, or recipes. |
89
94
 
90
95
  Networked commands run from the local machine and contact the sources declared
91
96
  by their profile. They do not use the hosted login or hosted usage controls;
@@ -106,12 +111,12 @@ HTTP status, and a bounded explanation.
106
111
 
107
112
  | Goal | Commands |
108
113
  | --- | --- |
109
- | Investigate a target | `lookup`, `http`, `tls`, `posture`, `registry-support` |
114
+ | Investigate a target | `lookup`, `http`, `tls`, `dnssec-validate`, `mail-transport`, `posture`, `registry-support` |
110
115
  | Review many targets | `bulk`, `discover`, `discover-scan`, `ct-search`, `ct-intake` |
111
116
  | Compare saved evidence | `compare`, `page-compare`, `diff`, `reconcile`, `timeline`, `mail-review` |
112
117
  | Review supplied evidence | `brief`, `review-evidence`, `registry-doctor`, `registry-cohort`, `source-report`, `sharing-review`, `map-observations` |
113
118
  | Plan and assure changes | `domain-control`, `monitor-once`, `assurance`, `change-packet`, `workflow-plan`, `workflow-run` |
114
- | Verify and package evidence | `case-pack`, `manifest`, `oam-export`, `verify-artifact`, `inspect-archive`, `sign-artifact`, `verify-signature`, `export` |
119
+ | Verify and package evidence | `case-pack`, `manifest`, `oam-export`, `verify-artifact`, `interchange-report`, `inspect-archive`, `sign-artifact`, `verify-signature`, `export` |
115
120
  | Calibrate offline | `risk-calibrate`, `lookalike-calibrate` |
116
121
  | Operate the CLI | `doctor`, `registry-scaffold`, `commands`, `completion`, `manual` |
117
122
 
@@ -168,6 +173,7 @@ commands do not publish partial files. Output is capped at 32 MiB.
168
173
  | 4 | A bounded operation completed partially. |
169
174
  | 70 | Unexpected CLI bootstrap failure. |
170
175
  | 130 | The analyst cancelled the command; no partial final result was emitted. |
176
+ | 143 | The process received SIGTERM; no partial final result was emitted. |
171
177
 
172
178
  ## Detailed command reference
173
179
 
@@ -188,7 +194,7 @@ full contracts into the packaged reference.
188
194
 
189
195
  ### Offline artefact verification
190
196
 
191
- [Read the archive, saved Lookup, and signed-artefact verification contract.](cli-reference.md#offline-artefact-verification)
197
+ [Read the archive, saved Lookup, Lookup-evidence, and signed-artefact verification contract.](cli-reference.md#offline-artefact-verification)
192
198
 
193
199
  ### Privacy-safe source reliability report
194
200
 
@@ -243,6 +249,18 @@ search contract.](cli-reference.md#workspace-archive-inspection)
243
249
 
244
250
  [Read the bounded TLS collection contract.](cli-reference.md#tls-intelligence)
245
251
 
252
+ ### Isolated cryptographic and mail transport review
253
+
254
+ [Read the explicitly authorised DNSSEC, TLSA/DANE, STARTTLS, and SMTP transport contract.](cli-reference.md#isolated-cryptographic-and-mail-transport-review)
255
+ The mail report keeps address selection, public revalidation, confirmed
256
+ connection, and cryptographic address authentication as separate provenance;
257
+ address authentication is unavailable when no address candidate was retained.
258
+ If a DANE-TA TLSA usage 2 association is published, active collection retains
259
+ only the observed leaf certificate and leaves that comparison partial without
260
+ certificate-path construction and trust-anchor path validation. SMTP relay
261
+ PKIX-TA usage 0 and PKIX-EE usage 1 records are retained as unsupported and
262
+ cannot complete SMTP DANE assurance; a separate usage 3 match remains eligible.
263
+
246
264
  ### Registry-source comparison
247
265
 
248
266
  [Read the normalised RDAP and WHOIS comparison contract.](cli-reference.md#registry-source-comparison)
@@ -257,7 +275,7 @@ search contract.](cli-reference.md#workspace-archive-inspection)
257
275
 
258
276
  ### Offline supplied-evidence review
259
277
 
260
- [Read the DNSSEC, DANE, zone-intent, portfolio, domain-change, and supplied-observation review contract.](cli-reference.md#offline-supplied-evidence-review)
278
+ [Read the separate DNSSEC, route-origin, DANE/TLSA, zone-intent, portfolio, domain-change, and supplied-observation review contract.](cli-reference.md#offline-supplied-evidence-review)
261
279
 
262
280
  ### Domain control manifests
263
281
 
@@ -290,6 +308,8 @@ search contract.](cli-reference.md#workspace-archive-inspection)
290
308
  ### Lookup evidence export
291
309
 
292
310
  [Read normalised JSON, Markdown, and HTML export behaviour.](cli-reference.md#lookup-evidence-export)
311
+ Saved Lookup JSON is byte-, nesting-, entry-, and per-container-bounded before parsing, and the
312
+ completed evidence package must satisfy the stricter portable-tree contract.
293
313
 
294
314
  ## Safety and limitations
295
315
 
@@ -1,6 +1,6 @@
1
- import { sha256ArtifactDigest } from "./artifact-integrity.js";
1
+ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
2
2
  export const ACQUISITION_DECISION_PACKET_SCHEMA = 'whoisleuth.acquisition-decision';
3
- export const ACQUISITION_DECISION_PACKET_VERSION = 1;
3
+ export const ACQUISITION_DECISION_PACKET_VERSION = 2;
4
4
  export const ACQUISITION_DECISIONS = [
5
5
  'unresolved',
6
6
  'continue_manual_review',
@@ -80,10 +80,10 @@ export async function buildAcquisitionDecisionPacket(input) {
80
80
  : []),
81
81
  ],
82
82
  };
83
- const digestSha256 = await sha256ArtifactDigest(unsigned);
83
+ const digestSha256 = await sha256ArtifactDigestV2(unsigned);
84
84
  const document = {
85
85
  ...unsigned,
86
- integrity: { algorithm: 'SHA-256', digestSha256 },
86
+ integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
87
87
  };
88
88
  return {
89
89
  document,
@@ -205,7 +205,7 @@ function contactsItem(insights) {
205
205
  function sourceComplete(evidence) {
206
206
  return evidence.source === 'dns' && evidence.complete === true;
207
207
  }
208
- function transitionDependencies(availability, activation, dnsEvidence, dnsRecords, tlsEvidence) {
208
+ function transitionDependencies(_availability, activation, dnsEvidence, dnsRecords, tlsEvidence) {
209
209
  const dnsComplete = sourceComplete(dnsEvidence);
210
210
  const nameservers = strings(dnsRecords.ns);
211
211
  const addresses = [...strings(dnsRecords.a), ...strings(dnsRecords.aaaa)];
@@ -19,3 +19,45 @@ export async function sha256ArtifactDigest(value) {
19
19
  .map((byte) => byte.toString(16).padStart(2, '0'))
20
20
  .join('')}`;
21
21
  }
22
+ export const SORTED_JSON_V1 = 'sorted-json-v1';
23
+ export const SORTED_JSON_V2 = 'sorted-json-v2';
24
+ function compareCodeUnits(left, right) {
25
+ return left < right ? -1 : left > right ? 1 : 0;
26
+ }
27
+ export function canonicalArtifactJsonV2(value) {
28
+ if (value === undefined)
29
+ return 'null';
30
+ if (value === null || typeof value !== 'object')
31
+ return JSON.stringify(value);
32
+ if (Array.isArray(value))
33
+ return `[${value.map(canonicalArtifactJsonV2).join(',')}]`;
34
+ const entries = Object.entries(value)
35
+ .filter(([, entry]) => entry !== undefined)
36
+ .sort(([left], [right]) => compareCodeUnits(left, right));
37
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalArtifactJsonV2(entry)}`).join(',')}}`;
38
+ }
39
+ export async function sha256ArtifactDigestV2(value) {
40
+ const bytes = new TextEncoder().encode(canonicalArtifactJsonV2(value));
41
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
42
+ return `sha256:${[...new Uint8Array(digest)]
43
+ .map((byte) => byte.toString(16).padStart(2, '0'))
44
+ .join('')}`;
45
+ }
46
+ export function resolveArtifactCanonicalization(version, declaredCanonicalization, routes, label = 'Artifact') {
47
+ const route = routes.find((candidate) => candidate.version === version);
48
+ const expectedDeclaration = route?.explicit ? route.canonicalization : undefined;
49
+ if (!route || declaredCanonicalization !== expectedDeclaration) {
50
+ throw new TypeError(`${label} has an unsupported version or canonicalization.`);
51
+ }
52
+ return route.canonicalization;
53
+ }
54
+ export function canonicalArtifactJsonFor(value, canonicalization) {
55
+ return canonicalization === SORTED_JSON_V1
56
+ ? canonicalArtifactJson(value)
57
+ : canonicalArtifactJsonV2(value);
58
+ }
59
+ export function sha256ArtifactDigestFor(value, canonicalization) {
60
+ return canonicalization === SORTED_JSON_V1
61
+ ? sha256ArtifactDigest(value)
62
+ : sha256ArtifactDigestV2(value);
63
+ }
@@ -2,10 +2,12 @@
2
2
  // persistence and downloads; this module owns schema migration, semantic field
3
3
  // bounds, import merging, and exact serialized-byte accounting.
4
4
  import { normalizeDomain } from "./case-model.js";
5
+ import { normalizeOpaqueReferenceId } from "./opaque-reference-id.js";
5
6
  import { normalizePageBaseline } from "./page-baseline.js";
6
7
  import { isInformativeFaviconHash } from "./utils.js";
7
8
  export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
8
9
  export const BRAND_PROFILE_SCHEMA_VERSION = 6;
10
+ export const SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS = Object.freeze([2, 3, 4, 5, BRAND_PROFILE_SCHEMA_VERSION]);
9
11
  export const MAX_PROFILES = 100;
10
12
  export const MAX_PROFILE_VALUES = 200;
11
13
  export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
@@ -24,7 +26,6 @@ export const MAX_DESIRED_POSTURE_RECORDS = 32;
24
26
  export const MAX_DESIRED_POSTURE_SUPPRESSIONS = 12;
25
27
  export const MAX_DESIRED_POSTURE_OBSERVATIONS = 12;
26
28
  export const MAX_DESIRED_POSTURE_CHANGE_WINDOWS = 8;
27
- const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
28
29
  const SHA256_RE = /^[a-f0-9]{64}$/i;
29
30
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
30
31
  const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -67,8 +68,8 @@ function boundedText(value, maximum = MAX_PROFILE_TEXT_LENGTH) {
67
68
  return '';
68
69
  return value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
69
70
  }
70
- function safeId(value) {
71
- return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : null;
71
+ export function normalizeBrandProfileId(value) {
72
+ return normalizeOpaqueReferenceId(value);
72
73
  }
73
74
  function timestamp(value, fallback) {
74
75
  if (typeof value === 'string' && value.length <= 64 && !CONTROL_RE.test(value)) {
@@ -337,7 +338,9 @@ export function normalizeBrandProfile(raw, options = {}) {
337
338
  const pageBaseline = candidateBaseline && officialDomains.includes(candidateBaseline.domain)
338
339
  ? candidateBaseline
339
340
  : null;
340
- const profileId = safeId(existing?.id) || safeId(value.id) || (typeof options.makeId === 'function' ? safeId(options.makeId()) : null);
341
+ const profileId = normalizeBrandProfileId(existing?.id)
342
+ || normalizeBrandProfileId(value.id)
343
+ || (typeof options.makeId === 'function' ? normalizeBrandProfileId(options.makeId()) : null);
341
344
  const name = boundedText(value.name, MAX_PROFILE_NAME_LENGTH);
342
345
  if (!profileId || !name)
343
346
  return null;
@@ -418,17 +421,39 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
418
421
  if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
419
422
  throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
420
423
  }
421
- if (![2, 3, 4, 5, BRAND_PROFILE_SCHEMA_VERSION].includes(importedVersion ?? 0)) {
422
- throw new Error(`Expected a WHOISleuth Brand Profile export using schema 2, 3, 4, 5, or ${BRAND_PROFILE_SCHEMA_VERSION}.`);
424
+ if (!SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.includes(importedVersion ?? 0)) {
425
+ throw new Error(`Expected a WHOISleuth Brand Profile export using schema ${SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.join(', ').replace(/, ([^,]+)$/u, ', or $1')}.`);
426
+ }
427
+ const idNames = new Map();
428
+ const retainIdName = (id, name) => {
429
+ const nameKey = name.toLowerCase();
430
+ const previous = idNames.get(id);
431
+ if (previous !== undefined && previous !== nameKey) {
432
+ throw new Error('Brand Profile import reuses one exact identifier for different normalised profile names. No profiles were imported.');
433
+ }
434
+ idNames.set(id, nameKey);
435
+ };
436
+ for (const item of profileList(localRaw).slice(0, MAX_PROFILES * 4)) {
437
+ const value = record(item);
438
+ const id = normalizeBrandProfileId(value.id);
439
+ const name = boundedText(value.name, MAX_PROFILE_NAME_LENGTH);
440
+ if (id && name)
441
+ retainIdName(id, name);
423
442
  }
424
443
  const local = normalizeBrandProfileStore(localRaw).profiles;
444
+ for (const profile of local)
445
+ retainIdName(profile.id, profile.name);
425
446
  const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
426
447
  const input = profileList(importedRaw);
427
448
  let added = 0;
428
449
  let updated = 0;
429
450
  let skipped = Math.max(0, input.length - MAX_PROFILES * 4);
430
451
  for (const item of input.slice(0, MAX_PROFILES * 4)) {
431
- const rawName = boundedText(record(item).name, MAX_PROFILE_NAME_LENGTH);
452
+ const value = record(item);
453
+ const rawName = boundedText(value.name, MAX_PROFILE_NAME_LENGTH);
454
+ const rawId = normalizeBrandProfileId(value.id);
455
+ if (rawId && rawName)
456
+ retainIdName(rawId, rawName);
432
457
  const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
433
458
  const profile = normalizeBrandProfile(item, {
434
459
  existing,
@@ -440,6 +465,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
440
465
  skipped++;
441
466
  continue;
442
467
  }
468
+ retainIdName(profile.id, profile.name);
443
469
  if (existing) {
444
470
  byName.set(profile.name.toLowerCase(), profile);
445
471
  updated++;
@@ -1,8 +1,9 @@
1
1
  import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
2
- import { sha256ArtifactDigest } from "./artifact-integrity.js";
2
+ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
3
3
  import { BULK_REVIEW_STALE_AFTER_DAYS } from "./bulk-retry-plan.js";
4
4
  export const BULK_DOMAIN_COMPARISON_SCHEMA = 'whoisleuth.domain-comparison';
5
5
  export const BULK_DOMAIN_COMPARISON_VERSION = 3;
6
+ export const BULK_DOMAIN_COMPARISON_EXPORT_VERSION = 4;
6
7
  function text(value, maximum = 500) {
7
8
  return typeof value === 'string'
8
9
  ? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
@@ -164,6 +165,23 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
164
165
  });
165
166
  const compactTls = comparisonEvidenceContext(left, right, 'tls', tls);
166
167
  const source = context(left, right, 'Recorded source coverage', [], observedAt, options);
168
+ const profileContextsComparable = left.profileContext.sourceState === 'ready'
169
+ && right.profileContext.sourceState === 'ready'
170
+ && left.profileContext.activeProfileId === right.profileContext.activeProfileId
171
+ && left.profileContext.profileUpdatedAt === right.profileContext.profileUpdatedAt;
172
+ const profileContext = {
173
+ ...http,
174
+ source: 'Brand Profile comparison context',
175
+ leftSourceState: profileContextsComparable ? 'complete' : 'unavailable',
176
+ rightSourceState: profileContextsComparable ? 'complete' : 'unavailable',
177
+ };
178
+ const profileLimitations = profileContextsComparable
179
+ ? []
180
+ : [
181
+ left.profileContext.limitation,
182
+ right.profileContext.limitation,
183
+ 'Official-asset comparison is withheld unless both rows retain the same ready Brand Profile provenance.',
184
+ ].filter(Boolean);
167
185
  const registrationConflicting = [left.availability, right.availability]
168
186
  .some((value) => ['conflict', 'conflicting'].includes(value.toLowerCase()));
169
187
  const rows = [
@@ -192,7 +210,7 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
192
210
  row('tracking', 'identity', 'Tracking identifiers', left.relationship.trackingIdentifiers, right.relationship.trackingIdentifiers, 'Normalised exact set comparison', http, ['Shared identifiers are an investigative lead, not proof of ownership.']),
193
211
  row('password-field', 'identity', 'Password field', left.hasPasswordField, right.hasPasswordField, 'Bounded static form observation', http),
194
212
  row('phishing-language', 'identity', 'Phishing-language indicator', left.phishingLanguageMatch, right.phishingLanguageMatch, 'Bounded explainable page-language signal', http, ['A wording match is a review lead, not proof of malicious intent.']),
195
- row('official-assets', 'identity', 'Official asset reuse', left.reusesOfficialAssets, right.reusesOfficialAssets, 'Configured Brand Profile comparison', http),
213
+ row('official-assets', 'identity', 'Official asset reuse', profileContextsComparable ? left.reusesOfficialAssets : null, profileContextsComparable ? right.reusesOfficialAssets : null, 'Configured Brand Profile comparison', profileContext, profileLimitations),
196
214
  row('technology', 'technology', 'Technology identifiers', left.comparisonEvidence?.technology.ids ?? [], right.comparisonEvidence?.technology.ids ?? [], 'Normalised exact set comparison of at most 12 curated identifiers', compactTechnology, ['An unmatched identifier is not evidence that a technology is absent. Shared technologies do not establish common ownership or control.']),
197
215
  row('source-health', 'source', 'Recorded source coverage', sourceSummary(left), sourceSummary(right), 'Source-by-source compact state comparison', source, ['A source-state difference can reflect collection conditions rather than a domain change.']),
198
216
  ];
@@ -219,11 +237,13 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
219
237
  'This compares normalised evidence already present in two saved observations for the same domain and makes no new request.',
220
238
  'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
221
239
  'An observed difference can reflect changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
240
+ ...(!profileContextsComparable ? ['Profile-derived identity comparison is unavailable because the saved Brand Profile contexts are not ready and identical.'] : []),
222
241
  ]
223
242
  : [
224
243
  'This compares compact settled evidence already present in Bulk and makes no new request.',
225
244
  'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
226
245
  'Equality does not establish common ownership, infrastructure control, intent, safety, or maliciousness.',
246
+ ...(!profileContextsComparable ? ['Profile-derived identity comparison is unavailable because the saved Brand Profile contexts are not ready and identical.'] : []),
227
247
  ],
228
248
  };
229
249
  }
@@ -235,14 +255,14 @@ export async function buildBulkDomainComparisonExport(comparisonRaw, generatedAt
235
255
  const generatedAt = timestamp(generatedAtRaw) || new Date().toISOString();
236
256
  const unsigned = {
237
257
  schema: BULK_DOMAIN_COMPARISON_SCHEMA,
238
- version: BULK_DOMAIN_COMPARISON_VERSION,
258
+ version: BULK_DOMAIN_COMPARISON_EXPORT_VERSION,
239
259
  generatedAt,
240
260
  comparison,
241
261
  };
242
- const digestSha256 = await sha256ArtifactDigest(unsigned);
262
+ const digestSha256 = await sha256ArtifactDigestV2(unsigned);
243
263
  const document = {
244
264
  ...unsigned,
245
- integrity: { algorithm: 'SHA-256', digestSha256 },
265
+ integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
246
266
  };
247
267
  return {
248
268
  document,
@@ -1,7 +1,8 @@
1
- import { sha256ArtifactDigest } from "./artifact-integrity.js";
2
- import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
1
+ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
2
+ import { BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION, normalizeBulkProfileContext, normalizeBulkSessionResult, } from "./bulk-session-model.js";
3
3
  export const BULK_MAIL_EXPOSURE_SCHEMA = 'whoisleuth.bulk-mail-exposure';
4
4
  export const BULK_MAIL_EXPOSURE_VERSION = 1;
5
+ export const BULK_MAIL_EXPOSURE_EXPORT_VERSION = 2;
5
6
  export const MAX_BULK_MAIL_EXPOSURE_ROWS = 2_000;
6
7
  const STATE_LABELS = {
7
8
  authenticated_mail: 'Receiving mail with SPF and DMARC',
@@ -16,7 +17,11 @@ function timestamp(value) {
16
17
  return null;
17
18
  return new Date(value).toISOString();
18
19
  }
19
- function baselineLabel(profile) {
20
+ function baselineLabel(profile, profileSourceState) {
21
+ if (profileSourceState === 'loading')
22
+ return 'Brand Profile context loading';
23
+ if (profileSourceState === 'unavailable')
24
+ return 'Brand Profile context unavailable';
20
25
  if (profile === 'defensive_no_mail')
21
26
  return 'Configured no-mail baseline';
22
27
  if (profile === 'parked')
@@ -64,7 +69,21 @@ function stateDetail(state) {
64
69
  }
65
70
  return 'The compact scan did not retain enough settled DNS evidence to classify mail exposure.';
66
71
  }
67
- function relation(state, profile) {
72
+ function relation(state, profile, profileSourceState, profileContextComparable, profileContextLimitation) {
73
+ if (profileSourceState !== 'ready') {
74
+ return {
75
+ baselineRelation: 'inconclusive',
76
+ baselineDetail: profileSourceState === 'loading'
77
+ ? 'Brand Profile context is still loading, so no profile-derived mail comparison is available.'
78
+ : 'Brand Profile context could not be read, so no profile-derived mail comparison is available.',
79
+ };
80
+ }
81
+ if (!profileContextComparable) {
82
+ return {
83
+ baselineRelation: 'inconclusive',
84
+ baselineDetail: profileContextLimitation || BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION,
85
+ };
86
+ }
68
87
  if (!profile || state === 'evidence_incomplete' || state === 'mail_auth_incomplete') {
69
88
  return {
70
89
  baselineRelation: 'inconclusive',
@@ -102,8 +121,16 @@ function relation(state, profile) {
102
121
  baselineDetail: 'The parked-domain profile is context rather than a claim about the expected candidate mail posture.',
103
122
  };
104
123
  }
105
- function rowLimitations(row, state) {
124
+ function rowLimitations(row, state, profileSourceState, profileContextComparable, profileContextLimitation) {
106
125
  const output = [];
126
+ if (profileSourceState !== 'ready') {
127
+ output.push(profileSourceState === 'loading'
128
+ ? 'Brand Profile context was still loading; official-domain mail posture was not evaluated.'
129
+ : 'Brand Profile context was unavailable; official-domain mail posture was not evaluated.');
130
+ }
131
+ if (profileSourceState === 'ready' && !profileContextComparable) {
132
+ output.push(profileContextLimitation || BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION);
133
+ }
107
134
  const coverage = dnsCoverage(row);
108
135
  if (!coverage)
109
136
  output.push('DNS source coverage was not recorded for this compact result.');
@@ -115,30 +142,48 @@ function rowLimitations(row, state) {
115
142
  output.push('No explicit MX is not equivalent to a null MX or proof that the domain cannot receive mail.');
116
143
  }
117
144
  output.push('Mail configuration alone does not establish use, intent, control, safety, or maliciousness.');
118
- return output.slice(0, 5);
145
+ return output.slice(0, 6);
119
146
  }
120
147
  export function buildBulkMailExposureReport(rowsRaw, options = {}) {
121
148
  const generatedAt = timestamp(options.generatedAt) ?? new Date().toISOString();
122
- const officialDomains = [...new Set((options.officialDomains ?? [])
149
+ const profileSourceState = options.profileSourceState ?? 'ready';
150
+ const currentProfileContext = normalizeBulkProfileContext(options.currentProfileContext ?? (profileSourceState === 'ready'
151
+ ? { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' }
152
+ : { sourceState: 'unavailable' }));
153
+ const profile = profileSourceState === 'ready' ? options.profile ?? null : null;
154
+ const officialDomains = [...new Set((profileSourceState === 'ready' ? options.officialDomains ?? [] : [])
123
155
  .map((domain) => domain.trim().toLowerCase().replace(/\.$/u, ''))
124
156
  .filter(Boolean))].slice(0, 20);
157
+ let profileContextUnevaluatedCount = 0;
125
158
  const rows = rowsRaw
126
- .map(normalizeBulkSessionResult)
159
+ .map((row) => normalizeBulkSessionResult(row))
127
160
  .filter((row) => Boolean(row))
128
161
  .filter((row) => row.trusted === null)
129
162
  .slice(0, MAX_BULK_MAIL_EXPOSURE_ROWS)
130
163
  .map((item) => {
131
164
  const state = exposureState(item);
165
+ const profileContextComparable = profileSourceState === 'ready'
166
+ && currentProfileContext.sourceState === 'ready'
167
+ && item.profileContext.sourceState === 'ready'
168
+ && item.profileContext.activeProfileId === currentProfileContext.activeProfileId
169
+ && item.profileContext.profileUpdatedAt === currentProfileContext.profileUpdatedAt;
170
+ const profileContextLimitation = profileContextComparable
171
+ ? item.profileContext.limitation
172
+ : item.profileContext.limitation || BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION;
173
+ if (!profileContextComparable)
174
+ profileContextUnevaluatedCount += 1;
132
175
  return {
133
176
  domain: item.domain,
134
177
  state,
135
178
  label: STATE_LABELS[state],
136
179
  detail: stateDetail(state),
137
- ...relation(state, options.profile ?? null),
180
+ ...relation(state, profile, profileSourceState, profileContextComparable, profileContextLimitation),
138
181
  mutationTypes: item.mutationTypes.slice(0, 40),
139
182
  registration: item.availability,
140
183
  sourceCoverage: item.sourceCoverage.slice(0, 12),
141
- limitations: rowLimitations(item, state),
184
+ profileContextState: item.profileContext.sourceState,
185
+ profileContextLimitation,
186
+ limitations: rowLimitations(item, state, profileSourceState, profileContextComparable, profileContextLimitation),
142
187
  };
143
188
  });
144
189
  const counts = {
@@ -156,16 +201,22 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
156
201
  generatedAt,
157
202
  observedAt: timestamp(options.observedAt),
158
203
  baseline: {
159
- profile: options.profile ?? null,
204
+ profile,
160
205
  officialDomains,
161
- label: baselineLabel(options.profile ?? null),
206
+ label: baselineLabel(profile, profileSourceState),
162
207
  limitations: [
208
+ ...(profileSourceState === 'loading'
209
+ ? ['Brand Profile context was loading, so profile-derived mail posture is inconclusive.']
210
+ : profileSourceState === 'unavailable'
211
+ ? ['Brand Profile context was unavailable, so profile-derived mail posture is inconclusive.']
212
+ : []),
163
213
  'The Brand Profile describes an analyst-configured official-domain posture; it is not a live observation.',
164
214
  'Candidate similarity and mail configuration do not establish use, control, intent, safety, or maliciousness.',
165
215
  ],
166
216
  },
167
217
  rows,
168
218
  counts,
219
+ profileContextUnevaluatedCount,
169
220
  limitations: [
170
221
  'This review uses compact Bulk evidence already collected and makes no additional request.',
171
222
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
@@ -176,13 +227,13 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
176
227
  export async function buildBulkMailExposureExport(report) {
177
228
  const unsigned = {
178
229
  schema: BULK_MAIL_EXPOSURE_SCHEMA,
179
- version: BULK_MAIL_EXPOSURE_VERSION,
230
+ version: BULK_MAIL_EXPOSURE_EXPORT_VERSION,
180
231
  report,
181
232
  };
182
- const digestSha256 = await sha256ArtifactDigest(unsigned);
233
+ const digestSha256 = await sha256ArtifactDigestV2(unsigned);
183
234
  const document = {
184
235
  ...unsigned,
185
- integrity: { algorithm: 'SHA-256', digestSha256 },
236
+ integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
186
237
  };
187
238
  return {
188
239
  document,
@@ -30,7 +30,7 @@ function retryRow(value, isStale) {
30
30
  export function buildBulkRetryPlan(values, mode, observedAtRaw = null, now = Date.now()) {
31
31
  const observationFreshness = freshness(observedAtRaw, now);
32
32
  const candidates = values
33
- .map(normalizeBulkSessionResult)
33
+ .map((row) => normalizeBulkSessionResult(row))
34
34
  .filter((value) => Boolean(value))
35
35
  .map((value) => retryRow(value, observationFreshness.state === 'stale'))
36
36
  .filter((value) => Boolean(value));
@@ -1,8 +1,8 @@
1
1
  import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
2
2
  import { BULK_REVIEW_STATES, } from "./bulk-review-model.js";
3
- import { sha256ArtifactDigest } from "./artifact-integrity.js";
3
+ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
4
4
  export const BULK_REVIEW_MANIFEST_SCHEMA = 'whoisleuth.bulk-review-manifest';
5
- export const BULK_REVIEW_MANIFEST_VERSION = 1;
5
+ export const BULK_REVIEW_MANIFEST_VERSION = 2;
6
6
  const MAX_MANIFEST_ROWS = 2_000;
7
7
  const REVIEW_STATE_SET = new Set(BULK_REVIEW_STATES);
8
8
  function timestamp(value, fallback) {
@@ -21,7 +21,7 @@ export async function buildBulkReviewManifest(input) {
21
21
  const observedAt = timestamp(input.observedAt, generatedAt);
22
22
  const states = reviewStateMap(input.reviewStates);
23
23
  const rows = input.rows
24
- .map(normalizeBulkSessionResult)
24
+ .map((row) => normalizeBulkSessionResult(row))
25
25
  .filter((item) => Boolean(item))
26
26
  .slice(0, MAX_MANIFEST_ROWS)
27
27
  .map((item) => ({
@@ -30,6 +30,7 @@ export async function buildBulkReviewManifest(input) {
30
30
  resultState: item.status,
31
31
  scanDepth: item.scanDepth,
32
32
  sourceCoverage: item.sourceCoverage,
33
+ profileContext: { ...item.profileContext },
33
34
  }));
34
35
  const unsigned = {
35
36
  schema: BULK_REVIEW_MANIFEST_SCHEMA,
@@ -45,14 +46,14 @@ export async function buildBulkReviewManifest(input) {
45
46
  rows,
46
47
  limitations: [
47
48
  'This manifest records the explicit review selection and view context for a separate CSV export.',
48
- 'It contains compact source states only and excludes raw payloads, contact records, notes, and transient request state.',
49
+ 'It contains compact source states and bounded row-level Brand Profile provenance only, and excludes raw payloads, Profile contents, contact records, notes, and transient request state.',
49
50
  'Reproducing the filters does not reproduce upstream responses or imply that evidence remains current.',
50
51
  ],
51
52
  };
52
- const digestSha256 = await sha256ArtifactDigest(unsigned);
53
+ const digestSha256 = await sha256ArtifactDigestV2(unsigned);
53
54
  const document = {
54
55
  ...unsigned,
55
- integrity: { algorithm: 'SHA-256', digestSha256 },
56
+ integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
56
57
  };
57
58
  return {
58
59
  document,
@@ -1,4 +1,5 @@
1
1
  import { normalizeDomain } from "./case-model.js";
2
+ import { normalizeBulkPresentationSortKey } from "./bulk-sort.js";
2
3
  export const BULK_REVIEW_SCHEMA = 'whoisleuth.bulk-review';
3
4
  export const BULK_REVIEW_SCHEMA_VERSION = 1;
4
5
  export const MAX_BULK_REVIEW_PRESETS = 24;
@@ -8,7 +9,7 @@ export const MAX_BULK_REVIEW_NAME_LENGTH = 80;
8
9
  export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
9
10
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
10
11
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
11
- const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'errors']);
12
+ const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'profile_unevaluated', 'errors']);
12
13
  const SOURCE_FILTERS = new Set(['', 'complete', 'limited', 'failed']);
13
14
  const LIFECYCLE_FILTERS = new Set(['', 'new', 'expiring', 'aged', 'unknown']);
14
15
  const AGE_FILTERS = new Set(['', '7d', '30d', '90d', '365d', 'older', 'unknown']);
@@ -50,7 +51,7 @@ function normalizeView(raw) {
50
51
  ? [...new Set(value.signalFilters.filter((item) => typeof item === 'string' && SIGNAL_FILTERS.has(item)))].slice(0, SIGNAL_FILTERS.size)
51
52
  : [];
52
53
  const sortKey = typeof value.sortKey === 'string' && SORT_KEYS.has(value.sortKey)
53
- ? value.sortKey
54
+ ? normalizeBulkPresentationSortKey(value.sortKey)
54
55
  : 'risk';
55
56
  return {
56
57
  primaryFilter: setValue(value.primaryFilter, PRIMARY_FILTERS, 'all'),