@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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/README.md +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
package/cli/runner.mjs
CHANGED
|
@@ -2,18 +2,14 @@ import { Buffer } from 'node:buffer';
|
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { abortable } from "../lib/abort.mjs";
|
|
5
|
-
import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
|
|
6
|
-
import { whoisQuery } from "../lib/whois-transport.mjs";
|
|
7
5
|
import { REGISTRY_CAPABILITIES_VERSION, registryCapabilityFor } from "../lib/registry-capabilities.mjs";
|
|
8
|
-
import { explainRiskScore, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
|
|
6
|
+
import { explainRiskScore, explainRiskScoreV6, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
|
|
9
7
|
import { CLI_COMMANDS, parseCliArguments } from "./arguments.mjs";
|
|
10
|
-
import { runBulkCommand } from "./bulk-command-runner.mjs";
|
|
11
8
|
import { buildCliCommandCatalogue, formatCliCommandCatalogue } from "./command-catalogue.mjs";
|
|
9
|
+
import { COMMAND_COLLECTION, COMMAND_DETAILS, COMMAND_USAGE, HELP, commandHelp, } from "./command-reference.mjs";
|
|
12
10
|
import { buildShellCompletion } from "./completion.mjs";
|
|
13
11
|
import { buildDoctorReport, formatDoctorReport } from "./doctor.mjs";
|
|
14
12
|
import { MAX_COMPARE_INPUT_BYTES, compareLookupDocument, parseCliLookupDocument, readCompareInputBounded, } from "./compare.mjs";
|
|
15
|
-
import { runDiscoveryCommand } from "./discovery-command-runner.mjs";
|
|
16
|
-
import { runDiscoveryScanCommand } from "./discovery-scan-command-runner.mjs";
|
|
17
13
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
18
14
|
import { evidenceCommandFailureLabel, isEvidenceCommand, runEvidenceCommand, } from "./evidence-command-runner.mjs";
|
|
19
15
|
import { buildCliEvidenceExport, formatCliEvidenceExport } from "./export-evidence.mjs";
|
|
@@ -23,289 +19,44 @@ import { buildCliCompareDocument, formatJsonDocument, } from "./formatters/json.
|
|
|
23
19
|
import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
|
|
24
20
|
import { formatTerminalCompare, formatTerminalRegistrySupport, formatTerminalRiskCalibration, } from "./formatters/terminal.mjs";
|
|
25
21
|
import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
|
|
22
|
+
import { MAX_LOOKUP_RECONCILIATION_INPUT_BYTES, buildCliLookupReconciliation, formatCliLookupReconciliation, } from "./lookup-reconcile.mjs";
|
|
23
|
+
import { MAX_SHARING_REVIEW_BYTES, buildSharingReview, formatSharingReview, } from "./sharing-review.mjs";
|
|
26
24
|
import { MAX_LOOKUP_TIMELINE_INPUT_BYTES, buildCliLookupTimeline, formatCliLookupTimeline, } from "./lookup-timeline.mjs";
|
|
27
25
|
import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
|
|
28
26
|
import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
|
|
29
|
-
import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
|
|
27
|
+
import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
|
|
30
28
|
import { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, buildDomainControlManifest, formatDomainControlResult, reviewDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
|
|
31
|
-
import {
|
|
29
|
+
import { DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, buildDomainControlFlightRecorder, formatDomainControlFlightRecorder, } from "../lib/domain-control-flight-recorder.mjs";
|
|
30
|
+
import { CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, buildCliDomainControlReview, formatCliDomainControlReview, } from "./domain-control-observations.mjs";
|
|
31
|
+
import { buildCliLookupBrief, formatCliLookupBrief } from "./lookup-brief.mjs";
|
|
32
|
+
import { buildCliCasePack, formatCliCasePack } from "./case-pack.mjs";
|
|
33
|
+
import { formatDomainControlMonitor, runDomainControlMonitor } from "./domain-control-monitor.mjs";
|
|
34
|
+
import { MAX_ASSURANCE_INPUT_BYTES, buildDomainAssurance, formatDomainAssurance, } from "../lib/domain-assurance.mjs";
|
|
35
|
+
import { MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES, buildDomainChangePacket, formatDomainChangePacket, } from "../lib/domain-change-packet.mjs";
|
|
32
36
|
import { buildCliManual } from "./manual.mjs";
|
|
37
|
+
import { MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, buildInvestigationManifest, formatInvestigationManifest, } from "./investigation-manifest.mjs";
|
|
38
|
+
import { MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES, mapExternalObservations, formatExternalObservationMapping, } from "./external-observation-mapping.mjs";
|
|
39
|
+
import { buildOpenAssetModelBridge, formatOpenAssetModelBridge, } from "./open-asset-model-bridge.mjs";
|
|
40
|
+
import { MAX_CT_EVENT_INPUT_BYTES, buildCtEventFindings, formatCtEventFindings, } from "./ct-event-intake.mjs";
|
|
33
41
|
import { buildInvestigationPlan, formatInvestigationPlan } from "./investigation-plan.mjs";
|
|
34
|
-
import {
|
|
42
|
+
import { formatInvestigationRun, runInvestigationRecipe } from "./investigation-run.mjs";
|
|
43
|
+
import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
|
|
44
|
+
import { formatCliJunit } from "./ci-report.mjs";
|
|
35
45
|
import { createBufferedOutput, writePrivateFile } from "./output-file.mjs";
|
|
36
46
|
import { createTerminalProgress } from "./progress.mjs";
|
|
37
47
|
import { buildRegistrySupportDocument } from "./registry-support.mjs";
|
|
48
|
+
import { buildRegistryDoctorReport, formatRegistryDoctorReport } from "./registry-doctor.mjs";
|
|
49
|
+
import { MAX_REGISTRY_COHORT_INPUT_BYTES, buildRegistryCohortReport, formatRegistryCohortReport, } from "./registry-cohort.mjs";
|
|
50
|
+
import { buildRegistryFixtureScaffold } from "./registry-fixture-scaffold.mjs";
|
|
38
51
|
import { MAX_OFFLINE_ARTIFACT_BYTES, MAX_OFFLINE_PASSPHRASE_FILE_BYTES, formatOfflineArtifactVerification, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
39
52
|
import { MAX_SOURCE_RELIABILITY_INPUT_BYTES, buildSourceReliabilityReport, formatSourceReliabilityReport, } from "./source-reliability.mjs";
|
|
40
53
|
import { MAX_RISK_CALIBRATION_INPUT_BYTES, buildRiskCalibrationReport, parseRiskCalibrationDataset, readRiskCalibrationInputBounded, } from "./risk-calibration.mjs";
|
|
54
|
+
import { MAX_LOOKALIKE_CALIBRATION_BYTES, buildLookalikeCalibration, formatLookalikeCalibration, } from "./lookalike-calibration.mjs";
|
|
41
55
|
import { MAX_SAVED_LOOKUP_INPUT_BYTES, readSavedLookupInputBounded } from "./saved-lookup.mjs";
|
|
42
56
|
import { presentTerminalOutput, terminalPresentation, } from "./terminal-presentation.mjs";
|
|
43
|
-
import { runNetworkCommand } from "./network-command-runner.mjs";
|
|
44
57
|
const require = createRequire(import.meta.url);
|
|
45
58
|
const { version: VERSION } = require('../package.json');
|
|
46
59
|
const MAX_STDIN_BYTES = 4096;
|
|
47
|
-
const HELP = `WHOISleuth CLI
|
|
48
|
-
Source-aware domain investigation from your terminal.
|
|
49
|
-
|
|
50
|
-
Quick start:
|
|
51
|
-
whoisleuth lookup example.test
|
|
52
|
-
whoisleuth lookup example.test --deep
|
|
53
|
-
cat domains.txt | whoisleuth bulk --jsonl
|
|
54
|
-
|
|
55
|
-
Investigate:
|
|
56
|
-
lookup Collect one domain, IP, or ASN.
|
|
57
|
-
bulk Triage newline-delimited targets with bounded concurrency.
|
|
58
|
-
http Inspect one homepage request and redirect chain.
|
|
59
|
-
tls Inspect one hostname's current certificate connection.
|
|
60
|
-
posture Review DNS mail and domain-control posture.
|
|
61
|
-
|
|
62
|
-
Discover:
|
|
63
|
-
ct-search Search certificate-transparency observations.
|
|
64
|
-
discover Generate offline lookalike candidates.
|
|
65
|
-
discover-scan Collect a bounded candidate review queue.
|
|
66
|
-
registry-support Explain local registry coverage without a request.
|
|
67
|
-
|
|
68
|
-
Review saved evidence:
|
|
69
|
-
source-report Summarise source reliability without retaining targets.
|
|
70
|
-
compare Compare saved registry publications.
|
|
71
|
-
page-compare Compare saved static page and TLS evidence.
|
|
72
|
-
mail-review Review saved passive mail exposure evidence.
|
|
73
|
-
review-evidence Review supplied DNS, routing, GeoIP, or RDAP evidence offline.
|
|
74
|
-
domain-control Build or review an integrity-protected desired-state manifest.
|
|
75
|
-
workflow-plan Plan a fixed investigation recipe without executing it.
|
|
76
|
-
diff Compare two saved domain observations.
|
|
77
|
-
timeline Compare a sequence of observations for one domain.
|
|
78
|
-
export Convert a saved lookup into an evidence report.
|
|
79
|
-
inspect-archive Inspect a workspace archive, redacted by default.
|
|
80
|
-
verify-artifact Validate saved evidence or an integrity envelope offline.
|
|
81
|
-
|
|
82
|
-
Integrity and calibration:
|
|
83
|
-
sign-artifact Sign one reviewed packet or manifest locally.
|
|
84
|
-
verify-signature Verify one signed evidence package locally.
|
|
85
|
-
risk-calibrate Replay reviewed labels without changing the model.
|
|
86
|
-
|
|
87
|
-
Terminal:
|
|
88
|
-
doctor Check the local runtime; network tests require --network.
|
|
89
|
-
commands List command contracts for people or local tooling.
|
|
90
|
-
completion Print completion for bash, zsh, fish, or PowerShell.
|
|
91
|
-
manual Print the generated manual page.
|
|
92
|
-
|
|
93
|
-
Run "whoisleuth <command> --help" for focused usage and an example.
|
|
94
|
-
Use --json or --jsonl where supported for machine-readable stdout.
|
|
95
|
-
Use --output <file> for atomic private file output and --force to replace it.
|
|
96
|
-
Diagnostics are written to stderr. Fast lookup is the default; deep collection
|
|
97
|
-
must be requested explicitly and can disclose a target to additional sources.
|
|
98
|
-
|
|
99
|
-
Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
|
|
100
|
-
Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
|
|
101
|
-
`;
|
|
102
|
-
const COMMAND_USAGE = Object.freeze({
|
|
103
|
-
completion: 'whoisleuth completion <bash|zsh|fish|powershell>',
|
|
104
|
-
doctor: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
|
|
105
|
-
commands: 'whoisleuth commands [--json] [--quiet] [--no-color]',
|
|
106
|
-
manual: 'whoisleuth manual',
|
|
107
|
-
lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--markdown|--html] [--fast|--deep] [--plan] [--summary|--verbose] [--strict-exit] [--events] [--quiet] [--no-color]',
|
|
108
|
-
bulk: 'whoisleuth bulk [file] [--json|--jsonl|--csv|--domains|--queries] [--registered-only|--inconclusive-only|--errors-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events]',
|
|
109
|
-
'ct-search': 'whoisleuth ct-search <keyword> [--json] [--quiet] [--no-color]',
|
|
110
|
-
discover: 'whoisleuth discover <brand|domain> [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains]',
|
|
111
|
-
'discover-scan': 'whoisleuth discover-scan <brand|domain> [--fast|--deep] [--scan-limit <n>] [--chunk-size <n>] [--concurrency <n>] [--resolver <IPs>] [--allowlist <file>] [--checkpoint <file> [--resume]] [--observation-snapshot <file>] [--json|--jsonl|--csv|--domains]',
|
|
112
|
-
posture: 'whoisleuth posture <domain> [--selectors <list>] [--retired-selectors <list>] [--mail-profile <profile>] [--json] [--quiet] [--no-color]',
|
|
113
|
-
http: 'whoisleuth http <domain> [--json] [--quiet] [--no-color]',
|
|
114
|
-
tls: 'whoisleuth tls <hostname> [--json] [--quiet] [--no-color]',
|
|
115
|
-
'registry-support': 'whoisleuth registry-support <domain|suffix> [--json] [--quiet] [--no-color]',
|
|
116
|
-
'risk-calibrate': 'whoisleuth risk-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
|
|
117
|
-
'verify-artifact': 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
|
|
118
|
-
'inspect-archive': 'whoisleuth inspect-archive [archive.json] [--passphrase-file <file>] [--search <value>] [--require-match] [--reveal] [--json]',
|
|
119
|
-
'sign-artifact': 'whoisleuth sign-artifact [artifact.json] --private-key-file <file>',
|
|
120
|
-
'verify-signature': 'whoisleuth verify-signature [package.json] [--public-key-file <file>] [--json] [--quiet] [--no-color]',
|
|
121
|
-
'source-report': 'whoisleuth source-report [lookup.json] [--json] [--quiet] [--no-color]',
|
|
122
|
-
compare: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
|
|
123
|
-
'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
|
|
124
|
-
'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
|
|
125
|
-
'review-evidence': 'whoisleuth review-evidence [evidence.json] [--json] [--quiet] [--no-color]',
|
|
126
|
-
'domain-control': 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
|
|
127
|
-
'workflow-plan': 'whoisleuth workflow-plan <recipe> <domain|brand> [--json] [--quiet] [--no-color]',
|
|
128
|
-
diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
|
|
129
|
-
timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
|
|
130
|
-
export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
|
|
131
|
-
});
|
|
132
|
-
const COMMAND_DETAILS = Object.freeze({
|
|
133
|
-
completion: {
|
|
134
|
-
description: 'Print a static shell-completion script for the installed CLI.',
|
|
135
|
-
example: 'whoisleuth completion zsh > ~/.zfunc/_whoisleuth',
|
|
136
|
-
boundary: 'Generation is offline and writes only the script to stdout. The command never modifies shell configuration.',
|
|
137
|
-
},
|
|
138
|
-
doctor: {
|
|
139
|
-
description: 'Check the supported runtime and local terminal capabilities.',
|
|
140
|
-
example: 'whoisleuth doctor --json',
|
|
141
|
-
boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
|
|
142
|
-
},
|
|
143
|
-
commands: {
|
|
144
|
-
description: 'List the installed command contracts in terminal or versioned JSON form.',
|
|
145
|
-
example: 'whoisleuth commands --json',
|
|
146
|
-
boundary: 'Catalogue generation is offline. It reports declared command modes and limits without executing collection or inspecting local evidence.',
|
|
147
|
-
},
|
|
148
|
-
manual: {
|
|
149
|
-
description: 'Print a generated roff manual page for local installation.',
|
|
150
|
-
example: 'whoisleuth manual | man -l -',
|
|
151
|
-
boundary: 'Generation is offline and derives from the same command catalogue as focused help.',
|
|
152
|
-
},
|
|
153
|
-
lookup: {
|
|
154
|
-
description: 'Collect registration evidence for one domain, IP, or ASN.',
|
|
155
|
-
example: 'whoisleuth lookup example.test --deep',
|
|
156
|
-
boundary: 'Fast is the default. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable.',
|
|
157
|
-
},
|
|
158
|
-
bulk: {
|
|
159
|
-
description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
|
|
160
|
-
example: 'cat domains.txt | whoisleuth bulk --jsonl',
|
|
161
|
-
boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV.',
|
|
162
|
-
},
|
|
163
|
-
'ct-search': {
|
|
164
|
-
description: 'Search certificate-transparency observations for one bounded keyword.',
|
|
165
|
-
example: 'whoisleuth ct-search "example brand" --json',
|
|
166
|
-
boundary: 'Certificate observations do not prove website activity, registration ownership, or malicious intent.',
|
|
167
|
-
},
|
|
168
|
-
discover: {
|
|
169
|
-
description: 'Generate bounded lookalike-domain candidates from local mutation rules.',
|
|
170
|
-
example: 'whoisleuth discover example.test --preset common --jsonl',
|
|
171
|
-
boundary: 'Generation and optional local snapshot comparison are offline. Candidates are leads only and are not resolved, registered, or classified as malicious.',
|
|
172
|
-
},
|
|
173
|
-
'discover-scan': {
|
|
174
|
-
description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
|
|
175
|
-
example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
|
|
176
|
-
boundary: 'This command performs network collection. Fast compact lookup is the default; deep mode is capped at 50 candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution.',
|
|
177
|
-
},
|
|
178
|
-
posture: {
|
|
179
|
-
description: 'Review bounded DNS mail, delegation, and domain-control posture.',
|
|
180
|
-
example: 'whoisleuth posture example.test --mail-profile standard --json',
|
|
181
|
-
boundary: 'Missing or failed DNS observations remain inconclusive and are not reported as absent controls.',
|
|
182
|
-
},
|
|
183
|
-
http: {
|
|
184
|
-
description: 'Inspect one homepage request, redirects, and bounded response metadata.',
|
|
185
|
-
example: 'whoisleuth http example.test --json',
|
|
186
|
-
boundary: 'Requests use the shared public-address and redirect guards. This is not a rendered browser or vulnerability scan.',
|
|
187
|
-
},
|
|
188
|
-
tls: {
|
|
189
|
-
description: 'Inspect one hostname certificate through a bounded TLS connection.',
|
|
190
|
-
example: 'whoisleuth tls example.test --json',
|
|
191
|
-
boundary: 'One observed connection is point-in-time evidence and does not establish every address, edge, or historical certificate.',
|
|
192
|
-
},
|
|
193
|
-
'registry-support': {
|
|
194
|
-
description: 'Explain the local registry capability profile for one domain or suffix.',
|
|
195
|
-
example: 'whoisleuth registry-support example.test --json',
|
|
196
|
-
boundary: 'This command is offline. Catalogue coverage does not test live reachability or decide registration or availability.',
|
|
197
|
-
},
|
|
198
|
-
'risk-calibrate': {
|
|
199
|
-
description: 'Replay reviewed labels against the current explainable Risk model.',
|
|
200
|
-
example: 'whoisleuth risk-calibrate calibration.json --json',
|
|
201
|
-
boundary: 'Calibration is offline and diagnostic. It never trains, tunes, or changes the scoring model automatically.',
|
|
202
|
-
},
|
|
203
|
-
'verify-artifact': {
|
|
204
|
-
description: 'Validate a supported archive, packet, manifest, or saved Lookup without printing evidence contents.',
|
|
205
|
-
example: 'whoisleuth verify-artifact workspace.json --json',
|
|
206
|
-
boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file.',
|
|
207
|
-
},
|
|
208
|
-
'inspect-archive': {
|
|
209
|
-
description: 'Summarise or search one workspace archive with redacted output by default.',
|
|
210
|
-
example: 'whoisleuth inspect-archive workspace.json --search example.test --json',
|
|
211
|
-
boundary: 'Exact matches require --reveal. The archive is read locally and is never uploaded.',
|
|
212
|
-
},
|
|
213
|
-
'sign-artifact': {
|
|
214
|
-
description: 'Sign one reviewed response packet or supported manifest with a local private key.',
|
|
215
|
-
example: 'whoisleuth sign-artifact packet.json --private-key-file analyst-private.pem',
|
|
216
|
-
boundary: 'The command never creates, stores, or transmits keys. Key custody and signer identity remain the operator\'s responsibility.',
|
|
217
|
-
},
|
|
218
|
-
'verify-signature': {
|
|
219
|
-
description: 'Verify the integrity and signature of one signed evidence package.',
|
|
220
|
-
example: 'whoisleuth verify-signature packet.signed.json --json',
|
|
221
|
-
boundary: 'A valid signature proves package consistency for the embedded key, not the real-world identity or authority of its holder.',
|
|
222
|
-
},
|
|
223
|
-
'source-report': {
|
|
224
|
-
description: 'Create a target-free reliability summary from a saved lookup.',
|
|
225
|
-
example: 'whoisleuth source-report lookup.json --json',
|
|
226
|
-
boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
|
|
227
|
-
},
|
|
228
|
-
compare: {
|
|
229
|
-
description: 'Compare separately attributed registry publications in a saved lookup.',
|
|
230
|
-
example: 'whoisleuth compare lookup.json --json',
|
|
231
|
-
boundary: 'Comparison is offline. Differences are review context and do not by themselves prove which publication is current.',
|
|
232
|
-
},
|
|
233
|
-
'page-compare': {
|
|
234
|
-
description: 'Compare static page identity, favicon, technology, and TLS evidence in two saved deep lookups.',
|
|
235
|
-
example: 'whoisleuth page-compare official.json candidate.json --json',
|
|
236
|
-
boundary: 'Comparison is offline and component-based. It executes no page code and produces no aggregate similarity or maliciousness score.',
|
|
237
|
-
},
|
|
238
|
-
'mail-review': {
|
|
239
|
-
description: 'Review passive MX, null MX, SPF, DMARC, and shared mail-provider evidence from saved Bulk results.',
|
|
240
|
-
example: 'whoisleuth mail-review candidates.json --json',
|
|
241
|
-
boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
|
|
242
|
-
},
|
|
243
|
-
'review-evidence': {
|
|
244
|
-
description: 'Review one versioned DNSSEC, TLSA, RPKI, GeoIP, encrypted-DNS, or RDAP planning document offline.',
|
|
245
|
-
example: 'whoisleuth review-evidence dnssec-evidence.json --json',
|
|
246
|
-
boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, or SMTP request.',
|
|
247
|
-
},
|
|
248
|
-
'domain-control': {
|
|
249
|
-
description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
|
|
250
|
-
example: 'whoisleuth domain-control domain-control-input.json --json',
|
|
251
|
-
boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
|
|
252
|
-
},
|
|
253
|
-
'workflow-plan': {
|
|
254
|
-
description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
|
|
255
|
-
example: 'whoisleuth workflow-plan domain-triage example.test --json',
|
|
256
|
-
boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
|
|
257
|
-
},
|
|
258
|
-
diff: {
|
|
259
|
-
description: 'Compare bounded evidence retained in two saved domain lookups.',
|
|
260
|
-
example: 'whoisleuth diff first.json second.json --json',
|
|
261
|
-
boundary: 'Comparison is offline. Missing, unavailable, equal, and different evidence remain separate states.',
|
|
262
|
-
},
|
|
263
|
-
timeline: {
|
|
264
|
-
description: 'Build an ordered same-domain history from saved Lookup observations.',
|
|
265
|
-
example: 'whoisleuth timeline first.json second.json latest.json --json',
|
|
266
|
-
boundary: 'The command is offline, accepts 2 to 20 bounded inputs for one domain, retains no filenames or raw registry payloads, and does not treat changed collection conditions as a domain change.',
|
|
267
|
-
},
|
|
268
|
-
export: {
|
|
269
|
-
description: 'Convert one saved lookup into a versioned evidence report.',
|
|
270
|
-
example: 'whoisleuth export lookup.json --markdown',
|
|
271
|
-
boundary: 'Exports preserve source attribution and limitations. Compact output intentionally omits raw registry payloads.',
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
|
-
const COMMAND_COLLECTION = Object.freeze({
|
|
275
|
-
completion: { mode: 'offline', scope: 'Prints one static script and changes no shell configuration.' },
|
|
276
|
-
doctor: { mode: 'network', scope: 'Network access is opt-in with --network and is limited to fixed public DNS, HTTPS, and WHOIS diagnostics.' },
|
|
277
|
-
commands: { mode: 'offline', scope: 'Reads the embedded command catalogue and performs no collection.' },
|
|
278
|
-
manual: { mode: 'offline', scope: 'Builds documentation from the embedded command catalogue.' },
|
|
279
|
-
lookup: { mode: 'network', scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.' },
|
|
280
|
-
bulk: { mode: 'network', scope: 'Accepts at most 500 fast or 50 deep targets, with concurrency capped at 8 fast or 3 deep.' },
|
|
281
|
-
'ct-search': { mode: 'network', scope: 'Accepts one bounded search keyword and queries the fixed certificate-transparency source.' },
|
|
282
|
-
discover: { mode: 'offline', scope: 'Generates a bounded candidate set from local rules, dictionaries, and optional saved snapshots.' },
|
|
283
|
-
'discover-scan': { mode: 'network', scope: 'Scans at most 500 fast or 50 deep candidates, with concurrency capped at 8 fast or 3 deep.' },
|
|
284
|
-
posture: { mode: 'network', scope: 'Accepts one domain and performs bounded DNS queries only.' },
|
|
285
|
-
http: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
|
|
286
|
-
tls: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
|
|
287
|
-
'registry-support': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
|
|
288
|
-
'risk-calibrate': { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
|
|
289
|
-
'verify-artifact': { mode: 'offline', scope: 'Reads one selected bounded archive, packet, manifest, or saved Lookup document.' },
|
|
290
|
-
'inspect-archive': { mode: 'offline', scope: 'Reads one selected bounded workspace archive with redacted output by default.' },
|
|
291
|
-
'sign-artifact': { mode: 'offline', scope: 'Reads one selected artifact and one local private key without transmitting either.' },
|
|
292
|
-
'verify-signature': { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
|
|
293
|
-
'source-report': { mode: 'offline', scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
|
|
294
|
-
compare: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
|
|
295
|
-
'page-compare': { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
|
|
296
|
-
'mail-review': { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
|
|
297
|
-
'review-evidence': { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
|
|
298
|
-
'domain-control': { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
|
|
299
|
-
'workflow-plan': { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
|
|
300
|
-
diff: { mode: 'offline', scope: 'Reads two saved Lookup documents for different domains.' },
|
|
301
|
-
timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
|
|
302
|
-
export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
|
|
303
|
-
});
|
|
304
|
-
function commandHelp(command) {
|
|
305
|
-
const detail = COMMAND_DETAILS[command];
|
|
306
|
-
const collection = COMMAND_COLLECTION[command];
|
|
307
|
-
return `WHOISleuth ${command}\n${detail.description}\n\nUsage:\n ${COMMAND_USAGE[command]}\n\nExample:\n ${detail.example}\n\nCollection:\n ${collection.mode === 'offline' ? 'Offline' : 'Network'}: ${collection.scope}\n\nBoundary:\n ${detail.boundary}\n\nRun "whoisleuth --help" to see the grouped command list.\n`;
|
|
308
|
-
}
|
|
309
60
|
async function readStdinBounded(stream, limit = MAX_STDIN_BYTES) {
|
|
310
61
|
if (!stream || stream.isTTY)
|
|
311
62
|
return '';
|
|
@@ -437,6 +188,84 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
437
188
|
}));
|
|
438
189
|
return EXIT_CODES.SUCCESS;
|
|
439
190
|
}
|
|
191
|
+
if (args.action === 'manifest') {
|
|
192
|
+
failureLabel = 'Investigation manifest';
|
|
193
|
+
const artifacts = [];
|
|
194
|
+
try {
|
|
195
|
+
for (const source of args.sources) {
|
|
196
|
+
const content = dependencies.readDiffInput
|
|
197
|
+
? await dependencies.readDiffInput(source)
|
|
198
|
+
: await readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), { limit: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, label: 'Manifest artefact input' });
|
|
199
|
+
artifacts.push({ content });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error instanceof CliUsageError)
|
|
204
|
+
throw error;
|
|
205
|
+
throw new CliUsageError(`Could not read manifest artefact input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
206
|
+
}
|
|
207
|
+
let document;
|
|
208
|
+
try {
|
|
209
|
+
document = await buildInvestigationManifest({
|
|
210
|
+
workflow: args.workflow,
|
|
211
|
+
configurationDigestSha256: args.configurationDigestSha256,
|
|
212
|
+
artifacts,
|
|
213
|
+
}, commandContext.now(), VERSION);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Investigation manifest input is invalid'));
|
|
217
|
+
}
|
|
218
|
+
if (!args.quiet)
|
|
219
|
+
write(stdout, args.output === 'json'
|
|
220
|
+
? formatJsonDocument(document)
|
|
221
|
+
: terminal(formatInvestigationManifest(document), args.color));
|
|
222
|
+
return EXIT_CODES.SUCCESS;
|
|
223
|
+
}
|
|
224
|
+
if (args.action === 'map-observations' || args.action === 'oam-export') {
|
|
225
|
+
const mapping = args.action === 'map-observations';
|
|
226
|
+
failureLabel = mapping ? 'External observation mapping' : 'Open Asset Model export';
|
|
227
|
+
let input;
|
|
228
|
+
try {
|
|
229
|
+
input = dependencies.readArtifactInput
|
|
230
|
+
? await dependencies.readArtifactInput(args.source)
|
|
231
|
+
: await readSavedLookupInputBounded(args.source
|
|
232
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
233
|
+
: dependencies.stdin || process.stdin, {
|
|
234
|
+
limit: MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES,
|
|
235
|
+
label: mapping ? 'External observation mapping input' : 'Open Asset Model bridge input',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error instanceof CliUsageError)
|
|
240
|
+
throw error;
|
|
241
|
+
throw new CliUsageError(`Could not read ${mapping ? 'observation mapping' : 'asset bridge'} input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
242
|
+
}
|
|
243
|
+
if (!input.trim())
|
|
244
|
+
throw new CliUsageError(`${args.action} requires one versioned JSON file or a document on stdin.`);
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(input);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
throw new CliUsageError(`${mapping ? 'External observation mapping' : 'Open Asset Model bridge'} input is not valid JSON.`);
|
|
251
|
+
}
|
|
252
|
+
let document;
|
|
253
|
+
try {
|
|
254
|
+
document = mapping
|
|
255
|
+
? mapExternalObservations(parsed)
|
|
256
|
+
: buildOpenAssetModelBridge(parsed, commandContext.now());
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
throw new CliUsageError(boundedCliErrorMessage(error, `${mapping ? 'External observation mapping' : 'Open Asset Model bridge'} input is invalid`));
|
|
260
|
+
}
|
|
261
|
+
if (!args.quiet)
|
|
262
|
+
write(stdout, args.output === 'json'
|
|
263
|
+
? formatJsonDocument(document)
|
|
264
|
+
: terminal(mapping
|
|
265
|
+
? formatExternalObservationMapping(document)
|
|
266
|
+
: formatOpenAssetModelBridge(document), args.color));
|
|
267
|
+
return EXIT_CODES.SUCCESS;
|
|
268
|
+
}
|
|
440
269
|
if (args.action === 'doctor') {
|
|
441
270
|
failureLabel = 'CLI diagnostics';
|
|
442
271
|
const buildReport = () => buildDoctorReport({
|
|
@@ -445,7 +274,7 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
445
274
|
network: args.network,
|
|
446
275
|
presentation: terminalPresentation(stdout, args.color, environment),
|
|
447
276
|
...(dependencies.resolvePublicAddresses ? { resolveAddresses: dependencies.resolvePublicAddresses } : {}),
|
|
448
|
-
...(dependencies.safeFetch ? { fetchHttps: dependencies.safeFetch } : {
|
|
277
|
+
...(dependencies.safeFetch ? { fetchHttps: dependencies.safeFetch } : {}),
|
|
449
278
|
...(dependencies.whoisQuery ? { queryWhois: dependencies.whoisQuery } : {}),
|
|
450
279
|
});
|
|
451
280
|
const report = args.network
|
|
@@ -475,6 +304,70 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
475
304
|
write(stdout, args.output === 'json' ? formatJsonDocument(document) : terminal(formatTerminalRegistrySupport(document), args.color));
|
|
476
305
|
return EXIT_CODES.SUCCESS;
|
|
477
306
|
}
|
|
307
|
+
if (args.action === 'registry-doctor') {
|
|
308
|
+
failureLabel = 'Registry compatibility diagnostic';
|
|
309
|
+
let input;
|
|
310
|
+
try {
|
|
311
|
+
input = dependencies.readCompareInput
|
|
312
|
+
? await dependencies.readCompareInput(args.source)
|
|
313
|
+
: await readSavedLookupInputBounded(args.source
|
|
314
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
315
|
+
: dependencies.stdin || process.stdin, {
|
|
316
|
+
limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
|
|
317
|
+
label: 'Registry doctor input',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (error instanceof CliUsageError)
|
|
322
|
+
throw error;
|
|
323
|
+
throw new CliUsageError(`Could not read registry doctor input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
324
|
+
}
|
|
325
|
+
if (!input.trim())
|
|
326
|
+
throw new CliUsageError('registry-doctor requires one saved Lookup JSON file or a document on stdin.');
|
|
327
|
+
const report = buildRegistryDoctorReport(input, commandContext.now());
|
|
328
|
+
if (!args.quiet)
|
|
329
|
+
write(stdout, args.output === 'json'
|
|
330
|
+
? formatJsonDocument(report)
|
|
331
|
+
: terminal(formatRegistryDoctorReport(report), args.color));
|
|
332
|
+
return report.summary.investigate ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
333
|
+
}
|
|
334
|
+
if (args.action === 'registry-cohort') {
|
|
335
|
+
failureLabel = 'Registry quality cohort';
|
|
336
|
+
let input;
|
|
337
|
+
try {
|
|
338
|
+
input = dependencies.readCompareInput
|
|
339
|
+
? await dependencies.readCompareInput(args.source)
|
|
340
|
+
: await readSavedLookupInputBounded(args.source
|
|
341
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
342
|
+
: dependencies.stdin || process.stdin, {
|
|
343
|
+
limit: MAX_REGISTRY_COHORT_INPUT_BYTES,
|
|
344
|
+
label: 'Registry cohort input',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (error instanceof CliUsageError)
|
|
349
|
+
throw error;
|
|
350
|
+
throw new CliUsageError(`Could not read registry cohort input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
351
|
+
}
|
|
352
|
+
const report = buildRegistryCohortReport(input, commandContext.now());
|
|
353
|
+
if (!args.quiet)
|
|
354
|
+
write(stdout, args.output === 'json'
|
|
355
|
+
? formatJsonDocument(report)
|
|
356
|
+
: terminal(formatRegistryCohortReport(report), args.color));
|
|
357
|
+
return report.cohorts.some((cohort) => cohort.state === 'review')
|
|
358
|
+
? EXIT_CODES.PARTIAL_FAILURE
|
|
359
|
+
: EXIT_CODES.SUCCESS;
|
|
360
|
+
}
|
|
361
|
+
if (args.action === 'registry-scaffold') {
|
|
362
|
+
failureLabel = 'Registry fixture scaffold';
|
|
363
|
+
try {
|
|
364
|
+
write(stdout, buildRegistryFixtureScaffold(args.profile, args.suffix, args.scenario));
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Registry fixture scaffold failed'));
|
|
368
|
+
}
|
|
369
|
+
return EXIT_CODES.SUCCESS;
|
|
370
|
+
}
|
|
478
371
|
if (args.action === 'risk-calibrate') {
|
|
479
372
|
failureLabel = 'Risk calibration';
|
|
480
373
|
let input;
|
|
@@ -497,13 +390,50 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
497
390
|
generatedAt: dependencies.now ? dependencies.now() : new Date().toISOString(),
|
|
498
391
|
modelVersion: dependencies.riskModelVersion || RISK_MODEL_VERSION,
|
|
499
392
|
reviewThreshold: dependencies.riskReviewThreshold || RISK_REVIEW_THRESHOLD,
|
|
393
|
+
...(!dependencies.explainRiskScore ? {
|
|
394
|
+
previousModelVersion: 6,
|
|
395
|
+
explainPreviousRiskScore: explainRiskScoreV6,
|
|
396
|
+
} : {}),
|
|
500
397
|
});
|
|
501
398
|
if (!args.quiet)
|
|
502
399
|
write(stdout, args.output === 'json' ? formatJsonDocument(report) : terminal(formatTerminalRiskCalibration(report), args.color));
|
|
503
400
|
return EXIT_CODES.SUCCESS;
|
|
504
401
|
}
|
|
402
|
+
if (args.action === 'lookalike-calibrate') {
|
|
403
|
+
failureLabel = 'Lookalike review-yield calibration';
|
|
404
|
+
let input;
|
|
405
|
+
try {
|
|
406
|
+
input = dependencies.readRiskCalibrationInput
|
|
407
|
+
? await dependencies.readRiskCalibrationInput(args.source)
|
|
408
|
+
: await readSavedLookupInputBounded(args.source
|
|
409
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
410
|
+
: dependencies.stdin || process.stdin, {
|
|
411
|
+
limit: MAX_LOOKALIKE_CALIBRATION_BYTES,
|
|
412
|
+
label: 'Lookalike calibration input',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (error instanceof CliUsageError)
|
|
417
|
+
throw error;
|
|
418
|
+
throw new CliUsageError(`Could not read lookalike calibration input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
419
|
+
}
|
|
420
|
+
if (!input.trim())
|
|
421
|
+
throw new CliUsageError('lookalike-calibrate requires one dataset JSON file or a document on stdin.');
|
|
422
|
+
let report;
|
|
423
|
+
try {
|
|
424
|
+
report = buildLookalikeCalibration(input, commandContext.now());
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Lookalike calibration input is invalid'));
|
|
428
|
+
}
|
|
429
|
+
if (!args.quiet)
|
|
430
|
+
write(stdout, args.output === 'json'
|
|
431
|
+
? formatJsonDocument(report)
|
|
432
|
+
: terminal(formatLookalikeCalibration(report), args.color));
|
|
433
|
+
return EXIT_CODES.SUCCESS;
|
|
434
|
+
}
|
|
505
435
|
if (args.action === 'verify-artifact') {
|
|
506
|
-
failureLabel = '
|
|
436
|
+
failureLabel = 'Artefact verification';
|
|
507
437
|
let input;
|
|
508
438
|
try {
|
|
509
439
|
input = dependencies.readArtifactInput
|
|
@@ -512,7 +442,7 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
512
442
|
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
513
443
|
: dependencies.stdin || process.stdin, {
|
|
514
444
|
limit: MAX_OFFLINE_ARTIFACT_BYTES,
|
|
515
|
-
label: '
|
|
445
|
+
label: 'Artefact input',
|
|
516
446
|
});
|
|
517
447
|
}
|
|
518
448
|
catch (error) {
|
|
@@ -521,7 +451,7 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
521
451
|
throw new CliUsageError(`Could not read artifact input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
522
452
|
}
|
|
523
453
|
if (!input.trim())
|
|
524
|
-
throw new CliUsageError('verify-artifact requires one JSON file or an
|
|
454
|
+
throw new CliUsageError('verify-artifact requires one JSON file or an artefact on stdin.');
|
|
525
455
|
let passphrase = null;
|
|
526
456
|
if (args.passphraseSource) {
|
|
527
457
|
let passphraseText;
|
|
@@ -692,11 +622,63 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
692
622
|
}
|
|
693
623
|
if (!input.trim())
|
|
694
624
|
throw new CliUsageError('review-evidence requires one JSON file or a document on stdin.');
|
|
695
|
-
const document =
|
|
625
|
+
const document = args.mmdbSource
|
|
626
|
+
? await buildOfflineEvidenceReviewWithLocalResources(input, commandContext.now(), { mmdbPath: args.mmdbSource })
|
|
627
|
+
: buildOfflineEvidenceReview(input, commandContext.now());
|
|
696
628
|
if (!args.quiet)
|
|
697
629
|
write(stdout, args.output === 'json'
|
|
698
630
|
? formatJsonDocument(document)
|
|
699
631
|
: terminal(formatOfflineEvidenceReview(document), args.color));
|
|
632
|
+
if (args.strictExit) {
|
|
633
|
+
const result = document.result && typeof document.result === 'object' && !Array.isArray(document.result)
|
|
634
|
+
? document.result
|
|
635
|
+
: {};
|
|
636
|
+
const gate = result.gate && typeof result.gate === 'object' && !Array.isArray(result.gate)
|
|
637
|
+
? result.gate
|
|
638
|
+
: null;
|
|
639
|
+
const zoneMismatch = document.kind === 'zone_intent' && (result.complete !== true
|
|
640
|
+
|| (result.counts && typeof result.counts === 'object' && !Array.isArray(result.counts)
|
|
641
|
+
&& ['different', 'missing', 'unexpected', 'incomplete'].some((key) => Number(result.counts[key]) > 0)));
|
|
642
|
+
if (gate?.pass === false || zoneMismatch)
|
|
643
|
+
return EXIT_CODES.PARTIAL_FAILURE;
|
|
644
|
+
}
|
|
645
|
+
return EXIT_CODES.SUCCESS;
|
|
646
|
+
}
|
|
647
|
+
if (args.action === 'brief' || args.action === 'case-pack') {
|
|
648
|
+
const isBrief = args.action === 'brief';
|
|
649
|
+
failureLabel = isBrief ? 'Lookup brief' : 'Case pack';
|
|
650
|
+
let input;
|
|
651
|
+
try {
|
|
652
|
+
input = dependencies.readArtifactInput
|
|
653
|
+
? await dependencies.readArtifactInput(args.source)
|
|
654
|
+
: await readSavedLookupInputBounded(args.source
|
|
655
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
656
|
+
: dependencies.stdin || process.stdin, {
|
|
657
|
+
limit: isBrief ? MAX_SAVED_LOOKUP_INPUT_BYTES : 4 * 1024 * 1024,
|
|
658
|
+
label: isBrief ? 'Lookup brief input' : 'Case-pack input',
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
if (error instanceof CliUsageError)
|
|
663
|
+
throw error;
|
|
664
|
+
throw new CliUsageError(`Could not read ${isBrief ? 'Lookup brief' : 'case-pack'} input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
665
|
+
}
|
|
666
|
+
if (!input.trim())
|
|
667
|
+
throw new CliUsageError(`${args.action} requires one JSON file or a document on stdin.`);
|
|
668
|
+
if (args.action === 'brief') {
|
|
669
|
+
const document = buildCliLookupBrief(input, commandContext.now());
|
|
670
|
+
if (!args.quiet)
|
|
671
|
+
write(stdout, args.output === 'json'
|
|
672
|
+
? formatJsonDocument(document)
|
|
673
|
+
: terminal(formatCliLookupBrief(document), args.color));
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
const document = buildCliCasePack(input, { audience: args.audience, reviewed: args.reviewed }, commandContext.now());
|
|
677
|
+
if (!args.quiet)
|
|
678
|
+
write(stdout, args.output === 'json'
|
|
679
|
+
? formatJsonDocument(document)
|
|
680
|
+
: terminal(formatCliCasePack(document), args.color));
|
|
681
|
+
}
|
|
700
682
|
return EXIT_CODES.SUCCESS;
|
|
701
683
|
}
|
|
702
684
|
if (args.action === 'domain-control') {
|
|
@@ -733,16 +715,201 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
733
715
|
? buildDomainControlManifest(parsed, commandContext.now())
|
|
734
716
|
: schema === DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
|
|
735
717
|
? reviewDomainControlManifest(parsed, commandContext.now())
|
|
736
|
-
:
|
|
718
|
+
: schema === CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
|
|
719
|
+
? buildCliDomainControlReview(input, commandContext.now())
|
|
720
|
+
: schema === DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA
|
|
721
|
+
? buildDomainControlFlightRecorder(parsed, commandContext.now())
|
|
722
|
+
: null;
|
|
737
723
|
if (!document) {
|
|
738
|
-
throw new CliUsageError(`Domain control input must use
|
|
724
|
+
throw new CliUsageError(`Domain control input must use a supported manifest, review, saved-Lookup review, or flight-recorder schema.`);
|
|
739
725
|
}
|
|
726
|
+
const terminalDocument = schema === CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
|
|
727
|
+
? formatCliDomainControlReview(document)
|
|
728
|
+
: schema === DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA
|
|
729
|
+
? formatDomainControlFlightRecorder(document)
|
|
730
|
+
: formatDomainControlResult(document);
|
|
740
731
|
if (!args.quiet)
|
|
741
732
|
write(stdout, args.output === 'json'
|
|
742
733
|
? formatJsonDocument(document)
|
|
743
|
-
: terminal(
|
|
734
|
+
: terminal(terminalDocument, args.color));
|
|
744
735
|
return EXIT_CODES.SUCCESS;
|
|
745
736
|
}
|
|
737
|
+
if (args.action === 'monitor-once') {
|
|
738
|
+
failureLabel = 'One-shot domain control review';
|
|
739
|
+
let manifestInput;
|
|
740
|
+
let previousInput = null;
|
|
741
|
+
try {
|
|
742
|
+
manifestInput = dependencies.readArtifactInput
|
|
743
|
+
? await dependencies.readArtifactInput(args.source)
|
|
744
|
+
: await readSavedLookupInputBounded(args.source
|
|
745
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
746
|
+
: dependencies.stdin || process.stdin, {
|
|
747
|
+
limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
|
|
748
|
+
label: 'Domain-control manifest input',
|
|
749
|
+
});
|
|
750
|
+
if (args.previousSource) {
|
|
751
|
+
previousInput = dependencies.readDiffInput
|
|
752
|
+
? await dependencies.readDiffInput(args.previousSource)
|
|
753
|
+
: await readSavedLookupInputBounded(createReadStream(args.previousSource, { highWaterMark: 64 * 1024 }), {
|
|
754
|
+
limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
|
|
755
|
+
label: 'Prior monitor snapshot',
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
if (error instanceof CliUsageError)
|
|
761
|
+
throw error;
|
|
762
|
+
throw new CliUsageError(`Could not read monitor input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
763
|
+
}
|
|
764
|
+
if (!manifestInput.trim())
|
|
765
|
+
throw new CliUsageError('monitor-once requires one domain-control manifest file or a document on stdin.');
|
|
766
|
+
const executeLookup = dependencies.runUnifiedLookup || (await import("../lib/lookup.mjs")).runUnifiedLookup;
|
|
767
|
+
const progress = commandContext.beginProgress('Collecting bounded domain-control evidence');
|
|
768
|
+
let document;
|
|
769
|
+
try {
|
|
770
|
+
document = await runDomainControlMonitor(manifestInput, previousInput, {
|
|
771
|
+
executeLookup,
|
|
772
|
+
now: commandContext.now,
|
|
773
|
+
limit: args.limit,
|
|
774
|
+
concurrency: args.concurrency,
|
|
775
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
776
|
+
onSettled: (completed, total) => progress.update(`Collected ${completed} of ${total} owned domains`),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
commandContext.endProgress();
|
|
781
|
+
}
|
|
782
|
+
if (!args.quiet)
|
|
783
|
+
write(stdout, args.output === 'json'
|
|
784
|
+
? formatJsonDocument(document)
|
|
785
|
+
: args.output === 'junit'
|
|
786
|
+
? formatCliJunit(document)
|
|
787
|
+
: terminal(formatDomainControlMonitor(document), args.color));
|
|
788
|
+
const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
|
|
789
|
+
if (policyFindings.length)
|
|
790
|
+
write(stderr, formatFailPolicyNotice(policyFindings));
|
|
791
|
+
return document.collection.failed || policyFindings.length ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
792
|
+
}
|
|
793
|
+
if (args.action === 'assurance') {
|
|
794
|
+
failureLabel = 'Domain assurance review';
|
|
795
|
+
let input;
|
|
796
|
+
try {
|
|
797
|
+
input = dependencies.readArtifactInput
|
|
798
|
+
? await dependencies.readArtifactInput(args.source)
|
|
799
|
+
: await readSavedLookupInputBounded(args.source
|
|
800
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
801
|
+
: dependencies.stdin || process.stdin, {
|
|
802
|
+
limit: MAX_ASSURANCE_INPUT_BYTES,
|
|
803
|
+
label: 'Domain assurance input',
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
if (error instanceof CliUsageError)
|
|
808
|
+
throw error;
|
|
809
|
+
throw new CliUsageError(`Could not read domain assurance input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
810
|
+
}
|
|
811
|
+
if (!input.trim())
|
|
812
|
+
throw new CliUsageError('assurance requires one versioned JSON file or a document on stdin.');
|
|
813
|
+
let parsed;
|
|
814
|
+
try {
|
|
815
|
+
parsed = JSON.parse(input);
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
throw new CliUsageError('Domain assurance input is not valid JSON.');
|
|
819
|
+
}
|
|
820
|
+
let document;
|
|
821
|
+
try {
|
|
822
|
+
document = buildDomainAssurance(parsed, commandContext.now());
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Domain assurance input is invalid'));
|
|
826
|
+
}
|
|
827
|
+
if (!args.quiet)
|
|
828
|
+
write(stdout, args.output === 'json'
|
|
829
|
+
? formatJsonDocument(document)
|
|
830
|
+
: terminal(formatDomainAssurance(document), args.color));
|
|
831
|
+
return EXIT_CODES.SUCCESS;
|
|
832
|
+
}
|
|
833
|
+
if (args.action === 'change-packet') {
|
|
834
|
+
failureLabel = 'Domain change packet';
|
|
835
|
+
let input;
|
|
836
|
+
try {
|
|
837
|
+
input = dependencies.readArtifactInput
|
|
838
|
+
? await dependencies.readArtifactInput(args.source)
|
|
839
|
+
: await readSavedLookupInputBounded(args.source
|
|
840
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
841
|
+
: dependencies.stdin || process.stdin, {
|
|
842
|
+
limit: MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES,
|
|
843
|
+
label: 'Domain change packet input',
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
if (error instanceof CliUsageError)
|
|
848
|
+
throw error;
|
|
849
|
+
throw new CliUsageError(`Could not read domain change packet input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
850
|
+
}
|
|
851
|
+
if (!input.trim())
|
|
852
|
+
throw new CliUsageError('change-packet requires one versioned JSON file or a document on stdin.');
|
|
853
|
+
let parsed;
|
|
854
|
+
try {
|
|
855
|
+
parsed = JSON.parse(input);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
throw new CliUsageError('Domain change packet input is not valid JSON.');
|
|
859
|
+
}
|
|
860
|
+
let document;
|
|
861
|
+
try {
|
|
862
|
+
document = await buildDomainChangePacket(parsed, commandContext.now());
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Domain change packet input is invalid'));
|
|
866
|
+
}
|
|
867
|
+
if (!args.quiet)
|
|
868
|
+
write(stdout, args.output === 'json'
|
|
869
|
+
? formatJsonDocument(document)
|
|
870
|
+
: terminal(formatDomainChangePacket(document), args.color));
|
|
871
|
+
return document.gate.pass ? EXIT_CODES.SUCCESS : EXIT_CODES.PARTIAL_FAILURE;
|
|
872
|
+
}
|
|
873
|
+
if (args.action === 'sharing-review') {
|
|
874
|
+
failureLabel = 'Evidence sharing review';
|
|
875
|
+
let input;
|
|
876
|
+
try {
|
|
877
|
+
input = dependencies.readArtifactInput
|
|
878
|
+
? await dependencies.readArtifactInput(args.source)
|
|
879
|
+
: await readSavedLookupInputBounded(args.source
|
|
880
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
881
|
+
: dependencies.stdin || process.stdin, {
|
|
882
|
+
limit: MAX_SHARING_REVIEW_BYTES,
|
|
883
|
+
label: 'Sharing review input',
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
if (error instanceof CliUsageError)
|
|
888
|
+
throw error;
|
|
889
|
+
throw new CliUsageError(`Could not read sharing review input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
890
|
+
}
|
|
891
|
+
if (!input.trim())
|
|
892
|
+
throw new CliUsageError('sharing-review requires one artefact JSON file or a document on stdin.');
|
|
893
|
+
let document;
|
|
894
|
+
try {
|
|
895
|
+
document = await buildSharingReview(input, {
|
|
896
|
+
marking: args.marking,
|
|
897
|
+
recipientScope: args.recipientScope,
|
|
898
|
+
purpose: args.purpose,
|
|
899
|
+
humanReviewed: args.humanReviewed,
|
|
900
|
+
personalDataReviewed: args.personalDataReviewed,
|
|
901
|
+
redactionsConfirmed: args.redactionsConfirmed,
|
|
902
|
+
}, commandContext.now());
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Sharing review input is invalid'));
|
|
906
|
+
}
|
|
907
|
+
if (!args.quiet)
|
|
908
|
+
write(stdout, args.output === 'json'
|
|
909
|
+
? formatJsonDocument(document)
|
|
910
|
+
: terminal(formatSharingReview(document), args.color));
|
|
911
|
+
return document.summary.status === 'blocked' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
912
|
+
}
|
|
746
913
|
if (args.action === 'workflow-plan') {
|
|
747
914
|
failureLabel = 'Investigation plan';
|
|
748
915
|
const document = buildInvestigationPlan(args.recipe, args.subject, commandContext.now());
|
|
@@ -752,6 +919,45 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
752
919
|
: terminal(formatInvestigationPlan(document), args.color));
|
|
753
920
|
return EXIT_CODES.SUCCESS;
|
|
754
921
|
}
|
|
922
|
+
if (args.action === 'workflow-run') {
|
|
923
|
+
failureLabel = 'Investigation workflow';
|
|
924
|
+
let resumeInput = null;
|
|
925
|
+
if (args.resumeSource) {
|
|
926
|
+
try {
|
|
927
|
+
resumeInput = dependencies.readDiffInput
|
|
928
|
+
? await dependencies.readDiffInput(args.resumeSource)
|
|
929
|
+
: await readSavedLookupInputBounded(createReadStream(args.resumeSource, { highWaterMark: 64 * 1024 }), {
|
|
930
|
+
limit: 24 * 1024 * 1024,
|
|
931
|
+
label: 'Investigation resume state',
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
if (error instanceof CliUsageError)
|
|
936
|
+
throw error;
|
|
937
|
+
throw new CliUsageError(`Could not read investigation resume state: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const document = await runInvestigationRecipe(args.recipe, args.subject, {
|
|
941
|
+
approveNetwork: args.approveNetwork,
|
|
942
|
+
resumeInput,
|
|
943
|
+
generatedAt: commandContext.now(),
|
|
944
|
+
execute: async (command, stepArguments) => {
|
|
945
|
+
const stepStdout = createBufferedOutput();
|
|
946
|
+
const stepStderr = createBufferedOutput();
|
|
947
|
+
const exitCode = await runCli([command, ...stepArguments], {
|
|
948
|
+
...dependencies,
|
|
949
|
+
stdout: stepStdout.stream,
|
|
950
|
+
stderr: stepStderr.stream,
|
|
951
|
+
});
|
|
952
|
+
return { exitCode, stdout: stepStdout.value() };
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
if (!args.quiet)
|
|
956
|
+
write(stdout, args.output === 'json'
|
|
957
|
+
? formatJsonDocument(document)
|
|
958
|
+
: terminal(formatInvestigationRun(document), args.color));
|
|
959
|
+
return document.state === 'step_failed' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
|
|
960
|
+
}
|
|
755
961
|
if (args.action === 'diff') {
|
|
756
962
|
failureLabel = 'Lookup evidence diff';
|
|
757
963
|
const readDiffInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
|
|
@@ -778,6 +984,36 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
778
984
|
: terminal(formatCliLookupDiff(document), args.color));
|
|
779
985
|
return EXIT_CODES.SUCCESS;
|
|
780
986
|
}
|
|
987
|
+
if (args.action === 'reconcile') {
|
|
988
|
+
failureLabel = 'Lookup observation reconciliation';
|
|
989
|
+
const readReconciliationInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
|
|
990
|
+
limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
|
|
991
|
+
label: 'Lookup reconciliation input',
|
|
992
|
+
})));
|
|
993
|
+
const inputs = [];
|
|
994
|
+
let totalBytes = 0;
|
|
995
|
+
try {
|
|
996
|
+
for (const source of args.sources) {
|
|
997
|
+
const input = await readReconciliationInput(source);
|
|
998
|
+
totalBytes += Buffer.byteLength(input, 'utf8');
|
|
999
|
+
if (totalBytes > MAX_LOOKUP_RECONCILIATION_INPUT_BYTES) {
|
|
1000
|
+
throw new CliUsageError(`Lookup reconciliation input is limited to ${MAX_LOOKUP_RECONCILIATION_INPUT_BYTES} bytes in total.`);
|
|
1001
|
+
}
|
|
1002
|
+
inputs.push(input);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
if (error instanceof CliUsageError)
|
|
1007
|
+
throw error;
|
|
1008
|
+
throw new CliUsageError(`Could not read Lookup reconciliation input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
1009
|
+
}
|
|
1010
|
+
const document = buildCliLookupReconciliation(inputs, commandContext.now());
|
|
1011
|
+
if (!args.quiet)
|
|
1012
|
+
write(stdout, args.output === 'json'
|
|
1013
|
+
? formatJsonDocument(document)
|
|
1014
|
+
: terminal(formatCliLookupReconciliation(document), args.color));
|
|
1015
|
+
return EXIT_CODES.SUCCESS;
|
|
1016
|
+
}
|
|
781
1017
|
if (args.action === 'timeline') {
|
|
782
1018
|
failureLabel = 'Lookup observation timeline';
|
|
783
1019
|
const readTimelineInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
|
|
@@ -842,16 +1078,59 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
842
1078
|
}
|
|
843
1079
|
if (args.action === 'bulk') {
|
|
844
1080
|
failureLabel = 'Bulk lookup';
|
|
1081
|
+
const { runBulkCommand } = await import("./bulk-command-runner.mjs");
|
|
845
1082
|
return await runBulkCommand(args, dependencies, commandContext);
|
|
846
1083
|
}
|
|
847
1084
|
if (args.action === 'discover') {
|
|
848
1085
|
failureLabel = 'Candidate generation';
|
|
1086
|
+
const { runDiscoveryCommand } = await import("./discovery-command-runner.mjs");
|
|
849
1087
|
return await runDiscoveryCommand(args, dependencies, commandContext);
|
|
850
1088
|
}
|
|
851
1089
|
if (args.action === 'discover-scan') {
|
|
852
1090
|
failureLabel = 'Candidate scan';
|
|
1091
|
+
const { runDiscoveryScanCommand } = await import("./discovery-scan-command-runner.mjs");
|
|
853
1092
|
return await runDiscoveryScanCommand(args, dependencies, commandContext);
|
|
854
1093
|
}
|
|
1094
|
+
if (args.action === 'ct-intake') {
|
|
1095
|
+
failureLabel = 'Certificate event intake';
|
|
1096
|
+
let input;
|
|
1097
|
+
try {
|
|
1098
|
+
input = dependencies.readArtifactInput
|
|
1099
|
+
? await dependencies.readArtifactInput(args.source)
|
|
1100
|
+
: await readSavedLookupInputBounded(args.source
|
|
1101
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
1102
|
+
: dependencies.stdin || process.stdin, {
|
|
1103
|
+
limit: MAX_CT_EVENT_INPUT_BYTES,
|
|
1104
|
+
label: 'Certificate event input',
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
catch (error) {
|
|
1108
|
+
if (error instanceof CliUsageError)
|
|
1109
|
+
throw error;
|
|
1110
|
+
throw new CliUsageError(`Could not read certificate event input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
1111
|
+
}
|
|
1112
|
+
if (!input.trim())
|
|
1113
|
+
throw new CliUsageError('ct-intake requires one versioned JSON file or a document on stdin.');
|
|
1114
|
+
let parsed;
|
|
1115
|
+
try {
|
|
1116
|
+
parsed = JSON.parse(input);
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
throw new CliUsageError('Certificate event input is not valid JSON.');
|
|
1120
|
+
}
|
|
1121
|
+
let document;
|
|
1122
|
+
try {
|
|
1123
|
+
document = buildCtEventFindings(parsed);
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Certificate event input is invalid'));
|
|
1127
|
+
}
|
|
1128
|
+
if (!args.quiet)
|
|
1129
|
+
write(stdout, args.output === 'json'
|
|
1130
|
+
? formatJsonDocument(document)
|
|
1131
|
+
: terminal(formatCtEventFindings(document), args.color));
|
|
1132
|
+
return EXIT_CODES.SUCCESS;
|
|
1133
|
+
}
|
|
855
1134
|
if (args.action === 'ct-search'
|
|
856
1135
|
|| args.action === 'posture'
|
|
857
1136
|
|| args.action === 'http'
|
|
@@ -863,8 +1142,10 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
863
1142
|
: args.action === 'http'
|
|
864
1143
|
? 'HTTP probe'
|
|
865
1144
|
: 'TLS intelligence';
|
|
1145
|
+
const { runNetworkCommand } = await import("./network-command-runner.mjs");
|
|
866
1146
|
return await runNetworkCommand(args, dependencies, commandContext);
|
|
867
1147
|
}
|
|
1148
|
+
const { runLookupCommand } = await import("./lookup-command-runner.mjs");
|
|
868
1149
|
return await runLookupCommand(args, dependencies, commandContext);
|
|
869
1150
|
}
|
|
870
1151
|
catch (error) {
|