@slicedearth/whoisleuth-cli 2.3.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 +19 -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 +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- 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 +83 -61
- 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-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- 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 +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -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 +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -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/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-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 +45 -0
- package/cli/runner.mjs +55 -6
- 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 +76 -30
- package/docs/cli.md +289 -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 +43 -3
- 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-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- 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 +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- 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 +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- 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-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 +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -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 +117 -146
- 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 +111 -71
- 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/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 +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- 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 +38 -587
- 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 +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- 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 +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -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 +0 -1
- 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 +160 -225
- 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 +155 -27
- 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 +89 -49
- 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 +161 -141
- 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 +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- 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 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
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:']);
|
|
@@ -222,8 +221,9 @@ function parseSecurityTxt(text, options = {}) {
|
|
|
222
221
|
malformedCount += languages.length - valid.length;
|
|
223
222
|
}
|
|
224
223
|
else if (field === 'expires') {
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
const expiry = normalizeExplicitIsoTimestamp(value);
|
|
225
|
+
if (expiry)
|
|
226
|
+
expires.push(expiry);
|
|
227
227
|
else
|
|
228
228
|
malformedCount += 1;
|
|
229
229
|
}
|
|
@@ -235,15 +235,18 @@ function parseSecurityTxt(text, options = {}) {
|
|
|
235
235
|
const boundedPolicies = uniqueBounded(policies);
|
|
236
236
|
const boundedEncryption = uniqueBounded(encryption);
|
|
237
237
|
const boundedCanonical = uniqueBounded(canonical);
|
|
238
|
+
const boundedCanonicalComparison = uniqueBounded(canonicalComparison);
|
|
239
|
+
const canonicalComparisonTruncated = valuesTruncated || boundedCanonicalComparison.truncated;
|
|
238
240
|
const boundedLanguages = uniqueBounded(preferredLanguages);
|
|
239
|
-
valuesTruncated ||= [boundedContacts, boundedPolicies, boundedEncryption, boundedCanonical, boundedLanguages]
|
|
241
|
+
valuesTruncated ||= [boundedContacts, boundedPolicies, boundedEncryption, boundedCanonical, boundedCanonicalComparison, boundedLanguages]
|
|
240
242
|
.some((entry) => entry.truncated);
|
|
241
243
|
const expiresAt = expires.length === 1 ? expires[0] : null;
|
|
242
244
|
const finalUrl = options.finalUrl ? normalizedHttpsUrl(options.finalUrl) : null;
|
|
243
245
|
const finalComparisonUrl = options.finalUrl ? normalizedCanonicalComparisonUrl(options.finalUrl) : null;
|
|
244
246
|
const canonicalMatches = boundedCanonical.values.length && finalUrl && finalComparisonUrl
|
|
245
|
-
?
|
|
246
|
-
|
|
247
|
+
? boundedCanonicalComparison.values.includes(finalComparisonUrl)
|
|
248
|
+
? true
|
|
249
|
+
: canonicalComparisonTruncated ? null : false
|
|
247
250
|
: null;
|
|
248
251
|
const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
|
|
249
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,
|