@slicedearth/whoisleuth-cli 2.1.0 → 2.2.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/docs/cli.md
CHANGED
|
@@ -56,6 +56,18 @@ whoisleuth lookup example.test --deep --browse
|
|
|
56
56
|
whoisleuth lookup example.test --deep --markdown --output lookup.md
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
To continue a reviewed terminal Lookup in the browser, save the completed
|
|
60
|
+
private document from the interactive view:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
whoisleuth lookup example.test --deep --browse --save-lookup lookup.json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
In Console Lookup, open **Replay exported evidence**, select the file, verify
|
|
67
|
+
its digest and source states, then create or update a browser-local Case. The
|
|
68
|
+
file is not uploaded. Case classification, exact incident links, response
|
|
69
|
+
actions and packet preparation remain deliberate browser steps.
|
|
70
|
+
|
|
59
71
|
Process selected local input:
|
|
60
72
|
|
|
61
73
|
```bash
|
package/lib/evidence-export.mjs
CHANGED
|
@@ -10,8 +10,9 @@ import { REGISTRAR_STANDING_SCHEMA, REGISTRAR_STANDING_VERSION, registrarIanaIds
|
|
|
10
10
|
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
11
11
|
export const V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 26;
|
|
12
12
|
export const PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION = 27;
|
|
13
|
+
export const LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 28;
|
|
13
14
|
export const PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION = PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION;
|
|
14
|
-
export const LOOKUP_EVIDENCE_SCHEMA_VERSION =
|
|
15
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION;
|
|
15
16
|
export const SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([
|
|
16
17
|
V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
17
18
|
PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
@@ -35,7 +36,7 @@ export const LOOKUP_EVIDENCE_COMPATIBILITY = defineSchemaCompatibility({
|
|
|
35
36
|
writeSemantics: 'read_only',
|
|
36
37
|
byteBudget: LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES,
|
|
37
38
|
owner: 'lib/evidence-export.mts',
|
|
38
|
-
note: 'Exact v1.47.4 version 26
|
|
39
|
+
note: 'Exact v1.47.4 version 26, published v2.0.1 version 27, and latest-public v2.1.0 version 28 remain replayable; version 28 adds a bounded official-source registrar-standing projection.',
|
|
39
40
|
});
|
|
40
41
|
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
41
42
|
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
package/package.json
CHANGED
|
@@ -1,11 +1,101 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
+
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
3
4
|
import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
|
|
4
5
|
import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
5
6
|
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
|
|
6
7
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
7
8
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
|
|
8
9
|
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
|
|
10
|
+
export const MAX_CASE_OBJECTIVE_LENGTH = 320;
|
|
11
|
+
export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
|
|
12
|
+
export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
|
|
13
|
+
const OBJECTIVE_PREFIX = 'Objective: ';
|
|
14
|
+
const RETENTION_SEPARATOR = ' | URL retained: ';
|
|
15
|
+
function boundedContextText(value, maximum) {
|
|
16
|
+
return typeof value === 'string'
|
|
17
|
+
? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
18
|
+
: '';
|
|
19
|
+
}
|
|
20
|
+
export function normalizeCaseObjective(value) {
|
|
21
|
+
return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
|
|
22
|
+
}
|
|
23
|
+
export function parseIncidentUrlContext(value) {
|
|
24
|
+
if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
|
|
25
|
+
return null;
|
|
26
|
+
if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
|
|
27
|
+
return null;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
|
|
36
|
+
return null;
|
|
37
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
38
|
+
const registrableDomain = canonicalRegistrableDomain(hostname);
|
|
39
|
+
if (!registrableDomain)
|
|
40
|
+
return null;
|
|
41
|
+
const exactUrl = parsed.toString();
|
|
42
|
+
if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
|
|
43
|
+
return null;
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
exactUrl,
|
|
46
|
+
hostname,
|
|
47
|
+
registrableDomain,
|
|
48
|
+
originUrl: parsed.origin,
|
|
49
|
+
hasPath: parsed.pathname !== '/',
|
|
50
|
+
hasQuery: Boolean(parsed.search),
|
|
51
|
+
hasFragment: Boolean(parsed.hash),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function caseInvestigationContext(record) {
|
|
55
|
+
if (!record)
|
|
56
|
+
return null;
|
|
57
|
+
for (const assertion of [...record.assertions].reverse()) {
|
|
58
|
+
if (assertion.kind !== 'next_step' || assertion.state !== 'open'
|
|
59
|
+
|| !assertion.statement.startsWith(INCIDENT_CONTEXT_STATEMENT_PREFIX))
|
|
60
|
+
continue;
|
|
61
|
+
const incidentUrl = assertion.statement.slice(INCIDENT_CONTEXT_STATEMENT_PREFIX.length);
|
|
62
|
+
const parsed = parseIncidentUrlContext(incidentUrl);
|
|
63
|
+
if (!parsed || parsed.registrableDomain !== record.domain)
|
|
64
|
+
continue;
|
|
65
|
+
const rationale = assertion.rationale ?? '';
|
|
66
|
+
const separatorIndex = rationale.lastIndexOf(RETENTION_SEPARATOR);
|
|
67
|
+
const objective = separatorIndex > OBJECTIVE_PREFIX.length && rationale.startsWith(OBJECTIVE_PREFIX)
|
|
68
|
+
? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, separatorIndex))
|
|
69
|
+
: '';
|
|
70
|
+
const retention = separatorIndex >= 0 ? rationale.slice(separatorIndex + RETENTION_SEPARATOR.length) : '';
|
|
71
|
+
if (!objective || (retention !== 'exact' && retention !== 'origin_only'))
|
|
72
|
+
continue;
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
objective,
|
|
75
|
+
incidentUrl: parsed.exactUrl,
|
|
76
|
+
urlRetention: retention,
|
|
77
|
+
assertionId: assertion.id,
|
|
78
|
+
updatedAt: assertion.updatedAt,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
export function caseInvestigationContextAssertion(input) {
|
|
84
|
+
const objective = normalizeCaseObjective(input.objective);
|
|
85
|
+
if (!objective)
|
|
86
|
+
throw new Error('Enter the investigation objective before retaining Incident context.');
|
|
87
|
+
const parsed = parseIncidentUrlContext(input.incidentUrl);
|
|
88
|
+
if (!parsed)
|
|
89
|
+
throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
|
|
90
|
+
const retention = input.retainExactUrl ? 'exact' : 'origin_only';
|
|
91
|
+
const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
statement: `${INCIDENT_CONTEXT_STATEMENT_PREFIX}${retainedUrl}`,
|
|
94
|
+
rationale: `${OBJECTIVE_PREFIX}${objective}${RETENTION_SEPARATOR}${retention}`,
|
|
95
|
+
retainedUrl,
|
|
96
|
+
retention,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
9
99
|
// ---------------------------------------------------------------------------
|
|
10
100
|
// Case normalization
|
|
11
101
|
// ---------------------------------------------------------------------------
|
|
@@ -300,3 +390,148 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
300
390
|
next[index] = record;
|
|
301
391
|
return { cases: next, record };
|
|
302
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Records one reviewed conclusion as a single pure Case mutation. The selected
|
|
395
|
+
* observations become bounded Case pins before the decision is created, so the
|
|
396
|
+
* decision cannot point at absent evidence. Counterevidence is retained as a
|
|
397
|
+
* separate resolved contradiction assertion rather than being hidden inside a
|
|
398
|
+
* favourable disposition.
|
|
399
|
+
*/
|
|
400
|
+
export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
401
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
402
|
+
const disposition = normalizeDisposition(input.disposition);
|
|
403
|
+
const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
|
|
404
|
+
if (disposition === 'unreviewed') {
|
|
405
|
+
throw new Error('Select a reviewed disposition before recording a conclusion.');
|
|
406
|
+
}
|
|
407
|
+
if (!reviewReasonCode) {
|
|
408
|
+
throw new Error('Select the reviewed reason before recording a conclusion.');
|
|
409
|
+
}
|
|
410
|
+
if (!Array.isArray(input.evidence) || !input.evidence.length) {
|
|
411
|
+
throw new Error('Select at least one observed fact for this conclusion.');
|
|
412
|
+
}
|
|
413
|
+
const invalidStance = input.evidence.find((item) => (!item || !['supports', 'contradicts', 'unresolved'].includes(item.stance)));
|
|
414
|
+
if (invalidStance)
|
|
415
|
+
throw new Error('A conclusion evidence relationship is invalid.');
|
|
416
|
+
if (!input.evidence.some((item) => item.stance === 'supports')) {
|
|
417
|
+
throw new Error('A conclusion requires at least one observed fact that supports it. Record only contradictory or unresolved material as an assertion instead.');
|
|
418
|
+
}
|
|
419
|
+
const current = cases.find((item) => item.id === id);
|
|
420
|
+
if (!current)
|
|
421
|
+
throw new Error('That case no longer exists.');
|
|
422
|
+
const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
423
|
+
const withPins = updateCase(cases, id, {
|
|
424
|
+
evidencePins: input.evidence.map((item) => item.pin),
|
|
425
|
+
}, now);
|
|
426
|
+
const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
|
|
427
|
+
if (addedPins.length !== input.evidence.length) {
|
|
428
|
+
throw new Error('The selected conclusion evidence could not be retained completely.');
|
|
429
|
+
}
|
|
430
|
+
const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
|
|
431
|
+
let concluded = updateCase(withPins.cases, id, {
|
|
432
|
+
disposition,
|
|
433
|
+
reviewReasonCode,
|
|
434
|
+
decision: {
|
|
435
|
+
summary: input.summary,
|
|
436
|
+
rationale: input.rationale,
|
|
437
|
+
evidencePinIds: supportingPinIds,
|
|
438
|
+
},
|
|
439
|
+
}, now);
|
|
440
|
+
const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
|
|
441
|
+
? [{ evidencePinId: pin.id, stance: 'contradicts' }]
|
|
442
|
+
: []));
|
|
443
|
+
if (contradictionRelations.length) {
|
|
444
|
+
const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
|
|
445
|
+
concluded = updateCase(concluded.cases, id, {
|
|
446
|
+
assertion: {
|
|
447
|
+
kind: 'contradiction',
|
|
448
|
+
statement: `Counterevidence considered for: ${retainedSummary}`,
|
|
449
|
+
rationale: input.rationale,
|
|
450
|
+
evidenceRelations: contradictionRelations,
|
|
451
|
+
state: 'resolved',
|
|
452
|
+
},
|
|
453
|
+
}, now);
|
|
454
|
+
}
|
|
455
|
+
const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
|
|
456
|
+
? [{ evidencePinId: pin.id, stance: 'unresolved' }]
|
|
457
|
+
: []));
|
|
458
|
+
if (unresolvedRelations.length) {
|
|
459
|
+
const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
|
|
460
|
+
concluded = updateCase(concluded.cases, id, {
|
|
461
|
+
assertion: {
|
|
462
|
+
kind: 'unknown',
|
|
463
|
+
statement: `Unresolved evidence considered for: ${retainedSummary}`,
|
|
464
|
+
rationale: input.rationale,
|
|
465
|
+
evidenceRelations: unresolvedRelations,
|
|
466
|
+
state: 'open',
|
|
467
|
+
},
|
|
468
|
+
}, now);
|
|
469
|
+
}
|
|
470
|
+
return concluded;
|
|
471
|
+
}
|
|
472
|
+
export function recordCaseInvestigationContext(cases, id, input, nowIso) {
|
|
473
|
+
const current = cases.find((item) => item.id === id);
|
|
474
|
+
if (!current)
|
|
475
|
+
throw new Error('That case no longer exists.');
|
|
476
|
+
const parsed = parseIncidentUrlContext(input.incidentUrl);
|
|
477
|
+
if (!parsed || parsed.registrableDomain !== current.domain) {
|
|
478
|
+
throw new Error(`The Incident URL must belong to the Case domain ${current.domain}.`);
|
|
479
|
+
}
|
|
480
|
+
const context = caseInvestigationContextAssertion(input);
|
|
481
|
+
const existing = caseInvestigationContext(current);
|
|
482
|
+
return updateCase(cases, id, existing
|
|
483
|
+
? {
|
|
484
|
+
assertionUpdate: {
|
|
485
|
+
id: existing.assertionId,
|
|
486
|
+
statement: context.statement,
|
|
487
|
+
rationale: context.rationale,
|
|
488
|
+
state: 'open',
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
: {
|
|
492
|
+
assertion: {
|
|
493
|
+
kind: 'next_step',
|
|
494
|
+
statement: context.statement,
|
|
495
|
+
rationale: context.rationale,
|
|
496
|
+
evidenceRelations: [],
|
|
497
|
+
state: 'open',
|
|
498
|
+
},
|
|
499
|
+
}, nowIso);
|
|
500
|
+
}
|
|
501
|
+
export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
502
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
503
|
+
const current = cases.find((item) => item.id === id);
|
|
504
|
+
if (!current)
|
|
505
|
+
throw new Error('That case no longer exists.');
|
|
506
|
+
const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
507
|
+
const withPin = updateCase(cases, id, {
|
|
508
|
+
evidencePin: {
|
|
509
|
+
field: 'case.recheck_comparison',
|
|
510
|
+
category: 'recheck',
|
|
511
|
+
label: 'Recheck comparison',
|
|
512
|
+
value: input.comparisonSummary,
|
|
513
|
+
source: input.source,
|
|
514
|
+
sourceState: 'reviewed',
|
|
515
|
+
observedAt: input.observedAt,
|
|
516
|
+
collectionDepth: input.collectionDepth,
|
|
517
|
+
completeness: input.completeness,
|
|
518
|
+
truncated: false,
|
|
519
|
+
limitations: input.limitations,
|
|
520
|
+
},
|
|
521
|
+
}, now);
|
|
522
|
+
const comparisonPin = withPin.record.evidencePins.find((pin) => !beforePinIds.has(pin.id));
|
|
523
|
+
if (!comparisonPin)
|
|
524
|
+
throw new Error('The recheck comparison could not be retained.');
|
|
525
|
+
return updateCase(withPin.cases, id, {
|
|
526
|
+
observedEffectReview: {
|
|
527
|
+
state: input.state,
|
|
528
|
+
observedAt: input.observedAt,
|
|
529
|
+
sourceClass: 'analyst',
|
|
530
|
+
source: input.source,
|
|
531
|
+
completeness: input.completeness,
|
|
532
|
+
evidencePinId: comparisonPin.id,
|
|
533
|
+
followUpAt: input.followUpAt,
|
|
534
|
+
limitations: input.limitations,
|
|
535
|
+
},
|
|
536
|
+
}, now);
|
|
537
|
+
}
|
|
@@ -2,9 +2,14 @@ import { MAX_DOMAIN_NAME_LENGTH } from "./domain-name.mjs";
|
|
|
2
2
|
import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
|
|
3
3
|
import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
|
|
4
4
|
export const CASE_CONTRACT_OWNER = 'packages/contracts/case-portability.mts';
|
|
5
|
-
export const LATEST_PUBLIC_APPLICATION_VERSION = '2.0
|
|
5
|
+
export const LATEST_PUBLIC_APPLICATION_VERSION = '2.1.0';
|
|
6
6
|
export const CASE_PORTABILITY_IDENTITY_CONSTANTS = Object.freeze([
|
|
7
7
|
'LATEST_PUBLIC_APPLICATION_VERSION',
|
|
8
|
+
'LATEST_PUBLIC_CASE_SCHEMA_VERSION',
|
|
9
|
+
'LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION',
|
|
10
|
+
'LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION',
|
|
11
|
+
'LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION',
|
|
12
|
+
'LATEST_PUBLIC_WORKSPACE_ARCHIVE_VERSION',
|
|
8
13
|
'CASE_BROWSER_STORE_LIFECYCLE_SCHEMA',
|
|
9
14
|
'CASE_EXPORT_LIFECYCLE_SCHEMA',
|
|
10
15
|
'CASE_SCHEMA_VERSION',
|
|
@@ -127,6 +132,7 @@ export const CASE_EXPORT_LIFECYCLE_SCHEMA = 'whoisleuth.case-export';
|
|
|
127
132
|
export const PUBLIC_CASE_SCHEMA_VERSION = 12;
|
|
128
133
|
export const PUBLISHED_V2_CASE_SCHEMA_VERSION = 13;
|
|
129
134
|
export const CASE_SCHEMA_VERSION = 14;
|
|
135
|
+
export const LATEST_PUBLIC_CASE_SCHEMA_VERSION = 14;
|
|
130
136
|
export const CASE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([
|
|
131
137
|
PUBLIC_CASE_SCHEMA_VERSION,
|
|
132
138
|
PUBLISHED_V2_CASE_SCHEMA_VERSION,
|
|
@@ -137,6 +143,7 @@ export const CASE_REPORT_SCHEMA = 'whoisleuth.case-report';
|
|
|
137
143
|
export const PUBLIC_CASE_REPORT_SCHEMA_VERSION = 8;
|
|
138
144
|
export const PUBLISHED_V2_CASE_REPORT_SCHEMA_VERSION = 9;
|
|
139
145
|
export const CASE_REPORT_SCHEMA_VERSION = 10;
|
|
146
|
+
export const LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION = 10;
|
|
140
147
|
export const CASE_REPORT_OUTPUT_VERSIONS = Object.freeze([
|
|
141
148
|
PUBLISHED_V2_CASE_REPORT_SCHEMA_VERSION,
|
|
142
149
|
CASE_REPORT_SCHEMA_VERSION,
|
|
@@ -145,6 +152,7 @@ export const CASE_RESPONSE_PACKET_SCHEMA = 'whoisleuth.case-response-packet';
|
|
|
145
152
|
export const PUBLIC_CASE_RESPONSE_PACKET_VERSION = 6;
|
|
146
153
|
export const PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION = 7;
|
|
147
154
|
export const CASE_RESPONSE_PACKET_VERSION = 8;
|
|
155
|
+
export const LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION = 8;
|
|
148
156
|
export const CASE_RESPONSE_PACKET_OUTPUT_VERSIONS = Object.freeze([
|
|
149
157
|
PUBLIC_CASE_RESPONSE_PACKET_VERSION,
|
|
150
158
|
PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION,
|
|
@@ -154,6 +162,7 @@ export const SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS = CASE_RESPONSE_PACKET_OUTP
|
|
|
154
162
|
export const CASE_RESPONSE_REVIEW_INPUTS_SCHEMA = 'whoisleuth.case-response-review-inputs';
|
|
155
163
|
export const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION = 1;
|
|
156
164
|
export const CASE_RESPONSE_REVIEW_INPUTS_VERSION = 2;
|
|
165
|
+
export const LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION = 2;
|
|
157
166
|
export const SUPPORTED_CASE_RESPONSE_REVIEW_INPUTS_VERSIONS = Object.freeze([
|
|
158
167
|
PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION,
|
|
159
168
|
CASE_RESPONSE_REVIEW_INPUTS_VERSION,
|
|
@@ -187,6 +196,7 @@ export const WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.workspace-archive';
|
|
|
187
196
|
export const PUBLIC_WORKSPACE_ARCHIVE_VERSION = 5;
|
|
188
197
|
export const PUBLISHED_V2_WORKSPACE_ARCHIVE_VERSION = 6;
|
|
189
198
|
export const WORKSPACE_ARCHIVE_VERSION = 7;
|
|
199
|
+
export const LATEST_PUBLIC_WORKSPACE_ARCHIVE_VERSION = 7;
|
|
190
200
|
export const SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS = Object.freeze([
|
|
191
201
|
PUBLIC_WORKSPACE_ARCHIVE_VERSION,
|
|
192
202
|
PUBLISHED_V2_WORKSPACE_ARCHIVE_VERSION,
|
|
@@ -595,8 +605,8 @@ const CASE_LIFECYCLE_FIXTURES = Object.freeze([
|
|
|
595
605
|
id: 'cli-case-pack-v2-case-v14',
|
|
596
606
|
path: 'test/fixtures/case-lifecycle/cli-case-pack-v2-case-v14.json',
|
|
597
607
|
bytes: 9363,
|
|
598
|
-
sha256: '
|
|
599
|
-
contentDigestSha256: 'sha256:
|
|
608
|
+
sha256: 'e25e98602c92e696a50c9fd48924c6c13853a5c23ea6fe617abc0b89d8c9e8ee',
|
|
609
|
+
contentDigestSha256: 'sha256:50044835b0e7aa45137b2f75b9894aa35c71101c769062eb524078bfb55eecc0',
|
|
600
610
|
schema: CLI_CASE_PACK_SCHEMA,
|
|
601
611
|
version: CLI_CASE_PACK_VERSION,
|
|
602
612
|
role: 'current',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeDomain, openOrCreateCase, updateCase, } from "../cases/case-model.mjs";
|
|
2
|
+
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
2
3
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
3
4
|
import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
|
|
4
5
|
import { hasUnsafeRetainedText } from "./retained-text.mjs";
|
|
@@ -311,6 +312,58 @@ function structuredPinFields(finding) {
|
|
|
311
312
|
certificateObservation,
|
|
312
313
|
};
|
|
313
314
|
}
|
|
315
|
+
function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
|
|
316
|
+
const target = current.find((candidate) => candidate.id === caseId) ?? null;
|
|
317
|
+
if (!target)
|
|
318
|
+
throw new Error('The selected Case is unavailable for this finding.');
|
|
319
|
+
if (existingPinKey(target, finding, source.name)) {
|
|
320
|
+
return { cases: [...current], record: target, added: false };
|
|
321
|
+
}
|
|
322
|
+
const sourceLimitations = [
|
|
323
|
+
finding.evidenceClass === 'deployment_observation'
|
|
324
|
+
? `Imported as an observation made by ${source.name}; this browser session did not collect or independently verify it.`
|
|
325
|
+
: `Reported by ${source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
|
|
326
|
+
...(source.reference ? [`Source reference: ${source.reference}`] : []),
|
|
327
|
+
...finding.limitations,
|
|
328
|
+
];
|
|
329
|
+
const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
|
|
330
|
+
let updated = updateCase([...current], target.id, {
|
|
331
|
+
evidencePin: {
|
|
332
|
+
...structuredPinFields(finding),
|
|
333
|
+
label: `External ${finding.category} finding`,
|
|
334
|
+
value: finding.structuredObservation?.value ?? pinValue(finding),
|
|
335
|
+
source: importedSourceLabel(finding, source.name),
|
|
336
|
+
observedAt: finding.observedAt,
|
|
337
|
+
completeness: finding.completeness,
|
|
338
|
+
limitations: sourceLimitations,
|
|
339
|
+
},
|
|
340
|
+
}, now);
|
|
341
|
+
const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
|
|
342
|
+
if (!addedPin)
|
|
343
|
+
throw new Error('The imported finding could not be retained as Case evidence.');
|
|
344
|
+
updated = updateCase(updated.cases, target.id, {
|
|
345
|
+
sighting: {
|
|
346
|
+
state: finding.evidenceClass === 'deployment_observation'
|
|
347
|
+
? 'observed_by_deployment'
|
|
348
|
+
: 'reported_by_provider',
|
|
349
|
+
category: finding.category === 'dns'
|
|
350
|
+
? 'delegation'
|
|
351
|
+
: finding.category === 'certificate'
|
|
352
|
+
? 'certificate'
|
|
353
|
+
: finding.category === 'registration'
|
|
354
|
+
? 'registration'
|
|
355
|
+
: finding.category === 'http' || finding.category === 'page'
|
|
356
|
+
? 'website'
|
|
357
|
+
: 'other',
|
|
358
|
+
source: source.name,
|
|
359
|
+
observedAt: finding.observedAt,
|
|
360
|
+
completeness: finding.completeness,
|
|
361
|
+
evidencePinId: addedPin.id,
|
|
362
|
+
limitations: sourceLimitations,
|
|
363
|
+
},
|
|
364
|
+
}, now);
|
|
365
|
+
return { cases: updated.cases, record: updated.record, added: true };
|
|
366
|
+
}
|
|
314
367
|
export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
|
|
315
368
|
let cases = [...current];
|
|
316
369
|
const createdDomains = new Set();
|
|
@@ -322,54 +375,12 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
|
|
|
322
375
|
const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
|
|
323
376
|
cases = opened.cases;
|
|
324
377
|
const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
|
|
325
|
-
|
|
378
|
+
const merged = mergeExternalFindingIntoCase(cases, target.id, finding, document.source, now);
|
|
379
|
+
cases = merged.cases;
|
|
380
|
+
if (!merged.added) {
|
|
326
381
|
duplicatesSkipped += 1;
|
|
327
382
|
continue;
|
|
328
383
|
}
|
|
329
|
-
const sourceLimitations = [
|
|
330
|
-
finding.evidenceClass === 'deployment_observation'
|
|
331
|
-
? `Imported as an observation made by ${document.source.name}; this browser session did not collect or independently verify it.`
|
|
332
|
-
: `Reported by ${document.source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
|
|
333
|
-
...(document.source.reference ? [`Source reference: ${document.source.reference}`] : []),
|
|
334
|
-
...finding.limitations,
|
|
335
|
-
];
|
|
336
|
-
const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
|
|
337
|
-
const updated = updateCase(cases, target.id, {
|
|
338
|
-
evidencePin: {
|
|
339
|
-
...structuredPinFields(finding),
|
|
340
|
-
label: `External ${finding.category} finding`,
|
|
341
|
-
value: finding.structuredObservation?.value ?? pinValue(finding),
|
|
342
|
-
source: importedSourceLabel(finding, document.source.name),
|
|
343
|
-
observedAt: finding.observedAt,
|
|
344
|
-
completeness: finding.completeness,
|
|
345
|
-
limitations: sourceLimitations,
|
|
346
|
-
},
|
|
347
|
-
}, now);
|
|
348
|
-
cases = updated.cases;
|
|
349
|
-
const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
|
|
350
|
-
if (addedPin) {
|
|
351
|
-
cases = updateCase(cases, updated.record.id, {
|
|
352
|
-
sighting: {
|
|
353
|
-
state: finding.evidenceClass === 'deployment_observation'
|
|
354
|
-
? 'observed_by_deployment'
|
|
355
|
-
: 'reported_by_provider',
|
|
356
|
-
category: finding.category === 'dns'
|
|
357
|
-
? 'delegation'
|
|
358
|
-
: finding.category === 'certificate'
|
|
359
|
-
? 'certificate'
|
|
360
|
-
: finding.category === 'registration'
|
|
361
|
-
? 'registration'
|
|
362
|
-
: finding.category === 'http' || finding.category === 'page'
|
|
363
|
-
? 'website'
|
|
364
|
-
: 'other',
|
|
365
|
-
source: document.source.name,
|
|
366
|
-
observedAt: finding.observedAt,
|
|
367
|
-
completeness: finding.completeness,
|
|
368
|
-
evidencePinId: addedPin.id,
|
|
369
|
-
limitations: sourceLimitations,
|
|
370
|
-
},
|
|
371
|
-
}, now).cases;
|
|
372
|
-
}
|
|
373
384
|
findingsAdded += 1;
|
|
374
385
|
if (existing)
|
|
375
386
|
updatedDomains.add(finding.domain);
|
|
@@ -384,3 +395,60 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
|
|
|
384
395
|
duplicatesSkipped,
|
|
385
396
|
};
|
|
386
397
|
}
|
|
398
|
+
function targetedFinding(finding, caseDomain) {
|
|
399
|
+
if (finding.domain === caseDomain)
|
|
400
|
+
return finding;
|
|
401
|
+
const prefix = `Captured hostname ${finding.domain}. `;
|
|
402
|
+
const maximumSummaryLength = 900;
|
|
403
|
+
const remaining = maximumSummaryLength - prefix.length;
|
|
404
|
+
const summary = remaining > 1 && finding.summary.length > remaining
|
|
405
|
+
? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
|
|
406
|
+
: `${prefix}${finding.summary}`;
|
|
407
|
+
return { ...finding, domain: caseDomain, summary };
|
|
408
|
+
}
|
|
409
|
+
export function externalFindingsCaseTargets(document, caseDomain) {
|
|
410
|
+
if (canonicalRegistrableDomain(caseDomain) !== caseDomain) {
|
|
411
|
+
throw new Error('The selected Case does not have a canonical registrable-domain identity.');
|
|
412
|
+
}
|
|
413
|
+
const targets = new Set();
|
|
414
|
+
for (const finding of document.findings) {
|
|
415
|
+
if (canonicalRegistrableDomain(finding.domain) !== caseDomain) {
|
|
416
|
+
throw new Error(`The imported finding for ${finding.domain} does not belong to the selected Case for ${caseDomain}.`);
|
|
417
|
+
}
|
|
418
|
+
targets.add(finding.domain);
|
|
419
|
+
}
|
|
420
|
+
return Object.freeze([...targets]);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Merges already-validated external findings into one selected Case. A Case is
|
|
424
|
+
* keyed to its registrable domain, while rendered captures may retain an exact
|
|
425
|
+
* hostname. Only same-domain findings are accepted, the exact hostname remains
|
|
426
|
+
* explicit in the retained evidence, and this operation can never open another
|
|
427
|
+
* Case as a side effect.
|
|
428
|
+
*/
|
|
429
|
+
export function mergeExternalFindingsIntoCase(current, caseId, document, now = new Date().toISOString()) {
|
|
430
|
+
const selected = current.find((candidate) => candidate.id === caseId) ?? null;
|
|
431
|
+
if (!selected)
|
|
432
|
+
throw new Error('Select an existing Case before importing rendered-capture evidence.');
|
|
433
|
+
externalFindingsCaseTargets(document, selected.domain);
|
|
434
|
+
let cases = [...current];
|
|
435
|
+
let record = selected;
|
|
436
|
+
let findingsAdded = 0;
|
|
437
|
+
let duplicatesSkipped = 0;
|
|
438
|
+
for (const original of document.findings) {
|
|
439
|
+
const finding = targetedFinding(original, selected.domain);
|
|
440
|
+
const merged = mergeExternalFindingIntoCase(cases, selected.id, finding, document.source, now);
|
|
441
|
+
cases = merged.cases;
|
|
442
|
+
record = merged.record;
|
|
443
|
+
if (merged.added)
|
|
444
|
+
findingsAdded += 1;
|
|
445
|
+
else
|
|
446
|
+
duplicatesSkipped += 1;
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
cases,
|
|
450
|
+
record,
|
|
451
|
+
findingsAdded,
|
|
452
|
+
duplicatesSkipped,
|
|
453
|
+
};
|
|
454
|
+
}
|