@slicedearth/whoisleuth-cli 1.45.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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  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 +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. 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
 
@@ -189,7 +194,7 @@ full contracts into the packaged reference.
189
194
 
190
195
  ### Offline artefact verification
191
196
 
192
- [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)
193
198
 
194
199
  ### Privacy-safe source reliability report
195
200
 
@@ -244,6 +249,18 @@ search contract.](cli-reference.md#workspace-archive-inspection)
244
249
 
245
250
  [Read the bounded TLS collection contract.](cli-reference.md#tls-intelligence)
246
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
+
247
264
  ### Registry-source comparison
248
265
 
249
266
  [Read the normalised RDAP and WHOIS comparison contract.](cli-reference.md#registry-source-comparison)
@@ -258,7 +275,7 @@ search contract.](cli-reference.md#workspace-archive-inspection)
258
275
 
259
276
  ### Offline supplied-evidence review
260
277
 
261
- [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)
262
279
 
263
280
  ### Domain control manifests
264
281
 
@@ -291,6 +308,8 @@ search contract.](cli-reference.md#workspace-archive-inspection)
291
308
  ### Lookup evidence export
292
309
 
293
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.
294
313
 
295
314
  ## Safety and limitations
296
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,
@@ -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])?$/;
@@ -68,7 +69,7 @@ function boundedText(value, maximum = MAX_PROFILE_TEXT_LENGTH) {
68
69
  return value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
69
70
  }
70
71
  export function normalizeBrandProfileId(value) {
71
- return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : null;
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)) {
@@ -420,17 +421,39 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
420
421
  if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
421
422
  throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
422
423
  }
423
- if (![2, 3, 4, 5, BRAND_PROFILE_SCHEMA_VERSION].includes(importedVersion ?? 0)) {
424
- 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);
425
442
  }
426
443
  const local = normalizeBrandProfileStore(localRaw).profiles;
444
+ for (const profile of local)
445
+ retainIdName(profile.id, profile.name);
427
446
  const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
428
447
  const input = profileList(importedRaw);
429
448
  let added = 0;
430
449
  let updated = 0;
431
450
  let skipped = Math.max(0, input.length - MAX_PROFILES * 4);
432
451
  for (const item of input.slice(0, MAX_PROFILES * 4)) {
433
- 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);
434
457
  const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
435
458
  const profile = normalizeBrandProfile(item, {
436
459
  existing,
@@ -442,6 +465,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
442
465
  skipped++;
443
466
  continue;
444
467
  }
468
+ retainIdName(profile.id, profile.name);
445
469
  if (existing) {
446
470
  byName.set(profile.name.toLowerCase(), profile);
447
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'),