@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +67 -10
- package/bin/whoisleuth.mjs +22 -9
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +5 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +96 -42
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
package/cli/case-pack.mjs
CHANGED
|
@@ -1,22 +1,671 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
|
-
import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
-
import { buildCaseReport } from "../frontend/src/lib/analysis/case-report.js";
|
|
5
|
-
import {
|
|
3
|
+
import { canonicalArtifactJson, canonicalArtifactJsonFor, canonicalArtifactJsonV2, resolveArtifactCanonicalization, SORTED_JSON_V1, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { buildCaseReport, CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, } from "../frontend/src/lib/analysis/case-report.js";
|
|
5
|
+
import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
|
|
6
|
+
import { CASE_IMPORT_VERSIONS, CONCLUSIVE_AVAILABILITY, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_CASE_IMPORT_BYTES, CASE_SCHEMA_VERSION, assertCaseBrandProfileIds, caseEvidenceIncomparableReasons, compareCaseEvidence, hashString, normalizeDomain, normalizeCaseStore, normalizeSnapshot, safeId, } from "../frontend/src/lib/analysis/case-model.js";
|
|
7
|
+
import { buildPortableGeneratorMetadata } from "../lib/portable-generator.mjs";
|
|
8
|
+
import { analystInteroperabilityTags } from "../lib/analyst-taxonomy.mjs";
|
|
6
9
|
import { CliUsageError } from "./errors.mjs";
|
|
7
10
|
export const CLI_CASE_PACK_SCHEMA = 'whoisleuth.cli.case-pack';
|
|
8
|
-
export const CLI_CASE_PACK_VERSION =
|
|
11
|
+
export const CLI_CASE_PACK_VERSION = 2;
|
|
12
|
+
export const LEGACY_CLI_CASE_PACK_VERSION = 1;
|
|
9
13
|
export const MAX_CASE_PACK_INPUT_BYTES = 4 * 1024 * 1024;
|
|
10
14
|
export const MAX_CASE_PACK_CASES = 25;
|
|
15
|
+
const MAX_CASE_PACK_REFERENCE_SCAN_DEPTH = 64;
|
|
16
|
+
const LEGACY_CASE_REPORT_VERSIONS = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
|
|
17
|
+
const INTERNAL_EXCLUSIONS = Object.freeze(['Raw upstream payloads and credentials are outside the case schema.']);
|
|
18
|
+
const TRUSTED_EXCLUSIONS = Object.freeze(['Case notes', 'Recipient values', 'Manual trail targets', 'Raw upstream payloads and credentials']);
|
|
19
|
+
const PUBLIC_EXCLUSIONS_V10 = Object.freeze(['Case notes', 'Actions and recipient values', 'Analyst assertions', 'Manual trail targets', 'Raw upstream payloads and credentials']);
|
|
20
|
+
const PUBLIC_EXCLUSIONS_V11 = Object.freeze([...PUBLIC_EXCLUSIONS_V10.slice(0, 3), 'Investigation branches', ...PUBLIC_EXCLUSIONS_V10.slice(3)]);
|
|
21
|
+
const PUBLIC_EXCLUSIONS = Object.freeze(['Case notes', 'Brand Profile references', ...PUBLIC_EXCLUSIONS_V11.slice(1)]);
|
|
22
|
+
const SENSITIVE_CASE_PACK_FIELDS = Object.freeze(['brandProfileIds', 'notes', 'actions', 'assertions', 'manualTrail', 'branches', 'recipient', 'target']);
|
|
23
|
+
const ROOT_KEYS = Object.freeze(['version', 'exportedAt', 'cases', 'packet', 'integrity']);
|
|
24
|
+
const PACKET_KEYS = Object.freeze(['schema', 'version', 'audience', 'reviewed', 'reports', 'redactionManifest', 'limitations']);
|
|
25
|
+
const REPORT_KEYS = Object.freeze(['schema', 'schemaVersion', 'generatedAt', 'application', 'case', 'currentAssessment', 'evidenceTimeline', 'analystResponse', 'limitations']);
|
|
26
|
+
const INTEGRITY_KEYS = Object.freeze(['algorithm', 'canonicalization', 'digestSha256']);
|
|
27
|
+
const PACKET_LIMITATIONS = Object.freeze([
|
|
28
|
+
'This local package is browser-importable through its top-level case collection and does not upload or submit evidence.',
|
|
29
|
+
'The reviewed flag records a deliberate CLI choice; it does not prove recipient authorisation, factual correctness, or legal sufficiency.',
|
|
30
|
+
'Importing the package does not restore fields excluded by its audience profile.',
|
|
31
|
+
]);
|
|
32
|
+
const LEGACY_RISK_MODEL_LIMITATION = 'This report contains normalized browser-local observations from WHOISleuth analyst cases. It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks. Absence of a signal (e.g. no MX record observed) does not prove nonexistence. It may not have been evaluated. Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes. Scan-depth and risk-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably. Generated locally in the browser. Review the package before sharing it.';
|
|
33
|
+
const LEGACY_SCORING_MODEL_LIMITATION = 'This report contains normalized browser-local observations from WHOISleuth analyst cases. It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks. Absence of a signal (e.g. no MX record observed) does not prove nonexistence. It may not have been evaluated. Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes. Scan-depth and scoring-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably. Generated locally in the browser. Review the package before sharing it.';
|
|
34
|
+
const LEGACY_PORTABLE_LIMITATION = 'This report contains normalised browser-local observations from WHOISleuth analyst cases. It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks. Absence of a signal (e.g. no MX record observed) does not prove nonexistence. It may not have been evaluated. Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes. Scan-depth and scoring-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably. Generated locally in the browser. Review the package before sharing it.';
|
|
35
|
+
const LEGACY_REPORT_LIMITATION_BY_VERSION = new Map([
|
|
36
|
+
[1, LEGACY_RISK_MODEL_LIMITATION],
|
|
37
|
+
[2, LEGACY_RISK_MODEL_LIMITATION],
|
|
38
|
+
[3, LEGACY_RISK_MODEL_LIMITATION],
|
|
39
|
+
[4, LEGACY_RISK_MODEL_LIMITATION],
|
|
40
|
+
[5, LEGACY_SCORING_MODEL_LIMITATION],
|
|
41
|
+
[6, LEGACY_PORTABLE_LIMITATION],
|
|
42
|
+
[7, LEGACY_PORTABLE_LIMITATION],
|
|
43
|
+
]);
|
|
44
|
+
const HISTORICAL_BASE_MATERIAL_FIELDS = Object.freeze([
|
|
45
|
+
'scanDepth',
|
|
46
|
+
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityScore',
|
|
47
|
+
'riskFactors', 'opportunityFactors',
|
|
48
|
+
'registrar', 'createdDate', 'expiryDate', 'nameservers',
|
|
49
|
+
'hasMx', 'hasSpf', 'hasDmarc',
|
|
50
|
+
'activityStatus', 'websiteProbeDetail', 'pageTitle',
|
|
51
|
+
'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
|
|
52
|
+
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
53
|
+
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'phishingLanguageMatch',
|
|
54
|
+
'mutationTypes',
|
|
55
|
+
]);
|
|
56
|
+
const HISTORICAL_EXTERNAL_FORM_MATERIAL_FIELDS = Object.freeze([
|
|
57
|
+
...HISTORICAL_BASE_MATERIAL_FIELDS.slice(0, -2),
|
|
58
|
+
'hasExternalFormAction',
|
|
59
|
+
...HISTORICAL_BASE_MATERIAL_FIELDS.slice(-2),
|
|
60
|
+
]);
|
|
61
|
+
const HISTORICAL_CONTEXT_MATERIAL_FIELDS = Object.freeze([
|
|
62
|
+
'scanDepth',
|
|
63
|
+
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
|
|
64
|
+
'riskFactors', 'opportunityFactors',
|
|
65
|
+
'registrar', 'createdDate', 'expiryDate', 'nameservers',
|
|
66
|
+
'hasMx', 'hasSpf', 'hasDmarc',
|
|
67
|
+
'activityStatus', 'websiteProbeDetail', 'pageTitle',
|
|
68
|
+
'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
|
|
69
|
+
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
70
|
+
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
|
|
71
|
+
'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
72
|
+
'mutationTypes',
|
|
73
|
+
]);
|
|
74
|
+
const HISTORICAL_CHANGE_ORDER = Object.freeze([
|
|
75
|
+
'availability', 'confidence', 'riskScore', 'riskFactors', 'opportunityScore', 'opportunityFactors',
|
|
76
|
+
'registrar', 'createdDate', 'expiryDate', 'nameservers', 'hasMx', 'hasSpf', 'hasDmarc',
|
|
77
|
+
'activityStatus', 'websiteProbeDetail', 'pageTitle', 'httpEvidenceStatus', 'httpFinalOrigin',
|
|
78
|
+
'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount', 'httpCrossOriginRedirect',
|
|
79
|
+
'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders', 'faviconMatch', 'faviconNearMatch',
|
|
80
|
+
'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch', 'mutationTypes',
|
|
81
|
+
]);
|
|
82
|
+
function record(value) {
|
|
83
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
84
|
+
? value
|
|
85
|
+
: null;
|
|
86
|
+
}
|
|
87
|
+
function expectedExclusions(audience, caseVersion) {
|
|
88
|
+
if (audience === 'internal')
|
|
89
|
+
return INTERNAL_EXCLUSIONS;
|
|
90
|
+
if (audience === 'trusted')
|
|
91
|
+
return TRUSTED_EXCLUSIONS;
|
|
92
|
+
if (caseVersion >= 12)
|
|
93
|
+
return PUBLIC_EXCLUSIONS;
|
|
94
|
+
return caseVersion === 11 ? PUBLIC_EXCLUSIONS_V11 : PUBLIC_EXCLUSIONS_V10;
|
|
95
|
+
}
|
|
96
|
+
function stringListsMatch(value, expected) {
|
|
97
|
+
return Array.isArray(value)
|
|
98
|
+
&& value.length === expected.length
|
|
99
|
+
&& value.every((item, index) => item === expected[index]);
|
|
100
|
+
}
|
|
101
|
+
function assertOnlyKeys(value, allowed, label) {
|
|
102
|
+
const allowedKeys = new Set(allowed);
|
|
103
|
+
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
|
104
|
+
throw new TypeError(`The CLI case pack contains an unexpected ${label} field.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function jsonNodeKind(value) {
|
|
108
|
+
if (value === null)
|
|
109
|
+
return 'null';
|
|
110
|
+
if (Array.isArray(value))
|
|
111
|
+
return 'array';
|
|
112
|
+
return typeof value === 'object' ? 'object' : 'scalar';
|
|
113
|
+
}
|
|
114
|
+
/** Historical records may omit later object fields, but every retained value must match the bounded projection. */
|
|
115
|
+
function assertProjectionKeys(actual, projection, path, depth = 0) {
|
|
116
|
+
if (depth > MAX_CASE_PACK_REFERENCE_SCAN_DEPTH) {
|
|
117
|
+
throw new TypeError('The CLI case pack exceeds its bounded projection depth.');
|
|
118
|
+
}
|
|
119
|
+
const actualKind = jsonNodeKind(actual);
|
|
120
|
+
const projectionKind = jsonNodeKind(projection);
|
|
121
|
+
if (actualKind !== projectionKind) {
|
|
122
|
+
throw new TypeError(`The CLI case pack contains an invalid ${path} projection node.`);
|
|
123
|
+
}
|
|
124
|
+
if (actualKind === 'array') {
|
|
125
|
+
const actualArray = actual;
|
|
126
|
+
const projectionArray = projection;
|
|
127
|
+
if (actualArray.length > projectionArray.length) {
|
|
128
|
+
throw new TypeError(`The CLI case pack contains data outside its normalised ${path} projection.`);
|
|
129
|
+
}
|
|
130
|
+
for (let index = 0; index < actualArray.length; index++) {
|
|
131
|
+
const expected = projectionArray[index];
|
|
132
|
+
if (expected === undefined) {
|
|
133
|
+
throw new TypeError(`The CLI case pack contains data outside its normalised ${path} projection.`);
|
|
134
|
+
}
|
|
135
|
+
assertProjectionKeys(actualArray[index], expected, `${path}[${index}]`, depth + 1);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (actualKind === 'object') {
|
|
140
|
+
const actualRecord = actual;
|
|
141
|
+
const projectionRecord = record(projection);
|
|
142
|
+
if (!projectionRecord)
|
|
143
|
+
throw new TypeError(`The CLI case pack contains an invalid ${path} projection.`);
|
|
144
|
+
for (const key of Object.keys(actualRecord)) {
|
|
145
|
+
if (!Object.hasOwn(projectionRecord, key)) {
|
|
146
|
+
throw new TypeError(`The CLI case pack contains an unexpected ${path}.${key} field.`);
|
|
147
|
+
}
|
|
148
|
+
assertProjectionKeys(actualRecord[key], projectionRecord[key], `${path}.${key}`, depth + 1);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!canonicalValuesMatch(actual, projection)) {
|
|
153
|
+
throw new TypeError(`The CLI case pack contains a changed or unbounded ${path} projection value.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function assertClosedEnvelopes(root, packet, integrity, redactionManifest, reports, current) {
|
|
157
|
+
assertOnlyKeys(root, ROOT_KEYS, 'root envelope');
|
|
158
|
+
assertOnlyKeys(packet, PACKET_KEYS, 'packet envelope');
|
|
159
|
+
assertOnlyKeys(integrity, INTEGRITY_KEYS, 'integrity envelope');
|
|
160
|
+
assertOnlyKeys(redactionManifest, current ? ['excluded', 'sourceCaseCount', 'brandProfileReferencesOmitted'] : ['excluded', 'sourceCaseCount'], 'redaction manifest');
|
|
161
|
+
if (!stringListsMatch(packet.limitations, PACKET_LIMITATIONS)) {
|
|
162
|
+
throw new TypeError('The CLI case pack contains an invalid packet limitation manifest.');
|
|
163
|
+
}
|
|
164
|
+
for (const value of reports) {
|
|
165
|
+
const report = record(value);
|
|
166
|
+
if (!report)
|
|
167
|
+
throw new TypeError('The CLI case pack contains an invalid Case report envelope.');
|
|
168
|
+
assertOnlyKeys(report, REPORT_KEYS, 'Case report envelope');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function topCasePath(path) {
|
|
172
|
+
return path.length === 2 && path[0] === 'cases' && typeof path[1] === 'number';
|
|
173
|
+
}
|
|
174
|
+
function reportCasePath(path) {
|
|
175
|
+
return path.length === 4 && path[0] === 'packet' && path[1] === 'reports'
|
|
176
|
+
&& typeof path[2] === 'number' && path[3] === 'case';
|
|
177
|
+
}
|
|
178
|
+
function analystResponsePath(path) {
|
|
179
|
+
return path.length === 4 && path[0] === 'packet' && path[1] === 'reports'
|
|
180
|
+
&& typeof path[2] === 'number' && path[3] === 'analystResponse';
|
|
181
|
+
}
|
|
182
|
+
function listItemPath(path, list) {
|
|
183
|
+
return (path.length === 4 && path[0] === 'cases' && typeof path[1] === 'number' && path[2] === list && typeof path[3] === 'number')
|
|
184
|
+
|| (path.length === 6 && path[0] === 'packet' && path[1] === 'reports' && typeof path[2] === 'number'
|
|
185
|
+
&& path[3] === 'analystResponse' && path[4] === list && typeof path[5] === 'number');
|
|
186
|
+
}
|
|
187
|
+
function allowedSensitiveFieldPath(key, path, current) {
|
|
188
|
+
if (key === 'brandProfileIds')
|
|
189
|
+
return current && (topCasePath(path) || reportCasePath(path));
|
|
190
|
+
if (key === 'notes')
|
|
191
|
+
return topCasePath(path) || reportCasePath(path);
|
|
192
|
+
if (key === 'actions' || key === 'assertions' || key === 'manualTrail' || key === 'branches') {
|
|
193
|
+
return topCasePath(path) || analystResponsePath(path);
|
|
194
|
+
}
|
|
195
|
+
if (key === 'recipient')
|
|
196
|
+
return listItemPath(path, 'actions');
|
|
197
|
+
if (key === 'target')
|
|
198
|
+
return listItemPath(path, 'manualTrail');
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/** Rejects hidden audience-sensitive copies with a budget proven by the serialized byte bound. */
|
|
202
|
+
function assertSensitiveFieldPlacement(root, current, serializedLength) {
|
|
203
|
+
const stack = [{ value: root, path: [], depth: 0 }];
|
|
204
|
+
const visited = new Set();
|
|
205
|
+
let inspected = 0;
|
|
206
|
+
while (stack.length) {
|
|
207
|
+
const entry = stack.pop();
|
|
208
|
+
if (!entry || !entry.value || typeof entry.value !== 'object')
|
|
209
|
+
continue;
|
|
210
|
+
for (const key of SENSITIVE_CASE_PACK_FIELDS) {
|
|
211
|
+
if (Object.hasOwn(entry.value, key) && !allowedSensitiveFieldPath(key, entry.path, current)) {
|
|
212
|
+
throw new TypeError('The CLI case pack contains audience-sensitive data outside its versioned case or report fields.');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (visited.has(entry.value))
|
|
216
|
+
continue;
|
|
217
|
+
visited.add(entry.value);
|
|
218
|
+
inspected += 1;
|
|
219
|
+
if (inspected > serializedLength + 1 || entry.depth > MAX_CASE_PACK_REFERENCE_SCAN_DEPTH) {
|
|
220
|
+
throw new TypeError('The CLI case pack exceeds its bounded audience-field scan.');
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(entry.value)) {
|
|
223
|
+
for (let index = entry.value.length - 1; index >= 0; index--) {
|
|
224
|
+
inspected += 1;
|
|
225
|
+
if (inspected > serializedLength + 1)
|
|
226
|
+
throw new TypeError('The CLI case pack exceeds its bounded audience-field scan.');
|
|
227
|
+
stack.push({ value: entry.value[index], path: [...entry.path, index], depth: entry.depth + 1 });
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
for (const key in entry.value) {
|
|
233
|
+
if (!Object.hasOwn(entry.value, key))
|
|
234
|
+
continue;
|
|
235
|
+
inspected += 1;
|
|
236
|
+
if (inspected > serializedLength + 1)
|
|
237
|
+
throw new TypeError('The CLI case pack exceeds its bounded audience-field scan.');
|
|
238
|
+
stack.push({ value: entry.value[key], path: [...entry.path, key], depth: entry.depth + 1 });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (cause) {
|
|
242
|
+
if (cause instanceof TypeError && /bounded audience-field scan/u.test(cause.message))
|
|
243
|
+
throw cause;
|
|
244
|
+
throw new TypeError('The CLI case pack could not be inspected safely for audience-sensitive data.');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function canonicalValuesMatch(left, right) {
|
|
249
|
+
try {
|
|
250
|
+
return canonicalArtifactJson(left) === canonicalArtifactJson(right);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function assertCanonicalCaseIdentities(cases, label) {
|
|
257
|
+
const ids = new Set();
|
|
258
|
+
const domains = new Set();
|
|
259
|
+
for (const value of cases) {
|
|
260
|
+
const item = record(value);
|
|
261
|
+
if (!item
|
|
262
|
+
|| typeof item.id !== 'string'
|
|
263
|
+
|| safeId(item.id) !== item.id
|
|
264
|
+
|| typeof item.domain !== 'string'
|
|
265
|
+
|| normalizeDomain(item.domain) !== item.domain
|
|
266
|
+
|| ids.has(item.id)
|
|
267
|
+
|| domains.has(item.domain)) {
|
|
268
|
+
throw new TypeError(`${label} contains a missing, unsafe, non-canonical, or duplicate Case identity.`);
|
|
269
|
+
}
|
|
270
|
+
ids.add(item.id);
|
|
271
|
+
domains.add(item.domain);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function assertCurrentCaseProjection(rawCases, normalised, label) {
|
|
275
|
+
if (rawCases.length !== normalised.length
|
|
276
|
+
|| rawCases.some((item, index) => !canonicalValuesMatch(item, normalised[index]))) {
|
|
277
|
+
throw new TypeError(`${label} contains a schema 12 Case that would be repaired, truncated, or otherwise changed during normalisation.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function requiredHistoricalCaseKeys(caseVersion) {
|
|
281
|
+
return [
|
|
282
|
+
'id', 'domain', 'status', 'disposition', 'tags', 'notes', 'source', 'evidenceHistory', 'createdAt', 'updatedAt',
|
|
283
|
+
...(caseVersion >= 3 ? ['evidencePins', 'decisions', 'actions'] : []),
|
|
284
|
+
...(caseVersion >= 4 ? ['assertions', 'manualTrail'] : []),
|
|
285
|
+
...(caseVersion >= 9 ? ['sightings'] : []),
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
function assertHistoricalCaseOwnKeys(cases, caseVersion) {
|
|
289
|
+
const required = requiredHistoricalCaseKeys(caseVersion);
|
|
290
|
+
for (let index = 0; index < cases.length; index++) {
|
|
291
|
+
const item = record(cases[index]);
|
|
292
|
+
if (!item)
|
|
293
|
+
throw new TypeError(`The CLI case pack contains an invalid historical Case at cases[${index}].`);
|
|
294
|
+
const missing = required.find((key) => !Object.hasOwn(item, key));
|
|
295
|
+
if (missing) {
|
|
296
|
+
throw new TypeError(`The CLI case pack is missing required historical Case field cases[${index}].${missing}.`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function reportVersionMatchesCase(caseVersion, reportVersion) {
|
|
301
|
+
if (!Number.isSafeInteger(reportVersion))
|
|
302
|
+
return false;
|
|
303
|
+
if (caseVersion === CASE_SCHEMA_VERSION)
|
|
304
|
+
return reportVersion === CASE_REPORT_SCHEMA_VERSION;
|
|
305
|
+
if (!LEGACY_CASE_REPORT_VERSIONS.includes(reportVersion))
|
|
306
|
+
return false;
|
|
307
|
+
if (caseVersion === 2)
|
|
308
|
+
return reportVersion === 1;
|
|
309
|
+
if (caseVersion === 3)
|
|
310
|
+
return reportVersion === 2;
|
|
311
|
+
if (caseVersion >= 4 && caseVersion <= 8)
|
|
312
|
+
return reportVersion === 3;
|
|
313
|
+
if (caseVersion === 9)
|
|
314
|
+
return reportVersion === 4;
|
|
315
|
+
if (caseVersion === 10)
|
|
316
|
+
return reportVersion === 5 || reportVersion === 6;
|
|
317
|
+
return caseVersion === 11 && reportVersion === 7;
|
|
318
|
+
}
|
|
319
|
+
function assertAudienceFields(value, audience, supportsBranches) {
|
|
320
|
+
if (!supportsBranches && Object.hasOwn(value, 'branches')) {
|
|
321
|
+
throw new TypeError('The CLI case pack contains an Investigation branch outside its versioned fields.');
|
|
322
|
+
}
|
|
323
|
+
if (audience === 'internal')
|
|
324
|
+
return;
|
|
325
|
+
if (Object.hasOwn(value, 'notes') && (!Array.isArray(value.notes) || value.notes.length !== 0)) {
|
|
326
|
+
throw new TypeError('The CLI case pack contains Case notes excluded by its audience.');
|
|
327
|
+
}
|
|
328
|
+
if (audience === 'public') {
|
|
329
|
+
for (const field of ['actions', 'assertions', ...(supportsBranches ? ['branches'] : [])]) {
|
|
330
|
+
if (Object.hasOwn(value, field) && (!Array.isArray(value[field]) || value[field].length !== 0)) {
|
|
331
|
+
throw new TypeError(`The public CLI case pack contains ${field} excluded by its audience.`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else if (Object.hasOwn(value, 'actions')) {
|
|
336
|
+
if (!Array.isArray(value.actions) || value.actions.some((entry) => {
|
|
337
|
+
const action = record(entry);
|
|
338
|
+
return !action || (Object.hasOwn(action, 'recipient') && action.recipient !== '[redacted]');
|
|
339
|
+
})) {
|
|
340
|
+
throw new TypeError('The trusted CLI case pack contains an unredacted action recipient.');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (Object.hasOwn(value, 'manualTrail')) {
|
|
344
|
+
if (!Array.isArray(value.manualTrail) || value.manualTrail.some((entry) => {
|
|
345
|
+
const event = record(entry);
|
|
346
|
+
return !event || (Object.hasOwn(event, 'target') && event.target !== null);
|
|
347
|
+
})) {
|
|
348
|
+
throw new TypeError('The CLI case pack contains a manual-trail target excluded by its audience.');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function reportApplicationVersion(report) {
|
|
353
|
+
return record(report.application)?.version;
|
|
354
|
+
}
|
|
355
|
+
function assertCurrentReportProjection(report, rawCase) {
|
|
356
|
+
if (typeof report.generatedAt !== 'string') {
|
|
357
|
+
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
|
|
358
|
+
}
|
|
359
|
+
const expected = buildCaseReport(rawCase, {
|
|
360
|
+
applicationVersion: reportApplicationVersion(report),
|
|
361
|
+
includeNotes: false,
|
|
362
|
+
generatedAt: report.generatedAt,
|
|
363
|
+
}).json;
|
|
364
|
+
if (!canonicalValuesMatch(report, expected)) {
|
|
365
|
+
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report projection.');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function historicalMaterialFields(caseVersion) {
|
|
369
|
+
if (caseVersion <= 7)
|
|
370
|
+
return HISTORICAL_BASE_MATERIAL_FIELDS;
|
|
371
|
+
if (caseVersion <= 9)
|
|
372
|
+
return HISTORICAL_EXTERNAL_FORM_MATERIAL_FIELDS;
|
|
373
|
+
return HISTORICAL_CONTEXT_MATERIAL_FIELDS;
|
|
374
|
+
}
|
|
375
|
+
function historicalMaterialValue(field, snapshot) {
|
|
376
|
+
switch (field) {
|
|
377
|
+
case 'availability':
|
|
378
|
+
return typeof snapshot.availability === 'string' && CONCLUSIVE_AVAILABILITY.has(snapshot.availability)
|
|
379
|
+
? snapshot.availability
|
|
380
|
+
: null;
|
|
381
|
+
case 'registrar':
|
|
382
|
+
return String(snapshot.registrar ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() || null;
|
|
383
|
+
case 'createdDate':
|
|
384
|
+
case 'expiryDate': {
|
|
385
|
+
const value = snapshot[field];
|
|
386
|
+
return typeof value === 'string' ? value.slice(0, 10) : null;
|
|
387
|
+
}
|
|
388
|
+
case 'riskFactors':
|
|
389
|
+
case 'opportunityFactors':
|
|
390
|
+
return snapshot[field].map((factor) => [factor.label, factor.points]);
|
|
391
|
+
default:
|
|
392
|
+
return snapshot[field] ?? null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function historicalMaterialIsEmpty(value) {
|
|
396
|
+
if (value === null || value === undefined)
|
|
397
|
+
return true;
|
|
398
|
+
if (Array.isArray(value))
|
|
399
|
+
return value.length === 0;
|
|
400
|
+
return typeof value === 'string' ? value.trim() === '' : false;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Recreates the exact material identity used by each historical Case epoch.
|
|
404
|
+
* Current migration happens separately so adding schema-12 fields can never
|
|
405
|
+
* invalidate an authentic old fingerprint or make a forged one look valid.
|
|
406
|
+
*/
|
|
407
|
+
function normalizeHistoricalEvidenceHistory(rawHistory, caseVersion) {
|
|
408
|
+
if (!Array.isArray(rawHistory) || rawHistory.length > MAX_EVIDENCE_SNAPSHOTS_PER_CASE) {
|
|
409
|
+
throw new TypeError('The CLI case pack contains an invalid historical evidence collection.');
|
|
410
|
+
}
|
|
411
|
+
const fields = historicalMaterialFields(caseVersion);
|
|
412
|
+
const materialSeen = new Set();
|
|
413
|
+
const built = rawHistory.map((raw, index) => {
|
|
414
|
+
const rawSnapshot = record(raw);
|
|
415
|
+
const normalized = normalizeSnapshot(raw, { fallback: null, sourceVersion: caseVersion });
|
|
416
|
+
if (!rawSnapshot || !normalized) {
|
|
417
|
+
throw new TypeError(`The CLI case pack contains an invalid historical evidence snapshot ${index}.`);
|
|
418
|
+
}
|
|
419
|
+
const materialRecord = {};
|
|
420
|
+
let hasEvidence = false;
|
|
421
|
+
for (const field of fields) {
|
|
422
|
+
const value = historicalMaterialValue(field, normalized);
|
|
423
|
+
materialRecord[field] = value;
|
|
424
|
+
if (field !== 'scanDepth' && field !== 'confidence' && !historicalMaterialIsEmpty(value))
|
|
425
|
+
hasEvidence = true;
|
|
426
|
+
}
|
|
427
|
+
if (!hasEvidence) {
|
|
428
|
+
throw new TypeError(`The CLI case pack contains an empty historical evidence snapshot ${index}.`);
|
|
429
|
+
}
|
|
430
|
+
const material = JSON.stringify(materialRecord);
|
|
431
|
+
if (materialSeen.has(material)) {
|
|
432
|
+
throw new TypeError('The CLI case pack contains duplicate historical evidence material.');
|
|
433
|
+
}
|
|
434
|
+
materialSeen.add(material);
|
|
435
|
+
const fingerprint = hashString(material);
|
|
436
|
+
return {
|
|
437
|
+
material,
|
|
438
|
+
snapshot: {
|
|
439
|
+
id: `ev-${fingerprint}`,
|
|
440
|
+
fingerprint,
|
|
441
|
+
firstCapturedAt: normalized.firstCapturedAt,
|
|
442
|
+
capturedAt: normalized.capturedAt,
|
|
443
|
+
source: normalized.source,
|
|
444
|
+
...Object.fromEntries(fields.map((field) => [field, normalized[field] ?? null])),
|
|
445
|
+
},
|
|
446
|
+
rawSnapshot,
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
built.sort((left, right) => (Date.parse(String(left.snapshot.capturedAt)) - Date.parse(String(right.snapshot.capturedAt))
|
|
450
|
+
|| Date.parse(String(left.snapshot.firstCapturedAt)) - Date.parse(String(right.snapshot.firstCapturedAt))
|
|
451
|
+
|| String(left.snapshot.fingerprint).localeCompare(String(right.snapshot.fingerprint))));
|
|
452
|
+
const usedIds = new Set();
|
|
453
|
+
for (const item of built) {
|
|
454
|
+
const base = String(item.snapshot.id);
|
|
455
|
+
let id = base;
|
|
456
|
+
let suffix = 2;
|
|
457
|
+
while (usedIds.has(id))
|
|
458
|
+
id = `${base}-${suffix++}`;
|
|
459
|
+
usedIds.add(id);
|
|
460
|
+
item.snapshot.id = id;
|
|
461
|
+
}
|
|
462
|
+
const projected = built.map((item) => item.snapshot);
|
|
463
|
+
assertProjectionKeys(rawHistory, projected, 'historical evidenceHistory');
|
|
464
|
+
for (let index = 0; index < built.length; index++) {
|
|
465
|
+
const item = built[index];
|
|
466
|
+
if (!item
|
|
467
|
+
|| item.rawSnapshot.id !== item.snapshot.id
|
|
468
|
+
|| item.rawSnapshot.fingerprint !== item.snapshot.fingerprint) {
|
|
469
|
+
throw new TypeError(`The CLI case pack contains an invalid historical evidence identity at snapshot ${index}.`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return projected;
|
|
473
|
+
}
|
|
474
|
+
function historicalEvidenceChanges(previous, current, caseVersion) {
|
|
475
|
+
const previousSnapshot = previous;
|
|
476
|
+
const currentSnapshot = current;
|
|
477
|
+
const changes = compareCaseEvidence(previousSnapshot, currentSnapshot)
|
|
478
|
+
.filter((change) => caseVersion >= 10 || !['opportunityScore', 'opportunityFactors'].includes(change.field));
|
|
479
|
+
if (caseVersion <= 9 && previous.opportunityScore !== current.opportunityScore) {
|
|
480
|
+
changes.push({
|
|
481
|
+
field: 'opportunityScore',
|
|
482
|
+
label: 'Opportunity score',
|
|
483
|
+
before: previous.opportunityScore ?? null,
|
|
484
|
+
after: current.opportunityScore ?? null,
|
|
485
|
+
tone: 'neutral',
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (caseVersion <= 9 && !canonicalValuesMatch(previous.opportunityFactors, current.opportunityFactors)) {
|
|
489
|
+
changes.push({
|
|
490
|
+
field: 'opportunityFactors',
|
|
491
|
+
label: 'Opportunity factors',
|
|
492
|
+
before: previous.opportunityFactors,
|
|
493
|
+
after: current.opportunityFactors,
|
|
494
|
+
tone: 'neutral',
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const order = new Map(HISTORICAL_CHANGE_ORDER.map((field, index) => [field, index]));
|
|
498
|
+
return changes
|
|
499
|
+
.sort((left, right) => (order.get(left.field) ?? Number.MAX_SAFE_INTEGER) - (order.get(right.field) ?? Number.MAX_SAFE_INTEGER))
|
|
500
|
+
.slice(0, 40);
|
|
501
|
+
}
|
|
502
|
+
function historicalEvidenceTimeline(history, caseVersion) {
|
|
503
|
+
return history.map((snapshot, index) => {
|
|
504
|
+
if (index === 0) {
|
|
505
|
+
return {
|
|
506
|
+
snapshot,
|
|
507
|
+
isBaseline: true,
|
|
508
|
+
hasRepeatedObservation: snapshot.firstCapturedAt !== snapshot.capturedAt,
|
|
509
|
+
changes: null,
|
|
510
|
+
hasIncomparableChange: false,
|
|
511
|
+
incomparableReasons: [],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const previous = history[index - 1];
|
|
515
|
+
if (!previous)
|
|
516
|
+
throw new TypeError('The CLI case pack contains an invalid historical evidence timeline.');
|
|
517
|
+
const changes = historicalEvidenceChanges(previous, snapshot, caseVersion);
|
|
518
|
+
let incomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot).filter((reason) => caseVersion >= 10 || reason !== 'opportunity-model');
|
|
519
|
+
if (changes.length === 0 && snapshot.fingerprint !== previous.fingerprint && incomparableReasons.length === 0) {
|
|
520
|
+
incomparableReasons = ['other'];
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
snapshot,
|
|
524
|
+
isBaseline: false,
|
|
525
|
+
hasRepeatedObservation: snapshot.firstCapturedAt !== snapshot.capturedAt,
|
|
526
|
+
changes: changes.length ? changes : null,
|
|
527
|
+
hasIncomparableChange: incomparableReasons.length > 0,
|
|
528
|
+
incomparableReasons,
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function pickHistoricalFields(value, fields) {
|
|
533
|
+
return Object.fromEntries(fields.map((field) => [field, structuredClone(value[field])]));
|
|
534
|
+
}
|
|
535
|
+
function historicalAnalystResponse(caseRecord, caseVersion, reportVersion) {
|
|
536
|
+
if (reportVersion === 1)
|
|
537
|
+
return null;
|
|
538
|
+
const oldPinFields = ['id', 'label', 'value', 'source', 'observedAt', 'completeness', 'limitations', 'createdAt'];
|
|
539
|
+
const expandedPinFields = [
|
|
540
|
+
'id', 'checkpointId', 'field', 'category', 'label', 'value', 'source', 'sourceState', 'sourceSchema',
|
|
541
|
+
'observedAt', 'collectionDepth', 'completeness', 'truncated',
|
|
542
|
+
...(caseVersion >= 7 ? ['transitionExpectation'] : []),
|
|
543
|
+
'limitations', 'createdAt',
|
|
544
|
+
];
|
|
545
|
+
const evidencePins = caseRecord.evidencePins.map((item) => pickHistoricalFields(item, caseVersion >= 5 ? expandedPinFields : oldPinFields));
|
|
546
|
+
const decisions = caseRecord.decisions.map((item) => pickHistoricalFields(item, ['id', 'summary', 'rationale', 'evidencePinIds', 'createdAt']));
|
|
547
|
+
const actions = caseRecord.actions.map((item) => pickHistoricalFields(item, ['id', 'type', 'recipient', 'contactSource', 'contactLimitations', 'dueAt', 'state', 'reference', 'followUpAt', 'outcome', 'createdAt', 'updatedAt']));
|
|
548
|
+
const assertions = caseRecord.assertions.map((item) => {
|
|
549
|
+
const projected = pickHistoricalFields(item, [
|
|
550
|
+
'id', 'kind', 'statement', 'rationale', 'evidencePinIds',
|
|
551
|
+
...(reportVersion >= 6 ? ['evidenceRelations'] : []),
|
|
552
|
+
'state', 'createdAt', 'updatedAt',
|
|
553
|
+
]);
|
|
554
|
+
if (caseVersion >= 7 && item.provenance)
|
|
555
|
+
projected.provenance = structuredClone(item.provenance);
|
|
556
|
+
return projected;
|
|
557
|
+
});
|
|
558
|
+
const manualTrail = caseRecord.manualTrail.map((item) => pickHistoricalFields(item, ['id', 'kind', 'summary', 'target', 'createdAt']));
|
|
559
|
+
const sightings = caseRecord.sightings.map((item) => pickHistoricalFields(item, ['id', 'state', 'sourceClass', 'category', 'source', 'observedAt', 'completeness', 'evidencePinId', 'limitations', 'createdAt']));
|
|
560
|
+
const branches = (caseRecord.branches ?? []).map((item) => pickHistoricalFields(item, ['id', 'name', 'state', 'evidencePinIds', 'checkpointIds', 'assertionIds', 'actionIds', 'createdAt', 'updatedAt']));
|
|
561
|
+
return {
|
|
562
|
+
evidencePins,
|
|
563
|
+
decisions,
|
|
564
|
+
actions,
|
|
565
|
+
...(reportVersion >= 3 ? { assertions, manualTrail } : {}),
|
|
566
|
+
...(reportVersion >= 4 ? { sightings } : {}),
|
|
567
|
+
...(reportVersion >= 7 ? { branches } : {}),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function assertHistoricalCaseResponseEpoch(rawCase, caseRecord, caseVersion, reportVersion, path) {
|
|
571
|
+
const expected = historicalAnalystResponse(caseRecord, caseVersion, reportVersion);
|
|
572
|
+
const introductions = new Map([
|
|
573
|
+
['evidencePins', 3], ['decisions', 3], ['actions', 3],
|
|
574
|
+
['assertions', 4], ['manualTrail', 4], ['sightings', 9], ['branches', 11],
|
|
575
|
+
]);
|
|
576
|
+
for (const [field, introduced] of introductions) {
|
|
577
|
+
if (caseVersion < introduced) {
|
|
578
|
+
if (Object.hasOwn(rawCase, field)) {
|
|
579
|
+
throw new TypeError(`The CLI case pack contains ${path}.${field} outside its historical Case epoch.`);
|
|
580
|
+
}
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (Object.hasOwn(rawCase, field)) {
|
|
584
|
+
const projection = record(expected)?.[field];
|
|
585
|
+
assertProjectionKeys(rawCase[field], projection, `${path}.${field}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (caseVersion < 10 && Object.hasOwn(rawCase, 'reviewReasonCode')) {
|
|
589
|
+
throw new TypeError(`The CLI case pack contains ${path}.reviewReasonCode outside its historical Case epoch.`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function historicalReportProjection(caseRecord, evidenceHistory, report, caseVersion, reportVersion, generatedAt) {
|
|
593
|
+
const limitation = LEGACY_REPORT_LIMITATION_BY_VERSION.get(reportVersion);
|
|
594
|
+
if (!limitation)
|
|
595
|
+
throw new TypeError('The CLI case pack contains an unsupported historical Case report.');
|
|
596
|
+
const reportCase = {
|
|
597
|
+
id: caseRecord.id,
|
|
598
|
+
domain: caseRecord.domain,
|
|
599
|
+
status: caseRecord.status,
|
|
600
|
+
disposition: caseRecord.disposition,
|
|
601
|
+
...(reportVersion >= 5 ? { reviewReasonCode: caseRecord.reviewReasonCode ?? null } : {}),
|
|
602
|
+
...(reportVersion >= 6 ? { interoperabilityTags: analystInteroperabilityTags(caseRecord.disposition, caseRecord.reviewReasonCode) } : {}),
|
|
603
|
+
tags: [...caseRecord.tags],
|
|
604
|
+
source: caseRecord.source,
|
|
605
|
+
openedAt: caseRecord.createdAt,
|
|
606
|
+
updatedAt: caseRecord.updatedAt,
|
|
607
|
+
notesIncluded: false,
|
|
608
|
+
};
|
|
609
|
+
const response = historicalAnalystResponse(caseRecord, caseVersion, reportVersion);
|
|
610
|
+
return {
|
|
611
|
+
schema: CASE_REPORT_SCHEMA,
|
|
612
|
+
schemaVersion: reportVersion,
|
|
613
|
+
generatedAt,
|
|
614
|
+
application: reportVersion >= 6
|
|
615
|
+
? buildPortableGeneratorMetadata(reportApplicationVersion(report))
|
|
616
|
+
: { name: 'WHOISleuth' },
|
|
617
|
+
case: reportCase,
|
|
618
|
+
currentAssessment: evidenceHistory.at(-1) ?? null,
|
|
619
|
+
evidenceTimeline: historicalEvidenceTimeline(evidenceHistory, caseVersion),
|
|
620
|
+
...(response ? { analystResponse: response } : {}),
|
|
621
|
+
limitations: limitation,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function assertLegacyProfileContextEpoch(rawCase, report) {
|
|
625
|
+
const assertSnapshot = (value, path) => {
|
|
626
|
+
const snapshot = record(value);
|
|
627
|
+
if (snapshot && (Object.hasOwn(snapshot, 'profileContextState') || Object.hasOwn(snapshot, 'profileContextLimitation'))) {
|
|
628
|
+
throw new TypeError(`The CLI case pack contains schema-12 profile context fields outside the historical ${path} epoch.`);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
if (Array.isArray(rawCase.evidenceHistory)) {
|
|
632
|
+
rawCase.evidenceHistory.forEach((snapshot, index) => assertSnapshot(snapshot, `cases evidence snapshot ${index}`));
|
|
633
|
+
}
|
|
634
|
+
assertSnapshot(report.currentAssessment, 'current assessment');
|
|
635
|
+
if (Array.isArray(report.evidenceTimeline)) {
|
|
636
|
+
report.evidenceTimeline.forEach((entry, index) => {
|
|
637
|
+
const timelineEntry = record(entry);
|
|
638
|
+
assertSnapshot(timelineEntry?.snapshot, `report timeline snapshot ${index}`);
|
|
639
|
+
if (!Array.isArray(timelineEntry?.changes))
|
|
640
|
+
return;
|
|
641
|
+
timelineEntry.changes.forEach((value, changeIndex) => {
|
|
642
|
+
const change = record(value);
|
|
643
|
+
if (!change)
|
|
644
|
+
return;
|
|
645
|
+
const field = change.field;
|
|
646
|
+
const label = change.label;
|
|
647
|
+
const carriesProfileContextField = ['profileContextState', 'profileContextLimitation'].some((key) => (Object.hasOwn(change, key)
|
|
648
|
+
|| field === key
|
|
649
|
+
|| [change.before, change.after].some((candidate) => Boolean(record(candidate) && Object.hasOwn(record(candidate), key)))));
|
|
650
|
+
if (carriesProfileContextField
|
|
651
|
+
|| (typeof label === 'string' && /profile[ -]?context/iu.test(label))) {
|
|
652
|
+
throw new TypeError(`The CLI case pack contains schema-12 profile context fields outside the historical report timeline change ${index}.${changeIndex} epoch.`);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
11
658
|
function redactedCase(record, audience) {
|
|
12
659
|
if (audience === 'internal')
|
|
13
660
|
return structuredClone(record);
|
|
14
661
|
return {
|
|
15
662
|
...structuredClone(record),
|
|
663
|
+
brandProfileIds: audience === 'public' ? [] : [...record.brandProfileIds],
|
|
16
664
|
notes: [],
|
|
17
665
|
actions: audience === 'public' ? [] : record.actions.map((item) => ({ ...item, recipient: '[redacted]' })),
|
|
18
666
|
manualTrail: record.manualTrail.map((item) => ({ ...item, target: null })),
|
|
19
667
|
assertions: audience === 'public' ? [] : structuredClone(record.assertions),
|
|
668
|
+
branches: audience === 'public' ? [] : structuredClone(record.branches ?? []),
|
|
20
669
|
};
|
|
21
670
|
}
|
|
22
671
|
export function buildCliCasePack(input, options, generatedAt = new Date().toISOString()) {
|
|
@@ -35,6 +684,23 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
35
684
|
if (typeof root.version !== 'number' || !CASE_IMPORT_VERSIONS.includes(root.version) || !Array.isArray(root.cases)) {
|
|
36
685
|
throw new CliUsageError(`Case-pack input must be a supported WHOISleuth case export through schema ${CASE_SCHEMA_VERSION}.`);
|
|
37
686
|
}
|
|
687
|
+
if (root.version === CASE_SCHEMA_VERSION) {
|
|
688
|
+
try {
|
|
689
|
+
assertCanonicalCaseIdentities(root.cases, 'Case-pack input');
|
|
690
|
+
for (const item of root.cases) {
|
|
691
|
+
const rawCase = record(item);
|
|
692
|
+
if (!rawCase || !Object.hasOwn(rawCase, 'brandProfileIds'))
|
|
693
|
+
throw new Error('missing');
|
|
694
|
+
assertCaseBrandProfileIds(rawCase.brandProfileIds);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch (cause) {
|
|
698
|
+
if (cause instanceof TypeError && /Case identity/u.test(cause.message)) {
|
|
699
|
+
throw new CliUsageError(cause.message);
|
|
700
|
+
}
|
|
701
|
+
throw new CliUsageError('Case-pack schema 12 input requires exact canonical Case identities and an exact, unique, bounded brandProfileIds array on every case.');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
38
704
|
const normalised = normalizeCaseStore(root).cases;
|
|
39
705
|
if (!normalised.length)
|
|
40
706
|
throw new CliUsageError('Case-pack input did not contain a valid case.');
|
|
@@ -44,33 +710,44 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
44
710
|
if (normalised.length > MAX_CASE_PACK_CASES) {
|
|
45
711
|
throw new CliUsageError(`Case packs are limited to ${MAX_CASE_PACK_CASES} reviewed cases. Export a smaller selected set so no case is silently omitted.`);
|
|
46
712
|
}
|
|
713
|
+
if (root.version === CASE_SCHEMA_VERSION) {
|
|
714
|
+
try {
|
|
715
|
+
assertCurrentCaseProjection(root.cases, normalised, 'Case-pack input');
|
|
716
|
+
}
|
|
717
|
+
catch (cause) {
|
|
718
|
+
throw new CliUsageError(cause instanceof Error ? cause.message : 'Case-pack schema 12 input is not exact.');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
47
721
|
const cases = normalised.map((item) => redactedCase(item, options.audience));
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
722
|
+
const brandProfileReferencesOmitted = options.audience === 'public'
|
|
723
|
+
? normalised.reduce((count, item) => count + item.brandProfileIds.length, 0)
|
|
724
|
+
: 0;
|
|
725
|
+
const reports = cases.map((item) => buildCaseReport(item, {
|
|
726
|
+
applicationVersion: WHOISLEUTH_APPLICATION_VERSION,
|
|
727
|
+
includeNotes: false,
|
|
728
|
+
generatedAt,
|
|
729
|
+
}).json);
|
|
730
|
+
const exclusions = expectedExclusions(options.audience, CASE_SCHEMA_VERSION);
|
|
54
731
|
const packet = Object.freeze({
|
|
55
732
|
schema: CLI_CASE_PACK_SCHEMA,
|
|
56
733
|
version: CLI_CASE_PACK_VERSION,
|
|
57
734
|
audience: options.audience,
|
|
58
735
|
reviewed: true,
|
|
59
736
|
reports: Object.freeze(reports),
|
|
60
|
-
redactionManifest: Object.freeze({
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
737
|
+
redactionManifest: Object.freeze({
|
|
738
|
+
excluded: Object.freeze(exclusions),
|
|
739
|
+
sourceCaseCount: normalised.length,
|
|
740
|
+
brandProfileReferencesOmitted,
|
|
741
|
+
}),
|
|
742
|
+
limitations: Object.freeze(PACKET_LIMITATIONS),
|
|
66
743
|
});
|
|
67
744
|
const unsigned = { version: CASE_SCHEMA_VERSION, exportedAt: generatedAt, cases, packet };
|
|
68
745
|
const document = Object.freeze({
|
|
69
746
|
...unsigned,
|
|
70
747
|
integrity: Object.freeze({
|
|
71
748
|
algorithm: 'SHA-256',
|
|
72
|
-
canonicalization:
|
|
73
|
-
digestSha256: `sha256:${createHash('sha256').update(
|
|
749
|
+
canonicalization: SORTED_JSON_V2,
|
|
750
|
+
digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJsonV2(unsigned)).digest('hex')}`,
|
|
74
751
|
}),
|
|
75
752
|
});
|
|
76
753
|
if (Buffer.byteLength(JSON.stringify(document), 'utf8') > MAX_CASE_IMPORT_BYTES) {
|
|
@@ -79,47 +756,155 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
79
756
|
return document;
|
|
80
757
|
}
|
|
81
758
|
export function verifyCliCasePack(input) {
|
|
82
|
-
const root =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
759
|
+
const root = record(input);
|
|
760
|
+
let serialized;
|
|
761
|
+
try {
|
|
762
|
+
serialized = JSON.stringify(root);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
throw new TypeError('The CLI case pack structure is not serializable.');
|
|
766
|
+
}
|
|
767
|
+
if (Buffer.byteLength(serialized, 'utf8') > MAX_CASE_IMPORT_BYTES) {
|
|
768
|
+
throw new TypeError('The CLI case pack exceeds the browser import limit.');
|
|
769
|
+
}
|
|
770
|
+
const packet = record(root?.packet);
|
|
771
|
+
const integrity = record(root?.integrity);
|
|
772
|
+
const redactionManifest = record(packet?.redactionManifest);
|
|
91
773
|
if (!root
|
|
92
774
|
|| !packet
|
|
93
775
|
|| packet.schema !== CLI_CASE_PACK_SCHEMA
|
|
94
|
-
|| packet.version !== CLI_CASE_PACK_VERSION
|
|
776
|
+
|| (packet.version !== LEGACY_CLI_CASE_PACK_VERSION && packet.version !== CLI_CASE_PACK_VERSION)
|
|
95
777
|
|| packet.reviewed !== true
|
|
96
778
|
|| !['internal', 'public', 'trusted'].includes(String(packet.audience))
|
|
97
779
|
|| !Array.isArray(packet.reports)
|
|
780
|
+
|| !redactionManifest
|
|
98
781
|
|| !Array.isArray(root.cases)
|
|
99
782
|
|| root.cases.length < 1
|
|
100
783
|
|| root.cases.length > MAX_CASE_PACK_CASES
|
|
101
784
|
|| packet.reports.length !== root.cases.length
|
|
785
|
+
|| typeof root.exportedAt !== 'string'
|
|
102
786
|
|| typeof root.version !== 'number'
|
|
103
787
|
|| !CASE_IMPORT_VERSIONS.includes(root.version)
|
|
104
788
|
|| !integrity
|
|
105
789
|
|| integrity.algorithm !== 'SHA-256'
|
|
106
|
-
|| integrity.canonicalization !== 'sorted-json-v1'
|
|
107
790
|
|| typeof integrity.digestSha256 !== 'string'
|
|
108
791
|
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
109
792
|
throw new TypeError('The CLI case pack structure or integrity envelope is invalid.');
|
|
110
793
|
}
|
|
111
|
-
|
|
112
|
-
|
|
794
|
+
let canonicalization;
|
|
795
|
+
try {
|
|
796
|
+
canonicalization = resolveArtifactCanonicalization(packet.version, integrity.canonicalization, [
|
|
797
|
+
{ version: LEGACY_CLI_CASE_PACK_VERSION, canonicalization: SORTED_JSON_V1, explicit: true },
|
|
798
|
+
{ version: CLI_CASE_PACK_VERSION, canonicalization: SORTED_JSON_V2, explicit: true },
|
|
799
|
+
], 'CLI case pack');
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
throw new TypeError('The CLI case pack structure or integrity envelope is invalid.');
|
|
113
803
|
}
|
|
114
804
|
const { integrity: _integrity, ...unsigned } = root;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
805
|
+
const audience = packet.audience;
|
|
806
|
+
const current = root.version === CASE_SCHEMA_VERSION;
|
|
807
|
+
assertClosedEnvelopes(root, packet, integrity, redactionManifest, packet.reports, current);
|
|
808
|
+
assertSensitiveFieldPlacement(root, current, serialized.length);
|
|
809
|
+
if (!Number.isSafeInteger(redactionManifest.sourceCaseCount)
|
|
810
|
+
|| Number(redactionManifest.sourceCaseCount) !== root.cases.length
|
|
811
|
+
|| !stringListsMatch(redactionManifest.excluded, expectedExclusions(audience, root.version))) {
|
|
812
|
+
throw new TypeError('The CLI case pack has an invalid audience redaction manifest.');
|
|
118
813
|
}
|
|
814
|
+
const caseReferenceLists = [];
|
|
815
|
+
const reportReferenceLists = [];
|
|
816
|
+
if (!current)
|
|
817
|
+
assertHistoricalCaseOwnKeys(root.cases, root.version);
|
|
818
|
+
assertCanonicalCaseIdentities(root.cases, 'The CLI case pack');
|
|
119
819
|
const normalised = normalizeCaseStore(root).cases;
|
|
120
820
|
if (normalised.length !== root.cases.length) {
|
|
121
821
|
throw new TypeError('The CLI case pack contains an invalid case collection.');
|
|
122
822
|
}
|
|
823
|
+
const normalisedByDomain = new Map(normalised.map((item) => [item.domain, item]));
|
|
824
|
+
if (current)
|
|
825
|
+
assertCurrentCaseProjection(root.cases, normalised, 'The CLI case pack');
|
|
826
|
+
for (let index = 0; index < root.cases.length; index++) {
|
|
827
|
+
const rawCase = record(root.cases[index]);
|
|
828
|
+
const report = record(packet.reports[index]);
|
|
829
|
+
const reportCase = record(report?.case);
|
|
830
|
+
const reportVersion = report?.schemaVersion;
|
|
831
|
+
if (!rawCase
|
|
832
|
+
|| !report
|
|
833
|
+
|| !reportCase
|
|
834
|
+
|| typeof rawCase.domain !== 'string'
|
|
835
|
+
|| report.schema !== CASE_REPORT_SCHEMA
|
|
836
|
+
|| !reportVersionMatchesCase(root.version, reportVersion)
|
|
837
|
+
|| reportCase.id !== rawCase.id
|
|
838
|
+
|| reportCase.domain !== rawCase.domain) {
|
|
839
|
+
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
|
|
840
|
+
}
|
|
841
|
+
const normalisedCase = normalisedByDomain.get(rawCase.domain);
|
|
842
|
+
if (!normalisedCase)
|
|
843
|
+
throw new TypeError('The CLI case pack contains an invalid case collection.');
|
|
844
|
+
assertAudienceFields(rawCase, audience, root.version >= 11);
|
|
845
|
+
if (!current)
|
|
846
|
+
assertLegacyProfileContextEpoch(rawCase, report);
|
|
847
|
+
if (current) {
|
|
848
|
+
try {
|
|
849
|
+
if (!Object.hasOwn(rawCase, 'brandProfileIds') || !Object.hasOwn(reportCase, 'brandProfileIds'))
|
|
850
|
+
throw new Error('missing');
|
|
851
|
+
caseReferenceLists.push(assertCaseBrandProfileIds(rawCase.brandProfileIds));
|
|
852
|
+
reportReferenceLists.push(assertCaseBrandProfileIds(reportCase.brandProfileIds));
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
throw new TypeError('The CLI case pack contains invalid Brand Profile references.');
|
|
856
|
+
}
|
|
857
|
+
assertCurrentReportProjection(report, rawCase);
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
const historicalHistory = normalizeHistoricalEvidenceHistory(rawCase.evidenceHistory, root.version);
|
|
861
|
+
assertProjectionKeys(rawCase, { ...normalisedCase, evidenceHistory: historicalHistory }, `cases[${index}]`);
|
|
862
|
+
assertHistoricalCaseResponseEpoch(rawCase, normalisedCase, root.version, reportVersion, `cases[${index}]`);
|
|
863
|
+
if (report.generatedAt !== root.exportedAt) {
|
|
864
|
+
throw new TypeError('The CLI case pack contains an invalid or mismatched historical report generation time.');
|
|
865
|
+
}
|
|
866
|
+
const expectedReport = historicalReportProjection(normalisedCase, historicalHistory, report, root.version, reportVersion, root.exportedAt);
|
|
867
|
+
if (!canonicalValuesMatch(report, expectedReport)) {
|
|
868
|
+
throw new TypeError('The CLI case pack contains an invalid or mismatched historical Case report projection.');
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (current) {
|
|
873
|
+
const omitted = redactionManifest.brandProfileReferencesOmitted;
|
|
874
|
+
if (!Object.hasOwn(redactionManifest, 'brandProfileReferencesOmitted')
|
|
875
|
+
|| !Number.isSafeInteger(omitted)
|
|
876
|
+
|| Number(omitted) < 0
|
|
877
|
+
|| Number(omitted) > MAX_CASE_PACK_CASES * 8) {
|
|
878
|
+
throw new TypeError('The CLI case pack has an invalid Brand Profile redaction manifest.');
|
|
879
|
+
}
|
|
880
|
+
const referencesMatch = caseReferenceLists.every((references, index) => {
|
|
881
|
+
const reportReferences = reportReferenceLists[index];
|
|
882
|
+
return reportReferences?.length === references.length
|
|
883
|
+
&& references.every((reference, referenceIndex) => reportReferences[referenceIndex] === reference);
|
|
884
|
+
});
|
|
885
|
+
if (audience === 'public') {
|
|
886
|
+
if (caseReferenceLists.some((references) => references.length !== 0)
|
|
887
|
+
|| reportReferenceLists.some((references) => references.length !== 0)) {
|
|
888
|
+
throw new TypeError('The public CLI case pack has an invalid Brand Profile redaction manifest.');
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
else if (Number(omitted) !== 0 || !referencesMatch) {
|
|
892
|
+
throw new TypeError('The trusted or internal CLI case pack has inconsistent Brand Profile references.');
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
else if (Object.hasOwn(redactionManifest, 'brandProfileReferencesOmitted')) {
|
|
896
|
+
throw new TypeError('The legacy CLI case pack has an invalid Brand Profile redaction manifest.');
|
|
897
|
+
}
|
|
898
|
+
let calculated;
|
|
899
|
+
try {
|
|
900
|
+
calculated = `sha256:${createHash('sha256').update(canonicalArtifactJsonFor(unsigned, canonicalization)).digest('hex')}`;
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
throw new TypeError('The CLI case pack structure is not serializable.');
|
|
904
|
+
}
|
|
905
|
+
if (calculated !== integrity.digestSha256) {
|
|
906
|
+
throw new TypeError('The CLI case pack failed its SHA-256 integrity check.');
|
|
907
|
+
}
|
|
123
908
|
return Object.freeze({ caseCount: normalised.length });
|
|
124
909
|
}
|
|
125
910
|
export function formatCliCasePack(document) {
|