@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -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
|
+
}
|
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],
|
|
@@ -5,9 +5,10 @@ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/art
|
|
|
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
9
|
export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
|
|
10
10
|
2: 1,
|
|
11
|
+
3: 2,
|
|
11
12
|
[DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
|
|
12
13
|
});
|
|
13
14
|
export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
|
|
@@ -43,16 +44,10 @@ function changeSummary(before, after) {
|
|
|
43
44
|
const beforeRows = rows(before);
|
|
44
45
|
const afterRows = rows(after);
|
|
45
46
|
const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
|
|
46
|
-
const
|
|
47
|
-
const representative = review.authoritativeRecordMatrix[0];
|
|
48
|
-
return Boolean(representative
|
|
49
|
-
&& representative.observations.length >= 2
|
|
50
|
-
&& representative.observations.every((item) => item.state === 'observed'));
|
|
51
|
-
};
|
|
52
|
-
const rowEvidence = (review, row) => {
|
|
47
|
+
const rowEvidence = (row) => {
|
|
53
48
|
if (!row)
|
|
54
49
|
return Object.freeze({
|
|
55
|
-
state:
|
|
50
|
+
state: 'unavailable',
|
|
56
51
|
values: Object.freeze([]),
|
|
57
52
|
});
|
|
58
53
|
if (row.observations.some((item) => item.state !== 'observed')) {
|
|
@@ -68,13 +63,15 @@ function changeSummary(before, after) {
|
|
|
68
63
|
});
|
|
69
64
|
};
|
|
70
65
|
const changed = [];
|
|
66
|
+
const reasons = keys.length > 500 ? ['The comparison exceeds 500 owner and record-type pairs.'] : [];
|
|
71
67
|
for (const key of keys.slice(0, 500)) {
|
|
72
68
|
const left = beforeRows.get(key);
|
|
73
69
|
const right = afterRows.get(key);
|
|
74
70
|
const [owner, type] = key.split('\u0000');
|
|
75
|
-
const beforeEvidence = rowEvidence(
|
|
76
|
-
const afterEvidence = rowEvidence(
|
|
71
|
+
const beforeEvidence = rowEvidence(left);
|
|
72
|
+
const afterEvidence = rowEvidence(right);
|
|
77
73
|
if (beforeEvidence.state !== 'complete' || afterEvidence.state !== 'complete') {
|
|
74
|
+
reasons.push(`Comparison evidence is incomplete for ${owner} ${type}.`);
|
|
78
75
|
continue;
|
|
79
76
|
}
|
|
80
77
|
if (JSON.stringify(beforeEvidence.values) === JSON.stringify(afterEvidence.values))
|
|
@@ -86,7 +83,7 @@ function changeSummary(before, after) {
|
|
|
86
83
|
afterValues: afterEvidence.values,
|
|
87
84
|
}));
|
|
88
85
|
}
|
|
89
|
-
return Object.freeze(changed);
|
|
86
|
+
return Object.freeze({ changed: Object.freeze(changed), reasons: Object.freeze(reasons) });
|
|
90
87
|
}
|
|
91
88
|
export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
92
89
|
const input = record(inputRaw, 'Domain change packet input');
|
|
@@ -107,11 +104,13 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
|
|
|
107
104
|
const preChange = reviewDomainChange(input.preChange, generatedAt);
|
|
108
105
|
const postChange = reviewDomainChange(input.postChange, generatedAt);
|
|
109
106
|
const assurance = buildDomainAssurance(input.assurance, generatedAt);
|
|
107
|
+
const comparison = changeSummary(preChange, postChange);
|
|
110
108
|
const assuranceReasons = assurance.result.review.reasons;
|
|
111
109
|
const gateReasons = Object.freeze([
|
|
112
110
|
...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
|
|
113
111
|
...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
|
|
114
112
|
...assuranceReasons.map((reason) => `Change plan: ${reason}`),
|
|
113
|
+
...comparison.reasons,
|
|
115
114
|
].slice(0, 100));
|
|
116
115
|
const unsigned = Object.freeze({
|
|
117
116
|
schema: DOMAIN_CHANGE_PACKET_SCHEMA,
|
|
@@ -122,7 +121,7 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
|
|
|
122
121
|
state: gateReasons.length ? 'review' : 'ready',
|
|
123
122
|
gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
|
|
124
123
|
summary: Object.freeze({
|
|
125
|
-
changedAuthoritativeRecordSets:
|
|
124
|
+
changedAuthoritativeRecordSets: comparison.changed,
|
|
126
125
|
preChangeState: preChange.state,
|
|
127
126
|
postChangeState: postChange.state,
|
|
128
127
|
assuranceState: assurance.result.review.state,
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { normaliseRdata } from "./zone-intent-review.mjs";
|
|
3
|
+
import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
|
|
3
4
|
export const DOMAIN_CHANGE_INPUT_SCHEMA = 'whoisleuth.domain-change.input';
|
|
4
|
-
export const DOMAIN_CHANGE_INPUT_VERSION =
|
|
5
|
+
export const DOMAIN_CHANGE_INPUT_VERSION = 2;
|
|
6
|
+
export const SUPPORTED_DOMAIN_CHANGE_INPUT_VERSIONS = Object.freeze([1, DOMAIN_CHANGE_INPUT_VERSION]);
|
|
5
7
|
export const DOMAIN_CHANGE_REVIEW_SCHEMA = 'whoisleuth.domain-change.review';
|
|
6
|
-
export const DOMAIN_CHANGE_REVIEW_VERSION =
|
|
8
|
+
export const DOMAIN_CHANGE_REVIEW_VERSION = 3;
|
|
7
9
|
export const MAX_DOMAIN_CHANGE_VANTAGES = 16;
|
|
8
10
|
export const MAX_DOMAIN_CHANGE_RECORDS = 500;
|
|
9
11
|
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'authoritySnapshots', 'resolverSnapshots', 'acmeDependencies', 'certificate', 'hsts']);
|
|
10
12
|
const SNAPSHOT_KEYS = new Set(['label', 'source', 'state', 'observedAt', 'records']);
|
|
13
|
+
const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
|
|
11
14
|
const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
|
|
12
15
|
const ACME_KEYS = new Set(['method', 'owner', 'target', 'provider', 'state']);
|
|
13
16
|
const CERTIFICATE_KEYS = new Set(['state', 'observedAt', 'currentSpkiSha256', 'plannedSpkiSha256', 'mustStaple', 'ocspStapled', 'embeddedSctCount']);
|
|
@@ -62,13 +65,14 @@ function normaliseRecord(value, apex, label) {
|
|
|
62
65
|
: (() => { throw new TypeError(`${label}.ttl is outside its supported range.`); })();
|
|
63
66
|
return Object.freeze({ owner: owner(input.owner, apex, `${label}.owner`), type, ttl, ...normaliseValue(type, input.value, `${label}.value`) });
|
|
64
67
|
}
|
|
65
|
-
function normaliseSnapshots(value, apex, label) {
|
|
68
|
+
function normaliseSnapshots(value, apex, label, legacy) {
|
|
66
69
|
if (!Array.isArray(value) || value.length > MAX_DOMAIN_CHANGE_VANTAGES)
|
|
67
70
|
throw new TypeError(`${label} must contain no more than ${MAX_DOMAIN_CHANGE_VANTAGES} snapshots.`);
|
|
68
71
|
let totalRecords = 0;
|
|
72
|
+
let totalQueries = 0;
|
|
69
73
|
const snapshots = value.map((raw, index) => {
|
|
70
74
|
const item = requireRecord(raw, `${label}[${index}]`);
|
|
71
|
-
exactKeys(item, SNAPSHOT_KEYS, `${label}[${index}]`);
|
|
75
|
+
exactKeys(item, legacy ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `${label}[${index}]`);
|
|
72
76
|
const state = evidenceState(item.state, `${label}[${index}].state`);
|
|
73
77
|
if (!Array.isArray(item.records))
|
|
74
78
|
throw new TypeError(`${label}[${index}].records must be an array.`);
|
|
@@ -78,12 +82,24 @@ function normaliseSnapshots(value, apex, label) {
|
|
|
78
82
|
const records = item.records.map((recordValue, recordIndex) => normaliseRecord(recordValue, apex, `${label}[${index}].records[${recordIndex}]`));
|
|
79
83
|
if (state === 'unavailable' && records.length)
|
|
80
84
|
throw new TypeError(`${label}[${index}] cannot contain records when unavailable.`);
|
|
85
|
+
const queries = normaliseDnsQueryCoverage(item.queries, {
|
|
86
|
+
legacy, label: `${label}[${index}].queries`, maximum: MAX_DOMAIN_CHANGE_RECORDS - totalQueries, state, records,
|
|
87
|
+
owner: (value, path) => owner(value, apex, path),
|
|
88
|
+
type: (value, path) => {
|
|
89
|
+
const type = text(value, path, 16).toUpperCase();
|
|
90
|
+
if (!TYPES.has(type))
|
|
91
|
+
throw new TypeError(`${path} is unsupported.`);
|
|
92
|
+
return type;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
totalQueries += queries.length;
|
|
81
96
|
return Object.freeze({
|
|
82
97
|
label: text(item.label, `${label}[${index}].label`, 120),
|
|
83
98
|
source: text(item.source, `${label}[${index}].source`, 240),
|
|
84
99
|
state,
|
|
85
100
|
observedAt: timestamp(item.observedAt, `${label}[${index}].observedAt`),
|
|
86
101
|
records: Object.freeze(records),
|
|
102
|
+
queries,
|
|
87
103
|
});
|
|
88
104
|
});
|
|
89
105
|
if (new Set(snapshots.map((item) => item.label.toLowerCase())).size !== snapshots.length)
|
|
@@ -91,17 +107,18 @@ function normaliseSnapshots(value, apex, label) {
|
|
|
91
107
|
return Object.freeze(snapshots);
|
|
92
108
|
}
|
|
93
109
|
function snapshotProvenance(snapshot) {
|
|
94
|
-
return Object.freeze({ label: snapshot.label, source: snapshot.source, state: snapshot.state, observedAt: snapshot.observedAt });
|
|
110
|
+
return Object.freeze({ label: snapshot.label, source: snapshot.source, state: snapshot.state, observedAt: snapshot.observedAt, queries: snapshot.queries });
|
|
95
111
|
}
|
|
96
112
|
function recordMatrix(snapshots) {
|
|
97
|
-
const keys = [...new Set(snapshots.flatMap((snapshot) => snapshot.
|
|
113
|
+
const keys = [...new Set(snapshots.flatMap((snapshot) => snapshot.queries.map((item) => `${item.owner}\u0000${item.type}`)))].sort();
|
|
98
114
|
return Object.freeze(keys.map((key) => {
|
|
99
115
|
const [recordOwner, type] = key.split('\u0000');
|
|
100
116
|
const observations = snapshots.map((snapshot) => {
|
|
101
117
|
const matches = snapshot.records.filter((item) => item.owner === recordOwner && item.type === type);
|
|
102
118
|
const ttls = matches.map((item) => item.ttl).filter((ttl) => ttl !== null);
|
|
103
119
|
return Object.freeze({
|
|
104
|
-
|
|
120
|
+
label: snapshot.label, source: snapshot.source, observedAt: snapshot.observedAt,
|
|
121
|
+
state: dnsQueryState(snapshot.queries, recordOwner, type),
|
|
105
122
|
values: Object.freeze([...new Set(matches.map((item) => item.value))].sort()),
|
|
106
123
|
ttlRange: ttls.length ? Object.freeze({
|
|
107
124
|
minimum: Math.min(...ttls),
|
|
@@ -114,13 +131,14 @@ function recordMatrix(snapshots) {
|
|
|
114
131
|
return Object.freeze({
|
|
115
132
|
owner: recordOwner,
|
|
116
133
|
type,
|
|
117
|
-
state: complete.length < 2 ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different',
|
|
134
|
+
state: complete.length < 2 || complete.length !== observations.length ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different',
|
|
118
135
|
observations: Object.freeze(observations),
|
|
119
136
|
});
|
|
120
137
|
}));
|
|
121
138
|
}
|
|
122
139
|
function reviewDnssecAutomation(matrix) {
|
|
123
|
-
const automation = matrix.filter((row) =>
|
|
140
|
+
const automation = matrix.filter((row) => ['CDS', 'CDNSKEY', 'CSYNC'].includes(row.type)
|
|
141
|
+
&& row.observations.some((item) => item.values.length > 0));
|
|
124
142
|
const byType = (type) => automation.find((row) => row.type === type);
|
|
125
143
|
const cds = byType('CDS');
|
|
126
144
|
const cdnskey = byType('CDNSKEY');
|
|
@@ -238,12 +256,12 @@ function serviceInventory(snapshots) {
|
|
|
238
256
|
}
|
|
239
257
|
export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
240
258
|
const input = requireRecord(inputRaw, 'Domain change input');
|
|
241
|
-
if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || input.version
|
|
242
|
-
throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version ${DOMAIN_CHANGE_INPUT_VERSION}.`);
|
|
259
|
+
if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CHANGE_INPUT_VERSIONS.includes(input.version))
|
|
260
|
+
throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version 1 or ${DOMAIN_CHANGE_INPUT_VERSION}.`);
|
|
243
261
|
exactKeys(input, ROOT_KEYS, 'Domain change input');
|
|
244
262
|
const apex = domain(input.domain, 'domain');
|
|
245
|
-
const authoritySnapshots = normaliseSnapshots(input.authoritySnapshots ?? [], apex, 'authoritySnapshots');
|
|
246
|
-
const resolverSnapshots = normaliseSnapshots(input.resolverSnapshots ?? [], apex, 'resolverSnapshots');
|
|
263
|
+
const authoritySnapshots = normaliseSnapshots(input.authoritySnapshots ?? [], apex, 'authoritySnapshots', input.version === 1);
|
|
264
|
+
const resolverSnapshots = normaliseSnapshots(input.resolverSnapshots ?? [], apex, 'resolverSnapshots', input.version === 1);
|
|
247
265
|
const authorityMatrix = recordMatrix(authoritySnapshots);
|
|
248
266
|
const resolverMatrix = recordMatrix(resolverSnapshots);
|
|
249
267
|
const acmeDependencies = normaliseAcme(input.acmeDependencies ?? [], apex);
|
|
@@ -305,7 +323,7 @@ export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISO
|
|
|
305
323
|
limitations: Object.freeze([
|
|
306
324
|
'This local review uses only analyst-supplied observations and makes no DNS, HTTP, certificate, certificate-authority, preload-list, or provider request.',
|
|
307
325
|
'Different observations can reflect propagation, caching, split-horizon DNS, resolver policy, or collection timing; they do not establish misconfiguration by themselves.',
|
|
308
|
-
'
|
|
326
|
+
'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are not absence; legacy inputs establish scope only for supplied records. TXT values are represented only by SHA-256 digests.',
|
|
309
327
|
'DNSSEC automation readiness describes CDS, CDNSKEY, and CSYNC publication consistency; it does not validate a cryptographic chain or authorise a registry-side change.',
|
|
310
328
|
'HSTS preload state is an analyst-supplied, source-attributed observation and must be refreshed against the nominated source before a change.',
|
|
311
329
|
]),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { canonicalDomainControlRecords } from "../packages/evidence/domain-control-runtime.mjs";
|
|
3
3
|
import { requireBoundedString, requireDomainName, requireIsoTimestamp, } from "./bounded-contract-normalizers.mjs";
|
|
4
|
-
import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH, SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_EVENTS, MAX_FLIGHT_RECORDER_INPUT_VALUES, MAX_FLIGHT_RECORDER_JSON_DEPTH, MAX_FLIGHT_RECORDER_JSON_VALUES, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_SOURCE_LENGTH, MAX_FLIGHT_RECORDER_VALUE_LENGTH, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH, MIN_FLIGHT_RECORDER_OBSERVATIONS, } from "../packages/contracts/domain-control-flight-recorder.mjs";
|
|
4
|
+
import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, SOURCE_TIMED_FLIGHT_RECORDER_EVENT_KEYS, SOURCE_TIMED_FLIGHT_RECORDER_LIMITATIONS, FLIGHT_RECORDER_CHANGE_INTERVAL_KEYS, HISTORICAL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH, SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_EVENTS, MAX_FLIGHT_RECORDER_INPUT_VALUES, MAX_FLIGHT_RECORDER_JSON_DEPTH, MAX_FLIGHT_RECORDER_JSON_VALUES, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_SOURCE_LENGTH, MAX_FLIGHT_RECORDER_VALUE_LENGTH, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH, MIN_FLIGHT_RECORDER_OBSERVATIONS, } from "../packages/contracts/domain-control-flight-recorder.mjs";
|
|
5
5
|
export { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, };
|
|
6
6
|
const ROOT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS);
|
|
7
7
|
const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS);
|
|
@@ -378,12 +378,11 @@ function approvedWindow(value, index) {
|
|
|
378
378
|
function sameValues(left, right) {
|
|
379
379
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
380
380
|
}
|
|
381
|
-
function matchingWindow(windows, domain, field,
|
|
382
|
-
const time = Date.parse(observedAt);
|
|
381
|
+
function matchingWindow(windows, domain, field, after, by) {
|
|
383
382
|
return windows.find((window) => (window.domain === domain
|
|
384
383
|
&& window.fields.includes(field)
|
|
385
|
-
&& Date.parse(window.startsAt) <=
|
|
386
|
-
&&
|
|
384
|
+
&& Date.parse(window.startsAt) <= Date.parse(after)
|
|
385
|
+
&& Date.parse(by) <= Date.parse(window.endsAt))) ?? null;
|
|
387
386
|
}
|
|
388
387
|
/** Equal-capture disagreement stays incomplete, independently of input ordering. */
|
|
389
388
|
export function mergeConcurrentDomainControlFields(observations) {
|
|
@@ -441,12 +440,22 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
|
|
|
441
440
|
throw new TypeError('Domain control flight-recorder captures cannot be later than generatedAt.');
|
|
442
441
|
}
|
|
443
442
|
const latest = new Map();
|
|
443
|
+
const lastComplete = new Map();
|
|
444
|
+
const incompleteSince = new Map();
|
|
444
445
|
const events = [];
|
|
445
446
|
let incompleteFields = 0;
|
|
446
447
|
for (const item of observationTimeline(observations)) {
|
|
447
|
-
|
|
448
|
+
const fields = [...item.fields];
|
|
449
|
+
for (const [key, previous] of latest) {
|
|
450
|
+
if (key.startsWith(`${item.domain}\u0000`) && !fields.some(field => field.id === previous.field.id)) {
|
|
451
|
+
fields.push({ id: previous.field.id, source: 'Not supplied in this capture', state: 'unavailable', observedAt: null, values: [] });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
fields.sort((a, b) => ordinalCompare(a.id, b.id));
|
|
455
|
+
for (const field of fields) {
|
|
448
456
|
const key = `${item.domain}\u0000${field.id}`;
|
|
449
457
|
const previous = latest.get(key);
|
|
458
|
+
let baseline = lastComplete.get(key);
|
|
450
459
|
let complete = field.state === 'observed' && field.observedAt !== null
|
|
451
460
|
&& Date.parse(field.observedAt) <= Date.parse(item.capturedAt) && item.collectionDepth !== 'unknown';
|
|
452
461
|
const sameContext = previous?.field.source === field.source && previous.collectionDepth === item.collectionDepth;
|
|
@@ -458,46 +467,67 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
|
|
|
458
467
|
: Date.parse(field.observedAt) > Date.parse(item.capturedAt) ? 'The source observation time is later than capture.'
|
|
459
468
|
: item.collectionDepth === 'unknown' ? 'Collection conditions are unknown or conflicting.'
|
|
460
469
|
: `The source is ${field.state}.`;
|
|
461
|
-
|
|
462
|
-
|
|
470
|
+
const contextChanged = baseline && field.source !== 'Not supplied in this capture'
|
|
471
|
+
&& (baseline.field.source !== field.source || (item.collectionDepth !== 'unknown' && baseline.collectionDepth !== item.collectionDepth));
|
|
472
|
+
if (contextChanged) {
|
|
473
|
+
if (complete) {
|
|
474
|
+
complete = false;
|
|
475
|
+
limitation = 'The source identity or collection conditions changed.';
|
|
476
|
+
}
|
|
477
|
+
lastComplete.delete(key);
|
|
478
|
+
baseline = undefined;
|
|
479
|
+
}
|
|
480
|
+
else if (complete && baseline && Date.parse(field.observedAt) <= Date.parse(baseline.field.observedAt)) {
|
|
463
481
|
complete = false;
|
|
464
|
-
limitation =
|
|
465
|
-
: 'The source observation time did not increase.';
|
|
482
|
+
limitation = 'The source observation time did not increase.';
|
|
466
483
|
}
|
|
467
484
|
latest.set(key, { field, collectionDepth: item.collectionDepth, complete });
|
|
468
485
|
let kind;
|
|
469
486
|
let explanation;
|
|
470
487
|
let before = [];
|
|
471
488
|
let approved = null;
|
|
489
|
+
let changeInterval = null;
|
|
472
490
|
if (!complete) {
|
|
491
|
+
incompleteSince.set(key, (incompleteSince.get(key) ?? 0) + 1);
|
|
473
492
|
incompleteFields += 1;
|
|
474
493
|
kind = previous?.complete ? 'collection_change' : 'incomplete_observation';
|
|
475
494
|
before = previous?.complete ? previous.field.values : [];
|
|
476
495
|
explanation = `${limitation} Retained values are incomplete; this is not evidence that a prior value disappeared or changed.`;
|
|
477
496
|
}
|
|
497
|
+
else if (baseline && !sameValues(baseline.field.values, field.values)) {
|
|
498
|
+
kind = 'observed_change';
|
|
499
|
+
before = baseline.field.values;
|
|
500
|
+
changeInterval = Object.freeze({ after: baseline.field.observedAt, by: field.observedAt, incompleteObservations: incompleteSince.get(key) ?? 0 });
|
|
501
|
+
approved = matchingWindow(windows, item.domain, field.id, changeInterval.after, changeInterval.by);
|
|
502
|
+
explanation = `Comparable complete observations contain different values. The change occurred after ${changeInterval.after} and by ${changeInterval.by}; its exact time is unknown.`
|
|
503
|
+
+ (changeInterval.incompleteObservations ? ` ${changeInterval.incompleteObservations} incomplete observation(s) occurred in this interval.` : '')
|
|
504
|
+
+ (approved ? ' The recorded approved window contains the entire interval.' : ' No recorded approved window contains the entire interval.');
|
|
505
|
+
}
|
|
478
506
|
else if (!previous) {
|
|
479
507
|
kind = 'first_observation';
|
|
480
508
|
explanation = 'The first complete source-timed value for this field was retained.';
|
|
481
509
|
}
|
|
482
510
|
else if (!previous.complete) {
|
|
483
511
|
kind = 'recovered';
|
|
484
|
-
explanation =
|
|
512
|
+
explanation = baseline
|
|
513
|
+
? 'The recovered complete value matches the last comparable complete observation. Intervening changes during the incomplete interval remain unknown.'
|
|
514
|
+
: 'A complete source-timed value is available after an incomplete observation; this does not establish a value change.';
|
|
485
515
|
}
|
|
486
516
|
else {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
: 'Two complete observations from the same source, with increasing source times, contain different values.';
|
|
517
|
+
lastComplete.set(key, { field, collectionDepth: item.collectionDepth, complete });
|
|
518
|
+
incompleteSince.delete(key);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (complete) {
|
|
522
|
+
lastComplete.set(key, { field, collectionDepth: item.collectionDepth, complete });
|
|
523
|
+
incompleteSince.delete(key);
|
|
495
524
|
}
|
|
496
525
|
events.push(Object.freeze({
|
|
497
526
|
id: `${item.domain}:${field.id}:${item.capturedAt}:${events.length + 1}`,
|
|
498
527
|
domain: item.domain, field: field.id, observedAt: field.observedAt, capturedAt: item.capturedAt,
|
|
499
528
|
kind, state: complete ? 'observed' : 'partial', before: Object.freeze([...before]), after: field.values,
|
|
500
|
-
source: field.source, approvedWindow: approved ? Object.freeze({ id: approved.id, reason: approved.reason }) : null,
|
|
529
|
+
source: field.source, approvedWindow: approved ? Object.freeze({ id: approved.id, reason: approved.reason, startsAt: approved.startsAt, endsAt: approved.endsAt }) : null,
|
|
530
|
+
collectionDepth: item.collectionDepth, changeInterval,
|
|
501
531
|
explanation,
|
|
502
532
|
}));
|
|
503
533
|
}
|
|
@@ -560,6 +590,7 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
560
590
|
throw new TypeError(`Domain control flight-recorder document must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} supported version ${SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.join(' or ')}.`);
|
|
561
591
|
}
|
|
562
592
|
const legacy = root.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
|
|
593
|
+
const intervalVersion = root.version === DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
|
|
563
594
|
const generatedAt = requireIsoTimestamp(root.generatedAt, 'Domain control flight-recorder document generatedAt');
|
|
564
595
|
if (generatedAt !== root.generatedAt) {
|
|
565
596
|
throw new TypeError('Domain control flight-recorder document generatedAt must use its canonical timestamp form.');
|
|
@@ -591,10 +622,13 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
591
622
|
};
|
|
592
623
|
const seenIds = new Set();
|
|
593
624
|
const latest = new Map();
|
|
625
|
+
const lastComplete = new Map();
|
|
626
|
+
const incompleteSince = new Map();
|
|
594
627
|
let lastCapture = -Infinity;
|
|
595
628
|
const events = eventInput.map((eventValue, index) => {
|
|
596
629
|
const label = `Domain control flight-recorder document event ${index + 1}`;
|
|
597
|
-
const event = exactRecord(eventValue, legacy ? new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS)
|
|
630
|
+
const event = exactRecord(eventValue, legacy ? new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS)
|
|
631
|
+
: intervalVersion ? EVENT_KEYS : new Set(SOURCE_TIMED_FLIGHT_RECORDER_EVENT_KEYS), label);
|
|
598
632
|
const eventDomain = requireDomainName(event.domain, `${label}.domain`);
|
|
599
633
|
if (eventDomain !== event.domain || !domains.includes(eventDomain)) {
|
|
600
634
|
throw new TypeError(`${label}.domain must be present in the document domain list.`);
|
|
@@ -624,20 +658,44 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
624
658
|
if (!legacy && expectedState === 'observed' && (observedAt === null || Date.parse(observedAt) > Date.parse(capturedAt))) {
|
|
625
659
|
throw new TypeError(`${label} cannot claim a complete observation without a source time at or before capture.`);
|
|
626
660
|
}
|
|
661
|
+
const collectionDepth = intervalVersion ? event.collectionDepth : undefined;
|
|
662
|
+
if (intervalVersion && (collectionDepth !== 'deep' && collectionDepth !== 'fast' && collectionDepth !== 'unknown'
|
|
663
|
+
|| expectedState === 'observed' && collectionDepth === 'unknown')) {
|
|
664
|
+
throw new TypeError(`${label} has invalid collection conditions.`);
|
|
665
|
+
}
|
|
627
666
|
const before = exactRecorderValues(event.before, `${label}.before`, legacy, event.field, expectedState === 'observed');
|
|
628
667
|
const after = exactRecorderValues(event.after, `${label}.after`, legacy, event.field, expectedState === 'observed');
|
|
629
668
|
if (((event.kind === 'first_observation' || event.kind === 'recovered') && before.length)
|
|
630
669
|
|| (legacy && event.kind === 'collection_change' && after.length)) {
|
|
631
670
|
throw new TypeError(`${label} before/after values are inconsistent.`);
|
|
632
671
|
}
|
|
672
|
+
let changeInterval = null;
|
|
673
|
+
if (intervalVersion && event.changeInterval !== null) {
|
|
674
|
+
const interval = exactRecord(event.changeInterval, new Set(FLIGHT_RECORDER_CHANGE_INTERVAL_KEYS), `${label}.changeInterval`);
|
|
675
|
+
const after = requireIsoTimestamp(interval.after, `${label}.changeInterval.after`);
|
|
676
|
+
const by = requireIsoTimestamp(interval.by, `${label}.changeInterval.by`);
|
|
677
|
+
if (kind !== 'observed_change' || after !== interval.after || by !== interval.by || by !== observedAt || Date.parse(after) >= Date.parse(by)) {
|
|
678
|
+
throw new TypeError(`${label} has an inconsistent change interval.`);
|
|
679
|
+
}
|
|
680
|
+
changeInterval = Object.freeze({ after, by, incompleteObservations: exactRecorderInteger(interval.incompleteObservations, 0, MAX_FLIGHT_RECORDER_OBSERVATIONS, `${label}.changeInterval.incompleteObservations`) });
|
|
681
|
+
}
|
|
682
|
+
if (intervalVersion && (kind === 'observed_change') !== (changeInterval !== null))
|
|
683
|
+
throw new TypeError(`${label} must retain its change interval.`);
|
|
633
684
|
let approvedWindow = null;
|
|
634
685
|
if (event.approvedWindow !== null) {
|
|
635
686
|
if (event.kind !== 'observed_change')
|
|
636
687
|
throw new TypeError(`${label}.approvedWindow is inconsistent.`);
|
|
637
|
-
const window = exactRecord(event.approvedWindow, EVENT_APPROVED_WINDOW_KEYS, `${label}.approvedWindow`);
|
|
688
|
+
const window = exactRecord(event.approvedWindow, intervalVersion ? EVENT_APPROVED_WINDOW_KEYS : new Set(HISTORICAL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS), `${label}.approvedWindow`);
|
|
689
|
+
const startsAt = intervalVersion ? requireIsoTimestamp(window.startsAt, `${label}.approvedWindow.startsAt`) : undefined;
|
|
690
|
+
const endsAt = intervalVersion ? requireIsoTimestamp(window.endsAt, `${label}.approvedWindow.endsAt`) : undefined;
|
|
691
|
+
if (intervalVersion && (!changeInterval || startsAt !== window.startsAt || endsAt !== window.endsAt
|
|
692
|
+
|| Date.parse(startsAt) > Date.parse(changeInterval.after) || Date.parse(endsAt) < Date.parse(changeInterval.by))) {
|
|
693
|
+
throw new TypeError(`${label}.approvedWindow does not contain the change interval.`);
|
|
694
|
+
}
|
|
638
695
|
approvedWindow = Object.freeze({
|
|
639
696
|
id: exactRecorderText(window.id, `${label}.approvedWindow.id`, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH),
|
|
640
697
|
reason: exactRecorderText(window.reason, `${label}.approvedWindow.reason`, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH),
|
|
698
|
+
...(intervalVersion ? { startsAt: startsAt, endsAt: endsAt } : {}),
|
|
641
699
|
});
|
|
642
700
|
kindCounts.approved_change += 1;
|
|
643
701
|
}
|
|
@@ -655,16 +713,33 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
655
713
|
source: exactRecorderText(event.source, `${label}.source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
|
|
656
714
|
approvedWindow,
|
|
657
715
|
explanation: exactRecorderText(event.explanation, `${label}.explanation`, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH : MAX_FLIGHT_RECORDER_VALUE_LENGTH),
|
|
716
|
+
...(intervalVersion ? { collectionDepth: collectionDepth, changeInterval } : {}),
|
|
658
717
|
});
|
|
659
718
|
if (!legacy) {
|
|
660
|
-
const
|
|
719
|
+
const key = `${eventDomain}\u0000${event.field}`;
|
|
720
|
+
const previous = intervalVersion ? lastComplete.get(key) : latest.get(key);
|
|
661
721
|
if (seenIds.has(result.id) || (kind === 'observed_change' && (!previous || previous.state !== 'observed'
|
|
662
722
|
|| previous.source !== result.source || Date.parse(previous.observedAt) >= Date.parse(result.observedAt)
|
|
663
|
-
|| !sameValues(previous.after, before) || sameValues(before, after)
|
|
723
|
+
|| !sameValues(previous.after, before) || sameValues(before, after)
|
|
724
|
+
|| intervalVersion && (previous.collectionDepth !== result.collectionDepth || !changeInterval
|
|
725
|
+
|| Date.parse(changeInterval.after) < Date.parse(previous.observedAt)
|
|
726
|
+
|| changeInterval.incompleteObservations !== (incompleteSince.get(key) ?? 0))))) {
|
|
664
727
|
throw new TypeError(`${label} has a duplicate identity or an unsupported observed-change claim.`);
|
|
665
728
|
}
|
|
666
729
|
seenIds.add(result.id);
|
|
667
|
-
latest.set(
|
|
730
|
+
latest.set(key, result);
|
|
731
|
+
if (intervalVersion) {
|
|
732
|
+
if (expectedState === 'observed') {
|
|
733
|
+
lastComplete.set(key, result);
|
|
734
|
+
incompleteSince.delete(key);
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
incompleteSince.set(key, (incompleteSince.get(key) ?? 0) + 1);
|
|
738
|
+
if (previous && result.source !== 'Not supplied in this capture'
|
|
739
|
+
&& (previous.source !== result.source || result.collectionDepth !== 'unknown' && previous.collectionDepth !== result.collectionDepth))
|
|
740
|
+
lastComplete.delete(key);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
668
743
|
}
|
|
669
744
|
return result;
|
|
670
745
|
});
|
|
@@ -685,14 +760,15 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
685
760
|
throw new TypeError('Domain control flight-recorder document summary is inconsistent.');
|
|
686
761
|
}
|
|
687
762
|
}
|
|
688
|
-
const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS
|
|
763
|
+
const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS
|
|
764
|
+
: intervalVersion ? DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS : SOURCE_TIMED_FLIGHT_RECORDER_LIMITATIONS;
|
|
689
765
|
const limitationsInput = boundedDataArray(root.limitations, 'Domain control flight-recorder document limitations', limitations.length, limitations.length);
|
|
690
766
|
if (limitationsInput.some((item, index) => item !== limitations[index])) {
|
|
691
767
|
throw new TypeError('Domain control flight-recorder document limitations are invalid.');
|
|
692
768
|
}
|
|
693
769
|
const document = Object.freeze({
|
|
694
770
|
schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
|
|
695
|
-
version:
|
|
771
|
+
version: root.version,
|
|
696
772
|
generatedAt,
|
|
697
773
|
domains: Object.freeze(domains),
|
|
698
774
|
observationCount,
|
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { domainToASCII } from 'node:url';
|
|
3
3
|
import { canonicalArtifactJsonFor, } from "../packages/evidence/artifact-integrity.mjs";
|
|
4
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_CURRENT_DOMAIN_CONTROL_RECORDS, DOMAIN_CONTROL_RECORD_LIST_FIELDS, DOMAIN_CONTROL_RECORD_MODE_OPTIONS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION, 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, PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS,
|
|
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, domainControlReviewCountKeys, domainControlReviewLimitations, 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, PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS, PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, 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
6
|
import { assertDomainControlManifestByteBudget, buildUnsignedDomainControlManifest, canonicalDomainControlRecordList, domainControlRecordMode, domainControlEvidenceAdmission, normalizeDomainControlManifestDocument, } from "../packages/evidence/domain-control-runtime.mjs";
|
|
7
7
|
import { exactKeys } from "./bounded-contract-normalizers.mjs";
|
|
8
8
|
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
@@ -323,28 +323,36 @@ function renewalComparison(entry, now) {
|
|
|
323
323
|
});
|
|
324
324
|
const due = Date.parse(entry.renewalReviewAt) <= Date.parse(now);
|
|
325
325
|
return Object.freeze({
|
|
326
|
-
field: 'renewalReviewAt', state: due ? 'due' : '
|
|
326
|
+
field: 'renewalReviewAt', state: due ? 'due' : 'not_due', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
|
|
327
327
|
explanation: due ? 'The configured renewal review date is due.' : 'The configured renewal review date is still in the future.',
|
|
328
328
|
expectation: 'expect_records',
|
|
329
329
|
});
|
|
330
330
|
}
|
|
331
|
-
function aggregateComparisonState(comparisons,
|
|
332
|
-
if (comparisons.some((item) => item.state === 'drift' || item.state === 'due'))
|
|
331
|
+
function aggregateComparisonState(comparisons, version = DOMAIN_CONTROL_REVIEW_VERSION) {
|
|
332
|
+
if (comparisons.some((item) => item.state === 'drift' || (version < 3 && item.state === 'due')))
|
|
333
333
|
return 'drift';
|
|
334
334
|
if (comparisons.some((item) => ['partial', 'unavailable', 'unsupported'].includes(item.state)))
|
|
335
335
|
return 'partial';
|
|
336
|
-
if (
|
|
336
|
+
if (comparisons.some((item) => item.state === 'due'))
|
|
337
|
+
return 'due';
|
|
338
|
+
if (version === 1 || comparisons.some((item) => item.state === 'aligned'))
|
|
337
339
|
return 'aligned';
|
|
338
|
-
|
|
340
|
+
if (comparisons.some((item) => item.state === 'observed'))
|
|
341
|
+
return 'observed';
|
|
342
|
+
return comparisons.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
|
|
339
343
|
}
|
|
340
|
-
function aggregateDomainState(domains,
|
|
344
|
+
function aggregateDomainState(domains, version = DOMAIN_CONTROL_REVIEW_VERSION) {
|
|
341
345
|
if (domains.some((item) => item.state === 'drift'))
|
|
342
346
|
return 'drift';
|
|
343
347
|
if (domains.some((item) => item.state === 'partial'))
|
|
344
348
|
return 'partial';
|
|
345
|
-
if (
|
|
349
|
+
if (domains.some((item) => item.state === 'due'))
|
|
350
|
+
return 'due';
|
|
351
|
+
if (version === 1 || domains.some((item) => item.state === 'aligned'))
|
|
346
352
|
return 'aligned';
|
|
347
|
-
|
|
353
|
+
if (domains.some((item) => item.state === 'observed'))
|
|
354
|
+
return 'observed';
|
|
355
|
+
return domains.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
|
|
348
356
|
}
|
|
349
357
|
export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
350
358
|
const source = record(input);
|
|
@@ -405,10 +413,10 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
405
413
|
throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
|
|
406
414
|
}
|
|
407
415
|
const legacy = root.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
|
|
408
|
-
const countKeys =
|
|
416
|
+
const countKeys = domainControlReviewCountKeys(root.version);
|
|
409
417
|
const comparisonKeys = legacy ? new Set(PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS) : REVIEW_COMPARISON_KEYS;
|
|
410
418
|
const recordMaximum = legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS;
|
|
411
|
-
const limitations =
|
|
419
|
+
const limitations = domainControlReviewLimitations(root.version);
|
|
412
420
|
const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
|
|
413
421
|
const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
|
|
414
422
|
const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
|
|
@@ -492,16 +500,16 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
492
500
|
|| (expectation === 'unconfigured' && (state !== 'not_configured' || observed.length || source !== null || observedAt !== null))
|
|
493
501
|
|| (state === 'not_configured' && expectation !== 'unconfigured')
|
|
494
502
|
|| (state === 'observed' && expectation !== 'observe_only')
|
|
495
|
-
|| (['aligned', 'drift', 'due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
|
|
503
|
+
|| (['aligned', 'drift', 'due', 'not_due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
|
|
496
504
|
throw new TypeError('Domain control review comparison expectation is inconsistent.');
|
|
497
505
|
}
|
|
498
506
|
if (expectedField === 'renewalReviewAt' && expectation === 'expect_records') {
|
|
499
507
|
if (desired.length !== 1 || normalizeExplicitIsoTimestamp(desired[0]) !== desired[0]
|
|
500
|
-
|| state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : 'aligned'))
|
|
508
|
+
|| state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : root.version === 2 ? 'aligned' : 'not_due'))
|
|
501
509
|
throw new TypeError('Domain control renewal review state is inconsistent.');
|
|
502
510
|
}
|
|
503
|
-
else if (state === 'due')
|
|
504
|
-
throw new TypeError('Only renewal review dates can be due.');
|
|
511
|
+
else if (state === 'due' || state === 'not_due')
|
|
512
|
+
throw new TypeError('Only renewal review dates can be due or not due.');
|
|
505
513
|
if (expectedField !== 'renewalReviewAt' && ['aligned', 'drift', 'observed'].includes(state)) {
|
|
506
514
|
if (!source || domainControlEvidenceAdmission(observedAt, generatedAt))
|
|
507
515
|
throw new TypeError('Domain control review requires current source timing for a conclusive comparison.');
|
|
@@ -521,7 +529,7 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
521
529
|
...(expectation !== undefined ? { expectation } : {}),
|
|
522
530
|
});
|
|
523
531
|
});
|
|
524
|
-
const expectedState = aggregateComparisonState(comparisons,
|
|
532
|
+
const expectedState = aggregateComparisonState(comparisons, root.version);
|
|
525
533
|
if (item.state !== expectedState) {
|
|
526
534
|
throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
|
|
527
535
|
}
|
|
@@ -535,7 +543,7 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
535
543
|
throw new TypeError('Domain control review document counts are inconsistent.');
|
|
536
544
|
}
|
|
537
545
|
}
|
|
538
|
-
const expectedState = expired ? 'expired' : aggregateDomainState(domains,
|
|
546
|
+
const expectedState = expired ? 'expired' : aggregateDomainState(domains, root.version);
|
|
539
547
|
if (root.state !== expectedState)
|
|
540
548
|
throw new TypeError('Domain control review document state is inconsistent.');
|
|
541
549
|
const ignoredObservationCount = exactReviewInteger(root.ignoredObservationCount, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, 'Domain control review document ignored observation count');
|