@slicedearth/whoisleuth-cli 1.47.4 → 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/README.md +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- 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 +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- 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 +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -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 +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- 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-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- 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-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- 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 +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- 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/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- 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 -21
- 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} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- 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/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -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/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- 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} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- 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/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- 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} +70 -47
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -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} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- 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} +13 -13
- 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} +110 -89
- package/third-party-notices.txt +14 -14
- 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 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- 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 -255
- 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 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /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,6 +1,7 @@
|
|
|
1
1
|
import { abortable } from "../lib/abort.mjs";
|
|
2
2
|
import { classifyQuery } from "../lib/classify.mjs";
|
|
3
3
|
import { runUnifiedLookup } from "../lib/lookup.mjs";
|
|
4
|
+
import { plannedLookupProgressSources } from "../lib/lookup-source-progress.mjs";
|
|
4
5
|
import { CliUsageError, boundedCliErrorMessage } from "./errors.mjs";
|
|
5
6
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
6
7
|
import { buildCliEvidenceExport } from "./export-evidence.mjs";
|
|
@@ -10,10 +11,23 @@ import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
|
|
|
10
11
|
import { formatTerminalLookup } from "./formatters/terminal.mjs";
|
|
11
12
|
import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
|
|
12
13
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
14
|
+
import { serializeCliLookupDocument } from "./saved-lookup.mjs";
|
|
13
15
|
import { lookupStrictExitFindings } from "./strict-exit.mjs";
|
|
14
16
|
import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
|
|
15
17
|
import { formatCliJunit } from "./ci-report.mjs";
|
|
18
|
+
import { browseLookupOperation, canBrowseLookup } from "./lookup-browser.mjs";
|
|
19
|
+
import { writePrivateFile } from "./output-file.mjs";
|
|
16
20
|
async function runLookupCommand(args, dependencies, context) {
|
|
21
|
+
const browserInput = dependencies.stdin || process.stdin;
|
|
22
|
+
const browserEnvironment = dependencies.environment || process.env;
|
|
23
|
+
if (args.browse === true) {
|
|
24
|
+
if (!args.query)
|
|
25
|
+
throw new CliUsageError('--browse requires a positional target because interactive stdin is reserved for navigation.');
|
|
26
|
+
const supportsBrowser = dependencies.canBrowseLookup || canBrowseLookup;
|
|
27
|
+
if (!supportsBrowser(browserInput, context.stdout, browserEnvironment)) {
|
|
28
|
+
throw new CliUsageError('--browse requires interactive terminal input and output. Use ordinary terminal output or --json when redirecting.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
17
31
|
const eventProgress = createCliProgressEvents(context.stderr, {
|
|
18
32
|
command: 'lookup',
|
|
19
33
|
enabled: args.events,
|
|
@@ -43,51 +57,100 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
43
57
|
: context.terminal(formatCliLookupPlan(plan), args.color));
|
|
44
58
|
return EXIT_CODES.SUCCESS;
|
|
45
59
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
let documentGeneratedAt = '';
|
|
61
|
+
const buildDocument = (result) => {
|
|
62
|
+
documentGeneratedAt = context.now();
|
|
63
|
+
return buildCliLookupDocument(query, classified, result, documentGeneratedAt, args.deep ? 'deep' : 'fast', {
|
|
64
|
+
...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
|
|
65
|
+
...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
let document;
|
|
69
|
+
if (args.browse === true) {
|
|
70
|
+
const browse = dependencies.browseLookupOperation || browseLookupOperation;
|
|
71
|
+
document = await browse({
|
|
72
|
+
input: browserInput,
|
|
73
|
+
output: context.stdout,
|
|
74
|
+
environment: browserEnvironment,
|
|
75
|
+
color: args.color,
|
|
76
|
+
...(args.palette ? { palette: args.palette } : {}),
|
|
77
|
+
query,
|
|
78
|
+
mode: args.deep ? 'deep' : 'fast',
|
|
79
|
+
plannedSources: args.deep ? plannedLookupProgressSources(classified) : [],
|
|
80
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
81
|
+
collect: async ({ signal, onSourceSettled }) => {
|
|
82
|
+
const result = await abortable(() => executeLookup(classified, args.deep
|
|
83
|
+
? {
|
|
84
|
+
fast: false,
|
|
85
|
+
compact: false,
|
|
86
|
+
signal,
|
|
87
|
+
onSourceSettled: (settlement) => {
|
|
88
|
+
onSourceSettled(settlement);
|
|
89
|
+
eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
: {
|
|
93
|
+
fast: true,
|
|
94
|
+
compact: false,
|
|
95
|
+
signal,
|
|
96
|
+
}), signal);
|
|
97
|
+
return buildDocument(result);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
if (args.saveLookup) {
|
|
101
|
+
if (dependencies.signal?.aborted) {
|
|
102
|
+
throw dependencies.signal.reason || new DOMException('Aborted', 'AbortError');
|
|
62
103
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
const save = dependencies.writePrivateFile || writePrivateFile;
|
|
105
|
+
await save(args.saveLookup, serializeCliLookupDocument(document), {
|
|
106
|
+
existingFileMessage: 'Saved Lookup file already exists. Choose a new --save-lookup path or remove the existing file explicitly.',
|
|
107
|
+
});
|
|
108
|
+
context.writeStderr('Saved the completed private Lookup JSON. It can contain raw public registry and WHOIS responses plus target and collection context; review it before sharing.\n');
|
|
109
|
+
}
|
|
68
110
|
}
|
|
69
|
-
|
|
70
|
-
context.
|
|
111
|
+
else {
|
|
112
|
+
const indicator = context.beginProgress(args.deep
|
|
113
|
+
? 'Collecting deep Lookup evidence'
|
|
114
|
+
: 'Collecting registration evidence');
|
|
115
|
+
let settledSources = 0;
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = await abortable(() => executeLookup(classified, args.deep
|
|
119
|
+
? {
|
|
120
|
+
fast: false,
|
|
121
|
+
compact: false,
|
|
122
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
123
|
+
onSourceSettled: (settlement) => {
|
|
124
|
+
settledSources += 1;
|
|
125
|
+
indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
|
|
126
|
+
eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
: {
|
|
130
|
+
fast: true,
|
|
131
|
+
compact: false,
|
|
132
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
133
|
+
}), dependencies.signal);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
context.endProgress();
|
|
137
|
+
}
|
|
138
|
+
document = buildDocument(result);
|
|
71
139
|
}
|
|
72
|
-
const now = context.now();
|
|
73
|
-
const document = buildCliLookupDocument(query, classified, result, now, args.deep ? 'deep' : 'fast', {
|
|
74
|
-
...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
|
|
75
|
-
...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
|
|
76
|
-
});
|
|
77
140
|
if (!args.quiet) {
|
|
78
141
|
if (args.output === 'json')
|
|
79
|
-
context.writeStdout(
|
|
142
|
+
context.writeStdout(serializeCliLookupDocument(document));
|
|
80
143
|
else if (args.output === 'junit')
|
|
81
144
|
context.writeStdout(formatCliJunit(document));
|
|
82
145
|
else if (args.output === 'markdown' || args.output === 'html') {
|
|
83
146
|
const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
|
|
84
147
|
const evidenceModule = await loadEvidence();
|
|
85
|
-
const report = buildCliEvidenceExport(JSON.stringify(document), evidenceModule,
|
|
148
|
+
const report = buildCliEvidenceExport(JSON.stringify(document), evidenceModule, documentGeneratedAt);
|
|
86
149
|
context.writeStdout(args.output === 'markdown'
|
|
87
150
|
? formatLookupEvidenceMarkdown(report, { includeAttribution: args.includeAttribution })
|
|
88
151
|
: formatLookupEvidenceHtml(report, { includeAttribution: args.includeAttribution }));
|
|
89
152
|
}
|
|
90
|
-
else {
|
|
153
|
+
else if (args.browse !== true) {
|
|
91
154
|
context.writeStdout(context.terminal(formatTerminalLookup(document, { detail: args.detail }), args.color));
|
|
92
155
|
}
|
|
93
156
|
}
|
package/cli/lookup-diff.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildBulkComparisonEvidence } from "../lib/bulk-comparison-evidence.mjs";
|
|
2
2
|
import { recordOrEmpty } from "../lib/bounded-contract-normalizers.mjs";
|
|
3
|
-
import { buildBulkDomainComparison, } from "../
|
|
4
|
-
import { relationshipObservation } from "../
|
|
3
|
+
import { buildBulkDomainComparison, } from "../packages/investigation/bulk-domain-comparison.mjs";
|
|
4
|
+
import { relationshipObservation } from "../packages/comparison/relationship-evidence.mjs";
|
|
5
5
|
import { CliUsageError } from "./errors.mjs";
|
|
6
6
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
7
7
|
export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
|
package/cli/lookup-plan.mjs
CHANGED
package/cli/mail-review.mjs
CHANGED
|
@@ -1,45 +1,169 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { getDomain } from 'tldts';
|
|
3
|
-
import {
|
|
3
|
+
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
4
|
+
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
4
5
|
import { CliUsageError } from "./errors.mjs";
|
|
5
6
|
export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
|
|
6
|
-
export const CLI_MAIL_REVIEW_VERSION =
|
|
7
|
+
export const CLI_MAIL_REVIEW_VERSION = 3;
|
|
7
8
|
export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
|
|
8
9
|
export const MAX_MAIL_REVIEW_ROWS = 500;
|
|
10
|
+
const UNSAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
|
|
11
|
+
const UNSAFE_TEXT_GLOBAL_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]+/gu;
|
|
9
12
|
function record(value) {
|
|
10
13
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
11
14
|
}
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
12
18
|
function text(value, maximum = 500) {
|
|
13
19
|
return typeof value === 'string'
|
|
14
|
-
? value.replace(
|
|
20
|
+
? value.replace(UNSAFE_TEXT_GLOBAL_RE, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
15
21
|
: '';
|
|
16
22
|
}
|
|
17
23
|
function boolean(value) {
|
|
18
24
|
return typeof value === 'boolean' ? value : null;
|
|
19
25
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const output = new Set();
|
|
24
|
-
for (const candidate of [...direct, ...summarized].slice(0, 200)) {
|
|
25
|
-
const normalized = text(candidate, 300).replace(/^\d+\s+/u, '').replace(/\.$/u, '').toLowerCase();
|
|
26
|
-
if (normalized && normalized.length <= 253)
|
|
27
|
-
output.add(normalized);
|
|
28
|
-
if (output.size >= 50)
|
|
29
|
-
break;
|
|
26
|
+
function timestamp(value, label) {
|
|
27
|
+
if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
|
|
28
|
+
throw new CliUsageError(`${label} must be a valid bounded timestamp.`);
|
|
30
29
|
}
|
|
31
|
-
return
|
|
30
|
+
return new Date(value).toISOString();
|
|
32
31
|
}
|
|
33
|
-
function
|
|
32
|
+
function booleanOrNull(value, label) {
|
|
33
|
+
if (value === null || typeof value === 'boolean')
|
|
34
|
+
return value;
|
|
35
|
+
throw new CliUsageError(`${label} must be true, false, or null.`);
|
|
36
|
+
}
|
|
37
|
+
const DNS_STATES = new Set([
|
|
38
|
+
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped',
|
|
39
|
+
'disabled', 'not_applicable', 'unavailable', 'rate_limited',
|
|
40
|
+
]);
|
|
41
|
+
function parseMxValues(value, label) {
|
|
42
|
+
if (!Array.isArray(value) || value.length > 100) {
|
|
43
|
+
throw new CliUsageError(`${label} must be a bounded MX string array.`);
|
|
44
|
+
}
|
|
45
|
+
const hosts = new Set();
|
|
46
|
+
let nullMx = false;
|
|
47
|
+
for (const [index, candidate] of value.entries()) {
|
|
48
|
+
const itemLabel = `${label} item ${index + 1}`;
|
|
49
|
+
if (typeof candidate !== 'string' || candidate.length < 1 || candidate.length > 300
|
|
50
|
+
|| candidate.trim() !== candidate || UNSAFE_TEXT_RE.test(candidate)) {
|
|
51
|
+
throw new CliUsageError(`${itemLabel} is not a safe canonical MX value.`);
|
|
52
|
+
}
|
|
53
|
+
const match = /^(?:(\d{1,5}) )?([^\s]+)$/u.exec(candidate);
|
|
54
|
+
if (!match || (match[1] !== undefined && Number(match[1]) > 65_535)) {
|
|
55
|
+
throw new CliUsageError(`${itemLabel} is not a valid MX priority and hostname.`);
|
|
56
|
+
}
|
|
57
|
+
const exchange = match[2];
|
|
58
|
+
if (!exchange)
|
|
59
|
+
throw new CliUsageError(`${itemLabel} is missing its MX hostname.`);
|
|
60
|
+
if (exchange === '.') {
|
|
61
|
+
nullMx = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const hostname = exchange.replace(/\.$/u, '').toLowerCase();
|
|
65
|
+
if (!isValidAsciiDomainName(hostname, { requireDot: true, requireLowercase: true })) {
|
|
66
|
+
throw new CliUsageError(`${itemLabel} is not a valid ASCII MX hostname.`);
|
|
67
|
+
}
|
|
68
|
+
hosts.add(hostname);
|
|
69
|
+
}
|
|
70
|
+
if (nullMx && hosts.size)
|
|
71
|
+
throw new CliUsageError(`${label} mixes a null MX marker with mail hosts.`);
|
|
72
|
+
return Object.freeze({ hosts: Object.freeze([...hosts].sort()), nullMx });
|
|
73
|
+
}
|
|
74
|
+
function sameStrings(left, right) {
|
|
75
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
76
|
+
}
|
|
77
|
+
function validateMxEvidence(availability, summary, label, hasMx, hasNullMx) {
|
|
78
|
+
const direct = parseMxValues(availability.mxHosts, `${label} availability MX hosts`);
|
|
79
|
+
const summarized = parseMxValues(summary.mx, `${label} DNS summary MX hosts`);
|
|
80
|
+
if (!sameStrings(direct.hosts, summarized.hosts)) {
|
|
81
|
+
throw new CliUsageError(`${label} has inconsistent availability and DNS summary MX hosts.`);
|
|
82
|
+
}
|
|
83
|
+
if ((direct.nullMx || summarized.nullMx) && hasNullMx !== true) {
|
|
84
|
+
throw new CliUsageError(`${label} has a null MX marker without matching null-MX evidence.`);
|
|
85
|
+
}
|
|
86
|
+
if (hasNullMx === true && (hasMx !== false || summarized.hosts.length > 0)) {
|
|
87
|
+
throw new CliUsageError(`${label} has contradictory null-MX and mail-host evidence.`);
|
|
88
|
+
}
|
|
89
|
+
return summarized.hosts;
|
|
90
|
+
}
|
|
91
|
+
function validateMailRow(value, index, expectedVersion) {
|
|
92
|
+
const item = record(value);
|
|
93
|
+
const label = `Mail review row ${index + 1}`;
|
|
94
|
+
const version = Number(item.version);
|
|
95
|
+
if (item.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(version)
|
|
96
|
+
|| (expectedVersion !== null && version !== expectedVersion)) {
|
|
97
|
+
throw new CliUsageError(`${label} must use the matching WHOISleuth Bulk item schema version 2 or 3.`);
|
|
98
|
+
}
|
|
99
|
+
timestamp(item.generatedAt, `${label} generation time`);
|
|
100
|
+
if (version === 3
|
|
101
|
+
&& (!(item.observedAt === null || (typeof item.observedAt === 'string' && timestamp(item.observedAt, `${label} observation time`)))
|
|
102
|
+
|| !['current_run', 'resumed_checkpoint'].includes(String(item.collectionOrigin)))) {
|
|
103
|
+
throw new CliUsageError(`${label} has invalid version 3 collection provenance.`);
|
|
104
|
+
}
|
|
105
|
+
if (item.ok === false) {
|
|
106
|
+
if (typeof item.query !== 'string' || !item.query || item.query.length > 2_048
|
|
107
|
+
|| typeof item.error !== 'string' || !item.error || item.error.length > 500) {
|
|
108
|
+
throw new CliUsageError(`${label} contains an invalid failed result.`);
|
|
109
|
+
}
|
|
110
|
+
return item;
|
|
111
|
+
}
|
|
112
|
+
const availability = record(item.availability);
|
|
113
|
+
const diagnostics = record(item.diagnostics);
|
|
114
|
+
const summary = record(item.dnsSummary);
|
|
115
|
+
const domain = item.registrableDomain;
|
|
116
|
+
const inputHostname = item.inputHostname;
|
|
117
|
+
if (item.ok !== true || item.type !== 'domain' || typeof item.query !== 'string' || !item.query
|
|
118
|
+
|| typeof domain !== 'string' || !isValidAsciiDomainName(domain, { requireDot: true, requireLowercase: true })
|
|
119
|
+
|| typeof inputHostname !== 'string' || !isValidAsciiDomainName(inputHostname, { requireDot: true, requireLowercase: true })
|
|
120
|
+
|| getDomain(inputHostname, { allowPrivateDomains: true }) !== domain
|
|
121
|
+
|| typeof item.isSubdomain !== 'boolean' || !['fast', 'deep'].includes(String(item.mode))
|
|
122
|
+
|| !isRecord(item.availability) || !isRecord(item.diagnostics) || !isRecord(item.dnsSummary)) {
|
|
123
|
+
throw new CliUsageError(`${label} does not match a completed domain Bulk result.`);
|
|
124
|
+
}
|
|
125
|
+
const dns = record(availability.dns);
|
|
126
|
+
const dnsStatus = text(summary.status, 40).toLowerCase();
|
|
127
|
+
if (!DNS_STATES.has(dnsStatus) || text(dns.status, 40).toLowerCase() !== dnsStatus) {
|
|
128
|
+
throw new CliUsageError(`${label} has missing or inconsistent DNS source status.`);
|
|
129
|
+
}
|
|
130
|
+
for (const key of ['a', 'aaaa', 'ns', 'mx']) {
|
|
131
|
+
if (!Array.isArray(summary[key]) || summary[key].length > 100) {
|
|
132
|
+
throw new CliUsageError(`${label} has an invalid bounded DNS ${key} summary.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const summaryNullMx = booleanOrNull(summary.hasNullMx, `${label} null-MX summary`);
|
|
136
|
+
const summarySpf = booleanOrNull(summary.hasSpf, `${label} SPF summary`);
|
|
137
|
+
const summaryDmarc = booleanOrNull(summary.hasDmarc, `${label} DMARC summary`);
|
|
138
|
+
const availabilityMx = booleanOrNull(availability.hasMx, `${label} MX state`);
|
|
139
|
+
const availabilityNullMx = booleanOrNull(availability.hasNullMx, `${label} null-MX state`);
|
|
140
|
+
const availabilitySpf = booleanOrNull(availability.hasSpf, `${label} SPF state`);
|
|
141
|
+
const availabilityDmarc = booleanOrNull(availability.hasDmarc, `${label} DMARC state`);
|
|
142
|
+
if (availabilityNullMx !== summaryNullMx || availabilitySpf !== summarySpf || availabilityDmarc !== summaryDmarc) {
|
|
143
|
+
throw new CliUsageError(`${label} has contradictory DNS and availability mail states.`);
|
|
144
|
+
}
|
|
145
|
+
const summarizedMx = validateMxEvidence(availability, summary, label, availabilityMx, availabilityNullMx);
|
|
146
|
+
if (availabilityMx !== (summarizedMx.length ? true : availabilityNullMx === null ? null : false)) {
|
|
147
|
+
throw new CliUsageError(`${label} has contradictory MX evidence.`);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
...item,
|
|
151
|
+
availability: { ...availability, mxHosts: summarizedMx },
|
|
152
|
+
dnsSummary: { ...summary, mx: summarizedMx },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function allProviderDomains(hosts) {
|
|
34
156
|
return [...new Set(hosts.flatMap((host) => {
|
|
35
|
-
const
|
|
36
|
-
const domain = getDomain(host, { allowPrivateDomains: true })
|
|
37
|
-
?? (labels.length >= 2 ? labels.slice(-2).join('.') : null);
|
|
157
|
+
const domain = getDomain(host, { allowPrivateDomains: true });
|
|
38
158
|
return domain ? [domain.toLowerCase()] : [];
|
|
39
|
-
}))].sort()
|
|
159
|
+
}))].sort();
|
|
160
|
+
}
|
|
161
|
+
function providerDomains(hosts) {
|
|
162
|
+
const domains = allProviderDomains(hosts);
|
|
163
|
+
return { domains: domains.slice(0, 20), omitted: Math.max(0, domains.length - 20) };
|
|
40
164
|
}
|
|
41
165
|
function mailState(input) {
|
|
42
|
-
if (
|
|
166
|
+
if (input.dnsStatus !== 'success')
|
|
43
167
|
return 'evidence_incomplete';
|
|
44
168
|
if (input.hasNullMx === true)
|
|
45
169
|
return 'null_mx';
|
|
@@ -54,50 +178,23 @@ function mailState(input) {
|
|
|
54
178
|
return 'no_explicit_mx';
|
|
55
179
|
return 'evidence_incomplete';
|
|
56
180
|
}
|
|
57
|
-
function alignMailDaneEvidence(report, hosts) {
|
|
58
|
-
const aligned = report.service?.transport === 'tcp'
|
|
59
|
-
&& report.service.port === 25
|
|
60
|
-
&& hosts.includes(report.service.hostname);
|
|
61
|
-
if (aligned || report.state === 'invalid')
|
|
62
|
-
return report;
|
|
63
|
-
return Object.freeze({
|
|
64
|
-
...report,
|
|
65
|
-
state: 'invalid',
|
|
66
|
-
limitations: Object.freeze([
|
|
67
|
-
...report.limitations,
|
|
68
|
-
'The TLSA service name did not identify port 25 on an MX hostname retained for this domain.',
|
|
69
|
-
]),
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
181
|
function normalizeMailRow(value) {
|
|
73
182
|
const item = record(value);
|
|
74
183
|
if (item.ok !== true)
|
|
75
184
|
return null;
|
|
76
185
|
const availability = record(item.availability);
|
|
77
186
|
const summary = record(item.dnsSummary);
|
|
78
|
-
const domain = text(item.registrableDomain
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const hasDmarc = boolean(availability.hasDmarc) ?? boolean(summary.hasDmarc);
|
|
187
|
+
const domain = text(item.registrableDomain, 253).toLowerCase().replace(/\.$/u, '');
|
|
188
|
+
const dnsStatus = text(summary.status, 40).toLowerCase();
|
|
189
|
+
const hosts = Array.isArray(availability.mxHosts)
|
|
190
|
+
? availability.mxHosts.filter((value) => typeof value === 'string')
|
|
191
|
+
: [];
|
|
192
|
+
const hasMx = boolean(availability.hasMx);
|
|
193
|
+
const hasNullMx = boolean(availability.hasNullMx);
|
|
194
|
+
const hasSpf = boolean(availability.hasSpf);
|
|
195
|
+
const hasDmarc = boolean(availability.hasDmarc);
|
|
88
196
|
const state = mailState({ dnsStatus, hasMx, hasNullMx, hasSpf, hasDmarc });
|
|
89
|
-
const
|
|
90
|
-
const dane = Object.keys(tlsaInput).length
|
|
91
|
-
? alignMailDaneEvidence(analyzeTlsaEvidence({
|
|
92
|
-
serviceName: tlsaInput.serviceName,
|
|
93
|
-
dnssecState: tlsaInput.dnssecState,
|
|
94
|
-
pkixValidationState: tlsaInput.pkixValidationState,
|
|
95
|
-
records: tlsaInput.records,
|
|
96
|
-
certificateDerBase64: tlsaInput.certificateDerBase64,
|
|
97
|
-
spkiDerBase64: tlsaInput.spkiDerBase64,
|
|
98
|
-
authorityMaterials: tlsaInput.authorityMaterials,
|
|
99
|
-
}), hosts)
|
|
100
|
-
: null;
|
|
197
|
+
const providers = providerDomains(hosts);
|
|
101
198
|
return {
|
|
102
199
|
domain,
|
|
103
200
|
state,
|
|
@@ -107,12 +204,12 @@ function normalizeMailRow(value) {
|
|
|
107
204
|
hasSpf,
|
|
108
205
|
hasDmarc,
|
|
109
206
|
mxHosts: hosts,
|
|
110
|
-
providerDomains:
|
|
111
|
-
|
|
207
|
+
providerDomains: providers.domains,
|
|
208
|
+
providerDomainsOmitted: providers.omitted,
|
|
209
|
+
providerDomainsTruncated: providers.omitted > 0,
|
|
112
210
|
limitations: [
|
|
113
211
|
...(dnsStatus === 'partial' ? ['DNS evidence was partial.'] : []),
|
|
114
212
|
...(state === 'no_explicit_mx' ? ['No explicit MX is not equivalent to a null MX or proof that delivery is impossible.'] : []),
|
|
115
|
-
...(dane?.state === 'matched' ? [] : dane ? ['Supplied TLSA evidence did not establish one complete DNSSEC-validated match.'] : ['No TLSA evidence was supplied for offline DANE comparison.']),
|
|
116
213
|
'Mail configuration does not establish use, control, intent, safety, or maliciousness.',
|
|
117
214
|
],
|
|
118
215
|
};
|
|
@@ -125,11 +222,15 @@ function parseInput(textValue) {
|
|
|
125
222
|
if (!textInput)
|
|
126
223
|
throw new CliUsageError('Mail review requires one Bulk JSON or JSONL document.');
|
|
127
224
|
let values;
|
|
225
|
+
let containerVersion = null;
|
|
128
226
|
try {
|
|
227
|
+
scanBoundedJson(textInput);
|
|
129
228
|
const parsed = JSON.parse(textInput);
|
|
130
229
|
const document = record(parsed);
|
|
131
230
|
if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
|
|
132
231
|
values = document.results;
|
|
232
|
+
containerVersion = Number(document.version);
|
|
233
|
+
timestamp(document.generatedAt, 'Mail review Bulk generation time');
|
|
133
234
|
}
|
|
134
235
|
else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
|
|
135
236
|
values = [document];
|
|
@@ -142,7 +243,10 @@ function parseInput(textValue) {
|
|
|
142
243
|
if (error instanceof CliUsageError)
|
|
143
244
|
throw error;
|
|
144
245
|
try {
|
|
145
|
-
values = textInput.split(/\r?\n/u).filter(Boolean).map((line) =>
|
|
246
|
+
values = textInput.split(/\r?\n/u).filter(Boolean).map((line) => {
|
|
247
|
+
scanBoundedJson(line);
|
|
248
|
+
return JSON.parse(line);
|
|
249
|
+
});
|
|
146
250
|
}
|
|
147
251
|
catch {
|
|
148
252
|
throw new CliUsageError('Mail review input must be valid WHOISleuth Bulk JSON or JSONL.');
|
|
@@ -151,11 +255,7 @@ function parseInput(textValue) {
|
|
|
151
255
|
if (!values.length || values.length > MAX_MAIL_REVIEW_ROWS) {
|
|
152
256
|
throw new CliUsageError(`Mail review supports between 1 and ${MAX_MAIL_REVIEW_ROWS} Bulk rows.`);
|
|
153
257
|
}
|
|
154
|
-
|
|
155
|
-
if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(Number(value.version)))) {
|
|
156
|
-
throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2 or 3.');
|
|
157
|
-
}
|
|
158
|
-
return rows;
|
|
258
|
+
return values.map((value, index) => validateMailRow(value, index, containerVersion));
|
|
159
259
|
}
|
|
160
260
|
function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
161
261
|
const inputRows = parseInput(textValue);
|
|
@@ -172,48 +272,59 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
172
272
|
};
|
|
173
273
|
for (const row of rows)
|
|
174
274
|
counts[row.state] += 1;
|
|
175
|
-
const daneCounts = {
|
|
176
|
-
matched: 0,
|
|
177
|
-
different: 0,
|
|
178
|
-
partial: 0,
|
|
179
|
-
untrusted: 0,
|
|
180
|
-
unavailable: 0,
|
|
181
|
-
invalid: 0,
|
|
182
|
-
not_supplied: 0,
|
|
183
|
-
};
|
|
184
|
-
for (const row of rows)
|
|
185
|
-
daneCounts[row.dane?.state ?? 'not_supplied'] += 1;
|
|
186
275
|
const providerMap = new Map();
|
|
187
276
|
for (const row of rows) {
|
|
188
|
-
|
|
277
|
+
// Row presentation is capped at 20 providers, but correlation operates on
|
|
278
|
+
// the complete already-validated MX set (itself bounded to 100 entries).
|
|
279
|
+
for (const provider of allProviderDomains(row.mxHosts)) {
|
|
189
280
|
const domains = providerMap.get(provider) ?? [];
|
|
190
281
|
domains.push(row.domain);
|
|
191
282
|
providerMap.set(provider, domains);
|
|
192
283
|
}
|
|
193
284
|
}
|
|
194
|
-
const
|
|
285
|
+
const allProviderRelationships = [...providerMap.entries()]
|
|
195
286
|
.filter(([, domains]) => new Set(domains).size >= 2)
|
|
196
|
-
.map(([providerDomain, domains]) =>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
287
|
+
.map(([providerDomain, domains]) => {
|
|
288
|
+
const uniqueDomains = [...new Set(domains)].sort();
|
|
289
|
+
const omittedDomains = Math.max(0, uniqueDomains.length - 100);
|
|
290
|
+
return {
|
|
291
|
+
providerDomain,
|
|
292
|
+
domains: uniqueDomains.slice(0, 100),
|
|
293
|
+
domainCount: uniqueDomains.length,
|
|
294
|
+
domainsTruncated: omittedDomains > 0,
|
|
295
|
+
omittedDomains,
|
|
296
|
+
method: 'Shared registrable domain of an observed MX hostname',
|
|
297
|
+
limitation: 'Shared mail providers are common and do not establish common ownership, control, intent, safety, or maliciousness.',
|
|
298
|
+
};
|
|
299
|
+
})
|
|
300
|
+
.sort((left, right) => left.providerDomain.localeCompare(right.providerDomain));
|
|
301
|
+
const providerRelationships = allProviderRelationships.slice(0, 100);
|
|
302
|
+
const providerCoverage = {
|
|
303
|
+
complete: rows.every((row) => !row.providerDomainsTruncated)
|
|
304
|
+
&& allProviderRelationships.length <= 100
|
|
305
|
+
&& allProviderRelationships.every((relationship) => !relationship.domainsTruncated),
|
|
306
|
+
rowsWithOmittedProviders: rows.filter((row) => row.providerDomainsTruncated).length,
|
|
307
|
+
omittedProviderDomains: rows.reduce((sum, row) => sum + row.providerDomainsOmitted, 0),
|
|
308
|
+
relationshipCount: allProviderRelationships.length,
|
|
309
|
+
retainedRelationshipCount: providerRelationships.length,
|
|
310
|
+
omittedRelationships: Math.max(0, allProviderRelationships.length - 100),
|
|
311
|
+
omittedRelationshipDomains: allProviderRelationships.reduce((sum, relationship) => sum + relationship.omittedDomains, 0),
|
|
312
|
+
};
|
|
204
313
|
return {
|
|
205
314
|
schema: CLI_MAIL_REVIEW_SCHEMA,
|
|
206
315
|
version: CLI_MAIL_REVIEW_VERSION,
|
|
207
316
|
generatedAt,
|
|
208
317
|
counts,
|
|
209
|
-
daneCounts,
|
|
210
318
|
rows,
|
|
211
319
|
providerRelationships,
|
|
320
|
+
providerCoverage,
|
|
212
321
|
limitations: [
|
|
213
322
|
'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
|
|
214
323
|
'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
|
|
215
|
-
'Optional TLSA evidence is compared offline. A DANE match requires separately validated DNSSEC evidence from the same observation.',
|
|
216
324
|
'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
|
|
325
|
+
...(!providerCoverage.complete
|
|
326
|
+
? ['Mail-provider relationship coverage reached a configured row, relationship, or domain bound; omitted relationships remain unknown.']
|
|
327
|
+
: []),
|
|
217
328
|
],
|
|
218
329
|
};
|
|
219
330
|
}
|
|
@@ -226,24 +337,25 @@ function formatCliMailReview(document) {
|
|
|
226
337
|
`Null MX ${document.counts.null_mx}`,
|
|
227
338
|
`No explicit MX ${document.counts.no_explicit_mx}`,
|
|
228
339
|
`Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete}`,
|
|
229
|
-
`DANE matched ${document.daneCounts.matched}`,
|
|
230
340
|
'',
|
|
231
341
|
];
|
|
232
342
|
for (const row of document.rows) {
|
|
233
343
|
lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
|
|
234
|
-
lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}`);
|
|
344
|
+
lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}${row.providerDomainsOmitted ? ` · +${row.providerDomainsOmitted} omitted` : ''}`);
|
|
235
345
|
lines.push(` SPF / DMARC ${row.hasSpf === null ? 'unknown' : row.hasSpf ? 'observed' : 'not observed'} / ${row.hasDmarc === null ? 'unknown' : row.hasDmarc ? 'observed' : 'not observed'}`);
|
|
236
|
-
lines.push(` DANE / TLSA ${row.dane?.state ?? 'not supplied'}`);
|
|
237
346
|
}
|
|
238
347
|
if (document.providerRelationships.length) {
|
|
239
348
|
lines.push('', 'Shared mail-provider relationships');
|
|
240
349
|
for (const relationship of document.providerRelationships) {
|
|
241
|
-
lines.push(`${relationship.providerDomain} ${relationship.domains.join(', ')}`);
|
|
350
|
+
lines.push(`${relationship.providerDomain} ${relationship.domains.join(', ')}${relationship.omittedDomains ? ` · +${relationship.omittedDomains} omitted` : ''}`);
|
|
242
351
|
}
|
|
243
352
|
}
|
|
353
|
+
if (!document.providerCoverage.complete) {
|
|
354
|
+
lines.push('', `Provider coverage partial ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
|
|
355
|
+
}
|
|
244
356
|
lines.push('', 'Limitations:');
|
|
245
357
|
for (const limitation of document.limitations)
|
|
246
358
|
lines.push(` - ${limitation}`);
|
|
247
359
|
return `${lines.join('\n')}\n`;
|
|
248
360
|
}
|
|
249
|
-
export {
|
|
361
|
+
export { buildCliMailReview, formatCliMailReview, mailState, normalizeMailRow };
|
package/cli/manual.mjs
CHANGED
|
@@ -12,20 +12,20 @@ function buildCliManual(options) {
|
|
|
12
12
|
}).join('\n');
|
|
13
13
|
return `.TH WHOISLEUTH 1 "" "WHOISleuth ${roffText(options.version)}" "User Commands"
|
|
14
14
|
.SH NAME
|
|
15
|
-
whoisleuth \-
|
|
15
|
+
whoisleuth \- domain investigation from the terminal
|
|
16
16
|
.SH SYNOPSIS
|
|
17
17
|
.B whoisleuth
|
|
18
|
-
command [options]
|
|
18
|
+
[command|target] [options]
|
|
19
19
|
.SH DESCRIPTION
|
|
20
|
-
WHOISleuth performs
|
|
20
|
+
WHOISleuth performs WHOIS, RDAP, DNS, HTTP, TLS, certificate-transparency, posture, lookalike and offline evidence operations with explicit source and collection states. With no arguments, an eligible interactive terminal opens the Lookup and command launcher; unsupported or redirected terminals print help.
|
|
21
21
|
.SH COMMANDS
|
|
22
22
|
${commands}
|
|
23
23
|
.SH OUTPUT
|
|
24
|
-
Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events
|
|
24
|
+
Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events use standard error. Use --output and optional --force for atomic private file output. Use --palette auto, light, or dark for a fixed terminal palette. Lookup --browse provides an interactive terminal view.
|
|
25
25
|
.SH EXIT STATUS
|
|
26
26
|
0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
|
|
27
27
|
.SH PRIVACY
|
|
28
|
-
Network commands disclose the target to the
|
|
28
|
+
Network commands disclose the target to the sources named in focused help. Offline commands read local input only. Output files use private permissions and remain on the operator's machine.
|
|
29
29
|
.SH LICENSE
|
|
30
30
|
AGPL-3.0-only. Copyright 2026 slicedearth.
|
|
31
31
|
.SH SEE ALSO
|