@slicedearth/whoisleuth-cli 1.47.4 → 2.0.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/README.md +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- 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 +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- 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 +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -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 +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- 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-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/project-metadata.mjs +1 -1
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- 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-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- 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 +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- 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 +13 -5
- package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- 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 -21
- 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} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- 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/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -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/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- 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} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- 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/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- 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} +71 -48
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -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} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- 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} +13 -13
- 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} +110 -89
- package/third-party-notices.txt +14 -14
- 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 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- 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 -255
- 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 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /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,21 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
|
|
3
|
+
import { ACQUISITION_DECISIONS, ACQUISITION_MANUAL_CHECKS, ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../packages/investigation/acquisition-decision-packet.mjs";
|
|
4
|
+
import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../packages/investigation/bulk-domain-comparison.mjs";
|
|
5
|
+
import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../packages/investigation/bulk-mail-exposure.mjs";
|
|
6
|
+
import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../packages/investigation/bulk-review-export.mjs";
|
|
7
|
+
import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSIVE_URLS, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILES, RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, RESPONSE_ROUTE_STALE_AFTER_DAYS, } from "../packages/cases/case-response-packet.mjs";
|
|
8
|
+
import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PROVIDER_OUTCOMES, isLegalCaseActionTransition, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_DECISION_PIN_REFERENCES, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RECIPIENT_LENGTH, } from "../packages/cases/case-response-model.mjs";
|
|
9
|
+
import { INVESTIGATION_CAPSULE_ANALYST_RECORDS_SCHEMA, INVESTIGATION_CAPSULE_ANALYST_RECORDS_VERSION, INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, PUBLIC_INVESTIGATION_CAPSULE_VERSION, } from "../packages/investigation/investigation-capsule.mjs";
|
|
10
|
+
import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, } from "../packages/investigation/lookup-asset-graph.mjs";
|
|
11
|
+
import { PUBLIC_LOOKUP_INVESTIGATION_BRIEF_VERSION, LOOKUP_INVESTIGATION_BRIEF_SCHEMA, LOOKUP_INVESTIGATION_BRIEF_VERSION, MAX_LOOKUP_INVESTIGATION_BRIEF_BYTES, } from "../packages/investigation/lookup-investigation-brief.mjs";
|
|
12
|
+
import { DECISION_FACT_CONSISTENCY_STATES, DECISION_FACT_EVIDENCE_STATES, DECISION_FACT_FRESHNESS_STATES, DECISION_FACT_PROVENANCE_STATES, DECISION_FACT_PROJECTION_VERSION, DECISION_FACT_VERSION, MAX_DECISION_FACT_CONTRIBUTORS, MAX_DECISION_FACT_CONTRADICTIONS, MAX_DECISION_FACT_LIMITATIONS, MAX_DECISION_FACT_NEXT_ACTIONS, MAX_DECISION_FACT_PROJECTION_CONTRADICTIONS, MAX_DECISION_FACT_PROJECTION_BYTES, MAX_DECISION_FACT_PROJECTION_FACTS, MAX_DECISION_FACT_PROJECTION_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_NEXT_ACTIONS, MAX_DECISION_FACT_PROJECTION_REFERENCES, MAX_DECISION_FACT_PROJECTION_SOURCES, MAX_DECISION_FACT_PROJECTION_SOURCE_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_SOURCE_REFERENCES, MAX_DECISION_FACT_REFERENCES, MAX_DECISION_FACTS, decisionFactCompleteness, } from "../packages/evidence/decision-fact.mjs";
|
|
13
|
+
import { LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, LOOKUP_CLAIM_PASSPORT_VERSION, MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, } from "../packages/investigation/lookup-claim-passport.mjs";
|
|
14
|
+
import { LOOKUP_CLAIM_IDS, LOOKUP_CLAIM_READINESS_VERSION, LOOKUP_CLAIM_REQUIREMENT_IDS, } from "../packages/investigation/lookup-claim-readiness.mjs";
|
|
15
|
+
import { normalizeDomainControlManifestDocument } from "../packages/evidence/domain-control-runtime.mjs";
|
|
12
16
|
import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION } from "../lib/domain-change-packet.mjs";
|
|
13
17
|
import { DOMAIN_CONTROL_MANIFEST_SCHEMA } from "../lib/domain-control-manifest.mjs";
|
|
14
18
|
import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, } from "./investigation-manifest.mjs";
|
|
15
|
-
import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree,
|
|
19
|
+
import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
|
|
16
20
|
import { compareRegistrySources } from "../lib/registry-comparison.mjs";
|
|
17
21
|
import { buildRegistryInsights } from "../lib/registry-insights.mjs";
|
|
18
22
|
import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
|
|
23
|
+
import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../lib/homepage-metadata-contract.mjs";
|
|
19
24
|
import { isDeepStrictEqual } from 'node:util';
|
|
20
25
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
21
26
|
const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
@@ -124,7 +129,7 @@ const LOOKUP_TIMING_SOURCES = [
|
|
|
124
129
|
'network_context', 'security_txt', 'external_intelligence',
|
|
125
130
|
'malware_host_intelligence', 'malware_ioc_intelligence',
|
|
126
131
|
];
|
|
127
|
-
const
|
|
132
|
+
const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
|
|
128
133
|
'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
|
|
129
134
|
'rdapServer', 'nameservers', 'statuses', 'registrar', 'registrant', 'abuse',
|
|
130
135
|
'createdDate', 'expiryDate', 'createdDateIso', 'expiryDateIso', 'domainAgeDays',
|
|
@@ -137,11 +142,40 @@ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
|
|
|
137
142
|
'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
|
|
138
143
|
'hasDmarc', 'bulkComparison', 'limitations',
|
|
139
144
|
];
|
|
145
|
+
const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS.filter((key) => !['registrar', 'registrant', 'abuse'].includes(key));
|
|
140
146
|
const LOOKUP_IDN_ANALYSIS_KEYS = [
|
|
141
147
|
'version', 'mappingVersion', 'asciiDomain', 'unicodeDomain', 'hasIdn',
|
|
142
148
|
'scripts', 'labels', 'mixedScript', 'skeleton', 'referenceMatches', 'findings',
|
|
143
149
|
'truncated', 'limitations',
|
|
144
150
|
];
|
|
151
|
+
function validateLookupEvidenceHomepageMetadata(availability, version) {
|
|
152
|
+
const pageIdentity = availability.pageIdentity === null || availability.pageIdentity === undefined
|
|
153
|
+
? null
|
|
154
|
+
: record(availability.pageIdentity, 'Lookup evidence page identity');
|
|
155
|
+
const http = availability.http === null || availability.http === undefined
|
|
156
|
+
? null
|
|
157
|
+
: record(availability.http, 'Lookup evidence HTTP analysis');
|
|
158
|
+
const response = http?.response === null || http?.response === undefined
|
|
159
|
+
? null
|
|
160
|
+
: record(http.response, 'Lookup evidence HTTP response');
|
|
161
|
+
const publicationPresent = Boolean(pageIdentity && Object.hasOwn(pageIdentity, 'publicationMetadata'));
|
|
162
|
+
const deliveryPresent = Boolean(response && Object.hasOwn(response, 'deliveryMetadata'));
|
|
163
|
+
if (version < LOOKUP_EVIDENCE_SCHEMA_VERSION && (publicationPresent || deliveryPresent)) {
|
|
164
|
+
fail('Lookup evidence homepage metadata epoch');
|
|
165
|
+
}
|
|
166
|
+
if (publicationPresent && !validPagePublicationMetadata(pageIdentity?.publicationMetadata)) {
|
|
167
|
+
fail('Lookup evidence page publication metadata');
|
|
168
|
+
}
|
|
169
|
+
if (publicationPresent && !['success', 'partial'].includes(String(pageIdentity?.status))) {
|
|
170
|
+
fail('Lookup evidence page publication metadata source state');
|
|
171
|
+
}
|
|
172
|
+
if (deliveryPresent && !validHttpDeliveryMetadata(response?.deliveryMetadata)) {
|
|
173
|
+
fail('Lookup evidence HTTP delivery metadata');
|
|
174
|
+
}
|
|
175
|
+
if (deliveryPresent && !['success', 'partial'].includes(String(http?.status))) {
|
|
176
|
+
fail('Lookup evidence HTTP delivery metadata source state');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
145
179
|
function validateLookupEvidenceRdapAttempt(value, label) {
|
|
146
180
|
const attempt = exact(value, ['endpoint', 'transportSecurity', 'status', 'outcome', 'detail', 'selected'], label);
|
|
147
181
|
let endpoint = null;
|
|
@@ -351,45 +385,40 @@ function validateLookupEvidenceRdap(value, version) {
|
|
|
351
385
|
const error = exact(source, ['status', 'error', 'attempts'], 'Lookup evidence RDAP source');
|
|
352
386
|
text(error.error, 'Lookup evidence RDAP error', 10_000);
|
|
353
387
|
array(error.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
|
|
354
|
-
|
|
355
|
-
record(item, `Lookup evidence legacy RDAP attempt ${index + 1}`);
|
|
356
|
-
else
|
|
357
|
-
validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
|
|
388
|
+
validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
|
|
358
389
|
});
|
|
359
390
|
return 'error';
|
|
360
391
|
}
|
|
361
|
-
const complete = exact(source,
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
392
|
+
const complete = exact(source, version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
|
|
393
|
+
? ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts', 'parsed']
|
|
394
|
+
: ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts', 'parsed', 'raw'], 'Lookup evidence RDAP source');
|
|
395
|
+
const status = enumeration(complete.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence RDAP status');
|
|
365
396
|
let endpoint = null;
|
|
366
397
|
if (complete.endpoint !== null)
|
|
367
398
|
endpoint = absoluteUrl(complete.endpoint, 'Lookup evidence RDAP endpoint', ['http:', 'https:']);
|
|
368
399
|
if (complete.transportSecurity !== null)
|
|
369
400
|
enumeration(complete.transportSecurity, ['http', 'https'], 'Lookup evidence RDAP transport');
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
fail('Lookup evidence RDAP transport');
|
|
375
|
-
}
|
|
401
|
+
if (endpoint && complete.transportSecurity !== endpoint.protocol.slice(0, -1))
|
|
402
|
+
fail('Lookup evidence RDAP transport');
|
|
403
|
+
if (!endpoint && complete.transportSecurity !== null)
|
|
404
|
+
fail('Lookup evidence RDAP transport');
|
|
376
405
|
if (complete.httpStatus !== null)
|
|
377
406
|
integer(complete.httpStatus, 'Lookup evidence RDAP HTTP status', 100, 599);
|
|
378
407
|
iso(complete.fetchedAt, 'Lookup evidence RDAP fetchedAt', true);
|
|
379
408
|
array(complete.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
|
|
380
|
-
|
|
381
|
-
record(item, `Lookup evidence legacy RDAP attempt ${index + 1}`);
|
|
382
|
-
else
|
|
383
|
-
validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
|
|
409
|
+
validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
|
|
384
410
|
});
|
|
385
411
|
nullableRecord(complete.parsed, 'Lookup evidence RDAP parsed data');
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
412
|
+
if (version < LOOKUP_EVIDENCE_SCHEMA_VERSION)
|
|
413
|
+
nullableRecord(complete.raw, 'Lookup evidence RDAP raw data');
|
|
414
|
+
if (version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
|
|
415
|
+
&& !isDeepStrictEqual(complete.parsed, projectLookupEvidenceRdapPublication(complete.parsed))) {
|
|
416
|
+
fail('Lookup evidence RDAP portable publication');
|
|
417
|
+
}
|
|
418
|
+
if (status === 'success' && complete.parsed === null)
|
|
389
419
|
fail('Lookup evidence RDAP publication');
|
|
390
|
-
if (
|
|
391
|
-
&&
|
|
392
|
-
&& (complete.parsed !== null || complete.raw !== null))
|
|
420
|
+
if (!['success', 'partial'].includes(status)
|
|
421
|
+
&& (complete.parsed !== null || (version < LOOKUP_EVIDENCE_SCHEMA_VERSION && complete.raw !== null)))
|
|
393
422
|
fail('Lookup evidence RDAP unavailable publication');
|
|
394
423
|
return status;
|
|
395
424
|
}
|
|
@@ -401,24 +430,22 @@ function validateLookupEvidenceWhois(value, version) {
|
|
|
401
430
|
return 'error';
|
|
402
431
|
}
|
|
403
432
|
const complete = exact(source, ['status', 'queriedAt', 'authoritativeHop', 'failedHop', 'conflictingHop', 'parsed', 'chain'], 'Lookup evidence WHOIS source');
|
|
404
|
-
const status = enumeration(complete.status,
|
|
405
|
-
? ['complete', 'partial', 'unknown']
|
|
406
|
-
: ['complete', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence WHOIS status');
|
|
433
|
+
const status = enumeration(complete.status, ['complete', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence WHOIS status');
|
|
407
434
|
iso(complete.queriedAt, 'Lookup evidence WHOIS queriedAt', true);
|
|
408
435
|
optionalText(complete.authoritativeHop, 'Lookup evidence WHOIS authoritative hop', 253);
|
|
409
436
|
optionalText(complete.failedHop, 'Lookup evidence WHOIS failed hop', 253);
|
|
410
437
|
optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
|
|
411
438
|
nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
|
|
439
|
+
if (version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
|
|
440
|
+
&& !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed))) {
|
|
441
|
+
fail('Lookup evidence WHOIS portable publication');
|
|
442
|
+
}
|
|
412
443
|
array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
|
|
413
|
-
|
|
414
|
-
record(item, `Lookup evidence legacy WHOIS chain item ${index + 1}`);
|
|
415
|
-
else
|
|
416
|
-
validateLookupEvidenceWhoisHop(item, `Lookup evidence WHOIS chain item ${index + 1}`);
|
|
444
|
+
validateLookupEvidenceWhoisHop(item, `Lookup evidence WHOIS chain item ${index + 1}`);
|
|
417
445
|
});
|
|
418
446
|
if (status === 'complete' && complete.parsed === null)
|
|
419
447
|
fail('Lookup evidence WHOIS publication');
|
|
420
|
-
if (
|
|
421
|
-
&& !['complete', 'partial'].includes(status)
|
|
448
|
+
if (!['complete', 'partial'].includes(status)
|
|
422
449
|
&& (complete.parsed !== null || complete.chain.length !== 0))
|
|
423
450
|
fail('Lookup evidence WHOIS unavailable publication');
|
|
424
451
|
return status;
|
|
@@ -687,7 +714,7 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
687
714
|
if (root.schema !== LOOKUP_EVIDENCE_SCHEMA
|
|
688
715
|
|| !SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS.includes(Number(root.schemaVersion)))
|
|
689
716
|
fail('Lookup evidence');
|
|
690
|
-
const version = integer(root.schemaVersion, 'Lookup evidence version',
|
|
717
|
+
const version = integer(root.schemaVersion, 'Lookup evidence version', PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, LOOKUP_EVIDENCE_SCHEMA_VERSION);
|
|
691
718
|
iso(root.generatedAt, 'Lookup evidence generatedAt');
|
|
692
719
|
const application = exact(root.application, ['name', 'version', 'projectUrl'], 'Lookup evidence application');
|
|
693
720
|
if (application.name !== 'WHOISleuth' || application.projectUrl !== WHOISLEUTH_SOURCE_REPOSITORY_URL)
|
|
@@ -707,79 +734,45 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
707
734
|
}
|
|
708
735
|
else if (query.inputHostname !== null || query.registrableDomain !== null || query.isSubdomain !== false)
|
|
709
736
|
fail('Lookup evidence query');
|
|
710
|
-
if (
|
|
711
|
-
&& !isDeepStrictEqual(query, projectLookupEvidenceQuery(query)))
|
|
737
|
+
if (!isDeepStrictEqual(query, projectLookupEvidenceQuery(query)))
|
|
712
738
|
fail('Lookup evidence query');
|
|
713
739
|
let diagnostics;
|
|
714
740
|
let rdapDiagnosticStatus;
|
|
715
741
|
let whoisDiagnosticStatus;
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
integer(diagnostics.version, 'Lookup evidence diagnostics version', 1, 10);
|
|
721
|
-
const rdapDiagnostics = record(diagnostics.rdap, 'Lookup evidence rdap diagnostics');
|
|
722
|
-
const whoisDiagnostics = record(diagnostics.whois, 'Lookup evidence whois diagnostics');
|
|
723
|
-
rdapDiagnosticStatus = enumeration(rdapDiagnostics.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence RDAP diagnostic status');
|
|
724
|
-
whoisDiagnosticStatus = enumeration(whoisDiagnostics.status, ['complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence WHOIS diagnostic status');
|
|
725
|
-
for (const id of ['availability', 'timing', 'registryAccess', 'reverseDns', 'network', 'securityTxt', 'sslbl']) {
|
|
726
|
-
if (diagnostics[id] !== undefined)
|
|
727
|
-
record(diagnostics[id], `Lookup evidence ${id} diagnostics`);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
else {
|
|
731
|
-
diagnostics = record(root.diagnostics, 'Lookup evidence diagnostics');
|
|
732
|
-
currentDiagnosticStates = validateLookupEvidenceCurrentDiagnostics(diagnostics);
|
|
733
|
-
rdapDiagnosticStatus = currentDiagnosticStates.rdap;
|
|
734
|
-
whoisDiagnosticStatus = currentDiagnosticStates.whois;
|
|
735
|
-
}
|
|
742
|
+
diagnostics = record(root.diagnostics, 'Lookup evidence diagnostics');
|
|
743
|
+
const currentDiagnosticStates = validateLookupEvidenceCurrentDiagnostics(diagnostics);
|
|
744
|
+
rdapDiagnosticStatus = currentDiagnosticStates.rdap;
|
|
745
|
+
whoisDiagnosticStatus = currentDiagnosticStates.whois;
|
|
736
746
|
const sources = exact(root.sources, ['rdap', 'whois', 'reverseDns', 'network', 'securityTxt', 'sslbl'], 'Lookup evidence sources');
|
|
737
747
|
const rdapSourceStatus = validateLookupEvidenceRdap(sources.rdap, version);
|
|
738
748
|
const whoisSourceStatus = validateLookupEvidenceWhois(sources.whois, version);
|
|
739
|
-
if (
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
fail('Lookup evidence WHOIS source state');
|
|
744
|
-
}
|
|
745
|
-
else {
|
|
746
|
-
const rdapSource = record(sources.rdap, 'Lookup evidence legacy RDAP source');
|
|
747
|
-
const whoisSource = record(sources.whois, 'Lookup evidence legacy WHOIS source');
|
|
748
|
-
if (rdapDiagnosticStatus === 'success'
|
|
749
|
-
&& (rdapSourceStatus !== 'success' || rdapSource.parsed === null))
|
|
750
|
-
fail('Lookup evidence legacy RDAP source state');
|
|
751
|
-
if (rdapDiagnosticStatus === 'not_found' && rdapSourceStatus !== 'not_found')
|
|
752
|
-
fail('Lookup evidence legacy RDAP source state');
|
|
753
|
-
if (rdapDiagnosticStatus === 'error' && rdapSourceStatus !== 'error')
|
|
754
|
-
fail('Lookup evidence legacy RDAP source state');
|
|
755
|
-
if (whoisDiagnosticStatus === 'complete'
|
|
756
|
-
&& (whoisSourceStatus !== 'complete' || whoisSource.parsed === null))
|
|
757
|
-
fail('Lookup evidence legacy WHOIS source state');
|
|
758
|
-
if (whoisDiagnosticStatus === 'partial'
|
|
759
|
-
&& (whoisSourceStatus !== 'partial' || whoisSource.parsed === null))
|
|
760
|
-
fail('Lookup evidence legacy WHOIS source state');
|
|
761
|
-
if (whoisDiagnosticStatus === 'error' && whoisSourceStatus !== 'error')
|
|
762
|
-
fail('Lookup evidence legacy WHOIS source state');
|
|
763
|
-
}
|
|
749
|
+
if (rdapSourceStatus !== rdapDiagnosticStatus)
|
|
750
|
+
fail('Lookup evidence RDAP source state');
|
|
751
|
+
if (whoisSourceStatus !== whoisDiagnosticStatus)
|
|
752
|
+
fail('Lookup evidence WHOIS source state');
|
|
764
753
|
const reverseDnsState = validateLookupEvidenceReverseDns(sources.reverseDns);
|
|
765
754
|
const networkState = validateLookupEvidenceNetwork(sources.network);
|
|
766
|
-
if (currentDiagnosticStates
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
fail('Lookup evidence network source state');
|
|
774
|
-
}
|
|
755
|
+
if (currentDiagnosticStates.reverseDns
|
|
756
|
+
&& !isDeepStrictEqual(reverseDnsState, currentDiagnosticStates.reverseDns)) {
|
|
757
|
+
fail('Lookup evidence reverse DNS source state');
|
|
758
|
+
}
|
|
759
|
+
if (currentDiagnosticStates.network !== null
|
|
760
|
+
&& (networkState?.status ?? null) !== currentDiagnosticStates.network) {
|
|
761
|
+
fail('Lookup evidence network source state');
|
|
775
762
|
}
|
|
776
763
|
validateLookupEvidenceSecurityTxt(sources.securityTxt);
|
|
777
764
|
validateLookupEvidenceSslbl(sources.sslbl);
|
|
778
765
|
const analysis = exact(root.analysis, ['availability', 'idn', 'registryInsights', 'registryComparison', 'registrarPublicationComparison'], 'Lookup evidence analysis');
|
|
779
766
|
if (analysis.availability !== null) {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
767
|
+
const currentAvailability = version >= LOOKUP_EVIDENCE_SCHEMA_VERSION;
|
|
768
|
+
const availability = exactOptional(analysis.availability, currentAvailability ? ['registryContactsExcluded'] : [], currentAvailability ? LOOKUP_AVAILABILITY_ANALYSIS_KEYS : PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS, 'Lookup evidence availability analysis');
|
|
769
|
+
if (currentAvailability && availability.registryContactsExcluded !== true) {
|
|
770
|
+
fail('Lookup evidence availability contact exclusion');
|
|
771
|
+
}
|
|
772
|
+
validateLookupEvidenceHomepageMetadata(availability, version);
|
|
773
|
+
if (!isDeepStrictEqual(analysis.availability, currentAvailability
|
|
774
|
+
? projectLookupEvidenceAvailability(analysis.availability)
|
|
775
|
+
: projectLookupEvidenceAvailabilityPublic(analysis.availability)))
|
|
783
776
|
fail('Lookup evidence availability analysis');
|
|
784
777
|
}
|
|
785
778
|
if (analysis.idn !== null) {
|
|
@@ -788,7 +781,7 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
788
781
|
const registryInsights = record(analysis.registryInsights, 'Lookup evidence registry insights');
|
|
789
782
|
const registryComparison = record(analysis.registryComparison, 'Lookup evidence registry comparison');
|
|
790
783
|
validateLookupEvidenceRegistrarComparison(analysis.registrarPublicationComparison);
|
|
791
|
-
|
|
784
|
+
{
|
|
792
785
|
const rdap = record(sources.rdap, 'Lookup evidence RDAP source');
|
|
793
786
|
const whois = record(sources.whois, 'Lookup evidence WHOIS source');
|
|
794
787
|
const rdapParsed = ['success', 'partial'].includes(rdapDiagnosticStatus) ? rdap.parsed : null;
|
|
@@ -799,7 +792,7 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
799
792
|
});
|
|
800
793
|
if (!isDeepStrictEqual(registryComparison, expectedComparison))
|
|
801
794
|
fail('Lookup evidence registry comparison derivation');
|
|
802
|
-
const
|
|
795
|
+
const rebuiltInsights = buildRegistryInsights({
|
|
803
796
|
rdapParsed,
|
|
804
797
|
rdapStatus: rdapDiagnosticStatus,
|
|
805
798
|
rdapFetchedAt: rdap.fetchedAt,
|
|
@@ -807,6 +800,9 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
807
800
|
whoisStatus: whoisDiagnosticStatus,
|
|
808
801
|
whoisQueriedAt: whois.queriedAt,
|
|
809
802
|
});
|
|
803
|
+
const expectedInsights = version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
|
|
804
|
+
? projectLookupEvidenceRegistryInsights(rebuiltInsights)
|
|
805
|
+
: rebuiltInsights;
|
|
810
806
|
if (!isDeepStrictEqual(registryInsights, expectedInsights))
|
|
811
807
|
fail('Lookup evidence registry insight derivation');
|
|
812
808
|
try {
|
|
@@ -817,16 +813,11 @@ export function validateLookupEvidenceArtifactStructure(value) {
|
|
|
817
813
|
}
|
|
818
814
|
}
|
|
819
815
|
}
|
|
820
|
-
function validateIntegrity(value, label, version,
|
|
821
|
-
|
|
822
|
-
if (version !== legacyVersion && version !== currentVersion)
|
|
816
|
+
function validateIntegrity(value, label, version, currentVersion) {
|
|
817
|
+
if (version !== currentVersion)
|
|
823
818
|
fail(label);
|
|
824
|
-
const integrity = exact(value,
|
|
825
|
-
|
|
826
|
-
: ['algorithm', 'digestSha256'], label);
|
|
827
|
-
if (integrity.algorithm !== 'SHA-256')
|
|
828
|
-
fail(label);
|
|
829
|
-
if (explicit && integrity.canonicalization !== (version === currentVersion ? 'sorted-json-v2' : 'sorted-json-v1'))
|
|
819
|
+
const integrity = exact(value, ['algorithm', 'canonicalization', 'digestSha256'], label);
|
|
820
|
+
if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2')
|
|
830
821
|
fail(label);
|
|
831
822
|
digest(integrity.digestSha256, label);
|
|
832
823
|
}
|
|
@@ -877,7 +868,7 @@ function validateAcquisition(value) {
|
|
|
877
868
|
strings(evidence.nextSteps, 'Acquisition next steps', 6, 600);
|
|
878
869
|
strings(evidence.limitations, 'Acquisition evidence limitations', 12, 600);
|
|
879
870
|
strings(root.limitations, 'Acquisition limitations', 8, 600);
|
|
880
|
-
validateIntegrity(root.integrity, 'Acquisition integrity', root.version,
|
|
871
|
+
validateIntegrity(root.integrity, 'Acquisition integrity', root.version, ACQUISITION_DECISION_PACKET_VERSION);
|
|
881
872
|
}
|
|
882
873
|
const CLAIM_PASSPORT_SOURCE_STATES = ['complete', 'not_found', 'partial', 'skipped', 'unavailable', 'unknown', 'unsupported'];
|
|
883
874
|
function validateClaimPassportTarget(type, value) {
|
|
@@ -971,7 +962,7 @@ function validateLookupClaimPassport(value) {
|
|
|
971
962
|
if (models.risk !== null)
|
|
972
963
|
integer(models.risk, 'Lookup claim passport Risk model', 1, 1_000);
|
|
973
964
|
strings(root.limitations, 'Lookup claim passport limitations', MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, 600);
|
|
974
|
-
validateIntegrity(root.integrity, 'Lookup claim passport integrity', root.version, LOOKUP_CLAIM_PASSPORT_VERSION
|
|
965
|
+
validateIntegrity(root.integrity, 'Lookup claim passport integrity', root.version, LOOKUP_CLAIM_PASSPORT_VERSION);
|
|
975
966
|
}
|
|
976
967
|
const COMPARISON_STATES = ['conflicting', 'different', 'equal', 'missing', 'not_recorded', 'unavailable'];
|
|
977
968
|
const SOURCE_STATES = ['complete', 'error', 'not_found', 'not_recorded', 'partial', 'skipped', 'unavailable', 'unsupported'];
|
|
@@ -1026,7 +1017,7 @@ function validateDomainComparison(value) {
|
|
|
1026
1017
|
fail('Domain comparison counts');
|
|
1027
1018
|
}
|
|
1028
1019
|
strings(comparison.limitations, 'Domain comparison limitations', 12, 600);
|
|
1029
|
-
validateIntegrity(root.integrity, 'Domain comparison integrity', root.version,
|
|
1020
|
+
validateIntegrity(root.integrity, 'Domain comparison integrity', root.version, BULK_DOMAIN_COMPARISON_EXPORT_VERSION);
|
|
1030
1021
|
}
|
|
1031
1022
|
const MAIL_STATES = ['authenticated_mail', 'evidence_incomplete', 'mail_auth_gap', 'mail_auth_incomplete', 'no_explicit_mx', 'null_mx'];
|
|
1032
1023
|
function validateSourceCoverage(value, label) {
|
|
@@ -1086,7 +1077,7 @@ function validateMailExposure(value) {
|
|
|
1086
1077
|
if (declaredUnevaluated < unevaluated)
|
|
1087
1078
|
fail('Bulk mail exposure unevaluated count');
|
|
1088
1079
|
strings(report.limitations, 'Bulk mail exposure limitations', 12, 600);
|
|
1089
|
-
validateIntegrity(root.integrity, 'Bulk mail exposure integrity', root.version,
|
|
1080
|
+
validateIntegrity(root.integrity, 'Bulk mail exposure integrity', root.version, BULK_MAIL_EXPOSURE_EXPORT_VERSION);
|
|
1090
1081
|
}
|
|
1091
1082
|
function validateBulkView(value, label) {
|
|
1092
1083
|
const view = exact(value, ['primaryFilter', 'mutationFilter', 'signalFilters', 'sourceFilter', 'lifecycleFilter', 'ageFilter', 'mailFilter', 'registrarFilter', 'caseDispositionFilter', 'reviewStateFilter', 'groupBy', 'sortKey', 'sortDirection'], label);
|
|
@@ -1131,7 +1122,7 @@ function validateBulkReviewManifest(value) {
|
|
|
1131
1122
|
if (rows.length !== domains.length)
|
|
1132
1123
|
fail('Bulk review manifest selection');
|
|
1133
1124
|
strings(root.limitations, 'Bulk review manifest limitations', 8, 600);
|
|
1134
|
-
validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version,
|
|
1125
|
+
validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version, BULK_REVIEW_MANIFEST_VERSION);
|
|
1135
1126
|
}
|
|
1136
1127
|
function validateInvestigationManifest(value) {
|
|
1137
1128
|
const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity'], 'Investigation manifest');
|
|
@@ -1168,9 +1159,21 @@ function validateInvestigationManifest(value) {
|
|
|
1168
1159
|
|| integer(summary.totalBytes, 'Investigation manifest total bytes', 1, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) !== totalBytes)
|
|
1169
1160
|
fail('Investigation manifest summary');
|
|
1170
1161
|
strings(root.limitations, 'Investigation manifest limitations', 8, 600);
|
|
1171
|
-
validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version,
|
|
1162
|
+
validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version, INVESTIGATION_MANIFEST_VERSION);
|
|
1172
1163
|
}
|
|
1173
|
-
|
|
1164
|
+
const CASE_RESPONSE_PREFLIGHT_IDS = [
|
|
1165
|
+
'required_incident_fields',
|
|
1166
|
+
'evidence_pins',
|
|
1167
|
+
'analyst_decision',
|
|
1168
|
+
'recipient_route',
|
|
1169
|
+
'profile_recipient',
|
|
1170
|
+
'case_disposition',
|
|
1171
|
+
'evidence_freshness',
|
|
1172
|
+
'contradictory_evidence',
|
|
1173
|
+
'action_tracking',
|
|
1174
|
+
];
|
|
1175
|
+
const PUBLIC_CASE_ACTION_STATES = ['planned', 'ready_for_review', 'submitted', 'acknowledged', 'resolved', 'closed'];
|
|
1176
|
+
function validatePublicActionSummary(value, label) {
|
|
1174
1177
|
const summary = exact(value, ['total', 'active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome', 'latestOutcomes'], label);
|
|
1175
1178
|
const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
|
|
1176
1179
|
for (const key of ['active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome'])
|
|
@@ -1182,22 +1185,11 @@ function validateActionSummary(value, label) {
|
|
|
1182
1185
|
const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'outcome', 'updatedAt'], label);
|
|
1183
1186
|
text(outcome.actionId, label, 64);
|
|
1184
1187
|
text(outcome.recipient, label, 320);
|
|
1185
|
-
enumeration(outcome.state,
|
|
1188
|
+
enumeration(outcome.state, PUBLIC_CASE_ACTION_STATES, label);
|
|
1186
1189
|
text(outcome.outcome, label, 2_000);
|
|
1187
1190
|
iso(outcome.updatedAt, label);
|
|
1188
1191
|
}
|
|
1189
1192
|
}
|
|
1190
|
-
const CASE_RESPONSE_PREFLIGHT_IDS = [
|
|
1191
|
-
'required_incident_fields',
|
|
1192
|
-
'evidence_pins',
|
|
1193
|
-
'analyst_decision',
|
|
1194
|
-
'recipient_route',
|
|
1195
|
-
'profile_recipient',
|
|
1196
|
-
'case_disposition',
|
|
1197
|
-
'evidence_freshness',
|
|
1198
|
-
'contradictory_evidence',
|
|
1199
|
-
'action_tracking',
|
|
1200
|
-
];
|
|
1201
1193
|
function expectedObservationAge(observedAt, generatedAt) {
|
|
1202
1194
|
const ageSeconds = Math.floor((Date.parse(generatedAt) - Date.parse(observedAt)) / 1_000);
|
|
1203
1195
|
if (ageSeconds < -300)
|
|
@@ -1208,10 +1200,10 @@ function expectedObservationAge(observedAt, generatedAt) {
|
|
|
1208
1200
|
return { ageSeconds, band: 'one_to_seven_days', refreshRecommended: false };
|
|
1209
1201
|
return { ageSeconds, band: 'over_seven_days', refreshRecommended: true };
|
|
1210
1202
|
}
|
|
1211
|
-
function
|
|
1203
|
+
function validateCaseResponsePacketV6(value) {
|
|
1212
1204
|
const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case', 'incident', 'contacts', 'preflight', 'escalationHistory', 'provenance', 'integrity'], 'Case-response packet');
|
|
1213
|
-
if (root.schemaVersion !==
|
|
1214
|
-
fail('Case-response packet');
|
|
1205
|
+
if (root.schemaVersion !== PUBLIC_CASE_RESPONSE_PACKET_VERSION)
|
|
1206
|
+
fail('Case-response packet v6');
|
|
1215
1207
|
iso(root.generatedAt, 'Case-response packet generatedAt');
|
|
1216
1208
|
if (root.reviewRequired !== true || root.submissionPerformed !== false)
|
|
1217
1209
|
fail('Case-response packet review state');
|
|
@@ -1304,14 +1296,14 @@ function validateCaseResponsePacket(value) {
|
|
|
1304
1296
|
const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
|
|
1305
1297
|
if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
|
|
1306
1298
|
fail('Case-response preflight status');
|
|
1307
|
-
|
|
1299
|
+
validatePublicActionSummary(preflight.actionSummary, 'Case-response action summary');
|
|
1308
1300
|
const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
|
|
1309
1301
|
for (const candidate of history) {
|
|
1310
1302
|
const action = exact(candidate, ['type', 'recipient', 'contactSource', 'state', 'reference', 'outcome', 'createdAt', 'updatedAt'], 'Case-response escalation action');
|
|
1311
1303
|
enumeration(action.type, CASE_ACTION_TYPES, 'Case-response action type');
|
|
1312
1304
|
text(action.recipient, 'Case-response action recipient', 320, true);
|
|
1313
1305
|
text(action.contactSource, 'Case-response action source', 120, true);
|
|
1314
|
-
enumeration(action.state,
|
|
1306
|
+
enumeration(action.state, PUBLIC_CASE_ACTION_STATES, 'Case-response action state');
|
|
1315
1307
|
optionalText(action.reference, 'Case-response action reference', 500);
|
|
1316
1308
|
optionalText(action.outcome, 'Case-response action outcome', 2_000);
|
|
1317
1309
|
iso(action.createdAt, 'Case-response action createdAt');
|
|
@@ -1333,12 +1325,509 @@ function validateCaseResponsePacket(value) {
|
|
|
1333
1325
|
strings(provenance.limitations, 'Case-response limitations', 8, 600);
|
|
1334
1326
|
const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
|
|
1335
1327
|
if (integrity.algorithm !== 'SHA-256'
|
|
1336
|
-
|| integrity.canonicalization !==
|
|
1328
|
+
|| integrity.canonicalization !== 'sorted-json-v2'
|
|
1337
1329
|
|| integrity.scope !== 'packet excluding integrity')
|
|
1338
1330
|
fail('Case-response integrity');
|
|
1339
1331
|
if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
|
|
1340
1332
|
fail('Case-response integrity');
|
|
1341
1333
|
}
|
|
1334
|
+
function validateCurrentActionSummary(value, label) {
|
|
1335
|
+
const summary = exact(value, [
|
|
1336
|
+
'total', 'active', 'drafting', 'readyForReview', 'reviewed', 'authorised', 'submitted',
|
|
1337
|
+
'acknowledged', 'terminal', 'overdue', 'followUpDue', 'withProviderOutcome', 'latestOutcomes',
|
|
1338
|
+
], label);
|
|
1339
|
+
const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
|
|
1340
|
+
const drafting = integer(summary.drafting, label, 0, total);
|
|
1341
|
+
const ready = integer(summary.readyForReview, label, 0, total);
|
|
1342
|
+
const reviewed = integer(summary.reviewed, label, 0, total);
|
|
1343
|
+
const authorised = integer(summary.authorised, label, 0, total);
|
|
1344
|
+
const submitted = integer(summary.submitted, label, 0, total);
|
|
1345
|
+
const acknowledged = integer(summary.acknowledged, label, 0, total);
|
|
1346
|
+
const terminal = integer(summary.terminal, label, 0, total);
|
|
1347
|
+
const active = integer(summary.active, label, 0, total);
|
|
1348
|
+
if (drafting + ready + reviewed + authorised + submitted + acknowledged + terminal !== total || active + terminal !== total)
|
|
1349
|
+
fail(label);
|
|
1350
|
+
for (const key of ['overdue', 'followUpDue', 'withProviderOutcome'])
|
|
1351
|
+
integer(summary[key], label, 0, total);
|
|
1352
|
+
const outcomes = array(summary.latestOutcomes, label, 5);
|
|
1353
|
+
for (const candidate of outcomes) {
|
|
1354
|
+
const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'providerOutcome', 'outcomeDetail', 'occurredAt'], label);
|
|
1355
|
+
text(outcome.actionId, label, 64);
|
|
1356
|
+
text(outcome.recipient, label, 320);
|
|
1357
|
+
enumeration(outcome.state, CASE_ACTION_STATES, label);
|
|
1358
|
+
enumeration(outcome.providerOutcome, CASE_PROVIDER_OUTCOMES, label);
|
|
1359
|
+
optionalText(outcome.outcomeDetail, label, 2_000);
|
|
1360
|
+
iso(outcome.occurredAt, label);
|
|
1361
|
+
}
|
|
1362
|
+
return total;
|
|
1363
|
+
}
|
|
1364
|
+
function validateCaseResponsePacketV7(value) {
|
|
1365
|
+
const root = exact(value, [
|
|
1366
|
+
'schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case',
|
|
1367
|
+
'incident', 'contacts', 'selectedEvidence', 'contradictions', 'readiness', 'artefactReferences',
|
|
1368
|
+
'authorisation', 'preflight', 'escalationHistory', 'escalationHistoryOmitted',
|
|
1369
|
+
'escalationHistoryLimitations', 'responseLifecycle', 'provenance', 'integrity',
|
|
1370
|
+
], 'Case-response packet v7');
|
|
1371
|
+
if (root.schemaVersion !== CASE_RESPONSE_PACKET_VERSION)
|
|
1372
|
+
fail('Case-response packet v7');
|
|
1373
|
+
iso(root.generatedAt, 'Case-response packet generatedAt');
|
|
1374
|
+
if (root.reviewRequired !== true || root.submissionPerformed !== false)
|
|
1375
|
+
fail('Case-response packet review state');
|
|
1376
|
+
const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
|
|
1377
|
+
const profileId = enumeration(profile.id, RESPONSE_PACKET_PROFILE_IDS, 'Case-response profile id');
|
|
1378
|
+
text(profile.label, 'Case-response profile label', 200);
|
|
1379
|
+
text(profile.audience, 'Case-response profile audience', 300);
|
|
1380
|
+
text(profile.subject, 'Case-response profile subject', 500);
|
|
1381
|
+
for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
|
|
1382
|
+
strings(profile[key], `Case-response profile ${key}`, 24, 500);
|
|
1383
|
+
const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
|
|
1384
|
+
if (!selectedProfile
|
|
1385
|
+
|| profile.label !== selectedProfile.label
|
|
1386
|
+
|| profile.audience !== selectedProfile.audience
|
|
1387
|
+
|| !sameValues(profile.checklist, selectedProfile.checklist)
|
|
1388
|
+
|| !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
|
|
1389
|
+
|| !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
|
|
1390
|
+
|| !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
|
|
1391
|
+
|| !sameValues(profile.redactions, selectedProfile.redactions)
|
|
1392
|
+
|| !sameValues(profile.attachments, selectedProfile.attachments)
|
|
1393
|
+
|| !sameValues(profile.followUpFields, selectedProfile.followUpFields))
|
|
1394
|
+
fail('Case-response profile');
|
|
1395
|
+
const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
|
|
1396
|
+
text(caseRecord.id, 'Case-response case id', 128);
|
|
1397
|
+
domain(caseRecord.domain, 'Case-response case domain');
|
|
1398
|
+
text(caseRecord.status, 'Case-response case status', 80);
|
|
1399
|
+
text(caseRecord.disposition, 'Case-response case disposition', 80);
|
|
1400
|
+
iso(caseRecord.updatedAt, 'Case-response case updatedAt');
|
|
1401
|
+
const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
|
|
1402
|
+
text(incident.category, 'Case-response category', 80);
|
|
1403
|
+
text(incident.affectedParty, 'Case-response affected party', 200);
|
|
1404
|
+
const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
|
|
1405
|
+
if (!urls.length || urls.some((item) => {
|
|
1406
|
+
try {
|
|
1407
|
+
const parsed = new URL(item);
|
|
1408
|
+
return !['http:', 'https:'].includes(parsed.protocol) || Boolean(parsed.username || parsed.password) || parsed.toString() !== item;
|
|
1409
|
+
}
|
|
1410
|
+
catch {
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
}) || new Set(urls).size !== urls.length)
|
|
1414
|
+
fail('Case-response abusive URLs');
|
|
1415
|
+
text(incident.observedHarm, 'Case-response observed harm', 2_000);
|
|
1416
|
+
iso(incident.observedAt, 'Case-response observedAt');
|
|
1417
|
+
if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`)
|
|
1418
|
+
fail('Case-response profile subject');
|
|
1419
|
+
const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
|
|
1420
|
+
const contactKeys = new Set();
|
|
1421
|
+
for (const candidate of contacts) {
|
|
1422
|
+
const contact = exact(candidate, ['kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations'], 'Case-response contact');
|
|
1423
|
+
const kind = enumeration(contact.kind, RESPONSE_CONTACT_KINDS, 'Case-response contact kind');
|
|
1424
|
+
const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
|
|
1425
|
+
const key = `${kind}\u0000${contactValue.toLowerCase()}`;
|
|
1426
|
+
if (contactKeys.has(key))
|
|
1427
|
+
fail('Case-response contacts');
|
|
1428
|
+
contactKeys.add(key);
|
|
1429
|
+
text(contact.source, 'Case-response contact source', 120);
|
|
1430
|
+
iso(contact.observedAt, 'Case-response contact observedAt', true);
|
|
1431
|
+
const freshness = enumeration(contact.freshness, ['current', 'stale', 'unknown'], 'Case-response contact freshness');
|
|
1432
|
+
const routeAge = contact.observedAt === null
|
|
1433
|
+
? null
|
|
1434
|
+
: Date.parse(root.generatedAt) - Date.parse(contact.observedAt);
|
|
1435
|
+
const expectedFreshness = routeAge === null
|
|
1436
|
+
? 'unknown'
|
|
1437
|
+
: routeAge < -300_000 || routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
|
|
1438
|
+
? 'stale'
|
|
1439
|
+
: 'current';
|
|
1440
|
+
if (freshness !== expectedFreshness)
|
|
1441
|
+
fail('Case-response contact freshness');
|
|
1442
|
+
strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
|
|
1443
|
+
}
|
|
1444
|
+
const evidence = array(root.selectedEvidence, 'Case-response selected evidence', MAX_RESPONSE_SELECTED_EVIDENCE);
|
|
1445
|
+
const evidenceIds = new Set();
|
|
1446
|
+
for (const candidate of evidence) {
|
|
1447
|
+
const item = exact(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], 'Case-response selected evidence');
|
|
1448
|
+
const id = text(item.id, 'Case-response evidence id', 64);
|
|
1449
|
+
if (evidenceIds.has(id))
|
|
1450
|
+
fail('Case-response selected evidence');
|
|
1451
|
+
evidenceIds.add(id);
|
|
1452
|
+
text(item.label, 'Case-response evidence label', 80);
|
|
1453
|
+
text(item.source, 'Case-response evidence source', 120);
|
|
1454
|
+
iso(item.observedAt, 'Case-response evidence observedAt');
|
|
1455
|
+
enumeration(item.completeness, ['complete', 'partial', 'inconclusive', 'unknown'], 'Case-response evidence completeness');
|
|
1456
|
+
strings(item.limitations, 'Case-response evidence limitations', 8, 240);
|
|
1457
|
+
}
|
|
1458
|
+
const contradictions = array(root.contradictions, 'Case-response contradictions', MAX_RESPONSE_CONTRADICTIONS);
|
|
1459
|
+
const contradictionIds = new Set();
|
|
1460
|
+
for (const candidate of contradictions) {
|
|
1461
|
+
const item = exact(candidate, ['id', 'statement', 'state', 'limitations'], 'Case-response contradiction');
|
|
1462
|
+
const id = text(item.id, 'Case-response contradiction id', 64);
|
|
1463
|
+
if (contradictionIds.has(id))
|
|
1464
|
+
fail('Case-response contradictions');
|
|
1465
|
+
contradictionIds.add(id);
|
|
1466
|
+
text(item.statement, 'Case-response contradiction statement', 2_000);
|
|
1467
|
+
enumeration(item.state, ['open', 'resolved'], 'Case-response contradiction state');
|
|
1468
|
+
strings(item.limitations, 'Case-response contradiction limitations', 8, 240);
|
|
1469
|
+
}
|
|
1470
|
+
const readiness = exact(root.readiness, ['profileId', 'rows', 'counts', 'limitations'], 'Case-response readiness');
|
|
1471
|
+
if (readiness.profileId !== profileId)
|
|
1472
|
+
fail('Case-response readiness profile');
|
|
1473
|
+
const readinessRows = array(readiness.rows, 'Case-response readiness rows', RESPONSE_READINESS_ROW_IDS.length, RESPONSE_READINESS_ROW_IDS.length);
|
|
1474
|
+
const actualReadiness = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
|
|
1475
|
+
const requiredReadiness = new Set([
|
|
1476
|
+
'observed_behaviour', 'exact_url', 'observation_time', 'capture_provenance', 'authority_review',
|
|
1477
|
+
'selected_evidence', 'contradictions', 'source_limitations',
|
|
1478
|
+
...(profileId === 'internal_soc' ? [] : ['recipient_route']),
|
|
1479
|
+
...(['registrar', 'registry', 'network_hosting'].includes(profileId) ? ['infrastructure_responsibility'] : []),
|
|
1480
|
+
]);
|
|
1481
|
+
const requiredContact = selectedProfile?.requiredContactKind
|
|
1482
|
+
? contacts.find((candidate) => candidate.kind === selectedProfile.requiredContactKind)
|
|
1483
|
+
: contacts[0];
|
|
1484
|
+
const expectedReadinessStates = {
|
|
1485
|
+
observed_behaviour: 'complete',
|
|
1486
|
+
exact_url: 'complete',
|
|
1487
|
+
observation_time: expectedObservationAge(incident.observedAt, root.generatedAt).refreshRecommended ? 'stale' : 'complete',
|
|
1488
|
+
capture_provenance: evidence.length ? 'complete' : 'unavailable',
|
|
1489
|
+
recipient_route: !requiredContact
|
|
1490
|
+
? 'not_provided'
|
|
1491
|
+
: requiredContact.freshness === 'stale'
|
|
1492
|
+
? 'stale'
|
|
1493
|
+
: requiredContact.freshness === 'current' && requiredContact.source ? 'complete' : 'partial',
|
|
1494
|
+
selected_evidence: !evidence.length
|
|
1495
|
+
? 'not_provided'
|
|
1496
|
+
: evidence.every((candidate) => candidate.completeness === 'complete') ? 'complete' : 'partial',
|
|
1497
|
+
...(contradictions.length ? {
|
|
1498
|
+
contradictions: contradictions.some((candidate) => candidate.state === 'open') ? 'partial' : 'complete',
|
|
1499
|
+
} : {}),
|
|
1500
|
+
};
|
|
1501
|
+
for (const [index, candidate] of readinessRows.entries()) {
|
|
1502
|
+
const row = exact(candidate, ['id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations'], 'Case-response readiness row');
|
|
1503
|
+
if (row.id !== RESPONSE_READINESS_ROW_IDS[index])
|
|
1504
|
+
fail('Case-response readiness row order');
|
|
1505
|
+
text(row.label, 'Case-response readiness label', 200);
|
|
1506
|
+
const state = enumeration(row.state, RESPONSE_READINESS_STATES, 'Case-response readiness state');
|
|
1507
|
+
actualReadiness[state] = (actualReadiness[state] ?? 0) + 1;
|
|
1508
|
+
text(row.detail, 'Case-response readiness detail', 500);
|
|
1509
|
+
boolean(row.requiredForAuthorisation, 'Case-response readiness requirement');
|
|
1510
|
+
if (row.requiredForAuthorisation !== requiredReadiness.has(row.id)
|
|
1511
|
+
|| (expectedReadinessStates[row.id] !== undefined
|
|
1512
|
+
&& row.state !== expectedReadinessStates[row.id]))
|
|
1513
|
+
fail('Case-response readiness projection');
|
|
1514
|
+
strings(row.limitations, 'Case-response readiness limitations', 8, 240);
|
|
1515
|
+
}
|
|
1516
|
+
const readinessCounts = exact(readiness.counts, [...RESPONSE_READINESS_STATES], 'Case-response readiness counts');
|
|
1517
|
+
for (const state of RESPONSE_READINESS_STATES)
|
|
1518
|
+
if (integer(readinessCounts[state], 'Case-response readiness count', 0, readinessRows.length) !== actualReadiness[state])
|
|
1519
|
+
fail('Case-response readiness counts');
|
|
1520
|
+
strings(readiness.limitations, 'Case-response readiness limitations', 8, 600);
|
|
1521
|
+
const artefacts = array(root.artefactReferences, 'Case-response artefact references', MAX_RESPONSE_ARTEFACT_REFERENCES);
|
|
1522
|
+
const artefactIds = new Set();
|
|
1523
|
+
for (const candidate of artefacts) {
|
|
1524
|
+
const item = exact(candidate, ['id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256', 'byteLength', 'limitations'], 'Case-response artefact reference');
|
|
1525
|
+
const id = text(item.id, 'Case-response artefact id', 64);
|
|
1526
|
+
if (artefactIds.has(id))
|
|
1527
|
+
fail('Case-response artefact references');
|
|
1528
|
+
artefactIds.add(id);
|
|
1529
|
+
text(item.label, 'Case-response artefact label', 120);
|
|
1530
|
+
text(item.mediaType, 'Case-response artefact media type', 120);
|
|
1531
|
+
iso(item.capturedAt, 'Case-response artefact capturedAt');
|
|
1532
|
+
text(item.source, 'Case-response artefact source', 120);
|
|
1533
|
+
if (typeof item.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(item.digestSha256))
|
|
1534
|
+
fail('Case-response artefact digest');
|
|
1535
|
+
if (item.byteLength !== null)
|
|
1536
|
+
integer(item.byteLength, 'Case-response artefact byte length', 0, 100 * 1024 * 1024);
|
|
1537
|
+
strings(item.limitations, 'Case-response artefact limitations', 8, 240);
|
|
1538
|
+
}
|
|
1539
|
+
const authorisation = exact(root.authorisation, ['status', 'reviewedInputDigestSha256', 'suppliedReviewDigestSha256', 'digestMatches', 'confirmedAt', 'confirmations', 'missingConfirmations', 'limitations'], 'Case-response authorisation');
|
|
1540
|
+
const authorisationStatus = enumeration(authorisation.status, ['draft', 'authorised'], 'Case-response authorisation status');
|
|
1541
|
+
if (typeof authorisation.reviewedInputDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.reviewedInputDigestSha256))
|
|
1542
|
+
fail('Case-response review digest');
|
|
1543
|
+
if (authorisation.suppliedReviewDigestSha256 !== null && (typeof authorisation.suppliedReviewDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.suppliedReviewDigestSha256)))
|
|
1544
|
+
fail('Case-response supplied review digest');
|
|
1545
|
+
boolean(authorisation.digestMatches, 'Case-response authorisation digest match');
|
|
1546
|
+
iso(authorisation.confirmedAt, 'Case-response authorisation confirmedAt', true);
|
|
1547
|
+
if (authorisation.confirmedAt !== null
|
|
1548
|
+
&& Date.parse(authorisation.confirmedAt) > Date.parse(root.generatedAt) + MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS) {
|
|
1549
|
+
fail('Case-response authorisation confirmedAt');
|
|
1550
|
+
}
|
|
1551
|
+
const confirmations = exact(authorisation.confirmations, [...RESPONSE_AUTHORISATION_CONFIRMATION_IDS], 'Case-response confirmations');
|
|
1552
|
+
for (const id of RESPONSE_AUTHORISATION_CONFIRMATION_IDS)
|
|
1553
|
+
boolean(confirmations[id], `Case-response confirmation ${id}`);
|
|
1554
|
+
const missing = strings(authorisation.missingConfirmations, 'Case-response missing confirmations', RESPONSE_AUTHORISATION_CONFIRMATION_IDS.length, 80);
|
|
1555
|
+
if (missing.some((id) => !RESPONSE_AUTHORISATION_CONFIRMATION_IDS.includes(id)))
|
|
1556
|
+
fail('Case-response missing confirmations');
|
|
1557
|
+
strings(authorisation.limitations, 'Case-response authorisation limitations', 8, 600);
|
|
1558
|
+
const expectedMissing = RESPONSE_AUTHORISATION_CONFIRMATION_IDS.filter((id) => confirmations[id] !== true);
|
|
1559
|
+
const expectedDigestMatches = authorisation.suppliedReviewDigestSha256 === authorisation.reviewedInputDigestSha256;
|
|
1560
|
+
const missingRequiredReadiness = readinessRows.some((candidate) => {
|
|
1561
|
+
const row = candidate;
|
|
1562
|
+
return row.requiredForAuthorisation === true && (row.state === 'not_provided' || row.state === 'unavailable');
|
|
1563
|
+
});
|
|
1564
|
+
const authorityReady = readinessRows.some((candidate) => {
|
|
1565
|
+
const row = candidate;
|
|
1566
|
+
return row.id === 'authority_review' && row.state === 'complete';
|
|
1567
|
+
});
|
|
1568
|
+
if (authorisation.digestMatches !== expectedDigestMatches
|
|
1569
|
+
|| !sameValues(missing, expectedMissing)
|
|
1570
|
+
|| (authorisationStatus === 'authorised' && (authorisation.digestMatches !== true || authorisation.confirmedAt === null || missing.length || missingRequiredReadiness || !authorityReady))
|
|
1571
|
+
|| (authorisationStatus === 'draft' && authorisation.confirmedAt !== null))
|
|
1572
|
+
fail('Case-response authorisation');
|
|
1573
|
+
const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
|
|
1574
|
+
if (preflight.version !== 2)
|
|
1575
|
+
fail('Case-response preflight');
|
|
1576
|
+
const checks = array(preflight.checks, 'Case-response preflight checks', CASE_RESPONSE_PREFLIGHT_IDS.length, CASE_RESPONSE_PREFLIGHT_IDS.length);
|
|
1577
|
+
const actualCounts = { block: 0, caution: 0, pass: 0 };
|
|
1578
|
+
for (const [index, candidate] of checks.entries()) {
|
|
1579
|
+
const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
|
|
1580
|
+
if (check.id !== CASE_RESPONSE_PREFLIGHT_IDS[index])
|
|
1581
|
+
fail('Case-response preflight check order');
|
|
1582
|
+
text(check.label, 'Case-response preflight label', 200);
|
|
1583
|
+
const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
|
|
1584
|
+
actualCounts[state] += 1;
|
|
1585
|
+
text(check.detail, 'Case-response preflight detail', 1_000);
|
|
1586
|
+
}
|
|
1587
|
+
const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
|
|
1588
|
+
for (const state of ['block', 'caution', 'pass'])
|
|
1589
|
+
if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
|
|
1590
|
+
fail('Case-response preflight counts');
|
|
1591
|
+
const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
|
|
1592
|
+
if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
|
|
1593
|
+
fail('Case-response preflight status');
|
|
1594
|
+
const actionTotal = validateCurrentActionSummary(preflight.actionSummary, 'Case-response action summary');
|
|
1595
|
+
const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
|
|
1596
|
+
const escalationHistoryOmitted = integer(root.escalationHistoryOmitted, 'Case-response omitted actions', 0, MAX_CASE_ACTIONS);
|
|
1597
|
+
const escalationHistoryLimitations = strings(root.escalationHistoryLimitations, 'Case-response history limitations', 4, 600);
|
|
1598
|
+
if (history.length !== Math.min(actionTotal, MAX_RESPONSE_ACTION_HISTORY)
|
|
1599
|
+
|| escalationHistoryOmitted !== actionTotal - history.length
|
|
1600
|
+
|| (escalationHistoryOmitted > 0) !== (escalationHistoryLimitations.length > 0))
|
|
1601
|
+
fail('Case-response action-history bounds');
|
|
1602
|
+
const actionIds = new Set();
|
|
1603
|
+
const providerEvents = [];
|
|
1604
|
+
for (const candidate of history) {
|
|
1605
|
+
const action = exact(candidate, ['actionId', 'type', 'recipient', 'contactSource', 'state', 'reference', 'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted', 'historyLimitations', 'transitions', 'createdAt', 'updatedAt'], 'Case-response escalation action');
|
|
1606
|
+
const actionId = text(action.actionId, 'Case-response action id', 64);
|
|
1607
|
+
if (actionIds.has(actionId))
|
|
1608
|
+
fail('Case-response action identity');
|
|
1609
|
+
actionIds.add(actionId);
|
|
1610
|
+
enumeration(action.type, CASE_ACTION_TYPES, 'Case-response action type');
|
|
1611
|
+
text(action.recipient, 'Case-response action recipient', 320);
|
|
1612
|
+
text(action.contactSource, 'Case-response action source', 120);
|
|
1613
|
+
enumeration(action.state, CASE_ACTION_STATES, 'Case-response action state');
|
|
1614
|
+
optionalText(action.reference, 'Case-response action reference', 500);
|
|
1615
|
+
if (action.providerOutcome !== null)
|
|
1616
|
+
enumeration(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
|
|
1617
|
+
optionalText(action.outcomeDetail, 'Case-response action outcome detail', 2_000);
|
|
1618
|
+
optionalText(action.originActionId, 'Case-response action origin', 64);
|
|
1619
|
+
integer(action.historyOmitted, 'Case-response action omitted history', 0, 1_000_000);
|
|
1620
|
+
strings(action.historyLimitations, 'Case-response action history limitations', 8, 240);
|
|
1621
|
+
const transitions = array(action.transitions, 'Case-response action transitions', MAX_CASE_ACTION_EVENTS_PER_ACTION, 1);
|
|
1622
|
+
const eventIds = new Set();
|
|
1623
|
+
let projectedState = null;
|
|
1624
|
+
let latestReference = null;
|
|
1625
|
+
let latestProviderOutcome = null;
|
|
1626
|
+
let latestProviderOutcomeDetail = null;
|
|
1627
|
+
let latestOutcomeDetail = null;
|
|
1628
|
+
let previousTime = Number.NEGATIVE_INFINITY;
|
|
1629
|
+
let previousEventId = '';
|
|
1630
|
+
for (const eventCandidate of transitions) {
|
|
1631
|
+
const event = exact(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], 'Case-response action transition');
|
|
1632
|
+
const eventId = text(event.id, 'Case-response action event id', 64);
|
|
1633
|
+
if (eventIds.has(eventId))
|
|
1634
|
+
fail('Case-response action event identity');
|
|
1635
|
+
eventIds.add(eventId);
|
|
1636
|
+
const previousState = event.previousState === null ? null : enumeration(event.previousState, CASE_ACTION_STATES, 'Case-response previous state');
|
|
1637
|
+
const nextState = enumeration(event.nextState, CASE_ACTION_STATES, 'Case-response next state');
|
|
1638
|
+
iso(event.occurredAt, 'Case-response action event time');
|
|
1639
|
+
const eventTime = Date.parse(event.occurredAt);
|
|
1640
|
+
if (eventTime < previousTime || (eventTime === previousTime && eventId < previousEventId))
|
|
1641
|
+
fail('Case-response action event order');
|
|
1642
|
+
previousTime = eventTime;
|
|
1643
|
+
previousEventId = eventId;
|
|
1644
|
+
const sourceClass = enumeration(event.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response action event source class');
|
|
1645
|
+
text(event.provenance, 'Case-response action event provenance', 80);
|
|
1646
|
+
optionalText(event.reference, 'Case-response action event reference', 500);
|
|
1647
|
+
optionalText(event.evidencePinId, 'Case-response action event evidence pin', 64);
|
|
1648
|
+
strings(event.limitations, 'Case-response action event limitations', 8, 240);
|
|
1649
|
+
const providerOutcome = event.providerOutcome === null ? null : enumeration(event.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action event provider outcome');
|
|
1650
|
+
if (providerOutcome !== null && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration')
|
|
1651
|
+
fail('Case-response action event provider outcome');
|
|
1652
|
+
const migrationSnapshot = sourceClass === 'migration' && previousState === null;
|
|
1653
|
+
if (providerOutcome !== null && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot)
|
|
1654
|
+
fail('Case-response submission event outcome');
|
|
1655
|
+
if (providerOutcome === 'no_response' && sourceClass === 'provider')
|
|
1656
|
+
fail('Case-response no-response event source');
|
|
1657
|
+
if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
|
|
1658
|
+
&& sourceClass !== 'analyst' && !migrationSnapshot)
|
|
1659
|
+
fail('Case-response explicit analyst transition');
|
|
1660
|
+
if (nextState === 'terminal' && previousState !== null
|
|
1661
|
+
&& ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
|
|
1662
|
+
&& (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn'))
|
|
1663
|
+
fail('Case-response early terminal transition');
|
|
1664
|
+
if (providerOutcome === 'withdrawn'
|
|
1665
|
+
&& (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
|
|
1666
|
+
fail('Case-response withdrawn transition');
|
|
1667
|
+
optionalText(event.outcomeDetail, 'Case-response action event outcome detail', 2_000);
|
|
1668
|
+
optionalText(event.originActionId, 'Case-response action event origin', 64);
|
|
1669
|
+
if (event.originActionId === actionId)
|
|
1670
|
+
fail('Case-response action event origin');
|
|
1671
|
+
boolean(event.applied, 'Case-response action event projection');
|
|
1672
|
+
const applied = event.applied;
|
|
1673
|
+
if (providerOutcome !== null) {
|
|
1674
|
+
providerEvents.push({
|
|
1675
|
+
actionId,
|
|
1676
|
+
eventId,
|
|
1677
|
+
outcome: providerOutcome,
|
|
1678
|
+
occurredAt: event.occurredAt,
|
|
1679
|
+
reference: event.reference,
|
|
1680
|
+
applied,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
if (projectedState === null && Number(action.historyOmitted) > 0 && previousState !== null)
|
|
1684
|
+
projectedState = previousState;
|
|
1685
|
+
const expectedApplied = previousState === projectedState
|
|
1686
|
+
&& isLegalCaseActionTransition(previousState, nextState, sourceClass);
|
|
1687
|
+
if (applied !== expectedApplied)
|
|
1688
|
+
fail('Case-response action event projection');
|
|
1689
|
+
if (applied) {
|
|
1690
|
+
projectedState = nextState;
|
|
1691
|
+
if (event.reference !== null)
|
|
1692
|
+
latestReference = event.reference;
|
|
1693
|
+
if (event.outcomeDetail !== null)
|
|
1694
|
+
latestOutcomeDetail = event.outcomeDetail;
|
|
1695
|
+
if (providerOutcome !== null) {
|
|
1696
|
+
latestProviderOutcome = providerOutcome;
|
|
1697
|
+
latestProviderOutcomeDetail = event.outcomeDetail;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
if ((projectedState ?? 'drafting') !== action.state
|
|
1702
|
+
|| latestReference !== action.reference
|
|
1703
|
+
|| latestProviderOutcome !== action.providerOutcome
|
|
1704
|
+
|| (latestProviderOutcomeDetail ?? latestOutcomeDetail) !== action.outcomeDetail)
|
|
1705
|
+
fail('Case-response action projection');
|
|
1706
|
+
if (action.originActionId === actionId)
|
|
1707
|
+
fail('Case-response action origin');
|
|
1708
|
+
iso(action.createdAt, 'Case-response action createdAt');
|
|
1709
|
+
iso(action.updatedAt, 'Case-response action updatedAt');
|
|
1710
|
+
}
|
|
1711
|
+
const lifecycle = exact(root.responseLifecycle, ['providerOutcomeState', 'latestProviderOutcome', 'observedChangeState', 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations'], 'Case-response lifecycle');
|
|
1712
|
+
const providerOutcomeState = enumeration(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle provider state');
|
|
1713
|
+
let actualLatestProvider = null;
|
|
1714
|
+
if (lifecycle.latestProviderOutcome !== null) {
|
|
1715
|
+
const outcome = exact(lifecycle.latestProviderOutcome, ['actionId', 'eventId', 'outcome', 'occurredAt', 'reference'], 'Case-response lifecycle provider outcome');
|
|
1716
|
+
const actionId = text(outcome.actionId, 'Case-response lifecycle action id', 64);
|
|
1717
|
+
const eventId = text(outcome.eventId, 'Case-response lifecycle event id', 64);
|
|
1718
|
+
const providerOutcome = enumeration(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response lifecycle provider outcome');
|
|
1719
|
+
iso(outcome.occurredAt, 'Case-response lifecycle provider time');
|
|
1720
|
+
const occurredAt = outcome.occurredAt;
|
|
1721
|
+
optionalText(outcome.reference, 'Case-response lifecycle reference', 500);
|
|
1722
|
+
actualLatestProvider = {
|
|
1723
|
+
actionId,
|
|
1724
|
+
eventId,
|
|
1725
|
+
outcome: providerOutcome,
|
|
1726
|
+
occurredAt,
|
|
1727
|
+
reference: outcome.reference,
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
providerEvents.sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt)
|
|
1731
|
+
|| (right.eventId < left.eventId ? -1 : right.eventId > left.eventId ? 1 : 0));
|
|
1732
|
+
const latestProviderTime = providerEvents[0]?.occurredAt ?? null;
|
|
1733
|
+
const latestProviderEvents = latestProviderTime === null
|
|
1734
|
+
? []
|
|
1735
|
+
: providerEvents.filter((event) => event.occurredAt === latestProviderTime);
|
|
1736
|
+
const expectedLatestProvider = escalationHistoryOmitted === 0
|
|
1737
|
+
&& latestProviderEvents.length === 1 && latestProviderEvents[0].applied
|
|
1738
|
+
? latestProviderEvents[0]
|
|
1739
|
+
: null;
|
|
1740
|
+
const expectedProviderState = escalationHistoryOmitted > 0
|
|
1741
|
+
? 'ambiguous'
|
|
1742
|
+
: providerEvents.length === 0 ? 'missing' : expectedLatestProvider ? 'available' : 'ambiguous';
|
|
1743
|
+
if (providerOutcomeState !== expectedProviderState
|
|
1744
|
+
|| (actualLatestProvider === null) !== (expectedLatestProvider === null)
|
|
1745
|
+
|| (actualLatestProvider && expectedLatestProvider && (actualLatestProvider.actionId !== expectedLatestProvider.actionId
|
|
1746
|
+
|| actualLatestProvider.eventId !== expectedLatestProvider.eventId
|
|
1747
|
+
|| actualLatestProvider.outcome !== expectedLatestProvider.outcome
|
|
1748
|
+
|| actualLatestProvider.occurredAt !== expectedLatestProvider.occurredAt
|
|
1749
|
+
|| actualLatestProvider.reference !== expectedLatestProvider.reference)))
|
|
1750
|
+
fail('Case-response lifecycle provider state');
|
|
1751
|
+
const observedChangeState = enumeration(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle observed-change state');
|
|
1752
|
+
if (lifecycle.latestObservedEffect !== null) {
|
|
1753
|
+
const effect = exact(lifecycle.latestObservedEffect, ['reviewId', 'state', 'observedAt', 'sourceClass', 'source'], 'Case-response lifecycle observed effect');
|
|
1754
|
+
text(effect.reviewId, 'Case-response effect review id', 64);
|
|
1755
|
+
enumeration(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response effect state');
|
|
1756
|
+
iso(effect.observedAt, 'Case-response effect observedAt');
|
|
1757
|
+
enumeration(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response effect source class');
|
|
1758
|
+
text(effect.source, 'Case-response effect source', 80);
|
|
1759
|
+
}
|
|
1760
|
+
iso(lifecycle.latestObservedChangeAt, 'Case-response observed change time', true);
|
|
1761
|
+
if ((observedChangeState === 'available') !== (lifecycle.latestObservedChangeAt !== null))
|
|
1762
|
+
fail('Case-response lifecycle observed-change state');
|
|
1763
|
+
if (lifecycle.closure !== null) {
|
|
1764
|
+
const closure = exact(lifecycle.closure, ['id', 'reason', 'createdAt', 'limitations'], 'Case-response closure');
|
|
1765
|
+
text(closure.id, 'Case-response closure id', 64);
|
|
1766
|
+
enumeration(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
|
|
1767
|
+
iso(closure.createdAt, 'Case-response closure time');
|
|
1768
|
+
strings(closure.limitations, 'Case-response closure limitations', 8, 240);
|
|
1769
|
+
}
|
|
1770
|
+
strings(lifecycle.limitations, 'Case-response lifecycle limitations', 8, 600);
|
|
1771
|
+
const reviewedInputs = {
|
|
1772
|
+
contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
|
|
1773
|
+
version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
|
|
1774
|
+
profile: {
|
|
1775
|
+
id: profile.id,
|
|
1776
|
+
label: profile.label,
|
|
1777
|
+
audience: profile.audience,
|
|
1778
|
+
subject: profile.subject,
|
|
1779
|
+
checklist: profile.checklist,
|
|
1780
|
+
includedEvidence: profile.includedEvidence,
|
|
1781
|
+
excludedEvidence: profile.excludedEvidence,
|
|
1782
|
+
redactions: profile.redactions,
|
|
1783
|
+
},
|
|
1784
|
+
case: root.case,
|
|
1785
|
+
incident: root.incident,
|
|
1786
|
+
contacts: root.contacts,
|
|
1787
|
+
selectedEvidence: root.selectedEvidence,
|
|
1788
|
+
contradictions: root.contradictions,
|
|
1789
|
+
readiness: root.readiness,
|
|
1790
|
+
artefactReferences: root.artefactReferences,
|
|
1791
|
+
escalationHistory: root.escalationHistory,
|
|
1792
|
+
escalationHistoryOmitted: root.escalationHistoryOmitted,
|
|
1793
|
+
escalationHistoryLimitations: root.escalationHistoryLimitations,
|
|
1794
|
+
responseLifecycle: root.responseLifecycle,
|
|
1795
|
+
};
|
|
1796
|
+
const expectedReviewDigest = createHash('sha256')
|
|
1797
|
+
.update(canonicalArtifactJsonV2(reviewedInputs))
|
|
1798
|
+
.digest('hex');
|
|
1799
|
+
if (authorisation.reviewedInputDigestSha256 !== expectedReviewDigest)
|
|
1800
|
+
fail('Case-response reviewed-input digest');
|
|
1801
|
+
const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
|
|
1802
|
+
iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
|
|
1803
|
+
integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
|
|
1804
|
+
integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
|
|
1805
|
+
integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
|
|
1806
|
+
const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
|
|
1807
|
+
integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
1808
|
+
enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
|
|
1809
|
+
boolean(age.refreshRecommended, 'Case-response refresh recommendation');
|
|
1810
|
+
const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
|
|
1811
|
+
if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band || age.refreshRecommended !== expectedAge.refreshRecommended)
|
|
1812
|
+
fail('Case-response observation age');
|
|
1813
|
+
strings(provenance.limitations, 'Case-response limitations', 8, 600);
|
|
1814
|
+
const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
|
|
1815
|
+
if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2' || integrity.scope !== 'packet excluding integrity')
|
|
1816
|
+
fail('Case-response integrity');
|
|
1817
|
+
if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
|
|
1818
|
+
fail('Case-response integrity');
|
|
1819
|
+
}
|
|
1820
|
+
function validateCaseResponsePacket(value) {
|
|
1821
|
+
if (value.schemaVersion === PUBLIC_CASE_RESPONSE_PACKET_VERSION)
|
|
1822
|
+
return validateCaseResponsePacketV6(value);
|
|
1823
|
+
if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion < CASE_RESPONSE_PACKET_VERSION) {
|
|
1824
|
+
throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is not part of the public compatibility boundary; no data was changed.`);
|
|
1825
|
+
}
|
|
1826
|
+
if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion > CASE_RESPONSE_PACKET_VERSION) {
|
|
1827
|
+
throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is newer than the supported version ${CASE_RESPONSE_PACKET_VERSION}; no data was changed.`);
|
|
1828
|
+
}
|
|
1829
|
+
validateCaseResponsePacketV7(value);
|
|
1830
|
+
}
|
|
1342
1831
|
function validateBriefFact(value, label) {
|
|
1343
1832
|
const fact = exact(value, ['label', 'value', 'detail', 'provenance'], label);
|
|
1344
1833
|
text(fact.label, label, 320);
|
|
@@ -1365,9 +1854,10 @@ function validateDecisionEntry(value, label) {
|
|
|
1365
1854
|
if (typeof entry.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(entry.href))
|
|
1366
1855
|
fail(label);
|
|
1367
1856
|
}
|
|
1368
|
-
function
|
|
1857
|
+
function validatePublicBrief(value) {
|
|
1369
1858
|
const brief = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'target', 'targetType', 'task', 'taskLabel', 'question', 'summary', 'observation', 'verifiedFacts', 'contradictions', 'unknowns', 'nextActions', 'relationships', 'limitations'], 'Investigation capsule brief');
|
|
1370
|
-
if (brief.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
|
|
1859
|
+
if (brief.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
|
|
1860
|
+
|| brief.schemaVersion !== PUBLIC_LOOKUP_INVESTIGATION_BRIEF_VERSION)
|
|
1371
1861
|
fail('Investigation capsule brief');
|
|
1372
1862
|
iso(brief.generatedAt, 'Investigation capsule brief generatedAt');
|
|
1373
1863
|
text(brief.target, 'Investigation capsule brief target', 253);
|
|
@@ -1411,9 +1901,191 @@ function validateBrief(value) {
|
|
|
1411
1901
|
strings(relationships.kinds, 'Investigation capsule relationship kinds', 12, 320);
|
|
1412
1902
|
strings(brief.limitations, 'Investigation capsule brief limitations', 20, 320);
|
|
1413
1903
|
}
|
|
1904
|
+
function validateProjectionCollection(value, label, totalMaximum, displayedMaximum, validateItem) {
|
|
1905
|
+
const collection = exact(value, ['total', 'displayed', 'omitted', 'items'], label);
|
|
1906
|
+
const total = integer(collection.total, `${label} total`, 0, totalMaximum);
|
|
1907
|
+
const displayed = integer(collection.displayed, `${label} displayed`, 0, displayedMaximum);
|
|
1908
|
+
const omitted = integer(collection.omitted, `${label} omitted`, 0, totalMaximum);
|
|
1909
|
+
const items = array(collection.items, `${label} items`, displayedMaximum);
|
|
1910
|
+
if (displayed > total || omitted !== total - displayed || items.length !== displayed)
|
|
1911
|
+
fail(label);
|
|
1912
|
+
items.forEach(validateItem);
|
|
1913
|
+
return { total, displayed, omitted, items };
|
|
1914
|
+
}
|
|
1915
|
+
function validateSortedUnique(values, label) {
|
|
1916
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
1917
|
+
if (index > 0 && values[index - 1] >= values[index])
|
|
1918
|
+
fail(label);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
function validateDecisionIdentifier(value, label) {
|
|
1922
|
+
const identifier = text(value, label, 200);
|
|
1923
|
+
if (!/^[a-z0-9](?:[a-z0-9._:-]{0,199})$/u.test(identifier))
|
|
1924
|
+
fail(label);
|
|
1925
|
+
return identifier;
|
|
1926
|
+
}
|
|
1927
|
+
function validateProjectedStringCollection(value, label, totalMaximum, displayedMaximum, textMaximum) {
|
|
1928
|
+
const projection = validateProjectionCollection(value, label, totalMaximum, displayedMaximum, (item) => { text(item, `${label} item`, textMaximum); });
|
|
1929
|
+
validateSortedUnique(projection.items, label);
|
|
1930
|
+
return projection;
|
|
1931
|
+
}
|
|
1932
|
+
function validateDecisionFactSource(value, label) {
|
|
1933
|
+
const source = exact(value, [
|
|
1934
|
+
'id',
|
|
1935
|
+
'label',
|
|
1936
|
+
'provenance',
|
|
1937
|
+
'evidenceState',
|
|
1938
|
+
'observedAt',
|
|
1939
|
+
'references',
|
|
1940
|
+
'limitations',
|
|
1941
|
+
], label);
|
|
1942
|
+
const id = validateDecisionIdentifier(source.id, `${label} id`);
|
|
1943
|
+
text(source.label, `${label} label`, 160);
|
|
1944
|
+
enumeration(source.provenance, DECISION_FACT_PROVENANCE_STATES, `${label} provenance`);
|
|
1945
|
+
enumeration(source.evidenceState, DECISION_FACT_EVIDENCE_STATES, `${label} evidence state`);
|
|
1946
|
+
iso(source.observedAt, `${label} observation time`, true);
|
|
1947
|
+
validateProjectedStringCollection(source.references, `${label} references`, MAX_DECISION_FACT_REFERENCES, MAX_DECISION_FACT_PROJECTION_SOURCE_REFERENCES, 200);
|
|
1948
|
+
validateProjectedStringCollection(source.limitations, `${label} limitations`, MAX_DECISION_FACT_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_SOURCE_LIMITATIONS, 280);
|
|
1949
|
+
return id;
|
|
1950
|
+
}
|
|
1951
|
+
function validateProjectedDecisionFact(value, label) {
|
|
1952
|
+
const fact = exact(value, [
|
|
1953
|
+
'version',
|
|
1954
|
+
'id',
|
|
1955
|
+
'question',
|
|
1956
|
+
'conclusion',
|
|
1957
|
+
'importance',
|
|
1958
|
+
'evidenceState',
|
|
1959
|
+
'completeness',
|
|
1960
|
+
'freshness',
|
|
1961
|
+
'consistency',
|
|
1962
|
+
'dependencies',
|
|
1963
|
+
'sourceReferences',
|
|
1964
|
+
'sources',
|
|
1965
|
+
'contradictions',
|
|
1966
|
+
'limitations',
|
|
1967
|
+
'safeNextActions',
|
|
1968
|
+
], label);
|
|
1969
|
+
if (fact.version !== DECISION_FACT_VERSION)
|
|
1970
|
+
fail(`${label} version`);
|
|
1971
|
+
const id = validateDecisionIdentifier(fact.id, `${label} id`);
|
|
1972
|
+
text(fact.question, `${label} question`, 320);
|
|
1973
|
+
text(fact.conclusion, `${label} conclusion`, 640);
|
|
1974
|
+
enumeration(fact.importance, ['high', 'medium', 'low'], `${label} importance`);
|
|
1975
|
+
const evidenceState = enumeration(fact.evidenceState, DECISION_FACT_EVIDENCE_STATES, `${label} evidence state`);
|
|
1976
|
+
if (fact.completeness !== decisionFactCompleteness(evidenceState))
|
|
1977
|
+
fail(`${label} completeness`);
|
|
1978
|
+
enumeration(fact.freshness, DECISION_FACT_FRESHNESS_STATES, `${label} freshness`);
|
|
1979
|
+
const consistency = enumeration(fact.consistency, DECISION_FACT_CONSISTENCY_STATES, `${label} consistency`);
|
|
1980
|
+
const dependencies = validateProjectedStringCollection(fact.dependencies, `${label} dependencies`, MAX_DECISION_FACT_CONTRIBUTORS, MAX_DECISION_FACT_PROJECTION_SOURCES, 200);
|
|
1981
|
+
dependencies.items.forEach((item) => validateDecisionIdentifier(item, `${label} dependency`));
|
|
1982
|
+
validateProjectedStringCollection(fact.sourceReferences, `${label} source references`, MAX_DECISION_FACT_REFERENCES, MAX_DECISION_FACT_PROJECTION_REFERENCES, 200);
|
|
1983
|
+
const sourceIds = [];
|
|
1984
|
+
const sources = validateProjectionCollection(fact.sources, `${label} sources`, MAX_DECISION_FACT_CONTRIBUTORS, MAX_DECISION_FACT_PROJECTION_SOURCES, (item, index) => { sourceIds.push(validateDecisionFactSource(item, `${label} source ${index + 1}`)); });
|
|
1985
|
+
validateSortedUnique(sourceIds, `${label} sources`);
|
|
1986
|
+
if (dependencies.total !== sources.total
|
|
1987
|
+
|| dependencies.displayed !== sources.displayed
|
|
1988
|
+
|| dependencies.omitted !== sources.omitted
|
|
1989
|
+
|| !sameValues(dependencies.items, sourceIds))
|
|
1990
|
+
fail(`${label} dependencies`);
|
|
1991
|
+
validateProjectedStringCollection(fact.contradictions, `${label} contradictions`, MAX_DECISION_FACT_CONTRADICTIONS, MAX_DECISION_FACT_PROJECTION_CONTRADICTIONS, 640);
|
|
1992
|
+
validateProjectedStringCollection(fact.limitations, `${label} limitations`, MAX_DECISION_FACT_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_LIMITATIONS, 280);
|
|
1993
|
+
const actionIds = [];
|
|
1994
|
+
validateProjectionCollection(fact.safeNextActions, `${label} safe next actions`, MAX_DECISION_FACT_NEXT_ACTIONS, MAX_DECISION_FACT_PROJECTION_NEXT_ACTIONS, (candidate, index) => {
|
|
1995
|
+
const action = exact(candidate, ['id', 'label', 'reason', 'expectedOutcome', 'href', 'importance'], `${label} safe next action ${index + 1}`);
|
|
1996
|
+
actionIds.push(validateDecisionIdentifier(action.id, `${label} safe next action id`));
|
|
1997
|
+
text(action.label, `${label} safe next action label`, 160);
|
|
1998
|
+
text(action.reason, `${label} safe next action reason`, 320);
|
|
1999
|
+
text(action.expectedOutcome, `${label} safe next action outcome`, 320);
|
|
2000
|
+
if (typeof action.href !== 'string' || !/^#[a-z0-9](?:[a-z0-9._:-]{0,159})$/u.test(action.href))
|
|
2001
|
+
fail(`${label} safe next action href`);
|
|
2002
|
+
enumeration(action.importance, ['high', 'medium', 'low'], `${label} safe next action importance`);
|
|
2003
|
+
});
|
|
2004
|
+
validateSortedUnique(actionIds, `${label} safe next actions`);
|
|
2005
|
+
return { id, consistency };
|
|
2006
|
+
}
|
|
2007
|
+
function validateDecisionFactProjection(value) {
|
|
2008
|
+
const projection = exact(value, [
|
|
2009
|
+
'version',
|
|
2010
|
+
'total',
|
|
2011
|
+
'displayed',
|
|
2012
|
+
'omitted',
|
|
2013
|
+
'contradictory',
|
|
2014
|
+
'unresolved',
|
|
2015
|
+
'facts',
|
|
2016
|
+
], 'Investigation capsule Decision Fact projection');
|
|
2017
|
+
if (projection.version !== DECISION_FACT_PROJECTION_VERSION)
|
|
2018
|
+
fail('Investigation capsule Decision Fact projection');
|
|
2019
|
+
const total = integer(projection.total, 'Investigation capsule Decision Fact total', 0, MAX_DECISION_FACTS);
|
|
2020
|
+
const displayed = integer(projection.displayed, 'Investigation capsule Decision Fact displayed', 0, MAX_DECISION_FACT_PROJECTION_FACTS);
|
|
2021
|
+
const omitted = integer(projection.omitted, 'Investigation capsule Decision Fact omitted', 0, MAX_DECISION_FACTS);
|
|
2022
|
+
const contradictory = integer(projection.contradictory, 'Investigation capsule Decision Fact contradictory', 0, total);
|
|
2023
|
+
const unresolved = integer(projection.unresolved, 'Investigation capsule Decision Fact unresolved', 0, total);
|
|
2024
|
+
const facts = array(projection.facts, 'Investigation capsule Decision Facts', MAX_DECISION_FACT_PROJECTION_FACTS);
|
|
2025
|
+
if (displayed > total || omitted !== total - displayed || facts.length !== displayed
|
|
2026
|
+
|| contradictory + unresolved > total)
|
|
2027
|
+
fail('Investigation capsule Decision Fact projection');
|
|
2028
|
+
const factIds = [];
|
|
2029
|
+
let displayedContradictory = 0;
|
|
2030
|
+
let displayedUnresolved = 0;
|
|
2031
|
+
for (const [index, candidate] of facts.entries()) {
|
|
2032
|
+
const validated = validateProjectedDecisionFact(candidate, `Investigation capsule Decision Fact ${index + 1}`);
|
|
2033
|
+
factIds.push(validated.id);
|
|
2034
|
+
if (validated.consistency === 'contradictory')
|
|
2035
|
+
displayedContradictory += 1;
|
|
2036
|
+
if (validated.consistency === 'unknown')
|
|
2037
|
+
displayedUnresolved += 1;
|
|
2038
|
+
}
|
|
2039
|
+
validateSortedUnique(factIds, 'Investigation capsule Decision Facts');
|
|
2040
|
+
if (contradictory < displayedContradictory || unresolved < displayedUnresolved
|
|
2041
|
+
|| (omitted === 0 && (contradictory !== displayedContradictory || unresolved !== displayedUnresolved))) {
|
|
2042
|
+
fail('Investigation capsule Decision Fact state counts');
|
|
2043
|
+
}
|
|
2044
|
+
if (new TextEncoder().encode(JSON.stringify(projection)).byteLength > MAX_DECISION_FACT_PROJECTION_BYTES) {
|
|
2045
|
+
fail('Investigation capsule Decision Fact projection byte limit');
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
function validateCurrentBrief(value) {
|
|
2049
|
+
const brief = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'target', 'targetType', 'task', 'taskLabel', 'question', 'summary', 'observation', 'decisionFacts', 'relationships', 'limitations'], 'Investigation capsule brief');
|
|
2050
|
+
if (brief.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
|
|
2051
|
+
|| brief.schemaVersion !== LOOKUP_INVESTIGATION_BRIEF_VERSION)
|
|
2052
|
+
fail('Investigation capsule brief');
|
|
2053
|
+
iso(brief.generatedAt, 'Investigation capsule brief generatedAt');
|
|
2054
|
+
text(brief.target, 'Investigation capsule brief target', 253);
|
|
2055
|
+
text(brief.targetType, 'Investigation capsule brief target type', 40);
|
|
2056
|
+
enumeration(brief.task, ['general', 'acquisition', 'brand', 'incident', 'owned'], 'Investigation capsule brief task');
|
|
2057
|
+
text(brief.taskLabel, 'Investigation capsule brief task label', 320);
|
|
2058
|
+
text(brief.question, 'Investigation capsule brief question', 320);
|
|
2059
|
+
text(brief.summary, 'Investigation capsule brief summary', 500);
|
|
2060
|
+
const observation = exact(brief.observation, ['observedAt', 'evidenceAgeDays', 'completeSources', 'limitedSources', 'freshnessPolicy'], 'Investigation capsule observation');
|
|
2061
|
+
iso(observation.observedAt, 'Investigation capsule observation time', true);
|
|
2062
|
+
if (observation.evidenceAgeDays !== null)
|
|
2063
|
+
integer(observation.evidenceAgeDays, 'Investigation capsule evidence age', 0, 1_000_000);
|
|
2064
|
+
integer(observation.completeSources, 'Investigation capsule complete sources', 0, 100);
|
|
2065
|
+
integer(observation.limitedSources, 'Investigation capsule limited sources', 0, 100);
|
|
2066
|
+
const policy = exact(observation.freshnessPolicy, ['version', 'id', 'task', 'thresholdsDays'], 'Investigation capsule freshness policy');
|
|
2067
|
+
if (policy.version !== 1)
|
|
2068
|
+
fail('Investigation capsule freshness policy');
|
|
2069
|
+
enumeration(policy.id, ['task-default', 'analyst-custom'], 'Investigation capsule freshness policy');
|
|
2070
|
+
if (policy.task !== brief.task)
|
|
2071
|
+
fail('Investigation capsule freshness policy');
|
|
2072
|
+
const thresholds = exact(policy.thresholdsDays, ['registration', 'network', 'web'], 'Investigation capsule freshness thresholds');
|
|
2073
|
+
for (const key of ['registration', 'network', 'web'])
|
|
2074
|
+
integer(thresholds[key], 'Investigation capsule freshness threshold', 0, 3650);
|
|
2075
|
+
validateDecisionFactProjection(brief.decisionFacts);
|
|
2076
|
+
const relationships = exact(brief.relationships, ['nodes', 'edges', 'truncated', 'kinds'], 'Investigation capsule relationship summary');
|
|
2077
|
+
integer(relationships.nodes, 'Investigation capsule relationship node count', 0, 72);
|
|
2078
|
+
integer(relationships.edges, 'Investigation capsule relationship edge count', 0, 120);
|
|
2079
|
+
boolean(relationships.truncated, 'Investigation capsule relationship truncation');
|
|
2080
|
+
strings(relationships.kinds, 'Investigation capsule relationship kinds', 12, 320);
|
|
2081
|
+
strings(brief.limitations, 'Investigation capsule brief limitations', 20, 320);
|
|
2082
|
+
if (new TextEncoder().encode(JSON.stringify(brief)).byteLength > MAX_LOOKUP_INVESTIGATION_BRIEF_BYTES) {
|
|
2083
|
+
fail('Investigation capsule brief byte limit');
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
1414
2086
|
function validateGraph(value) {
|
|
1415
2087
|
const graph = exact(value, ['version', 'targetId', 'nodes', 'edges', 'sources', 'truncated', 'limitations'], 'Investigation capsule graph');
|
|
1416
|
-
if (graph.version !==
|
|
2088
|
+
if (graph.version !== LOOKUP_ASSET_GRAPH_VERSION)
|
|
1417
2089
|
fail('Investigation capsule graph');
|
|
1418
2090
|
text(graph.targetId, 'Investigation capsule graph target', 160);
|
|
1419
2091
|
const nodes = array(graph.nodes, 'Investigation capsule graph nodes', 72, 1);
|
|
@@ -1501,7 +2173,8 @@ function validateAnalystRecords(value) {
|
|
|
1501
2173
|
}
|
|
1502
2174
|
export function validateInvestigationCapsuleStructure(value) {
|
|
1503
2175
|
const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'application', 'target', 'sourceContracts', 'investigationBrief', 'graphSnapshot', 'analystRecords', 'integrity', 'limitations'], 'Investigation capsule');
|
|
1504
|
-
if (root.schemaVersion !==
|
|
2176
|
+
if (root.schemaVersion !== PUBLIC_INVESTIGATION_CAPSULE_VERSION
|
|
2177
|
+
&& root.schemaVersion !== INVESTIGATION_CAPSULE_VERSION)
|
|
1505
2178
|
fail('Investigation capsule');
|
|
1506
2179
|
iso(root.generatedAt, 'Investigation capsule generatedAt');
|
|
1507
2180
|
const application = exact(root.application, ['name', 'version'], 'Investigation capsule application');
|
|
@@ -1527,21 +2200,27 @@ export function validateInvestigationCapsuleStructure(value) {
|
|
|
1527
2200
|
const expectedContractIds = root.analystRecords === null
|
|
1528
2201
|
? ['lookup-evidence', 'investigation-brief', 'asset-graph']
|
|
1529
2202
|
: ['lookup-evidence', 'investigation-brief', 'asset-graph', 'analyst-records'];
|
|
2203
|
+
const expectedBriefVersion = root.schemaVersion === INVESTIGATION_CAPSULE_VERSION
|
|
2204
|
+
? LOOKUP_INVESTIGATION_BRIEF_VERSION
|
|
2205
|
+
: PUBLIC_LOOKUP_INVESTIGATION_BRIEF_VERSION;
|
|
1530
2206
|
if (!sameValues(contracts.map((candidate) => candidate.id), expectedContractIds))
|
|
1531
2207
|
fail('Investigation capsule source contracts');
|
|
1532
2208
|
if (!byId.has('lookup-evidence') || !byId.has('investigation-brief') || !byId.has('asset-graph')
|
|
1533
2209
|
|| byId.get('lookup-evidence')?.embedded !== false
|
|
1534
2210
|
|| byId.get('investigation-brief')?.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
|
|
1535
|
-
|| byId.get('investigation-brief')?.version !==
|
|
2211
|
+
|| byId.get('investigation-brief')?.version !== expectedBriefVersion
|
|
1536
2212
|
|| byId.get('investigation-brief')?.embedded !== true
|
|
1537
|
-
|| byId.get('asset-graph')?.schema !==
|
|
1538
|
-
|| byId.get('asset-graph')?.version !==
|
|
2213
|
+
|| byId.get('asset-graph')?.schema !== LOOKUP_ASSET_GRAPH_SCHEMA
|
|
2214
|
+
|| byId.get('asset-graph')?.version !== LOOKUP_ASSET_GRAPH_VERSION
|
|
1539
2215
|
|| byId.get('asset-graph')?.embedded !== true
|
|
1540
|
-
|| (byId.has('analyst-records') && (byId.get('analyst-records')?.schema !==
|
|
1541
|
-
|| byId.get('analyst-records')?.version !==
|
|
2216
|
+
|| (byId.has('analyst-records') && (byId.get('analyst-records')?.schema !== INVESTIGATION_CAPSULE_ANALYST_RECORDS_SCHEMA
|
|
2217
|
+
|| byId.get('analyst-records')?.version !== INVESTIGATION_CAPSULE_ANALYST_RECORDS_VERSION
|
|
1542
2218
|
|| byId.get('analyst-records')?.embedded !== true)))
|
|
1543
2219
|
fail('Investigation capsule source contracts');
|
|
1544
|
-
|
|
2220
|
+
if (root.schemaVersion === INVESTIGATION_CAPSULE_VERSION)
|
|
2221
|
+
validateCurrentBrief(root.investigationBrief);
|
|
2222
|
+
else
|
|
2223
|
+
validatePublicBrief(root.investigationBrief);
|
|
1545
2224
|
validateGraph(root.graphSnapshot);
|
|
1546
2225
|
validateAnalystRecords(root.analystRecords);
|
|
1547
2226
|
const brief = root.investigationBrief;
|
|
@@ -1551,17 +2230,12 @@ export function validateInvestigationCapsuleStructure(value) {
|
|
|
1551
2230
|
|| brief.relationships.nodes !== graph.nodes.length
|
|
1552
2231
|
|| brief.relationships.edges !== graph.edges.length)
|
|
1553
2232
|
fail('Investigation capsule projection linkage');
|
|
1554
|
-
const
|
|
1555
|
-
const integrity = exact(root.integrity, current
|
|
1556
|
-
? ['algorithm', 'canonicalization', 'scope', 'briefDigest', 'graphDigest', 'analystRecordsDigest', 'digestSha256']
|
|
1557
|
-
: ['algorithm', 'briefDigest', 'graphDigest', 'analystRecordsDigest'], 'Investigation capsule integrity');
|
|
2233
|
+
const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'briefDigest', 'graphDigest', 'analystRecordsDigest', 'digestSha256'], 'Investigation capsule integrity');
|
|
1558
2234
|
if (integrity.algorithm !== 'SHA-256')
|
|
1559
2235
|
fail('Investigation capsule integrity');
|
|
1560
|
-
if (
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
digest(integrity.digestSha256, 'Investigation capsule digest');
|
|
1564
|
-
}
|
|
2236
|
+
if (integrity.canonicalization !== 'sorted-json-v2' || integrity.scope !== 'capsule excluding integrity')
|
|
2237
|
+
fail('Investigation capsule integrity');
|
|
2238
|
+
digest(integrity.digestSha256, 'Investigation capsule digest');
|
|
1565
2239
|
digest(integrity.briefDigest, 'Investigation capsule brief digest');
|
|
1566
2240
|
digest(integrity.graphDigest, 'Investigation capsule graph digest');
|
|
1567
2241
|
if (integrity.analystRecordsDigest !== null)
|
|
@@ -1791,7 +2465,7 @@ function validateDomainChangePacket(value) {
|
|
|
1791
2465
|
}))
|
|
1792
2466
|
fail('Domain change packet summary');
|
|
1793
2467
|
strings(root.limitations, 'Domain change packet limitations', 8, 600);
|
|
1794
|
-
validateIntegrity(root.integrity, 'Domain change packet integrity', root.version,
|
|
2468
|
+
validateIntegrity(root.integrity, 'Domain change packet integrity', root.version, DOMAIN_CHANGE_PACKET_VERSION);
|
|
1795
2469
|
}
|
|
1796
2470
|
export function validateSignedDigestArtifactStructure(schema, value) {
|
|
1797
2471
|
if (value.schema !== schema)
|
|
@@ -1808,7 +2482,7 @@ export function validateSignedDigestArtifactStructure(schema, value) {
|
|
|
1808
2482
|
validateBulkReviewManifest(value);
|
|
1809
2483
|
else if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA) {
|
|
1810
2484
|
try {
|
|
1811
|
-
|
|
2485
|
+
normalizeDomainControlManifestDocument(value);
|
|
1812
2486
|
}
|
|
1813
2487
|
catch {
|
|
1814
2488
|
fail('Domain control manifest');
|