@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { domainToASCII } from 'node:url';
|
|
2
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
3
|
+
import { INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES, cliInvocationNetworkEffect, } from "./command-reference.mjs";
|
|
2
4
|
export const CLI_INVESTIGATION_PLAN_SCHEMA = 'whoisleuth.cli.investigation-plan';
|
|
3
5
|
export const CLI_INVESTIGATION_PLAN_VERSION = 1;
|
|
4
|
-
export const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
'owned-domain-review',
|
|
8
|
-
'historical-comparison',
|
|
9
|
-
];
|
|
6
|
+
export const CLI_WORKFLOW_RECIPE_CATALOGUE_SCHEMA = 'whoisleuth.cli.workflow-recipe-catalogue';
|
|
7
|
+
export const CLI_WORKFLOW_RECIPE_CATALOGUE_VERSION = 1;
|
|
8
|
+
export { INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES };
|
|
10
9
|
const RECIPES = Object.freeze({
|
|
11
10
|
'domain-triage': Object.freeze({
|
|
11
|
+
id: 'domain-triage',
|
|
12
12
|
label: 'New domain triage',
|
|
13
|
-
|
|
14
|
-
objective: 'Collect and preserve
|
|
13
|
+
subjectRequirement: 'domain',
|
|
14
|
+
objective: 'Collect and preserve separately attributed registration, DNS, HTTP, TLS, page, and network-context evidence.',
|
|
15
|
+
runnable: true,
|
|
16
|
+
limitations: Object.freeze(['Collection remains analyst-triggered and source limitations remain explicit.']),
|
|
15
17
|
steps: (domain) => Object.freeze([
|
|
16
18
|
step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
|
|
17
19
|
step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
|
|
@@ -19,9 +21,12 @@ const RECIPES = Object.freeze({
|
|
|
19
21
|
]),
|
|
20
22
|
}),
|
|
21
23
|
'lookalike-review': Object.freeze({
|
|
24
|
+
id: 'lookalike-review',
|
|
22
25
|
label: 'Lookalike candidate review',
|
|
23
|
-
|
|
26
|
+
subjectRequirement: 'brand_or_domain',
|
|
24
27
|
objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
|
|
28
|
+
runnable: true,
|
|
29
|
+
limitations: Object.freeze(['Candidate generation does not establish registration, control, intent, or maliciousness.']),
|
|
25
30
|
steps: (subject) => Object.freeze([
|
|
26
31
|
step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
|
|
27
32
|
step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
|
|
@@ -29,9 +34,12 @@ const RECIPES = Object.freeze({
|
|
|
29
34
|
]),
|
|
30
35
|
}),
|
|
31
36
|
'owned-domain-review': Object.freeze({
|
|
37
|
+
id: 'owned-domain-review',
|
|
32
38
|
label: 'Owned domain posture review',
|
|
33
|
-
|
|
39
|
+
subjectRequirement: 'domain',
|
|
34
40
|
objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
|
|
41
|
+
runnable: true,
|
|
42
|
+
limitations: Object.freeze(['Use only for a domain the analyst owns or is authorised to review.']),
|
|
35
43
|
steps: (domain) => Object.freeze([
|
|
36
44
|
step('posture', 'Collect bounded DNS posture', 'posture', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.posture', 'Review mail profile and delegation evidence before interpreting missing records.'),
|
|
37
45
|
step('lookup', 'Collect supporting Deep evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain separately attributed registration, DNS, TLS, and page observations.'),
|
|
@@ -39,17 +47,160 @@ const RECIPES = Object.freeze({
|
|
|
39
47
|
]),
|
|
40
48
|
}),
|
|
41
49
|
'historical-comparison': Object.freeze({
|
|
50
|
+
id: 'historical-comparison',
|
|
42
51
|
label: 'Historical observation comparison',
|
|
43
|
-
|
|
52
|
+
subjectRequirement: 'domain',
|
|
44
53
|
objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
|
|
54
|
+
runnable: true,
|
|
55
|
+
limitations: Object.freeze(['A later observation does not retroactively refresh retained evidence.']),
|
|
45
56
|
steps: (domain) => Object.freeze([
|
|
46
57
|
step('current', 'Collect the current lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'A current request does not refresh or validate older provider-reported history.'),
|
|
47
58
|
step('diff', 'Compare two selected observations', 'diff', ['<previous.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Equal, different, conflicting, and unavailable evidence remain separate.'),
|
|
48
59
|
step('timeline', 'Build a bounded local timeline', 'timeline', ['<oldest.json>', '<newer.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-timeline', 'Choose two to twenty same-domain files in chronological scope.'),
|
|
49
60
|
]),
|
|
50
61
|
}),
|
|
62
|
+
'campaign-review': Object.freeze({
|
|
63
|
+
id: 'campaign-review',
|
|
64
|
+
label: 'Campaign candidate review',
|
|
65
|
+
subjectRequirement: 'brand_or_domain',
|
|
66
|
+
objective: 'Prepare a bounded candidate set, collect a deliberately selected queue, and review retained evidence without asserting campaign attribution.',
|
|
67
|
+
runnable: false,
|
|
68
|
+
limitations: Object.freeze(['Grouping candidates is analyst triage and does not prove common ownership, control, infrastructure, or intent.']),
|
|
69
|
+
steps: (subject) => Object.freeze([
|
|
70
|
+
step('prepare', 'Prepare candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and bounded omissions before selecting a collection scope.'),
|
|
71
|
+
step('collect', 'Collect the selected candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Treat partial and inconclusive authority results as explicit outcomes.'),
|
|
72
|
+
step('review', 'Review selected retained evidence', 'review-evidence', ['<selected-evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.offline-evidence-review', 'Keep source observations separate and record any campaign grouping as analyst-authored.'),
|
|
73
|
+
]),
|
|
74
|
+
}),
|
|
75
|
+
'certificate-anomaly': Object.freeze({
|
|
76
|
+
id: 'certificate-anomaly',
|
|
77
|
+
label: 'Certificate anomaly review',
|
|
78
|
+
subjectRequirement: 'domain',
|
|
79
|
+
objective: 'Review bounded certificate observations alongside current source-qualified domain evidence without treating issuance as proof of control or intent.',
|
|
80
|
+
runnable: false,
|
|
81
|
+
limitations: Object.freeze(['Certificate observations are separately attributed and do not establish current service control.']),
|
|
82
|
+
steps: (domain) => Object.freeze([
|
|
83
|
+
step('search', 'Collect bounded certificate observations', 'ct-search', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.ct-search', 'Review source availability, truncation, and observation timing.'),
|
|
84
|
+
step('intake', 'Normalise the selected observations', 'ct-intake', ['<certificate-events.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.ct-event-batch', 'Only selected saved observations enter the offline intake.'),
|
|
85
|
+
step('corroborate', 'Collect supporting domain evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Compare evidence families without collapsing certificate and registration identities.'),
|
|
86
|
+
]),
|
|
87
|
+
}),
|
|
88
|
+
'registry-disagreement': Object.freeze({
|
|
89
|
+
id: 'registry-disagreement',
|
|
90
|
+
label: 'Registry disagreement review',
|
|
91
|
+
subjectRequirement: 'domain',
|
|
92
|
+
objective: 'Collect separately attributed registration evidence and review conflicting publications without selecting an arbitrary source as truth.',
|
|
93
|
+
runnable: false,
|
|
94
|
+
limitations: Object.freeze(['Only authority-aware registration evidence may decide availability; disagreement remains explicit.']),
|
|
95
|
+
steps: (domain) => Object.freeze([
|
|
96
|
+
step('collect', 'Collect source-qualified registration evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain RDAP, registrar RDAP, WHOIS, and authority states separately.'),
|
|
97
|
+
step('compare', 'Compare registry publications offline', 'compare', ['<saved-lookup.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.compare', 'Do not convert conflicting or unavailable publications into equivalence.'),
|
|
98
|
+
step('report', 'Prepare a target-free source report', 'source-report', ['<saved-lookup.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.source-reliability-report', 'The report describes source behaviour, not ownership, safety, or legal status.'),
|
|
99
|
+
]),
|
|
100
|
+
}),
|
|
101
|
+
'evidence-handoff': Object.freeze({
|
|
102
|
+
id: 'evidence-handoff',
|
|
103
|
+
label: 'Reviewed evidence handoff',
|
|
104
|
+
subjectRequirement: 'review_label',
|
|
105
|
+
objective: 'Verify, minimise, and package analyst-selected evidence for a deliberate handoff without transmitting or submitting it.',
|
|
106
|
+
runnable: false,
|
|
107
|
+
limitations: Object.freeze(['The recipe prepares local material only; sharing remains a separate deliberate action.']),
|
|
108
|
+
steps: () => Object.freeze([
|
|
109
|
+
step('verify', 'Verify the selected artefact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Verification checks structure and integrity, not the truth or currency of observations.'),
|
|
110
|
+
step('package', 'Build a reviewed public Case-pack', 'case-pack', ['<cases.json>', '--audience', 'public', '--reviewed', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.case-pack', 'Review minimisation and audience projection before retaining the separate package.'),
|
|
111
|
+
step('lint', 'Review deliberate-sharing metadata', 'sharing-review', ['<package.json>', '--marking', 'clear', '--recipient-scope', 'public', '--purpose', 'reviewed evidence handoff', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.sharing-review', 'A clear lint result does not send, upload, publish, or authorise the artefact.'),
|
|
112
|
+
]),
|
|
113
|
+
}),
|
|
114
|
+
'planned-domain-change': Object.freeze({
|
|
115
|
+
id: 'planned-domain-change',
|
|
116
|
+
label: 'Planned domain change',
|
|
117
|
+
subjectRequirement: 'domain',
|
|
118
|
+
objective: 'Review an analyst-authored desired state and prepare bounded change material without changing DNS, registry, mail, or hosted configuration.',
|
|
119
|
+
runnable: false,
|
|
120
|
+
limitations: Object.freeze(['Planning and packaging never apply, submit, schedule, or enforce a change.']),
|
|
121
|
+
steps: () => Object.freeze([
|
|
122
|
+
step('control', 'Review the desired-state manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-control-review', 'Only supplied complete observations may produce drift.'),
|
|
123
|
+
step('assure', 'Review change and recovery assumptions', 'assurance', ['<assurance-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-assurance', 'Record uncertainty, rollback dependencies, and unavailable evidence explicitly.'),
|
|
124
|
+
step('package', 'Build a reviewed change packet', 'change-packet', ['<change-packet-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-change-packet', 'The packet is local reviewed material and performs no submission or enforcement.'),
|
|
125
|
+
]),
|
|
126
|
+
}),
|
|
127
|
+
'post-change-verification': Object.freeze({
|
|
128
|
+
id: 'post-change-verification',
|
|
129
|
+
label: 'Post-change verification',
|
|
130
|
+
subjectRequirement: 'domain',
|
|
131
|
+
objective: 'Perform one explicit later observation and compare it with analyst-selected retained evidence after an authorised change.',
|
|
132
|
+
runnable: false,
|
|
133
|
+
limitations: Object.freeze(['This is a one-time recheck, not monitoring setup or proof that every resolver or service has converged.']),
|
|
134
|
+
steps: () => Object.freeze([
|
|
135
|
+
step('recheck', 'Run one bounded retained-manifest review', 'monitor-once', ['<manifest.json>', '--limit', '1', '--json'], 'network', 'network_disclosure', 'whoisleuth.domain-control-review', 'One later observation may remain partial, unavailable, stale, or conflicting.'),
|
|
136
|
+
step('compare', 'Compare selected before and after evidence', 'diff', ['<before.json>', '<after.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Materiality is derived from compatible retained evidence and does not infer intent.'),
|
|
137
|
+
step('record', 'Record reviewed completion material', 'change-packet', ['<post-change-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-change-packet', 'Recording reviewed material does not submit it or start automatic monitoring.'),
|
|
138
|
+
]),
|
|
139
|
+
}),
|
|
51
140
|
});
|
|
141
|
+
export const INVESTIGATION_RECIPE_REGISTRY = RECIPES;
|
|
142
|
+
export const INVESTIGATION_PLAN_RECIPE_LABELS = Object.freeze(Object.fromEntries(INVESTIGATION_PLAN_RECIPES.map((recipe) => [recipe, RECIPES[recipe].label])));
|
|
143
|
+
export function isRunnableInvestigationRecipe(value) {
|
|
144
|
+
return RUNNABLE_INVESTIGATION_PLAN_RECIPES.includes(value);
|
|
145
|
+
}
|
|
146
|
+
function recipeCatalogueEntry(recipe) {
|
|
147
|
+
const exampleSubject = recipe.subjectRequirement === 'domain' ? 'example.test' : 'Example Organisation';
|
|
148
|
+
const steps = recipe.steps(exampleSubject).map((item) => Object.freeze({
|
|
149
|
+
id: item.id,
|
|
150
|
+
label: item.label,
|
|
151
|
+
command: item.command,
|
|
152
|
+
exampleArguments: item.arguments,
|
|
153
|
+
mode: item.mode,
|
|
154
|
+
approval: item.approval,
|
|
155
|
+
produces: item.produces,
|
|
156
|
+
completion: item.completion,
|
|
157
|
+
}));
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
id: recipe.id,
|
|
160
|
+
label: recipe.label,
|
|
161
|
+
objective: recipe.objective,
|
|
162
|
+
subjectRequirement: recipe.subjectRequirement,
|
|
163
|
+
runnableByWorkflowRun: recipe.runnable,
|
|
164
|
+
networkModes: Object.freeze([...new Set(steps.map((item) => item.mode))]),
|
|
165
|
+
approvals: Object.freeze([...new Set(steps.map((item) => item.approval))]),
|
|
166
|
+
steps: Object.freeze(steps),
|
|
167
|
+
limitations: recipe.limitations,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
export function buildWorkflowRecipeCatalogue(recipeId = null) {
|
|
171
|
+
const selected = recipeId === null ? INVESTIGATION_PLAN_RECIPES : [recipeId];
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
schema: CLI_WORKFLOW_RECIPE_CATALOGUE_SCHEMA,
|
|
174
|
+
version: CLI_WORKFLOW_RECIPE_CATALOGUE_VERSION,
|
|
175
|
+
recipes: Object.freeze(selected.map((id) => recipeCatalogueEntry(RECIPES[id]))),
|
|
176
|
+
limitations: Object.freeze([
|
|
177
|
+
'Catalogue and explanation modes are fixed metadata. They make no request, read no evidence file, and execute no step.',
|
|
178
|
+
'workflow-run remains limited to recipes explicitly marked runnable by the installed registry.',
|
|
179
|
+
]),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
export function formatWorkflowRecipeCatalogue(catalogue) {
|
|
183
|
+
return [
|
|
184
|
+
'WHOISleuth workflow recipes',
|
|
185
|
+
'',
|
|
186
|
+
...catalogue.recipes.flatMap((recipe) => [
|
|
187
|
+
`${recipe.id} — ${recipe.label}`,
|
|
188
|
+
` Subject: ${recipe.subjectRequirement.replaceAll('_', ' ')}`,
|
|
189
|
+
` Workflow run: ${recipe.runnableByWorkflowRun ? 'supported' : 'plan and explanation only'}`,
|
|
190
|
+
` ${recipe.objective}`,
|
|
191
|
+
...recipe.steps.map((item, index) => ` ${index + 1}. ${item.label} [${item.mode}; ${item.approval.replaceAll('_', ' ')}]`),
|
|
192
|
+
...recipe.limitations.map((item) => ` Limitation: ${item}`),
|
|
193
|
+
'',
|
|
194
|
+
]),
|
|
195
|
+
...catalogue.limitations.map((item) => `Limitation: ${item}`),
|
|
196
|
+
'',
|
|
197
|
+
].join('\n');
|
|
198
|
+
}
|
|
52
199
|
function step(id, label, command, args, mode, approval, produces, completion) {
|
|
200
|
+
const invocationEffect = cliInvocationNetworkEffect(command, args);
|
|
201
|
+
if ((mode === 'network') !== (invocationEffect === 'network')) {
|
|
202
|
+
throw new Error(`Investigation step ${id} does not match the registered network effect for ${command}.`);
|
|
203
|
+
}
|
|
53
204
|
return Object.freeze({ id, label, command, arguments: Object.freeze([...args]), mode, approval, produces, completion });
|
|
54
205
|
}
|
|
55
206
|
function boundedSubject(value) {
|
|
@@ -71,17 +222,17 @@ export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue
|
|
|
71
222
|
if (!recipe)
|
|
72
223
|
throw new TypeError('Investigation-plan recipe is unsupported.');
|
|
73
224
|
const suppliedSubject = boundedSubject(subjectValue);
|
|
74
|
-
const subject = recipe.
|
|
225
|
+
const subject = recipe.subjectRequirement === 'domain' ? normalizedDomain(suppliedSubject) : suppliedSubject.toLowerCase();
|
|
75
226
|
if (!subject)
|
|
76
227
|
throw new TypeError('This investigation-plan recipe requires one valid domain.');
|
|
77
|
-
const
|
|
78
|
-
if (!
|
|
79
|
-
throw new TypeError('Investigation-plan generation time
|
|
228
|
+
const generatedAt = normalizeExplicitIsoTimestamp(generatedAtValue);
|
|
229
|
+
if (!generatedAt)
|
|
230
|
+
throw new TypeError('Investigation-plan generation time must use an explicit timezone.');
|
|
80
231
|
const steps = recipe.steps(subject);
|
|
81
232
|
return Object.freeze({
|
|
82
233
|
schema: CLI_INVESTIGATION_PLAN_SCHEMA,
|
|
83
234
|
version: CLI_INVESTIGATION_PLAN_VERSION,
|
|
84
|
-
generatedAt
|
|
235
|
+
generatedAt,
|
|
85
236
|
recipe: Object.freeze({ id: recipeId, label: recipe.label, objective: recipe.objective }),
|
|
86
237
|
subject,
|
|
87
238
|
execution: 'plan_only',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { buildInvestigationPlan, } from "./investigation-plan.mjs";
|
|
2
|
+
import { buildInvestigationPlan, isRunnableInvestigationRecipe, } from "./investigation-plan.mjs";
|
|
3
3
|
import { CliUsageError } from "./errors.mjs";
|
|
4
|
+
import EXIT_CODES from "./exit-codes.mjs";
|
|
5
|
+
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
4
6
|
export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
|
|
5
7
|
export const CLI_INVESTIGATION_RUN_VERSION = 1;
|
|
6
8
|
export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
|
|
@@ -9,54 +11,101 @@ function boundedResult(value) {
|
|
|
9
11
|
throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
|
|
10
12
|
}
|
|
11
13
|
try {
|
|
14
|
+
scanBoundedJson(value);
|
|
12
15
|
return JSON.parse(value);
|
|
13
16
|
}
|
|
14
|
-
catch {
|
|
17
|
+
catch (cause) {
|
|
18
|
+
if (cause instanceof TypeError && cause.message !== 'Artefact input is not valid JSON.') {
|
|
19
|
+
throw new CliUsageError(`Investigation step output ${cause.message.replace(/^Artefact JSON /u, '')}`);
|
|
20
|
+
}
|
|
15
21
|
return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').slice(0, MAX_INVESTIGATION_RUN_BYTES);
|
|
16
22
|
}
|
|
17
23
|
}
|
|
18
|
-
function
|
|
24
|
+
function resultSchema(value) {
|
|
25
|
+
return value && typeof value === 'object' && !Array.isArray(value) && typeof value.schema === 'string'
|
|
26
|
+
? String(value.schema)
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
function sameArguments(left, right) {
|
|
30
|
+
return Array.isArray(left)
|
|
31
|
+
&& left.length === right.length
|
|
32
|
+
&& left.every((value, index) => typeof value === 'string' && value === right[index]);
|
|
33
|
+
}
|
|
34
|
+
function parseResumeState(input, plan) {
|
|
19
35
|
if (!input)
|
|
20
36
|
return [];
|
|
21
37
|
if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
|
|
22
38
|
throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
|
|
39
|
+
const normalizedInput = input.replace(/^\uFEFF/u, '');
|
|
40
|
+
try {
|
|
41
|
+
scanBoundedJson(normalizedInput);
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
const detail = cause instanceof Error ? cause.message : '';
|
|
45
|
+
if (detail === 'Artefact input is not valid JSON.')
|
|
46
|
+
throw new CliUsageError('Investigation resume state must be valid JSON.');
|
|
47
|
+
throw new CliUsageError(detail ? `Investigation resume state ${detail.replace(/^Artefact JSON /u, '')}` : 'Investigation resume state must be valid JSON.');
|
|
48
|
+
}
|
|
23
49
|
let parsed;
|
|
24
50
|
try {
|
|
25
|
-
parsed = JSON.parse(
|
|
51
|
+
parsed = JSON.parse(normalizedInput);
|
|
26
52
|
}
|
|
27
53
|
catch {
|
|
28
54
|
throw new CliUsageError('Investigation resume state must be valid JSON.');
|
|
29
55
|
}
|
|
30
56
|
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
31
|
-
if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== recipe || root.subject !== subject || !Array.isArray(root.completedSteps)) {
|
|
57
|
+
if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
|
|
32
58
|
throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
|
|
33
59
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
if (root.completedSteps.length > plan.steps.length) {
|
|
61
|
+
throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
|
|
62
|
+
}
|
|
63
|
+
const completed = [];
|
|
64
|
+
for (const [index, value] of root.completedSteps.entries()) {
|
|
65
|
+
const planned = plan.steps[index];
|
|
66
|
+
if (!planned || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
67
|
+
throw new CliUsageError('Investigation resume state contains a malformed or out-of-order step.');
|
|
68
|
+
}
|
|
37
69
|
const item = value;
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
if (item.id !== planned.id
|
|
71
|
+
|| item.command !== planned.command
|
|
72
|
+
|| item.mode !== planned.mode
|
|
73
|
+
|| !sameArguments(item.arguments, planned.arguments)
|
|
74
|
+
|| !Number.isSafeInteger(item.exitCode)) {
|
|
75
|
+
throw new CliUsageError('Investigation resume state does not match the installed fixed recipe.');
|
|
76
|
+
}
|
|
77
|
+
const exitCode = Number(item.exitCode);
|
|
78
|
+
if (exitCode !== 0 && exitCode !== 2) {
|
|
79
|
+
if (index !== root.completedSteps.length - 1) {
|
|
80
|
+
throw new CliUsageError('A failed investigation step must be the final retained step.');
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (resultSchema(item.result) !== planned.produces) {
|
|
85
|
+
throw new CliUsageError('Investigation resume state contains output from an unexpected command contract.');
|
|
86
|
+
}
|
|
87
|
+
completed.push(Object.freeze({
|
|
88
|
+
id: planned.id,
|
|
89
|
+
command: planned.command,
|
|
90
|
+
arguments: planned.arguments,
|
|
91
|
+
mode: planned.mode,
|
|
92
|
+
exitCode,
|
|
93
|
+
result: item.result,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
return completed;
|
|
49
97
|
}
|
|
50
98
|
export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
99
|
+
if (!isRunnableInvestigationRecipe(recipe)) {
|
|
100
|
+
throw new CliUsageError('workflow-run supports only installed recipes whose exact steps satisfy the execution contract.');
|
|
101
|
+
}
|
|
51
102
|
const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
|
|
52
|
-
const prior = parseResumeState(options.resumeInput,
|
|
53
|
-
const allowedStepIds = new Set(plan.steps.map((step) => step.id));
|
|
54
|
-
if (prior.some((step) => !allowedStepIds.has(step.id)))
|
|
55
|
-
throw new CliUsageError('Investigation resume state contains a step outside the fixed recipe.');
|
|
103
|
+
const prior = parseResumeState(options.resumeInput, plan);
|
|
56
104
|
const completed = [...prior];
|
|
57
105
|
let state = 'complete';
|
|
58
106
|
let currentStep = null;
|
|
59
107
|
for (const step of plan.steps) {
|
|
108
|
+
options.signal?.throwIfAborted();
|
|
60
109
|
if (completed.some((item) => item.id === step.id))
|
|
61
110
|
continue;
|
|
62
111
|
currentStep = step;
|
|
@@ -69,13 +118,20 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
|
69
118
|
break;
|
|
70
119
|
}
|
|
71
120
|
const result = await options.execute(step.command, step.arguments);
|
|
121
|
+
if (result.exitCode === EXIT_CODES.CANCELLED) {
|
|
122
|
+
throw options.signal?.reason || new DOMException('Cancelled', 'AbortError');
|
|
123
|
+
}
|
|
124
|
+
const parsedResult = boundedResult(result.stdout);
|
|
125
|
+
if ((result.exitCode === 0 || result.exitCode === 2) && resultSchema(parsedResult) !== step.produces) {
|
|
126
|
+
throw new CliUsageError(`Investigation step ${step.id} returned an unexpected command contract.`);
|
|
127
|
+
}
|
|
72
128
|
completed.push(Object.freeze({
|
|
73
129
|
id: step.id,
|
|
74
130
|
command: step.command,
|
|
75
131
|
arguments: step.arguments,
|
|
76
132
|
mode: step.mode,
|
|
77
133
|
exitCode: result.exitCode,
|
|
78
|
-
result:
|
|
134
|
+
result: parsedResult,
|
|
79
135
|
}));
|
|
80
136
|
if (result.exitCode !== 0 && result.exitCode !== 2) {
|
|
81
137
|
state = 'step_failed';
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { open, realpath } from 'node:fs/promises';
|
|
2
3
|
import { isIP } from 'node:net';
|
|
3
|
-
import {
|
|
4
|
+
import { Reader } from 'maxmind';
|
|
4
5
|
import { formatIpPrefix, parseIpPrefix } from "../lib/ip-prefix.mjs";
|
|
5
6
|
import { CliUsageError } from "./errors.mjs";
|
|
6
7
|
export const LOCAL_MMDB_QUERY_SCHEMA = 'whoisleuth.local-mmdb-query';
|
|
8
|
+
export const LOCAL_MMDB_QUERY_VERSION = 1;
|
|
7
9
|
export const MAX_LOCAL_MMDB_BYTES = 512 * 1024 * 1024;
|
|
8
10
|
function record(value) {
|
|
9
11
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
@@ -17,6 +19,43 @@ function names(value) {
|
|
|
17
19
|
const source = record(value);
|
|
18
20
|
return boundedText(source.en, 120);
|
|
19
21
|
}
|
|
22
|
+
async function readStableMmdb(databasePath) {
|
|
23
|
+
let handle = null;
|
|
24
|
+
try {
|
|
25
|
+
const resolvedPath = await realpath(databasePath);
|
|
26
|
+
handle = await open(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
|
|
27
|
+
const before = await handle.stat();
|
|
28
|
+
if (!before.isFile() || before.size <= 0 || before.size > MAX_LOCAL_MMDB_BYTES) {
|
|
29
|
+
throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
|
|
30
|
+
}
|
|
31
|
+
const database = Buffer.allocUnsafe(before.size);
|
|
32
|
+
let offset = 0;
|
|
33
|
+
while (offset < database.length) {
|
|
34
|
+
const { bytesRead } = await handle.read(database, offset, database.length - offset, offset);
|
|
35
|
+
if (bytesRead === 0)
|
|
36
|
+
break;
|
|
37
|
+
offset += bytesRead;
|
|
38
|
+
}
|
|
39
|
+
const after = await handle.stat();
|
|
40
|
+
if (offset !== database.length
|
|
41
|
+
|| before.dev !== after.dev
|
|
42
|
+
|| before.ino !== after.ino
|
|
43
|
+
|| before.size !== after.size
|
|
44
|
+
|| before.mtimeMs !== after.mtimeMs
|
|
45
|
+
|| before.ctimeMs !== after.ctimeMs) {
|
|
46
|
+
throw new CliUsageError('The supplied MMDB changed while it was being read.');
|
|
47
|
+
}
|
|
48
|
+
return database;
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
if (cause instanceof CliUsageError)
|
|
52
|
+
throw cause;
|
|
53
|
+
throw new CliUsageError('The supplied MMDB file could not be read.');
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle?.close().catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
20
59
|
export async function reviewLocalMmdb(input, databasePath) {
|
|
21
60
|
const address = boundedText(input.address, 64)?.toLowerCase() ?? '';
|
|
22
61
|
const sourceLabel = boundedText(input.sourceLabel, 120);
|
|
@@ -27,21 +66,13 @@ export async function reviewLocalMmdb(input, databasePath) {
|
|
|
27
66
|
if (!sourceLabel || !databaseVersion || !license) {
|
|
28
67
|
throw new CliUsageError('Local MMDB review requires bounded sourceLabel, databaseVersion, and licence metadata.');
|
|
29
68
|
}
|
|
30
|
-
let info;
|
|
31
|
-
try {
|
|
32
|
-
info = await stat(databasePath);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
throw new CliUsageError('The supplied MMDB file could not be read.');
|
|
36
|
-
}
|
|
37
|
-
if (!info.isFile() || info.size <= 0 || info.size > MAX_LOCAL_MMDB_BYTES) {
|
|
38
|
-
throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
|
|
39
|
-
}
|
|
40
69
|
let reader;
|
|
41
70
|
try {
|
|
42
|
-
reader = await
|
|
71
|
+
reader = new Reader(await readStableMmdb(databasePath));
|
|
43
72
|
}
|
|
44
|
-
catch {
|
|
73
|
+
catch (cause) {
|
|
74
|
+
if (cause instanceof CliUsageError)
|
|
75
|
+
throw cause;
|
|
45
76
|
throw new CliUsageError('The supplied file is not a readable MaxMind DB database.');
|
|
46
77
|
}
|
|
47
78
|
const [rawMatch, prefixLength] = reader.getWithPrefixLength(address);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
2
3
|
const LOOKALIKE_CALIBRATION_INPUT_SCHEMA = 'whoisleuth.lookalike-calibration-input';
|
|
3
4
|
const LOOKALIKE_CALIBRATION_SCHEMA = 'whoisleuth.lookalike-calibration';
|
|
4
5
|
const LOOKALIKE_CALIBRATION_VERSION = 1;
|
|
@@ -29,10 +30,11 @@ function buildLookalikeCalibration(raw, generatedAt = new Date().toISOString())
|
|
|
29
30
|
}
|
|
30
31
|
let parsed;
|
|
31
32
|
try {
|
|
33
|
+
scanBoundedJson(raw);
|
|
32
34
|
parsed = JSON.parse(raw);
|
|
33
35
|
}
|
|
34
36
|
catch {
|
|
35
|
-
throw new TypeError('Lookalike calibration input must be valid JSON.');
|
|
37
|
+
throw new TypeError('Lookalike calibration input must be valid bounded JSON without duplicate keys.');
|
|
36
38
|
}
|
|
37
39
|
const input = record(parsed);
|
|
38
40
|
if (input.schema !== LOOKALIKE_CALIBRATION_INPUT_SCHEMA || input.version !== LOOKALIKE_CALIBRATION_VERSION) {
|
package/cli/lookup-brief.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
2
|
+
import { safeTerminalValue } from "./formatters/terminal.mjs";
|
|
2
3
|
export const CLI_LOOKUP_BRIEF_SCHEMA = 'whoisleuth.cli.lookup-brief';
|
|
3
4
|
export const CLI_LOOKUP_BRIEF_VERSION = 2;
|
|
4
5
|
function record(value) {
|
|
@@ -7,7 +8,11 @@ function record(value) {
|
|
|
7
8
|
function text(value, maximum = 300) {
|
|
8
9
|
if (typeof value !== 'string')
|
|
9
10
|
return null;
|
|
10
|
-
const normalized = value
|
|
11
|
+
const normalized = value
|
|
12
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu, ' ')
|
|
13
|
+
.replace(/\s+/gu, ' ')
|
|
14
|
+
.trim()
|
|
15
|
+
.slice(0, maximum);
|
|
11
16
|
return normalized || null;
|
|
12
17
|
}
|
|
13
18
|
function sourceState(value) {
|
|
@@ -96,20 +101,20 @@ export function buildCliLookupBrief(input, generatedAt = new Date().toISOString(
|
|
|
96
101
|
}
|
|
97
102
|
export function formatCliLookupBrief(document) {
|
|
98
103
|
return [
|
|
99
|
-
`Lookup brief: ${document.target}`,
|
|
100
|
-
`Observed ${document.observedAt}`,
|
|
101
|
-
`Mode ${document.mode}`,
|
|
104
|
+
`Lookup brief: ${safeTerminalValue(document.target)}`,
|
|
105
|
+
`Observed ${safeTerminalValue(document.observedAt)}`,
|
|
106
|
+
`Mode ${safeTerminalValue(document.mode)}`,
|
|
102
107
|
'',
|
|
103
108
|
'Verified facts',
|
|
104
|
-
...(document.facts.length ? document.facts.map((item) => ` ${item.label}: ${item.value} (${item.source})`) : [' No bounded fact was available.']),
|
|
109
|
+
...(document.facts.length ? document.facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)} (${safeTerminalValue(item.source)})`) : [' No bounded fact was available.']),
|
|
105
110
|
'',
|
|
106
111
|
'Unknown or incomplete',
|
|
107
|
-
...(document.unknowns.length ? document.unknowns.map((item) => ` ${item}`) : [' No incomplete source state was identified.']),
|
|
112
|
+
...(document.unknowns.length ? document.unknowns.map((item) => ` ${safeTerminalValue(item)}`) : [' No incomplete source state was identified.']),
|
|
108
113
|
'',
|
|
109
114
|
'Recommended manual actions',
|
|
110
115
|
...document.actionPlan.flatMap((item, index) => [
|
|
111
|
-
` ${index + 1}. ${item.action}`,
|
|
112
|
-
` Expected outcome: ${item.expectedOutcome}`,
|
|
116
|
+
` ${index + 1}. ${safeTerminalValue(item.action)}`,
|
|
117
|
+
` Expected outcome: ${safeTerminalValue(item.expectedOutcome)}`,
|
|
113
118
|
]),
|
|
114
119
|
'',
|
|
115
120
|
].join('\n');
|