@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { promises as dns } from 'node:dns';
|
|
2
|
+
import { parse as parseDomain } from 'tldts';
|
|
3
|
+
import { validateDmarcExternalReporting } from "./domain-posture-analysis.mjs";
|
|
4
|
+
import { requireDmarcDomain, walkDmarcTree } from "./dmarc-discovery.mjs";
|
|
5
|
+
import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
|
|
6
|
+
import { defaultTcpExchange } from "./service-binding-dns.mjs";
|
|
7
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
8
|
+
const MAX_PARENT_SERVERS = 2;
|
|
9
|
+
const MAX_NS = 16;
|
|
10
|
+
const QUERY_TIMEOUT_MS = 2200;
|
|
11
|
+
const REVIEW_TIMEOUT_MS = 10_000;
|
|
12
|
+
export const MAX_DMARC_REVIEW_QUERIES = 32;
|
|
13
|
+
const MISSING = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
|
|
14
|
+
/** Explicit query parameter; malformed opt-ins never broaden collection. */
|
|
15
|
+
export function parseInheritedDnsSelection(value) {
|
|
16
|
+
if (value === undefined || value === '')
|
|
17
|
+
return undefined;
|
|
18
|
+
if (value === '1')
|
|
19
|
+
return true;
|
|
20
|
+
throw new TypeError('includeInheritedDns must be 1 when requested.');
|
|
21
|
+
}
|
|
22
|
+
function check(id, label, status, summary, detail, records) {
|
|
23
|
+
return { id, label, status, summary, detail, records, remediation: '' };
|
|
24
|
+
}
|
|
25
|
+
/** RFC 9989 sections 4.10–4.10.2; at most eight owners including the exact name. */
|
|
26
|
+
export async function reviewInheritedDmarc(domain, exact, resolve) {
|
|
27
|
+
const { rows, available, boundary, incomplete, organisationalDomain } = await walkDmarcTree(domain, exact, resolve, 'policy');
|
|
28
|
+
const explicit = rows[0].valid ? rows[0] : null;
|
|
29
|
+
const selected = explicit ?? available.find(row => row.owner === organisationalDomain) ?? (boundary?.psd === 'y' ? boundary : null);
|
|
30
|
+
const summary = incomplete ? 'Inherited policy could not be determined from the collected records.'
|
|
31
|
+
: selected ? `${selected.owner === domain ? 'Exact-name' : 'Inherited'} policy published at _dmarc.${selected.owner}.`
|
|
32
|
+
: 'No applicable DMARC policy was found in the completed tree walk.';
|
|
33
|
+
const detail = !incomplete && selected
|
|
34
|
+
? selected.owner === domain
|
|
35
|
+
? `Published policy: ${selected.parsed.policy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}.`
|
|
36
|
+
: `For an existing name: ${selected.parsed.subdomainPolicy}. For a nonexistent name: ${selected.parsed.nonexistentSubdomainPolicy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}. Name existence is not inferred from this review.`
|
|
37
|
+
: 'Invalid or unavailable records do not establish absence or a usable inherited policy.';
|
|
38
|
+
return check('dmarc_inheritance', 'Inherited DMARC policy', incomplete || !selected ? 'warning' : 'info', summary, `${detail} Discovery follows RFC 9989 with at most eight owners. This is publication evidence, not message authentication or a guarantee of receiver behaviour.`, rows.map(row => row.record));
|
|
39
|
+
}
|
|
40
|
+
function nameservers(value) {
|
|
41
|
+
if (!Array.isArray(value) || value.length > MAX_NS)
|
|
42
|
+
return null;
|
|
43
|
+
const names = value.map(raw => typeof raw === 'string' && raw.length <= 254 ? raw.toLowerCase().replace(/\.$/u, '') : '');
|
|
44
|
+
return names.every(name => name.length <= 253 && isValidAsciiHostname(name)) ? [...new Set(names)].sort() : null;
|
|
45
|
+
}
|
|
46
|
+
export async function reviewParentDelegation(domain, recursive, dependencies, signal) {
|
|
47
|
+
requireDmarcDomain(domain);
|
|
48
|
+
const parsed = parseDomain(domain, { allowPrivateDomains: false });
|
|
49
|
+
const child = parsed.domain;
|
|
50
|
+
const parent = parsed.publicSuffix;
|
|
51
|
+
const records = [];
|
|
52
|
+
let referrals = 0;
|
|
53
|
+
let differs = false;
|
|
54
|
+
let parentDiffers = false;
|
|
55
|
+
let firstReferral = null;
|
|
56
|
+
let incomplete = false;
|
|
57
|
+
const observed = recursive.error || domain !== child ? null : nameservers(recursive.records);
|
|
58
|
+
const finish = () => check('parent_delegation', 'Direct parent delegation', differs || parentDiffers || incomplete || !referrals ? 'warning' : 'info', parentDiffers ? 'Sampled parent servers returned different delegation referrals.'
|
|
59
|
+
: differs ? 'Parent referral and recursive nameserver observations differ.'
|
|
60
|
+
: referrals ? `${referrals} parent server${referrals === 1 ? '' : 's'} returned a delegation referral for ${child}.`
|
|
61
|
+
: 'No direct parent referral was established.', `Registration-domain scope: ${child ?? 'unavailable'}; parent candidate: ${parent ?? 'unavailable'}. At most two discovered parent servers are queried over pinned public-address DNS/TCP, without recursion. This is a sample, not full delegation or DNSSEC validation.${incomplete ? ' Some discovery or direct observations were unavailable or incomplete.' : ''}${!observed ? ' No complete same-scope recursive comparison is available.' : ''}`, records);
|
|
62
|
+
if (!child || !parent || child === parent)
|
|
63
|
+
return finish();
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
let discovered;
|
|
66
|
+
try {
|
|
67
|
+
discovered = nameservers(await dependencies.resolveNs(parent));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
incomplete = true;
|
|
72
|
+
return finish();
|
|
73
|
+
}
|
|
74
|
+
if (!discovered?.length) {
|
|
75
|
+
incomplete = true;
|
|
76
|
+
return finish();
|
|
77
|
+
}
|
|
78
|
+
records.push(`Recursive DNS NS ${parent} · ${dependencies.now().toISOString()} · ${discovered.length} discovered; ${Math.min(MAX_PARENT_SERVERS, discovered.length)} selected.`);
|
|
79
|
+
for (const server of discovered.slice(0, MAX_PARENT_SERVERS)) {
|
|
80
|
+
signal?.throwIfAborted();
|
|
81
|
+
const resolutions = await Promise.allSettled([dependencies.resolve4(server), dependencies.resolve6(server)]);
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
const failed = resolutions.some(result => result.status === 'fulfilled'
|
|
84
|
+
? !Array.isArray(result.value) || result.value.length > 16
|
|
85
|
+
: !MISSING.has(String(result.reason?.code)));
|
|
86
|
+
if (failed) {
|
|
87
|
+
incomplete = true;
|
|
88
|
+
records.push(`${server}: public-address discovery unavailable or rejected.`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const addresses = resolutions.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
|
92
|
+
const endpoints = addresses.map(normalizeResolverEndpoint);
|
|
93
|
+
if (!addresses.length || endpoints.some(endpoint => !endpoint)) {
|
|
94
|
+
incomplete = true;
|
|
95
|
+
records.push(`${server}: public-address discovery unavailable or rejected.`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const endpoint = endpoints.filter((value) => value !== null)
|
|
99
|
+
.sort((a, b) => a.address < b.address ? -1 : a.address > b.address ? 1 : 0)[0];
|
|
100
|
+
const query = buildDnssecQuery(child, DNS_TYPE_NS);
|
|
101
|
+
query.writeUInt16BE(0, 2); // No recursion or authenticated-data assertion is requested.
|
|
102
|
+
try {
|
|
103
|
+
const responseBytes = await dependencies.exchange(query, endpoint, { timeoutMs: QUERY_TIMEOUT_MS, ...(signal ? { signal } : {}) });
|
|
104
|
+
signal?.throwIfAborted();
|
|
105
|
+
if (responseBytes.byteLength > 65_535)
|
|
106
|
+
throw new RangeError('The DNS response exceeds the wire limit.');
|
|
107
|
+
const response = parseDnssecResponse(responseBytes, { transactionId: query.readUInt16BE(0), name: child, type: DNS_TYPE_NS });
|
|
108
|
+
const referred = nameservers(response.records.filter(row => row.section === 'authority' && row.owner === child && row.type === DNS_TYPE_NS && row.class === 1)
|
|
109
|
+
.flatMap(row => row.data.kind === 'NS' ? [row.data.name] : []));
|
|
110
|
+
records.push(`Direct DNS/TCP ${server} [${endpoint.address}] · ${dependencies.now().toISOString()} · ${child} NS · response ${response.rcode}.`);
|
|
111
|
+
if (response.rcode !== 0 || !referred?.length) {
|
|
112
|
+
incomplete = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
referrals += 1;
|
|
116
|
+
if (firstReferral && JSON.stringify(firstReferral) !== JSON.stringify(referred))
|
|
117
|
+
parentDiffers = true;
|
|
118
|
+
firstReferral ??= referred;
|
|
119
|
+
for (const name of referred)
|
|
120
|
+
records.push(`${server} authority section: ${child} NS ${name}`);
|
|
121
|
+
if (observed && JSON.stringify(observed) !== JSON.stringify(referred))
|
|
122
|
+
differs = true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
signal?.throwIfAborted();
|
|
126
|
+
incomplete = true;
|
|
127
|
+
records.push(`${server}: bounded direct referral query failed.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return finish();
|
|
131
|
+
}
|
|
132
|
+
export async function collectDnsInheritanceReview(domain, exact, recursive, options = {}, injected) {
|
|
133
|
+
requireDmarcDomain(domain);
|
|
134
|
+
options.signal?.throwIfAborted();
|
|
135
|
+
const timeout = AbortSignal.timeout(REVIEW_TIMEOUT_MS);
|
|
136
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
137
|
+
const resolver = injected ? null : new dns.Resolver({ timeout: QUERY_TIMEOUT_MS, tries: 1 });
|
|
138
|
+
const cancel = () => resolver?.cancel();
|
|
139
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
140
|
+
const dependencies = injected ?? {
|
|
141
|
+
resolveTxt: (name) => resolver.resolveTxt(name), resolveNs: (name) => resolver.resolveNs(name),
|
|
142
|
+
resolve4: (name) => resolver.resolve4(name), resolve6: (name) => resolver.resolve6(name),
|
|
143
|
+
exchange: defaultTcpExchange, now: () => new Date(),
|
|
144
|
+
};
|
|
145
|
+
let questions = 0;
|
|
146
|
+
const cache = new Map([[`_dmarc.${domain}`, Promise.resolve(exact)]]);
|
|
147
|
+
const query = async (owner) => {
|
|
148
|
+
signal.throwIfAborted();
|
|
149
|
+
if (owner.length > 253)
|
|
150
|
+
return { records: [], error: 'The DNS question exceeds the name limit.' };
|
|
151
|
+
questions += 1;
|
|
152
|
+
try {
|
|
153
|
+
return { records: await dependencies.resolveTxt(owner), error: null, observedAt: dependencies.now().toISOString() };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
options.signal?.throwIfAborted();
|
|
157
|
+
return { records: [], error: MISSING.has(String(error?.code)) ? null : 'DNS observation unavailable.', observedAt: dependencies.now().toISOString() };
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const resolve = (owner) => {
|
|
161
|
+
options.signal?.throwIfAborted();
|
|
162
|
+
const existing = cache.get(owner);
|
|
163
|
+
if (existing)
|
|
164
|
+
return existing;
|
|
165
|
+
if (questions >= MAX_DMARC_REVIEW_QUERIES)
|
|
166
|
+
return Promise.resolve({ records: [], error: 'The shared DMARC query budget was reached.' });
|
|
167
|
+
const pending = query(owner);
|
|
168
|
+
cache.set(owner, pending);
|
|
169
|
+
return pending;
|
|
170
|
+
};
|
|
171
|
+
const organisations = new Map();
|
|
172
|
+
const boundaryRecords = new Map();
|
|
173
|
+
const boundaryResults = new Map();
|
|
174
|
+
const resolveOrganisationalDomain = (owner) => {
|
|
175
|
+
const existing = organisations.get(owner);
|
|
176
|
+
if (existing)
|
|
177
|
+
return existing;
|
|
178
|
+
const pending = (async () => {
|
|
179
|
+
const walk = await walkDmarcTree(owner, await resolve(`_dmarc.${owner}`), resolve, 'organisation');
|
|
180
|
+
for (const row of walk.rows)
|
|
181
|
+
if (cache.has(`_dmarc.${row.owner}`))
|
|
182
|
+
boundaryRecords.set(row.owner, row.record);
|
|
183
|
+
boundaryResults.set(owner, `${owner}: organisational domain ${walk.organisationalDomain ?? 'unknown'}.`);
|
|
184
|
+
return walk.organisationalDomain;
|
|
185
|
+
})();
|
|
186
|
+
organisations.set(owner, pending);
|
|
187
|
+
return pending;
|
|
188
|
+
};
|
|
189
|
+
const operations = [
|
|
190
|
+
reviewInheritedDmarc(domain, exact, resolve),
|
|
191
|
+
reviewParentDelegation(domain, recursive, dependencies, signal),
|
|
192
|
+
validateDmarcExternalReporting(domain, exact, resolve, resolveOrganisationalDomain),
|
|
193
|
+
];
|
|
194
|
+
try {
|
|
195
|
+
const [policy, parent, reporting] = await Promise.allSettled(operations);
|
|
196
|
+
options.signal?.throwIfAborted();
|
|
197
|
+
const checks = [policy, parent].map((result, index) => result.status === 'fulfilled' ? result.value : check(index === 0 ? 'dmarc_inheritance' : 'parent_delegation', index === 0 ? 'Inherited DMARC policy' : 'Direct parent delegation', 'warning', 'The additional review could not complete.', 'No absence or effective-policy conclusion is available from the incomplete review.', []));
|
|
198
|
+
const dmarcAuthorizations = reporting.status === 'fulfilled' ? reporting.value : [];
|
|
199
|
+
if (organisations.size)
|
|
200
|
+
checks.push(check('dmarc_reporting_boundaries', 'DMARC reporting boundaries', reporting.status === 'rejected' || dmarcAuthorizations.some(item => !['self', 'authorized'].includes(item.state)) ? 'warning' : 'info', 'Reporting destinations were reviewed using DNS-derived organisational boundaries.', `${questions} additional DMARC TXT questions used; the shared limit is ${MAX_DMARC_REVIEW_QUERIES} within ten seconds. Each tree walk examines at most eight owners. Unavailable boundaries remain unknown; a missing optional authorisation record does not establish a configuration error.`, [...boundaryResults.values(), ...boundaryRecords.values()]));
|
|
201
|
+
return { checks, dmarcAuthorizations };
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
signal.removeEventListener('abort', cancel);
|
|
205
|
+
cancel();
|
|
206
|
+
}
|
|
207
|
+
}
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// Bounded DNS evidence for investigative deep scans. This collector keeps
|
|
2
|
-
//
|
|
2
|
+
// resolver-reported negative answers distinct from failures, preserves only record
|
|
3
3
|
// types that aid domain triage, and never treats shared infrastructure as proof
|
|
4
4
|
// of common ownership or maliciousness.
|
|
5
5
|
import { promises as dns } from 'node:dns';
|
|
6
6
|
import * as net from 'node:net';
|
|
7
7
|
import { classifyMxRecords } from "./dns-mx.mjs";
|
|
8
|
+
import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
|
|
8
9
|
import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
|
|
9
10
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
10
11
|
import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
|
|
11
12
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
12
13
|
import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
|
|
14
|
+
import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
|
|
15
|
+
import { DNS_QUERY_OUTCOMES, dnsQueryFailureOutcome } from "../packages/evidence/dns-query-outcome.mjs";
|
|
13
16
|
const DNS_TIMEOUT_MS = 5000;
|
|
14
17
|
const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
|
|
15
18
|
const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
|
|
@@ -22,7 +25,6 @@ const MAX_SERVICE_ALPN_IDS = 16;
|
|
|
22
25
|
const MAX_SERVICE_ADDRESS_HINTS = 8;
|
|
23
26
|
const MAX_CAA_TREE_QUERIES = 8;
|
|
24
27
|
const DNS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
|
|
25
|
-
const MISSING_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
|
|
26
28
|
function skippedDnsIntelligence(detail = 'DNS intelligence is disabled by deployment policy.', { includeExtendedContext = false, includeInheritedCaa = false, } = {}) {
|
|
27
29
|
const skipped = { status: 'skipped', error: null, truncated: false, discarded: 0 };
|
|
28
30
|
const recordTypes = ['a', 'aaaa', 'cname', 'ns', 'mx', 'spf', 'dmarc', 'caa'];
|
|
@@ -123,7 +125,7 @@ function boundedUnique(values, limit = MAX_RECORDS_PER_TYPE) {
|
|
|
123
125
|
}
|
|
124
126
|
function normalizeAddresses(records, family) {
|
|
125
127
|
const values = [];
|
|
126
|
-
let discarded = 0;
|
|
128
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
127
129
|
for (const record of Array.isArray(records) ? records : []) {
|
|
128
130
|
const value = typeof record === 'string' ? record : record && typeof record === 'object' ? record.address : null;
|
|
129
131
|
if (typeof value !== 'string' || net.isIP(value) !== family)
|
|
@@ -135,7 +137,7 @@ function normalizeAddresses(records, family) {
|
|
|
135
137
|
}
|
|
136
138
|
function normalizeHostnames(records) {
|
|
137
139
|
const values = [];
|
|
138
|
-
let discarded = 0;
|
|
140
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
139
141
|
for (const record of Array.isArray(records) ? records : []) {
|
|
140
142
|
const value = normalizeHostname(record);
|
|
141
143
|
if (value)
|
|
@@ -147,19 +149,15 @@ function normalizeHostnames(records) {
|
|
|
147
149
|
}
|
|
148
150
|
function normalizeMx(records) {
|
|
149
151
|
const byKey = new Map();
|
|
150
|
-
let discarded = 0;
|
|
152
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
151
153
|
for (const record of Array.isArray(records) ? records : []) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
const value = record;
|
|
157
|
-
const exchange = value.exchange === '' || value.exchange === '.' ? '' : normalizeHostname(value.exchange);
|
|
158
|
-
const priority = Number(value.priority);
|
|
159
|
-
if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65535) {
|
|
154
|
+
const value = normalizeResolverMxRecord(record);
|
|
155
|
+
if (!value) {
|
|
160
156
|
discarded += 1;
|
|
161
157
|
continue;
|
|
162
158
|
}
|
|
159
|
+
const { priority } = value;
|
|
160
|
+
const exchange = value.exchange === '.' ? '' : value.exchange;
|
|
163
161
|
byKey.set(`${priority}:${exchange}`, { priority, exchange });
|
|
164
162
|
}
|
|
165
163
|
const values = [...byKey.values()].sort((a, b) => a.priority - b.priority || a.exchange.localeCompare(b.exchange));
|
|
@@ -167,7 +165,7 @@ function normalizeMx(records) {
|
|
|
167
165
|
}
|
|
168
166
|
function normalizeTxtPolicies(records, prefix) {
|
|
169
167
|
const values = [];
|
|
170
|
-
let discarded = 0;
|
|
168
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
171
169
|
for (const chunks of Array.isArray(records) ? records : []) {
|
|
172
170
|
if (!Array.isArray(chunks) || chunks.some((chunk) => typeof chunk !== 'string')) {
|
|
173
171
|
discarded += 1;
|
|
@@ -185,7 +183,7 @@ function normalizeTxtPolicies(records, prefix) {
|
|
|
185
183
|
}
|
|
186
184
|
function normalizeCaa(records) {
|
|
187
185
|
const byKey = new Map();
|
|
188
|
-
let discarded = 0;
|
|
186
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
189
187
|
for (const record of Array.isArray(records) ? records : []) {
|
|
190
188
|
const entry = record && typeof record === 'object' ? record : {};
|
|
191
189
|
const critical = Number(entry.critical);
|
|
@@ -388,27 +386,35 @@ function normalizePtr(records) {
|
|
|
388
386
|
}
|
|
389
387
|
function withTimeout(factory, timeoutMs) {
|
|
390
388
|
return new Promise((resolve, reject) => {
|
|
391
|
-
const timer = setTimeout(() => reject(new Error('DNS query timed out')), timeoutMs);
|
|
389
|
+
const timer = setTimeout(() => reject(Object.assign(new Error('DNS query timed out'), { code: 'ETIMEOUT' })), timeoutMs);
|
|
392
390
|
Promise.resolve().then(factory).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
393
391
|
});
|
|
394
392
|
}
|
|
395
393
|
async function query(factory, normalize, timeoutMs) {
|
|
396
394
|
try {
|
|
397
395
|
const normalized = normalize(await withTimeout(factory, timeoutMs));
|
|
396
|
+
const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
|
|
398
397
|
return {
|
|
399
|
-
status: normalized.records.length ? 'success' : 'not_found',
|
|
398
|
+
status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
|
|
399
|
+
detail: unusable ? 'invalid_response' : normalized.records.length ? 'records' : 'empty_answer',
|
|
400
400
|
records: normalized.records,
|
|
401
|
-
error: null,
|
|
401
|
+
error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
|
|
402
402
|
truncated: normalized.truncated,
|
|
403
403
|
discarded: normalized.discarded || 0,
|
|
404
404
|
};
|
|
405
405
|
}
|
|
406
406
|
catch (error) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
|
|
407
|
+
const detail = dnsQueryFailureOutcome(error && typeof error === 'object' ? error.code : undefined);
|
|
408
|
+
const missing = DNS_QUERY_OUTCOMES[detail].status === 'not_found';
|
|
409
|
+
return { status: missing ? 'not_found' : 'error', detail, records: [], error: missing ? null : boundedError(error), truncated: false, discarded: 0 };
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
|
+
function completeQuery(result) {
|
|
413
|
+
return result.status !== 'error' && !result.truncated && result.discarded === 0;
|
|
414
|
+
}
|
|
415
|
+
function observedPresence(result) {
|
|
416
|
+
return result.records.length > 0 ? true : completeQuery(result) ? false : null;
|
|
417
|
+
}
|
|
412
418
|
async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
413
419
|
const normalizedDomain = normalizeHostname(domain);
|
|
414
420
|
const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
|
|
@@ -459,7 +465,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
459
465
|
truncated: result.truncated,
|
|
460
466
|
discarded: result.discarded,
|
|
461
467
|
});
|
|
462
|
-
if (result.status === 'error') {
|
|
468
|
+
if (result.status === 'error' && !result.truncated && result.discarded === 0) {
|
|
463
469
|
return {
|
|
464
470
|
...createObservation({
|
|
465
471
|
status: index === 0 ? 'error' : 'partial',
|
|
@@ -576,6 +582,10 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
576
582
|
};
|
|
577
583
|
}
|
|
578
584
|
async function collectDnsIntelligence(domain, options = {}) {
|
|
585
|
+
const registrationDomain = options.registrationDomain ?? domain;
|
|
586
|
+
if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
|
|
587
|
+
throw new TypeError('DNS registration context must match the hostname being observed.');
|
|
588
|
+
}
|
|
579
589
|
const resolvers = options.resolvers || {};
|
|
580
590
|
const includeExtendedContext = options.includeExtendedContext === true;
|
|
581
591
|
const includeInheritedCaa = options.includeInheritedCaa === true;
|
|
@@ -606,8 +616,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
606
616
|
const httpsPromise = includeExtendedContext
|
|
607
617
|
? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
|
|
608
618
|
: Promise.resolve(null);
|
|
619
|
+
const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
|
|
620
|
+
? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
|
|
621
|
+
: nsPromise;
|
|
609
622
|
const delegationPromise = includeExtendedContext
|
|
610
|
-
?
|
|
623
|
+
? registrationNsPromise.then((parentNameservers) => collectDnsDelegationHealth(registrationDomain, parentNameservers, {
|
|
611
624
|
registryEvidence: options.registryEvidence,
|
|
612
625
|
resolve4: (resolvers.resolve4 || dns.resolve4),
|
|
613
626
|
resolve6: (resolvers.resolve6 || dns.resolve6),
|
|
@@ -645,7 +658,8 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
645
658
|
|| discardedCount > 0
|
|
646
659
|
|| (caaPolicy !== null && caaPolicy.complete !== true)
|
|
647
660
|
|| (delegation !== null && delegation.status !== 'success');
|
|
648
|
-
const classifiedMx =
|
|
661
|
+
const classifiedMx = classifyMxRecords(mx.records);
|
|
662
|
+
const mxComplete = completeQuery(mx);
|
|
649
663
|
return {
|
|
650
664
|
...createObservation({
|
|
651
665
|
status: errorCount === values.length ? 'error' : incomplete ? 'partial' : 'success',
|
|
@@ -670,6 +684,7 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
670
684
|
diagnostics: {
|
|
671
685
|
...Object.fromEntries(Object.entries(queries).map(([name, item]) => [name, {
|
|
672
686
|
status: item.status,
|
|
687
|
+
detail: item.detail,
|
|
673
688
|
error: item.error,
|
|
674
689
|
truncated: item.truncated,
|
|
675
690
|
discarded: item.discarded,
|
|
@@ -703,11 +718,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
703
718
|
...(soa ? { soa: soa.records } : {}),
|
|
704
719
|
...(https ? { https: https.records } : {}),
|
|
705
720
|
},
|
|
706
|
-
hasMx: classifiedMx ?
|
|
707
|
-
hasNullMx:
|
|
708
|
-
mxHosts: classifiedMx
|
|
709
|
-
hasSpf: spf
|
|
710
|
-
hasDmarc: dmarc
|
|
721
|
+
hasMx: classifiedMx.hasMx ? true : mxComplete ? false : null,
|
|
722
|
+
hasNullMx: mxComplete ? classifiedMx.hasNullMx : null,
|
|
723
|
+
mxHosts: classifiedMx.mxHosts,
|
|
724
|
+
hasSpf: observedPresence(spf),
|
|
725
|
+
hasDmarc: observedPresence(dmarc),
|
|
711
726
|
delegation,
|
|
712
727
|
...(caaPolicy ? { caaPolicy } : {}),
|
|
713
728
|
};
|
package/lib/dns-mx.mjs
CHANGED
|
@@ -14,7 +14,7 @@ function classifyMxRecords(records) {
|
|
|
14
14
|
// of configuration. Not currently used for scoring (both cases already
|
|
15
15
|
// correctly score as hasMx: false); exposed for callers that want to tell
|
|
16
16
|
// the two apart.
|
|
17
|
-
const hasNullMx = records.length
|
|
17
|
+
const hasNullMx = records.length === 1 && records[0]?.priority === 0 && realRecords.length === 0;
|
|
18
18
|
return {
|
|
19
19
|
hasMx: realRecords.length > 0,
|
|
20
20
|
hasNullMx,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { exactKeys, requireRecord } from "./bounded-contract-normalizers.mjs";
|
|
2
|
+
const QUERY_KEYS = new Set(['owner', 'type', 'state']);
|
|
3
|
+
/** A positive legacy record proves its own query scope, never another type's absence. */
|
|
4
|
+
export function normaliseDnsQueryCoverage(value, options) {
|
|
5
|
+
const { label, maximum, state, records } = options;
|
|
6
|
+
if (options.legacy) {
|
|
7
|
+
return Object.freeze([...new Map(records.map((record) => [
|
|
8
|
+
`${record.owner}\u0000${record.type}`,
|
|
9
|
+
Object.freeze({ owner: record.owner, type: record.type, state }),
|
|
10
|
+
])).values()]);
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(value) || value.length > maximum)
|
|
13
|
+
throw new TypeError(`${label} must contain no more than ${maximum} query outcomes.`);
|
|
14
|
+
const byKey = new Map();
|
|
15
|
+
for (const [index, raw] of value.entries()) {
|
|
16
|
+
const path = `${label}[${index}]`;
|
|
17
|
+
const item = requireRecord(raw, path);
|
|
18
|
+
exactKeys(item, QUERY_KEYS, path);
|
|
19
|
+
const owner = options.owner(item.owner, `${path}.owner`);
|
|
20
|
+
const type = options.type(item.type, `${path}.type`);
|
|
21
|
+
if (item.state !== 'observed' && item.state !== 'partial' && item.state !== 'unavailable')
|
|
22
|
+
throw new TypeError(`${path}.state is unsupported.`);
|
|
23
|
+
if ((state === 'unavailable' && item.state !== 'unavailable') || (state === 'observed' && item.state !== 'observed')) {
|
|
24
|
+
throw new TypeError(`${path}.state contradicts the snapshot state.`);
|
|
25
|
+
}
|
|
26
|
+
const key = `${owner}\u0000${type}`;
|
|
27
|
+
if (byKey.has(key))
|
|
28
|
+
throw new TypeError(`${path} duplicates an owner and type.`);
|
|
29
|
+
byKey.set(key, Object.freeze({ owner, type, state: item.state }));
|
|
30
|
+
}
|
|
31
|
+
for (const record of records) {
|
|
32
|
+
const query = byKey.get(`${record.owner}\u0000${record.type}`);
|
|
33
|
+
if (!query || query.state === 'unavailable')
|
|
34
|
+
throw new TypeError(`${label} must declare an observed or partial query for every supplied record.`);
|
|
35
|
+
}
|
|
36
|
+
return Object.freeze([...byKey.values()]);
|
|
37
|
+
}
|
|
38
|
+
export function dnsQueryState(queries, owner, type) {
|
|
39
|
+
return queries.find((query) => query.owner === owner && query.type === type)?.state ?? 'not_queried';
|
|
40
|
+
}
|
|
@@ -130,6 +130,9 @@ function signatureTimeState(value, observedAt) {
|
|
|
130
130
|
}
|
|
131
131
|
function validateDnssecEvidence(input) {
|
|
132
132
|
const normalizedOwner = ownerName(input.ownerName);
|
|
133
|
+
const missingDs = input.dsRecords === undefined;
|
|
134
|
+
const malformedContainers = [input.dsRecords, input.dnskeyRecords, input.rrSigRecords]
|
|
135
|
+
.filter((value) => value !== undefined && !Array.isArray(value)).length;
|
|
133
136
|
const rawDs = Array.isArray(input.dsRecords) ? input.dsRecords : [];
|
|
134
137
|
const rawDnskeys = Array.isArray(input.dnskeyRecords) ? input.dnskeyRecords : [];
|
|
135
138
|
const rawRrsigs = Array.isArray(input.rrSigRecords) ? input.rrSigRecords : [];
|
|
@@ -138,7 +141,8 @@ function validateDnssecEvidence(input) {
|
|
|
138
141
|
const rrsigs = rawRrsigs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeRrsig).filter((item) => item !== null);
|
|
139
142
|
const rejectedCount = Math.min(rawDs.length, MAX_DNSSEC_RECORDS) - dsRecords.length
|
|
140
143
|
+ Math.min(rawDnskeys.length, MAX_DNSSEC_RECORDS) - dnskeys.length
|
|
141
|
-
+ Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length
|
|
144
|
+
+ Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length
|
|
145
|
+
+ malformedContainers;
|
|
142
146
|
const truncated = rawDs.length > MAX_DNSSEC_RECORDS
|
|
143
147
|
|| rawDnskeys.length > MAX_DNSSEC_RECORDS
|
|
144
148
|
|| rawRrsigs.length > MAX_DNSSEC_RECORDS;
|
|
@@ -166,7 +170,11 @@ function validateDnssecEvidence(input) {
|
|
|
166
170
|
limitations: Object.freeze(limitations),
|
|
167
171
|
});
|
|
168
172
|
}
|
|
169
|
-
const incompleteInput = rejectedCount > 0 || truncated;
|
|
173
|
+
const incompleteInput = missingDs || rejectedCount > 0 || truncated;
|
|
174
|
+
if (missingDs)
|
|
175
|
+
findings.push('No DS record collection was supplied. Omitted input is not a verified empty collection.');
|
|
176
|
+
if (malformedContainers)
|
|
177
|
+
findings.push(`${malformedContainers} record collection container(s) had an invalid type; their contents are unknown.`);
|
|
170
178
|
if (input.delegationSigned === false && dsRecords.length === 0 && !incompleteInput) {
|
|
171
179
|
findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
|
|
172
180
|
return Object.freeze({
|
|
@@ -217,7 +225,7 @@ function validateDnssecEvidence(input) {
|
|
|
217
225
|
findings.push('The retained subset contains no usable DS record, but omitted or rejected records prevent a definitive unsigned conclusion.');
|
|
218
226
|
}
|
|
219
227
|
if (rejectedCount > 0)
|
|
220
|
-
findings.push(`${rejectedCount} malformed record(s) were rejected.`);
|
|
228
|
+
findings.push(`${rejectedCount} malformed record(s) or collection container(s) were rejected.`);
|
|
221
229
|
if (truncated)
|
|
222
230
|
findings.push('The supplied record set exceeded the review bound and was truncated.');
|
|
223
231
|
const completeLocalRelationship = dsRecords.length > 0
|
package/lib/domain-assurance.mjs
CHANGED
|
@@ -243,7 +243,7 @@ function buildRecoveryDependencies(input) {
|
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
245
|
const RETIREMENT_CHECKS = Object.freeze([
|
|
246
|
-
['autoRenewDisabled', 'Auto-renew is
|
|
246
|
+
['autoRenewDisabled', 'Auto-renew is disabled as declared', true],
|
|
247
247
|
['registrarLockMaintained', 'Registrar lock remains enabled', true],
|
|
248
248
|
['websiteRetired', 'Web service is retired or intentionally redirected', true],
|
|
249
249
|
['mailRetired', 'Mail service is retired', true],
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
|
|
3
|
-
import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
|
|
3
|
+
import { DOMAIN_CHANGE_INPUT_SCHEMA, DOMAIN_CHANGE_REVIEW_VERSION, reviewDomainChange, } from "./domain-change-review.mjs";
|
|
4
4
|
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/artifact-integrity.mjs";
|
|
5
5
|
export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
|
|
6
6
|
export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
|
|
7
7
|
export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;
|
|
8
|
-
export const DOMAIN_CHANGE_PACKET_VERSION =
|
|
8
|
+
export const DOMAIN_CHANGE_PACKET_VERSION = 4;
|
|
9
|
+
export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
|
|
10
|
+
2: 1,
|
|
11
|
+
3: 2,
|
|
12
|
+
[DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
|
|
13
|
+
});
|
|
14
|
+
export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
|
|
9
15
|
export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
|
|
10
16
|
const ROOT_KEYS = new Set([
|
|
11
17
|
'schema',
|
|
@@ -38,16 +44,10 @@ function changeSummary(before, after) {
|
|
|
38
44
|
const beforeRows = rows(before);
|
|
39
45
|
const afterRows = rows(after);
|
|
40
46
|
const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
|
|
41
|
-
const
|
|
42
|
-
const representative = review.authoritativeRecordMatrix[0];
|
|
43
|
-
return Boolean(representative
|
|
44
|
-
&& representative.observations.length >= 2
|
|
45
|
-
&& representative.observations.every((item) => item.state === 'observed'));
|
|
46
|
-
};
|
|
47
|
-
const rowEvidence = (review, row) => {
|
|
47
|
+
const rowEvidence = (row) => {
|
|
48
48
|
if (!row)
|
|
49
49
|
return Object.freeze({
|
|
50
|
-
state:
|
|
50
|
+
state: 'unavailable',
|
|
51
51
|
values: Object.freeze([]),
|
|
52
52
|
});
|
|
53
53
|
if (row.observations.some((item) => item.state !== 'observed')) {
|
|
@@ -63,13 +63,15 @@ function changeSummary(before, after) {
|
|
|
63
63
|
});
|
|
64
64
|
};
|
|
65
65
|
const changed = [];
|
|
66
|
+
const reasons = keys.length > 500 ? ['The comparison exceeds 500 owner and record-type pairs.'] : [];
|
|
66
67
|
for (const key of keys.slice(0, 500)) {
|
|
67
68
|
const left = beforeRows.get(key);
|
|
68
69
|
const right = afterRows.get(key);
|
|
69
70
|
const [owner, type] = key.split('\u0000');
|
|
70
|
-
const beforeEvidence = rowEvidence(
|
|
71
|
-
const afterEvidence = rowEvidence(
|
|
71
|
+
const beforeEvidence = rowEvidence(left);
|
|
72
|
+
const afterEvidence = rowEvidence(right);
|
|
72
73
|
if (beforeEvidence.state !== 'complete' || afterEvidence.state !== 'complete') {
|
|
74
|
+
reasons.push(`Comparison evidence is incomplete for ${owner} ${type}.`);
|
|
73
75
|
continue;
|
|
74
76
|
}
|
|
75
77
|
if (JSON.stringify(beforeEvidence.values) === JSON.stringify(afterEvidence.values))
|
|
@@ -81,7 +83,7 @@ function changeSummary(before, after) {
|
|
|
81
83
|
afterValues: afterEvidence.values,
|
|
82
84
|
}));
|
|
83
85
|
}
|
|
84
|
-
return Object.freeze(changed);
|
|
86
|
+
return Object.freeze({ changed: Object.freeze(changed), reasons: Object.freeze(reasons) });
|
|
85
87
|
}
|
|
86
88
|
export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
87
89
|
const input = record(inputRaw, 'Domain change packet input');
|
|
@@ -102,11 +104,13 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
|
|
|
102
104
|
const preChange = reviewDomainChange(input.preChange, generatedAt);
|
|
103
105
|
const postChange = reviewDomainChange(input.postChange, generatedAt);
|
|
104
106
|
const assurance = buildDomainAssurance(input.assurance, generatedAt);
|
|
107
|
+
const comparison = changeSummary(preChange, postChange);
|
|
105
108
|
const assuranceReasons = assurance.result.review.reasons;
|
|
106
109
|
const gateReasons = Object.freeze([
|
|
107
110
|
...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
|
|
108
111
|
...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
|
|
109
112
|
...assuranceReasons.map((reason) => `Change plan: ${reason}`),
|
|
113
|
+
...comparison.reasons,
|
|
110
114
|
].slice(0, 100));
|
|
111
115
|
const unsigned = Object.freeze({
|
|
112
116
|
schema: DOMAIN_CHANGE_PACKET_SCHEMA,
|
|
@@ -117,7 +121,7 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
|
|
|
117
121
|
state: gateReasons.length ? 'review' : 'ready',
|
|
118
122
|
gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
|
|
119
123
|
summary: Object.freeze({
|
|
120
|
-
changedAuthoritativeRecordSets:
|
|
124
|
+
changedAuthoritativeRecordSets: comparison.changed,
|
|
121
125
|
preChangeState: preChange.state,
|
|
122
126
|
postChangeState: postChange.state,
|
|
123
127
|
assuranceState: assurance.result.review.state,
|