@slicedearth/whoisleuth-cli 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/DISCLOSURE +8 -5
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -857
  5. package/cli/artifact-validation/lookup-evidence.mjs +15 -2
  6. package/cli/artifact-verify.mjs +32 -1
  7. package/cli/bulk-command-runner.mjs +5 -4
  8. package/cli/case-command.mjs +17 -17
  9. package/cli/case-pack.mjs +7 -1
  10. package/cli/ci-report.mjs +17 -5
  11. package/cli/collection-arguments.mjs +6 -0
  12. package/cli/collection-command-definitions.mjs +13 -3
  13. package/cli/command-definition.mjs +7 -5
  14. package/cli/discovery-scan-command-runner.mjs +13 -7
  15. package/cli/doctor.mjs +37 -3
  16. package/cli/domain-control-monitor.mjs +25 -2
  17. package/cli/domain-control-observations.mjs +52 -15
  18. package/cli/fail-policy.mjs +31 -22
  19. package/cli/formatters/html.mjs +3 -0
  20. package/cli/formatters/json.mjs +8 -1
  21. package/cli/formatters/terminal-lookup.mjs +24 -1
  22. package/cli/history-command-definitions.mjs +1 -1
  23. package/cli/investigation-plan.mjs +15 -0
  24. package/cli/lookup-brief.mjs +11 -5
  25. package/cli/lookup-command-runner.mjs +26 -18
  26. package/cli/lookup-diff.mjs +1 -0
  27. package/cli/review-command-definitions.mjs +11 -11
  28. package/cli/risk-policy.mjs +83 -0
  29. package/cli/saved-lookup.mjs +22 -7
  30. package/cli/source-reliability.mjs +12 -10
  31. package/cli/support-arguments.mjs +1 -1
  32. package/cli/support-command-definitions.mjs +5 -4
  33. package/cli/support-command-runner.mjs +6 -4
  34. package/cli/workflow-command-definitions.mjs +2 -1
  35. package/cli/workflow-command-runner.mjs +5 -1
  36. package/docs/cli-reference.md +56 -11
  37. package/docs/cli.md +162 -33
  38. package/docs/contextual-reviews.md +10 -0
  39. package/docs/infrastructure-observations.md +9 -0
  40. package/lib/dmarc-discovery.mjs +4 -2
  41. package/lib/dns-inheritance-review.mjs +14 -1
  42. package/lib/evidence-export.mjs +10 -4
  43. package/lib/evidence-report.mjs +2 -1
  44. package/lib/generated/cisa-kev-catalog.mjs +19 -4
  45. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  46. package/lib/generated/retire-browser-catalog.mjs +178 -5
  47. package/lib/idn-confusables.mjs +5 -1
  48. package/lib/lookup-collection-receipt.mjs +52 -0
  49. package/lib/lookup-diagnostics.mjs +2 -12
  50. package/lib/lookup-progress.mjs +29 -31
  51. package/lib/lookup-response-contract.mjs +9 -0
  52. package/lib/lookup-response.mjs +15 -14
  53. package/lib/lookup-source-progress.mjs +4 -2
  54. package/lib/lookup.mjs +33 -2
  55. package/lib/rdap-capabilities.mjs +43 -9
  56. package/lib/registry-insights.mjs +17 -6
  57. package/lib/sslbl-certificates.generated.mjs +143 -142
  58. package/lib/whois-authority.mjs +56 -1
  59. package/lib/whois-chain.mjs +2 -1
  60. package/lib/whois-contracts.mjs +2 -0
  61. package/lib/whois-normalization.mjs +4 -4
  62. package/package.json +6 -6
  63. package/packages/analysis/bounded-json.mjs +12 -2
  64. package/packages/analysis/brand-profile-signals.mjs +42 -0
  65. package/packages/cases/case-attachment-model.mjs +36 -1
  66. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  67. package/packages/cases/case-evidence-links.mjs +28 -0
  68. package/packages/cases/case-evidence-model.mjs +39 -6
  69. package/packages/cases/case-migration-model.mjs +9 -1
  70. package/packages/cases/case-object-coverage.mjs +106 -20
  71. package/packages/cases/case-record-operations.mjs +13 -0
  72. package/packages/cases/case-record-projection.mjs +28 -2
  73. package/packages/cases/case-report.mjs +5 -4
  74. package/packages/cases/case-response-packet-validation.mjs +880 -0
  75. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  76. package/packages/cases/case-response-packet.mjs +2 -15
  77. package/packages/cases/case-response-review-inputs.mjs +18 -1
  78. package/packages/cases/case-type-presentation.mjs +10 -0
  79. package/packages/cases/case-workflow-metadata.mjs +0 -8
  80. package/packages/comparison/capture-context.mjs +3 -2
  81. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  82. package/packages/contracts/case-portability.mjs +59 -28
  83. package/packages/contracts/cli-doctor.mjs +4 -0
  84. package/packages/contracts/cli-lookup.mjs +57 -46
  85. package/packages/contracts/lookup-evidence.mjs +12 -2
  86. package/packages/contracts/registry-insights.mjs +6 -0
  87. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  88. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  89. package/packages/evidence/lookup-collection-review.mjs +74 -0
  90. package/packages/interchange/external-findings-import.mjs +8 -4
  91. package/packages/investigation/bulk-domain-comparison.mjs +17 -8
  92. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  93. package/packages/investigation/domain-history-review.mjs +7 -1
  94. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  95. package/packages/workspace/bulk-session-model.mjs +8 -0
  96. package/third-party-notices.txt +30 -30
