@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA } from "../packages/contracts/domain-control-monitor.mjs";
|
|
2
|
+
import { DOMAIN_CONTROL_REVIEW_SCHEMA } from "../packages/contracts/domain-control-review.mjs";
|
|
3
|
+
import { EXTERNAL_FINDINGS_SCHEMA } from "../packages/contracts/external-observation-interchange.mjs";
|
|
4
|
+
import { CLI_LOOKUP_BRIEF_SCHEMA } from "../packages/contracts/cli-lookup-brief.mjs";
|
|
5
|
+
export const INVESTIGATION_RECIPE_DEFINITIONS = Object.freeze({
|
|
6
|
+
'domain-triage': Object.freeze({
|
|
7
|
+
label: 'New domain triage',
|
|
8
|
+
standardInputs: Object.freeze([
|
|
9
|
+
{ stepId: 'export', input: 1, sourceStepId: 'collect' },
|
|
10
|
+
{ stepId: 'verify', input: 1, sourceStepId: 'export' },
|
|
11
|
+
]),
|
|
12
|
+
subjectRequirement: 'domain',
|
|
13
|
+
objective: 'Collect and preserve separately attributed registration, DNS, HTTP, TLS, page, and network-context evidence.',
|
|
14
|
+
limitations: Object.freeze([
|
|
15
|
+
'Collection remains analyst-triggered and source limitations remain explicit.',
|
|
16
|
+
'Disposition, reviewed response actions, monitoring, and closure continue in the saved Case workspace; this CLI recipe does not submit reports.',
|
|
17
|
+
]),
|
|
18
|
+
steps: (domain) => Object.freeze([
|
|
19
|
+
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.'),
|
|
20
|
+
step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Reuse the collected Lookup by default, or select a different reviewed Lookup file.'),
|
|
21
|
+
step('verify', 'Verify the exported artefact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Keep verification distinct from a claim that the observations are correct or current.'),
|
|
22
|
+
]),
|
|
23
|
+
}),
|
|
24
|
+
'lookalike-review': Object.freeze({
|
|
25
|
+
label: 'Lookalike candidate review',
|
|
26
|
+
subjectRequirement: 'brand_or_domain',
|
|
27
|
+
objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
|
|
28
|
+
limitations: Object.freeze([
|
|
29
|
+
'Candidate generation does not establish registration, control, intent, or maliciousness.',
|
|
30
|
+
'Official-reference collection and page comparison require analyst-selected saved evidence; use page-compare after retaining the reference and candidate observations.',
|
|
31
|
+
]),
|
|
32
|
+
steps: (subject) => Object.freeze([
|
|
33
|
+
step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
|
|
34
|
+
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.'),
|
|
35
|
+
step('inspect', 'Deep-review one selected candidate', 'lookup', ['<selected-domain>', '--deep', '--json'], 'network', 'analyst_selection', 'whoisleuth.cli.lookup', 'Select a candidate deliberately; generation does not prove registration, control, intent, or maliciousness.'),
|
|
36
|
+
]),
|
|
37
|
+
}),
|
|
38
|
+
'owned-domain-review': Object.freeze({
|
|
39
|
+
label: 'Owned domain posture review',
|
|
40
|
+
subjectRequirement: 'domain',
|
|
41
|
+
objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
|
|
42
|
+
limitations: Object.freeze([
|
|
43
|
+
'Use only for a domain the analyst owns or is authorised to review.',
|
|
44
|
+
]),
|
|
45
|
+
steps: (domain) => Object.freeze([
|
|
46
|
+
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.'),
|
|
47
|
+
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.'),
|
|
48
|
+
step('manifest', 'Review the domain control manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', DOMAIN_CONTROL_REVIEW_SCHEMA, 'Only complete supplied observations may produce drift.'),
|
|
49
|
+
]),
|
|
50
|
+
}),
|
|
51
|
+
'historical-comparison': Object.freeze({
|
|
52
|
+
label: 'Historical observation comparison',
|
|
53
|
+
standardInputs: Object.freeze([
|
|
54
|
+
{ stepId: 'diff', input: 2, sourceStepId: 'current' },
|
|
55
|
+
{ stepId: 'timeline', input: 3, sourceStepId: 'current' },
|
|
56
|
+
]),
|
|
57
|
+
subjectRequirement: 'domain',
|
|
58
|
+
objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
|
|
59
|
+
limitations: Object.freeze([
|
|
60
|
+
'A later observation does not retroactively refresh retained evidence.',
|
|
61
|
+
]),
|
|
62
|
+
steps: (domain) => Object.freeze([
|
|
63
|
+
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.'),
|
|
64
|
+
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.'),
|
|
65
|
+
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.'),
|
|
66
|
+
]),
|
|
67
|
+
}),
|
|
68
|
+
'campaign-review': Object.freeze({
|
|
69
|
+
label: 'Campaign candidate review',
|
|
70
|
+
subjectRequirement: 'brand_or_domain',
|
|
71
|
+
objective: 'Prepare a bounded candidate set, collect a deliberately selected queue, and review retained evidence without asserting campaign attribution.',
|
|
72
|
+
limitations: Object.freeze([
|
|
73
|
+
'Grouping candidates is analyst triage and does not prove common ownership, control, infrastructure, or intent.',
|
|
74
|
+
]),
|
|
75
|
+
steps: (subject) => Object.freeze([
|
|
76
|
+
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.'),
|
|
77
|
+
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.'),
|
|
78
|
+
step('review', 'Prepare a selected candidate brief', 'brief', ['<saved-lookup.json>', '--json'], 'offline', 'analyst_selection', CLI_LOOKUP_BRIEF_SCHEMA, 'Select a saved candidate Lookup; keep any campaign grouping analyst-authored.'),
|
|
79
|
+
]),
|
|
80
|
+
}),
|
|
81
|
+
'certificate-anomaly': Object.freeze({
|
|
82
|
+
label: 'Certificate anomaly review',
|
|
83
|
+
subjectRequirement: 'domain',
|
|
84
|
+
objective: 'Review bounded certificate observations alongside current source-qualified domain evidence without treating issuance as proof of control or intent.',
|
|
85
|
+
limitations: Object.freeze([
|
|
86
|
+
'Certificate observations are separately attributed and do not establish current service control.',
|
|
87
|
+
]),
|
|
88
|
+
steps: (domain) => Object.freeze([
|
|
89
|
+
step('search', 'Collect bounded certificate observations', 'ct-search', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.ct-search', 'Review source availability, truncation, and observation timing.'),
|
|
90
|
+
step('intake', 'Normalise the selected observations', 'ct-intake', ['<certificate-events.json>', '--json'], 'offline', 'analyst_selection', EXTERNAL_FINDINGS_SCHEMA, 'Select a certificate-event batch; a certificate-search report is not interchangeable with that input.'),
|
|
91
|
+
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.'),
|
|
92
|
+
]),
|
|
93
|
+
}),
|
|
94
|
+
'registry-disagreement': Object.freeze({
|
|
95
|
+
label: 'Registry disagreement review',
|
|
96
|
+
standardInputs: Object.freeze([
|
|
97
|
+
{ stepId: 'compare', input: 1, sourceStepId: 'collect' },
|
|
98
|
+
{ stepId: 'report', input: 1, sourceStepId: 'collect' },
|
|
99
|
+
]),
|
|
100
|
+
subjectRequirement: 'domain',
|
|
101
|
+
objective: 'Collect separately attributed registration evidence and review conflicting publications without selecting an arbitrary source as truth.',
|
|
102
|
+
limitations: Object.freeze([
|
|
103
|
+
'Only authority-aware registration evidence may decide availability; disagreement remains explicit.',
|
|
104
|
+
]),
|
|
105
|
+
steps: (domain) => Object.freeze([
|
|
106
|
+
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.'),
|
|
107
|
+
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.'),
|
|
108
|
+
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.'),
|
|
109
|
+
]),
|
|
110
|
+
}),
|
|
111
|
+
'evidence-handoff': Object.freeze({
|
|
112
|
+
label: 'Reviewed evidence handoff',
|
|
113
|
+
standardInputs: Object.freeze([{ stepId: 'lint', input: 1, sourceStepId: 'package' }]),
|
|
114
|
+
subjectRequirement: 'review_label',
|
|
115
|
+
objective: 'Verify, minimise, and package analyst-selected evidence for a deliberate handoff without transmitting or submitting it.',
|
|
116
|
+
limitations: Object.freeze([
|
|
117
|
+
'The recipe prepares local material only; sharing remains a separate deliberate action.',
|
|
118
|
+
]),
|
|
119
|
+
steps: () => Object.freeze([
|
|
120
|
+
step('verify', 'Verify the selected artefact', 'verify-artifact', ['<evidence.json>', '--json', '--strict-exit'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Verification checks structure and integrity, not the truth or currency of observations.'),
|
|
121
|
+
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.'),
|
|
122
|
+
step('lint', 'Review deliberate-sharing metadata', 'sharing-review', [
|
|
123
|
+
'<package.json>',
|
|
124
|
+
'--marking',
|
|
125
|
+
'clear',
|
|
126
|
+
'--recipient-scope',
|
|
127
|
+
'public',
|
|
128
|
+
'--purpose',
|
|
129
|
+
'reviewed evidence handoff',
|
|
130
|
+
'--human-reviewed',
|
|
131
|
+
'--personal-data-reviewed',
|
|
132
|
+
'--redactions-confirmed',
|
|
133
|
+
'--json',
|
|
134
|
+
], 'offline', 'analyst_selection', 'whoisleuth.cli.sharing-review', 'A clear lint result does not send, upload, publish, or authorise the artefact.'),
|
|
135
|
+
]),
|
|
136
|
+
}),
|
|
137
|
+
'planned-domain-change': Object.freeze({
|
|
138
|
+
label: 'Planned domain change',
|
|
139
|
+
subjectRequirement: 'domain',
|
|
140
|
+
objective: 'Review an analyst-authored desired state and prepare bounded change material without changing DNS, registry, mail, or hosted configuration.',
|
|
141
|
+
limitations: Object.freeze([
|
|
142
|
+
'Planning and packaging never apply, submit, schedule, or enforce a change.',
|
|
143
|
+
]),
|
|
144
|
+
steps: () => Object.freeze([
|
|
145
|
+
step('control', 'Review the desired-state manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', DOMAIN_CONTROL_REVIEW_SCHEMA, 'Only supplied complete observations may produce drift.'),
|
|
146
|
+
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.'),
|
|
147
|
+
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.'),
|
|
148
|
+
]),
|
|
149
|
+
}),
|
|
150
|
+
'post-change-verification': Object.freeze({
|
|
151
|
+
label: 'Post-change verification',
|
|
152
|
+
subjectRequirement: 'domain',
|
|
153
|
+
objective: 'Perform one explicit later observation and compare it with analyst-selected retained evidence after an authorised change.',
|
|
154
|
+
limitations: Object.freeze([
|
|
155
|
+
'This is a one-time recheck, not monitoring setup or proof that every resolver or service has converged.',
|
|
156
|
+
]),
|
|
157
|
+
steps: () => Object.freeze([
|
|
158
|
+
step('recheck', 'Run one bounded retained-manifest review', 'monitor-once', ['<manifest.json>', '--limit', '1', '--json'], 'network', 'network_disclosure', CLI_DOMAIN_CONTROL_MONITOR_SCHEMA, 'One later observation may remain partial, unavailable, stale, or conflicting.'),
|
|
159
|
+
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.'),
|
|
160
|
+
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.'),
|
|
161
|
+
]),
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
export const INVESTIGATION_PLAN_RECIPES = Object.freeze(Object.keys(INVESTIGATION_RECIPE_DEFINITIONS));
|
|
165
|
+
export const RUNNABLE_INVESTIGATION_PLAN_RECIPES = INVESTIGATION_PLAN_RECIPES;
|
|
166
|
+
function step(id, label, command, args, mode, approval, produces, completion) {
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
id,
|
|
169
|
+
label,
|
|
170
|
+
command,
|
|
171
|
+
arguments: Object.freeze([...args]),
|
|
172
|
+
mode,
|
|
173
|
+
approval,
|
|
174
|
+
produces,
|
|
175
|
+
completion,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -4,8 +4,8 @@ import { buildInvestigationPlan, isRunnableInvestigationRecipe, workflowReviewDe
|
|
|
4
4
|
import { CliUsageError } from "./errors.mjs";
|
|
5
5
|
import { hasUnsafeCliText } from "./errors.mjs";
|
|
6
6
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
7
|
-
import { scanBoundedJson } from "../
|
|
8
|
-
import { CLI_INVESTIGATION_RUN_SCHEMA, CLI_INVESTIGATION_RUN_VERSION, SUPPORTED_CLI_INVESTIGATION_RUN_VERSIONS, MAX_INVESTIGATION_RUN_BYTES, MAX_INVESTIGATION_RUN_SELECTIONS, MAX_INVESTIGATION_RUN_SELECTION_LENGTH, } from "../packages/contracts/investigation-run.mjs";
|
|
7
|
+
import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
|
|
8
|
+
import { CLI_INVESTIGATION_RUN_SCHEMA, CLI_INVESTIGATION_PREVIEW_SCHEMA, CLI_INVESTIGATION_PREVIEW_VERSION, CLI_INVESTIGATION_RUN_VERSION, SUPPORTED_CLI_INVESTIGATION_RUN_VERSIONS, MAX_INVESTIGATION_RUN_BYTES, MAX_INVESTIGATION_RUN_SELECTIONS, MAX_INVESTIGATION_RUN_SELECTION_LENGTH, } from "../packages/contracts/investigation-run.mjs";
|
|
9
9
|
import { normalizeWorkflowBindings, validateWorkflowResult, workflowArtifactReference, workflowInputSchemas, } from "./investigation-artifacts.mjs";
|
|
10
10
|
export { CLI_INVESTIGATION_RUN_SCHEMA, CLI_INVESTIGATION_RUN_VERSION, SUPPORTED_CLI_INVESTIGATION_RUN_VERSIONS, MAX_INVESTIGATION_RUN_BYTES, MAX_INVESTIGATION_RUN_SELECTIONS, MAX_INVESTIGATION_RUN_SELECTION_LENGTH, };
|
|
11
11
|
const PLACEHOLDER_PATTERN = /^<[^>]+>$/u;
|
|
@@ -180,7 +180,7 @@ function validateSelectedInputs(plan, selections, bindings, completed) {
|
|
|
180
180
|
}
|
|
181
181
|
function parseResumeState(input, plan) {
|
|
182
182
|
if (!input)
|
|
183
|
-
return Object.freeze({ completed: Object.freeze([]), selections: Object.freeze([]), bindings: Object.freeze([]) });
|
|
183
|
+
return Object.freeze({ completed: Object.freeze([]), selections: Object.freeze([]), bindings: Object.freeze([]), retryableStepId: null });
|
|
184
184
|
if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
|
|
185
185
|
throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
|
|
186
186
|
const normalizedInput = input.replace(/^\uFEFF/u, '');
|
|
@@ -222,6 +222,7 @@ function parseResumeState(input, plan) {
|
|
|
222
222
|
throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
|
|
223
223
|
}
|
|
224
224
|
const completed = [];
|
|
225
|
+
let retryableStepId = null;
|
|
225
226
|
for (const [index, value] of root.completedSteps.entries()) {
|
|
226
227
|
const baseStep = plan.steps[index];
|
|
227
228
|
const planned = baseStep ? selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? [], bindings, completed) : null;
|
|
@@ -251,6 +252,7 @@ function parseResumeState(input, plan) {
|
|
|
251
252
|
if (index !== root.completedSteps.length - 1) {
|
|
252
253
|
throw new CliUsageError('A failed investigation step must be the final retained step.');
|
|
253
254
|
}
|
|
255
|
+
retryableStepId = planned.id;
|
|
254
256
|
break;
|
|
255
257
|
}
|
|
256
258
|
completed.push(Object.freeze({
|
|
@@ -265,19 +267,13 @@ function parseResumeState(input, plan) {
|
|
|
265
267
|
}));
|
|
266
268
|
}
|
|
267
269
|
validateSelectedInputs(plan, selections, bindings, completed);
|
|
268
|
-
return Object.freeze({ completed: Object.freeze(completed), selections, bindings });
|
|
270
|
+
return Object.freeze({ completed: Object.freeze(completed), selections, bindings, retryableStepId });
|
|
269
271
|
}
|
|
270
|
-
|
|
272
|
+
function prepareInvestigationInvocation(recipe, subjectValue, options) {
|
|
271
273
|
if (!isRunnableInvestigationRecipe(recipe)) {
|
|
272
274
|
throw new CliUsageError('workflow-run supports only installed recipes whose exact steps satisfy the execution contract.');
|
|
273
275
|
}
|
|
274
276
|
const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
|
|
275
|
-
const confirmedReviews = options.confirmedReviews ?? [];
|
|
276
|
-
if (!Array.isArray(confirmedReviews) || confirmedReviews.length > plan.steps.length
|
|
277
|
-
|| new Set(confirmedReviews).size !== confirmedReviews.length
|
|
278
|
-
|| confirmedReviews.some(id => !plan.steps.some(step => step.id === id && workflowReviewDeclarations(step).length))) {
|
|
279
|
-
throw new CliUsageError('--confirm-review must name each selected review-declaration step at most once.');
|
|
280
|
-
}
|
|
281
277
|
const prior = parseResumeState(options.resumeInput, plan);
|
|
282
278
|
const suppliedSelections = normalizeInvocationSelections(plan, options.selections ?? []);
|
|
283
279
|
// A complete explicit input list replaces a not-yet-run step's standard or
|
|
@@ -286,8 +282,78 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
|
286
282
|
&& !prior.completed.some(step => step.id === selection.stepId)).map(selection => selection.stepId));
|
|
287
283
|
const initialBindings = options.resumeInput === null ? workflowStandardInputs(recipe) : prior.bindings;
|
|
288
284
|
const bindings = mergeBindings(plan, initialBindings.filter(binding => !explicitSteps.has(binding.stepId)), normalizeWorkflowBindings(plan, options.artifactBindings ?? []), prior.completed);
|
|
289
|
-
|
|
285
|
+
const selections = mergeSelections(plan, prior.selections, suppliedSelections, prior.completed, bindings);
|
|
290
286
|
validateSelectedInputs(plan, selections, bindings, prior.completed);
|
|
287
|
+
return { plan, prior, bindings, selections };
|
|
288
|
+
}
|
|
289
|
+
/** Validate exactly what execution would resume, without an executor or authority. */
|
|
290
|
+
export function previewInvestigationRecipe(recipe, subjectValue, options) {
|
|
291
|
+
const { plan, prior, bindings, selections } = prepareInvestigationInvocation(recipe, subjectValue, options);
|
|
292
|
+
const selected = new Map(selections.map(item => [item.stepId, item.values]));
|
|
293
|
+
const steps = plan.steps.map(base => {
|
|
294
|
+
const retained = prior.completed.find(item => item.id === base.id);
|
|
295
|
+
const step = selectedStep(base, selected.get(base.id) ?? [], bindings, prior.completed);
|
|
296
|
+
let input = 0;
|
|
297
|
+
const inputs = base.arguments.flatMap((argument, index) => {
|
|
298
|
+
if (!PLACEHOLDER_PATTERN.test(argument))
|
|
299
|
+
return [];
|
|
300
|
+
input++;
|
|
301
|
+
const binding = bindings.find(item => item.stepId === base.id && item.input === input);
|
|
302
|
+
const source = binding ? prior.completed.find(item => item.id === binding.sourceStepId) : undefined;
|
|
303
|
+
return [{
|
|
304
|
+
input, placeholder: argument,
|
|
305
|
+
state: binding ? source?.artifact ? 'validated_output' : 'awaiting_output'
|
|
306
|
+
: PLACEHOLDER_PATTERN.test(step.arguments[index]) ? 'unresolved' : 'selected_file_or_value',
|
|
307
|
+
sourceStepId: binding?.sourceStepId ?? null,
|
|
308
|
+
artifactId: source?.artifact?.id ?? null,
|
|
309
|
+
value: binding ? null : PLACEHOLDER_PATTERN.test(step.arguments[index]) ? null : step.arguments[index],
|
|
310
|
+
}];
|
|
311
|
+
});
|
|
312
|
+
return {
|
|
313
|
+
id: step.id, label: step.label, command: step.command, mode: step.mode,
|
|
314
|
+
state: retained ? retained.exitCode === EXIT_CODES.PARTIAL_FAILURE ? 'retained_partial' : 'retained_complete'
|
|
315
|
+
: prior.retryableStepId === step.id ? 'retryable_failure' : 'remaining',
|
|
316
|
+
artifact: retained?.artifact ?? null, inputs,
|
|
317
|
+
networkApprovalRequired: !retained && step.mode === 'network',
|
|
318
|
+
reviewDeclarationsRequired: retained ? [] : workflowReviewDeclarations(step),
|
|
319
|
+
arguments: step.arguments,
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
schema: CLI_INVESTIGATION_PREVIEW_SCHEMA, version: CLI_INVESTIGATION_PREVIEW_VERSION,
|
|
324
|
+
generatedAt: options.generatedAt, recipe, subject: plan.subject, steps,
|
|
325
|
+
requestsExecuted: 0, checkpointWritten: false,
|
|
326
|
+
limitations: [
|
|
327
|
+
'This preview validates retained results and connections but does not open selected input files, execute steps, or grant approvals.',
|
|
328
|
+
'Retained partial observations are reused as partial, not silently recollected. Failed steps remain retryable.',
|
|
329
|
+
'File paths and selected values are included in this local preview. Review them before sharing.',
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
export function formatInvestigationPreview(document) {
|
|
334
|
+
return [
|
|
335
|
+
`Workflow preview: ${document.recipe}`, `Subject: ${document.subject}`,
|
|
336
|
+
'Offline inspection only; no requests or checkpoint writes.',
|
|
337
|
+
...document.steps.flatMap(step => [
|
|
338
|
+
`${step.id}: ${step.state.replaceAll('_', ' ')} · ${step.label}`,
|
|
339
|
+
...(step.artifact ? [` Retained: ${step.artifact.schema} v${step.artifact.version} · ${step.artifact.id}`] : []),
|
|
340
|
+
...step.inputs.map(input => ` Input ${input.input}: ${input.state.replaceAll('_', ' ')}${input.sourceStepId ? ` from ${input.sourceStepId}` : ''}${input.value ? ` · ${input.value}` : ''}`),
|
|
341
|
+
...(step.networkApprovalRequired ? [' Requires fresh network approval.'] : []),
|
|
342
|
+
...(step.reviewDeclarationsRequired.length ? [` Requires human review: ${step.reviewDeclarationsRequired.join(', ')}.`] : []),
|
|
343
|
+
]),
|
|
344
|
+
...document.limitations, '',
|
|
345
|
+
].join('\n');
|
|
346
|
+
}
|
|
347
|
+
export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
348
|
+
const prepared = prepareInvestigationInvocation(recipe, subjectValue, options);
|
|
349
|
+
const { plan, prior, bindings } = prepared;
|
|
350
|
+
let selections = prepared.selections;
|
|
351
|
+
const confirmedReviews = options.confirmedReviews ?? [];
|
|
352
|
+
if (!Array.isArray(confirmedReviews) || confirmedReviews.length > plan.steps.length
|
|
353
|
+
|| new Set(confirmedReviews).size !== confirmedReviews.length
|
|
354
|
+
|| confirmedReviews.some(id => !plan.steps.some(step => step.id === id && workflowReviewDeclarations(step).length))) {
|
|
355
|
+
throw new CliUsageError('--confirm-review must name each selected review-declaration step at most once.');
|
|
356
|
+
}
|
|
291
357
|
const selectionsByStep = new Map(selections.map((item) => [item.stepId, item.values]));
|
|
292
358
|
const completed = [...prior.completed];
|
|
293
359
|
let state = completed.some((step) => step.exitCode === EXIT_CODES.PARTIAL_FAILURE) ? 'partial' : 'complete';
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { constants as fsConstants } from 'node:fs';
|
|
2
2
|
import { open, realpath } from 'node:fs/promises';
|
|
3
3
|
import { isIP } from 'node:net';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
|
|
4
6
|
import { Reader } from 'maxmind';
|
|
5
7
|
import { formatIpPrefix, parseIpPrefix } from "../lib/ip-prefix.mjs";
|
|
6
8
|
import { CliUsageError } from "./errors.mjs";
|
|
9
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
10
|
+
import { canonicalPublicIpAddress } from "../packages/evidence/public-address-policy.mjs";
|
|
7
11
|
export const LOCAL_MMDB_QUERY_SCHEMA = 'whoisleuth.local-mmdb-query';
|
|
8
|
-
export const LOCAL_MMDB_QUERY_VERSION =
|
|
12
|
+
export const LOCAL_MMDB_QUERY_VERSION = 2;
|
|
13
|
+
export const LOCAL_MMDB_REVIEW_SCHEMA = 'whoisleuth.local-mmdb-review';
|
|
14
|
+
export const LOCAL_MMDB_REVIEW_VERSION = 1;
|
|
9
15
|
export const MAX_LOCAL_MMDB_BYTES = 512 * 1024 * 1024;
|
|
16
|
+
export const LOCAL_MMDB_REVIEW_DEADLINE_MS = 10_000;
|
|
17
|
+
export const MAX_LOCAL_MMDB_REVIEW_BYTES = 8 * 1024;
|
|
18
|
+
const DAY_MS = 86_400_000;
|
|
19
|
+
const LOCAL_MMDB_WORKER_KIND = 'local-mmdb-review';
|
|
20
|
+
const SUPPORTED_DATABASE_TYPES = new Set(['GeoIP2-City', 'GeoLite2-City', 'GeoIP2-Country', 'GeoLite2-Country',
|
|
21
|
+
'GeoIP2-ASN', 'GeoLite2-ASN', 'GeoIP2-ISP', 'GeoIP2-Enterprise']);
|
|
10
22
|
function record(value) {
|
|
11
23
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
12
24
|
}
|
|
@@ -19,16 +31,18 @@ function names(value) {
|
|
|
19
31
|
const source = record(value);
|
|
20
32
|
return boundedText(source.en, 120);
|
|
21
33
|
}
|
|
22
|
-
async function readStableMmdb(databasePath) {
|
|
34
|
+
async function readStableMmdb(databasePath, openFile = open) {
|
|
23
35
|
let handle = null;
|
|
24
36
|
try {
|
|
25
37
|
const resolvedPath = await realpath(databasePath);
|
|
26
|
-
handle = await
|
|
38
|
+
handle = await openFile(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
|
|
27
39
|
const before = await handle.stat();
|
|
28
40
|
if (!before.isFile() || before.size <= 0 || before.size > MAX_LOCAL_MMDB_BYTES) {
|
|
29
41
|
throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
|
|
30
42
|
}
|
|
31
|
-
|
|
43
|
+
// Dedicated backing storage can be transferred without copying a pooled
|
|
44
|
+
// buffer or exposing bytes outside the stable file's exact length.
|
|
45
|
+
const database = Buffer.allocUnsafeSlow(before.size);
|
|
32
46
|
let offset = 0;
|
|
33
47
|
while (offset < database.length) {
|
|
34
48
|
const { bytesRead } = await handle.read(database, offset, database.length - offset, offset);
|
|
@@ -56,7 +70,7 @@ async function readStableMmdb(databasePath) {
|
|
|
56
70
|
await handle?.close().catch(() => { });
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
|
-
|
|
73
|
+
function prepareQuery(input, checkedAt) {
|
|
60
74
|
const address = boundedText(input.address, 64)?.toLowerCase() ?? '';
|
|
61
75
|
const sourceLabel = boundedText(input.sourceLabel, 120);
|
|
62
76
|
const databaseVersion = boundedText(input.databaseVersion, 80);
|
|
@@ -66,15 +80,79 @@ export async function reviewLocalMmdb(input, databasePath) {
|
|
|
66
80
|
if (!sourceLabel || !databaseVersion || !license) {
|
|
67
81
|
throw new CliUsageError('Local MMDB review requires bounded sourceLabel, databaseVersion, and licence metadata.');
|
|
68
82
|
}
|
|
83
|
+
if (input.version !== undefined && input.version !== 1 && input.version !== LOCAL_MMDB_QUERY_VERSION) {
|
|
84
|
+
throw new CliUsageError('Local MMDB review requires a supported query version.');
|
|
85
|
+
}
|
|
86
|
+
if (input.version !== LOCAL_MMDB_QUERY_VERSION)
|
|
87
|
+
return { version: 1, address, sourceLabel, databaseVersion, license, checkedAt: null, freshnessPolicy: null };
|
|
88
|
+
const policy = record(input.freshnessPolicy);
|
|
89
|
+
const rationale = boundedText(policy.rationale, 240);
|
|
90
|
+
const reviewedAt = normalizeExplicitIsoTimestamp(checkedAt);
|
|
91
|
+
if (!reviewedAt || !rationale || /[\u0080-\u009f]/u.test(String(policy.rationale)) || !Number.isSafeInteger(policy.maxAgeDays) || Number(policy.maxAgeDays) <= 0
|
|
92
|
+
|| Number(policy.maxAgeDays) > Math.floor(Number.MAX_SAFE_INTEGER / DAY_MS)) {
|
|
93
|
+
throw new CliUsageError('Current MMDB review requires an explicit review time and freshnessPolicy with positive whole maxAgeDays and a bounded plain-text rationale.');
|
|
94
|
+
}
|
|
95
|
+
return { version: 2, address, sourceLabel, databaseVersion, license, checkedAt: reviewedAt,
|
|
96
|
+
freshnessPolicy: { maxAgeDays: Number(policy.maxAgeDays), rationale } };
|
|
97
|
+
}
|
|
98
|
+
/** Intrinsic metadata is separate from the analyst's declared version/licence.
|
|
99
|
+
* The policy admits age for this review, not accuracy or an update guarantee. */
|
|
100
|
+
export function inspectLocalMmdbMetadata(value, byteLength, checkedAt, policy) {
|
|
101
|
+
const metadata = record(value);
|
|
102
|
+
const databaseType = boundedText(metadata.databaseType, 120);
|
|
103
|
+
const buildMs = metadata.buildEpoch instanceof Date ? metadata.buildEpoch.getTime() : NaN;
|
|
104
|
+
const reviewMs = Date.parse(normalizeExplicitIsoTimestamp(checkedAt) ?? '');
|
|
105
|
+
const integer = (candidate) => typeof candidate === 'number' && Number.isSafeInteger(candidate) && candidate >= 0;
|
|
106
|
+
if (!databaseType || metadata.databaseType !== databaseType || !Number.isSafeInteger(buildMs) || buildMs < 0 || buildMs % 1000 !== 0 || !Number.isFinite(reviewMs)
|
|
107
|
+
|| !integer(metadata.binaryFormatMajorVersion) || !integer(metadata.binaryFormatMinorVersion)
|
|
108
|
+
|| ![4, 6].includes(Number(metadata.ipVersion)) || typeof metadata.ipVersion !== 'number'
|
|
109
|
+
|| !integer(metadata.nodeCount) || metadata.nodeCount < 1 || ![24, 28, 32].includes(Number(metadata.recordSize))
|
|
110
|
+
|| typeof metadata.recordSize !== 'number' || !integer(metadata.searchTreeSize)
|
|
111
|
+
|| metadata.searchTreeSize !== metadata.nodeCount * metadata.recordSize / 4 || metadata.searchTreeSize + 16 >= byteLength) {
|
|
112
|
+
return { metadata: null, ageDays: null, reason: 'invalid_metadata' };
|
|
113
|
+
}
|
|
114
|
+
const intrinsic = { databaseType, builtAt: new Date(buildMs).toISOString(),
|
|
115
|
+
binaryFormat: { major: metadata.binaryFormatMajorVersion, minor: metadata.binaryFormatMinorVersion }, ipVersion: metadata.ipVersion };
|
|
116
|
+
const ageDays = (reviewMs - buildMs) / DAY_MS;
|
|
117
|
+
const reason = metadata.binaryFormatMajorVersion !== 2 || metadata.binaryFormatMinorVersion !== 0 ? 'unsupported_format'
|
|
118
|
+
: !SUPPORTED_DATABASE_TYPES.has(databaseType) ? 'unsupported_database'
|
|
119
|
+
: ageDays < 0 ? 'future_database' : ageDays > policy.maxAgeDays ? 'stale_database' : 'admitted';
|
|
120
|
+
return { metadata: intrinsic, ageDays, reason };
|
|
121
|
+
}
|
|
122
|
+
const LIMITATIONS = Object.freeze([
|
|
123
|
+
'This CLI-only result comes from an analyst-supplied local MMDB file. WHOISleuth does not bundle, download, update, license, or transmit the database.',
|
|
124
|
+
'Geolocation and network attribution are provider estimates and may be stale, coarse, shared, or incorrect.',
|
|
125
|
+
'A location or ASN association does not establish the operator, owner, user, intent, safety, or maliciousness of an address.',
|
|
126
|
+
]);
|
|
127
|
+
function currentResult(query, identity, admission, reason, match = null) {
|
|
128
|
+
const state = match ? reason === 'matched' ? 'matched' : 'partial' : 'unavailable';
|
|
129
|
+
return { schema: LOCAL_MMDB_REVIEW_SCHEMA, version: LOCAL_MMDB_REVIEW_VERSION, state, reason,
|
|
130
|
+
completeness: state === 'matched' ? 'complete' : state === 'partial' ? 'partial' : 'unavailable',
|
|
131
|
+
address: query.address, match, source: { label: query.sourceLabel, version: query.databaseVersion, license: query.license },
|
|
132
|
+
database: { ...identity, metadata: admission.metadata },
|
|
133
|
+
freshness: { state: admission.reason === 'stale_database' ? 'stale' : admission.reason === 'admitted' ? 'current' : 'unknown',
|
|
134
|
+
checkedAt: query.checkedAt, ageDays: admission.ageDays, policy: query.freshnessPolicy },
|
|
135
|
+
limitations: [...LIMITATIONS, 'Declared source, version and licence labels are analyst claims; intrinsic metadata and digest do not authenticate the database or verify licensing.',
|
|
136
|
+
'Freshness is checked against the explicitly justified age policy. A miss, rejected or stale database is unavailable context, not evidence of location or absence.'] };
|
|
137
|
+
}
|
|
138
|
+
function reviewBytes(query, database, identity) {
|
|
69
139
|
let reader;
|
|
70
140
|
try {
|
|
71
|
-
reader = new Reader(
|
|
141
|
+
reader = new Reader(database);
|
|
72
142
|
}
|
|
73
|
-
catch
|
|
74
|
-
if (
|
|
75
|
-
|
|
143
|
+
catch {
|
|
144
|
+
if (query.version === 2)
|
|
145
|
+
return currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, 'invalid_database');
|
|
76
146
|
throw new CliUsageError('The supplied file is not a readable MaxMind DB database.');
|
|
77
147
|
}
|
|
148
|
+
const admission = query.version === 2 ? inspectLocalMmdbMetadata(reader.metadata, database.length, query.checkedAt, query.freshnessPolicy) : null;
|
|
149
|
+
if (admission && admission.reason !== 'admitted')
|
|
150
|
+
return currentResult(query, identity, admission, admission.reason);
|
|
151
|
+
if (admission && !canonicalPublicIpAddress(query.address))
|
|
152
|
+
return currentResult(query, identity, admission, 'unsupported_address');
|
|
153
|
+
if (admission && isIP(query.address) === 6 && admission.metadata?.ipVersion === 4)
|
|
154
|
+
return currentResult(query, identity, admission, 'unsupported_address_family');
|
|
155
|
+
const address = query.address;
|
|
78
156
|
const [rawMatch, prefixLength] = reader.getWithPrefixLength(address);
|
|
79
157
|
const match = record(rawMatch);
|
|
80
158
|
const traits = record(match.traits);
|
|
@@ -84,9 +162,10 @@ export async function reviewLocalMmdb(input, databasePath) {
|
|
|
84
162
|
const subdivision = record(subdivisions[0]);
|
|
85
163
|
const city = record(match.city);
|
|
86
164
|
const asnValue = match.autonomous_system_number ?? traits.autonomous_system_number;
|
|
87
|
-
const asn = typeof asnValue === 'number' && Number.isSafeInteger(asnValue) && asnValue >= 0
|
|
165
|
+
const asn = typeof asnValue === 'number' && Number.isSafeInteger(asnValue) && asnValue >= 0
|
|
166
|
+
&& (query.version === 1 || asnValue <= 4_294_967_295) ? asnValue : null;
|
|
88
167
|
const prefix = parseIpPrefix(`${address}/${prefixLength}`);
|
|
89
|
-
|
|
168
|
+
const result = Object.freeze({
|
|
90
169
|
state: rawMatch ? 'matched' : 'not_found',
|
|
91
170
|
address,
|
|
92
171
|
match: rawMatch ? Object.freeze({
|
|
@@ -97,11 +176,112 @@ export async function reviewLocalMmdb(input, databasePath) {
|
|
|
97
176
|
asn,
|
|
98
177
|
asName: boundedText(match.autonomous_system_organization ?? traits.autonomous_system_organization, 240),
|
|
99
178
|
}) : null,
|
|
100
|
-
source: Object.freeze({ label: sourceLabel, version: databaseVersion, license }),
|
|
101
|
-
limitations:
|
|
102
|
-
'This CLI-only result comes from an analyst-supplied local MMDB file. WHOISleuth does not bundle, download, update, license, or transmit the database.',
|
|
103
|
-
'Geolocation and network attribution are provider estimates and may be stale, coarse, shared, or incorrect.',
|
|
104
|
-
'A location or ASN association does not establish the operator, owner, user, intent, safety, or maliciousness of an address.',
|
|
105
|
-
]),
|
|
179
|
+
source: Object.freeze({ label: query.sourceLabel, version: query.databaseVersion, license: query.license }),
|
|
180
|
+
limitations: LIMITATIONS,
|
|
106
181
|
});
|
|
182
|
+
if (!admission)
|
|
183
|
+
return result;
|
|
184
|
+
if (!rawMatch)
|
|
185
|
+
return currentResult(query, identity, admission, 'no_match');
|
|
186
|
+
if (!result.match || !prefix || !rawMatch || typeof rawMatch !== 'object' || Array.isArray(rawMatch))
|
|
187
|
+
return currentResult(query, identity, admission, 'invalid_record');
|
|
188
|
+
const rawCountry = country.iso_code;
|
|
189
|
+
const countryCode = boundedText(rawCountry, 2)?.toUpperCase() ?? null;
|
|
190
|
+
const projected = { ...result.match, countryCode: countryCode && /^[A-Z]{2}$/u.test(countryCode) ? countryCode : null };
|
|
191
|
+
if (![projected.countryCode, projected.region, projected.city, projected.asn, projected.asName].some(value => value !== null)) {
|
|
192
|
+
return currentResult(query, identity, admission, 'no_useful_record');
|
|
193
|
+
}
|
|
194
|
+
const invalid = rawCountry !== undefined && projected.countryCode === null
|
|
195
|
+
|| asnValue !== undefined && asn === null
|
|
196
|
+
|| match.country !== undefined && (!match.country || typeof match.country !== 'object' || Array.isArray(match.country))
|
|
197
|
+
|| match.city !== undefined && (!match.city || typeof match.city !== 'object' || Array.isArray(match.city))
|
|
198
|
+
|| match.subdivisions !== undefined && !Array.isArray(match.subdivisions)
|
|
199
|
+
|| subdivision.names !== undefined && projected.region === null
|
|
200
|
+
|| subdivision.iso_code !== undefined && projected.region === null
|
|
201
|
+
|| city.names !== undefined && projected.city === null
|
|
202
|
+
|| (match.autonomous_system_organization ?? traits.autonomous_system_organization) !== undefined && projected.asName === null;
|
|
203
|
+
return currentResult(query, identity, admission, invalid ? 'incomplete_record' : 'matched', projected);
|
|
204
|
+
}
|
|
205
|
+
function freezeResult(result) {
|
|
206
|
+
if (result.match)
|
|
207
|
+
Object.freeze(result.match);
|
|
208
|
+
Object.freeze(result.source);
|
|
209
|
+
Object.freeze(result.limitations);
|
|
210
|
+
if ('database' in result) {
|
|
211
|
+
if (result.database.metadata) {
|
|
212
|
+
Object.freeze(result.database.metadata.binaryFormat);
|
|
213
|
+
Object.freeze(result.database.metadata);
|
|
214
|
+
}
|
|
215
|
+
Object.freeze(result.database);
|
|
216
|
+
Object.freeze(result.freshness.policy);
|
|
217
|
+
Object.freeze(result.freshness);
|
|
218
|
+
}
|
|
219
|
+
return Object.freeze(result);
|
|
220
|
+
}
|
|
221
|
+
if (!isMainThread && parentPort && record(workerData).kind === LOCAL_MMDB_WORKER_KIND) {
|
|
222
|
+
const port = parentPort, request = workerData;
|
|
223
|
+
try {
|
|
224
|
+
const bytes = Buffer.from(request.bytes.buffer, request.bytes.byteOffset, request.bytes.byteLength);
|
|
225
|
+
const result = reviewBytes(request.query, bytes, request.identity);
|
|
226
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > MAX_LOCAL_MMDB_REVIEW_BYTES)
|
|
227
|
+
throw new TypeError('Local result exceeds its output bound.');
|
|
228
|
+
port.postMessage({ result });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
port.postMessage({ error: true });
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
request.bytes.fill(0);
|
|
235
|
+
port.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** One disposable worker pre-empts the existing synchronous reader. It is not
|
|
239
|
+
* a sandbox and never receives a database path or inherited environment. */
|
|
240
|
+
export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date().toISOString(), dependencies = {}) {
|
|
241
|
+
const query = prepareQuery(input, checkedAt);
|
|
242
|
+
const deadlineMs = dependencies.deadlineMs ?? LOCAL_MMDB_REVIEW_DEADLINE_MS;
|
|
243
|
+
if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0 || deadlineMs > LOCAL_MMDB_REVIEW_DEADLINE_MS)
|
|
244
|
+
throw new CliUsageError('Invalid local database processing deadline.');
|
|
245
|
+
const database = await readStableMmdb(databasePath, dependencies.openFile);
|
|
246
|
+
const identity = { sha256: createHash('sha256').update(database).digest('hex'), byteLength: database.length };
|
|
247
|
+
const options = { workerData: { kind: LOCAL_MMDB_WORKER_KIND, query, bytes: database, identity },
|
|
248
|
+
transferList: [database.buffer], env: {}, execArgv: [], stdout: true, stderr: true,
|
|
249
|
+
resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16, stackSizeMb: 4 } };
|
|
250
|
+
let worker;
|
|
251
|
+
try {
|
|
252
|
+
worker = dependencies.createWorker ? dependencies.createWorker(options) : new Worker(new URL(import.meta.url), options);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
if (database.byteLength)
|
|
256
|
+
database.fill(0);
|
|
257
|
+
if (query.version === 2)
|
|
258
|
+
return freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, 'processing_unavailable'));
|
|
259
|
+
throw new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.');
|
|
260
|
+
}
|
|
261
|
+
worker.stdout?.resume();
|
|
262
|
+
worker.stderr?.resume();
|
|
263
|
+
try {
|
|
264
|
+
return await new Promise((resolve, reject) => {
|
|
265
|
+
let settled = false;
|
|
266
|
+
const finish = (result, reason = 'processing_unavailable') => {
|
|
267
|
+
if (settled)
|
|
268
|
+
return;
|
|
269
|
+
settled = true;
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
if (result)
|
|
272
|
+
resolve(freezeResult(result));
|
|
273
|
+
else if (query.version === 2)
|
|
274
|
+
resolve(freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, reason)));
|
|
275
|
+
else
|
|
276
|
+
reject(new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.'));
|
|
277
|
+
};
|
|
278
|
+
const timer = setTimeout(() => finish(undefined, 'processing_deadline'), deadlineMs);
|
|
279
|
+
worker.once('message', (reply) => reply && 'result' in reply ? finish(reply.result) : finish());
|
|
280
|
+
worker.once('error', () => finish());
|
|
281
|
+
worker.once('exit', () => finish());
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
await worker.terminate();
|
|
286
|
+
}
|
|
107
287
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { scanBoundedJson } from "../
|
|
2
|
+
import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
|
|
3
3
|
const LOOKALIKE_CALIBRATION_INPUT_SCHEMA = 'whoisleuth.lookalike-calibration-input';
|
|
4
4
|
const LOOKALIKE_CALIBRATION_SCHEMA = 'whoisleuth.lookalike-calibration';
|
|
5
5
|
const LOOKALIKE_CALIBRATION_VERSION = 1;
|
package/cli/lookup-brief.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
2
2
|
import { safeTerminalValue } from "./formatters/terminal.mjs";
|
|
3
3
|
import { compareRdapPublications, compareRegistrySources } from "../lib/registry-comparison.mjs";
|
|
4
|
-
|
|
5
|
-
export
|
|
4
|
+
import { CLI_LOOKUP_BRIEF_SCHEMA, CLI_LOOKUP_BRIEF_VERSION } from "../packages/contracts/cli-lookup-brief.mjs";
|
|
5
|
+
export { CLI_LOOKUP_BRIEF_SCHEMA, CLI_LOOKUP_BRIEF_VERSION };
|
|
6
6
|
function record(value) {
|
|
7
7
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
8
8
|
}
|