@slicedearth/whoisleuth-cli 2.3.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 +8 -2
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -408
- package/cli/artifact-validation/case-response.mjs +65 -44
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +15 -9
- package/cli/artifact-validation/signed-review.mjs +136 -68
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +92 -24
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +40 -5
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +4 -2
- package/cli/bulk-output.mjs +29 -3
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +212 -0
- package/cli/case-pack.mjs +50 -32
- 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 -206
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +94 -1357
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +8 -6
- package/cli/ct-event-intake.mjs +16 -16
- package/cli/discover.mjs +5 -4
- package/cli/discovery-observation-snapshot.mjs +46 -18
- package/cli/discovery-scan-command-runner.mjs +5 -3
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +5 -5
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +151 -103
- package/cli/errors.mjs +37 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +18 -8
- package/cli/evidence-signing.mjs +5 -4
- package/cli/external-observation-mapping.mjs +9 -10
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +24 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -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 -46
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +8 -10
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +38 -147
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +256 -35
- package/cli/local-document-checkpoint.mjs +170 -0
- 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-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +33 -39
- package/cli/lookup-diff.mjs +47 -12
- package/cli/lookup-plan.mjs +16 -14
- package/cli/lookup-reconcile.mjs +15 -12
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +75 -21
- package/cli/manual.mjs +1 -0
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +45 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/output-file.mjs +31 -5
- package/cli/posture.mjs +1 -1
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +10 -9
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +104 -149
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +74 -37
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +8 -80
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +75 -84
- 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/terminal-input.mjs +168 -0
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +75 -13
- package/docs/cli-reference.md +103 -30
- package/docs/cli.md +510 -25
- package/docs/contextual-reviews.md +155 -0
- package/lib/abort.mjs +4 -1
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +27 -754
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +2 -290
- package/lib/browser-library-profile.mjs +99 -130
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +89 -46
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +36 -22
- package/lib/dns-inheritance-review.mjs +207 -0
- package/lib/dns-intelligence.mjs +44 -29
- package/lib/dns-mx.mjs +1 -1
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +18 -14
- package/lib/domain-change-review.mjs +40 -16
- package/lib/domain-control-flight-recorder.mjs +301 -124
- package/lib/domain-control-manifest.mjs +126 -58
- package/lib/domain-posture-analysis.mjs +41 -11
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +120 -32
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +568 -0
- package/lib/evidence-export.mjs +12 -593
- package/lib/evidence-report.mjs +7 -2
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +50 -4
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +2742 -1034
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +87 -85
- package/lib/http-evidence-bounds.mjs +2 -7
- package/lib/http-intelligence.mjs +52 -27
- 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-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +79 -13
- package/lib/lookup-response-contract.mjs +1077 -0
- package/lib/lookup-response.mjs +11 -24
- package/lib/lookup-source-progress.mjs +24 -35
- package/lib/lookup.mjs +49 -28
- package/lib/observed-network-context.mjs +43 -27
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +87 -134
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +79 -24
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +26 -14
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +2 -36
- package/lib/registrar-standing-contract.mjs +2 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +7 -3
- package/lib/registry-capability-catalogue.mjs +29 -20
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +2 -338
- package/lib/risk-scoring.mjs +2 -345
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/security-txt.mjs +10 -7
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +35 -0
- package/lib/smtp-transport-review.mjs +75 -34
- package/lib/sslbl-certificates.generated.mjs +141 -138
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +2 -1
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +2 -36
- package/lib/typosquat-generator.mjs +29 -16
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-enrichment.mjs +388 -0
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +55 -5
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-parser.mjs +5 -3
- package/lib/whois-transport.mjs +23 -7
- package/package.json +14 -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-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +229 -69
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +55 -0
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +98 -48
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +149 -0
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +4 -3
- package/packages/cases/case-record-operations.mjs +123 -133
- package/packages/cases/case-record-projection.mjs +30 -20
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +114 -67
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +756 -0
- package/packages/cases/case-response-model.mjs +49 -1129
- package/packages/cases/case-response-outcomes.mjs +338 -0
- package/packages/cases/case-response-packet-render.mjs +157 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +58 -593
- package/packages/cases/case-response-records.mjs +93 -0
- package/packages/cases/case-response-review-inputs.mjs +382 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +26 -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/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +3 -2
- package/packages/comparison/relationship-evidence.mjs +103 -14
- package/packages/comparison/relationship-provenance.mjs +99 -0
- package/packages/contracts/analyst-interchange.mjs +234 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +370 -114
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
- package/packages/contracts/domain-control-manifest.mjs +128 -74
- package/packages/contracts/domain-control-monitor.mjs +59 -45
- package/packages/contracts/domain-control-review.mjs +200 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +18 -0
- package/packages/contracts/ip-address.mjs +44 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +219 -238
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +108 -0
- package/packages/evidence/observation.mjs +18 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +93 -61
- 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 +389 -0
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +9 -14
- 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-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/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/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +160 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +39 -31
- package/packages/workspace/bulk-session-model.mjs +112 -27
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +45 -6
- package/packages/workspace/page-baseline.mjs +20 -17
- package/packages/workspace/relationship-observation-model.mjs +54 -35
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +30 -14
- package/packages/workspace/watchlist-store.mjs +24 -6
- package/packages/workspace/website-snapshot-model.mjs +98 -33
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +90 -25
- package/third-party-notices.txt +577 -17
- package/lib/hostname.mjs +0 -38
- package/packages/investigation/bulk-review-export.mjs +0 -63
package/lib/ct-search.mjs
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
// earliest public signal a new lookalike exists at all. Shared by the
|
|
8
8
|
// Express server and the Netlify Functions.
|
|
9
9
|
import { parse } from 'tldts';
|
|
10
|
-
import {
|
|
10
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
|
+
import { normalizeCtQuery } from "../packages/analysis/ct-query.mjs";
|
|
11
12
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
12
13
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
13
14
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
14
|
-
import { isValidAsciiHostname } from "
|
|
15
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
15
16
|
import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
|
|
16
|
-
import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "
|
|
17
|
+
import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "../packages/analysis/ct-response-bounds.mjs";
|
|
17
18
|
const CRT_SH_TIMEOUT_MS = 20000;
|
|
18
19
|
const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
|
|
19
20
|
// crt.sh's response size scales with how many certificates ever matched the
|
|
@@ -37,6 +38,27 @@ const RETRY_DELAY_MS = 1500;
|
|
|
37
38
|
// delay before a status-code retry above), so this caps the worst case at
|
|
38
39
|
// two attempts (~40s) instead of stacking on top of MAX_RETRIES.
|
|
39
40
|
const MAX_TIMEOUT_RETRIES = 1;
|
|
41
|
+
const COLLECTION_ERRORS = {
|
|
42
|
+
CT_TIMEOUT: [504, 'Certificate search timed out. Try a narrower keyword or try again later.'],
|
|
43
|
+
CT_OVERLOADED: [503, 'Certificate search is temporarily overloaded. Try again later.'],
|
|
44
|
+
CT_QUERY_TOO_BROAD: [422, 'Certificate search returned too many results. Try a narrower keyword.'],
|
|
45
|
+
CT_INVALID_RESPONSE: [502, 'Certificate search returned invalid UTF-8 or an unexpected response format (expected a JSON array). Try again later.'],
|
|
46
|
+
CT_UPSTREAM_ERROR: [502, 'Certificate search could not retrieve a response from its source. Try again later.'],
|
|
47
|
+
};
|
|
48
|
+
export class CtCollectionError extends Error {
|
|
49
|
+
code;
|
|
50
|
+
constructor(code) {
|
|
51
|
+
super(COLLECTION_ERRORS[code][1]);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.name = 'CtCollectionError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Only this finite collection vocabulary may cross an HTTP error boundary. */
|
|
57
|
+
export function ctCollectionErrorResponse(error) {
|
|
58
|
+
return error instanceof CtCollectionError
|
|
59
|
+
? { statusCode: COLLECTION_ERRORS[error.code][0], body: { error: COLLECTION_ERRORS[error.code][1], errorCode: error.code } }
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
40
62
|
// Row-count defense: a hostile or malformed response could fit a very large
|
|
41
63
|
// number of tiny rows into the 5 MB byte cap and impose excessive
|
|
42
64
|
// iteration/set overhead after parsing. Legitimate responses under the byte
|
|
@@ -71,59 +93,79 @@ function normalizeHostname(raw) {
|
|
|
71
93
|
h = h.slice(2);
|
|
72
94
|
return HOSTNAME_RE.test(h) && isValidAsciiHostname(h, { requireLowercase: true }) ? h : null;
|
|
73
95
|
}
|
|
74
|
-
function delay(ms) {
|
|
75
|
-
return
|
|
96
|
+
function delay(ms, signal) {
|
|
97
|
+
return sleep(ms, undefined, signal ? { signal } : {});
|
|
76
98
|
}
|
|
77
|
-
async function fetchCrtSh(keyword,
|
|
99
|
+
async function fetchCrtSh(keyword, dependencies = {}) {
|
|
100
|
+
dependencies.signal?.throwIfAborted();
|
|
78
101
|
const fetcher = dependencies.fetcher || safeFetch;
|
|
79
102
|
const wait = dependencies.delay || delay;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
104
|
+
dependencies.signal?.throwIfAborted();
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
|
|
107
|
+
const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
|
|
108
|
+
let retryDelay = 0;
|
|
84
109
|
try {
|
|
85
|
-
res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
110
|
+
const res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
86
111
|
headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
|
|
87
|
-
signal
|
|
112
|
+
signal,
|
|
88
113
|
}, 0);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
await res.body?.cancel().catch(() => { });
|
|
116
|
+
if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
|
|
117
|
+
retryDelay = RETRY_DELAY_MS * (attempt + 1);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
throw new CtCollectionError(RETRYABLE_STATUSES.has(res.status) || res.status === 429 ? 'CT_OVERLOADED' : 'CT_UPSTREAM_ERROR');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
const body = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
if (body.truncated)
|
|
129
|
+
throw new CtCollectionError('CT_QUERY_TOO_BROAD');
|
|
130
|
+
text = body.text;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// Preserve transport/deadline failures for the attempt retry below.
|
|
134
|
+
if (error instanceof TypeError && !signal.aborted) {
|
|
135
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
data = JSON.parse(text);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(data))
|
|
147
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
148
|
+
if (data.length > MAX_CT_ROWS)
|
|
149
|
+
throw new CtCollectionError('CT_QUERY_TOO_BROAD');
|
|
150
|
+
return data;
|
|
106
151
|
}
|
|
107
|
-
throw new Error(RETRYABLE_STATUSES.has(res.status)
|
|
108
|
-
? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
|
|
109
|
-
: `crt.sh returned ${res.status}`);
|
|
110
|
-
}
|
|
111
|
-
const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
|
|
112
|
-
if (truncated) {
|
|
113
|
-
throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
|
|
114
152
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
153
|
+
catch (error) {
|
|
154
|
+
dependencies.signal?.throwIfAborted();
|
|
155
|
+
if (!controller.signal.aborted && (!(error instanceof Error) || error.name !== 'AbortError'))
|
|
156
|
+
throw error;
|
|
157
|
+
if (attempt >= MAX_TIMEOUT_RETRIES)
|
|
158
|
+
throw new CtCollectionError('CT_TIMEOUT');
|
|
118
159
|
}
|
|
119
|
-
|
|
120
|
-
|
|
160
|
+
finally {
|
|
161
|
+
// End this attempt before delay/retry: body reads share the same deadline
|
|
162
|
+
// as headers, and earlier timers cannot fire during a later attempt.
|
|
163
|
+
clearTimeout(timeout);
|
|
121
164
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
finally {
|
|
125
|
-
clearTimeout(timeout);
|
|
165
|
+
if (retryDelay)
|
|
166
|
+
await wait(retryDelay, dependencies.signal);
|
|
126
167
|
}
|
|
168
|
+
throw new CtCollectionError('CT_UPSTREAM_ERROR');
|
|
127
169
|
}
|
|
128
170
|
// ---------------------------------------------------------------------------
|
|
129
171
|
// Certificate identity
|
|
@@ -464,6 +506,7 @@ function summarizeCtResults(rows) {
|
|
|
464
506
|
// Production search
|
|
465
507
|
// ---------------------------------------------------------------------------
|
|
466
508
|
async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
509
|
+
dependencies.signal?.throwIfAborted();
|
|
467
510
|
const startedAt = Date.now();
|
|
468
511
|
const trimmed = normalizeCtQuery(keyword);
|
|
469
512
|
if (!trimmed) {
|
|
@@ -478,7 +521,7 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
|
478
521
|
}),
|
|
479
522
|
};
|
|
480
523
|
}
|
|
481
|
-
const data = await fetchCrtSh(trimmed,
|
|
524
|
+
const data = await fetchCrtSh(trimmed, dependencies);
|
|
482
525
|
const summary = summarizeCtResults(data);
|
|
483
526
|
return {
|
|
484
527
|
certCount: data.length,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
2
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
3
|
+
import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
|
|
4
|
+
export function requireDmarcDomain(domain) {
|
|
5
|
+
if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
|
|
6
|
+
throw new TypeError('The additional DNS review requires a normalised domain.');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function dmarcPolicyObservation(owner, observation) {
|
|
10
|
+
const records = observation.records;
|
|
11
|
+
let bytes = 0;
|
|
12
|
+
const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
|
|
13
|
+
const parts = typeof raw === 'string' ? [raw] : raw;
|
|
14
|
+
return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
|
|
15
|
+
if (typeof part !== 'string' || part.length > 4096)
|
|
16
|
+
return false;
|
|
17
|
+
bytes += Buffer.byteLength(part, 'utf8');
|
|
18
|
+
return bytes <= 65_536;
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
const parsed = parseDmarcRecords(admitted ? records : []);
|
|
22
|
+
const unavailable = Boolean(observation.error) || !admitted;
|
|
23
|
+
const psd = parsed.tags.psd?.toLowerCase() ?? 'u';
|
|
24
|
+
const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(psd);
|
|
25
|
+
const incomplete = unavailable || (!valid && parsed.records.length === 1);
|
|
26
|
+
const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
|
|
27
|
+
const description = valid
|
|
28
|
+
? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${psd}; test=${parsed.testMode ? 'yes' : 'no'}`
|
|
29
|
+
: state;
|
|
30
|
+
const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
|
|
31
|
+
? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
|
|
32
|
+
return { owner, parsed, psd, valid, unavailable, incomplete,
|
|
33
|
+
record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
|
|
34
|
+
}
|
|
35
|
+
/** RFC 9989 sections 4.10–4.10.2: at most eight owners, never PSL inference. */
|
|
36
|
+
export async function walkDmarcTree(domain, exact, resolve, purpose) {
|
|
37
|
+
requireDmarcDomain(domain);
|
|
38
|
+
const rows = [dmarcPolicyObservation(domain, exact)];
|
|
39
|
+
const first = rows[0];
|
|
40
|
+
const labels = domain.split('.');
|
|
41
|
+
let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
|
|
42
|
+
const policyOnly = purpose === 'policy' && first.valid;
|
|
43
|
+
if (!policyOnly && !first.unavailable && !(first.valid && ['n', 'y'].includes(first.psd))) {
|
|
44
|
+
while (next.length) {
|
|
45
|
+
const owner = next.join('.');
|
|
46
|
+
const row = dmarcPolicyObservation(owner, await resolve(`_dmarc.${owner}`));
|
|
47
|
+
rows.push(row);
|
|
48
|
+
if (row.unavailable || (row.valid && ['n', 'y'].includes(row.psd)))
|
|
49
|
+
break;
|
|
50
|
+
next = next.slice(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const available = rows.filter(row => row.valid);
|
|
54
|
+
const boundary = available.find(row => row.psd === 'n' || (row.psd === 'y' && row.owner !== domain));
|
|
55
|
+
const incomplete = rows.some(row => row.incomplete);
|
|
56
|
+
// An exact policy alone does not establish the organisational boundary.
|
|
57
|
+
const organisationalDomain = incomplete || (policyOnly && !['n', 'y'].includes(first.psd)) ? null
|
|
58
|
+
: boundary?.psd === 'y'
|
|
59
|
+
? labels.slice(-(boundary.owner.split('.').length + 1)).join('.')
|
|
60
|
+
: boundary?.owner ?? available.at(-1)?.owner ?? domain;
|
|
61
|
+
return { rows, available, boundary, incomplete, organisationalDomain };
|
|
62
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { normaliseRdata } from "./zone-intent-review.mjs";
|
|
3
|
+
import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
|
|
3
4
|
export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
|
|
4
5
|
export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
|
|
5
|
-
export const
|
|
6
|
+
export const DNS_CONVERGENCE_INPUT_VERSION = 2;
|
|
7
|
+
export const SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS = Object.freeze([1, DNS_CONVERGENCE_INPUT_VERSION]);
|
|
8
|
+
export const DNS_CONVERGENCE_REVIEW_VERSION = 2;
|
|
6
9
|
export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
|
|
7
10
|
export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
|
|
8
11
|
const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
|
|
9
12
|
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
|
|
10
13
|
const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
|
|
11
14
|
const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
|
|
15
|
+
const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
|
|
12
16
|
const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
|
|
13
17
|
function type(value, label) {
|
|
14
18
|
const candidate = requireBoundedString(value, label, 16).toUpperCase();
|
|
@@ -83,8 +87,8 @@ function expectedRows(value, apex) {
|
|
|
83
87
|
}
|
|
84
88
|
export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
85
89
|
const input = requireRecord(inputRaw, 'DNS convergence input');
|
|
86
|
-
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version
|
|
87
|
-
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
|
|
90
|
+
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || !SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS.includes(input.version)) {
|
|
91
|
+
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1 or ${DNS_CONVERGENCE_INPUT_VERSION}.`);
|
|
88
92
|
}
|
|
89
93
|
exactKeys(input, ROOT_KEYS, 'DNS convergence input');
|
|
90
94
|
const apex = requireDomainName(input.domain, 'domain');
|
|
@@ -93,9 +97,10 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
93
97
|
throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
|
|
94
98
|
}
|
|
95
99
|
let recordCount = 0;
|
|
100
|
+
let queryCount = 0;
|
|
96
101
|
const snapshots = input.snapshots.map((raw, index) => {
|
|
97
102
|
const item = requireRecord(raw, `snapshots[${index}]`);
|
|
98
|
-
exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
103
|
+
exactKeys(item, input.version === 1 ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
99
104
|
if (!Array.isArray(item.records))
|
|
100
105
|
throw new TypeError(`snapshots[${index}].records must be an array.`);
|
|
101
106
|
recordCount += item.records.length;
|
|
@@ -104,12 +109,20 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
104
109
|
const observationState = state(item.state, `snapshots[${index}].state`);
|
|
105
110
|
if (observationState === 'unavailable' && item.records.length)
|
|
106
111
|
throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
|
|
112
|
+
const records = Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`)));
|
|
113
|
+
const queries = normaliseDnsQueryCoverage(item.queries, {
|
|
114
|
+
legacy: input.version === 1, label: `snapshots[${index}].queries`,
|
|
115
|
+
maximum: MAX_DNS_CONVERGENCE_RECORDS - queryCount, state: observationState, records,
|
|
116
|
+
owner: (value, label) => owner(value, apex, label), type,
|
|
117
|
+
});
|
|
118
|
+
queryCount += queries.length;
|
|
107
119
|
return Object.freeze({
|
|
108
120
|
observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
|
|
109
121
|
source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
|
|
110
122
|
observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
|
|
111
123
|
state: observationState,
|
|
112
|
-
records
|
|
124
|
+
records,
|
|
125
|
+
queries,
|
|
113
126
|
});
|
|
114
127
|
}).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
|
|
115
128
|
const observers = [...new Set(snapshots.map((item) => item.observer))];
|
|
@@ -118,7 +131,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
118
131
|
const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
|
|
119
132
|
const keys = [...new Set([
|
|
120
133
|
...expected.map((item) => `${item.owner}\u0000${item.type}`),
|
|
121
|
-
...snapshots.flatMap((item) => item.
|
|
134
|
+
...snapshots.flatMap((item) => item.queries.map((entry) => `${entry.owner}\u0000${entry.type}`)),
|
|
122
135
|
])].sort();
|
|
123
136
|
const rows = keys.map((key) => {
|
|
124
137
|
const [recordOwner = '', recordType = ''] = key.split('\u0000');
|
|
@@ -134,7 +147,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
134
147
|
observer: snapshot.observer,
|
|
135
148
|
source: snapshot.source,
|
|
136
149
|
observedAt: snapshot.observedAt,
|
|
137
|
-
state: snapshot.
|
|
150
|
+
state: dnsQueryState(snapshot.queries, recordOwner, recordType),
|
|
138
151
|
values: Object.freeze(values),
|
|
139
152
|
cacheUntil,
|
|
140
153
|
});
|
|
@@ -178,7 +191,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
178
191
|
'This local workbench compares only supplied observations and makes no DNS request.',
|
|
179
192
|
'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
|
|
180
193
|
'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
|
|
181
|
-
'
|
|
194
|
+
'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are never interpreted as absence; legacy inputs establish scope only for their supplied records.',
|
|
182
195
|
]),
|
|
183
196
|
});
|
|
184
197
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Bounded delegation-health evidence for deep single-domain Lookup. Registry
|
|
2
|
-
// publication, recursive
|
|
2
|
+
// publication, recursive resolver answers, and direct nameserver queries
|
|
3
3
|
// remain separately attributed. The result never participates in registration
|
|
4
4
|
// availability and never treats a failed query as record absence.
|
|
5
5
|
import { promises as dns } from 'node:dns';
|
|
6
6
|
import * as net from 'node:net';
|
|
7
7
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
|
|
9
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
9
10
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
10
11
|
const DNS_DELEGATION_HEALTH_VERSION = 1;
|
|
11
12
|
const MAX_AUTHORITIES = 4;
|
|
@@ -148,10 +149,9 @@ function normaliseAuthorityValueSet(type, value) {
|
|
|
148
149
|
}
|
|
149
150
|
const source = record(item);
|
|
150
151
|
if (type === 'MX') {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return [`${priority} ${exchange}`];
|
|
152
|
+
const mx = normalizeResolverMxRecord(item);
|
|
153
|
+
if (mx)
|
|
154
|
+
return [`${mx.priority} ${mx.exchange}`];
|
|
155
155
|
rejected += 1;
|
|
156
156
|
return [];
|
|
157
157
|
}
|
|
@@ -291,6 +291,7 @@ function finding(id, label, state, summary, detail, remediation) {
|
|
|
291
291
|
function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks were not collected for this lookup.') {
|
|
292
292
|
return {
|
|
293
293
|
delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
|
|
294
|
+
domain: null,
|
|
294
295
|
...createObservation({
|
|
295
296
|
status: 'skipped',
|
|
296
297
|
scanMode: 'deep',
|
|
@@ -326,9 +327,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
326
327
|
? parentQuery.status
|
|
327
328
|
: 'error';
|
|
328
329
|
const parentNameservers = hostnames(parentQuery.records);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
const eligibleAuthorities = [...new Set([...parentNameservers, ...registry.nameservers])].sort();
|
|
331
|
+
const candidates = eligibleAuthorities.slice(0, MAX_AUTHORITIES);
|
|
332
|
+
const omittedAuthorityCount = eligibleAuthorities.length - candidates.length;
|
|
332
333
|
const resolve4 = options.resolve4 || dns.resolve4;
|
|
333
334
|
const resolve6 = options.resolve6 || dns.resolve6;
|
|
334
335
|
const queryAuthority = options.queryAuthority || defaultAuthorityQuery;
|
|
@@ -464,8 +465,10 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
464
465
|
.filter((primary) => primary !== null))];
|
|
465
466
|
const soaSerialConflict = soaSerials.length > 1;
|
|
466
467
|
const soaPrimaryConflict = soaPrimaries.length > 1;
|
|
467
|
-
const
|
|
468
|
-
|
|
468
|
+
const glueInventoryComplete = (parentStatus === 'success' && parentNameservers.length > 0 && parentQuery.truncated !== true)
|
|
469
|
+
|| (registry.nameservers.length > 0 && !registry.truncated);
|
|
470
|
+
const inBailiwick = eligibleAuthorities.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
|
|
471
|
+
const unobservedGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
|
|
469
472
|
const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
|
|
470
473
|
const recordMatrix = authorityRecordMatrix(authorities);
|
|
471
474
|
const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
|
|
@@ -473,13 +476,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
473
476
|
const recordPartial = recordMatrix.filter((row) => row.state === 'partial');
|
|
474
477
|
const recordInsufficient = recordMatrix.filter((row) => row.state === 'insufficient');
|
|
475
478
|
const findings = [
|
|
476
|
-
finding('parent_registry_ns', '
|
|
479
|
+
finding('parent_registry_ns', 'Recursive and registry nameservers', !parentNameservers.length || !registry.nameservers.length
|
|
477
480
|
? 'unknown'
|
|
478
481
|
: sameSet(parentNameservers, registry.nameservers) ? 'healthy' : 'warning', !parentNameservers.length || !registry.nameservers.length
|
|
479
482
|
? 'Comparison is incomplete'
|
|
480
483
|
: sameSet(parentNameservers, registry.nameservers)
|
|
481
|
-
? '
|
|
482
|
-
: '
|
|
484
|
+
? 'Recursive observation and registry publication agree'
|
|
485
|
+
: 'Recursive observation and registry publication differ', `Recursive resolver: ${parentNameservers.join(', ') || 'unavailable'}. Registry publication: ${registry.nameservers.join(', ') || 'unavailable'}.`, 'Confirm the intended delegation with the registry and DNS operator before changing nameservers.'),
|
|
483
486
|
finding('authority_reachability', 'Direct nameserver reachability', lameAuthorities.length ? 'danger'
|
|
484
487
|
: partialAuthorities.length || unreachableAuthorities.length ? 'warning'
|
|
485
488
|
: successfulAuthorities.length ? 'healthy' : 'unknown', lameAuthorities.length
|
|
@@ -496,7 +499,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
496
499
|
? 'No direct authority answer was available'
|
|
497
500
|
: inconsistentAuthoritySets
|
|
498
501
|
? 'Direct nameserver answers are inconsistent'
|
|
499
|
-
: 'Direct nameserver answers agree with the
|
|
502
|
+
: 'Direct nameserver answers agree with the recursive observation', successfulAuthorities.map((authority) => `${authority.nameserver}: ${authority.nameservers.join(', ') || 'no NS answer'}`).join(' | ') || 'No direct answers.', 'Align the NS set served by every authority with the intended parent delegation before cutover.'),
|
|
500
503
|
finding('authority_soa_consistency', 'Authoritative SOA consistency', retainedSoa.length < 2 || soaSerials.length < 1 || soaPrimaries.length < 1
|
|
501
504
|
? 'unknown'
|
|
502
505
|
: soaSerialConflict || soaPrimaryConflict ? 'warning' : 'healthy', retainedSoa.length < 2
|
|
@@ -506,11 +509,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
506
509
|
: soaPrimaryConflict
|
|
507
510
|
? 'Authoritative servers published different SOA primary nameservers'
|
|
508
511
|
: 'Observed authority SOA serials and primary nameservers agree', retainedSoa.map((entry) => (`${entry.nameserver}: serial ${entry.soa.serial ?? 'unavailable'}, primary ${entry.soa.nsname ?? 'unavailable'}, refresh ${entry.soa.refresh ?? 'unavailable'}, retry ${entry.soa.retry ?? 'unavailable'}, expire ${entry.soa.expire ?? 'unavailable'}`)).join(' | ') || 'No complete SOA observation was available.', 'Confirm zone propagation and the intended SOA primary before relying on a DNS change or secondary-server state.'),
|
|
509
|
-
finding('in_bailiwick_glue', 'In-bailiwick glue', !
|
|
510
|
-
? '
|
|
511
|
-
:
|
|
512
|
-
? '
|
|
513
|
-
:
|
|
512
|
+
finding('in_bailiwick_glue', 'In-bailiwick glue', !glueInventoryComplete || unobservedGlue.length ? 'unknown' : 'healthy', !glueInventoryComplete
|
|
513
|
+
? 'The delegated nameserver inventory is unavailable or incomplete'
|
|
514
|
+
: !inBailiwick.length
|
|
515
|
+
? 'No in-bailiwick nameserver requires registry glue'
|
|
516
|
+
: unobservedGlue.length
|
|
517
|
+
? 'Registry address publication does not establish glue for every in-bailiwick nameserver'
|
|
518
|
+
: 'Registry publication includes observed glue for each in-bailiwick nameserver', unobservedGlue.length ? `No address publication retained for: ${unobservedGlue.join(', ')}. Optional RDAP address fields and recursive NS answers do not establish that parent-zone glue is absent.` : `In-bailiwick nameservers: ${inBailiwick.join(', ') || 'none'}.`, 'Verify parent-zone glue with the registry or DNS operator before deciding whether an update is needed.'),
|
|
514
519
|
finding('dnssec_delegation', 'DNSSEC delegation publication', dnssecMismatch ? 'warning'
|
|
515
520
|
: registry.delegationSigned === null && registry.dsRecordCount === 0 ? 'unknown' : 'healthy', dnssecMismatch
|
|
516
521
|
? 'Registry DNSSEC fields are internally inconsistent'
|
|
@@ -530,6 +535,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
530
535
|
];
|
|
531
536
|
const collectionIncomplete = parentStatus !== 'success'
|
|
532
537
|
|| candidates.length === 0
|
|
538
|
+
|| omittedAuthorityCount > 0
|
|
533
539
|
|| parentQuery.truncated === true
|
|
534
540
|
|| registry.truncated
|
|
535
541
|
|| authorities.some((authority) => authority.state !== 'success')
|
|
@@ -540,6 +546,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
540
546
|
const observedAt = (options.observedAt || (() => new Date().toISOString()))();
|
|
541
547
|
return {
|
|
542
548
|
delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
|
|
549
|
+
domain,
|
|
543
550
|
...createObservation({
|
|
544
551
|
status,
|
|
545
552
|
observedAt,
|
|
@@ -549,9 +556,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
549
556
|
complete: !collectionIncomplete,
|
|
550
557
|
truncated: parentQuery.truncated === true
|
|
551
558
|
|| registry.truncated
|
|
559
|
+
|| omittedAuthorityCount > 0
|
|
552
560
|
|| (recordCollectionAttempted && recordMatrix.some((row) => row.observations.some((item) => item.truncated))),
|
|
553
561
|
limitations: [
|
|
554
|
-
'The
|
|
562
|
+
'The initial nameserver set comes from a recursive resolver, not a direct parent-server query; registry nameservers and glue come from the separately attributed RDAP publication.',
|
|
563
|
+
...(omittedAuthorityCount > 0
|
|
564
|
+
? [`${candidates.length} of ${eligibleAuthorities.length} eligible nameservers were selected; ${omittedAuthorityCount} were not queried.`]
|
|
565
|
+
: []),
|
|
555
566
|
'Direct queries use at most four selected nameservers and two validated public addresses per nameserver.',
|
|
556
567
|
'Extended A, AAAA, CAA, and MX comparison uses only one selected validated public address per nameserver and caps each record set at sixteen values.',
|
|
557
568
|
'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
|
|
@@ -563,6 +574,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
563
574
|
],
|
|
564
575
|
diagnostics: {
|
|
565
576
|
authorityCount: authorities.length,
|
|
577
|
+
eligibleAuthorityCount: eligibleAuthorities.length,
|
|
578
|
+
queriedAuthorityCount: authorities.filter((authority) => authority.attempts.length > 0).length,
|
|
579
|
+
omittedAuthorityCount,
|
|
566
580
|
queriedAddressCount: authorities.reduce((sum, authority) => sum + authority.attempts.length, 0),
|
|
567
581
|
successfulAuthorityCount: successfulAuthorities.length,
|
|
568
582
|
partialAuthorityCount: partialAuthorities.length,
|
|
@@ -576,7 +590,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
576
590
|
? 'The bounded delegation-health collection completed.'
|
|
577
591
|
: status === 'partial'
|
|
578
592
|
? 'The delegation-health collection is partial; review each source state before changing DNS.'
|
|
579
|
-
: 'The delegation-health collection could not confirm a
|
|
593
|
+
: 'The delegation-health collection could not confirm a recursive or direct authority observation.',
|
|
580
594
|
parent: {
|
|
581
595
|
state: parentStatus,
|
|
582
596
|
nameservers: parentNameservers,
|