@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
|
@@ -109,9 +109,7 @@ function thresholdMetrics(value, label, expectedThreshold, expectedTotal, expect
|
|
|
109
109
|
const recall = ratio(truePositive, truePositive + falseNegative);
|
|
110
110
|
const specificity = ratio(trueNegative, trueNegative + falsePositive);
|
|
111
111
|
const falsePositiveRate = ratio(falsePositive, falsePositive + trueNegative);
|
|
112
|
-
const f1 =
|
|
113
|
-
? null
|
|
114
|
-
: Number(((2 * precision * recall) / (precision + recall)).toFixed(4));
|
|
112
|
+
const f1 = ratio(2 * truePositive, 2 * truePositive + falsePositive + falseNegative);
|
|
115
113
|
const balancedAccuracy = recall === null || specificity === null
|
|
116
114
|
? null
|
|
117
115
|
: Number(((recall + specificity) / 2).toFixed(4));
|
|
@@ -209,6 +207,15 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
209
207
|
throw new Error('Risk calibration summary must contain the fixed threshold set exactly once.');
|
|
210
208
|
}
|
|
211
209
|
const thresholds = Object.freeze(root.thresholds.map((value, index) => thresholdMetrics(value, `Threshold ${index + 1}`, RISK_CALIBRATION_SUMMARY_THRESHOLDS[index], positive + negative, positive, negative)));
|
|
210
|
+
for (let index = 1; index < thresholds.length; index += 1) {
|
|
211
|
+
const lower = thresholds[index - 1];
|
|
212
|
+
const higher = thresholds[index];
|
|
213
|
+
// The population and class totals are already fixed by thresholdMetrics.
|
|
214
|
+
// Raising the threshold can remove positive classifications, never add them.
|
|
215
|
+
if (higher.truePositive > lower.truePositive || higher.falsePositive > lower.falsePositive) {
|
|
216
|
+
throw new Error('Risk calibration positive classifications increase at a higher threshold.');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
212
219
|
if (!Array.isArray(root.strata) || root.strata.length > MAX_RISK_CALIBRATION_SUMMARY_STRATA) {
|
|
213
220
|
throw new Error('Risk calibration strata must be a bounded array.');
|
|
214
221
|
}
|
|
@@ -245,13 +252,18 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
245
252
|
metrics: thresholdMetrics(stratum.metrics, `${label} metrics`, currentReviewThreshold, sampleCount),
|
|
246
253
|
});
|
|
247
254
|
}));
|
|
255
|
+
const currentMetrics = thresholds.find((metric) => metric.threshold === currentReviewThreshold);
|
|
248
256
|
for (const dimension of ['review_reason', 'scan_depth']) {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
.reduce((sum, stratum) => sum + stratum.sampleCount, 0);
|
|
257
|
+
const members = strata.filter((stratum) => stratum.dimension === dimension);
|
|
258
|
+
const dimensionTotal = members.reduce((sum, stratum) => sum + stratum.sampleCount, 0);
|
|
252
259
|
if (dimensionTotal !== positive + negative) {
|
|
253
260
|
throw new Error(`Risk calibration ${dimension.replace('_', '-')} strata do not cover every included label exactly once.`);
|
|
254
261
|
}
|
|
262
|
+
for (const field of ['truePositive', 'falsePositive', 'trueNegative', 'falseNegative']) {
|
|
263
|
+
if (members.reduce((sum, stratum) => sum + stratum.metrics[field], 0) !== currentMetrics[field]) {
|
|
264
|
+
throw new Error(`Risk calibration ${dimension.replace('_', '-')} strata disagree with the current-threshold confusion counts.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
255
267
|
}
|
|
256
268
|
const comparison = object(root.modelComparison, 'Risk model comparison');
|
|
257
269
|
exactKeys(comparison, MODEL_COMPARISON_KEYS, 'Risk model comparison');
|
package/lib/rpki-evidence.mjs
CHANGED
|
@@ -9,9 +9,10 @@ function record(value) {
|
|
|
9
9
|
: null;
|
|
10
10
|
}
|
|
11
11
|
function normalizeAsn(value) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const number = typeof value === 'number' ? value
|
|
13
|
+
: typeof value === 'string' && value.length <= 16 && /^(?:AS)?\d{1,10}$/iu.test(value.trim())
|
|
14
|
+
? Number(value.trim().replace(/^AS/iu, '')) : NaN;
|
|
15
|
+
return Number.isSafeInteger(number) && number >= 0 && number <= 4_294_967_295 ? number : null;
|
|
15
16
|
}
|
|
16
17
|
function normalizeAuthorization(value) {
|
|
17
18
|
const source = record(value);
|
|
@@ -21,7 +22,8 @@ function normalizeAuthorization(value) {
|
|
|
21
22
|
const parsedPrefix = parseIpPrefix(prefix);
|
|
22
23
|
const asn = normalizeAsn(source.asn ?? source.asID ?? source.origin);
|
|
23
24
|
const maxLengthValue = source.maxLength ?? source.max_length ?? parsedPrefix?.length;
|
|
24
|
-
const maxLength =
|
|
25
|
+
const maxLength = typeof maxLengthValue === 'number' ? maxLengthValue
|
|
26
|
+
: typeof maxLengthValue === 'string' && /^\d{1,3}$/u.test(maxLengthValue) ? Number(maxLengthValue) : NaN;
|
|
25
27
|
const maximum = parsedPrefix?.family === 4 ? 32 : 128;
|
|
26
28
|
if (!parsedPrefix || asn === null || !Number.isInteger(maxLength)
|
|
27
29
|
|| maxLength < parsedPrefix.length || maxLength > maximum)
|
|
@@ -49,8 +51,9 @@ function reviewRpkiRoute(input) {
|
|
|
49
51
|
const baseLimitations = Object.freeze([
|
|
50
52
|
'This offline review evaluates an explicitly supplied route prefix and origin ASN against an analyst-supplied VRP snapshot.',
|
|
51
53
|
'It does not collect BGP announcements, establish route ownership, or prove that the snapshot was current or complete.',
|
|
54
|
+
'AS0 authorisations do not authorise an announcement. An AS0 route origin is invalid input; snapshot validation and signature verification remain external to this review.',
|
|
52
55
|
]);
|
|
53
|
-
if (!route || originAsn === null || !rows) {
|
|
56
|
+
if (!route || originAsn === null || originAsn === 0 || !rows) {
|
|
54
57
|
return Object.freeze({
|
|
55
58
|
schema: RPKI_EVIDENCE_SCHEMA,
|
|
56
59
|
version: RPKI_EVIDENCE_VERSION,
|
package/lib/safe-fetch.mjs
CHANGED
|
@@ -23,8 +23,10 @@ import * as net from 'node:net';
|
|
|
23
23
|
import * as crypto from 'node:crypto';
|
|
24
24
|
import { Agent, fetch as undiciFetch } from 'undici';
|
|
25
25
|
import { MAX_OUTBOUND_REDIRECTS } from "./outbound-request-bounds.mjs";
|
|
26
|
+
import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../packages/contracts/http-url.mjs";
|
|
27
|
+
import { ipv6Groups } from "../packages/contracts/ip-address.mjs";
|
|
26
28
|
const MAX_REDIRECTS = MAX_OUTBOUND_REDIRECTS;
|
|
27
|
-
const MAX_SAFE_FETCH_URL_LENGTH =
|
|
29
|
+
const MAX_SAFE_FETCH_URL_LENGTH = MAX_OUTBOUND_HTTP_URL_CHARACTERS;
|
|
28
30
|
const MAX_SAFE_FETCH_ADDRESS_CANDIDATES = 64;
|
|
29
31
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
30
32
|
async function defaultSafeFetchRequest(url, options) {
|
|
@@ -70,47 +72,6 @@ function isPrivateIpv4(ip) {
|
|
|
70
72
|
return true; // multicast/reserved
|
|
71
73
|
return false;
|
|
72
74
|
}
|
|
73
|
-
// Expands a valid IPv6 address string to its full 8-group, zero-padded hex
|
|
74
|
-
// form - e.g. "::1" -> all-zero groups ending in "0001". Needed because the
|
|
75
|
-
// same address can be written multiple ways (a "::" shorthand for a run of
|
|
76
|
-
// zero groups, an embedded IPv4 tail as either dotted-decimal or two hex
|
|
77
|
-
// groups), and a hostile authoritative DNS server - the exact threat this
|
|
78
|
-
// module exists to guard against - is free to choose whichever string form
|
|
79
|
-
// it wants. Checking string prefixes on the as-written form (the previous
|
|
80
|
-
// approach) only catches whichever form happened to be anticipated; e.g.
|
|
81
|
-
// "::ffff:127.0.0.1" and "::ffff:7f00:1" are the same address, but only the
|
|
82
|
-
// first was recognized as IPv4-mapped loopback.
|
|
83
|
-
function expandIpv6Groups(ip) {
|
|
84
|
-
const hasDoubleColon = ip.includes('::');
|
|
85
|
-
const [headStr, tailStr] = hasDoubleColon ? ip.split('::') : [ip, undefined];
|
|
86
|
-
// A dotted-decimal segment (the tail of "::ffff:127.0.0.1") is two
|
|
87
|
-
// 16-bit groups' worth of bits - convert it to hex groups first so the
|
|
88
|
-
// rest of this function only ever deals with plain hex.
|
|
89
|
-
const toHexGroups = (part) => {
|
|
90
|
-
if (!part)
|
|
91
|
-
return [];
|
|
92
|
-
return part.split(':').flatMap((group) => {
|
|
93
|
-
if (!group.includes('.'))
|
|
94
|
-
return [group];
|
|
95
|
-
const bytes = group.split('.').map(Number);
|
|
96
|
-
const [first = -1, second = -1, third = -1, fourth = -1] = bytes;
|
|
97
|
-
if (bytes.length !== 4
|
|
98
|
-
|| [first, second, third, fourth].some((byte) => !Number.isInteger(byte)))
|
|
99
|
-
return [];
|
|
100
|
-
return [
|
|
101
|
-
(((first << 8) | second) >>> 0).toString(16),
|
|
102
|
-
(((third << 8) | fourth) >>> 0).toString(16),
|
|
103
|
-
];
|
|
104
|
-
});
|
|
105
|
-
};
|
|
106
|
-
const headGroups = toHexGroups(headStr);
|
|
107
|
-
const tailGroups = hasDoubleColon ? toHexGroups(tailStr) : [];
|
|
108
|
-
if (!hasDoubleColon)
|
|
109
|
-
return headGroups.map((g) => g.padStart(4, '0').toLowerCase());
|
|
110
|
-
const missing = 8 - headGroups.length - tailGroups.length;
|
|
111
|
-
const middleGroups = new Array(Math.max(missing, 0)).fill('0');
|
|
112
|
-
return [...headGroups, ...middleGroups, ...tailGroups].map((g) => g.padStart(4, '0').toLowerCase());
|
|
113
|
-
}
|
|
114
75
|
function groupsToIpv4(hiGroup, loGroup) {
|
|
115
76
|
const hi = parseInt(hiGroup, 16);
|
|
116
77
|
const lo = parseInt(loGroup, 16);
|
|
@@ -119,7 +80,7 @@ function groupsToIpv4(hiGroup, loGroup) {
|
|
|
119
80
|
return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join('.');
|
|
120
81
|
}
|
|
121
82
|
function isPrivateIpv6(ip) {
|
|
122
|
-
const groups =
|
|
83
|
+
const groups = ipv6Groups(ip);
|
|
123
84
|
if (groups.length !== 8)
|
|
124
85
|
return true; // couldn't parse cleanly - fail closed
|
|
125
86
|
const [g0 = '', g1 = '', g2 = '', g3 = '', g4 = '', g5 = '', g6 = '', g7 = '',] = groups;
|
|
@@ -319,6 +280,47 @@ async function raceWithSignal(promise, signal) {
|
|
|
319
280
|
void promise.then((value) => { signal.removeEventListener('abort', abort); resolve(value); }, (error) => { signal.removeEventListener('abort', abort); reject(error); });
|
|
320
281
|
});
|
|
321
282
|
}
|
|
283
|
+
// Request bodies that can be replayed must be owned before the first await.
|
|
284
|
+
// Streams and multipart encoders remain valid for one request, never a replay.
|
|
285
|
+
function ownedRequestBody(body) {
|
|
286
|
+
if (body instanceof URLSearchParams)
|
|
287
|
+
return new URLSearchParams(body);
|
|
288
|
+
if (body instanceof ArrayBuffer)
|
|
289
|
+
return body.slice(0);
|
|
290
|
+
if (ArrayBuffer.isView(body))
|
|
291
|
+
return Uint8Array.from(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
|
|
292
|
+
return body;
|
|
293
|
+
}
|
|
294
|
+
function redirectedRequest(options, status, source, destination) {
|
|
295
|
+
const next = { ...options, headers: new Headers(options.headers) };
|
|
296
|
+
const method = (options.method ?? 'GET').toUpperCase();
|
|
297
|
+
if ((status === 301 || status === 302) && method === 'POST'
|
|
298
|
+
|| status === 303 && method !== 'GET' && method !== 'HEAD') {
|
|
299
|
+
next.method = 'GET';
|
|
300
|
+
next.body = null;
|
|
301
|
+
for (const name of ['content-encoding', 'content-language', 'content-location', 'content-type', 'content-length', 'transfer-encoding'])
|
|
302
|
+
next.headers.delete(name);
|
|
303
|
+
}
|
|
304
|
+
else if (next.body != null && !(typeof next.body === 'string' || next.body instanceof Blob
|
|
305
|
+
|| next.body instanceof URLSearchParams || next.body instanceof ArrayBuffer || ArrayBuffer.isView(next.body))) {
|
|
306
|
+
throw new Error('Refusing to replay a streaming or multipart request body after a redirect');
|
|
307
|
+
}
|
|
308
|
+
if (source.origin !== destination.origin) {
|
|
309
|
+
// This collector is not a general authenticated browser. Do not forward a
|
|
310
|
+
// caller's body or custom credentials to another authority or a downgrade.
|
|
311
|
+
if (next.body != null || !['GET', 'HEAD'].includes((next.method ?? 'GET').toUpperCase())) {
|
|
312
|
+
throw new Error('Refusing to forward a request body or mutation across origins');
|
|
313
|
+
}
|
|
314
|
+
const publicHeaders = new Set(['accept', 'accept-encoding', 'accept-language', 'user-agent', 'range']);
|
|
315
|
+
for (const name of [...next.headers.keys()])
|
|
316
|
+
if (!publicHeaders.has(name))
|
|
317
|
+
next.headers.delete(name);
|
|
318
|
+
next.credentials = 'omit';
|
|
319
|
+
next.referrer = '';
|
|
320
|
+
next.referrerPolicy = 'no-referrer';
|
|
321
|
+
}
|
|
322
|
+
return next;
|
|
323
|
+
}
|
|
322
324
|
// Detailed form of the shared safe request engine. It follows redirects
|
|
323
325
|
// manually under the same DNS validation and connection-pinning policy as
|
|
324
326
|
// safeFetch(), while retaining a bounded hop trace for consumers that need
|
|
@@ -343,10 +345,14 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
|
|
|
343
345
|
const hops = [];
|
|
344
346
|
let currentUrl = requestedUrl;
|
|
345
347
|
let redirectCount = 0;
|
|
348
|
+
let requestOptions = { ...options, headers: new Headers(options.headers),
|
|
349
|
+
...(options.body !== undefined ? { body: ownedRequestBody(options.body) } : {}) };
|
|
346
350
|
while (true) {
|
|
347
351
|
const parsed = new URL(currentUrl);
|
|
348
352
|
const hostname = resolutionHostname(parsed);
|
|
349
|
-
const signal =
|
|
353
|
+
const signal = requestOptions.signal ?? null;
|
|
354
|
+
if (signal?.aborted)
|
|
355
|
+
throw abortError(signal);
|
|
350
356
|
const records = normalizePublicAddressCandidates(await raceWithSignal(resolveAddresses(hostname), signal), hostname);
|
|
351
357
|
if (signal?.aborted)
|
|
352
358
|
throw abortError(signal);
|
|
@@ -354,7 +360,7 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
|
|
|
354
360
|
const hopStartedAt = now();
|
|
355
361
|
// `dispatcher` is a supported undici extension used to pin the connection.
|
|
356
362
|
// Keep the non-standard property local to this request boundary.
|
|
357
|
-
const fetchOptions = { ...
|
|
363
|
+
const fetchOptions = { ...requestOptions, redirect: 'manual', dispatcher };
|
|
358
364
|
let response;
|
|
359
365
|
try {
|
|
360
366
|
response = await request(currentUrl, fetchOptions);
|
|
@@ -384,6 +390,7 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
|
|
|
384
390
|
if (nextUrl && redirectCount < maxRedirects) {
|
|
385
391
|
await response.body?.cancel().catch(() => { });
|
|
386
392
|
await closeDispatcher(dispatcher);
|
|
393
|
+
requestOptions = redirectedRequest(requestOptions, response.status, parsed, new URL(nextUrl));
|
|
387
394
|
redirectCount += 1;
|
|
388
395
|
currentUrl = nextUrl;
|
|
389
396
|
continue;
|
|
@@ -418,8 +425,8 @@ async function safeFetch(url, options = {}, redirectsLeft = MAX_REDIRECTS) {
|
|
|
418
425
|
// them can be made to hold an arbitrarily large body in memory. `truncated`
|
|
419
426
|
// tells the caller whether the real body was actually cut short - callers
|
|
420
427
|
// that need well-formed content (e.g. JSON) should treat that as a hard
|
|
421
|
-
// failure rather than parsing a partial blob
|
|
422
|
-
//
|
|
428
|
+
// failure rather than parsing a partial blob. Partial-evidence consumers may
|
|
429
|
+
// use the prefix, but must preserve its incomplete scope.
|
|
423
430
|
async function readTextCapped(res, maxBytes, options = {}) {
|
|
424
431
|
const hasher = options.includeSha256 === true ? crypto.createHash('sha256') : null;
|
|
425
432
|
const result = (text, truncated, bytesRead) => ({
|
package/lib/security-txt.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Risk. Published contact and policy values are normalized into a small,
|
|
5
5
|
// bounded record; the response body itself is discarded after parsing.
|
|
6
6
|
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
7
|
-
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
7
|
+
import { createObservation, normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
8
8
|
const SECURITY_TXT_VERSION = 1;
|
|
9
9
|
const SECURITY_TXT_PATH = '/.well-known/security.txt';
|
|
10
10
|
const SECURITY_TXT_TIMEOUT_MS = 6_000;
|
|
@@ -18,7 +18,6 @@ const CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
|
18
18
|
const URI_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
|
|
19
19
|
const FIELD_RE = /^([A-Za-z][A-Za-z-]*):[ \t]*(.*)$/u;
|
|
20
20
|
const LANGUAGE_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/iu;
|
|
21
|
-
const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
|
|
22
21
|
const CONTACT_PROTOCOLS = new Set(['https:', 'mailto:', 'tel:']);
|
|
23
22
|
const HTTPS_PROTOCOLS = new Set(['https:']);
|
|
24
23
|
const ENCRYPTION_PROTOCOLS = new Set(['https:', 'dns:', 'openpgp4fpr:']);
|
|
@@ -50,6 +49,38 @@ function normalizedPublishedUri(value, schemes) {
|
|
|
50
49
|
return null;
|
|
51
50
|
if (url.protocol === 'https:' && !url.hostname)
|
|
52
51
|
return null;
|
|
52
|
+
if (url.protocol === 'mailto:') {
|
|
53
|
+
let recipients;
|
|
54
|
+
try {
|
|
55
|
+
recipients = decodeURIComponent(url.pathname).split(',');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!recipients.length || recipients.some((recipient) => {
|
|
61
|
+
const separator = recipient.lastIndexOf('@');
|
|
62
|
+
if (separator <= 0 || separator === recipient.length - 1)
|
|
63
|
+
return true;
|
|
64
|
+
const local = recipient.slice(0, separator);
|
|
65
|
+
const mailDomain = recipient.slice(separator + 1).toLowerCase();
|
|
66
|
+
return !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/iu.test(local)
|
|
67
|
+
|| local.startsWith('.') || local.endsWith('.') || local.includes('..')
|
|
68
|
+
|| !canonicalHostname(mailDomain);
|
|
69
|
+
}))
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (url.protocol === 'tel:') {
|
|
73
|
+
let number;
|
|
74
|
+
try {
|
|
75
|
+
number = decodeURIComponent(url.pathname);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!/^\+?[0-9(). -]+(?:;[a-z0-9-]+=[a-z0-9.-]+)*$/iu.test(number)
|
|
81
|
+
|| (number.match(/[0-9]/gu)?.length ?? 0) < 3)
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
53
84
|
url.search = '';
|
|
54
85
|
url.hash = '';
|
|
55
86
|
return url.toString().slice(0, MAX_SECURITY_TXT_URL_LENGTH);
|
|
@@ -190,8 +221,9 @@ function parseSecurityTxt(text, options = {}) {
|
|
|
190
221
|
malformedCount += languages.length - valid.length;
|
|
191
222
|
}
|
|
192
223
|
else if (field === 'expires') {
|
|
193
|
-
|
|
194
|
-
|
|
224
|
+
const expiry = normalizeExplicitIsoTimestamp(value);
|
|
225
|
+
if (expiry)
|
|
226
|
+
expires.push(expiry);
|
|
195
227
|
else
|
|
196
228
|
malformedCount += 1;
|
|
197
229
|
}
|
|
@@ -203,15 +235,18 @@ function parseSecurityTxt(text, options = {}) {
|
|
|
203
235
|
const boundedPolicies = uniqueBounded(policies);
|
|
204
236
|
const boundedEncryption = uniqueBounded(encryption);
|
|
205
237
|
const boundedCanonical = uniqueBounded(canonical);
|
|
238
|
+
const boundedCanonicalComparison = uniqueBounded(canonicalComparison);
|
|
239
|
+
const canonicalComparisonTruncated = valuesTruncated || boundedCanonicalComparison.truncated;
|
|
206
240
|
const boundedLanguages = uniqueBounded(preferredLanguages);
|
|
207
|
-
valuesTruncated ||= [boundedContacts, boundedPolicies, boundedEncryption, boundedCanonical, boundedLanguages]
|
|
241
|
+
valuesTruncated ||= [boundedContacts, boundedPolicies, boundedEncryption, boundedCanonical, boundedCanonicalComparison, boundedLanguages]
|
|
208
242
|
.some((entry) => entry.truncated);
|
|
209
243
|
const expiresAt = expires.length === 1 ? expires[0] : null;
|
|
210
244
|
const finalUrl = options.finalUrl ? normalizedHttpsUrl(options.finalUrl) : null;
|
|
211
245
|
const finalComparisonUrl = options.finalUrl ? normalizedCanonicalComparisonUrl(options.finalUrl) : null;
|
|
212
246
|
const canonicalMatches = boundedCanonical.values.length && finalUrl && finalComparisonUrl
|
|
213
|
-
?
|
|
214
|
-
|
|
247
|
+
? boundedCanonicalComparison.values.includes(finalComparisonUrl)
|
|
248
|
+
? true
|
|
249
|
+
: canonicalComparisonTruncated ? null : false
|
|
215
250
|
: null;
|
|
216
251
|
const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
|
|
217
252
|
const requiredMalformed = boundedContacts.values.length === 0 || expires.length !== 1;
|
|
@@ -442,6 +442,7 @@ function parseResolverEndpoint(value) {
|
|
|
442
442
|
}
|
|
443
443
|
function defaultTcpExchange(query, resolver, options) {
|
|
444
444
|
return new Promise((resolve, reject) => {
|
|
445
|
+
options.signal?.throwIfAborted();
|
|
445
446
|
const socket = createConnection({
|
|
446
447
|
host: resolver.address,
|
|
447
448
|
port: resolver.port,
|
|
@@ -457,12 +458,19 @@ function defaultTcpExchange(query, resolver, options) {
|
|
|
457
458
|
settled = true;
|
|
458
459
|
if (hardDeadline)
|
|
459
460
|
clearTimeout(hardDeadline);
|
|
461
|
+
options.signal?.removeEventListener('abort', abort);
|
|
460
462
|
socket.destroy();
|
|
461
463
|
if (error)
|
|
462
464
|
reject(error);
|
|
463
465
|
else
|
|
464
466
|
resolve(response);
|
|
465
467
|
};
|
|
468
|
+
const abort = () => finish(options.signal?.reason ?? new Error('DNS TCP query cancelled'));
|
|
469
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
470
|
+
if (options.signal?.aborted) {
|
|
471
|
+
abort();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
466
474
|
hardDeadline = setTimeout(() => finish(new ServiceBindingDnsError('DNS TCP query timed out', 'ETIMEOUT')), options.timeoutMs);
|
|
467
475
|
hardDeadline.unref();
|
|
468
476
|
socket.setTimeout(options.timeoutMs, () => finish(new ServiceBindingDnsError('DNS TCP query timed out', 'ETIMEOUT')));
|
|
@@ -25,6 +25,7 @@ const MAX_SMTP_RESPONSE_BYTES = 16 * 1024;
|
|
|
25
25
|
const MAX_SMTP_REPLY_LINES = 64;
|
|
26
26
|
const MAX_SMTP_LINE_BYTES = 1_000;
|
|
27
27
|
const MAX_SMTP_CAPABILITIES = 32;
|
|
28
|
+
const SMTP_CAPABILITY_OMISSION = 'The retained EHLO capability inventory is incomplete; additional names were omitted. STARTTLS detection used the full bounded reply.';
|
|
28
29
|
const MAX_SMTP_CERTIFICATE_BYTES = 256 * 1024;
|
|
29
30
|
const SMTP_ENDPOINT_TIMEOUT_MS = 8_000;
|
|
30
31
|
const MAIL_TRANSPORT_TOTAL_TIMEOUT_MS = 30_000;
|
|
@@ -138,19 +139,27 @@ function normalizeSmtpReply(linesValue) {
|
|
|
138
139
|
const sha256 = crypto.createHash('sha256').update(`${lines.join('\r\n')}\r\n`, 'ascii').digest('hex');
|
|
139
140
|
return Object.freeze({ code, lines: Object.freeze(lines), bytes, sha256 });
|
|
140
141
|
}
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
function smtpCapabilityInventory(reply) {
|
|
143
|
+
// The reply has already passed line and byte admission. The display cap must
|
|
144
|
+
// not decide which protocol extensions the endpoint advertised.
|
|
145
|
+
const seen = new Set();
|
|
144
146
|
const output = [];
|
|
145
|
-
for (const line of reply.lines.slice(1)) {
|
|
147
|
+
for (const line of reply?.code === 250 ? reply.lines.slice(1) : []) {
|
|
146
148
|
const capability = line.slice(4).trim().split(/\s+/u)[0]?.toUpperCase() || '';
|
|
147
|
-
if (!/^[A-Z0-9][A-Z0-9-]{0,31}$/u.test(capability) ||
|
|
149
|
+
if (!/^[A-Z0-9][A-Z0-9-]{0,31}$/u.test(capability) || seen.has(capability))
|
|
148
150
|
continue;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
seen.add(capability);
|
|
152
|
+
if (output.length < MAX_SMTP_CAPABILITIES)
|
|
153
|
+
output.push(capability);
|
|
152
154
|
}
|
|
153
|
-
return Object.freeze(
|
|
155
|
+
return Object.freeze({
|
|
156
|
+
capabilities: Object.freeze(output.sort()),
|
|
157
|
+
starttlsAdvertised: seen.has('STARTTLS'),
|
|
158
|
+
truncated: seen.size > output.length,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function smtpCapabilities(reply) {
|
|
162
|
+
return smtpCapabilityInventory(reply).capabilities;
|
|
154
163
|
}
|
|
155
164
|
async function runSmtpConversation(hostname, connection) {
|
|
156
165
|
try {
|
|
@@ -162,8 +171,7 @@ async function runSmtpConversation(hostname, connection) {
|
|
|
162
171
|
connection.assertIdle();
|
|
163
172
|
await connection.write(EHLO_COMMAND);
|
|
164
173
|
const ehlo = await connection.readReply();
|
|
165
|
-
const capabilities =
|
|
166
|
-
const starttlsAdvertised = capabilities.includes('STARTTLS');
|
|
174
|
+
const { capabilities, starttlsAdvertised } = smtpCapabilityInventory(ehlo);
|
|
167
175
|
if (!starttlsAdvertised) {
|
|
168
176
|
const diagnostics = connection.diagnostics();
|
|
169
177
|
return Object.freeze({ connectedAddress: connection.remoteAddress, greeting, ehlo, capabilities, starttlsAdvertised, starttlsReply: null, starttlsState: 'not_advertised', tls: null, ...diagnostics });
|
|
@@ -486,11 +494,12 @@ function addressAuthentication(selected) {
|
|
|
486
494
|
})
|
|
487
495
|
: Object.freeze({ state: 'unavailable', detail: 'No address candidate was retained for cryptographic authentication review.' });
|
|
488
496
|
}
|
|
489
|
-
function endpointLimitations(addressState) {
|
|
497
|
+
function endpointLimitations(addressState, capabilitiesTruncated = false) {
|
|
490
498
|
const addressLimitation = addressState === 'connected'
|
|
491
499
|
? 'The endpoint observation is one point-in-time connection to one revalidated and pinned public address. Other addresses or later observations may differ.'
|
|
492
500
|
: 'The endpoint did not produce confirmed connection evidence. Its address field retains only the highest completed public selection or revalidation stage, if any.';
|
|
493
501
|
return Object.freeze([
|
|
502
|
+
...(capabilitiesTruncated ? [SMTP_CAPABILITY_OMISSION] : []),
|
|
494
503
|
addressLimitation,
|
|
495
504
|
'Public-address validation, fresh revalidation, and connection pinning do not cryptographically authenticate the selected A or AAAA RRset or any CNAME chain.',
|
|
496
505
|
'Only the SMTP greeting, EHLO capability names, and optional STARTTLS negotiation were observed. No authentication, recipient, mailbox, catch-all, relay, or message command was attempted.',
|
|
@@ -587,10 +596,12 @@ async function collectEndpoint(options) {
|
|
|
587
596
|
const tlsaComplete = !['indeterminate', 'timed_out', 'unavailable'].includes(tlsa.state)
|
|
588
597
|
&& (tlsa.state !== 'validated' || !tlsa.dane || !['partial', 'unavailable', 'invalid'].includes(tlsa.dane.state));
|
|
589
598
|
const certificateComplete = smtp.starttlsState !== 'negotiated' || certificate.observation.state === 'observed';
|
|
599
|
+
const capabilitiesTruncated = smtpCapabilityInventory(smtp.ehlo).truncated;
|
|
590
600
|
const complete = smtpState === 'observed'
|
|
591
601
|
&& dnssec.report.completeness === 'complete'
|
|
592
602
|
&& tlsaComplete
|
|
593
|
-
&& certificateComplete
|
|
603
|
+
&& certificateComplete
|
|
604
|
+
&& !capabilitiesTruncated;
|
|
594
605
|
return Object.freeze({
|
|
595
606
|
host: options.host,
|
|
596
607
|
observedAt: options.observedAt,
|
|
@@ -604,7 +615,7 @@ async function collectEndpoint(options) {
|
|
|
604
615
|
dnssec: dnssec.report,
|
|
605
616
|
tlsa,
|
|
606
617
|
failure: null,
|
|
607
|
-
limitations: endpointLimitations(addressState),
|
|
618
|
+
limitations: endpointLimitations(addressState, capabilitiesTruncated),
|
|
608
619
|
});
|
|
609
620
|
}
|
|
610
621
|
catch (error) {
|
|
@@ -627,7 +638,7 @@ async function collectEndpoint(options) {
|
|
|
627
638
|
dnssec: dnssec?.report ?? null,
|
|
628
639
|
tlsa: unavailableTlsa('unavailable', 'TLSA and DANE comparison was unavailable because endpoint collection did not complete.'),
|
|
629
640
|
failure: Object.freeze({ stage, detail }),
|
|
630
|
-
limitations: endpointLimitations(addressState),
|
|
641
|
+
limitations: endpointLimitations(addressState, smtpCapabilityInventory(connectedSmtp?.ehlo ?? null).truncated),
|
|
631
642
|
});
|
|
632
643
|
}
|
|
633
644
|
}
|
|
@@ -748,6 +759,7 @@ async function collectMailTransportReview(inputValue, options, dependencies = {}
|
|
|
748
759
|
retries: 0,
|
|
749
760
|
}),
|
|
750
761
|
limitations: Object.freeze([
|
|
762
|
+
...(endpoints.some((endpoint) => endpoint.limitations.includes(SMTP_CAPABILITY_OMISSION)) ? [SMTP_CAPABILITY_OMISSION] : []),
|
|
751
763
|
'This command performs active network collection only for the selected MX hosts in this explicitly authorised run. It is not invoked by Lookup, Bulk, monitoring, or automatic recipes.',
|
|
752
764
|
'DNSSEC, TLSA or DANE, PKIX, certificate identity, STARTTLS, SMTP transport, MTA-STS context, and TLS-RPT context retain separate states and provenance. One family never supplies or upgrades another family.',
|
|
753
765
|
ACTIVE_DANE_TA_LIMITATION,
|