@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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 +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- 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/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -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/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- 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-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- 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-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -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 +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- 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-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- 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/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -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 +40 -21
- 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/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- 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 +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -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 +30 -30
|
@@ -99,15 +99,19 @@ function withTimeout(promise, timeoutMs) {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
async function defaultAuthorityQuery(input) {
|
|
102
|
+
input.signal?.throwIfAborted();
|
|
102
103
|
const resolver = new dns.Resolver({
|
|
103
104
|
timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
|
|
104
105
|
tries: 1,
|
|
105
106
|
});
|
|
106
107
|
resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
|
|
108
|
+
const cancelled = () => resolver.cancel();
|
|
109
|
+
input.signal?.addEventListener('abort', cancelled, { once: true });
|
|
107
110
|
const [nameservers, soa] = await Promise.allSettled([
|
|
108
111
|
resolver.resolveNs(input.domain),
|
|
109
112
|
resolver.resolveSoa(input.domain),
|
|
110
|
-
]);
|
|
113
|
+
]).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
|
|
114
|
+
input.signal?.throwIfAborted();
|
|
111
115
|
const reason = nameservers.status === 'rejected'
|
|
112
116
|
? nameservers.reason
|
|
113
117
|
: soa.status === 'rejected'
|
|
@@ -188,18 +192,21 @@ function normaliseAuthorityValues(type, value) {
|
|
|
188
192
|
return normaliseAuthorityValueSet(type, value).values;
|
|
189
193
|
}
|
|
190
194
|
async function defaultAuthorityRecordQuery(input) {
|
|
195
|
+
input.signal?.throwIfAborted();
|
|
191
196
|
const resolver = new dns.Resolver({
|
|
192
197
|
timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
|
|
193
198
|
tries: 1,
|
|
194
199
|
});
|
|
195
200
|
resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
|
|
201
|
+
const cancelled = () => resolver.cancel();
|
|
202
|
+
input.signal?.addEventListener('abort', cancelled, { once: true });
|
|
196
203
|
const queries = [
|
|
197
204
|
['A', resolver.resolve4(input.domain)],
|
|
198
205
|
['AAAA', resolver.resolve6(input.domain)],
|
|
199
206
|
['CAA', resolver.resolveCaa(input.domain)],
|
|
200
207
|
['MX', resolver.resolveMx(input.domain)],
|
|
201
208
|
];
|
|
202
|
-
|
|
209
|
+
const result = await Promise.all(queries.map(async ([type, promise]) => {
|
|
203
210
|
try {
|
|
204
211
|
const response = await withTimeout(promise, input.timeoutMs);
|
|
205
212
|
const normalised = normaliseAuthorityValueSet(type, response);
|
|
@@ -224,7 +231,9 @@ async function defaultAuthorityRecordQuery(input) {
|
|
|
224
231
|
discarded: 0,
|
|
225
232
|
};
|
|
226
233
|
}
|
|
227
|
-
}));
|
|
234
|
+
})).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
|
|
235
|
+
input.signal?.throwIfAborted();
|
|
236
|
+
return result;
|
|
228
237
|
}
|
|
229
238
|
function authorityRecordMatrix(authorities) {
|
|
230
239
|
const types = ['A', 'AAAA', 'CAA', 'MX'];
|
|
@@ -318,6 +327,7 @@ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks we
|
|
|
318
327
|
};
|
|
319
328
|
}
|
|
320
329
|
async function collectDnsDelegationHealth(domainValue, parentQueryValue, options = {}) {
|
|
330
|
+
options.signal?.throwIfAborted();
|
|
321
331
|
const domain = hostname(domainValue);
|
|
322
332
|
if (!domain)
|
|
323
333
|
return skippedDnsDelegationHealth('The domain was not eligible for authoritative delegation checks.');
|
|
@@ -338,7 +348,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
338
348
|
const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
|
|
339
349
|
const now = options.now || Date.now;
|
|
340
350
|
const started = now();
|
|
341
|
-
const
|
|
351
|
+
const pending = candidates.map(async (nameserver) => {
|
|
352
|
+
options.signal?.throwIfAborted();
|
|
342
353
|
const glue = registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses ?? [];
|
|
343
354
|
const resolved = glue.length
|
|
344
355
|
? glue
|
|
@@ -350,10 +361,11 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
350
361
|
: [])
|
|
351
362
|
.map(publicAddress)
|
|
352
363
|
.filter((address) => address !== null);
|
|
364
|
+
options.signal?.throwIfAborted();
|
|
353
365
|
const addresses = [...new Set(resolved)].slice(0, MAX_AUTHORITY_ADDRESSES);
|
|
354
366
|
const attempts = await Promise.all(addresses.map(async (address) => {
|
|
355
367
|
try {
|
|
356
|
-
const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs }), timeoutMs);
|
|
368
|
+
const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs);
|
|
357
369
|
const query = record(raw);
|
|
358
370
|
const code = boundedText(query.errorCode, 40);
|
|
359
371
|
const error = boundedText(query.error, MAX_ERROR_LENGTH);
|
|
@@ -384,6 +396,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
384
396
|
};
|
|
385
397
|
}
|
|
386
398
|
}));
|
|
399
|
+
options.signal?.throwIfAborted();
|
|
387
400
|
const successful = attempts.find((attempt) => attempt.state === 'success');
|
|
388
401
|
const partial = attempts.find((attempt) => attempt.state === 'partial');
|
|
389
402
|
const retained = successful ?? partial;
|
|
@@ -391,7 +404,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
391
404
|
const recordAddress = retained?.address ?? addresses[0];
|
|
392
405
|
if (queryAuthorityRecords && recordAddress) {
|
|
393
406
|
try {
|
|
394
|
-
recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs }), timeoutMs))
|
|
407
|
+
recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs))
|
|
395
408
|
.filter((item) => ['A', 'AAAA', 'CAA', 'MX'].includes(item.type))
|
|
396
409
|
.slice(0, 4)
|
|
397
410
|
.map((item) => {
|
|
@@ -428,6 +441,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
428
441
|
}));
|
|
429
442
|
}
|
|
430
443
|
}
|
|
444
|
+
options.signal?.throwIfAborted();
|
|
431
445
|
return {
|
|
432
446
|
nameserver,
|
|
433
447
|
addressSource: glue.length ? 'registry_glue' : 'recursive_address',
|
|
@@ -445,7 +459,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
445
459
|
recordSets,
|
|
446
460
|
attempts,
|
|
447
461
|
};
|
|
448
|
-
})
|
|
462
|
+
});
|
|
463
|
+
const authorities = await Promise.all(pending).finally(() => Promise.allSettled(pending));
|
|
464
|
+
options.signal?.throwIfAborted();
|
|
449
465
|
const successfulAuthorities = authorities.filter((authority) => authority.state === 'success');
|
|
450
466
|
const partialAuthorities = authorities.filter((authority) => authority.state === 'partial');
|
|
451
467
|
const lameAuthorities = authorities.filter((authority) => authority.state === 'lame');
|
|
@@ -5,6 +5,7 @@ import { requireDmarcDomain, walkDmarcTree } from "./dmarc-discovery.mjs";
|
|
|
5
5
|
import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
|
|
6
6
|
import { defaultTcpExchange } from "./service-binding-dns.mjs";
|
|
7
7
|
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
8
|
+
import { normalizeDmarcPolicyContext } from "../packages/evidence/dmarc-policy-context.mjs";
|
|
8
9
|
const MAX_PARENT_SERVERS = 2;
|
|
9
10
|
const MAX_NS = 16;
|
|
10
11
|
const QUERY_TIMEOUT_MS = 2200;
|
|
@@ -35,7 +36,19 @@ export async function reviewInheritedDmarc(domain, exact, resolve) {
|
|
|
35
36
|
? `Published policy: ${selected.parsed.policy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}.`
|
|
36
37
|
: `For an existing name: ${selected.parsed.subdomainPolicy}. For a nonexistent name: ${selected.parsed.nonexistentSubdomainPolicy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}. Name existence is not inferred from this review.`
|
|
37
38
|
: 'Invalid or unavailable records do not establish absence or a usable inherited policy.';
|
|
38
|
-
|
|
39
|
+
const effective = !incomplete ? selected : null;
|
|
40
|
+
return {
|
|
41
|
+
...check('dmarc_inheritance', 'Inherited DMARC policy', incomplete || !selected ? 'warning' : 'info', summary, `${detail} Discovery follows RFC 9989 with at most eight owners. This is publication evidence, not message authentication or a guarantee of receiver behaviour.`, rows.map(row => row.record)),
|
|
42
|
+
dmarcPolicyContext: normalizeDmarcPolicyContext({
|
|
43
|
+
version: 1, source: 'recursive_dns_txt', complete: !incomplete,
|
|
44
|
+
relation: effective ? effective.owner === domain ? 'exact' : 'inherited' : 'undetermined',
|
|
45
|
+
owner: effective?.owner ?? null, observedAt: effective?.observedAt ?? null,
|
|
46
|
+
policy: effective?.parsed.policy ?? null, subdomainPolicy: effective?.parsed.subdomainPolicy ?? null,
|
|
47
|
+
nonexistentPolicy: effective?.parsed.nonexistentSubdomainPolicy ?? null,
|
|
48
|
+
psd: effective?.psd ?? null, testMode: effective?.parsed.testMode ?? null,
|
|
49
|
+
discovery: rows.map(row => ({ owner: row.owner, state: row.state, observedAt: row.observedAt })),
|
|
50
|
+
}, domain),
|
|
51
|
+
};
|
|
39
52
|
}
|
|
40
53
|
function nameservers(value) {
|
|
41
54
|
if (!Array.isArray(value) || value.length > MAX_NS)
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -416,6 +416,7 @@ function observedPresence(result) {
|
|
|
416
416
|
return result.records.length > 0 ? true : completeQuery(result) ? false : null;
|
|
417
417
|
}
|
|
418
418
|
async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
419
|
+
options.signal?.throwIfAborted();
|
|
419
420
|
const normalizedDomain = normalizeHostname(domain);
|
|
420
421
|
const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
|
|
421
422
|
const now = options.now || Date.now;
|
|
@@ -455,9 +456,11 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
455
456
|
const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
|
|
456
457
|
const queries = [];
|
|
457
458
|
for (const [index, owner] of owners.entries()) {
|
|
459
|
+
options.signal?.throwIfAborted();
|
|
458
460
|
const result = index === 0 && options.directResult
|
|
459
461
|
? options.directResult
|
|
460
462
|
: await query(() => resolver(owner), normalizeCaa, timeoutMs);
|
|
463
|
+
options.signal?.throwIfAborted();
|
|
461
464
|
queries.push({
|
|
462
465
|
owner,
|
|
463
466
|
status: result.status,
|
|
@@ -582,6 +585,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
582
585
|
};
|
|
583
586
|
}
|
|
584
587
|
async function collectDnsIntelligence(domain, options = {}) {
|
|
588
|
+
options.signal?.throwIfAborted();
|
|
585
589
|
const registrationDomain = options.registrationDomain ?? domain;
|
|
586
590
|
if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
|
|
587
591
|
throw new TypeError('DNS registration context must match the hostname being observed.');
|
|
@@ -592,7 +596,10 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
592
596
|
const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
|
|
593
597
|
const now = options.now || Date.now;
|
|
594
598
|
const started = now();
|
|
595
|
-
const invoke = (name, fallback, value = domain) => () =>
|
|
599
|
+
const invoke = (name, fallback, value = domain) => () => {
|
|
600
|
+
options.signal?.throwIfAborted();
|
|
601
|
+
return (resolvers[name] || fallback)(value);
|
|
602
|
+
};
|
|
596
603
|
const aPromise = query(invoke('resolve4', dns.resolve4), (records) => normalizeAddresses(records, 4), timeoutMs);
|
|
597
604
|
const aaaaPromise = query(invoke('resolve6', dns.resolve6), (records) => normalizeAddresses(records, 6), timeoutMs);
|
|
598
605
|
const cnamePromise = query(invoke('resolveCname', dns.resolveCname), normalizeHostnames, timeoutMs);
|
|
@@ -607,6 +614,7 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
607
614
|
directResult,
|
|
608
615
|
timeoutMs,
|
|
609
616
|
now,
|
|
617
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
610
618
|
...(options.observedAt ? { observedAt: options.observedAt } : {}),
|
|
611
619
|
}))
|
|
612
620
|
: Promise.resolve(null);
|
|
@@ -614,7 +622,7 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
614
622
|
? query(invoke('resolveSoa', dns.resolveSoa), normalizeSoa, timeoutMs)
|
|
615
623
|
: Promise.resolve(null);
|
|
616
624
|
const httpsPromise = includeExtendedContext
|
|
617
|
-
? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
|
|
625
|
+
? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs, ...(options.signal ? { signal: options.signal } : {}) })), normalizeServiceBindings, timeoutMs)
|
|
618
626
|
: Promise.resolve(null);
|
|
619
627
|
const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
|
|
620
628
|
? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
|
|
@@ -627,10 +635,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
627
635
|
...(options.queryAuthority ? { queryAuthority: options.queryAuthority } : {}),
|
|
628
636
|
timeoutMs,
|
|
629
637
|
now,
|
|
638
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
630
639
|
...(options.observedAt ? { observedAt: options.observedAt } : {}),
|
|
631
640
|
}))
|
|
632
641
|
: Promise.resolve(null);
|
|
633
|
-
const
|
|
642
|
+
const pending = [
|
|
634
643
|
aPromise,
|
|
635
644
|
aaaaPromise,
|
|
636
645
|
cnamePromise,
|
|
@@ -643,7 +652,12 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
643
652
|
soaPromise,
|
|
644
653
|
httpsPromise,
|
|
645
654
|
delegationPromise,
|
|
646
|
-
]
|
|
655
|
+
];
|
|
656
|
+
// A rejected continuation must not release the lookup while sibling queries
|
|
657
|
+
// are still running. Their existing bounded resolver deadlines remain active.
|
|
658
|
+
const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all(pending)
|
|
659
|
+
.finally(() => Promise.allSettled(pending));
|
|
660
|
+
options.signal?.throwIfAborted();
|
|
647
661
|
const queries = {
|
|
648
662
|
a, aaaa, cname, ns, mx, spf, dmarc, caa,
|
|
649
663
|
...(soa ? { soa } : {}),
|
|
@@ -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-export.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { buildRegistryInsights } from "./registry-insights.mjs";
|
|
|
6
6
|
import { buildPortableGeneratorMetadata } from "../packages/analysis/portable-generator.mjs";
|
|
7
7
|
import { assertBoundedJsonStructure } from "../packages/analysis/bounded-json.mjs";
|
|
8
8
|
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
9
|
-
import { LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../packages/contracts/lookup-evidence.mjs";
|
|
9
|
+
import { LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, AUTHORITY_AWARE_LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../packages/contracts/lookup-evidence.mjs";
|
|
10
10
|
export * from "../packages/contracts/lookup-evidence.mjs";
|
|
11
11
|
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
12
12
|
import { validLookupObservationScope } from "../packages/evidence/lookup-target.mjs";
|
|
@@ -705,6 +705,7 @@ const LOOKUP_WHOIS_PORTABLE_SCALARS = Object.freeze({
|
|
|
705
705
|
failedHop: nullableText(253),
|
|
706
706
|
conflictingHop: nullableText(253),
|
|
707
707
|
});
|
|
708
|
+
const WHOIS_REGISTRATION_STATUS = enumeration(new Set(['registered', 'not_found', 'inconclusive']));
|
|
708
709
|
const LOOKUP_REGISTRY_INSIGHT_PORTABLE_KEYS = new Set([
|
|
709
710
|
'version', 'contactDisclosure', 'lifecycle', 'reconciliation', 'publications',
|
|
710
711
|
'rdapCapabilities',
|
|
@@ -815,11 +816,16 @@ export function projectLookupEvidenceRdapPublication(value) {
|
|
|
815
816
|
return output;
|
|
816
817
|
}
|
|
817
818
|
/** Positive portable projection for normalized WHOIS publication fields. */
|
|
818
|
-
export function projectLookupEvidenceWhoisPublication(value) {
|
|
819
|
+
export function projectLookupEvidenceWhoisPublication(value, schemaVersion = LOOKUP_EVIDENCE_SCHEMA_VERSION) {
|
|
819
820
|
const input = recordOrNull(value);
|
|
820
821
|
if (!input)
|
|
821
822
|
return null;
|
|
822
823
|
const output = projectedKnownScalarRecord(input, LOOKUP_WHOIS_PORTABLE_SCALARS) || {};
|
|
824
|
+
if (schemaVersion >= AUTHORITY_AWARE_LOOKUP_EVIDENCE_SCHEMA_VERSION && Object.hasOwn(input, 'registrationStatus')) {
|
|
825
|
+
const status = WHOIS_REGISTRATION_STATUS(input.registrationStatus);
|
|
826
|
+
if (status !== undefined)
|
|
827
|
+
output.registrationStatus = status;
|
|
828
|
+
}
|
|
823
829
|
if (Object.hasOwn(input, 'lifecycle')) {
|
|
824
830
|
output.lifecycle = projectedKnownScalarRecord(input.lifecycle, LOOKUP_RDAP_LIFECYCLE_SCALARS);
|
|
825
831
|
}
|
|
@@ -884,7 +890,7 @@ function projectedWhoisChainHop(value) {
|
|
|
884
890
|
};
|
|
885
891
|
}
|
|
886
892
|
/** Exact positive projection for one current-schema WHOIS source wrapper. */
|
|
887
|
-
export function projectLookupEvidenceWhoisSourcePublication(value) {
|
|
893
|
+
export function projectLookupEvidenceWhoisSourcePublication(value, schemaVersion = LOOKUP_EVIDENCE_SCHEMA_VERSION) {
|
|
888
894
|
const source = recordOrNull(value);
|
|
889
895
|
if (!source)
|
|
890
896
|
return null;
|
|
@@ -895,7 +901,7 @@ export function projectLookupEvidenceWhoisSourcePublication(value) {
|
|
|
895
901
|
error: boundedSourceError(source.error, 'WHOIS source reported an error.'),
|
|
896
902
|
};
|
|
897
903
|
const hasPublication = status === 'complete' || status === 'partial';
|
|
898
|
-
const parsed = hasPublication ? projectLookupEvidenceWhoisPublication(source.parsed) : null;
|
|
904
|
+
const parsed = hasPublication ? projectLookupEvidenceWhoisPublication(source.parsed, schemaVersion) : null;
|
|
899
905
|
if (status === 'complete' && parsed === null)
|
|
900
906
|
return null;
|
|
901
907
|
return {
|
package/lib/evidence-report.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registryAccessProfileLabel } from "./registry-access.mjs";
|
|
2
|
+
import { isSupportedRegistryInsightsVersion } from "../packages/contracts/registry-insights.mjs";
|
|
2
3
|
import { dnsAddressFamilyEvidence } from "../packages/evidence/dns-query-outcome.mjs";
|
|
3
4
|
import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../packages/analysis/portable-generator.mjs";
|
|
4
5
|
import { technologyEvidenceRoles, } from "./technology-evidence-role.mjs";
|
|
@@ -267,6 +268,7 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
267
268
|
reportField('Redirects', http.redirectCount),
|
|
268
269
|
reportField('Page title', availability.pageTitle),
|
|
269
270
|
reportField('Password field observed', yesNoUnknown(availability.hasPasswordField)),
|
|
271
|
+
reportField('Password form declares external destination', yesNoUnknown(availability.hasExternalPasswordForm)),
|
|
270
272
|
reportField('Authoritative nameserver evidence', listText(availability.nameservers)),
|
|
271
273
|
reportField('Observed edge, CDN, reverse proxy or WAF', reportTechnologyNames(technologyFindings, 'observed_edge')),
|
|
272
274
|
reportField('Application-platform indicators', reportTechnologyNames(technologyFindings, 'application_platform')),
|
|
@@ -385,7 +387,7 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
385
387
|
reportField('Standing limitations', listText(registrarStanding.limitations)),
|
|
386
388
|
reportField('Review steps', listText(registrarStanding.nextActions)),
|
|
387
389
|
] : [],
|
|
388
|
-
registryInterpretation: registryInsights.version
|
|
390
|
+
registryInterpretation: isSupportedRegistryInsightsVersion(registryInsights.version) ? [
|
|
389
391
|
reportField('Lifecycle', displayLabel(registryLifecycle.label)),
|
|
390
392
|
reportField('Raw lifecycle statuses', listText(registryLifecycle.rawStatuses)),
|
|
391
393
|
reportField('Client registration lock observed', yesNoUnknown(registryLocks.client)),
|
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'),
|