@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../lib/bounded-json.mjs";
|
|
3
|
+
import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
|
|
4
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
5
|
+
import { MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_OBJECTIVE_LENGTH, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, MAX_NOTE_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../packages/contracts/case-portability.mjs";
|
|
6
|
+
import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
|
|
7
|
+
import { createCaseIncident, openOrCreateCase, recordCaseConclusion, recordCaseRecheckOutcome, updateCase } from "../packages/cases/case-record-operations.mjs";
|
|
8
|
+
import { buildCaseExport, serializeCaseStore } from "../packages/cases/case-storage-model.mjs";
|
|
9
|
+
import { appendCaseEvidencePin } from "../packages/cases/case-response-model.mjs";
|
|
10
|
+
import { readCaseRecheckAnswerContext } from "../packages/cases/case-recheck-model.mjs";
|
|
11
|
+
import { dispositionLabel, statusLabel } from "../packages/cases/case-record-decisions.mjs";
|
|
12
|
+
import { normalizeDomain } from "../packages/cases/case-record-core.mjs";
|
|
13
|
+
import { safeTerminalValue } from "./formatters/terminal.mjs";
|
|
14
|
+
import { CliUsageError } from "./errors.mjs";
|
|
15
|
+
import EXIT_CODES from "./exit-codes.mjs";
|
|
16
|
+
function fields(input, allowed, label) {
|
|
17
|
+
if (Object.keys(input).some(key => !allowed.includes(key)))
|
|
18
|
+
throw new CliUsageError(`${label} contains an unsupported field.`);
|
|
19
|
+
}
|
|
20
|
+
function prose(input, maximum, label) {
|
|
21
|
+
if (typeof input !== 'string' || !input.trim() || input.trim().length > maximum
|
|
22
|
+
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(input)) {
|
|
23
|
+
throw new CliUsageError(`${label} must contain 1–${maximum} characters without unsafe control characters.`);
|
|
24
|
+
}
|
|
25
|
+
return input.trim();
|
|
26
|
+
}
|
|
27
|
+
function selection(cases, args, required) {
|
|
28
|
+
const domain = args.domain === null ? null : normalizeDomain(args.domain);
|
|
29
|
+
if (args.domain !== null && !domain)
|
|
30
|
+
throw new CliUsageError('Select a valid Case domain.');
|
|
31
|
+
const selected = cases.filter(record => (!args.caseId || record.id === args.caseId) && (!domain || record.domain === domain));
|
|
32
|
+
if ((args.caseId || domain || required) && selected.length === 0)
|
|
33
|
+
throw new CliUsageError('The selected Case is not present in this file.');
|
|
34
|
+
if (required && selected.length !== 1)
|
|
35
|
+
throw new CliUsageError('Select exactly one Case with --case-id; no arbitrary Case was chosen.');
|
|
36
|
+
return selected;
|
|
37
|
+
}
|
|
38
|
+
function pin(input, now) {
|
|
39
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
40
|
+
throw new CliUsageError('An evidence pin must be a JSON object.');
|
|
41
|
+
const raw = input;
|
|
42
|
+
if (Object.hasOwn(raw, 'id') || Object.hasOwn(raw, 'createdAt'))
|
|
43
|
+
throw new CliUsageError('New pin identity and creation time are allocated by the Case operation.');
|
|
44
|
+
const normal = appendCaseEvidencePin([], raw, now)[0];
|
|
45
|
+
// Validate the supplied subset against the domain result, without duplicating
|
|
46
|
+
// its field inventory or accepting silent truncation/default substitution.
|
|
47
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
48
|
+
if (!Object.hasOwn(normal, key) || canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(normal[key])) {
|
|
49
|
+
throw new CliUsageError(`Pin field ${safeTerminalValue(key)} is not canonical or exceeds its bound. Use explicit ISO timestamps and supported values; no pin was saved.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
|
54
|
+
function retainExistingEntries(before, after) {
|
|
55
|
+
const pairs = [
|
|
56
|
+
[before.notes, after.notes], [before.evidenceHistory, after.evidenceHistory], [before.evidencePins, after.evidencePins],
|
|
57
|
+
[before.decisions, after.decisions], [before.actions, after.actions], [before.assertions, after.assertions],
|
|
58
|
+
[before.manualTrail, after.manualTrail], [before.sightings, after.sightings],
|
|
59
|
+
[before.observedEffects.reviews, after.observedEffects.reviews], [before.closures.records, after.closures.records],
|
|
60
|
+
[before.branches ?? [], after.branches ?? []], [before.attachments ?? [], after.attachments ?? []],
|
|
61
|
+
];
|
|
62
|
+
for (const [prior, next] of pairs) {
|
|
63
|
+
const retained = new Set(next.map(value => canonicalArtifactJsonV2(value)));
|
|
64
|
+
if (prior.some(value => !retained.has(canonicalArtifactJsonV2(value)))) {
|
|
65
|
+
throw new CliUsageError('The operation would remove or alter earlier retained evidence. Export a smaller working set or review it in the console; nothing was written.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function mutate(cases, args, input, note, now) {
|
|
70
|
+
if (args.operation === 'open') {
|
|
71
|
+
const title = args.title === null ? undefined : prose(args.title, MAX_CASE_OBJECTIVE_LENGTH, 'Incident title');
|
|
72
|
+
const result = args.newIncident
|
|
73
|
+
? createCaseIncident(cases, { domain: args.domain, title }, now)
|
|
74
|
+
: openOrCreateCase(cases, { domain: args.domain, ...(title ? { title } : {}) }, now, args.caseId ? { caseId: args.caseId } : {});
|
|
75
|
+
if (!result.created && title && title !== result.record.title)
|
|
76
|
+
throw new CliUsageError('The selected Case already exists with another title. Use --new-incident for a separate investigation.');
|
|
77
|
+
return result.cases;
|
|
78
|
+
}
|
|
79
|
+
const current = selection(cases, args, true)[0];
|
|
80
|
+
let result;
|
|
81
|
+
if (args.operation === 'note') {
|
|
82
|
+
result = updateCase(cases, current.id, { note: prose(note, MAX_NOTE_LENGTH, 'Case note') }, now);
|
|
83
|
+
}
|
|
84
|
+
else if (args.operation === 'pin') {
|
|
85
|
+
result = updateCase(cases, current.id, { evidencePin: pin(input, now) }, now);
|
|
86
|
+
}
|
|
87
|
+
else if (args.operation === 'assess') {
|
|
88
|
+
if (!input)
|
|
89
|
+
throw new CliUsageError('An assessment requires JSON input.');
|
|
90
|
+
fields(input, ['disposition', 'reviewReasonCode', 'summary', 'rationale', 'evidence'], 'Assessment');
|
|
91
|
+
if (!Array.isArray(input.evidence) || !input.evidence.length || input.evidence.length > MAX_CASE_CHECKPOINT_FACTS) {
|
|
92
|
+
throw new CliUsageError(`Select 1–${MAX_CASE_CHECKPOINT_FACTS} evidence facts for the assessment.`);
|
|
93
|
+
}
|
|
94
|
+
const evidence = input.evidence.map(value => {
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
96
|
+
throw new CliUsageError('Each assessment fact needs a pin and stance.');
|
|
97
|
+
fields(value, ['pin', 'pinId', 'stance'], 'Assessment evidence');
|
|
98
|
+
if (!['supports', 'contradicts', 'unresolved'].includes(value.stance))
|
|
99
|
+
throw new CliUsageError('Use supports, contradicts or unresolved for each evidence stance.');
|
|
100
|
+
if (Object.hasOwn(value, 'pin') === Object.hasOwn(value, 'pinId'))
|
|
101
|
+
throw new CliUsageError('Select one new pin or one retained pinId for each assessment fact.');
|
|
102
|
+
return Object.hasOwn(value, 'pinId') ? { pinId: value.pinId, stance: value.stance } : { pin: pin(value.pin, now), stance: value.stance };
|
|
103
|
+
});
|
|
104
|
+
const summary = prose(input.summary, MAX_RESPONSE_VALUE_LENGTH, 'Assessment summary');
|
|
105
|
+
const rationale = prose(input.rationale, MAX_RESPONSE_RATIONALE_LENGTH, 'Assessment rationale');
|
|
106
|
+
result = recordCaseConclusion(cases, current.id, { disposition: input.disposition, reviewReasonCode: input.reviewReasonCode, summary, rationale, evidence }, now);
|
|
107
|
+
if (result.record.decisions.at(-1)?.summary !== summary || result.record.decisions.at(-1)?.rationale !== rationale) {
|
|
108
|
+
throw new CliUsageError('Assessment text needs canonical single-line values; no assessment was written.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (args.operation === 'recheck') {
|
|
112
|
+
if (!input)
|
|
113
|
+
throw new CliUsageError('A recheck requires JSON input.');
|
|
114
|
+
fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname'], 'Recheck');
|
|
115
|
+
const recheck = readCaseRecheckAnswerContext(input.recheck);
|
|
116
|
+
if (input.state === 'not_reproduced' && !recheck) {
|
|
117
|
+
throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned Case or record unavailable for a limited observation.');
|
|
118
|
+
}
|
|
119
|
+
const summary = prose(input.comparisonSummary, MAX_RESPONSE_VALUE_LENGTH, 'Comparison summary');
|
|
120
|
+
result = recordCaseRecheckOutcome(cases, current.id, {
|
|
121
|
+
...input, state: input.state, observedAt: input.observedAt, completeness: input.completeness,
|
|
122
|
+
comparisonSummary: summary, source: input.source, ...(recheck ? { recheck } : {}),
|
|
123
|
+
}, now);
|
|
124
|
+
const review = result.record.observedEffects.reviews.at(-1);
|
|
125
|
+
const comparison = result.record.evidencePins.find(value => value.id === review.evidencePinId);
|
|
126
|
+
const projected = { state: review.state, observedAt: review.observedAt, completeness: review.completeness,
|
|
127
|
+
comparisonSummary: comparison.value, source: review.source, followUpAt: review.followUpAt,
|
|
128
|
+
limitations: review.limitations, collectionDepth: comparison.collectionDepth,
|
|
129
|
+
...(review.recheck ? { recheck: review.recheck } : {}),
|
|
130
|
+
...(comparison.observationHostname ? { observationHostname: comparison.observationHostname } : {}),
|
|
131
|
+
};
|
|
132
|
+
for (const [key, value] of Object.entries(input)) {
|
|
133
|
+
if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(projected[key]))
|
|
134
|
+
throw new CliUsageError(`Recheck field ${safeTerminalValue(key)} would be repaired or truncated; nothing was written.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
throw new CliUsageError('Select a supported Case mutation.');
|
|
139
|
+
retainExistingEntries(current, result.record);
|
|
140
|
+
return result.cases;
|
|
141
|
+
}
|
|
142
|
+
function formatCases(cases, digest) {
|
|
143
|
+
const lines = ['Local Case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
|
|
144
|
+
for (const record of cases) {
|
|
145
|
+
lines.push(`Case ${safeTerminalValue(record.id)} · ${safeTerminalValue(record.title || record.domain)}`, `Domain ${safeTerminalValue(record.domain)}`, `Status ${statusLabel(record.status)}`, `Disposition ${dispositionLabel(record.disposition)} (analyst assessment)`, `Updated ${record.updatedAt}`);
|
|
146
|
+
for (const note of record.notes)
|
|
147
|
+
lines.push(`Note ${note.createdAt}`, safeTerminalValue(note.body, '—', MAX_NOTE_LENGTH));
|
|
148
|
+
for (const pin of record.evidencePins)
|
|
149
|
+
lines.push(`Pin ${safeTerminalValue(pin.id)} · ${safeTerminalValue(pin.label)}`, safeTerminalValue(pin.value, '—', MAX_RESPONSE_VALUE_LENGTH), ` Source ${safeTerminalValue(pin.source)} · ${safeTerminalValue(pin.observedAt, 'time unavailable')} · ${pin.completeness}`);
|
|
150
|
+
for (const decision of record.decisions)
|
|
151
|
+
lines.push(`Assessment ${decision.createdAt}`, safeTerminalValue(decision.summary, '—', MAX_RESPONSE_VALUE_LENGTH), safeTerminalValue(decision.rationale, '—', MAX_RESPONSE_RATIONALE_LENGTH));
|
|
152
|
+
for (const review of record.observedEffects.reviews)
|
|
153
|
+
lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`);
|
|
154
|
+
lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
|
|
155
|
+
}
|
|
156
|
+
lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
export async function runCaseCommand(args, dependencies, context) {
|
|
160
|
+
context.setFailureLabel('Local Case operation');
|
|
161
|
+
try {
|
|
162
|
+
const raw = args.source ? dependencies.caseFileInput ?? await context.readInput(args.source, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case file') : null;
|
|
163
|
+
const digest = raw === null ? null : `sha256:${createHash('sha256').update(raw).digest('hex')}`;
|
|
164
|
+
if (args.expectedFileDigest && args.expectedFileDigest !== digest)
|
|
165
|
+
throw new CliUsageError('The Case file differs from the reviewed file digest. Nothing was written.');
|
|
166
|
+
const cases = raw === null ? [] : readEditableCaseExport(raw);
|
|
167
|
+
const now = normalizeExplicitIsoTimestamp(context.now());
|
|
168
|
+
if (!now)
|
|
169
|
+
throw new CliUsageError('A valid current timestamp is required.');
|
|
170
|
+
if (args.operation === 'show') {
|
|
171
|
+
const selected = selection(cases, args, false);
|
|
172
|
+
context.writeStdout(args.output === 'json' ? `${JSON.stringify(buildCaseExport(selected, now))}\n` : context.terminal(formatCases(selected, digest), args.color));
|
|
173
|
+
return EXIT_CODES.SUCCESS;
|
|
174
|
+
}
|
|
175
|
+
const input = args.inputSource ? parseBoundedJsonObject(await context.readInput(args.inputSource, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case operation input'), {
|
|
176
|
+
maximumBytes: MAX_EDITABLE_CASE_INPUT_BYTES, limits: boundedJsonLimitsForBytes(MAX_CASE_STORE_BYTES), label: 'Case operation input',
|
|
177
|
+
}) : null;
|
|
178
|
+
const note = args.noteSource ? await context.readInput(args.noteSource, MAX_NOTE_LENGTH * 4 + 4, 'Case note') : args.text;
|
|
179
|
+
const next = mutate(cases, args, input, note, now);
|
|
180
|
+
if (Buffer.byteLength(serializeCaseStore(next)) > MAX_CASE_STORE_BYTES)
|
|
181
|
+
throw new CliUsageError('The updated Case store exceeds its byte budget. No evidence was pruned and nothing was written.');
|
|
182
|
+
const output = `${JSON.stringify(buildCaseExport(next, now))}\n`;
|
|
183
|
+
// Apply the same strict admission to our output before atomic publication.
|
|
184
|
+
readEditableCaseExport(output);
|
|
185
|
+
dependencies.signal?.throwIfAborted();
|
|
186
|
+
context.writeStdout(output);
|
|
187
|
+
return EXIT_CODES.SUCCESS;
|
|
188
|
+
}
|
|
189
|
+
catch (cause) {
|
|
190
|
+
dependencies.signal?.throwIfAborted();
|
|
191
|
+
if (cause instanceof CliUsageError)
|
|
192
|
+
throw cause;
|
|
193
|
+
throw new CliUsageError(cause instanceof Error ? cause.message : 'The Case operation could not be completed.');
|
|
194
|
+
}
|
|
195
|
+
}
|
package/cli/case-pack.mjs
CHANGED
|
@@ -1,45 +1,20 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
3
|
import { canonicalArtifactJson, canonicalArtifactJsonV2, SORTED_JSON_V2, } from "../packages/evidence/artifact-integrity.mjs";
|
|
4
|
-
import { buildCaseReport } from "../packages/cases/case-report.mjs";
|
|
4
|
+
import { buildCaseReport, buildCaseReportVerificationProjection } from "../packages/cases/case-report.mjs";
|
|
5
5
|
import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
|
|
6
6
|
import { assertBoundedJsonStructure, scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
7
|
-
import { assertCaseBrandProfileIds, normalizeDomain, normalizeCaseStore, safeId, } from "../packages/cases/case-model.mjs";
|
|
8
|
-
import { CASE_REPORT_SCHEMA, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS as INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS as PACKET_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS as PACKET_KEYS, CLI_CASE_PACK_REPORT_KEYS as REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS as ROOT_KEYS, MAX_CASE_IMPORT_BYTES, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, PUBLIC_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, } from "../packages/contracts/case-portability.mjs";
|
|
7
|
+
import { assertCaseBrandProfileIds, CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, normalizeDomain, normalizeCaseStore, projectCaseForAudience, safeId, } from "../packages/cases/case-model.mjs";
|
|
8
|
+
import { CASE_REPORT_SCHEMA, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS as INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS as PACKET_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS as PACKET_KEYS, CLI_CASE_PACK_REPORT_KEYS as REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS as ROOT_KEYS, MAX_CASE_IMPORT_BYTES, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, } from "../packages/contracts/case-portability.mjs";
|
|
9
9
|
import { CliUsageError } from "./errors.mjs";
|
|
10
|
+
import { canonicalCaseExportProjection as canonicalPackProjection } from "../packages/cases/case-export-input.mjs";
|
|
10
11
|
export { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, };
|
|
11
12
|
const MAX_CASE_PACK_REFERENCE_SCAN_DEPTH = 64;
|
|
12
|
-
const INTERNAL_EXCLUSIONS = Object.freeze(['Raw upstream payloads and credentials are outside the case schema.']);
|
|
13
|
-
const TRUSTED_EXCLUSIONS = Object.freeze(['Case notes', 'Recipient values', 'Manual trail targets', 'Raw upstream payloads and credentials']);
|
|
14
|
-
const PUBLIC_EXCLUSIONS = Object.freeze([
|
|
15
|
-
'Case notes',
|
|
16
|
-
'Brand Profile references',
|
|
17
|
-
'Actions and recipient values',
|
|
18
|
-
'Analyst assertions',
|
|
19
|
-
'Investigation branches',
|
|
20
|
-
'Manual trail targets',
|
|
21
|
-
'Raw upstream payloads and credentials',
|
|
22
|
-
'Independent observed-effect reviews and closure history',
|
|
23
|
-
]);
|
|
24
|
-
const PUBLIC_V12_EXCLUSIONS = Object.freeze(PUBLIC_EXCLUSIONS.slice(0, -1));
|
|
25
|
-
const SENSITIVE_CASE_PACK_FIELDS = Object.freeze([
|
|
26
|
-
'brandProfileIds', 'notes', 'actions', 'assertions', 'manualTrail', 'observedEffects',
|
|
27
|
-
'closures', 'branches', 'recipient', 'target',
|
|
28
|
-
]);
|
|
29
|
-
const PUBLIC_OBSERVED_EFFECT_LIMITATION = 'Independent observed-effect review records were excluded from this public Case pack.';
|
|
30
|
-
const PUBLIC_CLOSURE_LIMITATION = 'Deliberate closure records were excluded from this public Case pack.';
|
|
31
13
|
function record(value) {
|
|
32
14
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
33
15
|
? value
|
|
34
16
|
: null;
|
|
35
17
|
}
|
|
36
|
-
function expectedExclusions(audience, caseVersion = CASE_SCHEMA_VERSION) {
|
|
37
|
-
if (audience === 'internal')
|
|
38
|
-
return INTERNAL_EXCLUSIONS;
|
|
39
|
-
if (audience === 'trusted')
|
|
40
|
-
return TRUSTED_EXCLUSIONS;
|
|
41
|
-
return caseVersion === PUBLIC_CASE_SCHEMA_VERSION ? PUBLIC_V12_EXCLUSIONS : PUBLIC_EXCLUSIONS;
|
|
42
|
-
}
|
|
43
18
|
function stringListsMatch(value, expected) {
|
|
44
19
|
return Array.isArray(value)
|
|
45
20
|
&& value.length === expected.length
|
|
@@ -105,7 +80,7 @@ function assertSensitiveFieldPlacement(root, serializedLength) {
|
|
|
105
80
|
const entry = stack.pop();
|
|
106
81
|
if (!entry || !entry.value || typeof entry.value !== 'object')
|
|
107
82
|
continue;
|
|
108
|
-
for (const key of
|
|
83
|
+
for (const key of CASE_AUDIENCE_SENSITIVE_FIELD_NAMES) {
|
|
109
84
|
if (Object.hasOwn(entry.value, key) && !allowedSensitiveFieldPath(key, entry.path)) {
|
|
110
85
|
throw new TypeError('The CLI case pack contains audience-sensitive data outside its versioned case or report fields.');
|
|
111
86
|
}
|
|
@@ -151,7 +126,7 @@ function canonicalValuesMatch(left, right) {
|
|
|
151
126
|
return false;
|
|
152
127
|
}
|
|
153
128
|
}
|
|
154
|
-
function assertCanonicalCaseIdentities(cases, label) {
|
|
129
|
+
function assertCanonicalCaseIdentities(cases, label, caseVersion) {
|
|
155
130
|
const ids = new Set();
|
|
156
131
|
const domains = new Set();
|
|
157
132
|
for (const value of cases) {
|
|
@@ -162,50 +137,27 @@ function assertCanonicalCaseIdentities(cases, label) {
|
|
|
162
137
|
|| typeof item.domain !== 'string'
|
|
163
138
|
|| normalizeDomain(item.domain) !== item.domain
|
|
164
139
|
|| ids.has(item.id)
|
|
165
|
-
|| domains.has(item.domain)) {
|
|
140
|
+
|| (caseVersion < INCIDENT_CASE_SCHEMA_VERSION && domains.has(item.domain))) {
|
|
166
141
|
throw new TypeError(`${label} contains a missing, unsafe, non-canonical, or duplicate Case identity.`);
|
|
167
142
|
}
|
|
168
143
|
ids.add(item.id);
|
|
169
144
|
domains.add(item.domain);
|
|
170
145
|
}
|
|
171
146
|
}
|
|
172
|
-
function assertCurrentCaseProjection(rawCases, normalised, label) {
|
|
173
|
-
if (rawCases.length !== normalised.length
|
|
174
|
-
|
|
175
|
-
|
|
147
|
+
function assertCurrentCaseProjection(rawCases, normalised, label, caseVersion = CASE_SCHEMA_VERSION) {
|
|
148
|
+
if (rawCases.length !== normalised.length || rawCases.some((item, index) => {
|
|
149
|
+
const raw = record(item);
|
|
150
|
+
return !raw
|
|
151
|
+
|| !canonicalValuesMatch(raw, normalised[index]);
|
|
152
|
+
})) {
|
|
153
|
+
throw new TypeError(`${label} contains a schema ${caseVersion} Case that would be repaired, truncated, or otherwise changed during normalisation.`);
|
|
176
154
|
}
|
|
177
155
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
reviews: [],
|
|
181
|
-
omitted: 0,
|
|
182
|
-
preV13HistoryUnavailable,
|
|
183
|
-
limitations: [
|
|
184
|
-
...(preV13HistoryUnavailable
|
|
185
|
-
? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.']
|
|
186
|
-
: []),
|
|
187
|
-
'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
|
|
188
|
-
PUBLIC_OBSERVED_EFFECT_LIMITATION,
|
|
189
|
-
].sort(),
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
function publicClosureHistory(preV13HistoryUnavailable) {
|
|
193
|
-
return {
|
|
194
|
-
records: [],
|
|
195
|
-
omitted: 0,
|
|
196
|
-
preV13HistoryUnavailable,
|
|
197
|
-
limitations: [
|
|
198
|
-
...(preV13HistoryUnavailable
|
|
199
|
-
? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.']
|
|
200
|
-
: []),
|
|
201
|
-
'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
|
|
202
|
-
PUBLIC_CLOSURE_LIMITATION,
|
|
203
|
-
].sort(),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
function assertAudienceFields(value, audience) {
|
|
156
|
+
/** Historical packs keep their immutable wire shape, so only their retained audience guarantees are checked here. */
|
|
157
|
+
function assertLegacyAudienceFields(value, normalised, audience) {
|
|
207
158
|
if (audience === 'internal')
|
|
208
159
|
return;
|
|
160
|
+
const expected = projectCaseForAudience(normalised, audience);
|
|
209
161
|
if (Object.hasOwn(value, 'notes') && (!Array.isArray(value.notes) || value.notes.length !== 0)) {
|
|
210
162
|
throw new TypeError('The CLI case pack contains Case notes excluded by its audience.');
|
|
211
163
|
}
|
|
@@ -218,11 +170,8 @@ function assertAudienceFields(value, audience) {
|
|
|
218
170
|
for (const [field, list] of [['observedEffects', 'reviews'], ['closures', 'records']]) {
|
|
219
171
|
if (Object.hasOwn(value, field)) {
|
|
220
172
|
const history = record(value[field]);
|
|
221
|
-
const expected = field === 'observedEffects'
|
|
222
|
-
? publicObservedEffectHistory(history?.preV13HistoryUnavailable === true)
|
|
223
|
-
: publicClosureHistory(history?.preV13HistoryUnavailable === true);
|
|
224
173
|
if (!history || !Array.isArray(history[list]) || history[list].length !== 0
|
|
225
|
-
|| !canonicalValuesMatch(history, expected)) {
|
|
174
|
+
|| !canonicalValuesMatch(history, expected[field])) {
|
|
226
175
|
throw new TypeError(`The public CLI case pack contains ${field} excluded by its audience.`);
|
|
227
176
|
}
|
|
228
177
|
}
|
|
@@ -245,6 +194,20 @@ function assertAudienceFields(value, audience) {
|
|
|
245
194
|
}
|
|
246
195
|
}
|
|
247
196
|
}
|
|
197
|
+
function assertCurrentAudienceProjection(value, normalised, audience, caseVersion) {
|
|
198
|
+
const expected = canonicalPackProjection([projectCaseForAudience(normalised, audience)], caseVersion)[0];
|
|
199
|
+
for (const field of Object.keys(expected)) {
|
|
200
|
+
if (!canonicalValuesMatch(value[field], expected[field])) {
|
|
201
|
+
throw new TypeError(`The CLI case pack contains ${field} excluded by its audience or transformed incorrectly.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function assertAudienceProjection(value, normalised, audience, currentSchema, caseVersion) {
|
|
206
|
+
if (currentSchema)
|
|
207
|
+
assertCurrentAudienceProjection(value, normalised, audience, caseVersion);
|
|
208
|
+
else
|
|
209
|
+
assertLegacyAudienceFields(value, normalised, audience);
|
|
210
|
+
}
|
|
248
211
|
function reportApplicationVersion(report) {
|
|
249
212
|
return record(report.application)?.version;
|
|
250
213
|
}
|
|
@@ -252,37 +215,15 @@ function assertCurrentReportProjection(report, rawCase) {
|
|
|
252
215
|
if (typeof report.generatedAt !== 'string') {
|
|
253
216
|
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
|
|
254
217
|
}
|
|
255
|
-
const expected =
|
|
218
|
+
const expected = buildCaseReportVerificationProjection(rawCase, {
|
|
256
219
|
applicationVersion: reportApplicationVersion(report),
|
|
257
220
|
includeNotes: false,
|
|
258
221
|
generatedAt: report.generatedAt,
|
|
259
|
-
})
|
|
222
|
+
}, report.schemaVersion);
|
|
260
223
|
if (!canonicalValuesMatch(report, expected)) {
|
|
261
224
|
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report projection.');
|
|
262
225
|
}
|
|
263
226
|
}
|
|
264
|
-
function redactedCase(record, audience) {
|
|
265
|
-
if (audience === 'internal')
|
|
266
|
-
return structuredClone(record);
|
|
267
|
-
return {
|
|
268
|
-
...structuredClone(record),
|
|
269
|
-
status: audience === 'public' && record.status === 'resolved' && record.closures.records.length
|
|
270
|
-
? 'reviewing'
|
|
271
|
-
: record.status,
|
|
272
|
-
brandProfileIds: audience === 'public' ? [] : [...record.brandProfileIds],
|
|
273
|
-
notes: [],
|
|
274
|
-
actions: audience === 'public' ? [] : record.actions.map((item) => ({ ...item, recipient: '[redacted]' })),
|
|
275
|
-
manualTrail: record.manualTrail.map((item) => ({ ...item, target: null })),
|
|
276
|
-
assertions: audience === 'public' ? [] : structuredClone(record.assertions),
|
|
277
|
-
observedEffects: audience === 'public'
|
|
278
|
-
? publicObservedEffectHistory(record.observedEffects.preV13HistoryUnavailable)
|
|
279
|
-
: structuredClone(record.observedEffects),
|
|
280
|
-
closures: audience === 'public'
|
|
281
|
-
? publicClosureHistory(record.closures.preV13HistoryUnavailable)
|
|
282
|
-
: structuredClone(record.closures),
|
|
283
|
-
branches: audience === 'public' ? [] : structuredClone(record.branches ?? []),
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
227
|
export function buildCliCasePack(input, options, generatedAt = new Date().toISOString()) {
|
|
287
228
|
if (!options.reviewed)
|
|
288
229
|
throw new CliUsageError('case-pack requires --reviewed after the audience-specific output has been checked.');
|
|
@@ -298,17 +239,18 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
298
239
|
throw new CliUsageError('Case-pack input must be valid bounded JSON without duplicate keys.');
|
|
299
240
|
}
|
|
300
241
|
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
301
|
-
|
|
302
|
-
|
|
242
|
+
const supportedInput = CLI_CASE_PACK_INPUT_CASE_VERSIONS.some((version) => version === root.version);
|
|
243
|
+
if (Number.isSafeInteger(root.version) && root.version < CASE_SCHEMA_VERSION && !supportedInput) {
|
|
244
|
+
throw new CliUsageError(`Case schema ${String(root.version)} cannot be packaged directly. This command accepts schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(', ')}. Use a supported Case import to create a current export first; no data was changed.`);
|
|
303
245
|
}
|
|
304
246
|
if (Number.isSafeInteger(root.version) && root.version > CASE_SCHEMA_VERSION) {
|
|
305
247
|
throw new CliUsageError(`Case schema ${String(root.version)} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
306
248
|
}
|
|
307
|
-
if (
|
|
308
|
-
throw new CliUsageError(`Case-pack input must be a well-formed WHOISleuth Case schema ${
|
|
249
|
+
if (!supportedInput || !Array.isArray(root.cases)) {
|
|
250
|
+
throw new CliUsageError(`Case-pack input must be a well-formed WHOISleuth Case export with schema ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}.`);
|
|
309
251
|
}
|
|
310
252
|
try {
|
|
311
|
-
assertCanonicalCaseIdentities(root.cases, 'Case-pack input');
|
|
253
|
+
assertCanonicalCaseIdentities(root.cases, 'Case-pack input', root.version);
|
|
312
254
|
for (const item of root.cases) {
|
|
313
255
|
const rawCase = record(item);
|
|
314
256
|
if (!rawCase || !Object.hasOwn(rawCase, 'brandProfileIds'))
|
|
@@ -332,12 +274,12 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
332
274
|
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.`);
|
|
333
275
|
}
|
|
334
276
|
try {
|
|
335
|
-
assertCurrentCaseProjection(root.cases, normalised, 'Case-pack input');
|
|
277
|
+
assertCurrentCaseProjection(root.cases, canonicalPackProjection(normalised, root.version), 'Case-pack input', root.version);
|
|
336
278
|
}
|
|
337
279
|
catch (cause) {
|
|
338
280
|
throw new CliUsageError(cause instanceof Error ? cause.message : `Case-pack schema ${CASE_SCHEMA_VERSION} input is not exact.`);
|
|
339
281
|
}
|
|
340
|
-
const cases = normalised.map((item) =>
|
|
282
|
+
const cases = normalised.map((item) => projectCaseForAudience(item, options.audience));
|
|
341
283
|
const brandProfileReferencesOmitted = options.audience === 'public'
|
|
342
284
|
? normalised.reduce((count, item) => count + item.brandProfileIds.length, 0)
|
|
343
285
|
: 0;
|
|
@@ -346,7 +288,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
346
288
|
includeNotes: false,
|
|
347
289
|
generatedAt,
|
|
348
290
|
}).json);
|
|
349
|
-
const exclusions =
|
|
291
|
+
const exclusions = caseAudienceExclusions(options.audience, CASE_SCHEMA_VERSION);
|
|
350
292
|
const packet = Object.freeze({
|
|
351
293
|
schema: CLI_CASE_PACK_SCHEMA,
|
|
352
294
|
version: CLI_CASE_PACK_VERSION,
|
|
@@ -370,7 +312,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
370
312
|
}),
|
|
371
313
|
});
|
|
372
314
|
if (Buffer.byteLength(JSON.stringify(document), 'utf8') > MAX_CASE_IMPORT_BYTES) {
|
|
373
|
-
throw new CliUsageError(
|
|
315
|
+
throw new CliUsageError(`The generated case pack exceeds the browser import limit of ${MAX_CASE_IMPORT_BYTES} bytes. Select fewer cases or a more restrictive audience so no evidence is silently omitted.`);
|
|
374
316
|
}
|
|
375
317
|
return document;
|
|
376
318
|
}
|
|
@@ -436,17 +378,19 @@ export function verifyCliCasePack(input) {
|
|
|
436
378
|
const audience = packet.audience;
|
|
437
379
|
if (!Number.isSafeInteger(redactionManifest.sourceCaseCount)
|
|
438
380
|
|| redactionManifest.sourceCaseCount !== root.cases.length
|
|
439
|
-
|| !stringListsMatch(redactionManifest.excluded,
|
|
381
|
+
|| !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
|
|
440
382
|
throw new TypeError('The CLI case pack has an invalid audience redaction manifest.');
|
|
441
383
|
}
|
|
442
|
-
assertCanonicalCaseIdentities(root.cases, 'The CLI case pack');
|
|
384
|
+
assertCanonicalCaseIdentities(root.cases, 'The CLI case pack', root.version);
|
|
443
385
|
const normalised = normalizeCaseStore(root).cases;
|
|
444
386
|
if (normalised.length !== root.cases.length) {
|
|
445
387
|
throw new TypeError('The CLI case pack contains an invalid case collection.');
|
|
446
388
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
389
|
+
const currentCaseSchema = root.version >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
390
|
+
const projected = canonicalPackProjection(normalised, root.version);
|
|
391
|
+
if (currentCaseSchema)
|
|
392
|
+
assertCurrentCaseProjection(root.cases, projected, 'The CLI case pack', root.version);
|
|
393
|
+
const normalisedById = new Map(projected.map((item) => [item.id, item]));
|
|
450
394
|
const caseReferenceLists = [];
|
|
451
395
|
const reportReferenceLists = [];
|
|
452
396
|
for (let index = 0; index < root.cases.length; index += 1) {
|
|
@@ -457,14 +401,18 @@ export function verifyCliCasePack(input) {
|
|
|
457
401
|
|| !report
|
|
458
402
|
|| !reportCase
|
|
459
403
|
|| typeof rawCase.domain !== 'string'
|
|
404
|
+
|| typeof rawCase.id !== 'string'
|
|
460
405
|
|| report.schema !== CASE_REPORT_SCHEMA
|
|
461
406
|
|| !caseReportVersionMatchesCase(root.version, report.schemaVersion)
|
|
462
407
|
|| reportCase.id !== rawCase.id
|
|
463
408
|
|| reportCase.domain !== rawCase.domain
|
|
464
|
-
|| !
|
|
409
|
+
|| !normalisedById.has(rawCase.id)) {
|
|
465
410
|
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
|
|
466
411
|
}
|
|
467
|
-
|
|
412
|
+
const normalisedCase = normalisedById.get(rawCase.id);
|
|
413
|
+
if (!normalisedCase)
|
|
414
|
+
throw new TypeError('The CLI case pack contains an invalid Case projection.');
|
|
415
|
+
assertAudienceProjection(rawCase, normalisedCase, audience, currentCaseSchema, root.version);
|
|
468
416
|
try {
|
|
469
417
|
if (!Object.hasOwn(rawCase, 'brandProfileIds') || !Object.hasOwn(reportCase, 'brandProfileIds'))
|
|
470
418
|
throw new Error('missing');
|
|
@@ -474,8 +422,15 @@ export function verifyCliCasePack(input) {
|
|
|
474
422
|
catch {
|
|
475
423
|
throw new TypeError('The CLI case pack contains invalid Brand Profile references.');
|
|
476
424
|
}
|
|
477
|
-
if (
|
|
478
|
-
assertCurrentReportProjection(report,
|
|
425
|
+
if (currentCaseSchema)
|
|
426
|
+
assertCurrentReportProjection(report, normalisedCase);
|
|
427
|
+
else {
|
|
428
|
+
assertLegacyAudienceFields(reportCase, normalisedCase, audience);
|
|
429
|
+
const response = record(report.analystResponse);
|
|
430
|
+
if (!response)
|
|
431
|
+
throw new TypeError('The CLI case pack contains an invalid analyst-response report.');
|
|
432
|
+
assertLegacyAudienceFields(response, normalisedCase, audience);
|
|
433
|
+
}
|
|
479
434
|
}
|
|
480
435
|
const omitted = redactionManifest.brandProfileReferencesOmitted;
|
|
481
436
|
if (!Object.hasOwn(redactionManifest, 'brandProfileReferencesOmitted')
|