@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -1,43 +1,106 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { domainToASCII } from 'node:url';
|
|
3
|
-
import {
|
|
3
|
+
import { canonicalArtifactJsonFor, } from "../packages/evidence/artifact-integrity.mjs";
|
|
4
|
+
import { DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, } from "../packages/contracts/domain-control-manifest.mjs";
|
|
5
|
+
import { DOMAIN_CONTROL_REVIEW_FIELDS, DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, DOMAIN_CONTROL_REVIEW_COUNT_KEYS, DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_LIMITATIONS, DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS, DOMAIN_CONTROL_REVIEW_ROOT_KEYS, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, } from "../packages/contracts/domain-control-review.mjs";
|
|
6
|
+
import { assertDomainControlManifestByteBudget, buildUnsignedDomainControlManifest, canonicalDomainControlRecordList, normalizeDomainControlManifestDocument, } from "../packages/evidence/domain-control-runtime.mjs";
|
|
4
7
|
import { exactKeys } from "./bounded-contract-normalizers.mjs";
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
export const
|
|
8
|
-
export const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const OBSERVATION_KEYS = new Set(['domain', 'fields']);
|
|
19
|
-
const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
|
|
20
|
-
const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
|
|
21
|
-
const DOMAIN_CONTROL_LIMITATIONS = Object.freeze([
|
|
22
|
-
'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
|
|
23
|
-
'Empty desired fields are unconfigured rather than claims that a record should be absent.',
|
|
24
|
-
]);
|
|
8
|
+
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
9
|
+
export { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, };
|
|
10
|
+
export const MAX_DOMAIN_CONTROL_ENTRIES = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES;
|
|
11
|
+
export const MAX_DOMAIN_CONTROL_RECORDS = MAX_CANONICAL_DOMAIN_CONTROL_RECORDS;
|
|
12
|
+
const REVIEW_INPUT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_INPUT_KEYS);
|
|
13
|
+
const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS);
|
|
14
|
+
const OBSERVATION_FIELDS = new Set(DOMAIN_CONTROL_REVIEW_FIELDS);
|
|
15
|
+
const OBSERVATION_FIELD_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS);
|
|
16
|
+
const REVIEW_ROOT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_ROOT_KEYS);
|
|
17
|
+
const REVIEW_MANIFEST_KEYS = new Set(DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS);
|
|
18
|
+
const REVIEW_COUNT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_COUNT_KEYS);
|
|
19
|
+
const REVIEW_DOMAIN_KEYS = new Set(DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS);
|
|
20
|
+
const REVIEW_COMPARISON_KEYS = new Set(DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS);
|
|
25
21
|
function record(value) {
|
|
26
22
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
27
23
|
? value
|
|
28
24
|
: null;
|
|
29
25
|
}
|
|
26
|
+
function exactReviewRecord(value, keys, label) {
|
|
27
|
+
try {
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
29
|
+
throw new TypeError(`${label} must be an object.`);
|
|
30
|
+
}
|
|
31
|
+
const prototype = Object.getPrototypeOf(value);
|
|
32
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
33
|
+
throw new TypeError(`${label} must be an ordinary object.`);
|
|
34
|
+
}
|
|
35
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
36
|
+
if (ownKeys.length !== keys.size
|
|
37
|
+
|| ownKeys.some((key) => typeof key !== 'string' || !keys.has(key))) {
|
|
38
|
+
throw new TypeError(`${label} must use its exact registered fields.`);
|
|
39
|
+
}
|
|
40
|
+
const result = {};
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
43
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
44
|
+
throw new TypeError(`${label}.${key} must be an enumerable data field.`);
|
|
45
|
+
}
|
|
46
|
+
result[key] = descriptor.value;
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze(result);
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
if (cause instanceof TypeError && cause.message.startsWith(label))
|
|
52
|
+
throw cause;
|
|
53
|
+
throw new TypeError(`${label} must be an ordinary object.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function exactReviewText(value, maximum, label) {
|
|
57
|
+
const normalized = boundedText(value, maximum);
|
|
58
|
+
if (!normalized || normalized !== value) {
|
|
59
|
+
throw new TypeError(`${label} must be a canonical bounded string.`);
|
|
60
|
+
}
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
function exactReviewTimestamp(value, label) {
|
|
64
|
+
const normalized = timestamp(value);
|
|
65
|
+
if (!normalized || normalized !== value)
|
|
66
|
+
throw new TypeError(`${label} must be a canonical timestamp.`);
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
function exactReviewInteger(value, maximum, label) {
|
|
70
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > maximum) {
|
|
71
|
+
throw new TypeError(`${label} must be a bounded non-negative integer.`);
|
|
72
|
+
}
|
|
73
|
+
return Number(value);
|
|
74
|
+
}
|
|
75
|
+
function exactReviewStringArray(value, maximumItems, maximumCharacters, label) {
|
|
76
|
+
const input = boundedDataArray(value, maximumItems);
|
|
77
|
+
if (!input)
|
|
78
|
+
throw new TypeError(`${label} must be a bounded array.`);
|
|
79
|
+
const output = input.map((item, index) => exactReviewText(item, maximumCharacters, `${label}[${index}]`));
|
|
80
|
+
if (new Set(output).size !== output.length
|
|
81
|
+
|| output.some((item, index) => index > 0 && output[index - 1] >= item)) {
|
|
82
|
+
throw new TypeError(`${label} must contain unique canonically ordered values.`);
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze(output);
|
|
85
|
+
}
|
|
30
86
|
function boundedText(value, maximum = 300) {
|
|
31
|
-
if (typeof value !== 'string'
|
|
87
|
+
if (typeof value !== 'string'
|
|
88
|
+
|| value.length > maximum * DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR
|
|
89
|
+
|| /[\u0000-\u001f\u007f]/u.test(value))
|
|
32
90
|
return null;
|
|
33
91
|
return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
|
|
34
92
|
}
|
|
35
|
-
function timestamp(value) {
|
|
36
|
-
|
|
37
|
-
|
|
93
|
+
function timestamp(value, legacy = false) {
|
|
94
|
+
if (typeof value !== 'string'
|
|
95
|
+
|| value.length > MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH * DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR
|
|
96
|
+
|| /[\u0000-\u001f\u007f]/u.test(value))
|
|
97
|
+
return null;
|
|
98
|
+
const text = value.replace(/\s+/gu, ' ').trim();
|
|
99
|
+
if (!text || text.length > MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH)
|
|
38
100
|
return null;
|
|
39
|
-
|
|
40
|
-
|
|
101
|
+
return text
|
|
102
|
+
? normalizeExplicitIsoTimestamp(text) ?? (legacy ? normalizeLegacyIsoTimestamp(text) : null)
|
|
103
|
+
: null;
|
|
41
104
|
}
|
|
42
105
|
function domain(value) {
|
|
43
106
|
const text = boundedText(value, 253)?.toLowerCase().replace(/\.$/u, '');
|
|
@@ -49,165 +112,110 @@ function domain(value) {
|
|
|
49
112
|
return ascii;
|
|
50
113
|
}
|
|
51
114
|
function hostnameList(value) {
|
|
52
|
-
|
|
53
|
-
return [];
|
|
54
|
-
return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
|
|
115
|
+
return [...new Set(value
|
|
55
116
|
.map(domain)
|
|
56
117
|
.filter((item) => item !== null))]
|
|
57
118
|
.sort()
|
|
58
119
|
.slice(0, MAX_DOMAIN_CONTROL_RECORDS);
|
|
59
120
|
}
|
|
60
121
|
function textList(value, maximum = 300) {
|
|
61
|
-
|
|
62
|
-
return [];
|
|
63
|
-
return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
|
|
122
|
+
return [...new Set(value
|
|
64
123
|
.map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
|
|
65
124
|
.filter((item) => item !== null))]
|
|
66
125
|
.sort()
|
|
67
126
|
.slice(0, MAX_DOMAIN_CONTROL_RECORDS);
|
|
68
127
|
}
|
|
69
128
|
function spkiFingerprint(value) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
function normalizeEntry(value) {
|
|
74
|
-
const source = record(value);
|
|
75
|
-
const normalizedDomain = domain(source?.domain);
|
|
76
|
-
if (!source || !normalizedDomain)
|
|
129
|
+
if (typeof value !== 'string'
|
|
130
|
+
|| value.length > 64 * DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR
|
|
131
|
+
|| /[\u0000-\u001f\u007f]/u.test(value))
|
|
77
132
|
return null;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
: null;
|
|
81
|
-
return Object.freeze({
|
|
82
|
-
domain: normalizedDomain,
|
|
83
|
-
nameservers: Object.freeze(hostnameList(source.nameservers)),
|
|
84
|
-
ds: Object.freeze(textList(source.ds, 500)),
|
|
85
|
-
mx: Object.freeze(hostnameList(source.mx)),
|
|
86
|
-
caa: Object.freeze(textList(source.caa, 500)),
|
|
87
|
-
tlsIssuer: boundedText(source.tlsIssuer, 300)?.toLowerCase() ?? null,
|
|
88
|
-
tlsSpkiSha256: spkiFingerprint(source.tlsSpkiSha256),
|
|
89
|
-
registrarLock,
|
|
90
|
-
renewalReviewAt: timestamp(source.renewalReviewAt),
|
|
91
|
-
note: boundedText(source.note, 500),
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
function unsignedManifest(value) {
|
|
95
|
-
return value;
|
|
96
|
-
}
|
|
97
|
-
function integrityDigest(value) {
|
|
98
|
-
return `sha256:${createHash('sha256').update(canonicalArtifactJson(value)).digest('hex')}`;
|
|
133
|
+
const text = value.trim().toLowerCase();
|
|
134
|
+
return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
|
|
99
135
|
}
|
|
100
136
|
export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
|
|
106
|
-
const generatedAt = timestamp(generatedAtValue);
|
|
107
|
-
const expiresAt = timestamp(source.expiresAt);
|
|
108
|
-
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
|
|
109
|
-
throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
|
|
110
|
-
}
|
|
111
|
-
if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
|
|
112
|
-
throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
|
|
113
|
-
}
|
|
114
|
-
const entries = source.entries.map((entry, index) => {
|
|
115
|
-
exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest input entry ${index + 1}`);
|
|
116
|
-
return normalizeEntry(entry);
|
|
117
|
-
});
|
|
118
|
-
if (entries.some((entry) => entry === null))
|
|
119
|
-
throw new TypeError('Domain control manifest contains an invalid entry.');
|
|
120
|
-
const normalizedEntries = entries;
|
|
121
|
-
const domains = new Set(normalizedEntries.map((entry) => entry.domain));
|
|
122
|
-
if (domains.size !== normalizedEntries.length)
|
|
123
|
-
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
124
|
-
const base = Object.freeze({
|
|
125
|
-
schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
|
|
126
|
-
version: DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
127
|
-
generatedAt,
|
|
128
|
-
expiresAt,
|
|
129
|
-
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
130
|
-
limitations: DOMAIN_CONTROL_LIMITATIONS,
|
|
131
|
-
});
|
|
132
|
-
return Object.freeze({
|
|
133
|
-
...base,
|
|
137
|
+
const unsigned = buildUnsignedDomainControlManifest(input, generatedAtValue);
|
|
138
|
+
const manifest = Object.freeze({
|
|
139
|
+
...unsigned,
|
|
134
140
|
integrity: Object.freeze({
|
|
135
141
|
algorithm: 'SHA-256',
|
|
136
|
-
canonicalization:
|
|
137
|
-
digestSha256:
|
|
142
|
+
canonicalization: DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION,
|
|
143
|
+
digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJsonFor(unsigned, DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION)).digest('hex')}`,
|
|
138
144
|
}),
|
|
139
145
|
});
|
|
146
|
+
assertDomainControlManifestByteBudget(manifest);
|
|
147
|
+
return manifest;
|
|
140
148
|
}
|
|
141
149
|
export function verifyDomainControlManifest(value) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|| source.schema !== DOMAIN_CONTROL_MANIFEST_SCHEMA
|
|
146
|
-
|| source.version !== DOMAIN_CONTROL_MANIFEST_VERSION
|
|
147
|
-
|| !Array.isArray(source.entries)
|
|
148
|
-
|| source.entries.length < 1
|
|
149
|
-
|| source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
|
|
150
|
-
|| !Array.isArray(source.limitations)
|
|
151
|
-
|| source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
|
|
152
|
-
|| source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
|
|
153
|
-
|| !integrity
|
|
154
|
-
|| integrity.algorithm !== 'SHA-256'
|
|
155
|
-
|| integrity.canonicalization !== 'sorted-json-v1'
|
|
156
|
-
|| typeof integrity.digestSha256 !== 'string'
|
|
157
|
-
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
158
|
-
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
159
|
-
}
|
|
160
|
-
exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
|
|
161
|
-
exactKeys(integrity, MANIFEST_INTEGRITY_KEYS, 'Domain control manifest integrity');
|
|
162
|
-
for (const [index, entry] of source.entries.entries()) {
|
|
163
|
-
exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest entry ${index + 1}`);
|
|
164
|
-
}
|
|
165
|
-
const generatedAt = timestamp(source.generatedAt);
|
|
166
|
-
const expiresAt = timestamp(source.expiresAt);
|
|
167
|
-
const entries = source.entries.map(normalizeEntry);
|
|
168
|
-
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
|
|
169
|
-
throw new TypeError('Domain control manifest has invalid normalised content.');
|
|
170
|
-
}
|
|
171
|
-
const normalizedEntries = entries;
|
|
172
|
-
if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
|
|
173
|
-
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
150
|
+
const normalized = normalizeDomainControlManifestDocument(value);
|
|
151
|
+
if (`sha256:${createHash('sha256').update(normalized.canonicalUnsigned).digest('hex')}` !== normalized.manifest.integrity.digestSha256) {
|
|
152
|
+
throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
|
|
174
153
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
version: DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
179
|
-
generatedAt,
|
|
180
|
-
expiresAt,
|
|
181
|
-
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
182
|
-
limitations: DOMAIN_CONTROL_LIMITATIONS,
|
|
183
|
-
});
|
|
184
|
-
let suppliedCanonical;
|
|
154
|
+
return normalized.manifest;
|
|
155
|
+
}
|
|
156
|
+
function boundedDataArray(value, maximum) {
|
|
185
157
|
try {
|
|
186
|
-
|
|
158
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype)
|
|
159
|
+
return null;
|
|
160
|
+
if (Object.getOwnPropertyDescriptor(value, Symbol.iterator)
|
|
161
|
+
|| Object.getOwnPropertyDescriptor(value, 'toJSON'))
|
|
162
|
+
return null;
|
|
163
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
|
|
164
|
+
const length = lengthDescriptor && !lengthDescriptor.enumerable && 'value' in lengthDescriptor
|
|
165
|
+
? lengthDescriptor.value
|
|
166
|
+
: null;
|
|
167
|
+
if (!Number.isSafeInteger(length)
|
|
168
|
+
|| Number(length) < 0
|
|
169
|
+
|| Number(length) > maximum)
|
|
170
|
+
return null;
|
|
171
|
+
const boundedLength = Number(length);
|
|
172
|
+
const keys = Reflect.ownKeys(value);
|
|
173
|
+
if (keys.length !== boundedLength + 1 || !keys.includes('length'))
|
|
174
|
+
return null;
|
|
175
|
+
const result = [];
|
|
176
|
+
for (let index = 0; index < boundedLength; index += 1) {
|
|
177
|
+
const key = String(index);
|
|
178
|
+
if (!keys.includes(key))
|
|
179
|
+
return null;
|
|
180
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
181
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor))
|
|
182
|
+
return null;
|
|
183
|
+
result.push(descriptor.value);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
187
186
|
}
|
|
188
187
|
catch {
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
if (suppliedCanonical !== canonicalArtifactJson(canonical)) {
|
|
192
|
-
throw new TypeError('Domain control manifest must use its canonical normalised content.');
|
|
193
|
-
}
|
|
194
|
-
if (`sha256:${createHash('sha256').update(suppliedCanonical).digest('hex')}` !== integrity.digestSha256) {
|
|
195
|
-
throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
|
|
188
|
+
return null;
|
|
196
189
|
}
|
|
197
|
-
return source;
|
|
198
190
|
}
|
|
199
191
|
function fieldValues(field, value) {
|
|
200
|
-
if (field === 'nameservers'
|
|
201
|
-
|
|
192
|
+
if (field === 'nameservers') {
|
|
193
|
+
const normalized = value.map(domain);
|
|
194
|
+
return normalized.some((item) => item === null)
|
|
195
|
+
? null
|
|
196
|
+
: hostnameList(normalized);
|
|
197
|
+
}
|
|
198
|
+
if (field === 'mx' || field === 'caa' || field === 'ds') {
|
|
199
|
+
const normalized = value.flatMap((item) => canonicalDomainControlRecordList([item], field));
|
|
200
|
+
return normalized.length === value.length
|
|
201
|
+
? [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS)
|
|
202
|
+
: null;
|
|
203
|
+
}
|
|
202
204
|
if (field === 'tlsSpkiSha256') {
|
|
203
|
-
const normalized =
|
|
204
|
-
return
|
|
205
|
+
const normalized = value.map(spkiFingerprint);
|
|
206
|
+
return normalized.some((item) => item === null)
|
|
207
|
+
? null
|
|
208
|
+
: [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
|
|
205
209
|
}
|
|
206
210
|
if (field === 'registrarLock') {
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
return value.every((item) => item === 'required' || item === 'not_required')
|
|
212
|
+
? [...new Set(value)].sort()
|
|
213
|
+
: null;
|
|
209
214
|
}
|
|
210
|
-
|
|
215
|
+
const normalized = value.map((item) => boundedText(item, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH)?.toLowerCase() ?? null);
|
|
216
|
+
return normalized.some((item) => item === null)
|
|
217
|
+
? null
|
|
218
|
+
: textList(normalized, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH);
|
|
211
219
|
}
|
|
212
220
|
function normalizeObservation(value) {
|
|
213
221
|
const source = record(value);
|
|
@@ -218,22 +226,31 @@ function normalizeObservation(value) {
|
|
|
218
226
|
exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
|
|
219
227
|
exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
|
|
220
228
|
const fields = {};
|
|
221
|
-
for (const field of
|
|
229
|
+
for (const field of DOMAIN_CONTROL_REVIEW_FIELDS) {
|
|
230
|
+
if (!Object.hasOwn(rawFields, field))
|
|
231
|
+
continue;
|
|
222
232
|
const raw = record(rawFields[field]);
|
|
223
233
|
if (!raw)
|
|
224
|
-
|
|
234
|
+
return null;
|
|
225
235
|
exactKeys(raw, OBSERVATION_FIELD_KEYS, `Domain control observation field ${field}`);
|
|
236
|
+
if (!DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS.every((key) => Object.hasOwn(raw, key)))
|
|
237
|
+
return null;
|
|
226
238
|
const state = raw.state;
|
|
227
239
|
if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
|
|
228
240
|
return null;
|
|
229
|
-
const sourceLabel = boundedText(raw.source,
|
|
230
|
-
|
|
241
|
+
const sourceLabel = boundedText(raw.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH);
|
|
242
|
+
const values = boundedDataArray(raw.values, MAX_DOMAIN_CONTROL_INPUT_RECORDS);
|
|
243
|
+
if (!sourceLabel || !values)
|
|
244
|
+
return null;
|
|
245
|
+
const normalizedValues = fieldValues(field, values);
|
|
246
|
+
const observedAt = raw.observedAt === null ? null : timestamp(raw.observedAt, true);
|
|
247
|
+
if (!normalizedValues || (raw.observedAt !== null && !observedAt))
|
|
231
248
|
return null;
|
|
232
249
|
fields[field] = Object.freeze({
|
|
233
250
|
state,
|
|
234
|
-
values: Object.freeze(
|
|
251
|
+
values: Object.freeze(normalizedValues),
|
|
235
252
|
source: sourceLabel,
|
|
236
|
-
observedAt
|
|
253
|
+
observedAt,
|
|
237
254
|
});
|
|
238
255
|
}
|
|
239
256
|
return Object.freeze({ domain: normalizedDomain, fields: Object.freeze(fields) });
|
|
@@ -299,18 +316,19 @@ function renewalComparison(entry, now) {
|
|
|
299
316
|
}
|
|
300
317
|
export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
301
318
|
const source = record(input);
|
|
302
|
-
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !==
|
|
303
|
-
throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version
|
|
319
|
+
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== DOMAIN_CONTROL_REVIEW_VERSION) {
|
|
320
|
+
throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
|
|
304
321
|
}
|
|
305
322
|
exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
|
|
306
323
|
const generatedAt = timestamp(generatedAtValue);
|
|
307
324
|
if (!generatedAt)
|
|
308
|
-
throw new TypeError('Domain control review time
|
|
325
|
+
throw new TypeError('Domain control review time must be valid and include an explicit timezone.');
|
|
309
326
|
const manifest = verifyDomainControlManifest(source.manifest);
|
|
310
|
-
|
|
327
|
+
const rawObservations = boundedDataArray(source.observations, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS);
|
|
328
|
+
if (!rawObservations) {
|
|
311
329
|
throw new TypeError('Domain control observations must be a bounded array.');
|
|
312
330
|
}
|
|
313
|
-
const observations =
|
|
331
|
+
const observations = rawObservations.map(normalizeObservation);
|
|
314
332
|
if (observations.some((item) => item === null))
|
|
315
333
|
throw new TypeError('Domain control review contains an invalid observation.');
|
|
316
334
|
const normalizedObservations = observations;
|
|
@@ -318,7 +336,7 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
318
336
|
throw new TypeError('Domain control observations must use unique domains.');
|
|
319
337
|
}
|
|
320
338
|
const observationMap = new Map(normalizedObservations.map((item) => [item.domain, item]));
|
|
321
|
-
const fields =
|
|
339
|
+
const fields = DOMAIN_CONTROL_REVIEW_FIELDS;
|
|
322
340
|
const domains = manifest.entries.map((entry) => {
|
|
323
341
|
const observation = observationMap.get(entry.domain) ?? null;
|
|
324
342
|
const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
|
|
@@ -347,11 +365,136 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
347
365
|
counts: Object.freeze(counts),
|
|
348
366
|
domains: Object.freeze(domains),
|
|
349
367
|
ignoredObservationCount: normalizedObservations.filter((item) => !manifest.entries.some((entry) => entry.domain === item.domain)).length,
|
|
350
|
-
limitations:
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
368
|
+
limitations: DOMAIN_CONTROL_REVIEW_LIMITATIONS,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
export function validateDomainControlReviewDocument(value) {
|
|
372
|
+
const root = exactReviewRecord(value, REVIEW_ROOT_KEYS, 'Domain control review document');
|
|
373
|
+
if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || root.version !== DOMAIN_CONTROL_REVIEW_VERSION) {
|
|
374
|
+
throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
|
|
375
|
+
}
|
|
376
|
+
const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
|
|
377
|
+
const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
|
|
378
|
+
const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
|
|
379
|
+
const expiresAt = exactReviewTimestamp(manifestInput.expiresAt, 'Domain control review document manifest expiresAt');
|
|
380
|
+
if (typeof manifestInput.digestSha256 !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(manifestInput.digestSha256)) {
|
|
381
|
+
throw new TypeError('Domain control review document manifest digest is invalid.');
|
|
382
|
+
}
|
|
383
|
+
const expired = Date.parse(expiresAt) <= Date.parse(generatedAt);
|
|
384
|
+
if (manifestInput.expired !== expired) {
|
|
385
|
+
throw new TypeError('Domain control review document manifest currentness is inconsistent.');
|
|
386
|
+
}
|
|
387
|
+
const manifest = Object.freeze({
|
|
388
|
+
generatedAt: manifestGeneratedAt,
|
|
389
|
+
expiresAt,
|
|
390
|
+
digestSha256: manifestInput.digestSha256,
|
|
391
|
+
expired,
|
|
392
|
+
});
|
|
393
|
+
const countInput = exactReviewRecord(root.counts, REVIEW_COUNT_KEYS, 'Domain control review document counts');
|
|
394
|
+
const maximumComparisons = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES * (DOMAIN_CONTROL_REVIEW_FIELDS.length + 1);
|
|
395
|
+
const suppliedCounts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS.map((state) => [
|
|
396
|
+
state,
|
|
397
|
+
exactReviewInteger(countInput[state], maximumComparisons, `Domain control review document count ${state}`),
|
|
398
|
+
]));
|
|
399
|
+
const domainInput = boundedDataArray(root.domains, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES);
|
|
400
|
+
if (!domainInput || domainInput.length < 1) {
|
|
401
|
+
throw new TypeError('Domain control review document domains must be a bounded non-empty array.');
|
|
402
|
+
}
|
|
403
|
+
const comparisonFields = [...DOMAIN_CONTROL_REVIEW_FIELDS, 'renewalReviewAt'];
|
|
404
|
+
const comparisonStates = new Set(DOMAIN_CONTROL_REVIEW_COUNT_KEYS);
|
|
405
|
+
const calculatedCounts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS.map((state) => [state, 0]));
|
|
406
|
+
const domains = domainInput.map((domainValue, domainIndex) => {
|
|
407
|
+
const item = exactReviewRecord(domainValue, REVIEW_DOMAIN_KEYS, `Domain control review document domain ${domainIndex + 1}`);
|
|
408
|
+
const normalizedDomain = domain(item.domain);
|
|
409
|
+
if (!normalizedDomain || normalizedDomain !== item.domain) {
|
|
410
|
+
throw new TypeError(`Domain control review document domain ${domainIndex + 1} is invalid.`);
|
|
411
|
+
}
|
|
412
|
+
const comparisonsInput = boundedDataArray(item.comparisons, comparisonFields.length);
|
|
413
|
+
if (!comparisonsInput || comparisonsInput.length !== comparisonFields.length) {
|
|
414
|
+
throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparisons are incomplete.`);
|
|
415
|
+
}
|
|
416
|
+
const comparisons = comparisonsInput.map((comparisonValue, comparisonIndex) => {
|
|
417
|
+
const comparison = exactReviewRecord(comparisonValue, REVIEW_COMPARISON_KEYS, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
|
|
418
|
+
const expectedField = comparisonFields[comparisonIndex];
|
|
419
|
+
if (comparison.field !== expectedField
|
|
420
|
+
|| typeof comparison.state !== 'string'
|
|
421
|
+
|| !comparisonStates.has(comparison.state)) {
|
|
422
|
+
throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} is unsupported.`);
|
|
423
|
+
}
|
|
424
|
+
const state = comparison.state;
|
|
425
|
+
calculatedCounts[state] += 1;
|
|
426
|
+
const maximumValueCharacters = expectedField === 'ds'
|
|
427
|
+
? MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH
|
|
428
|
+
: expectedField === 'caa'
|
|
429
|
+
? MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH
|
|
430
|
+
: MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH;
|
|
431
|
+
const desired = exactReviewStringArray(comparison.desired, MAX_DOMAIN_CONTROL_RECORDS, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} desired values`);
|
|
432
|
+
const observed = exactReviewStringArray(comparison.observed, MAX_DOMAIN_CONTROL_RECORDS, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observed values`);
|
|
433
|
+
const source = comparison.source === null
|
|
434
|
+
? null
|
|
435
|
+
: exactReviewText(comparison.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} source`);
|
|
436
|
+
const observedAt = comparison.observedAt === null
|
|
437
|
+
? null
|
|
438
|
+
: exactReviewTimestamp(comparison.observedAt, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observedAt`);
|
|
439
|
+
if (expectedField === 'renewalReviewAt' && (observed.length || source !== null || observedAt !== null)) {
|
|
440
|
+
throw new TypeError(`Domain control review document domain ${domainIndex + 1} renewal comparison is inconsistent.`);
|
|
441
|
+
}
|
|
442
|
+
return Object.freeze({
|
|
443
|
+
field: expectedField,
|
|
444
|
+
state,
|
|
445
|
+
desired,
|
|
446
|
+
observed,
|
|
447
|
+
source,
|
|
448
|
+
observedAt,
|
|
449
|
+
explanation: exactReviewText(comparison.explanation, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} explanation`),
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
const expectedState = comparisons.some((comparison) => comparison.state === 'drift' || comparison.state === 'due')
|
|
453
|
+
? 'drift'
|
|
454
|
+
: comparisons.some((comparison) => comparison.state === 'partial'
|
|
455
|
+
|| comparison.state === 'unavailable'
|
|
456
|
+
|| comparison.state === 'unsupported')
|
|
457
|
+
? 'partial'
|
|
458
|
+
: 'aligned';
|
|
459
|
+
if (item.state !== expectedState) {
|
|
460
|
+
throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
|
|
461
|
+
}
|
|
462
|
+
return Object.freeze({ domain: normalizedDomain, state: expectedState, comparisons: Object.freeze(comparisons) });
|
|
463
|
+
});
|
|
464
|
+
if (new Set(domains.map((item) => item.domain)).size !== domains.length) {
|
|
465
|
+
throw new TypeError('Domain control review document domains must be unique.');
|
|
466
|
+
}
|
|
467
|
+
for (const state of DOMAIN_CONTROL_REVIEW_COUNT_KEYS) {
|
|
468
|
+
if (suppliedCounts[state] !== calculatedCounts[state]) {
|
|
469
|
+
throw new TypeError('Domain control review document counts are inconsistent.');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const expectedState = expired
|
|
473
|
+
? 'expired'
|
|
474
|
+
: domains.some((item) => item.state === 'drift')
|
|
475
|
+
? 'drift'
|
|
476
|
+
: domains.some((item) => item.state === 'partial')
|
|
477
|
+
? 'partial'
|
|
478
|
+
: 'aligned';
|
|
479
|
+
if (root.state !== expectedState)
|
|
480
|
+
throw new TypeError('Domain control review document state is inconsistent.');
|
|
481
|
+
const ignoredObservationCount = exactReviewInteger(root.ignoredObservationCount, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, 'Domain control review document ignored observation count');
|
|
482
|
+
const limitationsInput = boundedDataArray(root.limitations, DOMAIN_CONTROL_REVIEW_LIMITATIONS.length);
|
|
483
|
+
if (!limitationsInput
|
|
484
|
+
|| limitationsInput.length !== DOMAIN_CONTROL_REVIEW_LIMITATIONS.length
|
|
485
|
+
|| limitationsInput.some((item, index) => item !== DOMAIN_CONTROL_REVIEW_LIMITATIONS[index])) {
|
|
486
|
+
throw new TypeError('Domain control review document limitations are invalid.');
|
|
487
|
+
}
|
|
488
|
+
return Object.freeze({
|
|
489
|
+
schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
490
|
+
version: DOMAIN_CONTROL_REVIEW_VERSION,
|
|
491
|
+
generatedAt,
|
|
492
|
+
state: expectedState,
|
|
493
|
+
manifest,
|
|
494
|
+
counts: Object.freeze(suppliedCounts),
|
|
495
|
+
domains: Object.freeze(domains),
|
|
496
|
+
ignoredObservationCount,
|
|
497
|
+
limitations: DOMAIN_CONTROL_REVIEW_LIMITATIONS,
|
|
355
498
|
});
|
|
356
499
|
}
|
|
357
500
|
export function formatDomainControlResult(value) {
|
|
@@ -10,10 +10,13 @@ const SPF_MAX_BRANCHES = 32;
|
|
|
10
10
|
const MAX_DMARC_DESTINATIONS = 10;
|
|
11
11
|
const MAX_DEPENDENCIES = 64;
|
|
12
12
|
function strictHostname(value) {
|
|
13
|
-
const
|
|
13
|
+
const input = String(value || '').trim().replace(/\.+$/u, '');
|
|
14
|
+
if (/[^\x00-\x7f]/u.test(input))
|
|
15
|
+
return null;
|
|
16
|
+
const raw = input.toLowerCase();
|
|
14
17
|
if (!raw || raw.length > 253 || raw.includes('%'))
|
|
15
18
|
return null;
|
|
16
|
-
if (!raw.split('.').every((label) => /^(?:_?[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?)$/
|
|
19
|
+
if (!raw.split('.').every((label) => /^(?:_?[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?)$/u.test(label)))
|
|
17
20
|
return null;
|
|
18
21
|
return raw.includes('.') ? raw : null;
|
|
19
22
|
}
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -602,15 +602,18 @@ function buildPostureReport(domain, input) {
|
|
|
602
602
|
summary[item.status] += 1;
|
|
603
603
|
return { domain, summary, checks };
|
|
604
604
|
}
|
|
605
|
-
async function fetchMtaStsPolicy(domain) {
|
|
605
|
+
async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
|
|
606
606
|
const controller = new AbortController();
|
|
607
607
|
const timeout = setTimeout(() => controller.abort(), POLICY_TIMEOUT_MS);
|
|
608
608
|
try {
|
|
609
609
|
const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
|
|
610
|
-
|
|
610
|
+
// RFC 8461 section 3.3 requires a direct HTTPS 200 response and forbids
|
|
611
|
+
// following HTTP redirects for policy discovery. A zero-hop safe fetch
|
|
612
|
+
// also keeps the returned body bound to the required policy host/path.
|
|
613
|
+
const res = await fetcher(url, {
|
|
611
614
|
signal: controller.signal,
|
|
612
615
|
headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
|
|
613
|
-
});
|
|
616
|
+
}, 0);
|
|
614
617
|
if (!res.ok) {
|
|
615
618
|
// Not reading this body - release it explicitly instead of leaving an
|
|
616
619
|
// unconsumed stream (and the connection it's tied to) open until
|
|
@@ -743,4 +746,4 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
743
746
|
externalDependencies,
|
|
744
747
|
};
|
|
745
748
|
}
|
|
746
|
-
export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, buildPostureReport, checkDomainPosture, };
|
|
749
|
+
export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, fetchMtaStsPolicy, buildPostureReport, checkDomainPosture, };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
1
2
|
const ENCRYPTED_DNS_CONTRACT_SCHEMA = 'whoisleuth.encrypted-dns-adapter';
|
|
2
3
|
const ENCRYPTED_DNS_CONTRACT_VERSION = 1;
|
|
3
4
|
const DNS_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'DNSKEY', 'DS', 'HTTPS', 'MX', 'NS', 'RRSIG', 'SOA', 'SVCB', 'TLSA', 'TXT']);
|
|
@@ -28,9 +29,7 @@ function normalizeEncryptedDnsAdapter(value) {
|
|
|
28
29
|
const endpoint = httpsUrl(source.endpoint);
|
|
29
30
|
const termsUrl = httpsUrl(source.termsUrl);
|
|
30
31
|
const privacyUrl = httpsUrl(source.privacyUrl);
|
|
31
|
-
const reviewedAt =
|
|
32
|
-
? new Date(source.reviewedAt).toISOString()
|
|
33
|
-
: null;
|
|
32
|
+
const reviewedAt = normalizeExplicitIsoTimestamp(source.reviewedAt);
|
|
34
33
|
const method = source.method === 'GET' || source.method === 'POST' ? source.method : null;
|
|
35
34
|
const representation = source.representation === 'dns-json' || source.representation === 'dns-wire'
|
|
36
35
|
? source.representation
|