@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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/DISCLOSURE +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +29 -29
|
@@ -4,6 +4,48 @@
|
|
|
4
4
|
import { parse as parseDomain } from 'tldts';
|
|
5
5
|
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
6
6
|
import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
|
|
7
|
+
const SPF_PERMISSIVE_PATH_ISSUE = 'Observed permissive +all evidence on a potentially reachable positive SPF include or redirect path; review sender authorisation.';
|
|
8
|
+
// This is a conservative universal-result check for unconditional all/include
|
|
9
|
+
// chains, not a sender evaluator. Conditional mechanisms remain unknown.
|
|
10
|
+
function spfStaticResult(policy) {
|
|
11
|
+
const parsed = policy.parsed;
|
|
12
|
+
if (!parsed?.valid)
|
|
13
|
+
return { everySender: null, permissive: false };
|
|
14
|
+
let uncertain = false, permissive = false;
|
|
15
|
+
const finish = (result) => ({
|
|
16
|
+
everySender: !uncertain ? result : null,
|
|
17
|
+
permissive,
|
|
18
|
+
});
|
|
19
|
+
for (const mechanism of parsed.mechanisms) {
|
|
20
|
+
if (mechanism.kind === 'all') {
|
|
21
|
+
if (mechanism.qualifier === '+')
|
|
22
|
+
permissive = true;
|
|
23
|
+
return finish(mechanism.qualifier);
|
|
24
|
+
}
|
|
25
|
+
if (mechanism.kind === 'include') {
|
|
26
|
+
const child = policy.children.get(mechanism.includeIndex);
|
|
27
|
+
const result = child ? spfStaticResult(child) : { everySender: null, permissive: false };
|
|
28
|
+
if (mechanism.qualifier === '+' && result.permissive)
|
|
29
|
+
permissive = true;
|
|
30
|
+
if (result.everySender === '+')
|
|
31
|
+
return finish(mechanism.qualifier);
|
|
32
|
+
if (result.everySender !== null)
|
|
33
|
+
continue; // Only child pass matches include.
|
|
34
|
+
uncertain = true;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// Without sender evaluation, even a literal mechanism remains conditional.
|
|
38
|
+
uncertain = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (parsed.redirect) {
|
|
42
|
+
const child = policy.children.get(parsed.includes.length);
|
|
43
|
+
const result = child ? spfStaticResult(child) : { everySender: null, permissive: false };
|
|
44
|
+
permissive ||= result.permissive;
|
|
45
|
+
return result.everySender === null ? { everySender: null, permissive } : finish(result.everySender);
|
|
46
|
+
}
|
|
47
|
+
return { everySender: null, permissive };
|
|
48
|
+
}
|
|
7
49
|
const SPF_LOOKUP_LIMIT = 10;
|
|
8
50
|
const SPF_VOID_LOOKUP_LIMIT = 2;
|
|
9
51
|
const SPF_MAX_DEPTH = 5;
|
|
@@ -41,6 +83,7 @@ function branchFor(item, state, terminalPolicy, dnsLookupTerms, issues) {
|
|
|
41
83
|
};
|
|
42
84
|
}
|
|
43
85
|
async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
86
|
+
const rootPolicy = { parsed: null, children: new Map() };
|
|
44
87
|
const queue = [{
|
|
45
88
|
domain: rootDomain,
|
|
46
89
|
parent: null,
|
|
@@ -49,6 +92,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
49
92
|
path: [rootDomain],
|
|
50
93
|
records: rootQuery.records,
|
|
51
94
|
error: rootQuery.error,
|
|
95
|
+
policy: rootPolicy,
|
|
52
96
|
}];
|
|
53
97
|
const branches = [];
|
|
54
98
|
const issues = [];
|
|
@@ -100,6 +144,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
100
144
|
branches.push(branchFor(item, parsed.valid ? 'success' : 'invalid', parsed.terminalPolicy, parsed.dnsLookupTerms, parsed.issues));
|
|
101
145
|
if (!parsed.valid)
|
|
102
146
|
continue;
|
|
147
|
+
item.policy.parsed = parsed;
|
|
103
148
|
if (dnsLookupTerms > SPF_LOOKUP_LIMIT) {
|
|
104
149
|
boundReached = true;
|
|
105
150
|
issues.push(`The expanded policy declares more than ${SPF_LOOKUP_LIMIT} DNS-querying terms.`);
|
|
@@ -111,7 +156,9 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
111
156
|
...parsed.includes.map((domain) => ({ relation: 'include', domain })),
|
|
112
157
|
...(parsed.redirect ? [{ relation: 'redirect', domain: parsed.redirect }] : []),
|
|
113
158
|
];
|
|
114
|
-
for (const dependency of dependencies) {
|
|
159
|
+
for (const [dependencyIndex, dependency] of dependencies.entries()) {
|
|
160
|
+
const policy = { parsed: null, children: new Map() };
|
|
161
|
+
item.policy.children.set(dependencyIndex, policy);
|
|
115
162
|
const normalized = strictHostname(dependency.domain);
|
|
116
163
|
if (!normalized) {
|
|
117
164
|
branches.push(branchFor({
|
|
@@ -120,6 +167,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
120
167
|
relation: dependency.relation,
|
|
121
168
|
depth: item.depth + 1,
|
|
122
169
|
path: item.path,
|
|
170
|
+
policy,
|
|
123
171
|
}, 'invalid', null, 0, ['The SPF dependency is not a literal public hostname; macros are not expanded by this audit.']));
|
|
124
172
|
continue;
|
|
125
173
|
}
|
|
@@ -129,6 +177,7 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
129
177
|
relation: dependency.relation,
|
|
130
178
|
depth: item.depth + 1,
|
|
131
179
|
path: [...item.path, normalized],
|
|
180
|
+
policy,
|
|
132
181
|
});
|
|
133
182
|
}
|
|
134
183
|
}
|
|
@@ -145,6 +194,8 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
145
194
|
: incomplete
|
|
146
195
|
? 'partial'
|
|
147
196
|
: 'complete';
|
|
197
|
+
if (rootPolicy.parsed?.terminalPolicy !== 'pass' && spfStaticResult(rootPolicy).permissive)
|
|
198
|
+
issues.unshift(SPF_PERMISSIVE_PATH_ISSUE);
|
|
148
199
|
return {
|
|
149
200
|
version: 1,
|
|
150
201
|
state,
|
|
@@ -293,4 +344,4 @@ function buildExternalDependencies({ domain, nameservers, mx, spfExpansion, dmar
|
|
|
293
344
|
|| left.target.localeCompare(right.target)
|
|
294
345
|
|| left.source.localeCompare(right.source)));
|
|
295
346
|
}
|
|
296
|
-
export { SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };
|
|
347
|
+
export { SPF_PERMISSIVE_PATH_ISSUE, SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };
|
|
@@ -48,6 +48,7 @@ function parseSpfRecords(records) {
|
|
|
48
48
|
terminalPolicy: null,
|
|
49
49
|
redirect: null,
|
|
50
50
|
includes: [],
|
|
51
|
+
mechanisms: [],
|
|
51
52
|
authorizingTerms: [],
|
|
52
53
|
dnsLookupTerms: 0,
|
|
53
54
|
issues: [],
|
|
@@ -175,6 +176,14 @@ function parseSpfRecords(records) {
|
|
|
175
176
|
result.includes = terms
|
|
176
177
|
.map((term) => term.match(/^[+?~-]?include:(.+)$/i)?.[1] || '')
|
|
177
178
|
.filter(Boolean);
|
|
179
|
+
let includeIndex = 0;
|
|
180
|
+
result.mechanisms = terms.filter(term => !/^[+?~-]?[a-z][a-z0-9_.-]*=/iu.test(term)).map(term => {
|
|
181
|
+
const qualifier = (/^[+?~-]/u.test(term) ? term[0] : '+');
|
|
182
|
+
const body = /^[+?~-]/u.test(term) ? term.slice(1) : term;
|
|
183
|
+
const include = body.match(/^include:(.+)$/iu);
|
|
184
|
+
return { kind: /^all$/iu.test(body) ? 'all' : include ? 'include' : 'other', qualifier,
|
|
185
|
+
includeIndex: include ? includeIndex++ : null };
|
|
186
|
+
});
|
|
178
187
|
if (terms.some((term) => /^[+?~-]?ptr(?::|$)/i.test(term))) {
|
|
179
188
|
result.issues.push('The deprecated ptr mechanism should not be used.');
|
|
180
189
|
}
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.
|
|
|
12
12
|
import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECK_RECORDS, POSTURE_CHECK_SOURCES } from "../packages/evidence/domain-posture-context.mjs";
|
|
13
13
|
import { canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
|
|
14
14
|
import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
|
|
15
|
-
import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
|
|
15
|
+
import { SPF_PERMISSIVE_PATH_ISSUE, buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
|
|
16
16
|
const DNS_TIMEOUT_MS = 6000;
|
|
17
17
|
const POLICY_TIMEOUT_MS = 7000;
|
|
18
18
|
const MAX_POLICY_BYTES = 64 * 1024;
|
|
@@ -156,7 +156,8 @@ function spfCheck(query, expansion) {
|
|
|
156
156
|
remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
|
-
|
|
159
|
+
const permissiveExpansion = expansion?.issues.includes(SPF_PERMISSIVE_PATH_ISSUE) ?? false;
|
|
160
|
+
if (parsed.terminalPolicy === 'fail' && parsed.issues.length === 0 && !permissiveExpansion && (!expansion || expansion.state === 'complete')) {
|
|
160
161
|
return check('spf', 'SPF', 'pass', 'Restrictive fail-all policy', { detail: details.join(' '), records: parsed.records });
|
|
161
162
|
}
|
|
162
163
|
if (expansion && expansion.state !== 'complete') {
|
|
@@ -166,6 +167,12 @@ function spfCheck(query, expansion) {
|
|
|
166
167
|
remediation: 'Review unresolved, invalid, cyclic, or budget-limited include and redirect branches before treating the policy as complete.',
|
|
167
168
|
});
|
|
168
169
|
}
|
|
170
|
+
if (permissiveExpansion) {
|
|
171
|
+
return check('spf', 'SPF', 'warning', 'SPF authorisation needs review', {
|
|
172
|
+
detail: details.join(' '), records: parsed.records,
|
|
173
|
+
remediation: 'Review the positive include or redirect path containing +all; complete collection does not establish restrictive sender authorisation.',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
169
176
|
if (parsed.terminalPolicy === 'redirect') {
|
|
170
177
|
return check('spf', 'SPF', 'info', 'Policy delegates evaluation with redirect', {
|
|
171
178
|
detail: details.join(' '), records: parsed.records,
|
|
@@ -301,6 +308,11 @@ function dnssecDelegationConsistencyCheck(input, registry) {
|
|
|
301
308
|
}
|
|
302
309
|
const value = String(input.value || '').toLowerCase();
|
|
303
310
|
const hasDsRecords = registry.dsRecordCount > 0;
|
|
311
|
+
if (registry.dsDataTruncated) {
|
|
312
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
|
|
313
|
+
detail: 'The normalised registry response reported truncated DS data, so delegation consistency is inconclusive.',
|
|
314
|
+
});
|
|
315
|
+
}
|
|
304
316
|
if (value === 'signed' && hasDsRecords) {
|
|
305
317
|
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'pass', 'Signed state and retained DS records agree', {
|
|
306
318
|
detail: `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} retained.`,
|
|
@@ -311,11 +323,6 @@ function dnssecDelegationConsistencyCheck(input, registry) {
|
|
|
311
323
|
detail: 'This is a consistency result, not a recommendation to leave DNSSEC disabled.',
|
|
312
324
|
});
|
|
313
325
|
}
|
|
314
|
-
if (registry.dsDataTruncated) {
|
|
315
|
-
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
|
|
316
|
-
detail: 'The normalised registry response reported truncated DS data, so apparent disagreement is inconclusive.',
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
326
|
if (value === 'signed' && !hasDsRecords) {
|
|
320
327
|
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'warning', 'Signed state reported without retained DS records', {
|
|
321
328
|
detail: 'This can reflect registry publication differences or incomplete upstream data and should be reviewed before changing delegation.',
|
|
@@ -360,13 +367,25 @@ function matchesMtaPattern(host, pattern) {
|
|
|
360
367
|
}
|
|
361
368
|
return normalizedHost === normalizedPattern;
|
|
362
369
|
}
|
|
370
|
+
function mailPolicyApplicability(mxQuery) {
|
|
371
|
+
if (mxQuery.error)
|
|
372
|
+
return { hasMx: false, detail: 'Receiving-mail applicability is unknown because the MX lookup failed.' };
|
|
373
|
+
const records = completeMxRecords(mxQuery.records);
|
|
374
|
+
if (!records)
|
|
375
|
+
return { hasMx: false, detail: 'Receiving-mail applicability is unknown because the MX evidence is incomplete.' };
|
|
376
|
+
const { hasMx, hasNullMx } = classifyMxRecords(records);
|
|
377
|
+
return { hasMx, detail: hasMx ? '' : hasNullMx
|
|
378
|
+
? 'Null MX explicitly declares that this domain does not accept inbound mail.'
|
|
379
|
+
: 'No MX records were observed. This alone does not establish that the domain cannot receive mail.' };
|
|
380
|
+
}
|
|
363
381
|
function mtaStsCheck(dnsQuery, policyFetch, mxQuery) {
|
|
364
382
|
if (dnsQuery.error)
|
|
365
383
|
return queryFailureCheck('mta_sts', 'MTA-STS', dnsQuery.error);
|
|
366
384
|
const dnsPolicy = parseMtaStsDnsRecords(dnsQuery.records);
|
|
367
|
-
const hasMx = !mxQuery.error && classifyMxRecords(asMxRecords(mxQuery.records)).hasMx;
|
|
368
385
|
if (dnsPolicy.records.length === 0) {
|
|
386
|
+
const { hasMx, detail } = mailPolicyApplicability(mxQuery);
|
|
369
387
|
return check('mta_sts', 'MTA-STS', hasMx ? 'warning' : 'info', 'No MTA-STS policy advertised', {
|
|
388
|
+
detail,
|
|
370
389
|
remediation: hasMx ? 'Publish _mta-sts and serve a valid policy over HTTPS to require authenticated TLS for inbound mail.' : '',
|
|
371
390
|
});
|
|
372
391
|
}
|
|
@@ -436,9 +455,9 @@ function tlsRptCheck(query, mxQuery) {
|
|
|
436
455
|
return queryFailureCheck('tls_rpt', 'TLS-RPT', query.error);
|
|
437
456
|
const parsed = parseTlsRptRecords(query.records);
|
|
438
457
|
if (parsed.records.length === 0) {
|
|
439
|
-
const hasMx
|
|
458
|
+
const { hasMx, detail } = mailPolicyApplicability(mxQuery);
|
|
440
459
|
return check('tls_rpt', 'TLS-RPT', hasMx ? 'warning' : 'info', 'No SMTP TLS reporting policy', {
|
|
441
|
-
detail
|
|
460
|
+
detail,
|
|
442
461
|
remediation: hasMx ? 'Publish a v=TLSRPTv1 record at _smtp._tls with a monitored rua destination.' : '',
|
|
443
462
|
});
|
|
444
463
|
}
|
|
@@ -13,7 +13,7 @@ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
|
|
|
13
13
|
'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
|
|
14
14
|
'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
|
|
15
15
|
'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
|
|
16
|
-
'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
|
|
16
|
+
'phishingLanguageMatch', 'hasExternalFormAction', 'hasExternalPasswordForm', 'externalAssetHosts',
|
|
17
17
|
'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
|
|
18
18
|
'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
|
|
19
19
|
'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
|
|
@@ -105,6 +105,7 @@ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
|
105
105
|
// Credential, structured identity, role, behaviour, technology, and posture
|
|
106
106
|
// projections. Only fixed categories/counts and bounded descriptions enter.
|
|
107
107
|
'credentialSurfaceVersion', 'methods', 'actions', 'missing', 'get', 'post',
|
|
108
|
+
'formAttribution', 'unassociatedInputs', 'index', 'destinations', 'relationship', 'origin',
|
|
108
109
|
'dialog', 'other', 'sameOrigin', 'external', 'cleartext', 'unclassified',
|
|
109
110
|
'inputs', 'classifiedCount', 'categories', 'password', 'email', 'username',
|
|
110
111
|
'one_time_code', 'payment', 'inputsObserved', 'classifiedInputs',
|
|
@@ -322,6 +323,8 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legac
|
|
|
322
323
|
return value;
|
|
323
324
|
if (typeof value === 'string') {
|
|
324
325
|
const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
|
|
326
|
+
if (path.join('.') === 'credentialSurfaceProfile.formAttribution.forms.destinations.origin')
|
|
327
|
+
return portableOrigin(value);
|
|
325
328
|
if (PORTABLE_URL_KEYS.has(normalizedParent))
|
|
326
329
|
return portableUri(value, legacyUris);
|
|
327
330
|
if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
|
|
@@ -344,7 +347,7 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legac
|
|
|
344
347
|
const boundedCount = Number.isSafeInteger(item)
|
|
345
348
|
&& Number(item) >= 0
|
|
346
349
|
&& Number(item) <= LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS;
|
|
347
|
-
const credentialCategoryCount =
|
|
350
|
+
const credentialCategoryCount = ['credentialSurfaceProfile.inputs.categories', 'credentialSurfaceProfile.formAttribution.forms.categories'].includes(parentPath)
|
|
348
351
|
&& boundedCount;
|
|
349
352
|
const sanClassCount = parentPath === 'tls.certificate.subjectAltNames.classes'
|
|
350
353
|
&& LOOKUP_AVAILABILITY_TLS_SAN_CLASS_KEYS.has(key)
|
package/lib/evidence-report.mjs
CHANGED
|
@@ -267,6 +267,7 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
267
267
|
reportField('Redirects', http.redirectCount),
|
|
268
268
|
reportField('Page title', availability.pageTitle),
|
|
269
269
|
reportField('Password field observed', yesNoUnknown(availability.hasPasswordField)),
|
|
270
|
+
reportField('Password form declares external destination', yesNoUnknown(availability.hasExternalPasswordForm)),
|
|
270
271
|
reportField('Authoritative nameserver evidence', listText(availability.nameservers)),
|
|
271
272
|
reportField('Observed edge, CDN, reverse proxy or WAF', reportTechnologyNames(technologyFindings, 'observed_edge')),
|
|
272
273
|
reportField('Application-platform indicators', reportTechnologyNames(technologyFindings, 'application_platform')),
|
package/lib/favicon.mjs
CHANGED
|
@@ -146,13 +146,18 @@ function isFaviconImage(bytes) {
|
|
|
146
146
|
}
|
|
147
147
|
// Returns the favicon bytes for one candidate URL (a real fetch, or an inline
|
|
148
148
|
// data: decode), or null if it can't be retrieved/used.
|
|
149
|
-
async function fetchFaviconBytes(url, headers, fetcher, timeoutMs) {
|
|
149
|
+
async function fetchFaviconBytes(url, headers, fetcher, timeoutMs, signal) {
|
|
150
|
+
signal?.throwIfAborted();
|
|
150
151
|
if (/^data:/i.test(url))
|
|
151
152
|
return decodeDataUri(url);
|
|
152
153
|
const controller = new AbortController();
|
|
153
154
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
154
155
|
try {
|
|
155
|
-
const res = await fetcher(url, { signal: controller.signal, headers });
|
|
156
|
+
const res = await fetcher(url, { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, headers });
|
|
157
|
+
if (signal?.aborted) {
|
|
158
|
+
await res.body?.cancel().catch(() => { });
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
}
|
|
156
161
|
if (!res.ok) {
|
|
157
162
|
// Not reading this body - release it explicitly instead of leaving an
|
|
158
163
|
// unconsumed stream (and the connection it's tied to) open until
|
|
@@ -165,10 +170,12 @@ async function fetchFaviconBytes(url, headers, fetcher, timeoutMs) {
|
|
|
165
170
|
// headers immediately and then trickle or stall the body forever, hanging
|
|
166
171
|
// this worker with no deadline once disarmed.
|
|
167
172
|
const { bytes, truncated } = await readBytesCapped(res, MAX_FAVICON_BYTES);
|
|
173
|
+
signal?.throwIfAborted();
|
|
168
174
|
// A truncated or empty file can't be hashed meaningfully.
|
|
169
175
|
return truncated || bytes.length === 0 ? null : bytes;
|
|
170
176
|
}
|
|
171
177
|
catch {
|
|
178
|
+
signal?.throwIfAborted();
|
|
172
179
|
return null;
|
|
173
180
|
}
|
|
174
181
|
finally {
|
|
@@ -196,7 +203,8 @@ function buildFaviconCandidates(domain, html = '', options = {}) {
|
|
|
196
203
|
add(`http://${domain}/favicon.svg`);
|
|
197
204
|
return candidates.slice(0, MAX_FAVICON_CANDIDATES);
|
|
198
205
|
}
|
|
199
|
-
async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetcher = safeFetch, timeoutMs = FAVICON_FETCH_TIMEOUT_MS } = {}) {
|
|
206
|
+
async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetcher = safeFetch, timeoutMs = FAVICON_FETCH_TIMEOUT_MS, signal } = {}) {
|
|
207
|
+
signal?.throwIfAborted();
|
|
200
208
|
const headers = whoisleuthRequestHeaders();
|
|
201
209
|
const candidates = buildFaviconCandidates(domain, html, {
|
|
202
210
|
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
@@ -207,7 +215,8 @@ async function fetchFaviconHash(domain, { html = '', baseUrl, htmlAnalysis, fetc
|
|
|
207
215
|
: FAVICON_FETCH_TIMEOUT_MS;
|
|
208
216
|
for (const url of candidates) {
|
|
209
217
|
// eslint-disable-next-line no-await-in-loop
|
|
210
|
-
const bytes = await fetchFaviconBytes(url, headers, fetcher, requestTimeoutMs);
|
|
218
|
+
const bytes = await fetchFaviconBytes(url, headers, fetcher, requestTimeoutMs, signal);
|
|
219
|
+
signal?.throwIfAborted();
|
|
211
220
|
if (bytes && isFaviconImage(bytes)) {
|
|
212
221
|
return {
|
|
213
222
|
hash: crypto.createHash('sha256').update(bytes).digest('hex'),
|
package/lib/html-signals.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { isIP } from 'node:net';
|
|
7
7
|
import { domainToASCII } from 'node:url';
|
|
8
8
|
import { analyzeCredentialSurfaceProfile } from "./credential-surface-profile.mjs";
|
|
9
|
+
import { attributeCredentialForms } from "./credential-form-attribution.mjs";
|
|
10
|
+
import { externalPasswordFormObservation } from "../packages/evidence/credential-form-attribution.mjs";
|
|
9
11
|
import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
|
|
10
12
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
11
13
|
import { createPageFingerprints } from "./page-fingerprints.mjs";
|
|
@@ -719,10 +721,12 @@ async function extractHtmlSignals(html, domain, options = {}) {
|
|
|
719
721
|
const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis);
|
|
720
722
|
const domainSaleSignal = options.sourceTruncated !== true && domainSaleLandingPage(htmlAnalysis, domain)
|
|
721
723
|
? 'explicit domain-sale landing-page content' : null;
|
|
724
|
+
const formAttribution = attributeCredentialForms(htmlAnalysis, documentUrl, options.sourceTruncated);
|
|
722
725
|
return {
|
|
723
726
|
domainSaleSignal,
|
|
724
727
|
pageTitle: htmlAnalysis.title,
|
|
725
728
|
hasPasswordField: htmlAnalysis.forms.categories.password > 0,
|
|
729
|
+
hasExternalPasswordForm: externalPasswordFormObservation(formAttribution),
|
|
726
730
|
phishingLanguageMatch: pageLanguageSignal?.label ?? null,
|
|
727
731
|
hasExternalFormAction: pageIdentity
|
|
728
732
|
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
@@ -732,6 +736,8 @@ async function extractHtmlSignals(html, domain, options = {}) {
|
|
|
732
736
|
pageIdentity: pageIdentityOutput,
|
|
733
737
|
credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
|
|
734
738
|
htmlAnalysis,
|
|
739
|
+
formAttribution,
|
|
740
|
+
baseUrl: documentUrl,
|
|
735
741
|
observedAt: options.observedAt,
|
|
736
742
|
sourceTruncated: options.sourceTruncated,
|
|
737
743
|
}) : null,
|
|
@@ -8,6 +8,7 @@ import { MAX_LOOKUP_TLS_ALT_NAMES, MAX_LOOKUP_TLS_CERTIFICATE_POLICIES, MAX_LOOK
|
|
|
8
8
|
import { isJsonObject, normalizedDomain, validBoundedString, validHttpProvenanceUrl, validObservationDiagnostics, validOptionalNullableText, validStringArray, validUint, } from "./lookup-contract-primitives.mjs";
|
|
9
9
|
import { TECHNOLOGY_EVIDENCE_ROLE_ORDER } from "./technology-evidence-role.mjs";
|
|
10
10
|
import { MAX_HOMEPAGE_BYTES } from "./outbound-request-bounds.mjs";
|
|
11
|
+
import { CREDENTIAL_CATEGORY_NAMES, MAX_CREDENTIAL_FORMS, MAX_CREDENTIAL_INPUTS, externalPasswordFormObservation, validCredentialFormAttribution } from "../packages/evidence/credential-form-attribution.mjs";
|
|
11
12
|
export const TECHNOLOGY_PROFILE_VERSION = 12;
|
|
12
13
|
export const SUPPORTED_TECHNOLOGY_PROFILE_VERSIONS = Object.freeze([10, 11, TECHNOLOGY_PROFILE_VERSION]);
|
|
13
14
|
export const MAX_TECHNOLOGY_FINDINGS = 24;
|
|
@@ -17,7 +18,7 @@ export const BROWSER_LIBRARY_PROFILE_VERSION = 2;
|
|
|
17
18
|
export const MAX_LIBRARY_FINDINGS = 16;
|
|
18
19
|
export const WEBSITE_SECURITY_POSTURE_VERSION = 3;
|
|
19
20
|
export const MAX_SECURITY_POSTURE_FINDINGS = 32;
|
|
20
|
-
export const CREDENTIAL_SURFACE_PROFILE_VERSION =
|
|
21
|
+
export const CREDENTIAL_SURFACE_PROFILE_VERSION = 2;
|
|
21
22
|
export const STRUCTURED_DATA_IDENTITY_VERSION = 1;
|
|
22
23
|
export const MAX_STRUCTURED_DATA_ENTITIES = 16;
|
|
23
24
|
export const MAX_STRUCTURED_DATA_SAME_AS_HOSTS = 12;
|
|
@@ -66,7 +67,7 @@ const SIMHASH64_RE = /^[a-f0-9]{16}$/u;
|
|
|
66
67
|
const OID_RE = /^\d{1,10}(?:\.\d{1,10}){1,31}$/u;
|
|
67
68
|
const CREDENTIAL_METHOD_KEYS = Object.freeze(['missing', 'get', 'post', 'dialog', 'other']);
|
|
68
69
|
const CREDENTIAL_ACTION_KEYS = Object.freeze(['sameOrigin', 'external', 'missing', 'cleartext', 'unclassified']);
|
|
69
|
-
const CREDENTIAL_CATEGORY_KEYS =
|
|
70
|
+
const CREDENTIAL_CATEGORY_KEYS = CREDENTIAL_CATEGORY_NAMES;
|
|
70
71
|
const TLS_NAME_KEYS = Object.freeze([
|
|
71
72
|
'commonNames', 'organizations', 'organizationalUnits', 'countries', 'localities', 'states',
|
|
72
73
|
]);
|
|
@@ -683,28 +684,37 @@ function securityPostureContractState(value) {
|
|
|
683
684
|
return 'supported';
|
|
684
685
|
}
|
|
685
686
|
function credentialSurfaceContractState(value) {
|
|
686
|
-
const versionState = childVersionState(value, 'credentialSurfaceVersion', [CREDENTIAL_SURFACE_PROFILE_VERSION]);
|
|
687
|
+
const versionState = childVersionState(value, 'credentialSurfaceVersion', [1, CREDENTIAL_SURFACE_PROFILE_VERSION]);
|
|
687
688
|
if (versionState !== 'supported')
|
|
688
689
|
return versionState;
|
|
689
690
|
const profile = value;
|
|
690
691
|
const forms = profile.forms;
|
|
691
692
|
const inputs = profile.inputs;
|
|
692
|
-
if (!hasExactKeys(profile, [...OBSERVATION_FIELDS, 'credentialSurfaceVersion', 'forms', 'inputs'])
|
|
693
|
+
if (!hasExactKeys(profile, [...OBSERVATION_FIELDS, 'credentialSurfaceVersion', 'forms', 'inputs', ...(profile.credentialSurfaceVersion === 2 ? ['formAttribution'] : [])])
|
|
693
694
|
|| !validProfileObservation(profile, new Set(['success', 'partial']), new Set(['html']))
|
|
694
695
|
|| !isJsonObject(forms)
|
|
695
696
|
|| !hasExactKeys(forms, ['count', 'methods', 'actions'])
|
|
696
|
-
|| !validUint(forms.count,
|
|
697
|
-
|| !validCredentialCountRecord(forms.methods, CREDENTIAL_METHOD_KEYS,
|
|
698
|
-
|| !validCredentialCountRecord(forms.actions, CREDENTIAL_ACTION_KEYS,
|
|
697
|
+
|| !validUint(forms.count, MAX_CREDENTIAL_FORMS)
|
|
698
|
+
|| !validCredentialCountRecord(forms.methods, CREDENTIAL_METHOD_KEYS, MAX_CREDENTIAL_FORMS)
|
|
699
|
+
|| !validCredentialCountRecord(forms.actions, CREDENTIAL_ACTION_KEYS, MAX_CREDENTIAL_FORMS)
|
|
699
700
|
|| CREDENTIAL_METHOD_KEYS.reduce((sum, key) => sum + Number(forms.methods[key]), 0) !== forms.count
|
|
700
701
|
|| CREDENTIAL_ACTION_KEYS.reduce((sum, key) => sum + Number(forms.actions[key]), 0) !== forms.count
|
|
701
702
|
|| !isJsonObject(inputs)
|
|
702
703
|
|| !hasExactKeys(inputs, ['count', 'classifiedCount', 'categories'])
|
|
703
|
-
|| !validUint(inputs.count,
|
|
704
|
-
|| !validUint(inputs.classifiedCount,
|
|
704
|
+
|| !validUint(inputs.count, MAX_CREDENTIAL_INPUTS)
|
|
705
|
+
|| !validUint(inputs.classifiedCount, MAX_CREDENTIAL_INPUTS)
|
|
705
706
|
|| Number(inputs.classifiedCount) > Number(inputs.count)
|
|
706
|
-
|| !validCredentialCountRecord(inputs.categories, CREDENTIAL_CATEGORY_KEYS,
|
|
707
|
+
|| !validCredentialCountRecord(inputs.categories, CREDENTIAL_CATEGORY_KEYS, MAX_CREDENTIAL_INPUTS))
|
|
707
708
|
return 'invalid';
|
|
709
|
+
if (profile.credentialSurfaceVersion === 2 && (!validCredentialFormAttribution(profile.formAttribution)
|
|
710
|
+
|| profile.formAttribution.forms.length !== forms.count
|
|
711
|
+
|| (profile.complete === true && !profile.formAttribution.complete)))
|
|
712
|
+
return 'invalid';
|
|
713
|
+
if (profile.credentialSurfaceVersion === 2 && validCredentialFormAttribution(profile.formAttribution)) {
|
|
714
|
+
const attribution = profile.formAttribution;
|
|
715
|
+
if (CREDENTIAL_CATEGORY_KEYS.some(key => attribution.forms.reduce((sum, form) => sum + form.categories[key], 0) > Number(inputs.categories[key])))
|
|
716
|
+
return 'invalid';
|
|
717
|
+
}
|
|
708
718
|
return 'supported';
|
|
709
719
|
}
|
|
710
720
|
function structuredDataContractState(value) {
|
|
@@ -819,6 +829,13 @@ function sanitizeLookupChildProfiles(value) {
|
|
|
819
829
|
replaceChild('tls', 'TLS profile', 'tls', tlsProfileContractState);
|
|
820
830
|
replaceChild('securityPosture', 'Security-posture profile', 'derived', securityPostureContractState);
|
|
821
831
|
replaceChild('credentialSurfaceProfile', 'Credential-surface profile', 'html', credentialSurfaceContractState);
|
|
832
|
+
if (availability.hasExternalPasswordForm !== undefined && availability.credentialSurfaceProfile != null) {
|
|
833
|
+
const credential = availability.credentialSurfaceProfile;
|
|
834
|
+
if (!isJsonObject(credential) || credential.credentialSurfaceVersion !== 2
|
|
835
|
+
|| availability.hasExternalPasswordForm !== externalPasswordFormObservation(credential.formAttribution)) {
|
|
836
|
+
mutableAvailability().hasExternalPasswordForm = null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
822
839
|
replaceChild('structuredDataIdentity', 'Structured-data identity profile', 'html', structuredDataContractState);
|
|
823
840
|
replaceChild('pageRoleProfile', 'Page-role profile', 'derived', pageRoleContractState);
|
|
824
841
|
replaceChild('clientBehaviorProfile', 'Client-behaviour profile', 'derived', clientBehaviorContractState);
|
|
@@ -864,4 +881,4 @@ function sanitizeLookupChildProfiles(value) {
|
|
|
864
881
|
}
|
|
865
882
|
return output;
|
|
866
883
|
}
|
|
867
|
-
export { sanitizeLookupChildProfiles, technologyProfileContractState, validSecurityPostureFinding, validTlsChainCertificate, validTlsFinding, };
|
|
884
|
+
export { sanitizeLookupChildProfiles, credentialSurfaceContractState, technologyProfileContractState, validSecurityPostureFinding, validTlsChainCertificate, validTlsFinding, };
|
|
@@ -646,6 +646,7 @@ function validAvailabilityScalars(value) {
|
|
|
646
646
|
&& (value.hasNullMx === undefined || value.hasNullMx === null || typeof value.hasNullMx === 'boolean')
|
|
647
647
|
&& (value.hasSpf === undefined || value.hasSpf === null || typeof value.hasSpf === 'boolean')
|
|
648
648
|
&& (value.hasDmarc === undefined || value.hasDmarc === null || typeof value.hasDmarc === 'boolean')
|
|
649
|
+
&& (value.hasExternalPasswordForm === undefined || value.hasExternalPasswordForm === null || typeof value.hasExternalPasswordForm === 'boolean')
|
|
649
650
|
&& validOptionalStringArray(value.mxHosts, MAX_LOOKUP_DNS_RECORDS_PER_TYPE, 253);
|
|
650
651
|
}
|
|
651
652
|
function validLookupDomainIdentity(value) {
|
package/lib/operation-budget.mjs
CHANGED
|
@@ -34,6 +34,7 @@ const OPERATION_FEATURES = Object.freeze({
|
|
|
34
34
|
AVAILABILITY_FAST: 'availability_fast',
|
|
35
35
|
AVAILABILITY_DEEP: 'availability_deep',
|
|
36
36
|
CERTIFICATE_TRANSPARENCY: 'certificate_transparency',
|
|
37
|
+
DOMAIN_FEED_SEARCH: 'domain_feed_search',
|
|
37
38
|
DOMAIN_POSTURE: 'domain_posture',
|
|
38
39
|
});
|
|
39
40
|
const OPERATION_FEATURE_CLASSES = Object.freeze({
|
|
@@ -47,6 +48,7 @@ const OPERATION_FEATURE_CLASSES = Object.freeze({
|
|
|
47
48
|
[OPERATION_FEATURES.AVAILABILITY_FAST]: OPERATION_CLASSES.REGISTRY_LIGHT,
|
|
48
49
|
[OPERATION_FEATURES.AVAILABILITY_DEEP]: OPERATION_CLASSES.REGISTRY_DEEP,
|
|
49
50
|
[OPERATION_FEATURES.CERTIFICATE_TRANSPARENCY]: OPERATION_CLASSES.CERTIFICATE_SEARCH,
|
|
51
|
+
[OPERATION_FEATURES.DOMAIN_FEED_SEARCH]: OPERATION_CLASSES.CERTIFICATE_SEARCH,
|
|
50
52
|
[OPERATION_FEATURES.DOMAIN_POSTURE]: OPERATION_CLASSES.POSTURE_AUDIT,
|
|
51
53
|
});
|
|
52
54
|
// The browser currently runs at most 12 fast Bulk workers, 4 deep Bulk
|
|
@@ -87,6 +89,8 @@ function operationFeatureFor(feature, { fast = false, compact = false } = {}) {
|
|
|
87
89
|
return OPERATION_FEATURES.WHOIS;
|
|
88
90
|
if (feature === 'certificate_transparency')
|
|
89
91
|
return OPERATION_FEATURES.CERTIFICATE_TRANSPARENCY;
|
|
92
|
+
if (feature === 'domain_feed_search')
|
|
93
|
+
return OPERATION_FEATURES.DOMAIN_FEED_SEARCH;
|
|
90
94
|
if (feature === 'domain_posture')
|
|
91
95
|
return OPERATION_FEATURES.DOMAIN_POSTURE;
|
|
92
96
|
return null;
|
|
@@ -526,6 +526,8 @@ function defaultTcpExchange(query, resolver, options) {
|
|
|
526
526
|
}
|
|
527
527
|
});
|
|
528
528
|
socket.once('connect', () => {
|
|
529
|
+
if (settled)
|
|
530
|
+
return;
|
|
529
531
|
const length = Buffer.alloc(2);
|
|
530
532
|
length.writeUInt16BE(query.length, 0);
|
|
531
533
|
socket.write(Buffer.concat([length, query]));
|
|
@@ -534,6 +536,7 @@ function defaultTcpExchange(query, resolver, options) {
|
|
|
534
536
|
}
|
|
535
537
|
function defaultDnsExchange(query, resolver, options) {
|
|
536
538
|
return new Promise((resolve, reject) => {
|
|
539
|
+
options.signal?.throwIfAborted();
|
|
537
540
|
const socket = createSocket(resolver.family === 6 ? 'udp6' : 'udp4');
|
|
538
541
|
let settled = false;
|
|
539
542
|
const timer = setTimeout(() => {
|
|
@@ -544,6 +547,7 @@ function defaultDnsExchange(query, resolver, options) {
|
|
|
544
547
|
return;
|
|
545
548
|
settled = true;
|
|
546
549
|
clearTimeout(timer);
|
|
550
|
+
options.signal?.removeEventListener('abort', cancelled);
|
|
547
551
|
try {
|
|
548
552
|
socket.close();
|
|
549
553
|
}
|
|
@@ -555,6 +559,12 @@ function defaultDnsExchange(query, resolver, options) {
|
|
|
555
559
|
else
|
|
556
560
|
resolve(response);
|
|
557
561
|
};
|
|
562
|
+
const cancelled = () => finish(options.signal?.reason ?? new Error('DNS UDP query cancelled'));
|
|
563
|
+
options.signal?.addEventListener('abort', cancelled, { once: true });
|
|
564
|
+
if (options.signal?.aborted) {
|
|
565
|
+
cancelled();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
558
568
|
socket.once('error', (error) => finish(error));
|
|
559
569
|
socket.once('message', (message) => {
|
|
560
570
|
if (message.length > MAX_DNS_MESSAGE_BYTES) {
|
|
@@ -565,6 +575,8 @@ function defaultDnsExchange(query, resolver, options) {
|
|
|
565
575
|
}
|
|
566
576
|
});
|
|
567
577
|
socket.connect(resolver.port, resolver.address, () => {
|
|
578
|
+
if (settled)
|
|
579
|
+
return;
|
|
568
580
|
socket.send(query, (error) => {
|
|
569
581
|
if (error)
|
|
570
582
|
finish(error);
|
|
@@ -592,6 +604,7 @@ function missingResponseError(rcode) {
|
|
|
592
604
|
return new ServiceBindingDnsError(`DNS resolver returned ${names[rcode] || `rcode ${rcode}`}`, 'EDNSFAIL');
|
|
593
605
|
}
|
|
594
606
|
async function resolveServiceBindingRecords(nameValue, type, options = {}) {
|
|
607
|
+
options.signal?.throwIfAborted();
|
|
595
608
|
const name = normalizeQueryName(nameValue);
|
|
596
609
|
const transactionId = options.transactionId ?? randomInt(0x1_0000);
|
|
597
610
|
const query = buildServiceBindingDnsQuery(name, type, transactionId);
|
|
@@ -608,17 +621,20 @@ async function resolveServiceBindingRecords(nameValue, type, options = {}) {
|
|
|
608
621
|
const deadline = Date.now() + timeoutMs;
|
|
609
622
|
let lastError = null;
|
|
610
623
|
for (const resolver of resolvers) {
|
|
624
|
+
options.signal?.throwIfAborted();
|
|
611
625
|
const udpTimeoutMs = deadline - Date.now();
|
|
612
626
|
if (udpTimeoutMs <= 0)
|
|
613
627
|
break;
|
|
614
628
|
try {
|
|
615
|
-
let response = await exchange(query, resolver, { timeoutMs: udpTimeoutMs });
|
|
629
|
+
let response = await exchange(query, resolver, { timeoutMs: udpTimeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
|
|
630
|
+
options.signal?.throwIfAborted();
|
|
616
631
|
if (isTruncatedDnsResponse(response, transactionId)) {
|
|
617
632
|
const tcpTimeoutMs = deadline - Date.now();
|
|
618
633
|
if (tcpTimeoutMs <= 0) {
|
|
619
634
|
throw new ServiceBindingDnsError('DNS service-binding query timed out', 'ETIMEOUT');
|
|
620
635
|
}
|
|
621
|
-
response = await (options.tcpExchange || defaultTcpExchange)(query, resolver, { timeoutMs: tcpTimeoutMs });
|
|
636
|
+
response = await (options.tcpExchange || defaultTcpExchange)(query, resolver, { timeoutMs: tcpTimeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
|
|
637
|
+
options.signal?.throwIfAborted();
|
|
622
638
|
}
|
|
623
639
|
const parsed = parseServiceBindingDnsResponse(response, { transactionId, name, type });
|
|
624
640
|
if (parsed.truncatedResponse)
|
|
@@ -631,6 +647,7 @@ async function resolveServiceBindingRecords(nameValue, type, options = {}) {
|
|
|
631
647
|
return { records: parsed.records, truncated: parsed.truncated };
|
|
632
648
|
}
|
|
633
649
|
catch (error) {
|
|
650
|
+
options.signal?.throwIfAborted();
|
|
634
651
|
lastError = error;
|
|
635
652
|
const code = error && typeof error === 'object' ? String(error.code || '') : '';
|
|
636
653
|
if (code === 'ENODATA' || code === 'ENOTFOUND')
|