@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
package/cli/output-file.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { unlinkSync } from 'node:fs';
|
|
|
3
3
|
import { link, open, rename, unlink } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { CliUsageError } from "./errors.mjs";
|
|
6
|
+
import { MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES } from "../packages/contracts/investigation-package-limits.mjs";
|
|
6
7
|
export const MAX_CLI_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
7
8
|
const MAX_CLI_OUTPUT_PATH_LENGTH = 4096;
|
|
8
9
|
const pendingOutputFiles = new Map();
|
|
@@ -22,14 +23,26 @@ function safeOutputPath(value) {
|
|
|
22
23
|
}
|
|
23
24
|
return resolve(value);
|
|
24
25
|
}
|
|
25
|
-
function createBufferedOutput() {
|
|
26
|
+
function createBufferedOutput(options = { binary: false }) {
|
|
26
27
|
const chunks = [];
|
|
28
|
+
const binaryChunks = [];
|
|
27
29
|
let bytes = 0;
|
|
30
|
+
const writeBinary = (chunk) => {
|
|
31
|
+
if (!options.binary || !(chunk instanceof Uint8Array))
|
|
32
|
+
throw new CliUsageError('Binary output requires an explicit package destination.');
|
|
33
|
+
if (chunk.byteLength > MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES - bytes)
|
|
34
|
+
throw new CliUsageError('Binary package output exceeds its byte limit.');
|
|
35
|
+
bytes += chunk.byteLength;
|
|
36
|
+
binaryChunks.push(new Uint8Array(chunk));
|
|
37
|
+
};
|
|
28
38
|
return Object.freeze({
|
|
39
|
+
writeBinary,
|
|
29
40
|
stream: {
|
|
30
41
|
isTTY: false,
|
|
31
42
|
columns: 80,
|
|
32
43
|
write(chunk) {
|
|
44
|
+
if (options.binary)
|
|
45
|
+
throw new CliUsageError('Binary package output cannot contain terminal text.');
|
|
33
46
|
const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
|
|
34
47
|
bytes += Buffer.byteLength(value, 'utf8');
|
|
35
48
|
if (bytes > MAX_CLI_OUTPUT_BYTES) {
|
|
@@ -39,14 +52,26 @@ function createBufferedOutput() {
|
|
|
39
52
|
return true;
|
|
40
53
|
},
|
|
41
54
|
},
|
|
42
|
-
value: () =>
|
|
55
|
+
value: () => {
|
|
56
|
+
if (!options.binary)
|
|
57
|
+
return chunks.join('');
|
|
58
|
+
const result = new Uint8Array(bytes);
|
|
59
|
+
let offset = 0;
|
|
60
|
+
for (const chunk of binaryChunks) {
|
|
61
|
+
result.set(chunk, offset);
|
|
62
|
+
offset += chunk.byteLength;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
},
|
|
43
66
|
});
|
|
44
67
|
}
|
|
45
68
|
async function writePrivateFile(pathValue, content, options = {}, operations = OUTPUT_FILE_OPERATIONS) {
|
|
46
69
|
const target = safeOutputPath(pathValue);
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
const maximumBytes = typeof content === 'string' ? MAX_CLI_OUTPUT_BYTES : MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES;
|
|
71
|
+
if ((typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.byteLength) > maximumBytes) {
|
|
72
|
+
throw new CliUsageError(`Generated output is limited to ${maximumBytes} bytes.`);
|
|
49
73
|
}
|
|
74
|
+
const captured = typeof content === 'string' ? content : new Uint8Array(content);
|
|
50
75
|
const directory = dirname(target);
|
|
51
76
|
const temporary = join(directory, `.${basename(target)}.${process.pid}.${operations.randomUUID()}.tmp`);
|
|
52
77
|
let temporaryCreated = false;
|
|
@@ -59,12 +84,13 @@ async function writePrivateFile(pathValue, content, options = {}, operations = O
|
|
|
59
84
|
settled: false,
|
|
60
85
|
});
|
|
61
86
|
try {
|
|
62
|
-
await handle.writeFile(
|
|
87
|
+
await handle.writeFile(captured, { encoding: 'utf8' });
|
|
63
88
|
await handle.sync();
|
|
64
89
|
}
|
|
65
90
|
finally {
|
|
66
91
|
await handle.close();
|
|
67
92
|
}
|
|
93
|
+
await options.beforePublish?.();
|
|
68
94
|
if (options.force) {
|
|
69
95
|
await operations.rename(temporary, target);
|
|
70
96
|
temporaryCreated = false;
|
package/cli/registry-doctor.mjs
CHANGED
|
@@ -189,7 +189,9 @@ function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString())
|
|
|
189
189
|
...sources.filter((source) => source.alignment === 'investigate').map((source) => (source.expected === 'allowed'
|
|
190
190
|
? `Review ${source.source.toUpperCase()} source health, transport attempts, and fixture coverage; the catalogue expected collection to be available.`
|
|
191
191
|
: `Review the ${source.source.toUpperCase()} result and capability profile; the observed state did not match the declared access constraint.`)),
|
|
192
|
-
...sources.filter((source) => source.alignment === 'unexpected_observation').map((source) => (
|
|
192
|
+
...sources.filter((source) => source.alignment === 'unexpected_observation').map((source) => (source.source === 'rdap' && source.expected === 'unsupported'
|
|
193
|
+
? 'Review whether the retained no-service RDAP profile needs refreshing; current bootstrap discovery may identify a newly published service.'
|
|
194
|
+
: `Review why ${source.source.toUpperCase()} produced normalized evidence despite the declared ${source.expected.replaceAll('_', ' ')} access profile.`)),
|
|
193
195
|
...sources.filter((source) => source.alignment === 'observed' && source.objectIdentifier === 'not_observed').map((source) => (`${source.source.toUpperCase()} succeeded without a retained registry object identifier; this can be a valid publication omission and should remain unknown rather than an error.`)),
|
|
194
196
|
].slice(0, 12);
|
|
195
197
|
return {
|
package/cli/registry-support.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { registryStandardsCoverageSnapshot, } from "../lib/registry-capabilities.mjs";
|
|
2
|
+
import { REGISTRY_STANDARDS_COVERAGE_SCHEMA } from "../lib/registry-capability-catalogue.mjs";
|
|
2
3
|
const REGISTRY_SUPPORT_SCHEMA = 'whoisleuth.cli.registry-support';
|
|
3
4
|
const REGISTRY_SUPPORT_SCHEMA_VERSION = 4;
|
|
4
|
-
const REGISTRY_STANDARDS_COVERAGE_SCHEMA = 'whoisleuth.registry-standards-coverage';
|
|
5
5
|
const MAX_REGISTRY_SUPPORT_TEXT_LENGTH = 2048;
|
|
6
6
|
const MAX_REGISTRY_SUPPORT_REFERENCES = 20;
|
|
7
7
|
const MAX_REGISTRY_SUPPORT_REFERENCE_LENGTH = 2048;
|
|
@@ -62,7 +62,7 @@ function parseBulkExport(root) {
|
|
|
62
62
|
const rawSession = record(candidate);
|
|
63
63
|
const rawResults = Array.isArray(rawSession?.results) ? rawSession.results : null;
|
|
64
64
|
const rawDomains = Array.isArray(rawSession?.domains) ? rawSession.domains : null;
|
|
65
|
-
const session = normalizeBulkSession(candidate);
|
|
65
|
+
const session = normalizeBulkSession(candidate, version);
|
|
66
66
|
if (!session || !rawResults || !rawDomains
|
|
67
67
|
|| rawResults.length > MAX_BULK_SESSION_ROWS
|
|
68
68
|
|| rawResults.length !== session.results.length
|
|
@@ -338,7 +338,7 @@ export function buildCliRetainedArtifactDiff(leftRaw, rightRaw, options = {}, ge
|
|
|
338
338
|
if (left.schema === SAVED_LOOKUP_SCHEMA) {
|
|
339
339
|
if (options.leftSessionId || options.rightSessionId)
|
|
340
340
|
throw new CliUsageError('Saved-session selectors apply only to Bulk session exports.');
|
|
341
|
-
return buildCliLookupDiff(leftRaw, rightRaw, generatedAt);
|
|
341
|
+
return buildCliLookupDiff(leftRaw, rightRaw, generatedAt, { domainMode: 'auto' });
|
|
342
342
|
}
|
|
343
343
|
if (left.schema === BULK_SESSION_SCHEMA)
|
|
344
344
|
return buildBulkLedger(leftRaw, rightRaw, left, right, options, generatedAt);
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { readBoundedRegularFile } from "../lib/bounded-file.mjs";
|
|
2
|
+
import { MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES } from "../packages/contracts/investigation-package-limits.mjs";
|
|
3
|
+
import { verifyOfflineInvestigationFolder, verifyOfflineInvestigationPackage } from "./investigation-package-review.mjs";
|
|
4
|
+
import { readInvestigationFolder } from "./investigation-folder.mjs";
|
|
5
|
+
import { readBagItFolder } from "./bagit-folder.mjs";
|
|
6
|
+
import { verifyOfflineBagIt } from "./bagit-review.mjs";
|
|
7
|
+
import { MAX_BAGIT_ZIP_BYTES } from "../packages/interchange/bagit.mjs";
|
|
1
8
|
import { MAX_OFFLINE_ARTIFACT_BYTES, formatOfflineArtifactVerification, isCompleteOfflineArtifactVerification, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
2
9
|
import { MAX_CASE_PACK_INPUT_BYTES, buildCliCasePack, formatCliCasePack } from "./case-pack.mjs";
|
|
3
10
|
import { MAX_COMPARE_INPUT_BYTES, compareLookupDocument, parseCliLookupDocument, } from "./compare.mjs";
|
|
@@ -5,7 +12,9 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
|
5
12
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
6
13
|
import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
|
|
7
14
|
import { buildCliLookupBrief, formatCliLookupBrief } from "./lookup-brief.mjs";
|
|
15
|
+
import { runCaseCommand } from "./case-command.mjs";
|
|
8
16
|
import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
|
|
17
|
+
import { MAX_MAIL_HEADER_INPUT_BYTES, buildCliMailHeaderReview, formatCliMailHeaderReview, } from "./mail-header-review.mjs";
|
|
9
18
|
import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
|
|
10
19
|
import { MAX_SOURCE_RELIABILITY_INPUT_BYTES, buildSourceReliabilityReport, formatSourceReliabilityReport, } from "./source-reliability.mjs";
|
|
11
20
|
import { buildInterchangeFidelityReport, formatInterchangeFidelityReport, } from "./interchange-report.mjs";
|
|
@@ -16,6 +25,42 @@ import { REVIEW_INLINE_COMMANDS } from "./inline-command-families.mjs";
|
|
|
16
25
|
import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
|
|
17
26
|
async function runVerifyArtifactCommand(args, dependencies, context) {
|
|
18
27
|
context.setFailureLabel('Artefact verification');
|
|
28
|
+
if (args.folder) {
|
|
29
|
+
let files;
|
|
30
|
+
try {
|
|
31
|
+
files = await (args.bagit ? readBagItFolder : readInvestigationFolder)(args.folder, dependencies.signal);
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
dependencies.signal?.throwIfAborted();
|
|
35
|
+
const reason = cause instanceof TypeError ? boundedCliErrorMessage(cause) : 'The selected folder is unavailable or could not be read.';
|
|
36
|
+
throw new CliUsageError(`Could not read evidence folder: ${reason}`);
|
|
37
|
+
}
|
|
38
|
+
const report = await (args.bagit ? verifyOfflineBagIt : verifyOfflineInvestigationFolder)(files);
|
|
39
|
+
dependencies.signal?.throwIfAborted();
|
|
40
|
+
if (!args.quiet)
|
|
41
|
+
context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatOfflineArtifactVerification(report), args.color));
|
|
42
|
+
return args.strictExit && !isCompleteOfflineArtifactVerification(report) ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
43
|
+
}
|
|
44
|
+
if (args.package) {
|
|
45
|
+
if (!args.source || args.source === '-')
|
|
46
|
+
throw new CliUsageError('--package requires a selected ZIP file; binary stdin is not accepted.');
|
|
47
|
+
let bytes;
|
|
48
|
+
try {
|
|
49
|
+
bytes = dependencies.readBinaryArtifactInput ? await dependencies.readBinaryArtifactInput(args.source)
|
|
50
|
+
: await readBoundedRegularFile(args.source, { maximumBytes: args.bagit ? MAX_BAGIT_ZIP_BYTES : MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES, minimumBytes: 22, label: 'Investigation package', ...(dependencies.signal ? { signal: dependencies.signal } : {}) });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof CliUsageError)
|
|
54
|
+
throw error;
|
|
55
|
+
throw new CliUsageError(`Could not read package input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
56
|
+
}
|
|
57
|
+
const passphrase = args.passphraseSource ? await context.readPassphraseSource(args.passphraseSource) : undefined;
|
|
58
|
+
const report = args.bagit ? await verifyOfflineBagIt(bytes) : await verifyOfflineInvestigationPackage(bytes, passphrase);
|
|
59
|
+
dependencies.signal?.throwIfAborted();
|
|
60
|
+
if (!args.quiet)
|
|
61
|
+
context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatOfflineArtifactVerification(report), args.color));
|
|
62
|
+
return args.strictExit && !isCompleteOfflineArtifactVerification(report) ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
63
|
+
}
|
|
19
64
|
let input;
|
|
20
65
|
try {
|
|
21
66
|
input = dependencies.readArtifactInput
|
|
@@ -180,6 +225,29 @@ async function runMailReviewCommand(args, dependencies, context) {
|
|
|
180
225
|
}
|
|
181
226
|
return EXIT_CODES.SUCCESS;
|
|
182
227
|
}
|
|
228
|
+
async function runMailHeadersCommand(args, dependencies, context) {
|
|
229
|
+
context.setFailureLabel('Mail-header review');
|
|
230
|
+
let input;
|
|
231
|
+
try {
|
|
232
|
+
input = dependencies.readMailHeaderInput
|
|
233
|
+
? await dependencies.readMailHeaderInput(args.source)
|
|
234
|
+
: await context.readHeaderInput(args.source, MAX_MAIL_HEADER_INPUT_BYTES, 'Mail-header input');
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (error instanceof CliUsageError)
|
|
238
|
+
throw error;
|
|
239
|
+
throw new CliUsageError(`Could not read mail-header input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
240
|
+
}
|
|
241
|
+
if (!input.trim())
|
|
242
|
+
throw new CliUsageError('mail-headers requires one message or header file, or headers on stdin.');
|
|
243
|
+
const document = buildCliMailHeaderReview(input, context.now());
|
|
244
|
+
if (!args.quiet) {
|
|
245
|
+
context.writeStdout(args.output === 'json'
|
|
246
|
+
? formatJsonDocument(document)
|
|
247
|
+
: context.terminal(formatCliMailHeaderReview(document), args.color));
|
|
248
|
+
}
|
|
249
|
+
return EXIT_CODES.SUCCESS;
|
|
250
|
+
}
|
|
183
251
|
async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
|
|
184
252
|
context.setFailureLabel('Offline evidence review');
|
|
185
253
|
let input;
|
|
@@ -259,8 +327,10 @@ const REVIEW_COMMAND_HANDLERS = Object.freeze({
|
|
|
259
327
|
'compare': runCompareCommand,
|
|
260
328
|
'page-compare': runPageCompareCommand,
|
|
261
329
|
'mail-review': runMailReviewCommand,
|
|
330
|
+
'mail-headers': runMailHeadersCommand,
|
|
262
331
|
'review-evidence': runOfflineEvidenceReviewCommand,
|
|
263
332
|
'brief': runBriefOrCasePackCommand,
|
|
333
|
+
'case': runCaseCommand,
|
|
264
334
|
'case-pack': runBriefOrCasePackCommand,
|
|
265
335
|
});
|
|
266
336
|
function runReviewCommand(args, dependencies, context) {
|
package/cli/risk-calibration.mjs
CHANGED
|
@@ -5,18 +5,14 @@ import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
|
5
5
|
import { canonicalRegistrableDomain } from "../lib/registrable-domain.mjs";
|
|
6
6
|
import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../lib/analyst-taxonomy.mjs";
|
|
7
7
|
import { buildRiskCalibrationSummaryReport, parseRiskCalibrationSummaryReport, RISK_CALIBRATION_SUMMARY_INTERPRETATION, RISK_CALIBRATION_SUMMARY_THRESHOLDS, } from "../lib/risk-calibration-summary.mjs";
|
|
8
|
-
import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
8
|
+
import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, RISK_CALIBRATION_DISPOSITIONS, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
9
9
|
import { CliUsageError } from "./arguments.mjs";
|
|
10
10
|
import { RISK_MUTATION_TYPES } from "../lib/risk-scoring.mjs";
|
|
11
11
|
import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, } from "../lib/threat-intelligence-types.mjs";
|
|
12
12
|
export { MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_STRING_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
13
13
|
export const RISK_CALIBRATION_THRESHOLDS = RISK_CALIBRATION_SUMMARY_THRESHOLDS;
|
|
14
14
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
15
|
-
const DISPOSITIONS = new Set(
|
|
16
|
-
'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action',
|
|
17
|
-
]);
|
|
18
|
-
const POSITIVE_DISPOSITIONS = new Set(['confirmed_abuse']);
|
|
19
|
-
const NEGATIVE_DISPOSITIONS = new Set(['false_positive', 'expected']);
|
|
15
|
+
const DISPOSITIONS = new Set(RISK_CALIBRATION_DISPOSITIONS);
|
|
20
16
|
const AVAILABILITY_STATES = new Set(['registered', 'for_sale', 'expiring', 'available', 'unknown', 'error']);
|
|
21
17
|
const ACTIVITY_STATES = new Set(['active', 'parked', 'unreachable', 'no_site']);
|
|
22
18
|
const BOOLEAN_FIELDS = [
|
|
@@ -26,6 +22,17 @@ const BOOLEAN_FIELDS = [
|
|
|
26
22
|
];
|
|
27
23
|
const REVIEW_REASON_CODES = ANALYST_REVIEW_REASON_VALUES;
|
|
28
24
|
const MUTATION_TYPES = new Set(RISK_MUTATION_TYPES);
|
|
25
|
+
const DISPOSITION_METRIC_CLASSES = Object.freeze({
|
|
26
|
+
unreviewed: 'excluded',
|
|
27
|
+
suspicious: 'excluded',
|
|
28
|
+
confirmed_abuse: 'positive',
|
|
29
|
+
false_positive: 'negative',
|
|
30
|
+
expected: 'negative',
|
|
31
|
+
closed_no_action: 'excluded',
|
|
32
|
+
});
|
|
33
|
+
function isRiskCalibrationDisposition(value) {
|
|
34
|
+
return DISPOSITIONS.has(value);
|
|
35
|
+
}
|
|
29
36
|
function object(value, field) {
|
|
30
37
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
31
38
|
throw new CliUsageError(`${field} must be an object.`);
|
|
@@ -214,7 +221,7 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
214
221
|
if (!isValidAsciiDomainName(domain, { requireDot: true }))
|
|
215
222
|
throw new CliUsageError(`${prefix}.domain must be a valid ASCII DNS hostname, not an IP address.`);
|
|
216
223
|
const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH);
|
|
217
|
-
if (!
|
|
224
|
+
if (!isRiskCalibrationDisposition(analystDisposition))
|
|
218
225
|
throw new CliUsageError(`${prefix}.analystDisposition is unsupported.`);
|
|
219
226
|
let reviewReasonCode;
|
|
220
227
|
if (record.reviewReasonCode !== null && record.reviewReasonCode !== undefined) {
|
|
@@ -225,7 +232,7 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
225
232
|
return {
|
|
226
233
|
id,
|
|
227
234
|
domain,
|
|
228
|
-
analystDisposition
|
|
235
|
+
analystDisposition,
|
|
229
236
|
...(reviewReasonCode ? { reviewReasonCode } : {}),
|
|
230
237
|
evidence: projectEvidence(record.evidence, `${prefix}.evidence`),
|
|
231
238
|
};
|
|
@@ -276,9 +283,7 @@ function metricsForThreshold(records, threshold) {
|
|
|
276
283
|
recall,
|
|
277
284
|
specificity,
|
|
278
285
|
falsePositiveRate: ratio(falsePositive, falsePositive + trueNegative),
|
|
279
|
-
f1:
|
|
280
|
-
? null
|
|
281
|
-
: Number(((2 * precision * recall) / (precision + recall)).toFixed(4)),
|
|
286
|
+
f1: ratio(2 * truePositive, 2 * truePositive + falsePositive + falseNegative),
|
|
282
287
|
balancedAccuracy: recall === null || specificity === null
|
|
283
288
|
? null
|
|
284
289
|
: Number(((recall + specificity) / 2).toFixed(4)),
|
|
@@ -290,11 +295,7 @@ function metricsForThreshold(records, threshold) {
|
|
|
290
295
|
};
|
|
291
296
|
}
|
|
292
297
|
function metricClass(disposition) {
|
|
293
|
-
|
|
294
|
-
return 'positive';
|
|
295
|
-
if (NEGATIVE_DISPOSITIONS.has(disposition))
|
|
296
|
-
return 'negative';
|
|
297
|
-
return 'excluded';
|
|
298
|
+
return DISPOSITION_METRIC_CLASSES[disposition];
|
|
298
299
|
}
|
|
299
300
|
function scoreBand(score) {
|
|
300
301
|
if (score === null)
|
|
@@ -395,7 +396,8 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
395
396
|
if (!source || !current)
|
|
396
397
|
continue;
|
|
397
398
|
const previous = options.explainPreviousRiskScore(scoringEvidence(source));
|
|
398
|
-
|
|
399
|
+
const previousScore = previous?.score ?? null;
|
|
400
|
+
if (previousScore !== current.score)
|
|
399
401
|
scoresChanged += 1;
|
|
400
402
|
if (scoreBand(previous?.score ?? null) !== current.band)
|
|
401
403
|
bandsChanged += 1;
|
package/cli/runner.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { HELP, commandDefinition, commandHelp, } from "./command-reference.mjs";
|
|
|
5
5
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
6
6
|
import { evidenceCommandFailureLabel, isEvidenceCommand, runEvidenceCommand, } from "./evidence-command-runner.mjs";
|
|
7
7
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
8
|
-
import { readCliTextInput } from "./input.mjs";
|
|
8
|
+
import { readCliHeaderInput, readCliTextInput } from "./input.mjs";
|
|
9
9
|
import { INLINE_CLI_COMMANDS, runInlineCommand } from "./inline-command-runner.mjs";
|
|
10
10
|
import { canLaunchInteractiveCli, launchInteractiveCli, } from "./interactive-launcher.mjs";
|
|
11
11
|
import { MAX_OFFLINE_PASSPHRASE_FILE_BYTES, } from "./artifact-verify.mjs";
|
|
@@ -50,7 +50,7 @@ function usageEventReason(error) {
|
|
|
50
50
|
return 'missing_input';
|
|
51
51
|
return 'invalid_input';
|
|
52
52
|
}
|
|
53
|
-
async function runParsedCli(args, dependencies = {}) {
|
|
53
|
+
async function runParsedCli(args, dependencies = {}, writeBinaryOutput) {
|
|
54
54
|
const stdout = dependencies.stdout || process.stdout;
|
|
55
55
|
const stderr = dependencies.stderr || process.stderr;
|
|
56
56
|
const environment = dependencies.environment || process.env;
|
|
@@ -98,6 +98,11 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
98
98
|
label,
|
|
99
99
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
100
100
|
}));
|
|
101
|
+
const readHeaderInput = async (source, maximumBytes, label) => (readCliHeaderInput(source, dependencies.stdin || process.stdin, {
|
|
102
|
+
maximumBytes,
|
|
103
|
+
label,
|
|
104
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
105
|
+
}));
|
|
101
106
|
const readPassphraseSource = async (source) => {
|
|
102
107
|
let passphraseText;
|
|
103
108
|
try {
|
|
@@ -123,9 +128,11 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
123
128
|
terminal,
|
|
124
129
|
presentation: (color) => terminalPresentation(stdout, color, environment, palette),
|
|
125
130
|
writeStdout: (value) => write(stdout, value),
|
|
131
|
+
...(writeBinaryOutput ? { writeBinaryOutput } : {}),
|
|
126
132
|
writeStderr: (value) => write(stderr, value),
|
|
127
133
|
readSingleInput,
|
|
128
134
|
readInput,
|
|
135
|
+
readHeaderInput,
|
|
129
136
|
readPassphraseSource,
|
|
130
137
|
now: () => dependencies.now ? dependencies.now() : new Date().toISOString(),
|
|
131
138
|
beginProgress,
|
|
@@ -299,15 +306,58 @@ async function runCliCommand(argv, dependencies = {}) {
|
|
|
299
306
|
}
|
|
300
307
|
if (!args.destination)
|
|
301
308
|
return runParsedCli(args, dependencies);
|
|
302
|
-
const buffered = createBufferedOutput();
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return code;
|
|
309
|
+
const buffered = createBufferedOutput({ binary: args.action === 'manifest' && args.package === true });
|
|
310
|
+
let checkpoint = null;
|
|
311
|
+
let capturedSource = null;
|
|
306
312
|
try {
|
|
307
|
-
|
|
313
|
+
if (args.action === 'workflow-run') {
|
|
314
|
+
const { prepareInvestigationCheckpoint } = await import("./investigation-checkpoint.mjs");
|
|
315
|
+
const workflowCheckpoint = await prepareInvestigationCheckpoint({
|
|
316
|
+
destination: args.destination, resumeSource: args.resumeSource, force: args.force === true,
|
|
317
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
318
|
+
});
|
|
319
|
+
checkpoint = workflowCheckpoint;
|
|
320
|
+
capturedSource = workflowCheckpoint.resumeInput;
|
|
321
|
+
}
|
|
322
|
+
else if (args.action === 'case') {
|
|
323
|
+
const { prepareLocalDocumentWrite } = await import("./local-document-checkpoint.mjs");
|
|
324
|
+
const { MAX_EDITABLE_CASE_INPUT_BYTES, MAX_EDITABLE_CASE_OUTPUT_BYTES } = await import("../packages/contracts/case-portability.mjs");
|
|
325
|
+
const caseCheckpoint = await prepareLocalDocumentWrite({
|
|
326
|
+
destination: args.destination, source: args.source, force: args.force === true, label: 'Case',
|
|
327
|
+
allowSourceReplacement: args.operation !== 'show',
|
|
328
|
+
maximumInputBytes: MAX_EDITABLE_CASE_INPUT_BYTES, maximumOutputBytes: MAX_EDITABLE_CASE_OUTPUT_BYTES,
|
|
329
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
330
|
+
});
|
|
331
|
+
checkpoint = caseCheckpoint;
|
|
332
|
+
capturedSource = caseCheckpoint.sourceInput;
|
|
333
|
+
}
|
|
334
|
+
const code = await runParsedCli(args, {
|
|
335
|
+
...dependencies, stdout: buffered.stream,
|
|
336
|
+
...(args.action === 'workflow-run' && args.resumeSource && capturedSource !== null
|
|
337
|
+
? { workflowResumeInput: capturedSource }
|
|
338
|
+
: {}),
|
|
339
|
+
...(args.action === 'case' && capturedSource !== null ? { caseFileInput: capturedSource } : {}),
|
|
340
|
+
}, buffered.writeBinary);
|
|
341
|
+
if (code !== EXIT_CODES.SUCCESS && code !== EXIT_CODES.PARTIAL_FAILURE)
|
|
342
|
+
return code;
|
|
343
|
+
const content = buffered.value();
|
|
344
|
+
if (checkpoint) {
|
|
345
|
+
if (typeof content !== 'string')
|
|
346
|
+
throw new TypeError('Local document output must be text.');
|
|
347
|
+
await checkpoint.publish(content);
|
|
348
|
+
}
|
|
349
|
+
else
|
|
350
|
+
await writePrivateFile(args.destination, content, {
|
|
351
|
+
force: args.force === true,
|
|
352
|
+
beforePublish: async () => { dependencies.signal?.throwIfAborted(); },
|
|
353
|
+
});
|
|
308
354
|
return code;
|
|
309
355
|
}
|
|
310
356
|
catch (error) {
|
|
357
|
+
if (isCancellation(error, dependencies.signal)) {
|
|
358
|
+
write(stderr, 'Cancelled by analyst.\n');
|
|
359
|
+
return EXIT_CODES.CANCELLED;
|
|
360
|
+
}
|
|
311
361
|
if (error instanceof CliUsageError) {
|
|
312
362
|
write(stderr, `Usage error: ${boundedCliErrorMessage(error, 'Output file could not be written')}\n`);
|
|
313
363
|
return EXIT_CODES.USAGE;
|
|
@@ -315,6 +365,11 @@ async function runCliCommand(argv, dependencies = {}) {
|
|
|
315
365
|
write(stderr, `Output failed: ${boundedCliErrorMessage(error, 'Output file could not be written')}\n`);
|
|
316
366
|
return EXIT_CODES.LOOKUP_FAILED;
|
|
317
367
|
}
|
|
368
|
+
finally {
|
|
369
|
+
if (checkpoint && await checkpoint.release() > 0) {
|
|
370
|
+
write(stderr, `${args.action === 'case' ? 'Case' : 'Workflow'} cleanup warning: File ownership changed or a lease could not be removed. Inspect the selected directory before resuming.\n`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
318
373
|
}
|
|
319
374
|
async function runCli(argv, dependencies = {}) {
|
|
320
375
|
const stderr = dependencies.stderr || process.stderr;
|
package/cli/sharing-review.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { hasVerifiedWholeArtifactIntegrity, UnsupportedOfflineArtifactError, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
2
|
-
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
1
|
+
import { hasVerifiedWholeArtifactIntegrity, MAX_OFFLINE_ARTIFACT_BYTES, parseOfflineArtifactJson, UnsupportedOfflineArtifactError, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
3
2
|
const SHARING_REVIEW_SCHEMA = 'whoisleuth.cli.sharing-review';
|
|
4
3
|
const SHARING_REVIEW_VERSION = 2;
|
|
5
|
-
const MAX_SHARING_REVIEW_BYTES =
|
|
4
|
+
const MAX_SHARING_REVIEW_BYTES = MAX_OFFLINE_ARTIFACT_BYTES;
|
|
6
5
|
const TLP_MARKINGS = ['clear', 'green', 'amber', 'amber-strict', 'red'];
|
|
7
6
|
const RECIPIENT_SCOPES = ['public', 'community', 'organization', 'named-recipients'];
|
|
8
7
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
@@ -31,46 +30,37 @@ function markingFromText(value) {
|
|
|
31
30
|
return TLP_MARKINGS.includes(normalized) ? normalized : null;
|
|
32
31
|
}
|
|
33
32
|
function scanArtifact(root) {
|
|
34
|
-
|
|
33
|
+
// The caller has already enforced the JSON byte, node, nesting and container
|
|
34
|
+
// bounds. Scan that admitted tree in full without a second, smaller prefix.
|
|
35
|
+
const stack = [{ value: root, marking: false }];
|
|
35
36
|
const markings = new Set();
|
|
36
37
|
let riskyKeyCount = 0;
|
|
37
|
-
|
|
38
|
-
let truncated = false;
|
|
39
|
-
while (stack.length && visited < 20_000) {
|
|
38
|
+
while (stack.length) {
|
|
40
39
|
const next = stack.pop();
|
|
41
|
-
visited += 1;
|
|
42
|
-
if (next.key && RISKY_KEYS.has(normalizedKey(next.key)))
|
|
43
|
-
riskyKeyCount += 1;
|
|
44
40
|
if (typeof next.value === 'string') {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (next.depth >= 12) {
|
|
51
|
-
if (next.value && typeof next.value === 'object')
|
|
52
|
-
truncated = true;
|
|
41
|
+
if (next.marking) {
|
|
42
|
+
const marking = markingFromText(next.value);
|
|
43
|
+
if (marking)
|
|
44
|
+
markings.add(marking);
|
|
45
|
+
}
|
|
53
46
|
continue;
|
|
54
47
|
}
|
|
55
48
|
if (Array.isArray(next.value)) {
|
|
56
|
-
for (const
|
|
57
|
-
stack.push({ value
|
|
58
|
-
if (next.value.length > 500)
|
|
59
|
-
truncated = true;
|
|
49
|
+
for (const value of next.value)
|
|
50
|
+
stack.push({ value, marking: next.marking });
|
|
60
51
|
continue;
|
|
61
52
|
}
|
|
62
53
|
const valueRecord = record(next.value);
|
|
63
54
|
if (!valueRecord)
|
|
64
55
|
continue;
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
56
|
+
for (const [key, value] of Object.entries(valueRecord)) {
|
|
57
|
+
const normalized = normalizedKey(key);
|
|
58
|
+
if (RISKY_KEYS.has(normalized))
|
|
59
|
+
riskyKeyCount += 1;
|
|
60
|
+
stack.push({ value, marking: MARKING_KEYS.has(normalized) });
|
|
61
|
+
}
|
|
70
62
|
}
|
|
71
|
-
|
|
72
|
-
truncated = true;
|
|
73
|
-
return { importedMarkings: [...markings], riskyKeyCount, truncated };
|
|
63
|
+
return { importedMarkings: [...markings], riskyKeyCount };
|
|
74
64
|
}
|
|
75
65
|
function expectedScope(marking) {
|
|
76
66
|
if (marking === 'clear')
|
|
@@ -90,8 +80,7 @@ async function buildSharingReview(raw, options, generatedAt = new Date().toISOSt
|
|
|
90
80
|
}
|
|
91
81
|
let artifactValue;
|
|
92
82
|
try {
|
|
93
|
-
|
|
94
|
-
artifactValue = JSON.parse(raw);
|
|
83
|
+
artifactValue = parseOfflineArtifactJson(raw);
|
|
95
84
|
}
|
|
96
85
|
catch {
|
|
97
86
|
throw new TypeError('Sharing review input must be valid bounded JSON without duplicate keys.');
|
|
@@ -147,8 +136,6 @@ async function buildSharingReview(raw, options, generatedAt = new Date().toISOSt
|
|
|
147
136
|
add('recipient-scope', options.recipientScope === expectedScope(effective) ? 'pass' : 'block', 'Recipient scope', options.recipientScope === expectedScope(effective)
|
|
148
137
|
? `The recipient scope matches the effective ${tlpLabel(effective)} sharing boundary.`
|
|
149
138
|
: `${tlpLabel(effective)} requires the ${expectedScope(effective)} recipient scope in this local policy.`);
|
|
150
|
-
if (scan.truncated)
|
|
151
|
-
add('scan-bounds', 'caution', 'Bounded content scan', 'The defensive key and marking scan reached a traversal bound; review the artefact manually.');
|
|
152
139
|
const counts = {
|
|
153
140
|
block: findings.filter((finding) => finding.state === 'block').length,
|
|
154
141
|
caution: findings.filter((finding) => finding.state === 'caution').length,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
4
|
+
import { formatEvidenceSignatureVerification } from "./evidence-signing.mjs";
|
|
5
|
+
export const SIGNER_TRUST_STORE_SCHEMA = 'whoisleuth.evidence-signer-trust-store';
|
|
6
|
+
export const SIGNER_TRUST_STORE_VERSION = 1;
|
|
7
|
+
export const SIGNER_TRUST_REPORT_SCHEMA = 'whoisleuth.evidence-signer-trust-report';
|
|
8
|
+
export const SIGNER_TRUST_REPORT_VERSION = 1;
|
|
9
|
+
export const MAX_SIGNER_TRUST_STORE_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
export const MAX_SIGNER_TRUST_ENTRIES = 1_024;
|
|
11
|
+
export const MAX_SIGNER_TRUST_LABEL_LENGTH = 160;
|
|
12
|
+
export const MAX_SIGNER_TRUST_NOTE_LENGTH = 2_048;
|
|
13
|
+
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
14
|
+
const UNSAFE_TEXT = /[\x00-\x1f\x7f-\x9f\u2028\u2029]|\p{Default_Ignorable_Code_Point}/u;
|
|
15
|
+
const PRIVATE_KEY_HEADER = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/u;
|
|
16
|
+
function object(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
18
|
+
}
|
|
19
|
+
function text(value, maximum, label, allowEmpty = false) {
|
|
20
|
+
if (typeof value !== 'string' || value.length > maximum || (!allowEmpty && !value.trim()) || UNSAFE_TEXT.test(value) || PRIVATE_KEY_HEADER.test(value)) {
|
|
21
|
+
throw new TypeError(`Signer trust ${label} must be single-line text of at most ${maximum} characters without hidden controls.`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function parseSignerTrustStore(raw) {
|
|
26
|
+
const value = parseBoundedJsonObject(raw, { label: 'Signer trust store', maximumBytes: MAX_SIGNER_TRUST_STORE_BYTES });
|
|
27
|
+
if (value.schema !== SIGNER_TRUST_STORE_SCHEMA || value.version !== SIGNER_TRUST_STORE_VERSION
|
|
28
|
+
|| Object.keys(value).some((key) => !['schema', 'version', 'entries'].includes(key))
|
|
29
|
+
|| !Array.isArray(value.entries) || value.entries.length > MAX_SIGNER_TRUST_ENTRIES) {
|
|
30
|
+
throw new TypeError(`Signer trust store must use ${SIGNER_TRUST_STORE_SCHEMA} version ${SIGNER_TRUST_STORE_VERSION} with at most ${MAX_SIGNER_TRUST_ENTRIES} entries.`);
|
|
31
|
+
}
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const entries = value.entries.map((candidate) => {
|
|
34
|
+
const entry = object(candidate);
|
|
35
|
+
if (!entry || Object.keys(entry).some((key) => !['keyIdSha256', 'label', 'status', 'updatedAt', 'note', 'successorKeyIdSha256'].includes(key))
|
|
36
|
+
|| typeof entry.keyIdSha256 !== 'string' || !FINGERPRINT.test(entry.keyIdSha256)
|
|
37
|
+
|| typeof entry.status !== 'string' || !['trusted', 'retired', 'revoked'].includes(entry.status)) {
|
|
38
|
+
throw new TypeError('Signer trust entry requires a SHA-256 key fingerprint and trusted, retired or revoked status.');
|
|
39
|
+
}
|
|
40
|
+
if (seen.has(entry.keyIdSha256))
|
|
41
|
+
throw new TypeError('Signer trust store contains duplicate key fingerprints.');
|
|
42
|
+
seen.add(entry.keyIdSha256);
|
|
43
|
+
const updatedAt = normalizeExplicitIsoTimestamp(entry.updatedAt);
|
|
44
|
+
if (!updatedAt)
|
|
45
|
+
throw new TypeError('Signer trust entry updatedAt requires a valid timestamp with an explicit timezone.');
|
|
46
|
+
const successor = entry.successorKeyIdSha256 ?? null;
|
|
47
|
+
if (successor !== null && (typeof successor !== 'string' || !FINGERPRINT.test(successor) || successor === entry.keyIdSha256)) {
|
|
48
|
+
throw new TypeError('Signer trust successor must be a different SHA-256 fingerprint or null.');
|
|
49
|
+
}
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
keyIdSha256: entry.keyIdSha256,
|
|
52
|
+
label: text(entry.label, MAX_SIGNER_TRUST_LABEL_LENGTH, 'label'),
|
|
53
|
+
status: entry.status,
|
|
54
|
+
updatedAt,
|
|
55
|
+
note: text(entry.note === undefined ? '' : entry.note, MAX_SIGNER_TRUST_NOTE_LENGTH, 'note', true),
|
|
56
|
+
successorKeyIdSha256: successor,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return Object.freeze({ schema: SIGNER_TRUST_STORE_SCHEMA, version: SIGNER_TRUST_STORE_VERSION, entries: Object.freeze(entries) });
|
|
60
|
+
}
|
|
61
|
+
export function assessEvidenceSignerTrust(verification, trustStoreRaw, checkedAt = new Date().toISOString()) {
|
|
62
|
+
const now = normalizeExplicitIsoTimestamp(checkedAt);
|
|
63
|
+
if (!now)
|
|
64
|
+
throw new TypeError('Signer trust evaluation requires a valid timestamp with an explicit timezone.');
|
|
65
|
+
const store = parseSignerTrustStore(trustStoreRaw);
|
|
66
|
+
const entry = store.entries.find((candidate) => candidate.keyIdSha256 === verification.signature.keyIdSha256) ?? null;
|
|
67
|
+
// Signing time is self-asserted. It cannot bypass the current revocation policy,
|
|
68
|
+
// and a successor reference never makes another fingerprint trusted.
|
|
69
|
+
const state = !entry ? 'unknown' : entry.status !== 'trusted' ? entry.status
|
|
70
|
+
: Date.parse(entry.updatedAt) > Date.parse(now) ? 'review_required' : 'trusted';
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
schema: SIGNER_TRUST_REPORT_SCHEMA,
|
|
73
|
+
version: SIGNER_TRUST_REPORT_VERSION,
|
|
74
|
+
state: state === 'trusted' ? 'trusted' : 'not_trusted',
|
|
75
|
+
checkedAt: now,
|
|
76
|
+
verification,
|
|
77
|
+
trust: Object.freeze({ state, storeDigestSha256: createHash('sha256').update(trustStoreRaw, 'utf8').digest('hex'), entry }),
|
|
78
|
+
limitations: Object.freeze([
|
|
79
|
+
'Trust is the current policy in the explicitly selected local file. Obtain fingerprints and revocation updates through an authenticated channel; this command does not discover or refresh them.',
|
|
80
|
+
'A label or successor reference does not authenticate a person, organisation or replacement key. A trusted fingerprint does not establish evidence accuracy, authority or collection time.',
|
|
81
|
+
'Signing time is supplied by the signer, not a trusted timestamp. Retired and revoked fingerprints remain untrusted regardless of that time.',
|
|
82
|
+
'The report includes only the matching entry and a SHA-256 digest of the exact trust-file bytes, not the file path, other entries or key files. Do not put sensitive material in labels or notes.',
|
|
83
|
+
]),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export function formatSignerTrustReport(report) {
|
|
87
|
+
const entry = report.trust.entry;
|
|
88
|
+
return [
|
|
89
|
+
`WHOISleuth signer trust: ${report.trust.state}`,
|
|
90
|
+
`Checked: ${report.checkedAt}`,
|
|
91
|
+
`Trust-file SHA-256: ${report.trust.storeDigestSha256}`,
|
|
92
|
+
...(entry ? [
|
|
93
|
+
`Local label: ${entry.label}`,
|
|
94
|
+
`Entry reviewed: ${entry.updatedAt}`,
|
|
95
|
+
...(entry.note ? [`Local note: ${entry.note}`] : []),
|
|
96
|
+
...(entry.successorKeyIdSha256 ? [`Successor reference: sha256:${entry.successorKeyIdSha256} (not a trust grant)`] : []),
|
|
97
|
+
] : ['No matching fingerprint in the selected trust file.']),
|
|
98
|
+
'',
|
|
99
|
+
'Signature checks (separate from current trust policy)',
|
|
100
|
+
formatEvidenceSignatureVerification(report.verification, false).trimEnd(),
|
|
101
|
+
...report.limitations.map((limitation) => `Trust limitation: ${limitation}`),
|
|
102
|
+
'',
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|