@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// injected so every traversal can be fixture-tested and the caller retains the
|
|
3
3
|
// existing DNS timeout and missing/error semantics.
|
|
4
4
|
import { parse as parseDomain } from 'tldts';
|
|
5
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
5
6
|
import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
|
|
6
7
|
const SPF_LOOKUP_LIMIT = 10;
|
|
7
8
|
const SPF_VOID_LOOKUP_LIMIT = 2;
|
|
@@ -166,9 +167,10 @@ function reportDestinationDomain(value) {
|
|
|
166
167
|
if (/[!,;]/u.test(uri))
|
|
167
168
|
return null;
|
|
168
169
|
const destination = parseReportingDestination(uri);
|
|
169
|
-
|
|
170
|
+
const domain = destination?.scheme === 'mailto' ? strictHostname(destination.host) : null;
|
|
171
|
+
return domain && isValidAsciiHostname(domain) ? domain : null;
|
|
170
172
|
}
|
|
171
|
-
async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt) {
|
|
173
|
+
async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt, resolveOrganisationalDomain) {
|
|
172
174
|
if (dmarcQuery.error)
|
|
173
175
|
return [];
|
|
174
176
|
const parsed = parseDmarcRecords(dmarcQuery.records);
|
|
@@ -192,7 +194,7 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
|
|
|
192
194
|
});
|
|
193
195
|
continue;
|
|
194
196
|
}
|
|
195
|
-
if (destinationDomain === ownerDomain
|
|
197
|
+
if (destinationDomain === ownerDomain) {
|
|
196
198
|
results.push({
|
|
197
199
|
destination: destinationDomain,
|
|
198
200
|
reportType: item.reportType,
|
|
@@ -202,6 +204,20 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
|
|
|
202
204
|
});
|
|
203
205
|
continue;
|
|
204
206
|
}
|
|
207
|
+
const ownerOrganisation = resolveOrganisationalDomain ? await resolveOrganisationalDomain(ownerDomain) : null;
|
|
208
|
+
const recipientOrganisation = ownerOrganisation && resolveOrganisationalDomain ? await resolveOrganisationalDomain(destinationDomain) : null;
|
|
209
|
+
if (ownerOrganisation && ownerOrganisation === recipientOrganisation) {
|
|
210
|
+
results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'self', error: null });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const externalConfirmed = Boolean(ownerOrganisation && recipientOrganisation);
|
|
214
|
+
// Descendant reporting previously made no authorisation request. Do not
|
|
215
|
+
// broaden default collection to resolve its unknown organisational scope.
|
|
216
|
+
if (!resolveOrganisationalDomain && destinationDomain.endsWith(`.${ownerDomain}`)) {
|
|
217
|
+
results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable',
|
|
218
|
+
error: 'The organisational boundary was not checked. Select the additional DNS review to determine whether external reporting authorisation is required.' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
205
221
|
const recordName = `${ownerDomain}._report._dmarc.${destinationDomain}`;
|
|
206
222
|
if (recordName.length > 253) {
|
|
207
223
|
results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable', error: 'The reporting authorisation query exceeds the DNS name limit.' });
|
|
@@ -223,8 +239,9 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
|
|
|
223
239
|
destination: destinationDomain,
|
|
224
240
|
reportType: item.reportType,
|
|
225
241
|
recordName,
|
|
226
|
-
state: query.error ? 'unavailable' : authorized ? 'authorized' : 'not_found',
|
|
227
|
-
error: query.error
|
|
242
|
+
state: query.error ? 'unavailable' : authorized ? 'authorized' : externalConfirmed ? 'not_found' : 'unavailable',
|
|
243
|
+
error: query.error ?? (!authorized && !externalConfirmed
|
|
244
|
+
? 'The organisational boundary is unknown; no authorisation requirement can be inferred from the missing record.' : null),
|
|
228
245
|
});
|
|
229
246
|
}
|
|
230
247
|
return results;
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { nonEmptyErrorMessage } from "./error-detail.mjs";
|
|
|
7
7
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
8
8
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
9
9
|
import { classifyMxRecords } from "./dns-mx.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { collectDnsInheritanceReview } from "./dns-inheritance-review.mjs";
|
|
11
11
|
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
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";
|
|
@@ -202,16 +202,18 @@ function dmarcCheck(query, authorizations = []) {
|
|
|
202
202
|
parsed.failureReporting ? 'Failure reporting is configured.' : 'No failure reporting destination is configured.',
|
|
203
203
|
...parsed.issues,
|
|
204
204
|
];
|
|
205
|
-
const
|
|
206
|
-
const unresolvedExternal = external.filter((authorization) => authorization.state !== 'authorized');
|
|
205
|
+
const unresolvedExternal = authorizations.filter((authorization) => !['self', 'authorized'].includes(authorization.state));
|
|
207
206
|
const intendedDestinations = parsed.aggregateDestinations.length + parsed.failureDestinations.length;
|
|
208
207
|
const omittedDestinations = Math.max(0, intendedDestinations - authorizations.length);
|
|
209
208
|
if (omittedDestinations) {
|
|
210
209
|
details.push(`Reporting authorisation coverage: ${authorizations.length} of ${intendedDestinations} destinations reviewed; ${omittedDestinations} not checked within the bounded collection. Omitted destinations are not assumed authorised.`);
|
|
211
210
|
}
|
|
212
|
-
if (
|
|
213
|
-
details.push(`${
|
|
214
|
-
+ `${unresolvedExternal.length}
|
|
211
|
+
if (authorizations.length > 0) {
|
|
212
|
+
details.push(`${authorizations.length} reporting destination${authorizations.length === 1 ? '' : 's'} reviewed; `
|
|
213
|
+
+ `${unresolvedExternal.length} need further evidence.`);
|
|
214
|
+
}
|
|
215
|
+
if (unresolvedExternal.some(item => item.state === 'unavailable')) {
|
|
216
|
+
details.push('An unknown organisational boundary or unavailable query does not establish a missing authorisation requirement.');
|
|
215
217
|
}
|
|
216
218
|
if (parsed.testMode) {
|
|
217
219
|
return check('dmarc', 'DMARC', 'warning', 'Policy is in test mode (t=y)', {
|
|
@@ -235,9 +237,7 @@ function dmarcCheck(query, authorizations = []) {
|
|
|
235
237
|
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; reporting authorisation is incomplete`, {
|
|
236
238
|
detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
|
|
237
239
|
records: parsed.records,
|
|
238
|
-
remediation:
|
|
239
|
-
? 'Review the unchecked destinations and confirm any required external reporting authorisation before relying on delivery.'
|
|
240
|
-
: 'Publish the required external reporting authorisation record or remove the unavailable destination.',
|
|
240
|
+
remediation: 'Review unresolved destinations and their organisational boundaries, then confirm any required external reporting authorisation before relying on delivery.',
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
if (!parsed.aggregateReporting) {
|
|
@@ -785,17 +785,18 @@ async function collectDomainPosture(domain, { dkimSelectors = [], retiredDkimSel
|
|
|
785
785
|
const enrichment = [
|
|
786
786
|
parsedMtaDns?.valid ? dependencies.fetchMtaStsPolicy(domain, signal) : Promise.resolve(null),
|
|
787
787
|
expandSpfPolicy(domain, spf, resolveEnrichmentTxt),
|
|
788
|
-
validateDmarcExternalReporting(domain, dmarc, resolveEnrichmentTxt),
|
|
789
788
|
includeInheritedDns === true
|
|
790
|
-
? (dependencies.
|
|
791
|
-
? dependencies.
|
|
789
|
+
? (dependencies.collectDnsInheritanceReview
|
|
790
|
+
? dependencies.collectDnsInheritanceReview(domain, dmarc, nameservers, signal ? { signal } : {})
|
|
792
791
|
: Promise.reject(new TypeError('The explicit DNS review collector is unavailable.')))
|
|
793
|
-
:
|
|
792
|
+
: validateDmarcExternalReporting(domain, dmarc, resolveEnrichmentTxt)
|
|
793
|
+
.then(dmarcAuthorizations => ({ checks: [], dmarcAuthorizations })),
|
|
794
794
|
];
|
|
795
795
|
// A rejected policy request must not release the operation's capacity while
|
|
796
796
|
// other started enrichment collectors are still settling.
|
|
797
|
-
const [mtaStsPolicy, spfExpansion,
|
|
797
|
+
const [mtaStsPolicy, spfExpansion, dnsReview] = await Promise.all(enrichment)
|
|
798
798
|
.finally(() => Promise.allSettled(enrichment));
|
|
799
|
+
const { dmarcAuthorizations, checks: inheritanceChecks } = dnsReview;
|
|
799
800
|
signal?.throwIfAborted();
|
|
800
801
|
const dnssec = !rdap
|
|
801
802
|
? { value: null, error: 'RDAP did not return a domain record.' }
|
|
@@ -884,7 +885,7 @@ async function checkDomainPosture(domain, options = {}, dependencies) {
|
|
|
884
885
|
now: () => new Date(),
|
|
885
886
|
setTimer: (callback, milliseconds) => setTimeout(callback, milliseconds),
|
|
886
887
|
clearTimer: handle => clearTimeout(handle),
|
|
887
|
-
|
|
888
|
+
collectDnsInheritanceReview,
|
|
888
889
|
});
|
|
889
890
|
}
|
|
890
891
|
finally {
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Registration authority, lifecycle dates and positive-only delegation evidence.
|
|
2
|
+
import { promises as dns } from 'node:dns';
|
|
3
|
+
import { abortable } from "./abort.mjs";
|
|
4
|
+
import { fetchRdapRecord } from "./rdap.mjs";
|
|
5
|
+
import { buildWhoisChain, parseWhoisChain } from "./whois.mjs";
|
|
6
|
+
import { featureDecision, networkFeaturePolicy } from "./feature-policy.mjs";
|
|
7
|
+
import { parseRegistryDate, registryDateIso } from "./registry-dates.mjs";
|
|
8
|
+
import { nonEmptyErrorMessage } from "./error-detail.mjs";
|
|
9
|
+
import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
|
|
10
|
+
import { recordOrEmpty as errorRecord } from "./json-record.mjs";
|
|
11
|
+
const DNS_DELEGATION_TIMEOUT_MS = 4000;
|
|
12
|
+
const MAX_DELEGATION_NAMESERVERS = 50;
|
|
13
|
+
const MISSING_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME', 'NXDOMAIN']);
|
|
14
|
+
function registryStatusToken(value) {
|
|
15
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/gu, '');
|
|
16
|
+
}
|
|
17
|
+
function rdapEventDate(events, action) {
|
|
18
|
+
const date = events.find((event) => event.action === action)?.date;
|
|
19
|
+
return typeof date === 'string' ? date : null;
|
|
20
|
+
}
|
|
21
|
+
function computeAgeDays(dateStr) {
|
|
22
|
+
const d = parseRegistryDate(dateStr);
|
|
23
|
+
return d ? Math.floor((Date.now() - d.getTime()) / 86400000) : null;
|
|
24
|
+
}
|
|
25
|
+
function computeDaysUntil(dateStr) {
|
|
26
|
+
const d = parseRegistryDate(dateStr);
|
|
27
|
+
return d ? Math.ceil((d.getTime() - Date.now()) / 86400000) : null;
|
|
28
|
+
}
|
|
29
|
+
const PRIVACY_MARKERS = [
|
|
30
|
+
/redacted for privacy/i,
|
|
31
|
+
/data protected/i,
|
|
32
|
+
/privacy\s*protect/i,
|
|
33
|
+
/whoisguard/i,
|
|
34
|
+
/domains by proxy/i,
|
|
35
|
+
/perfect privacy/i,
|
|
36
|
+
/contact privacy/i,
|
|
37
|
+
/private registration/i,
|
|
38
|
+
/identity protect/i,
|
|
39
|
+
/not disclosed/i,
|
|
40
|
+
/withheld for privacy/i,
|
|
41
|
+
];
|
|
42
|
+
// Explicit privacy/redaction markers establish a positive privacy signal.
|
|
43
|
+
// Usable public contact data establishes a negative one. A missing or blank
|
|
44
|
+
// contact can instead reflect registry policy, access tier, parser coverage,
|
|
45
|
+
// or ordinary omission, so it remains inconclusive rather than being promoted
|
|
46
|
+
// to an affirmative privacy claim.
|
|
47
|
+
function isPrivacyProtected(registrant) {
|
|
48
|
+
if (!registrant)
|
|
49
|
+
return null;
|
|
50
|
+
const blob = [registrant.name, registrant.org, registrant.email].filter(Boolean).join(' ');
|
|
51
|
+
if (!blob)
|
|
52
|
+
return null;
|
|
53
|
+
return PRIVACY_MARKERS.some((re) => re.test(blob));
|
|
54
|
+
}
|
|
55
|
+
// RDAP Lookup retains a bounded multi-value contact inventory, but Bulk and
|
|
56
|
+
// watchlist availability records deliberately keep the historical compact
|
|
57
|
+
// shape. This prevents repeated contact arrays and registry links from
|
|
58
|
+
// expanding browser-local stores or flowing into case evidence implicitly.
|
|
59
|
+
function compactContact(contact) {
|
|
60
|
+
if (!contact || typeof contact !== 'object' || Array.isArray(contact))
|
|
61
|
+
return null;
|
|
62
|
+
const value = contact;
|
|
63
|
+
const compact = {
|
|
64
|
+
handle: value.handle || null,
|
|
65
|
+
name: value.name || null,
|
|
66
|
+
org: value.org || null,
|
|
67
|
+
email: value.email || null,
|
|
68
|
+
phone: value.phone || null,
|
|
69
|
+
address: value.address || null,
|
|
70
|
+
};
|
|
71
|
+
return Object.values(compact).some(Boolean) ? compact : null;
|
|
72
|
+
}
|
|
73
|
+
// A number of ccTLD registries publish neither an RDAP bootstrap entry nor a
|
|
74
|
+
// generally reachable port-43 WHOIS service. A positive NS answer cannot
|
|
75
|
+
// replace registry registration data, but it does prove that the registrable
|
|
76
|
+
// domain has an active DNS delegation. Use that as a bounded, positive-only
|
|
77
|
+
// fallback: no answer is never interpreted as availability because registered
|
|
78
|
+
// domains can legitimately be undelegated.
|
|
79
|
+
async function checkDnsDelegation(domain, options = {}) {
|
|
80
|
+
options.signal?.throwIfAborted();
|
|
81
|
+
// Cancel only this lookup's c-ares requests, never the shared DNS resolver.
|
|
82
|
+
const ownedResolver = options.resolver ? null : new dns.Resolver();
|
|
83
|
+
const resolve = options.resolver || ((name) => ownedResolver.resolveNs(name));
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
86
|
+
const cancel = () => ownedResolver?.cancel();
|
|
87
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
88
|
+
const timer = setTimeout(() => controller.abort(new Error('DNS delegation lookup timed out')), DNS_DELEGATION_TIMEOUT_MS);
|
|
89
|
+
try {
|
|
90
|
+
const records = await abortable(() => resolve(domain), signal);
|
|
91
|
+
const validNameservers = [...new Set((Array.isArray(records) ? records : [])
|
|
92
|
+
.filter((value) => typeof value === 'string')
|
|
93
|
+
.map((value) => value.trim().replace(/\.+$/, '').toLowerCase())
|
|
94
|
+
.filter((value) => value.length > 0 && value.length <= 253)
|
|
95
|
+
.filter((value) => value.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))))]
|
|
96
|
+
.sort();
|
|
97
|
+
const normalized = validNameservers.slice(0, MAX_DELEGATION_NAMESERVERS);
|
|
98
|
+
return {
|
|
99
|
+
delegated: normalized.length > 0,
|
|
100
|
+
nameservers: normalized,
|
|
101
|
+
nameserversTruncated: validNameservers.length > MAX_DELEGATION_NAMESERVERS,
|
|
102
|
+
error: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
options.signal?.throwIfAborted();
|
|
107
|
+
const error = errorRecord(err);
|
|
108
|
+
if (typeof error.code === 'string' && MISSING_DNS_CODES.has(error.code)) {
|
|
109
|
+
return { delegated: false, nameservers: [], nameserversTruncated: false, error: null };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
delegated: false,
|
|
113
|
+
nameservers: [],
|
|
114
|
+
nameserversTruncated: false,
|
|
115
|
+
error: nonEmptyErrorMessage(err, String(err)).slice(0, 180),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
signal.removeEventListener('abort', cancel);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function registryPolicyDetail(domain, fast) {
|
|
124
|
+
const details = [];
|
|
125
|
+
const rdapAdmission = registryServiceAdmissionFor(domain, 'rdap');
|
|
126
|
+
if (rdapAdmission && !rdapAdmission.allowed) {
|
|
127
|
+
details.push('RDAP was not queried because the registry access policy does not permit collection.');
|
|
128
|
+
}
|
|
129
|
+
const whoisAdmission = fast ? null : registryServiceAdmissionFor(domain, 'whois');
|
|
130
|
+
if (whoisAdmission && !whoisAdmission.allowed) {
|
|
131
|
+
details.push(whoisAdmission.state === 'permission_required'
|
|
132
|
+
? 'WHOIS was not queried because registry permission or source authorisation is required.'
|
|
133
|
+
: 'WHOIS was not queried because IANA publishes no domain WHOIS service for this suffix.');
|
|
134
|
+
}
|
|
135
|
+
return details.length ? ` ${details.join(' ')}` : '';
|
|
136
|
+
}
|
|
137
|
+
// fast: true skips the WHOIS fallback (no TCP:43 chain) and the homepage
|
|
138
|
+
// fetch (no for-sale/parking detection) - just RDAP plus the signals
|
|
139
|
+
// derivable from it (age, expiry proximity, privacy). Meant for scanning
|
|
140
|
+
// large sourcing candidate lists quickly and gently on registry rate
|
|
141
|
+
// limits; anything it can't resolve (state "unknown") is meant to get a
|
|
142
|
+
// follow-up deep check (fast: false, the default) on the shortlist only.
|
|
143
|
+
async function resolveDomainRegistration(domain, options, featurePolicy) {
|
|
144
|
+
options.signal?.throwIfAborted();
|
|
145
|
+
const fast = options.fast === true;
|
|
146
|
+
const rdapEnabled = featureDecision('rdap', featurePolicy).enabled;
|
|
147
|
+
const whoisEnabled = featureDecision('whois', featurePolicy).enabled;
|
|
148
|
+
const dnsIntelligenceEnabled = featureDecision('dns_intelligence', featurePolicy).enabled;
|
|
149
|
+
const hasPreloadedRdap = Object.prototype.hasOwnProperty.call(options, 'rdapRecord');
|
|
150
|
+
const hasPreloadedWhois = Object.prototype.hasOwnProperty.call(options, 'whoisChain');
|
|
151
|
+
const hasPreloadedRdapPromise = Object.prototype.hasOwnProperty.call(options, 'rdapRecordPromise');
|
|
152
|
+
const hasPreloadedWhoisPromise = Object.prototype.hasOwnProperty.call(options, 'whoisChainPromise');
|
|
153
|
+
const hasPreloadedDnsDelegation = Object.prototype.hasOwnProperty.call(options, 'dnsDelegation');
|
|
154
|
+
let nameservers = [];
|
|
155
|
+
let statuses = [];
|
|
156
|
+
let rdapServer = null;
|
|
157
|
+
let rdapFound = false;
|
|
158
|
+
let registrar = null;
|
|
159
|
+
let registrant = null;
|
|
160
|
+
let abuse = null;
|
|
161
|
+
let createdDate = null;
|
|
162
|
+
let expiryDate = null;
|
|
163
|
+
let createdDateIso = null;
|
|
164
|
+
let expiryDateIso = null;
|
|
165
|
+
let registrationSource = null;
|
|
166
|
+
let registrationConfidence = 'high';
|
|
167
|
+
let dnssec = null;
|
|
168
|
+
let registryDnsEvidence = null;
|
|
169
|
+
const assessment = (registration) => ({
|
|
170
|
+
registration, registryDnsEvidence, nameservers, dnssec,
|
|
171
|
+
});
|
|
172
|
+
if (rdapEnabled) {
|
|
173
|
+
try {
|
|
174
|
+
// Shared with /api/rdap (lib/rdap.mts's fetchRdapRecord) rather than a
|
|
175
|
+
// separate fetch+parse here - same registry data either way, and this
|
|
176
|
+
// also picks up that function's short-TTL cache (lib/lookup-cache.mts)
|
|
177
|
+
// and upstream timeout for free.
|
|
178
|
+
const recordValue = hasPreloadedRdapPromise
|
|
179
|
+
? await abortable(() => options.rdapRecordPromise, options.signal)
|
|
180
|
+
: hasPreloadedRdap
|
|
181
|
+
? options.rdapRecord
|
|
182
|
+
: await fetchRdapRecord('domain', domain, options.signal ? { signal: options.signal } : {});
|
|
183
|
+
options.signal?.throwIfAborted();
|
|
184
|
+
const record = errorRecord(recordValue);
|
|
185
|
+
const recordRdapServer = typeof record.rdapServer === 'string' ? record.rdapServer : null;
|
|
186
|
+
const upstreamStatus = typeof record.upstreamStatus === 'number' ? record.upstreamStatus : null;
|
|
187
|
+
if (Object.keys(record).length) {
|
|
188
|
+
rdapServer = recordRdapServer;
|
|
189
|
+
if (upstreamStatus === 404) {
|
|
190
|
+
const cleartext = record.transportSecurity === 'http'
|
|
191
|
+
|| (recordRdapServer !== null && /^http:\/\//iu.test(recordRdapServer));
|
|
192
|
+
return assessment({
|
|
193
|
+
state: 'available',
|
|
194
|
+
confidence: cleartext ? 'medium' : 'high',
|
|
195
|
+
detail: `The registry's RDAP service has no record for this domain.${cleartext ? ' This answer arrived over unencrypted HTTP, so its transport was not authenticated.' : ''}`,
|
|
196
|
+
source: 'rdap',
|
|
197
|
+
rdapServer: recordRdapServer,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const parsed = errorRecord(record.parsed);
|
|
201
|
+
if (Object.keys(parsed).length) {
|
|
202
|
+
registryDnsEvidence = parsed;
|
|
203
|
+
statuses = Array.isArray(parsed.statuses)
|
|
204
|
+
? parsed.statuses.filter((status) => typeof status === 'string').map((status) => status.toLowerCase())
|
|
205
|
+
: [];
|
|
206
|
+
nameservers = Array.isArray(parsed.nameservers)
|
|
207
|
+
? parsed.nameservers.filter((nameserver) => typeof nameserver === 'string')
|
|
208
|
+
: [];
|
|
209
|
+
registrar = compactContact(parsed.registrar);
|
|
210
|
+
registrant = compactContact(parsed.registrant);
|
|
211
|
+
abuse = compactContact(parsed.abuse);
|
|
212
|
+
const events = Array.isArray(parsed.events)
|
|
213
|
+
? parsed.events.map(errorRecord)
|
|
214
|
+
: [];
|
|
215
|
+
const lifecycle = errorRecord(parsed.lifecycle);
|
|
216
|
+
createdDate = typeof lifecycle.createdDate === 'string'
|
|
217
|
+
? lifecycle.createdDate
|
|
218
|
+
: rdapEventDate(events, 'registration');
|
|
219
|
+
expiryDate = typeof lifecycle.expiryDate === 'string'
|
|
220
|
+
? lifecycle.expiryDate
|
|
221
|
+
: rdapEventDate(events, 'expiration');
|
|
222
|
+
createdDateIso = typeof lifecycle.createdDateIso === 'string'
|
|
223
|
+
? lifecycle.createdDateIso
|
|
224
|
+
: registryDateIso(createdDate);
|
|
225
|
+
expiryDateIso = typeof lifecycle.expiryDateIso === 'string'
|
|
226
|
+
? lifecycle.expiryDateIso
|
|
227
|
+
: registryDateIso(expiryDate);
|
|
228
|
+
dnssec = typeof parsed.dnssec === 'string' ? parsed.dnssec : null;
|
|
229
|
+
rdapFound = true;
|
|
230
|
+
registrationSource = 'rdap';
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
options.signal?.throwIfAborted();
|
|
236
|
+
/* fall through to WHOIS-based detection (deep mode only) */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const dnsDelegationPromise = !rdapFound && dnsIntelligenceEnabled
|
|
240
|
+
? hasPreloadedDnsDelegation
|
|
241
|
+
? Promise.resolve(options.dnsDelegation)
|
|
242
|
+
: checkDnsDelegation(domain, {
|
|
243
|
+
...(options.resolveNs ? { resolver: options.resolveNs } : {}),
|
|
244
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
245
|
+
})
|
|
246
|
+
: null;
|
|
247
|
+
// DNS may settle while the independent WHOIS source is still pending. Keep
|
|
248
|
+
// its rejection observed even if WHOIS supplies an early conclusive return.
|
|
249
|
+
void dnsDelegationPromise?.catch(() => { });
|
|
250
|
+
let whoisChain = null;
|
|
251
|
+
let whoisParsed = null;
|
|
252
|
+
if (!rdapFound && !fast && whoisEnabled) {
|
|
253
|
+
try {
|
|
254
|
+
whoisChain = (hasPreloadedWhoisPromise
|
|
255
|
+
? await abortable(() => options.whoisChainPromise, options.signal)
|
|
256
|
+
: hasPreloadedWhois
|
|
257
|
+
? options.whoisChain
|
|
258
|
+
: await buildWhoisChain(domain, options.signal ? { signal: options.signal } : {})) ?? null;
|
|
259
|
+
options.signal?.throwIfAborted();
|
|
260
|
+
if (!Array.isArray(whoisChain))
|
|
261
|
+
throw new Error('WHOIS chain unavailable');
|
|
262
|
+
const parsed = parseWhoisChain(whoisChain, domain);
|
|
263
|
+
whoisParsed = parsed;
|
|
264
|
+
if (parsed.notFound) {
|
|
265
|
+
return assessment({
|
|
266
|
+
state: 'available',
|
|
267
|
+
confidence: 'medium',
|
|
268
|
+
detail: `WHOIS reports no matching record for this domain${parsed.notFoundSource ? ` (per ${parsed.notFoundSource})` : ''}.`,
|
|
269
|
+
source: 'whois',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (parsed.registrationStatus === 'registered') {
|
|
273
|
+
if (parsed.nameservers.length)
|
|
274
|
+
nameservers = parsed.nameservers;
|
|
275
|
+
if (parsed.statuses.length)
|
|
276
|
+
statuses = parsed.statuses.map((status) => status.toLowerCase());
|
|
277
|
+
if (parsed.registrar) {
|
|
278
|
+
registrar = {
|
|
279
|
+
handle: null,
|
|
280
|
+
ianaId: parsed.registrarIanaId || null,
|
|
281
|
+
name: parsed.registrar,
|
|
282
|
+
org: null,
|
|
283
|
+
email: parsed.abuseEmail || null,
|
|
284
|
+
phone: parsed.abusePhone || null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (parsed.registrantName || parsed.registrantOrg || parsed.registrantEmail || parsed.registrantPhone) {
|
|
288
|
+
registrant = {
|
|
289
|
+
handle: null,
|
|
290
|
+
name: parsed.registrantName || null,
|
|
291
|
+
org: parsed.registrantOrg || null,
|
|
292
|
+
email: parsed.registrantEmail || null,
|
|
293
|
+
phone: parsed.registrantPhone || null,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (parsed.abuseEmail || parsed.abusePhone) {
|
|
297
|
+
abuse = { handle: null, name: null, org: parsed.registrar || null, email: parsed.abuseEmail || null, phone: parsed.abusePhone || null };
|
|
298
|
+
}
|
|
299
|
+
createdDate = parsed.createdDate || null;
|
|
300
|
+
expiryDate = parsed.expiryDate || null;
|
|
301
|
+
createdDateIso = parsed.createdDateIso || parsed.lifecycle?.createdDateIso || registryDateIso(createdDate);
|
|
302
|
+
expiryDateIso = parsed.expiryDateIso || parsed.lifecycle?.expiryDateIso || registryDateIso(expiryDate);
|
|
303
|
+
if (!dnssec)
|
|
304
|
+
dnssec = parsed.dnssec || null;
|
|
305
|
+
registrationSource = 'whois';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
options.signal?.throwIfAborted();
|
|
310
|
+
/* if both RDAP and WHOIS fail, we simply can't determine availability */
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// WHOIS ran and was not a confirmed not-found, but produced no positive
|
|
314
|
+
// registration evidence either - e.g. the registry answered inconclusively
|
|
315
|
+
// or every referral hop failed/rate-limited. Report "unknown" rather than
|
|
316
|
+
// fabricating "registered" from an empty record.
|
|
317
|
+
const hasWhoisRegistrationData = whoisParsed?.registrationStatus === 'registered';
|
|
318
|
+
let dnsDelegated = false;
|
|
319
|
+
if (!rdapFound && !hasWhoisRegistrationData && dnsDelegationPromise) {
|
|
320
|
+
const delegation = await dnsDelegationPromise;
|
|
321
|
+
if (delegation && delegation.delegated === true && Array.isArray(delegation.nameservers) && delegation.nameservers.length) {
|
|
322
|
+
dnsDelegated = true;
|
|
323
|
+
nameservers = delegation.nameservers;
|
|
324
|
+
registrationSource = 'dns';
|
|
325
|
+
registrationConfidence = 'medium';
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
options.signal?.throwIfAborted();
|
|
329
|
+
if (!rdapFound && !hasWhoisRegistrationData && !dnsDelegated) {
|
|
330
|
+
const disabledSources = [
|
|
331
|
+
!rdapEnabled ? 'RDAP' : null,
|
|
332
|
+
!fast && !whoisEnabled ? 'WHOIS' : null,
|
|
333
|
+
!dnsIntelligenceEnabled ? 'DNS intelligence' : null,
|
|
334
|
+
].filter(Boolean);
|
|
335
|
+
const disabledDetail = disabledSources.length
|
|
336
|
+
? ` ${disabledSources.join(', ')} ${disabledSources.length === 1 ? 'is' : 'are'} disabled by deployment policy.`
|
|
337
|
+
: '';
|
|
338
|
+
const policyDetail = registryPolicyDetail(domain, fast);
|
|
339
|
+
return assessment({
|
|
340
|
+
state: 'unknown',
|
|
341
|
+
confidence: 'low',
|
|
342
|
+
detail: fast
|
|
343
|
+
? `No enabled registration source produced a record or authoritative delegation. A fast scan cannot determine registration status.${disabledDetail}${policyDetail}`
|
|
344
|
+
: whoisParsed && whoisParsed.failedHop
|
|
345
|
+
? `WHOIS was inconclusive - a referral hop did not answer conclusively (${whoisParsed.failedHop}).${policyDetail}`
|
|
346
|
+
: `No enabled registration source returned conclusive data or an authoritative DNS delegation.${disabledDetail}${policyDetail}`,
|
|
347
|
+
...(!fast && whoisEnabled ? { source: 'whois' } : {}),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const domainAgeDays = computeAgeDays(createdDateIso || createdDate);
|
|
351
|
+
const expiresInDays = computeDaysUntil(expiryDateIso || expiryDate);
|
|
352
|
+
// DNS proves delegation only; it says nothing about whether registry
|
|
353
|
+
// contact data is privacy-protected or merely unavailable.
|
|
354
|
+
const privacyProtected = registrationSource === 'dns' ? null : isPrivacyProtected(registrant);
|
|
355
|
+
const baseInfo = {
|
|
356
|
+
nameservers,
|
|
357
|
+
statuses,
|
|
358
|
+
registrar,
|
|
359
|
+
registrant,
|
|
360
|
+
abuse,
|
|
361
|
+
createdDate,
|
|
362
|
+
expiryDate,
|
|
363
|
+
createdDateIso,
|
|
364
|
+
expiryDateIso,
|
|
365
|
+
rdapServer,
|
|
366
|
+
domainAgeDays,
|
|
367
|
+
expiresInDays,
|
|
368
|
+
privacyProtected,
|
|
369
|
+
dnssec,
|
|
370
|
+
source: registrationSource,
|
|
371
|
+
};
|
|
372
|
+
if (statuses.some((status) => {
|
|
373
|
+
const normalizedStatus = registryStatusToken(status);
|
|
374
|
+
return normalizedStatus.includes('pendingdelete') || normalizedStatus.includes('redemptionperiod');
|
|
375
|
+
})) {
|
|
376
|
+
return assessment({
|
|
377
|
+
state: 'expiring',
|
|
378
|
+
confidence: 'medium',
|
|
379
|
+
detail: 'Domain is in redemption/pending-delete status and may become available soon.',
|
|
380
|
+
...baseInfo,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return assessment({
|
|
384
|
+
state: 'registered',
|
|
385
|
+
confidence: registrationConfidence,
|
|
386
|
+
detail: registrationSource === 'dns'
|
|
387
|
+
? 'Authoritative DNS delegation confirms the domain is registered, but RDAP/WHOIS registration details were unavailable.'
|
|
388
|
+
: 'Domain is registered. Run a deep check for parking/for-sale detection.',
|
|
389
|
+
...baseInfo,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
export { resolveDomainRegistration, checkDnsDelegation, isPrivacyProtected };
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Portable tree bounds, private-field exclusion and availability projection.
|
|
2
2
|
// Source-specific publication assembly remains in evidence-export.mts.
|
|
3
|
-
import { isSafeJsonObjectKey } from "
|
|
3
|
+
import { isSafeJsonObjectKey } from "../packages/analysis/bounded-json.mjs";
|
|
4
4
|
import { recordOrNull } from "./json-record.mjs";
|
|
5
|
-
import { isUriShapedLabel } from "
|
|
5
|
+
import { isUriShapedLabel } from "../packages/analysis/portable-generator.mjs";
|
|
6
6
|
import { validHttpDeliveryMetadata, validPagePublicationMetadata } from "./homepage-metadata-contract.mjs";
|
|
7
|
+
import { normalizeWebCollectionQuality } from "../packages/evidence/collection-quality.mjs";
|
|
7
8
|
import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES, LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS, LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH, LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH, } from "../packages/contracts/lookup-evidence.mjs";
|
|
8
9
|
const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
|
|
9
10
|
'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
|
|
@@ -22,7 +23,9 @@ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
|
|
|
22
23
|
...[...PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS]
|
|
23
24
|
.filter((key) => !['registrar', 'registrant', 'abuse'].includes(key)),
|
|
24
25
|
'registryContactsExcluded', 'observationHostname', 'webObservationMode',
|
|
26
|
+
'webCollectionQuality',
|
|
25
27
|
]);
|
|
28
|
+
const HISTORICAL_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([...LOOKUP_AVAILABILITY_ANALYSIS_KEYS].filter(key => key !== 'webCollectionQuality'));
|
|
26
29
|
const PRIVATE_EVIDENCE_KEYS = new Set([
|
|
27
30
|
'authorization', 'proxyauthorization', 'cookie', 'cookies', 'setcookie',
|
|
28
31
|
'session', 'sessionid', 'sessiontoken', 'token', 'accesstoken', 'refreshtoken',
|
|
@@ -174,7 +177,7 @@ function privateEvidenceKey(value, item) {
|
|
|
174
177
|
|| /(?:request|response|authorization|proxy)headers?$/u.test(normalized)
|
|
175
178
|
|| (normalized.includes('credential') && !normalized.startsWith('credentialsurface'));
|
|
176
179
|
}
|
|
177
|
-
export function portableUri(value) {
|
|
180
|
+
export function portableUri(value, legacyUris = false) {
|
|
178
181
|
const text = boundedString(value, 2048);
|
|
179
182
|
if (!text)
|
|
180
183
|
return null;
|
|
@@ -184,7 +187,7 @@ export function portableUri(value) {
|
|
|
184
187
|
|| url.username
|
|
185
188
|
|| url.password)
|
|
186
189
|
return null;
|
|
187
|
-
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
190
|
+
if (!legacyUris || url.protocol === 'http:' || url.protocol === 'https:') {
|
|
188
191
|
url.search = '';
|
|
189
192
|
url.hash = '';
|
|
190
193
|
}
|
|
@@ -268,7 +271,7 @@ function projectLookupEvidenceAvailabilityPublicValue(value, path, state, depth)
|
|
|
268
271
|
if (typeof value === 'string') {
|
|
269
272
|
const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
|
|
270
273
|
if (PORTABLE_URL_KEYS.has(normalizedParent))
|
|
271
|
-
return portableUri(value);
|
|
274
|
+
return portableUri(value, true);
|
|
272
275
|
if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
|
|
273
276
|
return portableOrigin(value);
|
|
274
277
|
return projectPortableString(value);
|
|
@@ -313,21 +316,21 @@ function projectLookupEvidenceAvailabilityPublicValue(value, path, state, depth)
|
|
|
313
316
|
}
|
|
314
317
|
return output;
|
|
315
318
|
}
|
|
316
|
-
function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
|
|
319
|
+
function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legacyUris) {
|
|
317
320
|
consumePortableProjectionEntry(state, depth);
|
|
318
321
|
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
319
322
|
return value;
|
|
320
323
|
if (typeof value === 'string') {
|
|
321
324
|
const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
|
|
322
325
|
if (PORTABLE_URL_KEYS.has(normalizedParent))
|
|
323
|
-
return portableUri(value);
|
|
326
|
+
return portableUri(value, legacyUris);
|
|
324
327
|
if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
|
|
325
328
|
return portableOrigin(value);
|
|
326
329
|
return projectLookupEvidenceAvailabilityString(value);
|
|
327
330
|
}
|
|
328
331
|
if (Array.isArray(value)) {
|
|
329
332
|
return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
|
|
330
|
-
.map((item) => projectLookupEvidenceAvailabilityValue(item, path, state, depth + 1));
|
|
333
|
+
.map((item) => projectLookupEvidenceAvailabilityValue(item, path, state, depth + 1, legacyUris));
|
|
331
334
|
}
|
|
332
335
|
const source = recordOrNull(value);
|
|
333
336
|
if (!source)
|
|
@@ -371,7 +374,7 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
|
|
|
371
374
|
&& (!LOOKUP_AVAILABILITY_VALUE_PATHS.has(parentPath)
|
|
372
375
|
|| (item !== null && typeof item !== 'string')))
|
|
373
376
|
continue;
|
|
374
|
-
output[key] = projectLookupEvidenceAvailabilityValue(item, [...path, key], state, depth + 1);
|
|
377
|
+
output[key] = projectLookupEvidenceAvailabilityValue(item, [...path, key], state, depth + 1, legacyUris);
|
|
375
378
|
}
|
|
376
379
|
return output;
|
|
377
380
|
}
|
|
@@ -381,7 +384,7 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
|
|
|
381
384
|
* extension cannot become authenticated evidence merely by avoiding a
|
|
382
385
|
* credential-key denylist.
|
|
383
386
|
*/
|
|
384
|
-
function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryContactsExcluded, currentPrivacyRules) {
|
|
387
|
+
function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryContactsExcluded, currentPrivacyRules, legacyUris = false) {
|
|
385
388
|
const source = recordOrNull(value);
|
|
386
389
|
if (!source)
|
|
387
390
|
return null;
|
|
@@ -394,8 +397,12 @@ function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryCont
|
|
|
394
397
|
const item = source[key];
|
|
395
398
|
if (privateEvidenceKey(key, item))
|
|
396
399
|
continue;
|
|
400
|
+
if (key === 'webCollectionQuality') {
|
|
401
|
+
output[key] = normalizeWebCollectionQuality(item);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
397
404
|
output[key] = currentPrivacyRules
|
|
398
|
-
? projectLookupEvidenceAvailabilityValue(item, [key], state, 1)
|
|
405
|
+
? projectLookupEvidenceAvailabilityValue(item, [key], state, 1, legacyUris)
|
|
399
406
|
: projectLookupEvidenceAvailabilityPublicValue(item, [key], state, 1);
|
|
400
407
|
}
|
|
401
408
|
if (registryContactsExcluded)
|
|
@@ -437,10 +444,10 @@ export function projectLookupEvidenceAvailabilityPublic(value) {
|
|
|
437
444
|
* and abuse contact routes are deliberately excluded at the root boundary;
|
|
438
445
|
* the marker prevents that privacy omission from being read as source absence.
|
|
439
446
|
*/
|
|
440
|
-
export function projectLookupEvidenceAvailability(value) {
|
|
441
|
-
return projectLookupEvidenceAvailabilityWithKeys(value, LOOKUP_AVAILABILITY_ANALYSIS_KEYS, true, true);
|
|
447
|
+
export function projectLookupEvidenceAvailability(value, { legacyUris = false } = {}) {
|
|
448
|
+
return projectLookupEvidenceAvailabilityWithKeys(value, legacyUris ? HISTORICAL_LOOKUP_AVAILABILITY_ANALYSIS_KEYS : LOOKUP_AVAILABILITY_ANALYSIS_KEYS, true, true, legacyUris);
|
|
442
449
|
}
|
|
443
|
-
export function assertLookupEvidencePrivacySafeTree(value) {
|
|
450
|
+
export function assertLookupEvidencePrivacySafeTree(value, { legacyUris = false } = {}) {
|
|
444
451
|
assertLookupEvidencePortableTree(value);
|
|
445
452
|
const pending = [value];
|
|
446
453
|
while (pending.length) {
|
|
@@ -466,7 +473,7 @@ export function assertLookupEvidencePrivacySafeTree(value) {
|
|
|
466
473
|
if (privateEvidenceKey(normalized, item)) {
|
|
467
474
|
throw new TypeError('Lookup evidence contains excluded private request or session material.');
|
|
468
475
|
}
|
|
469
|
-
if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized) && portableUri(item) !== item) {
|
|
476
|
+
if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized) && portableUri(item, legacyUris) !== item) {
|
|
470
477
|
throw new TypeError('Lookup evidence contains a credential-bearing or non-portable URL.');
|
|
471
478
|
}
|
|
472
479
|
pending.push(item);
|