@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
4
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
5
|
+
import { CliUsageError } from "./errors.mjs";
|
|
6
|
+
export const CLI_MAIL_HEADER_REVIEW_SCHEMA = 'whoisleuth.cli.mail-header-review';
|
|
7
|
+
export const CLI_MAIL_HEADER_REVIEW_VERSION = 1;
|
|
8
|
+
export const MAX_MAIL_HEADER_INPUT_BYTES = 256 * 1024;
|
|
9
|
+
export const MAX_MAIL_HEADER_FIELDS = 512;
|
|
10
|
+
export const MAX_MAIL_HEADER_LINE_BYTES = 4 * 1024;
|
|
11
|
+
export const MAX_MAIL_HEADER_VALUE_LENGTH = 16 * 1024;
|
|
12
|
+
export const MAX_MAIL_RECEIVED_HOPS = 64;
|
|
13
|
+
export const MAX_MAIL_REVIEW_DOMAINS = 128;
|
|
14
|
+
const UNSAFE_HEADER_CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
|
|
15
|
+
const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,78}$/u;
|
|
16
|
+
const AUTHENTICATION_METHODS = Object.freeze(['spf', 'dkim', 'dmarc', 'arc']);
|
|
17
|
+
const AUTHENTICATION_STATES = new Set(['fail', 'neutral', 'none', 'pass', 'permerror', 'softfail', 'temperror']);
|
|
18
|
+
function boundedHeaderSection(value) {
|
|
19
|
+
if (typeof value !== 'string' || !value)
|
|
20
|
+
throw new CliUsageError('Mail-header review requires one UTF-8 message or header block.');
|
|
21
|
+
const candidate = value.slice(0, MAX_MAIL_HEADER_INPUT_BYTES * 2 + 4);
|
|
22
|
+
const crlf = candidate.indexOf('\r\n\r\n');
|
|
23
|
+
const lf = candidate.indexOf('\n\n');
|
|
24
|
+
const separator = crlf < 0 ? lf : lf < 0 ? crlf : Math.min(crlf, lf);
|
|
25
|
+
const header = separator >= 0 ? candidate.slice(0, separator) : candidate;
|
|
26
|
+
const bytes = Buffer.byteLength(header, 'utf8');
|
|
27
|
+
if (bytes < 1 || bytes > MAX_MAIL_HEADER_INPUT_BYTES || (separator < 0 && value.length > candidate.length)) {
|
|
28
|
+
throw new CliUsageError(`Mail headers are limited to ${MAX_MAIL_HEADER_INPUT_BYTES} bytes before the message body.`);
|
|
29
|
+
}
|
|
30
|
+
return header;
|
|
31
|
+
}
|
|
32
|
+
function parseHeaderFields(value) {
|
|
33
|
+
const header = boundedHeaderSection(value);
|
|
34
|
+
const physicalLines = header.split(/\r\n|\n/u);
|
|
35
|
+
if (physicalLines.at(-1) === '')
|
|
36
|
+
physicalLines.pop();
|
|
37
|
+
const fields = [];
|
|
38
|
+
for (const [index, line] of physicalLines.entries()) {
|
|
39
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_MAIL_HEADER_LINE_BYTES) {
|
|
40
|
+
throw new CliUsageError(`Mail header line ${index + 1} exceeds the ${MAX_MAIL_HEADER_LINE_BYTES}-byte limit.`);
|
|
41
|
+
}
|
|
42
|
+
if (UNSAFE_HEADER_CONTROL_RE.test(line))
|
|
43
|
+
throw new CliUsageError(`Mail header line ${index + 1} contains unsafe control characters.`);
|
|
44
|
+
if (/^[ \t]/u.test(line)) {
|
|
45
|
+
const previous = fields.at(-1);
|
|
46
|
+
if (!previous)
|
|
47
|
+
throw new CliUsageError('Mail headers contain a continuation without a preceding field.');
|
|
48
|
+
const continuation = line.trim();
|
|
49
|
+
const combined = continuation ? `${previous.value} ${continuation}` : previous.value;
|
|
50
|
+
if (combined.length > MAX_MAIL_HEADER_VALUE_LENGTH)
|
|
51
|
+
throw new CliUsageError(`Mail header ${previous.name} exceeds its unfolded value limit.`);
|
|
52
|
+
previous.value = combined;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const separator = line.indexOf(':');
|
|
56
|
+
if (separator < 1)
|
|
57
|
+
throw new CliUsageError(`Mail header line ${index + 1} is malformed.`);
|
|
58
|
+
const name = line.slice(0, separator);
|
|
59
|
+
const fieldValue = line.slice(separator + 1).trim();
|
|
60
|
+
if (!HEADER_NAME_RE.test(name))
|
|
61
|
+
throw new CliUsageError(`Mail header line ${index + 1} has an invalid field name.`);
|
|
62
|
+
if (fieldValue.length > MAX_MAIL_HEADER_VALUE_LENGTH)
|
|
63
|
+
throw new CliUsageError(`Mail header ${name} exceeds its unfolded value limit.`);
|
|
64
|
+
fields.push({ name: name.toLowerCase(), value: fieldValue });
|
|
65
|
+
if (fields.length > MAX_MAIL_HEADER_FIELDS)
|
|
66
|
+
throw new CliUsageError(`Mail headers contain more than ${MAX_MAIL_HEADER_FIELDS} fields.`);
|
|
67
|
+
}
|
|
68
|
+
if (!fields.length)
|
|
69
|
+
throw new CliUsageError('Mail-header review did not find any header fields.');
|
|
70
|
+
return Object.freeze({
|
|
71
|
+
fields: Object.freeze(fields.map((field) => Object.freeze({ ...field }))),
|
|
72
|
+
bytes: Buffer.byteLength(header, 'utf8'),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function fieldsNamed(fields, name) {
|
|
76
|
+
return fields.filter((field) => field.name === name).map((field) => field.value);
|
|
77
|
+
}
|
|
78
|
+
function normalizeHeaderDomain(value) {
|
|
79
|
+
const normalized = value.trim().toLowerCase().replace(/^\[|\]$/gu, '').replace(/\.$/u, '');
|
|
80
|
+
return isValidAsciiDomainName(normalized, { requireLowercase: true }) ? normalized : null;
|
|
81
|
+
}
|
|
82
|
+
function lexicalSegments(value, delimiter, trackAngles = false) {
|
|
83
|
+
const segments = [];
|
|
84
|
+
let start = 0;
|
|
85
|
+
let quoted = false;
|
|
86
|
+
let escaped = false;
|
|
87
|
+
let commentDepth = 0;
|
|
88
|
+
let angleDepth = 0;
|
|
89
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
90
|
+
const character = value[index] ?? '';
|
|
91
|
+
if (escaped) {
|
|
92
|
+
escaped = false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
96
|
+
escaped = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (commentDepth > 0) {
|
|
100
|
+
if (character === '(')
|
|
101
|
+
commentDepth += 1;
|
|
102
|
+
else if (character === ')')
|
|
103
|
+
commentDepth -= 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (quoted) {
|
|
107
|
+
if (character === '"')
|
|
108
|
+
quoted = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (character === '"')
|
|
112
|
+
quoted = true;
|
|
113
|
+
else if (character === '(')
|
|
114
|
+
commentDepth = 1;
|
|
115
|
+
else if (character === ')')
|
|
116
|
+
return null;
|
|
117
|
+
else if (trackAngles && character === '<') {
|
|
118
|
+
if (angleDepth !== 0)
|
|
119
|
+
return null;
|
|
120
|
+
angleDepth = 1;
|
|
121
|
+
}
|
|
122
|
+
else if (trackAngles && character === '>') {
|
|
123
|
+
if (angleDepth !== 1)
|
|
124
|
+
return null;
|
|
125
|
+
angleDepth = 0;
|
|
126
|
+
}
|
|
127
|
+
else if (character === delimiter && angleDepth === 0) {
|
|
128
|
+
segments.push(value.slice(start, index));
|
|
129
|
+
start = index + 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (escaped || quoted || commentDepth !== 0 || angleDepth !== 0)
|
|
133
|
+
return null;
|
|
134
|
+
segments.push(value.slice(start));
|
|
135
|
+
return segments;
|
|
136
|
+
}
|
|
137
|
+
function angleAddress(value) {
|
|
138
|
+
let quoted = false;
|
|
139
|
+
let escaped = false;
|
|
140
|
+
let commentDepth = 0;
|
|
141
|
+
let start = -1;
|
|
142
|
+
let end = -1;
|
|
143
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
144
|
+
const character = value[index] ?? '';
|
|
145
|
+
if (escaped) {
|
|
146
|
+
escaped = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
150
|
+
escaped = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (commentDepth > 0) {
|
|
154
|
+
if (character === '(')
|
|
155
|
+
commentDepth += 1;
|
|
156
|
+
else if (character === ')')
|
|
157
|
+
commentDepth -= 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (quoted) {
|
|
161
|
+
if (character === '"')
|
|
162
|
+
quoted = false;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (character === '"')
|
|
166
|
+
quoted = true;
|
|
167
|
+
else if (character === '(')
|
|
168
|
+
commentDepth = 1;
|
|
169
|
+
else if (character === ')')
|
|
170
|
+
return null;
|
|
171
|
+
else if (character === '<') {
|
|
172
|
+
if (start >= 0 || end >= 0)
|
|
173
|
+
return null;
|
|
174
|
+
start = index;
|
|
175
|
+
}
|
|
176
|
+
else if (character === '>') {
|
|
177
|
+
if (start < 0 || end >= 0)
|
|
178
|
+
return null;
|
|
179
|
+
end = index;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (escaped || quoted || commentDepth !== 0 || (start >= 0) !== (end >= 0))
|
|
183
|
+
return null;
|
|
184
|
+
return start >= 0 ? value.slice(start + 1, end) : value;
|
|
185
|
+
}
|
|
186
|
+
function addressDomain(value) {
|
|
187
|
+
let candidate = '';
|
|
188
|
+
let quoted = false;
|
|
189
|
+
let escaped = false;
|
|
190
|
+
let commentDepth = 0;
|
|
191
|
+
const separators = [];
|
|
192
|
+
for (const character of value) {
|
|
193
|
+
if (escaped) {
|
|
194
|
+
candidate += character;
|
|
195
|
+
escaped = false;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
199
|
+
if (quoted)
|
|
200
|
+
candidate += character;
|
|
201
|
+
escaped = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (commentDepth > 0) {
|
|
205
|
+
if (character === '(')
|
|
206
|
+
commentDepth += 1;
|
|
207
|
+
else if (character === ')')
|
|
208
|
+
commentDepth -= 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (quoted) {
|
|
212
|
+
candidate += character;
|
|
213
|
+
if (character === '"')
|
|
214
|
+
quoted = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (character === '"') {
|
|
218
|
+
quoted = true;
|
|
219
|
+
candidate += character;
|
|
220
|
+
}
|
|
221
|
+
else if (character === '(')
|
|
222
|
+
commentDepth = 1;
|
|
223
|
+
else if (character === ')')
|
|
224
|
+
return null;
|
|
225
|
+
else {
|
|
226
|
+
if (character === '@')
|
|
227
|
+
separators.push(candidate.length);
|
|
228
|
+
candidate += character;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (escaped || quoted || commentDepth !== 0 || separators.length !== 1)
|
|
232
|
+
return null;
|
|
233
|
+
const separator = separators[0] ?? -1;
|
|
234
|
+
const local = candidate.slice(0, separator).trim();
|
|
235
|
+
const domain = candidate.slice(separator + 1).trim();
|
|
236
|
+
const validLocal = /^(?:[^\s"<>(),:;@]+|"(?:[^"\\]|\\.)+")$/u.test(local);
|
|
237
|
+
if (!validLocal || !domain || /\s/u.test(domain))
|
|
238
|
+
return null;
|
|
239
|
+
return normalizeHeaderDomain(domain);
|
|
240
|
+
}
|
|
241
|
+
function addressDomains(values) {
|
|
242
|
+
const domains = new Set();
|
|
243
|
+
for (const value of values) {
|
|
244
|
+
const mailboxes = lexicalSegments(value, ',', true);
|
|
245
|
+
if (!mailboxes)
|
|
246
|
+
continue;
|
|
247
|
+
for (const mailbox of mailboxes) {
|
|
248
|
+
const selected = angleAddress(mailbox);
|
|
249
|
+
const domain = selected === null ? null : addressDomain(selected);
|
|
250
|
+
if (domain)
|
|
251
|
+
domains.add(domain);
|
|
252
|
+
if (domains.size >= MAX_MAIL_REVIEW_DOMAINS)
|
|
253
|
+
return [...domains].sort();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return [...domains].sort();
|
|
257
|
+
}
|
|
258
|
+
function messageIdDomains(values) {
|
|
259
|
+
return addressDomains(values.map((value) => value.replace(/[<>]/gu, '')));
|
|
260
|
+
}
|
|
261
|
+
function authenticationState(values, method, directValues = []) {
|
|
262
|
+
const states = new Set();
|
|
263
|
+
let observations = 0;
|
|
264
|
+
for (const value of values) {
|
|
265
|
+
const clauses = lexicalSegments(value, ';');
|
|
266
|
+
if (!clauses)
|
|
267
|
+
continue;
|
|
268
|
+
for (const clause of clauses) {
|
|
269
|
+
const match = /^\s*([a-z][a-z0-9_-]*)\s*=\s*([a-z0-9_-]+)(?=\s|$)/iu.exec(clause);
|
|
270
|
+
if (match?.[1]?.toLowerCase() === method) {
|
|
271
|
+
observations += 1;
|
|
272
|
+
const raw = (match[2] ?? '').toLowerCase();
|
|
273
|
+
states.add(AUTHENTICATION_STATES.has(raw) ? raw : 'unknown');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const value of directValues) {
|
|
278
|
+
const match = /^\s*([a-z0-9_-]+)(?=\s|\(|$)/iu.exec(value);
|
|
279
|
+
const raw = (match?.[1] ?? '').toLowerCase();
|
|
280
|
+
if (!raw)
|
|
281
|
+
continue;
|
|
282
|
+
observations += 1;
|
|
283
|
+
states.add(AUTHENTICATION_STATES.has(raw) ? raw : 'unknown');
|
|
284
|
+
}
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
state: observations === 0 ? 'unknown' : states.size === 1 ? [...states][0] : 'mixed',
|
|
287
|
+
observations,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function authenticationServiceDomains(values) {
|
|
291
|
+
const output = new Set();
|
|
292
|
+
for (const value of values) {
|
|
293
|
+
const clauses = lexicalSegments(value, ';');
|
|
294
|
+
const service = normalizeHeaderDomain((clauses?.[0] ?? '').trim().split(/\s/u, 1)[0] ?? '');
|
|
295
|
+
if (service)
|
|
296
|
+
output.add(service);
|
|
297
|
+
}
|
|
298
|
+
return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
|
|
299
|
+
}
|
|
300
|
+
function dkimSigningDomains(values) {
|
|
301
|
+
const output = new Set();
|
|
302
|
+
for (const value of values) {
|
|
303
|
+
for (const clause of lexicalSegments(value, ';') ?? []) {
|
|
304
|
+
const match = /^\s*d\s*=\s*([^\s;]+)/iu.exec(clause);
|
|
305
|
+
const domain = normalizeHeaderDomain(match?.[1] ?? '');
|
|
306
|
+
if (domain)
|
|
307
|
+
output.add(domain);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
|
|
311
|
+
}
|
|
312
|
+
function reportedDate(values) {
|
|
313
|
+
if (values.length !== 1 || values[0].length > 240)
|
|
314
|
+
return null;
|
|
315
|
+
const parsed = Date.parse(values[0]);
|
|
316
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
317
|
+
}
|
|
318
|
+
function receivedHost(value, keyword) {
|
|
319
|
+
const expression = keyword === 'from' ? /(?:^|\s)from\s+([^\s(;]+)/iu : /(?:^|\s)by\s+([^\s(;]+)/iu;
|
|
320
|
+
const match = expression.exec(value);
|
|
321
|
+
return normalizeHeaderDomain(match?.[1] ?? '');
|
|
322
|
+
}
|
|
323
|
+
function receivedTransport(value) {
|
|
324
|
+
const match = /(?:^|\s)with\s+([A-Za-z0-9_-]{1,40})/iu.exec(value);
|
|
325
|
+
return match?.[1]?.toUpperCase() ?? null;
|
|
326
|
+
}
|
|
327
|
+
function receivedTimestamp(value) {
|
|
328
|
+
const separator = value.lastIndexOf(';');
|
|
329
|
+
if (separator < 0)
|
|
330
|
+
return null;
|
|
331
|
+
const raw = value.slice(separator + 1).trim();
|
|
332
|
+
if (!raw || raw.length > 240)
|
|
333
|
+
return null;
|
|
334
|
+
const parsed = Date.parse(raw);
|
|
335
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
336
|
+
}
|
|
337
|
+
function exactAlignment(left, right) {
|
|
338
|
+
if (!left.length || !right.length)
|
|
339
|
+
return 'unavailable';
|
|
340
|
+
return left.some((domain) => right.includes(domain)) ? 'aligned' : 'divergent';
|
|
341
|
+
}
|
|
342
|
+
function addDomainRoles(store, domains, role) {
|
|
343
|
+
for (const domain of domains) {
|
|
344
|
+
if (!store.has(domain) && store.size >= MAX_MAIL_REVIEW_DOMAINS)
|
|
345
|
+
return;
|
|
346
|
+
const roles = store.get(domain) ?? new Set();
|
|
347
|
+
roles.add(role);
|
|
348
|
+
store.set(domain, roles);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOString()) {
|
|
352
|
+
const normalizedGeneratedAt = normalizeExplicitIsoTimestamp(generatedAt);
|
|
353
|
+
if (!normalizedGeneratedAt)
|
|
354
|
+
throw new CliUsageError('Mail-header review generation time must be a valid ISO 8601 timestamp.');
|
|
355
|
+
const parsed = parseHeaderFields(value);
|
|
356
|
+
const fromDomains = addressDomains(fieldsNamed(parsed.fields, 'from'));
|
|
357
|
+
const replyToDomains = addressDomains(fieldsNamed(parsed.fields, 'reply-to'));
|
|
358
|
+
const returnPathDomains = addressDomains(fieldsNamed(parsed.fields, 'return-path'));
|
|
359
|
+
const messageIdentifierDomains = messageIdDomains(fieldsNamed(parsed.fields, 'message-id'));
|
|
360
|
+
const authenticationResults = fieldsNamed(parsed.fields, 'authentication-results');
|
|
361
|
+
const receivedSpf = fieldsNamed(parsed.fields, 'received-spf');
|
|
362
|
+
const authentication = Object.fromEntries(AUTHENTICATION_METHODS.map((method) => [
|
|
363
|
+
method,
|
|
364
|
+
authenticationState(authenticationResults, method, method === 'spf' ? receivedSpf : []),
|
|
365
|
+
]));
|
|
366
|
+
const authServiceDomains = authenticationServiceDomains(authenticationResults);
|
|
367
|
+
const signerDomains = dkimSigningDomains(fieldsNamed(parsed.fields, 'dkim-signature'));
|
|
368
|
+
const receivedValues = fieldsNamed(parsed.fields, 'received');
|
|
369
|
+
const omittedReceivedHops = Math.max(0, receivedValues.length - MAX_MAIL_RECEIVED_HOPS);
|
|
370
|
+
const receivedHops = receivedValues.slice(0, MAX_MAIL_RECEIVED_HOPS).map((header, index) => Object.freeze({
|
|
371
|
+
position: index + 1,
|
|
372
|
+
order: 'most_recent_first',
|
|
373
|
+
fromDomain: receivedHost(header, 'from'),
|
|
374
|
+
byDomain: receivedHost(header, 'by'),
|
|
375
|
+
transport: receivedTransport(header),
|
|
376
|
+
reportedAt: receivedTimestamp(header),
|
|
377
|
+
}));
|
|
378
|
+
const roles = new Map();
|
|
379
|
+
addDomainRoles(roles, fromDomains, 'from');
|
|
380
|
+
addDomainRoles(roles, replyToDomains, 'reply_to');
|
|
381
|
+
addDomainRoles(roles, returnPathDomains, 'return_path');
|
|
382
|
+
addDomainRoles(roles, messageIdentifierDomains, 'message_id');
|
|
383
|
+
addDomainRoles(roles, authServiceDomains, 'authentication_service');
|
|
384
|
+
addDomainRoles(roles, signerDomains, 'dkim_signer');
|
|
385
|
+
for (const hop of receivedHops) {
|
|
386
|
+
if (hop.fromDomain)
|
|
387
|
+
addDomainRoles(roles, [hop.fromDomain], 'received_from');
|
|
388
|
+
if (hop.byDomain)
|
|
389
|
+
addDomainRoles(roles, [hop.byDomain], 'received_by');
|
|
390
|
+
}
|
|
391
|
+
const domains = [...roles.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([domain, domainRoles]) => Object.freeze({
|
|
392
|
+
domain,
|
|
393
|
+
roles: Object.freeze([...domainRoles].sort()),
|
|
394
|
+
}));
|
|
395
|
+
const digest = createHash('sha256').update(boundedHeaderSection(value), 'utf8').digest('hex');
|
|
396
|
+
return Object.freeze({
|
|
397
|
+
schema: CLI_MAIL_HEADER_REVIEW_SCHEMA,
|
|
398
|
+
version: CLI_MAIL_HEADER_REVIEW_VERSION,
|
|
399
|
+
generatedAt: normalizedGeneratedAt,
|
|
400
|
+
provenance: Object.freeze({
|
|
401
|
+
input: 'analyst_selected_header_block',
|
|
402
|
+
headerBytes: parsed.bytes,
|
|
403
|
+
headerFields: parsed.fields.length,
|
|
404
|
+
headerDigestSha256: `sha256:${digest}`,
|
|
405
|
+
bodyRetained: false,
|
|
406
|
+
attachmentsRetained: false,
|
|
407
|
+
localPartsRetained: false,
|
|
408
|
+
}),
|
|
409
|
+
identity: Object.freeze({
|
|
410
|
+
reportedAt: reportedDate(fieldsNamed(parsed.fields, 'date')),
|
|
411
|
+
fromDomains: Object.freeze(fromDomains),
|
|
412
|
+
replyToDomains: Object.freeze(replyToDomains),
|
|
413
|
+
returnPathDomains: Object.freeze(returnPathDomains),
|
|
414
|
+
messageIdentifierDomains: Object.freeze(messageIdentifierDomains),
|
|
415
|
+
}),
|
|
416
|
+
authentication: Object.freeze(authentication),
|
|
417
|
+
routing: Object.freeze({
|
|
418
|
+
receivedHops: Object.freeze(receivedHops),
|
|
419
|
+
omittedReceivedHops,
|
|
420
|
+
complete: omittedReceivedHops === 0,
|
|
421
|
+
}),
|
|
422
|
+
alignment: Object.freeze({
|
|
423
|
+
fromToReplyTo: exactAlignment(fromDomains, replyToDomains),
|
|
424
|
+
fromToReturnPath: exactAlignment(fromDomains, returnPathDomains),
|
|
425
|
+
fromToDkimSigner: exactAlignment(fromDomains, signerDomains),
|
|
426
|
+
}),
|
|
427
|
+
domains: Object.freeze(domains),
|
|
428
|
+
limitations: Object.freeze([
|
|
429
|
+
'This review parses analyst-selected message headers offline. It makes no DNS, SMTP, HTTP, registry or provider request.',
|
|
430
|
+
'Authentication results are claims reported in the supplied headers; they are not independently validated here.',
|
|
431
|
+
'Exact-domain alignment is descriptive. Divergence can be legitimate and does not establish spoofing, abuse or maliciousness.',
|
|
432
|
+
'Display names, subjects, address local parts, message bodies, attachments and raw header values are not retained in the output.',
|
|
433
|
+
...(omittedReceivedHops ? [`${omittedReceivedHops} older Received header${omittedReceivedHops === 1 ? ' was' : 's were'} omitted by the ${MAX_MAIL_RECEIVED_HOPS}-hop bound.`] : []),
|
|
434
|
+
]),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
export function formatCliMailHeaderReview(document) {
|
|
438
|
+
const lines = [
|
|
439
|
+
'Offline mail-header review',
|
|
440
|
+
`Header identity ${document.provenance.headerDigestSha256}`,
|
|
441
|
+
`Header fields ${document.provenance.headerFields}`,
|
|
442
|
+
`Reported date ${document.identity.reportedAt ?? 'Unavailable'}`,
|
|
443
|
+
`From domains ${document.identity.fromDomains.join(', ') || 'Unavailable'}`,
|
|
444
|
+
`Reply-To domains ${document.identity.replyToDomains.join(', ') || 'Unavailable'}`,
|
|
445
|
+
`Return-Path ${document.identity.returnPathDomains.join(', ') || 'Unavailable'}`,
|
|
446
|
+
'',
|
|
447
|
+
'Reported authentication',
|
|
448
|
+
...AUTHENTICATION_METHODS.map((method) => ` ${method.toUpperCase().padEnd(6)} ${document.authentication[method].state} (${document.authentication[method].observations} observation${document.authentication[method].observations === 1 ? '' : 's'})`),
|
|
449
|
+
'',
|
|
450
|
+
'Exact-domain alignment',
|
|
451
|
+
` From / Reply-To ${document.alignment.fromToReplyTo}`,
|
|
452
|
+
` From / Return-Path ${document.alignment.fromToReturnPath}`,
|
|
453
|
+
` From / DKIM signer ${document.alignment.fromToDkimSigner}`,
|
|
454
|
+
'',
|
|
455
|
+
`Received path ${document.routing.receivedHops.length} retained${document.routing.omittedReceivedHops ? ` · ${document.routing.omittedReceivedHops} omitted` : ''}`,
|
|
456
|
+
];
|
|
457
|
+
for (const hop of document.routing.receivedHops) {
|
|
458
|
+
lines.push(` ${hop.position}. ${hop.fromDomain ?? 'unknown'} → ${hop.byDomain ?? 'unknown'}${hop.transport ? ` · ${hop.transport}` : ''}${hop.reportedAt ? ` · ${hop.reportedAt}` : ''}`);
|
|
459
|
+
}
|
|
460
|
+
lines.push('', `Header domains ${document.domains.length}`);
|
|
461
|
+
for (const domain of document.domains)
|
|
462
|
+
lines.push(` ${domain.domain} ${domain.roles.join(', ')}`);
|
|
463
|
+
lines.push('', 'Limitations:');
|
|
464
|
+
for (const limitation of document.limitations)
|
|
465
|
+
lines.push(` - ${limitation}`);
|
|
466
|
+
return `${lines.join('\n')}\n`;
|
|
467
|
+
}
|
package/cli/mail-review.mjs
CHANGED
|
@@ -3,9 +3,11 @@ import { getDomain } from 'tldts';
|
|
|
3
3
|
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
4
4
|
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
5
5
|
import { isRecord, recordOrEmpty } from "../lib/json-record.mjs";
|
|
6
|
+
import { classifyQuery } from "../lib/classify.mjs";
|
|
7
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
6
8
|
import { CliUsageError } from "./errors.mjs";
|
|
7
9
|
export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
|
|
8
|
-
export const CLI_MAIL_REVIEW_VERSION =
|
|
10
|
+
export const CLI_MAIL_REVIEW_VERSION = 4;
|
|
9
11
|
export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
|
|
10
12
|
export const MAX_MAIL_REVIEW_ROWS = 500;
|
|
11
13
|
const UNSAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
|
|
@@ -20,10 +22,10 @@ function boolean(value) {
|
|
|
20
22
|
return typeof value === 'boolean' ? value : null;
|
|
21
23
|
}
|
|
22
24
|
function timestamp(value, label) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return
|
|
25
|
+
const result = normalizeExplicitIsoTimestamp(value);
|
|
26
|
+
if (!result)
|
|
27
|
+
throw new CliUsageError(`${label} must be a valid bounded timestamp with an explicit timezone.`);
|
|
28
|
+
return result;
|
|
27
29
|
}
|
|
28
30
|
function booleanOrNull(value, label) {
|
|
29
31
|
if (value === null || typeof value === 'boolean')
|
|
@@ -87,8 +89,8 @@ function validateMxEvidence(availability, summary, label, hasMx, hasNullMx) {
|
|
|
87
89
|
function validateMailRow(value, index, expectedVersion) {
|
|
88
90
|
const item = record(value);
|
|
89
91
|
const label = `Mail review row ${index + 1}`;
|
|
90
|
-
const version =
|
|
91
|
-
if (item.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(version)
|
|
92
|
+
const version = item.version;
|
|
93
|
+
if (item.schema !== 'whoisleuth.cli.bulk.item' || typeof version !== 'number' || ![2, 3].includes(version)
|
|
92
94
|
|| (expectedVersion !== null && version !== expectedVersion)) {
|
|
93
95
|
throw new CliUsageError(`${label} must use the matching WHOISleuth Bulk item schema version 2 or 3.`);
|
|
94
96
|
}
|
|
@@ -193,6 +195,7 @@ function normalizeMailRow(value) {
|
|
|
193
195
|
const providers = providerDomains(hosts);
|
|
194
196
|
return {
|
|
195
197
|
domain,
|
|
198
|
+
provenance: mailRowProvenance(item),
|
|
196
199
|
state,
|
|
197
200
|
dnsStatus,
|
|
198
201
|
hasMx,
|
|
@@ -210,6 +213,29 @@ function normalizeMailRow(value) {
|
|
|
210
213
|
],
|
|
211
214
|
};
|
|
212
215
|
}
|
|
216
|
+
function mailRowProvenance(item) {
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
sourceGeneratedAt: timestamp(item.generatedAt, 'Bulk row generation time'),
|
|
219
|
+
observedAt: item.version === 3 ? normalizeExplicitIsoTimestamp(item.observedAt) : null,
|
|
220
|
+
dnsObservedAt: item.ok === true ? normalizeExplicitIsoTimestamp(record(record(item.availability).dns).observedAt) : null,
|
|
221
|
+
collectionOrigin: item.version === 3
|
|
222
|
+
? item.collectionOrigin
|
|
223
|
+
: 'unknown',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function failedMailRow(item, index) {
|
|
227
|
+
let target = null;
|
|
228
|
+
try {
|
|
229
|
+
target = classifyQuery(String(item.query)).value;
|
|
230
|
+
}
|
|
231
|
+
catch { /* Invalid targets remain identifiable by input position. */ }
|
|
232
|
+
return Object.freeze({
|
|
233
|
+
rowNumber: index + 1,
|
|
234
|
+
target,
|
|
235
|
+
state: 'collection_failed',
|
|
236
|
+
provenance: mailRowProvenance(item),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
213
239
|
function parseInput(textValue) {
|
|
214
240
|
if (typeof textValue !== 'string' || Buffer.byteLength(textValue, 'utf8') > MAX_MAIL_REVIEW_INPUT_BYTES) {
|
|
215
241
|
throw new CliUsageError(`Mail review input is limited to ${MAX_MAIL_REVIEW_INPUT_BYTES} bytes.`);
|
|
@@ -223,12 +249,12 @@ function parseInput(textValue) {
|
|
|
223
249
|
scanBoundedJson(textInput);
|
|
224
250
|
const parsed = JSON.parse(textInput);
|
|
225
251
|
const document = record(parsed);
|
|
226
|
-
if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(
|
|
252
|
+
if (document.schema === 'whoisleuth.cli.bulk' && typeof document.version === 'number' && [2, 3].includes(document.version) && Array.isArray(document.results)) {
|
|
227
253
|
values = document.results;
|
|
228
254
|
containerVersion = Number(document.version);
|
|
229
255
|
timestamp(document.generatedAt, 'Mail review Bulk generation time');
|
|
230
256
|
}
|
|
231
|
-
else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(
|
|
257
|
+
else if (document.schema === 'whoisleuth.cli.bulk.item' && typeof document.version === 'number' && [2, 3].includes(document.version)) {
|
|
232
258
|
values = [document];
|
|
233
259
|
}
|
|
234
260
|
else {
|
|
@@ -258,6 +284,13 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
258
284
|
const rows = inputRows.map(normalizeMailRow).filter((row) => Boolean(row));
|
|
259
285
|
if (!rows.length)
|
|
260
286
|
throw new CliUsageError('Mail review input contains no completed domain results.');
|
|
287
|
+
const failedRows = inputRows.flatMap((row, index) => row.ok === false ? [failedMailRow(row, index)] : []);
|
|
288
|
+
const inputCoverage = Object.freeze({
|
|
289
|
+
inputRows: inputRows.length,
|
|
290
|
+
reviewedRows: rows.length,
|
|
291
|
+
failedRows: failedRows.length,
|
|
292
|
+
unmeasuredObservationRows: [...rows, ...failedRows].filter((row) => row.provenance.observedAt === null).length,
|
|
293
|
+
});
|
|
261
294
|
const counts = {
|
|
262
295
|
authenticated_mail: 0,
|
|
263
296
|
evidence_incomplete: 0,
|
|
@@ -295,10 +328,14 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
295
328
|
})
|
|
296
329
|
.sort((left, right) => left.providerDomain.localeCompare(right.providerDomain));
|
|
297
330
|
const providerRelationships = allProviderRelationships.slice(0, 100);
|
|
331
|
+
const rowsWithIncompleteDns = rows.filter((row) => row.dnsStatus !== 'success' || row.hasMx === null || row.hasNullMx === null).length;
|
|
298
332
|
const providerCoverage = {
|
|
299
|
-
complete:
|
|
333
|
+
complete: failedRows.length === 0 && rowsWithIncompleteDns === 0
|
|
334
|
+
&& rows.every((row) => !row.providerDomainsTruncated)
|
|
300
335
|
&& allProviderRelationships.length <= 100
|
|
301
336
|
&& allProviderRelationships.every((relationship) => !relationship.domainsTruncated),
|
|
337
|
+
failedRows: failedRows.length,
|
|
338
|
+
rowsWithIncompleteDns,
|
|
302
339
|
rowsWithOmittedProviders: rows.filter((row) => row.providerDomainsTruncated).length,
|
|
303
340
|
omittedProviderDomains: rows.reduce((sum, row) => sum + row.providerDomainsOmitted, 0),
|
|
304
341
|
relationshipCount: allProviderRelationships.length,
|
|
@@ -309,16 +346,20 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
309
346
|
return {
|
|
310
347
|
schema: CLI_MAIL_REVIEW_SCHEMA,
|
|
311
348
|
version: CLI_MAIL_REVIEW_VERSION,
|
|
312
|
-
generatedAt,
|
|
349
|
+
generatedAt: timestamp(generatedAt, 'Mail review generation time'),
|
|
313
350
|
counts,
|
|
314
351
|
rows,
|
|
352
|
+
failedRows,
|
|
353
|
+
inputCoverage,
|
|
315
354
|
providerRelationships,
|
|
316
355
|
providerCoverage,
|
|
317
356
|
limitations: [
|
|
318
357
|
'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
|
|
319
358
|
'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
|
|
320
359
|
'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
|
|
321
|
-
...(
|
|
360
|
+
...(failedRows.length ? [`${failedRows.length} input target${failedRows.length === 1 ? '' : 's'} failed collection and cannot contribute mail evidence. Input positions and normalised targets are retained; consult the original input for failure diagnostics.`] : []),
|
|
361
|
+
...(rowsWithIncompleteDns ? [`${rowsWithIncompleteDns} reviewed domain${rowsWithIncompleteDns === 1 ? ' has' : 's have'} incomplete DNS evidence; unobserved provider relationships remain unknown.`] : []),
|
|
362
|
+
...(providerCoverage.rowsWithOmittedProviders || providerCoverage.omittedRelationships || providerCoverage.omittedRelationshipDomains
|
|
322
363
|
? ['Mail-provider relationship coverage reached a configured row, relationship, or domain bound; omitted relationships remain unknown.']
|
|
323
364
|
: []),
|
|
324
365
|
],
|
|
@@ -327,19 +368,30 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
327
368
|
function formatCliMailReview(document) {
|
|
328
369
|
const lines = [
|
|
329
370
|
'Passive mail exposure review',
|
|
371
|
+
`Input targets ${document.inputCoverage.inputRows}`,
|
|
330
372
|
`Domains ${document.rows.length}`,
|
|
373
|
+
`Failed targets ${document.inputCoverage.failedRows}`,
|
|
331
374
|
`Authenticated ${document.counts.authenticated_mail}`,
|
|
332
375
|
`Auth gaps ${document.counts.mail_auth_gap}`,
|
|
333
376
|
`Null MX ${document.counts.null_mx}`,
|
|
334
377
|
`No explicit MX ${document.counts.no_explicit_mx}`,
|
|
335
|
-
`Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete}`,
|
|
378
|
+
`Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete + document.inputCoverage.failedRows}`,
|
|
336
379
|
'',
|
|
337
380
|
];
|
|
338
381
|
for (const row of document.rows) {
|
|
339
382
|
lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
|
|
383
|
+
lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')}`);
|
|
384
|
+
lines.push(` DNS observed ${row.provenance.dnsObservedAt ?? 'unknown'} · Source output ${row.provenance.sourceGeneratedAt}`);
|
|
340
385
|
lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}${row.providerDomainsOmitted ? ` · +${row.providerDomainsOmitted} omitted` : ''}`);
|
|
341
386
|
lines.push(` SPF / DMARC ${row.hasSpf === null ? 'unknown' : row.hasSpf ? 'observed' : 'not observed'} / ${row.hasDmarc === null ? 'unknown' : row.hasDmarc ? 'observed' : 'not observed'}`);
|
|
342
387
|
}
|
|
388
|
+
if (document.failedRows.length) {
|
|
389
|
+
lines.push('', 'Failed collection inputs');
|
|
390
|
+
for (const row of document.failedRows) {
|
|
391
|
+
lines.push(`Input ${row.rowNumber} ${row.target ?? 'unrecognised target'} · collection failed`);
|
|
392
|
+
lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')} · Source output ${row.provenance.sourceGeneratedAt}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
343
395
|
if (document.providerRelationships.length) {
|
|
344
396
|
lines.push('', 'Shared mail-provider relationships');
|
|
345
397
|
for (const relationship of document.providerRelationships) {
|
|
@@ -347,7 +399,7 @@ function formatCliMailReview(document) {
|
|
|
347
399
|
}
|
|
348
400
|
}
|
|
349
401
|
if (!document.providerCoverage.complete) {
|
|
350
|
-
lines.push('', `Provider coverage partial ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
|
|
402
|
+
lines.push('', `Provider coverage partial ${document.providerCoverage.failedRows} failed targets · ${document.providerCoverage.rowsWithIncompleteDns} incomplete DNS rows · ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
|
|
351
403
|
}
|
|
352
404
|
lines.push('', 'Limitations:');
|
|
353
405
|
for (const limitation of document.limitations)
|
package/cli/manual.mjs
CHANGED
|
@@ -24,6 +24,7 @@ ${commands}
|
|
|
24
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
|
+
For verify-artifact, use --strict-exit in scripts: incomplete verification then returns 4. Without it, producing a verification report can return 0 even when its checks fail. Read the report state and checks.
|
|
27
28
|
.SH PRIVACY
|
|
28
29
|
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
30
|
.SH LICENSE
|
|
@@ -44,6 +44,8 @@ async function runNetworkCommand(args, dependencies, context) {
|
|
|
44
44
|
dkimSelectors,
|
|
45
45
|
retiredDkimSelectors,
|
|
46
46
|
mailProtectionProfile: args.mailProfile,
|
|
47
|
+
...(args.includeInheritedDns ? { includeInheritedDns: true } : {}),
|
|
48
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
47
49
|
}));
|
|
48
50
|
const document = buildCliPostureDocument(requestedDomain, report, context.now());
|
|
49
51
|
if (!args.quiet) {
|