@slicedearth/whoisleuth-cli 2.6.0 → 2.7.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.
- package/DISCLOSURE +8 -5
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -857
- package/cli/artifact-validation/lookup-evidence.mjs +15 -2
- package/cli/artifact-verify.mjs +32 -1
- package/cli/case-command.mjs +17 -17
- package/cli/case-pack.mjs +7 -1
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +5 -5
- package/cli/doctor.mjs +37 -3
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +8 -1
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/investigation-plan.mjs +15 -0
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +20 -13
- package/cli/review-command-definitions.mjs +11 -11
- package/cli/saved-lookup.mjs +22 -7
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +6 -4
- package/cli/workflow-command-definitions.mjs +2 -1
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +109 -32
- package/docs/contextual-reviews.md +10 -0
- package/docs/infrastructure-observations.md +9 -0
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +2 -1
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +9 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/sslbl-certificates.generated.mjs +143 -142
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +2 -1
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +6 -6
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-evidence-links.mjs +28 -0
- package/packages/cases/case-evidence-model.mjs +39 -6
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +106 -20
- package/packages/cases/case-record-operations.mjs +13 -0
- package/packages/cases/case-record-projection.mjs +28 -2
- package/packages/cases/case-report.mjs +5 -4
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +2 -15
- package/packages/cases/case-response-review-inputs.mjs +18 -1
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +0 -8
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/case-portability.mjs +59 -28
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/lookup-evidence.mjs +12 -2
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/interchange/external-findings-import.mjs +8 -4
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/domain-history-review.mjs +7 -1
- package/packages/workspace/brand-keyword-campaign.mjs +11 -1
- package/packages/workspace/bulk-session-model.mjs +8 -0
- package/third-party-notices.txt +29 -29
package/lib/whois-authority.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/whois-chain.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}];
|
package/lib/whois-contracts.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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.
|
|
36
|
+
"version": "2.7.0",
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@nuintun/qrcode": "5.0.3",
|
|
39
|
-
"@peculiar/x509": "2.
|
|
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.
|
|
45
|
+
"postal-mime": "4.0.7",
|
|
46
46
|
"reflect-metadata": "0.2.2",
|
|
47
47
|
"saxes": "6.0.0",
|
|
48
|
-
"tldts": "7.4.
|
|
49
|
-
"undici": "8.
|
|
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
|
}
|
|
@@ -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
|
-
|
|
529
|
-
&&
|
|
530
|
-
|
|
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
|
-
...
|
|
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');
|