@@ -2,6 +2,7 @@
2
2
  // registry response referred by IANA can decide existence; later registrar
3
3
  // output is diagnostic and cannot replace an inconclusive registry result.
4
4
  import { normalizeWhoisChain } from "./whois-normalization.mjs";
5
+ import { MAX_WHOIS_BYTES } from "./whois-transport.mjs";
5
6
  import { domainToASCII } from 'node:url';
6
7
  import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
7
8
  import { parseIndentedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
@@ -133,6 +134,52 @@ function ianaRegistryReferral(hop) {
133
134
  }
134
135
  return null;
135
136
  }
137
+ /** A complete, target-bound delegation record can establish missing service;
138
+ * merely reaching the end of an unrecognised response cannot. */
139
+ function ianaDiscoveryHasNoWhoisService(chain, hop, requestedDomain) {
140
+ const expectedRoot = requestedDomain === undefined ? null : canonicalObjectName(requestedDomain)?.split('.').at(-1);
141
+ if (!expectedRoot || normalizedWhoisServer(hop.server) !== 'whois.iana.org')
142
+ return false;
143
+ if (!Array.isArray(chain) || chain.length !== 1)
144
+ return false;
145
+ const original = chain[0]?.response;
146
+ // Normalisation bounds imported text. Do not interpret a clipped prefix as
147
+ // an entire discovery record with no referral in its omitted remainder.
148
+ if (typeof original !== 'string' || original.length > MAX_WHOIS_BYTES
149
+ || Buffer.byteLength(original, 'utf8') > MAX_WHOIS_BYTES)
150
+ return false;
151
+ const text = hop.response ?? '';
152
+ if (/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(text)
153
+ || classifyHopEvidence(hop, 0) !== 'inconclusive')
154
+ return false;
155
+ const fields = new Map();
156
+ const emptyServiceFields = new Set();
157
+ let previousServiceField = false;
158
+ for (const line of text.split('\n')) {
159
+ if (previousServiceField && /^[ \t]+\S/u.test(line))
160
+ return false;
161
+ previousServiceField = false;
162
+ if (/^[ \t]*(?:refer|ReferralServer|whois)\b/iu.test(line)) {
163
+ const service = line.match(/^[ \t]*(refer|ReferralServer|whois):[ \t]*$/iu)?.[1]?.toLowerCase();
164
+ if (!service || emptyServiceFields.has(service))
165
+ return false;
166
+ emptyServiceFields.add(service);
167
+ previousServiceField = true;
168
+ }
169
+ const field = line.match(/^[ \t]*(domain|organisation|status|source):[ \t]*(.*)$/iu);
170
+ if (!field)
171
+ continue;
172
+ const key = field[1].toLowerCase();
173
+ if (fields.has(key))
174
+ return false;
175
+ fields.set(key, field[2].trim());
176
+ }
177
+ return emptyServiceFields.size > 0
178
+ && fields.get('domain')?.toLowerCase() === expectedRoot
179
+ && Boolean(fields.get('organisation'))
180
+ && fields.get('status')?.toUpperCase() === 'ACTIVE'
181
+ && fields.get('source')?.toUpperCase() === 'IANA';
182
+ }
136
183
  export function analyzeWhoisChainAuthority(chain, requestedDomain) {
137
184
  const source = normalizeWhoisChain(chain);
138
185
  const evidence = source.map((hop, index) => ({
@@ -177,7 +224,15 @@ export function whoisCollectionStatus(chain, requestedDomain) {
177
224
  const source = normalizeWhoisChain(chain);
178
225
  if (!source.length || source[0]?.error)
179
226
  return 'error';
227
+ const first = source[0];
228
+ if (first.server === 'registry capability policy') {
229
+ if (source.length === 1 && first.queryProfile === 'not-issued' && first.response === undefined
230
+ && first.address === undefined && first.queriedAt === undefined
231
+ && (first.status === 'unsupported' || first.status === 'skipped'))
232
+ return first.status;
233
+ return 'error';
234
+ }
180
235
  if (source.length === 1)
181
- return 'unsupported';
236
+ return ianaDiscoveryHasNoWhoisService(chain, first, requestedDomain) ? 'unsupported' : 'partial';
182
237
  return analyzeWhoisChainAuthority(source, requestedDomain).chainStatus;
183
238
  }
@@ -65,7 +65,8 @@ export async function buildWhoisChainUncached(query, options = {}) {
65
65
  server: 'registry capability policy',
66
66
  queryProfile: 'not-issued',
67
67
  responseEncoding: 'utf-8',
68
- error: admission.state === 'permission_required'
68
+ status: admission.state === 'permission_required' ? 'skipped' : 'unsupported',
69
+ detail: admission.state === 'permission_required'
69
70
  ? 'WHOIS collection requires registry permission or source authorisation; no socket was opened.'
70
71
  : 'IANA publishes no domain WHOIS service for this suffix; no socket was opened.',
71
72
  }];
@@ -2,3 +2,5 @@
2
2
  // consumers can depend on the record shape without importing network code.
3
3
  export const MAX_WHOIS_STATUSES = 100;
4
4
  export const MAX_WHOIS_QUERY_HOPS = 6;
5
+ // A stopped referral remains evidence even when no further request is issued.
6
+ export const MAX_WHOIS_CHAIN_HOPS = MAX_WHOIS_QUERY_HOPS + 1;
@@ -2,14 +2,12 @@
2
2
  // Keeping this separate lets authority analysis and field parsing share the
3
3
  // exact same defensive input boundary.
4
4
  import { MAX_WHOIS_BYTES } from "./whois-transport.mjs";
5
- // Collection performs at most six network queries and may retain one seventh,
6
- // non-issued hop explaining a loop or hop-limit termination.
7
- const MAX_WHOIS_HOPS = 7;
5
+ import { MAX_WHOIS_CHAIN_HOPS } from "./whois-contracts.mjs";
8
6
  export function normalizeWhoisChain(value) {
9
7
  if (!Array.isArray(value))
10
8
  return [];
11
9
  const normalized = [];
12
- for (const hopValue of value.slice(0, MAX_WHOIS_HOPS)) {
10
+ for (const hopValue of value.slice(0, MAX_WHOIS_CHAIN_HOPS)) {
13
11
  if (!hopValue || typeof hopValue !== 'object' || Array.isArray(hopValue))
14
12
  continue;
15
13
  const hop = hopValue;
@@ -25,6 +23,8 @@ export function normalizeWhoisChain(value) {
25
23
  // original hop retains its wire text. Lone and embedded controls remain.
26
24
  ...(typeof hop.response === 'string' ? { response: hop.response.slice(0, MAX_WHOIS_BYTES).replace(/\r\n/g, '\n') } : {}),
27
25
  ...(typeof hop.error === 'string' ? { error: hop.error.slice(0, 1000) } : {}),
26
+ ...(hop.status === 'unsupported' || hop.status === 'skipped' ? { status: hop.status } : {}),
27
+ ...(typeof hop.detail === 'string' ? { detail: hop.detail.slice(0, 1000) } : {}),
28
28
  });
29
29
  }
30
30
  return normalized;
package/package.json CHANGED
@@ -29,24 +29,24 @@
29
29
  "type": "git",
30
30
  "url": "git+https://github.com/slicedearth/whoisleuth.git"
31
31
  },
32
- "homepage": "https://www.whoisleuth.com/",
32
+ "homepage": "https://www.whoisleuth.com",
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "2.6.0",
36
+ "version": "2.8.0",
37
37
  "dependencies": {
38
38
  "@nuintun/qrcode": "5.0.3",
39
- "@peculiar/x509": "2.1.0",
39
+ "@peculiar/x509": "2.2.0",
40
40
  "fast-png": "8.0.0",
41
41
  "fflate": "0.8.3",
42
42
  "maxmind": "5.0.7",
43
43
  "parse5": "8.0.1",
44
44
  "pdfjs-dist": "6.4.299",
45
- "postal-mime": "4.0.4",
45
+ "postal-mime": "4.0.7",
46
46
  "reflect-metadata": "0.2.2",
47
47
  "saxes": "6.0.0",
48
- "tldts": "7.4.16",
49
- "undici": "8.11.2"
48
+ "tldts": "7.4.18",
49
+ "undici": "8.12.2"
50
50
  },
51
51
  "files": [
52
52
  "bin/**/*.mjs",
@@ -127,7 +127,7 @@ export function assertBoundedJsonStructure(value, label = 'JSON value', limits =
127
127
  };
128
128
  visit(value, 0);
129
129
  }
130
- export function scanBoundedJson(raw, limits = {}, observeString) {
130
+ export function scanBoundedJson(raw, limits = {}, observeString, observeStringLocation) {
131
131
  const maximumDepth = limits.maximumDepth ?? MAX_BOUNDED_JSON_DEPTH;
132
132
  const maximumKeys = limits.maximumKeys ?? MAX_BOUNDED_JSON_KEYS;
133
133
  const maximumValues = limits.maximumValues ?? MAX_BOUNDED_JSON_VALUES;
@@ -136,6 +136,7 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
136
136
  let keys = 0;
137
137
  let values = 0;
138
138
  let stringCodeUnits = 0;
139
+ const path = [];
139
140
  const whitespace = () => {
140
141
  while (index < raw.length) {
141
142
  const code = raw.charCodeAt(index);
@@ -213,6 +214,7 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
213
214
  if (character === '"') {
214
215
  const parsedString = stringToken();
215
216
  observeString?.({ ...parsedString, kind: 'value', ...(propertyKey === undefined ? {} : { propertyKey }) });
217
+ observeStringLocation?.({ ...parsedString, end: index, path: [...path] });
216
218
  return;
217
219
  }
218
220
  if (character === '{') {
@@ -245,7 +247,11 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
245
247
  if (raw[index] !== ':')
246
248
  syntaxError();
247
249
  index += 1;
250
+ if (observeStringLocation)
251
+ path.push(key);
248
252
  value(depth + 1, key);
253
+ if (observeStringLocation)
254
+ path.pop();
249
255
  whitespace();
250
256
  if (raw[index] === '}') {
251
257
  index += 1;
@@ -271,7 +277,11 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
271
277
  if (items > maximumContainerItems) {
272
278
  throw new TypeError(`Artefact JSON contains a container with more than ${maximumContainerItems} items.`);
273
279
  }
280
+ if (observeStringLocation)
281
+ path.push(items - 1);
274
282
  value(depth + 1);
283
+ if (observeStringLocation)
284
+ path.pop();
275
285
  whitespace();
276
286
  if (raw[index] === ']') {
277
287
  index += 1;
@@ -312,7 +322,7 @@ export function parseBoundedJson(raw, options) {
312
322
  if (bytes < 1 || bytes > options.maximumBytes) {
313
323
  throw new TypeError(`${label} must be between 1 byte and ${options.maximumBytes} bytes.`);
314
324
  }
315
- scanBoundedJson(raw, options.limits);
325
+ scanBoundedJson(raw, options.limits, undefined, options.observeStringLocation);
316
326
  try {
317
327
  return JSON.parse(raw);
318
328
  }
@@ -0,0 +1,42 @@
1
+ import { hammingDistanceHex, isInformativePerceptualHash } from "./perceptual-hash-comparison.mjs";
2
+ function normalizedDomain(value) {
3
+ return value.trim().toLowerCase().replace(/\.$/u, '');
4
+ }
5
+ /** Shared exact comparison and role precedence for browser and CLI consumers. */
6
+ export function profileDomainMatch(domain, profile = null) {
7
+ if (!profile || !domain)
8
+ return null;
9
+ const target = normalizedDomain(domain);
10
+ const lists = [
11
+ ['official', profile.officialDomains, 'Exact official-domain declaration'],
12
+ ['partner', profile.approvedPartnerDomains, 'Exact approved-partner declaration'],
13
+ ['allowlisted', profile.allowlistedDomains, 'Exact domain allowlist entry'],
14
+ ];
15
+ for (const [kind, values, reason] of lists) {
16
+ const matched = values.find(value => normalizedDomain(value) === target);
17
+ if (matched !== undefined)
18
+ return { kind, matchedDomain: normalizedDomain(matched), reason };
19
+ }
20
+ return null;
21
+ }
22
+ export function profileDomainKind(domain, profile = null) {
23
+ return profileDomainMatch(domain, profile)?.kind ?? null;
24
+ }
25
+ export function profileSignals(domain, evidence, profile = null) {
26
+ const trusted = profileDomainKind(domain, profile);
27
+ if (!profile || trusted)
28
+ return { trusted, faviconMatch: false, faviconNearMatch: false, reusesOfficialAssets: false };
29
+ const exact = Boolean(evidence.faviconHash && profile.officialFaviconHash && evidence.faviconHash === profile.officialFaviconHash);
30
+ const distance = isInformativePerceptualHash(evidence.faviconPHash) && isInformativePerceptualHash(profile.officialFaviconPHash)
31
+ ? hammingDistanceHex(evidence.faviconPHash, profile.officialFaviconPHash)
32
+ : null;
33
+ const official = new Set(profile.officialDomains.map(normalizedDomain));
34
+ const reused = Array.isArray(evidence.externalAssetHosts)
35
+ && evidence.externalAssetHosts.some((host) => typeof host === 'string' && official.has(normalizedDomain(host)));
36
+ return {
37
+ trusted: null,
38
+ faviconMatch: exact,
39
+ faviconNearMatch: !exact && distance !== null && distance <= 8,
40
+ reusesOfficialAssets: reused,
41
+ };
42
+ }
@@ -1,7 +1,7 @@
1
1
  import { MAX_SELECTED_FILES, SELECTED_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
2
2
  import { array, enumeration, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
3
3
  import { readRetainedFileReference } from "../evidence/retained-file.mjs";
4
- import { readImageDerivation } from "../evidence/image-regions.mjs";
4
+ import { declareImageDerivation, readImageDerivation } from "../evidence/image-regions.mjs";
5
5
  /** Case provenance is independent of deduplicated immutable content. */
6
6
  export function readCaseAttachment(value) {
7
7
  const item = exactOptional(value, ['id', 'fileName', 'mediaType', 'source', 'observedAt', 'retainedAt', 'digestSha256', 'byteLength'], ['derivation'], 'Case attachment');
@@ -50,6 +50,41 @@ export function mergeCaseAttachments(local, incoming) {
50
50
  export function caseAttachmentReferences(cases) {
51
51
  return cases.flatMap(record => (record.attachments ?? []).map(({ digestSha256, byteLength }) => ({ digestSha256, byteLength })));
52
52
  }
53
+ /** Follow declared parents in one admitted Case's metadata; never resolve files or other Cases. */
54
+ export function reviewCaseImageDerivation(attachment, retained) {
55
+ if (!attachment.derivation)
56
+ return null;
57
+ if (!Array.isArray(retained) || retained.length > MAX_SELECTED_FILES)
58
+ throw new TypeError('Image history exceeds the Case attachment bound.');
59
+ const references = new Map();
60
+ for (const item of retained)
61
+ references.set(item.id, references.has(item.id) ? null : item);
62
+ const steps = [];
63
+ const finish = (termination) => Object.freeze({ steps: Object.freeze(steps), termination });
64
+ const visited = new Set([attachment.id]);
65
+ let current = attachment;
66
+ for (let depth = 0; depth < MAX_SELECTED_FILES; depth++) {
67
+ const derivation = current.derivation;
68
+ const declaration = declareImageDerivation(derivation);
69
+ const parent = references.get(derivation.sourceAttachmentId);
70
+ const matches = parent?.mediaType === 'image/png'
71
+ && parent.digestSha256 === declaration.source.digestSha256
72
+ && parent.byteLength === declaration.source.byteLength;
73
+ const parentReference = parent === undefined ? 'missing' : matches ? 'matching' : 'conflicting';
74
+ steps.push(Object.freeze({ declaration, parentReference, parentHasDeclaredEdits: matches ? Boolean(parent?.derivation) : null }));
75
+ if (parentReference === 'missing')
76
+ return finish('missing_parent');
77
+ if (!matches || !parent)
78
+ return finish('conflicting_parent');
79
+ if (visited.has(parent.id))
80
+ return finish('cycle');
81
+ if (!parent.derivation)
82
+ return finish('no_further_declaration');
83
+ visited.add(parent.id);
84
+ current = parent;
85
+ }
86
+ return finish('limit');
87
+ }
53
88
  export function addCaseAttachments(record, attachments, updatedAt) {
54
89
  iso(updatedAt, 'Case update time');
55
90
  return { ...record, attachments: mergeCaseAttachments(record.attachments, attachments) ?? [], updatedAt };
@@ -0,0 +1,68 @@
1
+ import { MAX_CASE_EVIDENCE_PINS, COLLECTION_RECEIPT_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
2
+ import { parseLookupCollectionReceipt, lookupCollectionReceiptIdentity } from "../evidence/lookup-collection-receipt.mjs";
3
+ import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
+ /** Removing the last pin in a group also removes its context. No other pin edit
5
+ * needs bookkeeping, and normalisation never changes a pin or checkpoint ID. */
6
+ export function readCaseCheckpointReceipts(value, pins, sourceVersion) {
7
+ if (value === undefined)
8
+ return undefined;
9
+ if (sourceVersion != null && sourceVersion < COLLECTION_RECEIPT_CASE_SCHEMA_VERSION) {
10
+ throw new TypeError('Checkpoint collection receipts require Case schema 20.');
11
+ }
12
+ if (!Array.isArray(value) || value.length > MAX_CASE_EVIDENCE_PINS) {
13
+ throw new TypeError('Checkpoint collection receipts must fit the retained evidence-pin limit.');
14
+ }
15
+ const groups = new Set(pins.map(pin => pin.checkpointId).filter(Boolean));
16
+ const entries = new Map();
17
+ for (const raw of value) {
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
19
+ || Object.keys(raw).length !== 2 || !Object.hasOwn(raw, 'checkpointId') || !Object.hasOwn(raw, 'collectionReceipt')
20
+ || typeof raw.checkpointId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(raw.checkpointId)) {
21
+ throw new TypeError('A checkpoint collection receipt is malformed.');
22
+ }
23
+ const collectionReceipt = parseLookupCollectionReceipt(raw.collectionReceipt);
24
+ if (!collectionReceipt || collectionReceipt.targetClass !== 'domain')
25
+ throw new TypeError('A Case checkpoint requires a domain collection receipt.');
26
+ const previous = entries.get(raw.checkpointId);
27
+ if (previous && lookupCollectionReceiptIdentity(previous.collectionReceipt) !== lookupCollectionReceiptIdentity(collectionReceipt)) {
28
+ throw new TypeError('Conflicting collection receipts use the same checkpoint identity. No evidence was changed.');
29
+ }
30
+ entries.set(raw.checkpointId, { checkpointId: raw.checkpointId, collectionReceipt });
31
+ }
32
+ const retained = [...entries.values()].filter(entry => groups.has(entry.checkpointId))
33
+ .sort((a, b) => a.checkpointId < b.checkpointId ? -1 : a.checkpointId > b.checkpointId ? 1 : 0);
34
+ for (const entry of retained) {
35
+ if (pins.some(pin => pin.checkpointId === entry.checkpointId && pin.collectionDepth !== entry.collectionReceipt.requestedDepth)) {
36
+ throw new TypeError('The checkpoint collection receipt does not match its retained pins.');
37
+ }
38
+ }
39
+ return retained.length ? retained : undefined;
40
+ }
41
+ export function mergeCaseCheckpointReceipts(left, right, pins) {
42
+ const before = readCaseCheckpointReceipts(left.checkpointCollectionReceipts, left.evidencePins) ?? [];
43
+ const incoming = readCaseCheckpointReceipts(right.checkpointCollectionReceipts, right.evidencePins) ?? [];
44
+ // Each input is bounded independently. Deduplicate before applying the final
45
+ // group bound so re-importing a full Case remains idempotent.
46
+ const byId = new Map(before.map(entry => [entry.checkpointId, entry]));
47
+ for (const entry of incoming) {
48
+ const previous = byId.get(entry.checkpointId);
49
+ if (previous && lookupCollectionReceiptIdentity(previous.collectionReceipt) !== lookupCollectionReceiptIdentity(entry.collectionReceipt)) {
50
+ throw new TypeError('Conflicting collection receipts use the same checkpoint identity. No evidence was changed.');
51
+ }
52
+ byId.set(entry.checkpointId, entry);
53
+ }
54
+ const groups = new Set(pins.map(pin => pin.checkpointId));
55
+ const retained = [...byId.values()].filter(entry => groups.has(entry.checkpointId));
56
+ for (const entry of retained) {
57
+ // A colliding group ID must not lend its receipt to unrelated retained
58
+ // pins. Identical imports and partial retention remain valid.
59
+ const sourcePins = [before.some(item => item.checkpointId === entry.checkpointId) ? left.evidencePins : [],
60
+ incoming.some(item => item.checkpointId === entry.checkpointId) ? right.evidencePins : []].flat();
61
+ for (const pin of pins.filter(pin => pin.checkpointId === entry.checkpointId)) {
62
+ if (!sourcePins.some(source => source.id === pin.id && canonicalArtifactJsonV2(source) === canonicalArtifactJsonV2(pin))) {
63
+ throw new TypeError('A checkpoint identity belongs to different retained pins. Its collection context cannot be reassigned.');
64
+ }
65
+ }
66
+ }
67
+ return readCaseCheckpointReceipts(retained, pins);
68
+ }
@@ -115,6 +115,34 @@ export function caseEvidenceLinkIssues(links, pins) {
115
115
  cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
116
116
  }));
117
117
  }
118
+ /** Follow declared sources, not automatic context matches or withdrawn links. */
119
+ export function traceCaseEvidenceSources(links, rootPinIds) {
120
+ array(links, 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
121
+ const roots = [...new Set(array(rootPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS).map(identifier))];
122
+ const neighbours = new Map();
123
+ function add(from, to, linkId) {
124
+ const entries = neighbours.get(from) ?? [];
125
+ entries.push({ linkId, pinId: to });
126
+ neighbours.set(from, entries);
127
+ }
128
+ for (const link of links)
129
+ if (!link.withdrawal) {
130
+ add(link.fromPinId, link.toPinId, link.id);
131
+ if (link.kind === 'shared_source')
132
+ add(link.toPinId, link.fromPinId, link.id);
133
+ }
134
+ const pinIds = new Set(roots), linkIds = new Set();
135
+ // Set iteration visits newly reached pins once, including unresolved references.
136
+ for (const pinId of pinIds)
137
+ for (const edge of neighbours.get(pinId) ?? []) {
138
+ linkIds.add(edge.linkId);
139
+ pinIds.add(edge.pinId);
140
+ }
141
+ return {
142
+ rootPinIds: roots, pinIds: [...pinIds], linkIds: [...linkIds],
143
+ withdrawnLinkIds: links.filter(link => link.withdrawal && (pinIds.has(link.fromPinId) || pinIds.has(link.toPinId))).map(link => link.id),
144
+ };
145
+ }
118
146
  /** Matching context is disclosed, never counted as independent corroboration. */
119
147
  export function caseEvidenceSharedContext(pins) {
120
148
  const groups = new Map();
@@ -5,7 +5,8 @@ import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scorin
5
5
  import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
6
6
  import { latestObservationCohort } from "../evidence/latest-observations.mjs";
7
7
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
- import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
8
+ import { COLLECTION_RECEIPT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
9
+ import { parseLookupCollectionReceipt, lookupCollectionReceiptIdentity } from "../evidence/lookup-collection-receipt.mjs";
9
10
  import { validWebObservationMode } from "../evidence/lookup-target.mjs";
10
11
  import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
11
12
  import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
@@ -125,6 +126,7 @@ const MATERIAL_FIELD_ORDER = [
125
126
  'webObservationMode',
126
127
  'scanDepth',
127
128
  'webCollectionQuality',
129
+ 'collectionReceipt',
128
130
  'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
129
131
  'riskFactors', 'opportunityFactors',
130
132
  'registrar', 'createdDate', 'expiryDate', 'nameservers',
@@ -178,7 +180,7 @@ function isEmptyMaterial(value) {
178
180
  }
179
181
  // Fields that describe the capture rather than assert evidence, so they never
180
182
  // on their own keep an otherwise-empty snapshot alive.
181
- const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
183
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'collectionReceipt', 'confidence']);
182
184
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
183
185
  // bare confidence/unknown-availability) is dropped rather than added to a
184
186
  // timeline.
@@ -198,7 +200,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
198
200
  const value = materialValue(field, snapshot, legacyOrder);
199
201
  // Optional collection context does not alter historical fingerprints when
200
202
  // absent; recorded context separates otherwise-identical captures.
201
- if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality' || field === 'hasExternalPasswordForm') && value === null)
203
+ if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality' || field === 'collectionReceipt' || field === 'hasExternalPasswordForm') && value === null)
202
204
  continue;
203
205
  canonical[field] = value;
204
206
  }
@@ -220,6 +222,23 @@ export function normalizeSnapshot(raw, options = {}) {
220
222
  const built = buildSnapshot(raw, options);
221
223
  return built ? built.snapshot : null;
222
224
  }
225
+ /** Shared artefacts use receipt-free identities, not hashes of private choices.
226
+ * Unchanged historical histories keep their exact identifiers. */
227
+ export function caseEvidenceWithoutCollectionReceipts(record) {
228
+ if (!Array.isArray(record.evidenceHistory))
229
+ return [];
230
+ if (!record.evidenceHistory.some(snapshot => snapshot?.collectionReceipt !== undefined)) {
231
+ return structuredClone(record.evidenceHistory);
232
+ }
233
+ return normalizeEvidenceHistory(record.evidenceHistory.map(snapshot => {
234
+ if (!snapshot || typeof snapshot !== 'object')
235
+ return snapshot;
236
+ const { collectionReceipt: _receipt, ...evidence } = snapshot;
237
+ return evidence;
238
+ }), {
239
+ fallback: null, caseDomain: record.domain,
240
+ });
241
+ }
223
242
  /** @returns {{ snapshot: CaseEvidenceSnapshot, material: string } | null} */
224
243
  function buildSnapshot(raw, options) {
225
244
  if (!raw || typeof raw !== 'object')
@@ -236,6 +255,13 @@ function buildSnapshot(raw, options) {
236
255
  // captures declare code-unit ordering; the declaration survives every save.
237
256
  const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
238
257
  const scanDepth = normalizeScanDepth(record.scanDepth);
258
+ const collectionReceipt = parseLookupCollectionReceipt(record.collectionReceipt);
259
+ if (collectionReceipt && options.sourceVersion != null && options.sourceVersion < COLLECTION_RECEIPT_CASE_SCHEMA_VERSION) {
260
+ throw new TypeError('Collection receipts require Case schema 20 or later; historical evidence was not reinterpreted.');
261
+ }
262
+ if (collectionReceipt && (collectionReceipt.targetClass !== 'domain' || collectionReceipt.requestedDepth !== scanDepth)) {
263
+ throw new TypeError('Collection receipt does not match the Case assessment.');
264
+ }
239
265
  const httpSummary = normalizeHttpSummary(record);
240
266
  const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
241
267
  if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
@@ -259,6 +285,7 @@ function buildSnapshot(raw, options) {
259
285
  ...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
260
286
  scanDepth,
261
287
  ...(webCollectionQuality ? { webCollectionQuality } : {}),
288
+ ...(collectionReceipt ? { collectionReceipt } : {}),
262
289
  availability: evidenceString(record.availability),
263
290
  confidence: evidenceString(record.confidence),
264
291
  riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
@@ -525,9 +552,14 @@ function buildEvidenceTimeline(history, enforceCollectionQuality) {
525
552
  const incomparableReasons = orderingLimitation
526
553
  ? ['other']
527
554
  : comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
528
- if (comparable && !changes.length && !incomparableReasons.length
529
- && canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
530
- incomparableReasons.push('other');
555
+ const collectionContextChanged = Boolean(comparable
556
+ && lookupCollectionReceiptIdentity(previous.snapshot.collectionReceipt) !== lookupCollectionReceiptIdentity(snapshot.collectionReceipt));
557
+ if (comparable && !changes.length && !incomparableReasons.length) {
558
+ const { collectionReceipt: _beforeReceipt, ...before } = previous.snapshot;
559
+ const { collectionReceipt: _afterReceipt, ...after } = snapshot;
560
+ if (canonicalMaterialString(before) !== canonicalMaterialString(after))
561
+ incomparableReasons.push('other');
562
+ }
531
563
  return {
532
564
  snapshot,
533
565
  isBaseline,
@@ -536,6 +568,7 @@ function buildEvidenceTimeline(history, enforceCollectionQuality) {
536
568
  hasIncomparableChange: incomparableReasons.length > 0,
537
569
  incomparableReasons,
538
570
  orderingLimitation,
571
+ collectionContextChanged,
539
572
  };
540
573
  });
541
574
  }
@@ -6,6 +6,8 @@ import { normalizeCase } from "./case-record-operations.mjs";
6
6
  import { normalizeCaseObjective } from "./case-incident-context.mjs";
7
7
  import { normalizeDomain } from "../evidence/domain-name.mjs";
8
8
  import { mergeImportedEvidenceHistory, normalizeEvidenceHistory } from "./case-evidence-model.mjs";
9
+ import { assertCaseCollectionReceiptAudience } from "./case-record-projection.mjs";
10
+ import { readCaseCheckpointReceipts, mergeCaseCheckpointReceipts } from "./case-checkpoint-receipts.mjs";
9
11
  import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
10
12
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
11
13
  import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
@@ -242,6 +244,7 @@ function extractImportPatch(raw, importedVersion) {
242
244
  caseDomain: domain,
243
245
  }),
244
246
  evidencePins,
247
+ checkpointCollectionReceipts: readCaseCheckpointReceipts(record.checkpointCollectionReceipts, evidencePins, importedVersion),
245
248
  decisions,
246
249
  actions,
247
250
  assertions,
@@ -297,6 +300,7 @@ function caseFromPatch(patch, now) {
297
300
  source: patch.source ?? DEFAULT_SOURCE,
298
301
  evidenceHistory: patch.evidenceHistory,
299
302
  evidencePins: patch.evidencePins,
303
+ ...(patch.checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts: patch.checkpointCollectionReceipts }),
300
304
  decisions: patch.decisions,
301
305
  actions: patch.actions,
302
306
  assertions: patch.assertions,
@@ -327,6 +331,7 @@ function applyImportPatch(local, patch) {
327
331
  const historySelection = mergeImportedEvidenceHistory(local.evidenceHistory, patch.evidenceHistory, local.domain);
328
332
  const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
329
333
  const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
334
+ const checkpointCollectionReceipts = mergeCaseCheckpointReceipts(local, patch, evidencePins);
330
335
  const pinIds = new Set(evidencePins.map((item) => item.id));
331
336
  const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
332
337
  const assertionSelection = retainLocalAuthoredRecords(local.assertions, patch.assertions, MAX_CASE_ASSERTIONS);
@@ -351,8 +356,9 @@ function applyImportPatch(local, patch) {
351
356
  + assertionSelection.omitted
352
357
  + trailSelection.omitted
353
358
  + sightingSelection.omitted;
359
+ const { checkpointCollectionReceipts: _previousReceipts, ...localFields } = local;
354
360
  return { record: {
355
- ...local,
361
+ ...localFields,
356
362
  title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
357
363
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
358
364
  disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
@@ -361,6 +367,7 @@ function applyImportPatch(local, patch) {
361
367
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
362
368
  evidenceHistory: historySelection.records,
363
369
  evidencePins,
370
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
364
371
  decisions,
365
372
  actions,
366
373
  assertions,
@@ -465,6 +472,7 @@ function caseCollectionImportEnvelope(importedRaw) {
465
472
  || caseTimestampOrNull(root.exportedAt) !== root.exportedAt) {
466
473
  throw new Error('The WHOISleuth CLI Case-pack envelope is invalid.');
467
474
  }
475
+ assertCaseCollectionReceiptAudience(root, audience);
468
476
  for (const reportValue of reports) {
469
477
  const report = objectRecord(reportValue);
470
478
  assertOnlyEnvelopeKeys(report, rootVersion === PUBLIC_CASE_SCHEMA_VERSION ? CLI_CASE_PACK_PUBLIC_REPORT_KEYS : CLI_CASE_PACK_REPORT_KEYS, 'CLI Case-pack report');