@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -38,7 +38,7 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
38
38
|
eventProgress.emit({ event: 'started' });
|
|
39
39
|
const input = args.query || await context.readSingleInput();
|
|
40
40
|
if (!input)
|
|
41
|
-
throw new CliUsageError('lookup requires one domain, IP address, or ASN as an argument or on stdin.');
|
|
41
|
+
throw new CliUsageError('lookup requires one domain, IP address, or ASN as an argument or on stdin.', 'missing_input');
|
|
42
42
|
const classify = dependencies.classifyQuery || classifyQuery;
|
|
43
43
|
const executeLookup = dependencies.runUnifiedLookup || runUnifiedLookup;
|
|
44
44
|
let classified;
|
|
@@ -74,6 +74,18 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
74
74
|
...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
|
|
75
75
|
});
|
|
76
76
|
};
|
|
77
|
+
const collect = (signal, onSourceSettled) => abortable(() => executeLookup(classified, {
|
|
78
|
+
fast: !args.deep,
|
|
79
|
+
compact: false,
|
|
80
|
+
...(signal ? { signal } : {}),
|
|
81
|
+
...(args.deep ? {
|
|
82
|
+
...(selectedUrl ? { selectedUrl } : {}),
|
|
83
|
+
onSourceSettled: settlement => {
|
|
84
|
+
onSourceSettled(settlement);
|
|
85
|
+
eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
|
|
86
|
+
},
|
|
87
|
+
} : {}),
|
|
88
|
+
}), signal);
|
|
77
89
|
let document;
|
|
78
90
|
if (args.browse === true) {
|
|
79
91
|
const browse = dependencies.browseLookupOperation || browseLookupOperation;
|
|
@@ -88,23 +100,7 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
88
100
|
plannedSources: args.deep ? plannedLookupProgressSources(classified) : [],
|
|
89
101
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
90
102
|
collect: async ({ signal, onSourceSettled }) => {
|
|
91
|
-
|
|
92
|
-
? {
|
|
93
|
-
fast: false,
|
|
94
|
-
compact: false,
|
|
95
|
-
...(selectedUrl ? { selectedUrl } : {}),
|
|
96
|
-
signal,
|
|
97
|
-
onSourceSettled: (settlement) => {
|
|
98
|
-
onSourceSettled(settlement);
|
|
99
|
-
eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
|
|
100
|
-
},
|
|
101
|
-
}
|
|
102
|
-
: {
|
|
103
|
-
fast: true,
|
|
104
|
-
compact: false,
|
|
105
|
-
signal,
|
|
106
|
-
}), signal);
|
|
107
|
-
return buildDocument(result);
|
|
103
|
+
return buildDocument(await collect(signal, onSourceSettled));
|
|
108
104
|
},
|
|
109
105
|
});
|
|
110
106
|
if (args.saveLookup) {
|
|
@@ -125,23 +121,10 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
125
121
|
let settledSources = 0;
|
|
126
122
|
let result;
|
|
127
123
|
try {
|
|
128
|
-
result = await
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
...(selectedUrl ? { selectedUrl } : {}),
|
|
133
|
-
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
134
|
-
onSourceSettled: (settlement) => {
|
|
135
|
-
settledSources += 1;
|
|
136
|
-
indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
|
|
137
|
-
eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
|
|
138
|
-
},
|
|
139
|
-
}
|
|
140
|
-
: {
|
|
141
|
-
fast: true,
|
|
142
|
-
compact: false,
|
|
143
|
-
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
144
|
-
}), dependencies.signal);
|
|
124
|
+
result = await collect(dependencies.signal, settlement => {
|
|
125
|
+
settledSources += 1;
|
|
126
|
+
indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
|
|
127
|
+
});
|
|
145
128
|
}
|
|
146
129
|
finally {
|
|
147
130
|
context.endProgress();
|
package/cli/lookup-diff.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { relationshipObservation } from "../packages/comparison/relationship-evi
|
|
|
5
5
|
import { CliUsageError } from "./errors.mjs";
|
|
6
6
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
7
7
|
import { lookupObservationHostname } from "../packages/evidence/lookup-target.mjs";
|
|
8
|
+
import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
|
|
8
9
|
export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
|
|
9
10
|
export const CLI_LOOKUP_DIFF_VERSION = 1;
|
|
10
11
|
function text(value, maximum = 300) {
|
|
@@ -198,10 +199,10 @@ function formatCliLookupDiff(document) {
|
|
|
198
199
|
const rows = document.comparison.rows.filter((row) => row.state !== 'equal');
|
|
199
200
|
const output = [
|
|
200
201
|
'Domain evidence diff',
|
|
201
|
-
`Left ${document.left.domain}`,
|
|
202
|
-
`Left observed ${document.left.generatedAt}`,
|
|
203
|
-
`Right ${document.right.domain}`,
|
|
204
|
-
`Right observed ${document.right.generatedAt}`,
|
|
202
|
+
`Left ${safeTerminalValue(document.left.domain)}`,
|
|
203
|
+
`Left observed ${safeTerminalValue(document.left.generatedAt)}`,
|
|
204
|
+
`Right ${safeTerminalValue(document.right.domain)}`,
|
|
205
|
+
`Right observed ${safeTerminalValue(document.right.generatedAt)}`,
|
|
205
206
|
`Changed ${document.comparison.counts.different + document.comparison.counts.conflicting}`,
|
|
206
207
|
`One-sided ${document.comparison.counts.missing}`,
|
|
207
208
|
`Unavailable ${document.comparison.counts.unavailable + document.comparison.counts.not_recorded}`,
|
|
@@ -210,14 +211,14 @@ function formatCliLookupDiff(document) {
|
|
|
210
211
|
if (!rows.length)
|
|
211
212
|
output.push('No bounded differences were observed in the comparable fields.');
|
|
212
213
|
for (const row of rows) {
|
|
213
|
-
output.push(`${row.label} [${row.state.replaceAll('_', ' ')}]`);
|
|
214
|
-
output.push(` Left: ${row.left}`);
|
|
215
|
-
output.push(` Right: ${row.right}`);
|
|
216
|
-
output.push(` Source: ${row.source}`);
|
|
214
|
+
output.push(`${safeTerminalValue(row.label)} [${safeTerminalValue(row.state).replaceAll('_', ' ')}]`);
|
|
215
|
+
output.push(` Left: ${safeTerminalValue(row.left)}`);
|
|
216
|
+
output.push(` Right: ${safeTerminalValue(row.right)}`);
|
|
217
|
+
output.push(` Source: ${safeTerminalValue(row.source)}`);
|
|
217
218
|
}
|
|
218
219
|
output.push('', 'Limitations:');
|
|
219
220
|
for (const limitation of document.limitations)
|
|
220
|
-
output.push(` - ${limitation}`);
|
|
221
|
+
output.push(` - ${safeTerminalValue(limitation)}`);
|
|
221
222
|
return `${output.join('\n')}\n`;
|
|
222
223
|
}
|
|
223
224
|
export { buildCliLookupDiff, formatCliLookupDiff };
|
package/cli/lookup-plan.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plannedLookupSources } from "../lib/lookup-progress.mjs";
|
|
1
2
|
export const CLI_LOOKUP_PLAN_SCHEMA = 'whoisleuth.cli.lookup-plan';
|
|
2
3
|
export const CLI_LOOKUP_PLAN_VERSION = 1;
|
|
3
4
|
const RDAP = Object.freeze({
|
|
@@ -43,20 +44,15 @@ const REVERSE_DNS = Object.freeze({
|
|
|
43
44
|
conditional: false,
|
|
44
45
|
});
|
|
45
46
|
function plannedSources(classified, deep, selectedUrl) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
if (classified.type === 'ipv4' || classified.type === 'ipv6') {
|
|
57
|
-
return Object.freeze([RDAP, WHOIS, REVERSE_DNS]);
|
|
58
|
-
}
|
|
59
|
-
return Object.freeze([RDAP, WHOIS]);
|
|
47
|
+
const domainEvidence = selectedUrl && deep ? Object.freeze({
|
|
48
|
+
...DEEP_DOMAIN_EVIDENCE,
|
|
49
|
+
disclosure: 'The selected URL path and query are sent to the website and followed redirects. The fragment is not sent. DNS and TLS use its hostname; registration uses the registrable domain. Saved HTTP provenance omits queries, but page-derived text may contain sensitive information.',
|
|
50
|
+
}) : deep ? DEEP_DOMAIN_EVIDENCE : FAST_DOMAIN_EVIDENCE;
|
|
51
|
+
const sources = {
|
|
52
|
+
rdap: RDAP, whois: WHOIS, domain_evidence: domainEvidence,
|
|
53
|
+
registrar_rdap: REGISTRAR_RDAP, network_context: NETWORK_CONTEXT, reverse_dns: REVERSE_DNS,
|
|
54
|
+
};
|
|
55
|
+
return Object.freeze(plannedLookupSources(classified.type, deep ? 'deep' : 'fast').map(id => sources[id]));
|
|
60
56
|
}
|
|
61
57
|
function buildCliLookupPlan(query, classified, deep, selectedUrl = false) {
|
|
62
58
|
return Object.freeze({
|
package/cli/lookup-reconcile.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { CliUsageError } from "./errors.mjs";
|
|
|
2
2
|
import { buildCliLookupDiff } from "./lookup-diff.mjs";
|
|
3
3
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
4
4
|
import { compareObservationContexts } from "../packages/comparison/capture-context.mjs";
|
|
5
|
+
import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
|
|
5
6
|
const CLI_LOOKUP_RECONCILIATION_SCHEMA = 'whoisleuth.cli.lookup-reconciliation';
|
|
6
7
|
const CLI_LOOKUP_RECONCILIATION_VERSION = 1;
|
|
7
8
|
const MAX_LOOKUP_RECONCILIATION_INPUT_BYTES = 32 * 1024 * 1024;
|
|
@@ -117,7 +118,7 @@ function formatCliLookupReconciliation(document) {
|
|
|
117
118
|
const context = compareObservationContexts(document.observations.map(observation => ({ ...observation, observedAt: observation.generatedAt })));
|
|
118
119
|
const output = [
|
|
119
120
|
'Lookup observation reconciliation',
|
|
120
|
-
`Domain ${document.domain}`,
|
|
121
|
+
`Domain ${safeTerminalValue(document.domain)}`,
|
|
121
122
|
`Observations ${document.summary.observationCount}`,
|
|
122
123
|
`Declared labels ${context.labels}; collection independence not verified`,
|
|
123
124
|
`Capture time span ${context.time.spanMilliseconds === null ? 'unknown' : `${context.time.spanMilliseconds / 1000} seconds`}`,
|
|
@@ -125,18 +126,18 @@ function formatCliLookupReconciliation(document) {
|
|
|
125
126
|
`Disagreement ${document.summary.disagreement}`,
|
|
126
127
|
`Non-comparable ${document.summary.nonComparable}`,
|
|
127
128
|
'',
|
|
128
|
-
...document.observations.map((observation, index) => `Observation ${index + 1}: ${observation.generatedAt} · ${observation.mode} · observer ${observation.observerLabel
|
|
129
|
+
...document.observations.map((observation, index) => `Observation ${index + 1}: ${safeTerminalValue(observation.generatedAt)} · ${safeTerminalValue(observation.mode)} · observer ${safeTerminalValue(observation.observerLabel, 'not declared')} · vantage ${safeTerminalValue(observation.vantageLabel, 'not declared')}`),
|
|
129
130
|
'',
|
|
130
131
|
];
|
|
131
132
|
for (const field of document.fields.filter((candidate) => candidate.state !== 'agreement')) {
|
|
132
|
-
output.push(`${field.label} [${field.state.replaceAll('_', ' ')}]`);
|
|
133
|
+
output.push(`${safeTerminalValue(field.label)} [${safeTerminalValue(field.state).replaceAll('_', ' ')}]`);
|
|
133
134
|
for (const value of field.values) {
|
|
134
|
-
output.push(` Observation ${value.observation + 1}: ${value.value} (${value.sourceState.replaceAll('_', ' ')})`);
|
|
135
|
+
output.push(` Observation ${value.observation + 1}: ${safeTerminalValue(value.value)} (${safeTerminalValue(value.sourceState).replaceAll('_', ' ')})`);
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
output.push('', 'Limitations:');
|
|
138
139
|
for (const limitation of document.limitations)
|
|
139
|
-
output.push(` - ${limitation}`);
|
|
140
|
+
output.push(` - ${safeTerminalValue(limitation)}`);
|
|
140
141
|
output.push(' - These selected observations do not establish worldwide availability, removal or takedown.');
|
|
141
142
|
return `${output.join('\n')}\n`;
|
|
142
143
|
}
|
package/cli/lookup-timeline.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
|
|
|
2
2
|
import { CliUsageError } from "./errors.mjs";
|
|
3
3
|
import { buildCliLookupDiff } from "./lookup-diff.mjs";
|
|
4
4
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
5
|
+
import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
|
|
5
6
|
export const CLI_LOOKUP_TIMELINE_SCHEMA = 'whoisleuth.cli.lookup-timeline';
|
|
6
7
|
export const CLI_LOOKUP_TIMELINE_VERSION = 1;
|
|
7
8
|
export const MAX_LOOKUP_TIMELINE_OBSERVATIONS = 20;
|
|
@@ -95,10 +96,10 @@ function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString())
|
|
|
95
96
|
function formatCliLookupTimeline(document) {
|
|
96
97
|
const output = [
|
|
97
98
|
'Same-domain observation timeline',
|
|
98
|
-
`Domain ${document.domain}`,
|
|
99
|
+
`Domain ${safeTerminalValue(document.domain)}`,
|
|
99
100
|
`Observations ${document.summary.observationCount}`,
|
|
100
|
-
`First observed ${document.observations[0]?.generatedAt
|
|
101
|
-
`Last observed ${document.observations.at(-1)?.generatedAt
|
|
101
|
+
`First observed ${safeTerminalValue(document.observations[0]?.generatedAt, 'Unavailable')}`,
|
|
102
|
+
`Last observed ${safeTerminalValue(document.observations.at(-1)?.generatedAt, 'Unavailable')}`,
|
|
102
103
|
`Changed transitions ${document.summary.transitionsWithObservedChanges}`,
|
|
103
104
|
];
|
|
104
105
|
for (const transition of document.transitions) {
|
|
@@ -106,18 +107,18 @@ function formatCliLookupTimeline(document) {
|
|
|
106
107
|
const to = document.observations[transition.toObservation];
|
|
107
108
|
const counts = transition.comparison.counts;
|
|
108
109
|
const changedRows = transition.comparison.rows.filter((row) => (row.state === 'conflicting' || row.state === 'different' || row.state === 'missing'));
|
|
109
|
-
output.push('', `${from?.generatedAt
|
|
110
|
+
output.push('', `${safeTerminalValue(from?.generatedAt, 'Unavailable')} to ${safeTerminalValue(to?.generatedAt, 'Unavailable')}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
|
|
110
111
|
if (!changedRows.length)
|
|
111
112
|
output.push(' No observed field changes in comparable evidence.');
|
|
112
113
|
for (const row of changedRows.slice(0, 10)) {
|
|
113
|
-
output.push(` ${row.label} [${row.state.replaceAll('_', ' ')}]: ${row.left} -> ${row.right}`);
|
|
114
|
+
output.push(` ${safeTerminalValue(row.label)} [${safeTerminalValue(row.state).replaceAll('_', ' ')}]: ${safeTerminalValue(row.left)} -> ${safeTerminalValue(row.right)}`);
|
|
114
115
|
}
|
|
115
116
|
if (changedRows.length > 10)
|
|
116
117
|
output.push(` ${changedRows.length - 10} additional changed fields omitted; use --json for the complete bounded comparison.`);
|
|
117
118
|
}
|
|
118
119
|
output.push('', 'Limitations:');
|
|
119
120
|
for (const limitation of document.limitations)
|
|
120
|
-
output.push(` - ${limitation}`);
|
|
121
|
+
output.push(` - ${safeTerminalValue(limitation)}`);
|
|
121
122
|
return `${output.join('\n')}\n`;
|
|
122
123
|
}
|
|
123
124
|
export { buildCliLookupTimeline, formatCliLookupTimeline };
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
|
-
import {
|
|
3
|
+
import { MAX_MAIL_REVIEW_DOMAINS, addressDomains, messageIdDomains, authenticationServiceDomains, dkimSigningDomains, normalizeHeaderDomain } from "../packages/investigation/mail-header-identity.mjs";
|
|
4
|
+
export { MAX_MAIL_REVIEW_DOMAINS } from "../packages/investigation/mail-header-identity.mjs";
|
|
4
5
|
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
5
6
|
import { CliUsageError } from "./errors.mjs";
|
|
7
|
+
import { reviewMailAuthentication, selectReceiverTrust, authenticationHeaderLabel, aggregateMailAuthentication } from "../packages/investigation/mail-authentication-review.mjs";
|
|
6
8
|
export const CLI_MAIL_HEADER_REVIEW_SCHEMA = 'whoisleuth.cli.mail-header-review';
|
|
7
|
-
export const CLI_MAIL_HEADER_REVIEW_VERSION =
|
|
9
|
+
export const CLI_MAIL_HEADER_REVIEW_VERSION = 2;
|
|
8
10
|
export const MAX_MAIL_HEADER_INPUT_BYTES = 256 * 1024;
|
|
9
11
|
export const MAX_MAIL_HEADER_FIELDS = 512;
|
|
10
12
|
export const MAX_MAIL_HEADER_LINE_BYTES = 4 * 1024;
|
|
11
13
|
export const MAX_MAIL_HEADER_VALUE_LENGTH = 16 * 1024;
|
|
12
14
|
export const MAX_MAIL_RECEIVED_HOPS = 64;
|
|
13
|
-
export const MAX_MAIL_REVIEW_DOMAINS = 128;
|
|
14
15
|
const UNSAFE_HEADER_CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
|
|
15
16
|
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
17
|
function boundedHeaderSection(value) {
|
|
19
18
|
if (typeof value !== 'string' || !value)
|
|
20
19
|
throw new CliUsageError('Mail-header review requires one UTF-8 message or header block.');
|
|
@@ -75,240 +74,6 @@ function parseHeaderFields(value) {
|
|
|
75
74
|
function fieldsNamed(fields, name) {
|
|
76
75
|
return fields.filter((field) => field.name === name).map((field) => field.value);
|
|
77
76
|
}
|
|
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
77
|
function reportedDate(values) {
|
|
313
78
|
if (values.length !== 1 || values[0].length > 240)
|
|
314
79
|
return null;
|
|
@@ -348,21 +113,26 @@ function addDomainRoles(store, domains, role) {
|
|
|
348
113
|
store.set(domain, roles);
|
|
349
114
|
}
|
|
350
115
|
}
|
|
351
|
-
export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOString()) {
|
|
116
|
+
export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOString(), trustedHeaders = []) {
|
|
352
117
|
const normalizedGeneratedAt = normalizeExplicitIsoTimestamp(generatedAt);
|
|
353
118
|
if (!normalizedGeneratedAt)
|
|
354
119
|
throw new CliUsageError('Mail-header review generation time must be a valid ISO 8601 timestamp.');
|
|
355
120
|
const parsed = parseHeaderFields(value);
|
|
121
|
+
let authenticationReview;
|
|
122
|
+
try {
|
|
123
|
+
authenticationReview = selectReceiverTrust(reviewMailAuthentication(parsed.fields), trustedHeaders);
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
if (cause instanceof TypeError)
|
|
127
|
+
throw new CliUsageError(cause.message);
|
|
128
|
+
throw cause;
|
|
129
|
+
}
|
|
356
130
|
const fromDomains = addressDomains(fieldsNamed(parsed.fields, 'from'));
|
|
357
131
|
const replyToDomains = addressDomains(fieldsNamed(parsed.fields, 'reply-to'));
|
|
358
132
|
const returnPathDomains = addressDomains(fieldsNamed(parsed.fields, 'return-path'));
|
|
359
133
|
const messageIdentifierDomains = messageIdDomains(fieldsNamed(parsed.fields, 'message-id'));
|
|
360
134
|
const authenticationResults = fieldsNamed(parsed.fields, 'authentication-results');
|
|
361
|
-
const
|
|
362
|
-
const authentication = Object.fromEntries(AUTHENTICATION_METHODS.map((method) => [
|
|
363
|
-
method,
|
|
364
|
-
authenticationState(authenticationResults, method, method === 'spf' ? receivedSpf : []),
|
|
365
|
-
]));
|
|
135
|
+
const authentication = aggregateMailAuthentication(authenticationReview);
|
|
366
136
|
const authServiceDomains = authenticationServiceDomains(authenticationResults);
|
|
367
137
|
const signerDomains = dkimSigningDomains(fieldsNamed(parsed.fields, 'dkim-signature'));
|
|
368
138
|
const receivedValues = fieldsNamed(parsed.fields, 'received');
|
|
@@ -414,6 +184,7 @@ export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOSt
|
|
|
414
184
|
messageIdentifierDomains: Object.freeze(messageIdentifierDomains),
|
|
415
185
|
}),
|
|
416
186
|
authentication: Object.freeze(authentication),
|
|
187
|
+
authenticationReview,
|
|
417
188
|
routing: Object.freeze({
|
|
418
189
|
receivedHops: Object.freeze(receivedHops),
|
|
419
190
|
omittedReceivedHops,
|
|
@@ -428,6 +199,7 @@ export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOSt
|
|
|
428
199
|
limitations: Object.freeze([
|
|
429
200
|
'This review parses analyst-selected message headers offline. It makes no DNS, SMTP, HTTP, registry or provider request.',
|
|
430
201
|
'Authentication results are claims reported in the supplied headers; they are not independently validated here.',
|
|
202
|
+
'Receiver trust is an explicit analyst selection for an exact header, not a conclusion from a matching service name. Aggregate counts combine all reported sources, not a trusted verdict.',
|
|
431
203
|
'Exact-domain alignment is descriptive. Divergence can be legitimate and does not establish spoofing, abuse or maliciousness.',
|
|
432
204
|
'Display names, subjects, address local parts, message bodies, attachments and raw header values are not retained in the output.',
|
|
433
205
|
...(omittedReceivedHops ? [`${omittedReceivedHops} older Received header${omittedReceivedHops === 1 ? ' was' : 's were'} omitted by the ${MAX_MAIL_RECEIVED_HOPS}-hop bound.`] : []),
|
|
@@ -444,8 +216,13 @@ export function formatCliMailHeaderReview(document) {
|
|
|
444
216
|
`Reply-To domains ${document.identity.replyToDomains.join(', ') || 'Unavailable'}`,
|
|
445
217
|
`Return-Path ${document.identity.returnPathDomains.join(', ') || 'Unavailable'}`,
|
|
446
218
|
'',
|
|
447
|
-
'
|
|
448
|
-
...
|
|
219
|
+
'Authentication by source header',
|
|
220
|
+
...document.authenticationReview.headers.flatMap(header => [
|
|
221
|
+
` ${authenticationHeaderLabel(header)} · ${header.state} · receiver trust: ${header.receiverTrust.replaceAll('_', ' ')}`,
|
|
222
|
+
...header.claims.map(claim => ` ${claim.method.toUpperCase()}/${claim.methodVersion}: ${claim.result}${claim.state === 'unsupported' ? ' (unsupported)' : ''}${claim.domains.map(value => ` · ${value.property}=${value.domain}`).join('')}${claim.duplicateProperties.length ? ` · ambiguous: ${claim.duplicateProperties.join(', ')}` : ''}`),
|
|
223
|
+
...(header.duplicateOf ? [` Duplicate of header ${header.duplicateOf} in this part.`] : []),
|
|
224
|
+
...header.issues.map(issue => ` ${issue}`),
|
|
225
|
+
]),
|
|
449
226
|
'',
|
|
450
227
|
'Exact-domain alignment',
|
|
451
228
|
` From / Reply-To ${document.alignment.fromToReplyTo}`,
|
package/cli/mail-review.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { getDomain } from 'tldts';
|
|
3
|
-
import { scanBoundedJson } from "../
|
|
4
|
-
import {
|
|
3
|
+
import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
|
|
4
|
+
import { compareCodeUnits } from "../lib/bounded-contract-normalizers.mjs";
|
|
5
|
+
import { isValidAsciiDomainName } from "../packages/contracts/domain-name.mjs";
|
|
5
6
|
import { isRecord, recordOrEmpty } from "../lib/json-record.mjs";
|
|
6
7
|
import { classifyQuery } from "../lib/classify.mjs";
|
|
7
8
|
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
8
9
|
import { CliUsageError } from "./errors.mjs";
|
|
10
|
+
import { PASSIVE_MAIL_INTERPRETATION, PASSIVE_MAIL_LABELS } from "../packages/contracts/passive-mail.mjs";
|
|
9
11
|
export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
|
|
10
12
|
export const CLI_MAIL_REVIEW_VERSION = 4;
|
|
11
13
|
export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
|
|
@@ -326,7 +328,7 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
326
328
|
limitation: 'Shared mail providers are common and do not establish common ownership, control, intent, safety, or maliciousness.',
|
|
327
329
|
};
|
|
328
330
|
})
|
|
329
|
-
.sort((left, right) => left.providerDomain
|
|
331
|
+
.sort((left, right) => compareCodeUnits(left.providerDomain, right.providerDomain));
|
|
330
332
|
const providerRelationships = allProviderRelationships.slice(0, 100);
|
|
331
333
|
const rowsWithIncompleteDns = rows.filter((row) => row.dnsStatus !== 'success' || row.hasMx === null || row.hasNullMx === null).length;
|
|
332
334
|
const providerCoverage = {
|
|
@@ -355,7 +357,7 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
355
357
|
providerCoverage,
|
|
356
358
|
limitations: [
|
|
357
359
|
'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
|
|
358
|
-
|
|
360
|
+
PASSIVE_MAIL_INTERPRETATION,
|
|
359
361
|
'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
|
|
360
362
|
...(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
363
|
...(rowsWithIncompleteDns ? [`${rowsWithIncompleteDns} reviewed domain${rowsWithIncompleteDns === 1 ? ' has' : 's have'} incomplete DNS evidence; unobserved provider relationships remain unknown.`] : []),
|
|
@@ -371,15 +373,15 @@ function formatCliMailReview(document) {
|
|
|
371
373
|
`Input targets ${document.inputCoverage.inputRows}`,
|
|
372
374
|
`Domains ${document.rows.length}`,
|
|
373
375
|
`Failed targets ${document.inputCoverage.failedRows}`,
|
|
374
|
-
`
|
|
375
|
-
`
|
|
376
|
+
`MX + SPF + DMARC ${document.counts.authenticated_mail}`,
|
|
377
|
+
`DNS policy gaps ${document.counts.mail_auth_gap}`,
|
|
376
378
|
`Null MX ${document.counts.null_mx}`,
|
|
377
379
|
`No explicit MX ${document.counts.no_explicit_mx}`,
|
|
378
380
|
`Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete + document.inputCoverage.failedRows}`,
|
|
379
381
|
'',
|
|
380
382
|
];
|
|
381
383
|
for (const row of document.rows) {
|
|
382
|
-
lines.push(`${row.domain} ${row.state
|
|
384
|
+
lines.push(`${row.domain} ${PASSIVE_MAIL_LABELS[row.state]}`);
|
|
383
385
|
lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')}`);
|
|
384
386
|
lines.push(` DNS observed ${row.provenance.dnsObservedAt ?? 'unknown'} · Source output ${row.provenance.sourceGeneratedAt}`);
|
|
385
387
|
lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}${row.providerDomainsOmitted ? ` · +${row.providerDomainsOmitted} omitted` : ''}`);
|