@slicedearth/whoisleuth-cli 2.3.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 +19 -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 +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- 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 +83 -61
- 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-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- 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 +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -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 +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -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/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-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 +45 -0
- package/cli/runner.mjs +55 -6
- 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 +76 -30
- package/docs/cli.md +289 -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 +43 -3
- 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-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- 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 +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- 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 +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- 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-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 +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -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 +117 -146
- 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 +111 -71
- 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/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 +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- 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 +38 -587
- 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 +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- 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 +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -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 +0 -1
- 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 +160 -225
- 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 +155 -27
- 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 +89 -49
- 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 +161 -141
- 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 +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- 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 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { readBoundedRegularFile } from "../lib/bounded-file.mjs";
|
|
3
|
+
import { buildInvestigationPackage } from "../packages/investigation/investigation-package.mjs";
|
|
4
|
+
import { encryptInvestigationPackage } from "../packages/investigation/investigation-package-crypto.mjs";
|
|
5
|
+
import { writeInvestigationFolder, writePreparedEvidenceFolder } from "./investigation-folder.mjs";
|
|
6
|
+
import { buildInvestigationBagIt, prepareInvestigationBagIt } from "../packages/investigation/investigation-bagit.mjs";
|
|
2
7
|
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
3
8
|
import { MAX_ASSURANCE_INPUT_BYTES, buildDomainAssurance, formatDomainAssurance, } from "../lib/domain-assurance.mjs";
|
|
4
9
|
import { MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES, buildDomainChangePacket, formatDomainChangePacket, } from "../lib/domain-change-packet.mjs";
|
|
@@ -12,7 +17,7 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
|
12
17
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
13
18
|
import { MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES, formatExternalObservationMapping, mapExternalObservations, } from "./external-observation-mapping.mjs";
|
|
14
19
|
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
15
|
-
import { MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, buildInvestigationManifest, formatInvestigationManifest, } from "./investigation-manifest.mjs";
|
|
20
|
+
import { MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, buildInvestigationManifest, formatInvestigationManifest, investigationFileMediaType, } from "./investigation-manifest.mjs";
|
|
16
21
|
import { buildOpenAssetModelBridge, formatOpenAssetModelBridge, } from "./open-asset-model-bridge.mjs";
|
|
17
22
|
import { ASSURANCE_INLINE_COMMANDS } from "./inline-command-families.mjs";
|
|
18
23
|
import { MAX_SHARING_REVIEW_BYTES, buildSharingReview, formatSharingReview, } from "./sharing-review.mjs";
|
|
@@ -23,6 +28,15 @@ async function runManifestCommand(args, dependencies, context) {
|
|
|
23
28
|
let totalBytes = 0;
|
|
24
29
|
try {
|
|
25
30
|
for (const source of args.sources) {
|
|
31
|
+
if (args.package || args.folder) {
|
|
32
|
+
const content = dependencies.readBinaryArtifactInput ? await dependencies.readBinaryArtifactInput(source)
|
|
33
|
+
: await readBoundedRegularFile(source, { maximumBytes: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, minimumBytes: 1, label: 'Package artefact input', ...(dependencies.signal ? { signal: dependencies.signal } : {}) });
|
|
34
|
+
if (content.byteLength > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES - totalBytes)
|
|
35
|
+
throw new CliUsageError('Package artefacts exceed the combined byte limit.');
|
|
36
|
+
totalBytes += content.byteLength;
|
|
37
|
+
artifacts.push({ content, mediaType: investigationFileMediaType(source) });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
26
40
|
const content = dependencies.readDiffInput
|
|
27
41
|
? await dependencies.readDiffInput(source)
|
|
28
42
|
: await context.readInput(source, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, 'Manifest artefact input');
|
|
@@ -40,11 +54,32 @@ async function runManifestCommand(args, dependencies, context) {
|
|
|
40
54
|
}
|
|
41
55
|
let document;
|
|
42
56
|
try {
|
|
43
|
-
|
|
57
|
+
if (args.package) {
|
|
58
|
+
const built = await (args.bagit ? buildInvestigationBagIt : buildInvestigationPackage)({ workflow: args.workflow, configurationDigestSha256: args.configurationDigestSha256, artifacts }, context.now(), context.packageVersion);
|
|
59
|
+
dependencies.signal?.throwIfAborted();
|
|
60
|
+
if (!context.writeBinaryOutput)
|
|
61
|
+
throw new CliUsageError('Package output requires an explicit file destination.');
|
|
62
|
+
const output = args.passphraseSource
|
|
63
|
+
? await encryptInvestigationPackage(built.bytes, await context.readPassphraseSource(args.passphraseSource))
|
|
64
|
+
: built.bytes;
|
|
65
|
+
dependencies.signal?.throwIfAborted();
|
|
66
|
+
context.writeBinaryOutput(output);
|
|
67
|
+
return EXIT_CODES.SUCCESS;
|
|
68
|
+
}
|
|
69
|
+
const input = {
|
|
44
70
|
workflow: args.workflow,
|
|
45
71
|
configurationDigestSha256: args.configurationDigestSha256,
|
|
46
72
|
artifacts,
|
|
47
|
-
}
|
|
73
|
+
};
|
|
74
|
+
if (args.bagit && args.folder) {
|
|
75
|
+
const prepared = await prepareInvestigationBagIt(input, context.now(), context.packageVersion);
|
|
76
|
+
await writePreparedEvidenceFolder(args.folder, prepared.files, 'data', 'tagmanifest-sha512.txt', dependencies.signal);
|
|
77
|
+
document = prepared.manifest;
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
document = args.folder
|
|
81
|
+
? await writeInvestigationFolder(args.folder, input, context.now(), context.packageVersion, dependencies.signal)
|
|
82
|
+
: await buildInvestigationManifest(input, context.now(), context.packageVersion);
|
|
48
83
|
}
|
|
49
84
|
catch (error) {
|
|
50
85
|
throw new CliUsageError(boundedCliErrorMessage(error, 'Investigation manifest input is invalid'));
|
|
@@ -52,7 +87,7 @@ async function runManifestCommand(args, dependencies, context) {
|
|
|
52
87
|
if (!args.quiet) {
|
|
53
88
|
context.writeStdout(args.output === 'json'
|
|
54
89
|
? formatJsonDocument(document)
|
|
55
|
-
: context.terminal(formatInvestigationManifest(document)
|
|
90
|
+
: context.terminal(`${args.folder ? 'Created a new private evidence folder.\n' : ''}${formatInvestigationManifest(document)}`, args.color));
|
|
56
91
|
}
|
|
57
92
|
return EXIT_CODES.SUCCESS;
|
|
58
93
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { lstat, opendir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readBoundedRegularFileWithin } from "../lib/bounded-file.mjs";
|
|
4
|
+
import { assertBagItSelection, bagItPath, bagItEntryMaximum, MAX_BAGIT_ENTRIES } from "../packages/interchange/bagit.mjs";
|
|
5
|
+
import { selectedEvidenceDirectory, evidenceDirectoryIdentity } from "./investigation-folder.mjs";
|
|
6
|
+
/** Bounded recursive enumeration; no symbolic links, special files or writes. */
|
|
7
|
+
export async function readBagItFolder(value, signal) {
|
|
8
|
+
const selected = selectedEvidenceDirectory(value);
|
|
9
|
+
const identity = await evidenceDirectoryIdentity(selected), root = await realpath(selected);
|
|
10
|
+
const directories = new Map([['', identity]]);
|
|
11
|
+
const entries = [];
|
|
12
|
+
const visit = async (relative) => {
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
const path = join(root, relative);
|
|
15
|
+
if (await realpath(path) !== path)
|
|
16
|
+
throw new TypeError('BagIt directories must not contain symbolic links.');
|
|
17
|
+
for await (const item of await opendir(path)) {
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
if (entries.length >= MAX_BAGIT_ENTRIES)
|
|
20
|
+
throw new TypeError('BagIt exceeds its entry limit.');
|
|
21
|
+
const name = `${relative ? `${relative}/` : ''}${item.name}`;
|
|
22
|
+
bagItPath(item.isDirectory() ? `${name}/` : name);
|
|
23
|
+
const stat = await lstat(join(root, name));
|
|
24
|
+
if (item.isDirectory() && stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
25
|
+
directories.set(name, await evidenceDirectoryIdentity(join(root, name)));
|
|
26
|
+
entries.push({ path: `${name}/`, byteLength: 0 });
|
|
27
|
+
await visit(name);
|
|
28
|
+
}
|
|
29
|
+
else if (item.isFile() && stat.isFile() && !stat.isSymbolicLink()) {
|
|
30
|
+
if (stat.size > bagItEntryMaximum(name))
|
|
31
|
+
throw new TypeError('BagIt entry exceeds its byte limit.');
|
|
32
|
+
entries.push({ path: name, byteLength: stat.size });
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
throw new TypeError('BagIt entries must be ordinary files and directories, not symbolic links or special files.');
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
await visit('');
|
|
39
|
+
assertBagItSelection(entries);
|
|
40
|
+
const files = new Map();
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
signal?.throwIfAborted();
|
|
43
|
+
files.set(entry.path, entry.path.endsWith('/') ? new Uint8Array() : await readBoundedRegularFileWithin(root, entry.path, {
|
|
44
|
+
maximumBytes: bagItEntryMaximum(entry.path), minimumBytes: 0, expectedBytes: entry.byteLength,
|
|
45
|
+
label: 'BagIt file', ...(signal ? { signal } : {}),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
for (const [path, before] of directories) {
|
|
49
|
+
signal?.throwIfAborted();
|
|
50
|
+
const currentPath = path ? join(root, path) : selected;
|
|
51
|
+
const after = await evidenceDirectoryIdentity(currentPath);
|
|
52
|
+
if (await realpath(currentPath) !== join(root, path) || before.device !== after.device || before.inode !== after.inode
|
|
53
|
+
|| before.modified !== after.modified || before.changed !== after.changed)
|
|
54
|
+
throw new TypeError('BagIt folder changed while it was read.');
|
|
55
|
+
}
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { inspectBagItEntries, readBagItZip } from "../packages/interchange/bagit.mjs";
|
|
2
|
+
import { OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, OFFLINE_ARTIFACT_VERIFICATION_VERSION } from "./artifact-verify.mjs";
|
|
3
|
+
export async function verifyOfflineBagIt(input) {
|
|
4
|
+
const files = input instanceof Uint8Array ? readBagItZip(input) : input;
|
|
5
|
+
const zipBytes = input instanceof Uint8Array ? input.byteLength : null;
|
|
6
|
+
const { review, inputBytes } = await inspectBagItEntries(files);
|
|
7
|
+
return {
|
|
8
|
+
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
9
|
+
artifact: { kind: 'bagit', schema: null, version: '1.0' },
|
|
10
|
+
state: review.state === 'valid' ? 'integrity_valid' : 'partial',
|
|
11
|
+
checks: { structure: review.complete ? 'verified' : 'failed',
|
|
12
|
+
contentIntegrity: review.state === 'valid' ? 'verified' : review.state === 'invalid' ? 'failed' : 'not_checked',
|
|
13
|
+
contentIntegrityScope: 'manifest_and_files', authenticatedEncryption: 'not_applicable' },
|
|
14
|
+
summary: { inputBytes: zipBytes ?? inputBytes,
|
|
15
|
+
sectionCount: null, recordCount: review.entries.length, ciphertextBytes: null },
|
|
16
|
+
manifestIdentity: null, bagit: review,
|
|
17
|
+
limitations: [
|
|
18
|
+
'BagIt verifies declared file checksums, not payload schemas, factual accuracy, source identity, signatures or trusted timestamps.',
|
|
19
|
+
'Tag files without a checksum are not integrity-checked. No file is imported, executed or fetched; fetch.txt locations and original paths are omitted from this report.',
|
|
20
|
+
'This bounded reader supports BagIt 1.0, UTF-8 text tags, SHA-256 and SHA-512, portable relative paths and the selected-file byte limits. Other encodings, algorithms and unsafe or platform-ambiguous paths are not silently accepted.',
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
package/cli/bounded-json.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, parseBoundedJson, parseBoundedJsonObject, scanBoundedJson, } from "../lib/bounded-json.mjs";
|
|
1
|
+
export { boundedJsonLimitsForBytes, MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, parseBoundedJson, parseBoundedJsonObject, scanBoundedJson, } from "../lib/bounded-json.mjs";
|
|
@@ -113,6 +113,8 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
113
113
|
context.writeStdout(formatJsonLines(selectedItems, metadata));
|
|
114
114
|
else if (args.output === 'csv')
|
|
115
115
|
context.writeStdout(formatBulkCsv(selectedItems));
|
|
116
|
+
else if (args.output === 'csv_metadata')
|
|
117
|
+
context.writeStdout(formatBulkCsv(selectedItems, selectedDocument));
|
|
116
118
|
else if (args.output === 'domains')
|
|
117
119
|
context.writeStdout(formatBulkDomainList(selectedItems));
|
|
118
120
|
else if (args.output === 'queries')
|
package/cli/bulk-output.mjs
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { cliCsvCell } from "./csv.mjs";
|
|
2
|
+
import { lookupDiagnosticStates } from "../lib/lookup-diagnostics.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
2
4
|
const REGISTERED_STATES = new Set(['expiring', 'for_sale', 'registered']);
|
|
3
5
|
const MAX_DNS_VALUES_PER_TYPE = 100;
|
|
6
|
+
const BULK_CSV_METADATA_COLUMNS = Object.freeze([
|
|
7
|
+
'source_schema', 'source_version', 'observed_at', 'report_generated_at',
|
|
8
|
+
'collection_origin', 'scan_mode', 'diagnostics_version', 'source_health',
|
|
9
|
+
]);
|
|
10
|
+
function bulkCsvMetadataValues(item, metadata) {
|
|
11
|
+
const diagnostics = record(item.diagnostics);
|
|
12
|
+
return [
|
|
13
|
+
metadata.schema,
|
|
14
|
+
metadata.version,
|
|
15
|
+
normalizeExplicitIsoTimestamp(item.observedAt) ?? 'unknown',
|
|
16
|
+
normalizeExplicitIsoTimestamp(metadata.generatedAt) ?? 'unknown',
|
|
17
|
+
item.collectionOrigin === 'current_run' || item.collectionOrigin === 'resumed_checkpoint' ? item.collectionOrigin : 'unknown',
|
|
18
|
+
metadata.mode,
|
|
19
|
+
Number.isSafeInteger(diagnostics.version) && Number(diagnostics.version) > 0 ? diagnostics.version : 'unknown',
|
|
20
|
+
JSON.stringify(lookupDiagnosticStates(item.diagnostics)),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
4
23
|
function record(value) {
|
|
5
24
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
6
25
|
? value
|
|
@@ -78,12 +97,18 @@ function bulkDnsSummary(item) {
|
|
|
78
97
|
function booleanCell(value) {
|
|
79
98
|
return value === null ? 'unknown' : value ? 'observed' : 'not_observed';
|
|
80
99
|
}
|
|
81
|
-
function formatBulkCsv(items) {
|
|
100
|
+
function formatBulkCsv(items, metadata) {
|
|
101
|
+
if (metadata && metadata.results.length !== items.length)
|
|
102
|
+
throw new TypeError('CSV metadata does not match the selected result count.');
|
|
82
103
|
const header = [
|
|
83
104
|
'query', 'domain', 'outcome', 'availability', 'confidence', 'dns_status',
|
|
84
105
|
'a', 'aaaa', 'ns', 'mx', 'null_mx', 'spf', 'dmarc', 'error',
|
|
106
|
+
...(metadata ? BULK_CSV_METADATA_COLUMNS : []),
|
|
85
107
|
];
|
|
86
|
-
const rows = items.map((item) => {
|
|
108
|
+
const rows = items.map((item, index) => {
|
|
109
|
+
const retained = metadata?.results[index];
|
|
110
|
+
if (metadata && (!retained || retained.index !== item.index || retained.query !== item.query))
|
|
111
|
+
throw new TypeError('CSV metadata does not match the selected result identity.');
|
|
87
112
|
const availability = item.ok ? record(record(item.result).availability) : {};
|
|
88
113
|
const dns = bulkDnsSummary(item);
|
|
89
114
|
return [
|
|
@@ -101,6 +126,7 @@ function formatBulkCsv(items) {
|
|
|
101
126
|
booleanCell(dns.hasSpf),
|
|
102
127
|
booleanCell(dns.hasDmarc),
|
|
103
128
|
item.ok ? '' : item.error,
|
|
129
|
+
...(metadata && retained ? bulkCsvMetadataValues(retained, metadata) : []),
|
|
104
130
|
].map(cliCsvCell).join(',');
|
|
105
131
|
});
|
|
106
132
|
return `${[header.join(','), ...rows].join('\n')}\n`;
|
|
@@ -114,4 +140,4 @@ function formatBulkDomainList(items) {
|
|
|
114
140
|
function formatBulkQueryList(items) {
|
|
115
141
|
return items.length ? `${items.map((item) => item.query).join('\n')}\n` : '';
|
|
116
142
|
}
|
|
117
|
-
export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems, };
|
|
143
|
+
export { REGISTERED_STATES, BULK_CSV_METADATA_COLUMNS, availabilityState, bulkDnsSummary, bulkCsvMetadataValues, formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems, };
|
|
@@ -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,12 +1,13 @@
|
|
|
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, CASE_AUDIENCE_SENSITIVE_FIELD_NAMES,
|
|
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
13
|
function record(value) {
|
|
@@ -125,7 +126,7 @@ function canonicalValuesMatch(left, right) {
|
|
|
125
126
|
return false;
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
|
-
function assertCanonicalCaseIdentities(cases, label) {
|
|
129
|
+
function assertCanonicalCaseIdentities(cases, label, caseVersion) {
|
|
129
130
|
const ids = new Set();
|
|
130
131
|
const domains = new Set();
|
|
131
132
|
for (const value of cases) {
|
|
@@ -136,23 +137,20 @@ function assertCanonicalCaseIdentities(cases, label) {
|
|
|
136
137
|
|| typeof item.domain !== 'string'
|
|
137
138
|
|| normalizeDomain(item.domain) !== item.domain
|
|
138
139
|
|| ids.has(item.id)
|
|
139
|
-
|| domains.has(item.domain)) {
|
|
140
|
+
|| (caseVersion < INCIDENT_CASE_SCHEMA_VERSION && domains.has(item.domain))) {
|
|
140
141
|
throw new TypeError(`${label} contains a missing, unsafe, non-canonical, or duplicate Case identity.`);
|
|
141
142
|
}
|
|
142
143
|
ids.add(item.id);
|
|
143
144
|
domains.add(item.domain);
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
|
-
function assertCurrentCaseProjection(rawCases, normalised, label) {
|
|
147
|
-
const requiredFields = new Set(CASE_RECORD_FIELD_NAMES);
|
|
147
|
+
function assertCurrentCaseProjection(rawCases, normalised, label, caseVersion = CASE_SCHEMA_VERSION) {
|
|
148
148
|
if (rawCases.length !== normalised.length || rawCases.some((item, index) => {
|
|
149
149
|
const raw = record(item);
|
|
150
150
|
return !raw
|
|
151
|
-
|| Object.keys(raw).length !== requiredFields.size
|
|
152
|
-
|| Object.keys(raw).some((key) => !requiredFields.has(key))
|
|
153
151
|
|| !canonicalValuesMatch(raw, normalised[index]);
|
|
154
152
|
})) {
|
|
155
|
-
throw new TypeError(`${label} contains a schema ${
|
|
153
|
+
throw new TypeError(`${label} contains a schema ${caseVersion} Case that would be repaired, truncated, or otherwise changed during normalisation.`);
|
|
156
154
|
}
|
|
157
155
|
}
|
|
158
156
|
/** Historical packs keep their immutable wire shape, so only their retained audience guarantees are checked here. */
|
|
@@ -196,17 +194,17 @@ function assertLegacyAudienceFields(value, normalised, audience) {
|
|
|
196
194
|
}
|
|
197
195
|
}
|
|
198
196
|
}
|
|
199
|
-
function assertCurrentAudienceProjection(value, normalised, audience) {
|
|
200
|
-
const expected = projectCaseForAudience(normalised, audience);
|
|
201
|
-
for (const field of
|
|
197
|
+
function assertCurrentAudienceProjection(value, normalised, audience, caseVersion) {
|
|
198
|
+
const expected = canonicalPackProjection([projectCaseForAudience(normalised, audience)], caseVersion)[0];
|
|
199
|
+
for (const field of Object.keys(expected)) {
|
|
202
200
|
if (!canonicalValuesMatch(value[field], expected[field])) {
|
|
203
201
|
throw new TypeError(`The CLI case pack contains ${field} excluded by its audience or transformed incorrectly.`);
|
|
204
202
|
}
|
|
205
203
|
}
|
|
206
204
|
}
|
|
207
|
-
function assertAudienceProjection(value, normalised, audience, currentSchema) {
|
|
205
|
+
function assertAudienceProjection(value, normalised, audience, currentSchema, caseVersion) {
|
|
208
206
|
if (currentSchema)
|
|
209
|
-
assertCurrentAudienceProjection(value, normalised, audience);
|
|
207
|
+
assertCurrentAudienceProjection(value, normalised, audience, caseVersion);
|
|
210
208
|
else
|
|
211
209
|
assertLegacyAudienceFields(value, normalised, audience);
|
|
212
210
|
}
|
|
@@ -217,11 +215,11 @@ function assertCurrentReportProjection(report, rawCase) {
|
|
|
217
215
|
if (typeof report.generatedAt !== 'string') {
|
|
218
216
|
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
|
|
219
217
|
}
|
|
220
|
-
const expected =
|
|
218
|
+
const expected = buildCaseReportVerificationProjection(rawCase, {
|
|
221
219
|
applicationVersion: reportApplicationVersion(report),
|
|
222
220
|
includeNotes: false,
|
|
223
221
|
generatedAt: report.generatedAt,
|
|
224
|
-
})
|
|
222
|
+
}, report.schemaVersion);
|
|
225
223
|
if (!canonicalValuesMatch(report, expected)) {
|
|
226
224
|
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report projection.');
|
|
227
225
|
}
|
|
@@ -241,17 +239,18 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
241
239
|
throw new CliUsageError('Case-pack input must be valid bounded JSON without duplicate keys.');
|
|
242
240
|
}
|
|
243
241
|
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
244
|
-
|
|
245
|
-
|
|
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.`);
|
|
246
245
|
}
|
|
247
246
|
if (Number.isSafeInteger(root.version) && root.version > CASE_SCHEMA_VERSION) {
|
|
248
247
|
throw new CliUsageError(`Case schema ${String(root.version)} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
249
248
|
}
|
|
250
|
-
if (
|
|
251
|
-
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 ')}.`);
|
|
252
251
|
}
|
|
253
252
|
try {
|
|
254
|
-
assertCanonicalCaseIdentities(root.cases, 'Case-pack input');
|
|
253
|
+
assertCanonicalCaseIdentities(root.cases, 'Case-pack input', root.version);
|
|
255
254
|
for (const item of root.cases) {
|
|
256
255
|
const rawCase = record(item);
|
|
257
256
|
if (!rawCase || !Object.hasOwn(rawCase, 'brandProfileIds'))
|
|
@@ -275,7 +274,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
275
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.`);
|
|
276
275
|
}
|
|
277
276
|
try {
|
|
278
|
-
assertCurrentCaseProjection(root.cases, normalised, 'Case-pack input');
|
|
277
|
+
assertCurrentCaseProjection(root.cases, canonicalPackProjection(normalised, root.version), 'Case-pack input', root.version);
|
|
279
278
|
}
|
|
280
279
|
catch (cause) {
|
|
281
280
|
throw new CliUsageError(cause instanceof Error ? cause.message : `Case-pack schema ${CASE_SCHEMA_VERSION} input is not exact.`);
|
|
@@ -313,7 +312,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
|
|
|
313
312
|
}),
|
|
314
313
|
});
|
|
315
314
|
if (Buffer.byteLength(JSON.stringify(document), 'utf8') > MAX_CASE_IMPORT_BYTES) {
|
|
316
|
-
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.`);
|
|
317
316
|
}
|
|
318
317
|
return document;
|
|
319
318
|
}
|
|
@@ -382,15 +381,16 @@ export function verifyCliCasePack(input) {
|
|
|
382
381
|
|| !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
|
|
383
382
|
throw new TypeError('The CLI case pack has an invalid audience redaction manifest.');
|
|
384
383
|
}
|
|
385
|
-
assertCanonicalCaseIdentities(root.cases, 'The CLI case pack');
|
|
384
|
+
assertCanonicalCaseIdentities(root.cases, 'The CLI case pack', root.version);
|
|
386
385
|
const normalised = normalizeCaseStore(root).cases;
|
|
387
386
|
if (normalised.length !== root.cases.length) {
|
|
388
387
|
throw new TypeError('The CLI case pack contains an invalid case collection.');
|
|
389
388
|
}
|
|
390
|
-
const currentCaseSchema = root.version
|
|
389
|
+
const currentCaseSchema = root.version >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
390
|
+
const projected = canonicalPackProjection(normalised, root.version);
|
|
391
391
|
if (currentCaseSchema)
|
|
392
|
-
assertCurrentCaseProjection(root.cases,
|
|
393
|
-
const
|
|
392
|
+
assertCurrentCaseProjection(root.cases, projected, 'The CLI case pack', root.version);
|
|
393
|
+
const normalisedById = new Map(projected.map((item) => [item.id, item]));
|
|
394
394
|
const caseReferenceLists = [];
|
|
395
395
|
const reportReferenceLists = [];
|
|
396
396
|
for (let index = 0; index < root.cases.length; index += 1) {
|
|
@@ -401,17 +401,18 @@ export function verifyCliCasePack(input) {
|
|
|
401
401
|
|| !report
|
|
402
402
|
|| !reportCase
|
|
403
403
|
|| typeof rawCase.domain !== 'string'
|
|
404
|
+
|| typeof rawCase.id !== 'string'
|
|
404
405
|
|| report.schema !== CASE_REPORT_SCHEMA
|
|
405
406
|
|| !caseReportVersionMatchesCase(root.version, report.schemaVersion)
|
|
406
407
|
|| reportCase.id !== rawCase.id
|
|
407
408
|
|| reportCase.domain !== rawCase.domain
|
|
408
|
-
|| !
|
|
409
|
+
|| !normalisedById.has(rawCase.id)) {
|
|
409
410
|
throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
|
|
410
411
|
}
|
|
411
|
-
const normalisedCase =
|
|
412
|
+
const normalisedCase = normalisedById.get(rawCase.id);
|
|
412
413
|
if (!normalisedCase)
|
|
413
414
|
throw new TypeError('The CLI case pack contains an invalid Case projection.');
|
|
414
|
-
assertAudienceProjection(rawCase, normalisedCase, audience, currentCaseSchema);
|
|
415
|
+
assertAudienceProjection(rawCase, normalisedCase, audience, currentCaseSchema, root.version);
|
|
415
416
|
try {
|
|
416
417
|
if (!Object.hasOwn(rawCase, 'brandProfileIds') || !Object.hasOwn(reportCase, 'brandProfileIds'))
|
|
417
418
|
throw new Error('missing');
|
|
@@ -423,6 +424,13 @@ export function verifyCliCasePack(input) {
|
|
|
423
424
|
}
|
|
424
425
|
if (currentCaseSchema)
|
|
425
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
|
+
}
|
|
426
434
|
}
|
|
427
435
|
const omitted = redactionManifest.brandProfileReferencesOmitted;
|
|
428
436
|
if (!Object.hasOwn(redactionManifest, 'brandProfileReferencesOmitted')
|
|
@@ -28,6 +28,10 @@ function parseTokens(command, argv) {
|
|
|
28
28
|
const positionals = [];
|
|
29
29
|
for (let index = 0; index < argv.length; index += 1) {
|
|
30
30
|
const argument = argv[index];
|
|
31
|
+
if (argument === '--') {
|
|
32
|
+
positionals.push(...argv.slice(index + 1));
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
31
35
|
if (!argument.startsWith('-')) {
|
|
32
36
|
positionals.push(argument);
|
|
33
37
|
continue;
|