@slicedearth/whoisleuth-cli 2.5.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -1,817 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertPacketAmendmentSelection } from "../../packages/cases/case-requested-evidence.mjs";
3
- import { canonicalArtifactJsonV2 } from "../../packages/evidence/artifact-integrity.mjs";
4
- import { CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_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";
5
- 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_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";
6
- import { HEX_DIGEST_RE, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, optionalText, sameValues, strings, text, } from "./structure-primitives.mjs";
7
- const CASE_RESPONSE_PREFLIGHT_IDS_V6 = [
8
- 'required_incident_fields',
9
- 'evidence_pins',
10
- 'analyst_decision',
11
- 'recipient_route',
12
- 'profile_recipient',
13
- 'case_disposition',
14
- 'evidence_freshness',
15
- 'contradictory_evidence',
16
- 'action_tracking',
17
- ];
18
- const CASE_RESPONSE_PREFLIGHT_IDS_V7 = CASE_RESPONSE_PREFLIGHT_IDS_V6;
19
- const CASE_RESPONSE_PREFLIGHT_IDS_V8 = [
20
- ...CASE_RESPONSE_PREFLIGHT_IDS_V7.slice(0, -1),
21
- 'packet_action',
22
- 'action_tracking',
23
- ];
24
- const PUBLIC_CASE_ACTION_STATES = ['planned', 'ready_for_review', 'submitted', 'acknowledged', 'resolved', 'closed'];
25
- const PRE_PLATFORM_PROFILE_IDS = RESPONSE_PACKET_PROFILE_IDS.filter((id) => id !== 'application_platform');
26
- const PRE_PLATFORM_CONTACT_KINDS = RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform');
27
- const PRE_PLATFORM_ACTION_TYPES = CASE_ACTION_TYPES.filter((type) => type !== 'platform_report');
28
- function legacyProfileRedactions(profileId) {
29
- if (profileId === 'registrar' || profileId === 'registry') {
30
- return ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'];
31
- }
32
- if (profileId === 'network_hosting')
33
- return ['Remove credentials, query secrets, fragments, cookies, and unrelated contacts'];
34
- if (profileId === 'security_contact')
35
- return ['Remove credentials, tokens, personal data, and unrelated identifiers'];
36
- if (profileId === 'browser_blocklist')
37
- return ['Remove credentials, tokens, URL fragments, and unrelated contacts'];
38
- return ['Remove credentials, tokens, and personal data not required for the internal decision'];
39
- }
40
- function validatePublicActionSummary(value, label) {
41
- const summary = exact(value, ['total', 'active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome', 'latestOutcomes'], label);
42
- const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
43
- for (const key of ['active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome'])
44
- integer(summary[key], label, 0, total);
45
- if (Number(summary.resolved) + Number(summary.closed) + Number(summary.active) !== total)
46
- fail(label);
47
- const outcomes = array(summary.latestOutcomes, label, 5);
48
- for (const candidate of outcomes) {
49
- const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'outcome', 'updatedAt'], label);
50
- text(outcome.actionId, label, 64);
51
- text(outcome.recipient, label, 320);
52
- enumeration(outcome.state, PUBLIC_CASE_ACTION_STATES, label);
53
- text(outcome.outcome, label, 2_000);
54
- iso(outcome.updatedAt, label);
55
- }
56
- }
57
- function expectedObservationAge(observedAt, generatedAt) {
58
- const ageSeconds = Math.floor((Date.parse(generatedAt) - Date.parse(observedAt)) / 1_000);
59
- if (ageSeconds < -300)
60
- return { ageSeconds, band: 'future_or_clock_skew', refreshRecommended: true };
61
- if (ageSeconds < 86_400)
62
- return { ageSeconds: Math.max(0, ageSeconds), band: 'under_24_hours', refreshRecommended: false };
63
- if (ageSeconds <= 604_800)
64
- return { ageSeconds, band: 'one_to_seven_days', refreshRecommended: false };
65
- return { ageSeconds, band: 'over_seven_days', refreshRecommended: true };
66
- }
67
- function validateCaseResponsePacketV6(value) {
68
- const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case', 'incident', 'contacts', 'preflight', 'escalationHistory', 'provenance', 'integrity'], 'Case-response packet');
69
- if (root.schemaVersion !== PUBLIC_CASE_RESPONSE_PACKET_VERSION)
70
- fail('Case-response packet v6');
71
- iso(root.generatedAt, 'Case-response packet generatedAt');
72
- if (root.reviewRequired !== true || root.submissionPerformed !== false)
73
- fail('Case-response packet review state');
74
- const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
75
- const profileId = enumeration(profile.id, PRE_PLATFORM_PROFILE_IDS, 'Case-response profile id');
76
- text(profile.label, 'Case-response profile label', 200);
77
- text(profile.audience, 'Case-response profile audience', 300);
78
- text(profile.subject, 'Case-response profile subject', 500);
79
- for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
80
- strings(profile[key], `Case-response profile ${key}`, 24, 500);
81
- const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
82
- text(caseRecord.id, 'Case-response case id', 128);
83
- domain(caseRecord.domain, 'Case-response case domain');
84
- text(caseRecord.status, 'Case-response case status', 80);
85
- text(caseRecord.disposition, 'Case-response case disposition', 80);
86
- iso(caseRecord.updatedAt, 'Case-response case updatedAt');
87
- const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
88
- text(incident.category, 'Case-response category', 80);
89
- text(incident.affectedParty, 'Case-response affected party', 200);
90
- const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
91
- if (!urls.length || urls.some((item) => { try {
92
- return !['http:', 'https:'].includes(new URL(item).protocol);
93
- }
94
- catch {
95
- return true;
96
- } }))
97
- fail('Case-response abusive URLs');
98
- text(incident.observedHarm, 'Case-response observed harm', 2_000);
99
- iso(incident.observedAt, 'Case-response observedAt');
100
- const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
101
- if (!selectedProfile
102
- || profile.label !== selectedProfile.label
103
- || profile.audience !== selectedProfile.audience
104
- || profile.subject !== `${selectedProfile.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
105
- || !sameValues(profile.checklist, selectedProfile.checklist)
106
- || !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
107
- || !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
108
- || !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
109
- || !sameValues(profile.redactions, legacyProfileRedactions(profileId))
110
- || !sameValues(profile.attachments, selectedProfile.attachments)
111
- || !sameValues(profile.followUpFields, selectedProfile.followUpFields))
112
- fail('Case-response profile');
113
- const canonicalUrls = urls.map((item) => {
114
- try {
115
- const parsed = new URL(item);
116
- return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password
117
- ? parsed.toString()
118
- : null;
119
- }
120
- catch {
121
- return null;
122
- }
123
- });
124
- if (canonicalUrls.some((item, index) => item === null || item !== urls[index])
125
- || new Set(urls).size !== urls.length)
126
- fail('Case-response abusive URLs');
127
- const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
128
- const contactKeys = new Set();
129
- for (const candidate of contacts) {
130
- const contact = exact(candidate, ['kind', 'contact', 'source', 'limitations'], 'Case-response contact');
131
- const kind = enumeration(contact.kind, PRE_PLATFORM_CONTACT_KINDS, 'Case-response contact kind');
132
- const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
133
- const contactKey = `${kind}\u0000${contactValue.toLowerCase()}`;
134
- if (contactKeys.has(contactKey))
135
- fail('Case-response contacts');
136
- contactKeys.add(contactKey);
137
- text(contact.source, 'Case-response contact source', MAX_RESPONSE_LABEL_LENGTH);
138
- strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
139
- }
140
- const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
141
- if (preflight.version !== 1)
142
- fail('Case-response preflight');
143
- const checks = array(preflight.checks, 'Case-response preflight checks', CASE_RESPONSE_PREFLIGHT_IDS_V6.length, CASE_RESPONSE_PREFLIGHT_IDS_V6.length);
144
- const actualCounts = { block: 0, caution: 0, pass: 0 };
145
- for (const [index, candidate] of checks.entries()) {
146
- const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
147
- const id = text(check.id, 'Case-response preflight id', 80);
148
- if (id !== CASE_RESPONSE_PREFLIGHT_IDS_V6[index])
149
- fail('Case-response preflight check order');
150
- text(check.label, 'Case-response preflight label', 200);
151
- const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
152
- actualCounts[state] += 1;
153
- text(check.detail, 'Case-response preflight detail', 1_000);
154
- }
155
- const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
156
- for (const state of ['block', 'caution', 'pass']) {
157
- if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
158
- fail('Case-response preflight counts');
159
- }
160
- const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
161
- if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
162
- fail('Case-response preflight status');
163
- validatePublicActionSummary(preflight.actionSummary, 'Case-response action summary');
164
- const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
165
- for (const candidate of history) {
166
- const action = exact(candidate, ['type', 'recipient', 'contactSource', 'state', 'reference', 'outcome', 'createdAt', 'updatedAt'], 'Case-response escalation action');
167
- enumeration(action.type, PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
168
- text(action.recipient, 'Case-response action recipient', 320, true);
169
- text(action.contactSource, 'Case-response action source', 120, true);
170
- enumeration(action.state, PUBLIC_CASE_ACTION_STATES, 'Case-response action state');
171
- optionalText(action.reference, 'Case-response action reference', 500);
172
- optionalText(action.outcome, 'Case-response action outcome', 2_000);
173
- iso(action.createdAt, 'Case-response action createdAt');
174
- iso(action.updatedAt, 'Case-response action updatedAt');
175
- }
176
- const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
177
- iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
178
- integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
179
- integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
180
- integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
181
- const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
182
- integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
183
- enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
184
- boolean(age.refreshRecommended, 'Case-response refresh recommendation');
185
- const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
186
- if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band
187
- || age.refreshRecommended !== expectedAge.refreshRecommended)
188
- fail('Case-response observation age');
189
- strings(provenance.limitations, 'Case-response limitations', 8, 600);
190
- const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
191
- if (integrity.algorithm !== 'SHA-256'
192
- || integrity.canonicalization !== 'sorted-json-v2'
193
- || integrity.scope !== 'packet excluding integrity')
194
- fail('Case-response integrity');
195
- if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
196
- fail('Case-response integrity');
197
- }
198
- function validateCurrentActionSummary(value, label) {
199
- const summary = exact(value, [
200
- 'total', 'active', 'drafting', 'readyForReview', 'reviewed', 'authorised', 'submitted',
201
- 'acknowledged', 'terminal', 'overdue', 'followUpDue', 'withProviderOutcome', 'latestOutcomes',
202
- ], label);
203
- const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
204
- const drafting = integer(summary.drafting, label, 0, total);
205
- const ready = integer(summary.readyForReview, label, 0, total);
206
- const reviewed = integer(summary.reviewed, label, 0, total);
207
- const authorised = integer(summary.authorised, label, 0, total);
208
- const submitted = integer(summary.submitted, label, 0, total);
209
- const acknowledged = integer(summary.acknowledged, label, 0, total);
210
- const terminal = integer(summary.terminal, label, 0, total);
211
- const active = integer(summary.active, label, 0, total);
212
- if (drafting + ready + reviewed + authorised + submitted + acknowledged + terminal !== total || active + terminal !== total)
213
- fail(label);
214
- for (const key of ['overdue', 'followUpDue', 'withProviderOutcome'])
215
- integer(summary[key], label, 0, total);
216
- const outcomes = array(summary.latestOutcomes, label, 5);
217
- for (const candidate of outcomes) {
218
- const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'providerOutcome', 'outcomeDetail', 'occurredAt'], label);
219
- text(outcome.actionId, label, 64);
220
- text(outcome.recipient, label, 320);
221
- enumeration(outcome.state, CASE_ACTION_STATES, label);
222
- enumeration(outcome.providerOutcome, CASE_PROVIDER_OUTCOMES, label);
223
- optionalText(outcome.outcomeDetail, label, 2_000);
224
- iso(outcome.occurredAt, label);
225
- }
226
- return total;
227
- }
228
- function validateVersionedCaseResponsePacket(value, version) {
229
- const hasPlatformRoutes = version >= PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION;
230
- const hasActionBinding = version >= PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION;
231
- const root = exact(value, [
232
- 'schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case',
233
- 'incident', 'contacts', 'selectedEvidence', 'contradictions', 'readiness', 'artefactReferences',
234
- 'authorisation', 'preflight', 'escalationHistory', 'escalationHistoryOmitted',
235
- 'escalationHistoryLimitations', 'responseLifecycle', 'provenance', 'integrity',
236
- ...(hasActionBinding ? ['recipientRoute', 'actionBinding'] : []),
237
- ], `Case-response packet v${version}`);
238
- if (root.schemaVersion !== version)
239
- fail(`Case-response packet v${version}`);
240
- iso(root.generatedAt, 'Case-response packet generatedAt');
241
- if (root.reviewRequired !== true || root.submissionPerformed !== false)
242
- fail('Case-response packet review state');
243
- const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
244
- const profileId = enumeration(profile.id, hasPlatformRoutes ? RESPONSE_PACKET_PROFILE_IDS : PRE_PLATFORM_PROFILE_IDS, 'Case-response profile id');
245
- text(profile.label, 'Case-response profile label', 200);
246
- text(profile.audience, 'Case-response profile audience', 300);
247
- text(profile.subject, 'Case-response profile subject', 500);
248
- for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
249
- strings(profile[key], `Case-response profile ${key}`, 24, 500);
250
- const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
251
- const expectedRedactions = hasActionBinding ? selectedProfile?.redactions : legacyProfileRedactions(profileId);
252
- if (!selectedProfile
253
- || profile.label !== selectedProfile.label
254
- || profile.audience !== selectedProfile.audience
255
- || !sameValues(profile.checklist, selectedProfile.checklist)
256
- || !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
257
- || !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
258
- || !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
259
- || !sameValues(profile.redactions, expectedRedactions ?? [])
260
- || !sameValues(profile.attachments, selectedProfile.attachments)
261
- || !sameValues(profile.followUpFields, selectedProfile.followUpFields))
262
- fail('Case-response profile');
263
- const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
264
- text(caseRecord.id, 'Case-response case id', 128);
265
- domain(caseRecord.domain, 'Case-response case domain');
266
- text(caseRecord.status, 'Case-response case status', 80);
267
- text(caseRecord.disposition, 'Case-response case disposition', 80);
268
- iso(caseRecord.updatedAt, 'Case-response case updatedAt');
269
- const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
270
- text(incident.category, 'Case-response category', 80);
271
- text(incident.affectedParty, 'Case-response affected party', 200);
272
- const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
273
- if (!urls.length || urls.some((item) => {
274
- try {
275
- const parsed = new URL(item);
276
- return !['http:', 'https:'].includes(parsed.protocol) || Boolean(parsed.username || parsed.password) || parsed.toString() !== item;
277
- }
278
- catch {
279
- return true;
280
- }
281
- }) || new Set(urls).size !== urls.length)
282
- fail('Case-response abusive URLs');
283
- text(incident.observedHarm, 'Case-response observed harm', 2_000);
284
- iso(incident.observedAt, 'Case-response observedAt');
285
- if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`)
286
- fail('Case-response profile subject');
287
- const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
288
- const contactKeys = new Set();
289
- for (const candidate of contacts) {
290
- const contact = exact(candidate, [
291
- 'kind', 'contact', 'source', 'observedAt', ...(hasPlatformRoutes ? ['reviewAfter'] : []), 'freshness', 'limitations',
292
- ], 'Case-response contact');
293
- const kind = enumeration(contact.kind, hasPlatformRoutes ? RESPONSE_CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS, 'Case-response contact kind');
294
- const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
295
- const key = `${kind}\u0000${contactValue.toLowerCase()}`;
296
- if (contactKeys.has(key))
297
- fail('Case-response contacts');
298
- contactKeys.add(key);
299
- text(contact.source, 'Case-response contact source', 120);
300
- iso(contact.observedAt, 'Case-response contact observedAt', true);
301
- if (hasPlatformRoutes)
302
- iso(contact.reviewAfter, 'Case-response contact reviewAfter', true);
303
- const freshness = enumeration(contact.freshness, ['current', 'stale', 'unknown'], 'Case-response contact freshness');
304
- const routeAge = contact.observedAt === null
305
- ? null
306
- : Date.parse(root.generatedAt) - Date.parse(contact.observedAt);
307
- const reviewExpired = hasPlatformRoutes && contact.reviewAfter !== null
308
- && Date.parse(root.generatedAt) >= Date.parse(contact.reviewAfter);
309
- const expectedFreshness = routeAge === null
310
- ? 'unknown'
311
- : routeAge < -300_000 || reviewExpired
312
- || (contact.reviewAfter == null && routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000)
313
- ? 'stale'
314
- : 'current';
315
- if (freshness !== expectedFreshness)
316
- fail('Case-response contact freshness');
317
- strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
318
- }
319
- let recipientRoute = null;
320
- let selectedActionId = null;
321
- let lineageActionIds = [];
322
- let lineageComplete = true;
323
- if (hasActionBinding) {
324
- if (root.recipientRoute !== null) {
325
- recipientRoute = exact(root.recipientRoute, [
326
- 'actionId', 'kind', 'contact', 'source', 'observedAt', ...(hasPlatformRoutes ? ['reviewAfter'] : []), 'freshness', 'limitations',
327
- ], 'Case-response recipient route');
328
- text(recipientRoute.actionId, 'Case-response recipient action id', 64);
329
- enumeration(recipientRoute.kind, [...(hasPlatformRoutes ? RESPONSE_CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS), 'manual'], 'Case-response recipient kind');
330
- text(recipientRoute.contact, 'Case-response recipient value', MAX_RESPONSE_RECIPIENT_LENGTH);
331
- text(recipientRoute.source, 'Case-response recipient source', 120);
332
- iso(recipientRoute.observedAt, 'Case-response recipient observedAt', true);
333
- if (hasPlatformRoutes)
334
- iso(recipientRoute.reviewAfter, 'Case-response recipient reviewAfter', true);
335
- const freshness = enumeration(recipientRoute.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
336
- const routeAge = recipientRoute.observedAt === null
337
- ? null
338
- : Date.parse(root.generatedAt) - Date.parse(recipientRoute.observedAt);
339
- const reviewExpired = hasPlatformRoutes && recipientRoute.reviewAfter !== null
340
- && Date.parse(root.generatedAt) >= Date.parse(recipientRoute.reviewAfter);
341
- const expectedFreshness = routeAge === null
342
- ? 'unknown'
343
- : routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
344
- || reviewExpired
345
- || (recipientRoute.reviewAfter == null && routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000) ? 'stale' : 'current';
346
- if (freshness !== expectedFreshness)
347
- fail('Case-response recipient freshness');
348
- strings(recipientRoute.limitations, 'Case-response recipient limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
349
- }
350
- const binding = exact(root.actionBinding, [
351
- 'state', 'selectedActionId', 'lineageActionIds', 'limitations',
352
- ], 'Case-response action binding');
353
- const state = enumeration(binding.state, ['selected', 'not_selected'], 'Case-response action binding state');
354
- optionalText(binding.selectedActionId, 'Case-response selected action id', 64);
355
- selectedActionId = binding.selectedActionId;
356
- lineageActionIds = strings(binding.lineageActionIds, 'Case-response action lineage', MAX_RESPONSE_ACTION_HISTORY, 64);
357
- const bindingLimitations = strings(binding.limitations, 'Case-response action-binding limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
358
- lineageComplete = !bindingLimitations.some((item) => /lineage is incomplete/iu.test(item));
359
- if ((state === 'selected') !== Boolean(selectedActionId)
360
- || (selectedActionId && lineageActionIds[0] !== selectedActionId)
361
- || new Set(lineageActionIds).size !== lineageActionIds.length
362
- || (recipientRoute && recipientRoute.actionId !== selectedActionId))
363
- fail('Case-response action binding');
364
- if (recipientRoute?.kind === 'manual' && contacts.length !== 0)
365
- fail('Case-response manual recipient route');
366
- if (recipientRoute && recipientRoute.kind !== 'manual') {
367
- if (contacts.length !== 1)
368
- fail('Case-response selected contact projection');
369
- const selectedContact = contacts[0];
370
- if (selectedContact.kind !== recipientRoute.kind
371
- || selectedContact.contact !== recipientRoute.contact
372
- || selectedContact.source !== recipientRoute.source
373
- || selectedContact.observedAt !== recipientRoute.observedAt
374
- || (hasPlatformRoutes && selectedContact.reviewAfter !== recipientRoute.reviewAfter)
375
- || selectedContact.freshness !== recipientRoute.freshness)
376
- fail('Case-response selected contact projection');
377
- }
378
- }
379
- const evidence = array(root.selectedEvidence, 'Case-response selected evidence', MAX_RESPONSE_SELECTED_EVIDENCE);
380
- const evidenceIds = new Set();
381
- for (const candidate of evidence) {
382
- const item = exactOptional(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION ? ['observationHostname'] : [], 'Case-response selected evidence');
383
- const id = text(item.id, 'Case-response evidence id', 64);
384
- if (evidenceIds.has(id))
385
- fail('Case-response selected evidence');
386
- evidenceIds.add(id);
387
- text(item.label, 'Case-response evidence label', 80);
388
- text(item.source, 'Case-response evidence source', 120);
389
- if (item.observationHostname !== undefined)
390
- domain(item.observationHostname, 'Case-response evidence hostname');
391
- iso(item.observedAt, 'Case-response evidence observedAt', version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION);
392
- enumeration(item.completeness, ['complete', 'partial', 'inconclusive', 'unknown'], 'Case-response evidence completeness');
393
- strings(item.limitations, 'Case-response evidence limitations', 8, 240);
394
- }
395
- const contradictions = array(root.contradictions, 'Case-response contradictions', MAX_RESPONSE_CONTRADICTIONS);
396
- const contradictionIds = new Set();
397
- for (const candidate of contradictions) {
398
- const item = exact(candidate, ['id', 'statement', 'state', 'limitations'], 'Case-response contradiction');
399
- const id = text(item.id, 'Case-response contradiction id', 64);
400
- if (contradictionIds.has(id))
401
- fail('Case-response contradictions');
402
- contradictionIds.add(id);
403
- text(item.statement, 'Case-response contradiction statement', 2_000);
404
- enumeration(item.state, ['open', 'resolved'], 'Case-response contradiction state');
405
- strings(item.limitations, 'Case-response contradiction limitations', 8, 240);
406
- }
407
- const readiness = exact(root.readiness, ['profileId', 'rows', 'counts', 'limitations'], 'Case-response readiness');
408
- if (readiness.profileId !== profileId)
409
- fail('Case-response readiness profile');
410
- const readinessRows = array(readiness.rows, 'Case-response readiness rows', RESPONSE_READINESS_ROW_IDS.length, RESPONSE_READINESS_ROW_IDS.length);
411
- const actualReadiness = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
412
- const requiredReadiness = new Set([
413
- 'observed_behaviour', 'exact_url', 'observation_time', 'capture_provenance', 'authority_review',
414
- 'selected_evidence', 'contradictions', 'source_limitations',
415
- ...(profileId === 'internal_soc' ? [] : ['recipient_route']),
416
- ...(['registrar', 'registry', 'network_hosting'].includes(profileId) ? ['infrastructure_responsibility'] : []),
417
- ]);
418
- const requiredContact = hasActionBinding
419
- ? recipientRoute ?? undefined
420
- : selectedProfile?.requiredContactKind
421
- ? contacts.find((candidate) => candidate.kind === selectedProfile.requiredContactKind)
422
- : contacts[0];
423
- const expectedReadinessStates = {
424
- observed_behaviour: 'complete',
425
- exact_url: 'complete',
426
- observation_time: expectedObservationAge(incident.observedAt, root.generatedAt).refreshRecommended ? 'stale' : 'complete',
427
- capture_provenance: !evidence.length ? 'unavailable' : evidence.every((candidate) => candidate.observedAt !== null) ? 'complete' : 'partial',
428
- recipient_route: !requiredContact
429
- ? 'not_provided'
430
- : requiredContact.freshness === 'stale'
431
- ? 'stale'
432
- : requiredContact.freshness === 'current' && requiredContact.source ? 'complete' : 'partial',
433
- selected_evidence: !evidence.length
434
- ? 'not_provided'
435
- : evidence.every((candidate) => candidate.completeness === 'complete') ? 'complete' : 'partial',
436
- ...(contradictions.length ? {
437
- contradictions: contradictions.some((candidate) => candidate.state === 'open') ? 'partial' : 'complete',
438
- } : {}),
439
- };
440
- for (const [index, candidate] of readinessRows.entries()) {
441
- const row = exact(candidate, ['id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations'], 'Case-response readiness row');
442
- if (row.id !== RESPONSE_READINESS_ROW_IDS[index])
443
- fail('Case-response readiness row order');
444
- text(row.label, 'Case-response readiness label', 200);
445
- const state = enumeration(row.state, RESPONSE_READINESS_STATES, 'Case-response readiness state');
446
- actualReadiness[state] = (actualReadiness[state] ?? 0) + 1;
447
- text(row.detail, 'Case-response readiness detail', 500);
448
- boolean(row.requiredForAuthorisation, 'Case-response readiness requirement');
449
- if (row.requiredForAuthorisation !== requiredReadiness.has(row.id)
450
- || (expectedReadinessStates[row.id] !== undefined
451
- && row.state !== expectedReadinessStates[row.id]))
452
- fail('Case-response readiness projection');
453
- strings(row.limitations, 'Case-response readiness limitations', 8, 240);
454
- }
455
- const readinessCounts = exact(readiness.counts, [...RESPONSE_READINESS_STATES], 'Case-response readiness counts');
456
- for (const state of RESPONSE_READINESS_STATES)
457
- if (integer(readinessCounts[state], 'Case-response readiness count', 0, readinessRows.length) !== actualReadiness[state])
458
- fail('Case-response readiness counts');
459
- strings(readiness.limitations, 'Case-response readiness limitations', 8, 600);
460
- const artefacts = array(root.artefactReferences, 'Case-response artefact references', MAX_RESPONSE_ARTEFACT_REFERENCES);
461
- const artefactIds = new Set();
462
- for (const candidate of artefacts) {
463
- const item = exact(candidate, ['id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256', 'byteLength', 'limitations'], 'Case-response artefact reference');
464
- const id = text(item.id, 'Case-response artefact id', 64);
465
- if (artefactIds.has(id))
466
- fail('Case-response artefact references');
467
- artefactIds.add(id);
468
- text(item.label, 'Case-response artefact label', 120);
469
- text(item.mediaType, 'Case-response artefact media type', 120);
470
- iso(item.capturedAt, 'Case-response artefact capturedAt');
471
- text(item.source, 'Case-response artefact source', 120);
472
- if (typeof item.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(item.digestSha256))
473
- fail('Case-response artefact digest');
474
- if (item.byteLength !== null)
475
- integer(item.byteLength, 'Case-response artefact byte length', 0, 100 * 1024 * 1024);
476
- strings(item.limitations, 'Case-response artefact limitations', 8, 240);
477
- }
478
- const authorisation = exact(root.authorisation, ['status', 'reviewedInputDigestSha256', 'suppliedReviewDigestSha256', 'digestMatches', 'confirmedAt', 'confirmations', 'missingConfirmations', 'limitations'], 'Case-response authorisation');
479
- const authorisationStatus = enumeration(authorisation.status, ['draft', 'authorised'], 'Case-response authorisation status');
480
- if (typeof authorisation.reviewedInputDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.reviewedInputDigestSha256))
481
- fail('Case-response review digest');
482
- if (authorisation.suppliedReviewDigestSha256 !== null && (typeof authorisation.suppliedReviewDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.suppliedReviewDigestSha256)))
483
- fail('Case-response supplied review digest');
484
- boolean(authorisation.digestMatches, 'Case-response authorisation digest match');
485
- iso(authorisation.confirmedAt, 'Case-response authorisation confirmedAt', true);
486
- if (authorisation.confirmedAt !== null
487
- && Date.parse(authorisation.confirmedAt) > Date.parse(root.generatedAt) + MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS) {
488
- fail('Case-response authorisation confirmedAt');
489
- }
490
- const confirmations = exact(authorisation.confirmations, [...RESPONSE_AUTHORISATION_CONFIRMATION_IDS], 'Case-response confirmations');
491
- for (const id of RESPONSE_AUTHORISATION_CONFIRMATION_IDS)
492
- boolean(confirmations[id], `Case-response confirmation ${id}`);
493
- const missing = strings(authorisation.missingConfirmations, 'Case-response missing confirmations', RESPONSE_AUTHORISATION_CONFIRMATION_IDS.length, 80);
494
- if (missing.some((id) => !RESPONSE_AUTHORISATION_CONFIRMATION_IDS.includes(id)))
495
- fail('Case-response missing confirmations');
496
- strings(authorisation.limitations, 'Case-response authorisation limitations', 8, 600);
497
- const expectedMissing = RESPONSE_AUTHORISATION_CONFIRMATION_IDS.filter((id) => confirmations[id] !== true);
498
- const expectedDigestMatches = authorisation.suppliedReviewDigestSha256 === authorisation.reviewedInputDigestSha256;
499
- const missingRequiredReadiness = readinessRows.some((candidate) => {
500
- const row = candidate;
501
- return row.requiredForAuthorisation === true && (row.state === 'not_provided' || row.state === 'unavailable');
502
- });
503
- const authorityReady = readinessRows.some((candidate) => {
504
- const row = candidate;
505
- return row.id === 'authority_review' && row.state === 'complete';
506
- });
507
- if (authorisation.digestMatches !== expectedDigestMatches
508
- || !sameValues(missing, expectedMissing)
509
- || (authorisationStatus === 'authorised' && (authorisation.digestMatches !== true || authorisation.confirmedAt === null || missing.length || missingRequiredReadiness || !authorityReady))
510
- || (authorisationStatus === 'draft' && authorisation.confirmedAt !== null))
511
- fail('Case-response authorisation');
512
- const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
513
- const preflightIds = hasActionBinding ? CASE_RESPONSE_PREFLIGHT_IDS_V8 : CASE_RESPONSE_PREFLIGHT_IDS_V7;
514
- if (preflight.version !== (hasActionBinding ? 3 : 2))
515
- fail('Case-response preflight');
516
- const checks = array(preflight.checks, 'Case-response preflight checks', preflightIds.length, preflightIds.length);
517
- const actualCounts = { block: 0, caution: 0, pass: 0 };
518
- for (const [index, candidate] of checks.entries()) {
519
- const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
520
- if (check.id !== preflightIds[index])
521
- fail('Case-response preflight check order');
522
- text(check.label, 'Case-response preflight label', 200);
523
- const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
524
- actualCounts[state] += 1;
525
- text(check.detail, 'Case-response preflight detail', 1_000);
526
- }
527
- const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
528
- for (const state of ['block', 'caution', 'pass'])
529
- if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
530
- fail('Case-response preflight counts');
531
- const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
532
- if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
533
- fail('Case-response preflight status');
534
- const actionTotal = validateCurrentActionSummary(preflight.actionSummary, 'Case-response action summary');
535
- const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
536
- const escalationHistoryOmitted = integer(root.escalationHistoryOmitted, 'Case-response omitted actions', 0, MAX_CASE_ACTIONS);
537
- const escalationHistoryLimitations = strings(root.escalationHistoryLimitations, 'Case-response history limitations', 4, 600);
538
- if ((!hasActionBinding && history.length !== Math.min(actionTotal, MAX_RESPONSE_ACTION_HISTORY))
539
- || (hasActionBinding && history.length > Math.min(actionTotal, MAX_RESPONSE_ACTION_HISTORY))
540
- || escalationHistoryOmitted !== actionTotal - history.length
541
- || (escalationHistoryOmitted > 0) !== (escalationHistoryLimitations.length > 0))
542
- fail('Case-response action-history bounds');
543
- const actionIds = new Set();
544
- const actionIdOrder = [];
545
- const providerEvents = [];
546
- for (const candidate of history) {
547
- const action = exactOptional(candidate, [
548
- 'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
549
- 'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
550
- 'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
551
- ...(hasActionBinding ? ['routeObservedAt'] : []),
552
- ...(hasPlatformRoutes ? ['routeReviewAfter'] : []),
553
- ], Number(root.schemaVersion) >= 11 ? ['amendment'] : [], 'Case-response escalation action');
554
- readCasePacketAmendment(action.amendment);
555
- const actionId = text(action.actionId, 'Case-response action id', 64);
556
- if (actionIds.has(actionId))
557
- fail('Case-response action identity');
558
- actionIds.add(actionId);
559
- actionIdOrder.push(actionId);
560
- enumeration(action.type, hasPlatformRoutes ? CASE_ACTION_TYPES : PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
561
- text(action.recipient, 'Case-response action recipient', 320);
562
- text(action.contactSource, 'Case-response action source', 120);
563
- if (hasActionBinding)
564
- iso(action.routeObservedAt, 'Case-response action route observedAt', true);
565
- if (hasPlatformRoutes)
566
- iso(action.routeReviewAfter, 'Case-response action route reviewAfter', true);
567
- enumeration(action.state, CASE_ACTION_STATES, 'Case-response action state');
568
- optionalText(action.reference, 'Case-response action reference', 500);
569
- if (action.providerOutcome !== null)
570
- enumeration(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
571
- optionalText(action.outcomeDetail, 'Case-response action outcome detail', 2_000);
572
- optionalText(action.originActionId, 'Case-response action origin', 64);
573
- integer(action.historyOmitted, 'Case-response action omitted history', 0, 1_000_000);
574
- strings(action.historyLimitations, 'Case-response action history limitations', 8, 240);
575
- const transitions = array(action.transitions, 'Case-response action transitions', MAX_CASE_ACTION_EVENTS_PER_ACTION, 1);
576
- const requestEvents = [];
577
- const eventIds = new Set();
578
- let projectedState = null;
579
- let latestReference = null;
580
- let latestProviderOutcome = null;
581
- let latestProviderOutcomeDetail = null;
582
- let latestOutcomeDetail = null;
583
- let previousTime = Number.NEGATIVE_INFINITY;
584
- let previousEventId = '';
585
- for (const eventCandidate of transitions) {
586
- const event = exactOptional(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], Number(root.schemaVersion) >= 11 ? ['evidenceRequest'] : [], 'Case-response action transition');
587
- const evidenceRequest = readCaseEvidenceRequest(event.evidenceRequest);
588
- assertEvidenceRequestEvent(evidenceRequest, event);
589
- const eventId = text(event.id, 'Case-response action event id', 64);
590
- requestEvents.push({ id: eventId, ...(evidenceRequest ? { evidenceRequest } : {}) });
591
- if (eventIds.has(eventId))
592
- fail('Case-response action event identity');
593
- eventIds.add(eventId);
594
- const previousState = event.previousState === null ? null : enumeration(event.previousState, CASE_ACTION_STATES, 'Case-response previous state');
595
- const nextState = enumeration(event.nextState, CASE_ACTION_STATES, 'Case-response next state');
596
- iso(event.occurredAt, 'Case-response action event time');
597
- const eventTime = Date.parse(event.occurredAt);
598
- if (eventTime < previousTime || (eventTime === previousTime && eventId < previousEventId))
599
- fail('Case-response action event order');
600
- previousTime = eventTime;
601
- previousEventId = eventId;
602
- const sourceClass = enumeration(event.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response action event source class');
603
- text(event.provenance, 'Case-response action event provenance', 80);
604
- optionalText(event.reference, 'Case-response action event reference', 500);
605
- optionalText(event.evidencePinId, 'Case-response action event evidence pin', 64);
606
- strings(event.limitations, 'Case-response action event limitations', 8, 240);
607
- const providerOutcome = event.providerOutcome === null ? null : enumeration(event.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action event provider outcome');
608
- if (providerOutcome !== null && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration')
609
- fail('Case-response action event provider outcome');
610
- const migrationSnapshot = sourceClass === 'migration' && previousState === null;
611
- if (providerOutcome !== null && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot)
612
- fail('Case-response submission event outcome');
613
- if (providerOutcome === 'no_response' && sourceClass === 'provider')
614
- fail('Case-response no-response event source');
615
- if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
616
- && sourceClass !== 'analyst' && !migrationSnapshot)
617
- fail('Case-response explicit analyst transition');
618
- if (nextState === 'terminal' && previousState !== null
619
- && ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
620
- && (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn'))
621
- fail('Case-response early terminal transition');
622
- if (providerOutcome === 'withdrawn'
623
- && (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
624
- fail('Case-response withdrawn transition');
625
- optionalText(event.outcomeDetail, 'Case-response action event outcome detail', 2_000);
626
- optionalText(event.originActionId, 'Case-response action event origin', 64);
627
- if (event.originActionId === actionId)
628
- fail('Case-response action event origin');
629
- boolean(event.applied, 'Case-response action event projection');
630
- const applied = event.applied;
631
- if (providerOutcome !== null) {
632
- providerEvents.push({
633
- actionId,
634
- eventId,
635
- outcome: providerOutcome,
636
- occurredAt: event.occurredAt,
637
- reference: event.reference,
638
- applied,
639
- });
640
- }
641
- if (projectedState === null && Number(action.historyOmitted) > 0 && previousState !== null)
642
- projectedState = previousState;
643
- const expectedApplied = previousState === projectedState
644
- && isLegalCaseActionTransition(previousState, nextState, sourceClass);
645
- if (applied !== expectedApplied)
646
- fail('Case-response action event projection');
647
- if (applied) {
648
- projectedState = nextState;
649
- if (event.reference !== null)
650
- latestReference = event.reference;
651
- if (event.outcomeDetail !== null)
652
- latestOutcomeDetail = event.outcomeDetail;
653
- if (providerOutcome !== null) {
654
- latestProviderOutcome = providerOutcome;
655
- latestProviderOutcomeDetail = event.outcomeDetail;
656
- }
657
- }
658
- }
659
- assertEvidenceRequestHistory(requestEvents, Number(action.historyOmitted) > 0);
660
- if ((projectedState ?? 'drafting') !== action.state
661
- || latestReference !== action.reference
662
- || latestProviderOutcome !== action.providerOutcome
663
- || (latestProviderOutcomeDetail ?? latestOutcomeDetail) !== action.outcomeDetail)
664
- fail('Case-response action projection');
665
- if (action.originActionId === actionId)
666
- fail('Case-response action origin');
667
- iso(action.createdAt, 'Case-response action createdAt');
668
- iso(action.updatedAt, 'Case-response action updatedAt');
669
- }
670
- if (hasActionBinding) {
671
- if (actionIds.size !== lineageActionIds.length
672
- || actionIdOrder.some((id, index) => lineageActionIds[index] !== id))
673
- fail('Case-response action lineage');
674
- if (recipientRoute && !actionIds.has(recipientRoute.actionId))
675
- fail('Case-response recipient action');
676
- }
677
- if (version >= 11)
678
- assertPacketAmendmentSelection(history.map(candidate => {
679
- const action = candidate;
680
- return { id: action.actionId, history: action.transitions, originActionId: action.originActionId, amendment: action.amendment };
681
- }), selectedActionId, evidenceIds);
682
- const lifecycle = exact(root.responseLifecycle, ['providerOutcomeState', 'latestProviderOutcome', 'observedChangeState', 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations'], 'Case-response lifecycle');
683
- const providerOutcomeState = enumeration(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle provider state');
684
- let actualLatestProvider = null;
685
- if (lifecycle.latestProviderOutcome !== null) {
686
- const outcome = exact(lifecycle.latestProviderOutcome, ['actionId', 'eventId', 'outcome', 'occurredAt', 'reference'], 'Case-response lifecycle provider outcome');
687
- const actionId = text(outcome.actionId, 'Case-response lifecycle action id', 64);
688
- const eventId = text(outcome.eventId, 'Case-response lifecycle event id', 64);
689
- const providerOutcome = enumeration(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response lifecycle provider outcome');
690
- iso(outcome.occurredAt, 'Case-response lifecycle provider time');
691
- const occurredAt = outcome.occurredAt;
692
- optionalText(outcome.reference, 'Case-response lifecycle reference', 500);
693
- actualLatestProvider = {
694
- actionId,
695
- eventId,
696
- outcome: providerOutcome,
697
- occurredAt,
698
- reference: outcome.reference,
699
- };
700
- }
701
- providerEvents.sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt)
702
- || (right.eventId < left.eventId ? -1 : right.eventId > left.eventId ? 1 : 0));
703
- const latestProviderTime = providerEvents[0]?.occurredAt ?? null;
704
- const latestProviderEvents = latestProviderTime === null
705
- ? []
706
- : providerEvents.filter((event) => event.occurredAt === latestProviderTime);
707
- const expectedLatestProvider = (hasActionBinding ? lineageComplete : escalationHistoryOmitted === 0)
708
- && latestProviderEvents.length === 1 && latestProviderEvents[0].applied
709
- ? latestProviderEvents[0]
710
- : null;
711
- const expectedProviderState = (hasActionBinding ? !lineageComplete : escalationHistoryOmitted > 0)
712
- ? 'ambiguous'
713
- : providerEvents.length === 0 ? 'missing' : expectedLatestProvider ? 'available' : 'ambiguous';
714
- if (providerOutcomeState !== expectedProviderState
715
- || (actualLatestProvider === null) !== (expectedLatestProvider === null)
716
- || (actualLatestProvider && expectedLatestProvider && (actualLatestProvider.actionId !== expectedLatestProvider.actionId
717
- || actualLatestProvider.eventId !== expectedLatestProvider.eventId
718
- || actualLatestProvider.outcome !== expectedLatestProvider.outcome
719
- || actualLatestProvider.occurredAt !== expectedLatestProvider.occurredAt
720
- || actualLatestProvider.reference !== expectedLatestProvider.reference)))
721
- fail('Case-response lifecycle provider state');
722
- const observedChangeState = enumeration(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle observed-change state');
723
- if (lifecycle.latestObservedEffect !== null) {
724
- const effect = exact(lifecycle.latestObservedEffect, ['reviewId', 'state', 'observedAt', 'sourceClass', 'source'], 'Case-response lifecycle observed effect');
725
- text(effect.reviewId, 'Case-response effect review id', 64);
726
- enumeration(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response effect state');
727
- iso(effect.observedAt, 'Case-response effect observedAt');
728
- enumeration(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response effect source class');
729
- text(effect.source, 'Case-response effect source', 80);
730
- }
731
- iso(lifecycle.latestObservedChangeAt, 'Case-response observed change time', true);
732
- if ((observedChangeState === 'available') !== (lifecycle.latestObservedChangeAt !== null))
733
- fail('Case-response lifecycle observed-change state');
734
- if (lifecycle.closure !== null) {
735
- const closure = exact(lifecycle.closure, ['id', 'reason', 'createdAt', 'limitations'], 'Case-response closure');
736
- text(closure.id, 'Case-response closure id', 64);
737
- enumeration(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
738
- iso(closure.createdAt, 'Case-response closure time');
739
- strings(closure.limitations, 'Case-response closure limitations', 8, 240);
740
- }
741
- strings(lifecycle.limitations, 'Case-response lifecycle limitations', 8, 600);
742
- const reviewedInputs = {
743
- contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
744
- version: version === CASE_RESPONSE_PACKET_VERSION
745
- ? CASE_RESPONSE_REVIEW_INPUTS_VERSION
746
- : version === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION ? LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION
747
- : hasPlatformRoutes ? PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION
748
- : hasActionBinding ? PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION,
749
- profile: {
750
- id: profile.id,
751
- label: profile.label,
752
- audience: profile.audience,
753
- subject: profile.subject,
754
- checklist: profile.checklist,
755
- includedEvidence: profile.includedEvidence,
756
- excludedEvidence: profile.excludedEvidence,
757
- redactions: profile.redactions,
758
- },
759
- case: root.case,
760
- incident: root.incident,
761
- contacts: root.contacts,
762
- ...(hasActionBinding ? { recipientRoute: root.recipientRoute, actionBinding: root.actionBinding } : {}),
763
- selectedEvidence: root.selectedEvidence,
764
- contradictions: root.contradictions,
765
- readiness: root.readiness,
766
- artefactReferences: root.artefactReferences,
767
- escalationHistory: root.escalationHistory,
768
- escalationHistoryOmitted: root.escalationHistoryOmitted,
769
- escalationHistoryLimitations: root.escalationHistoryLimitations,
770
- responseLifecycle: root.responseLifecycle,
771
- };
772
- const expectedReviewDigest = createHash('sha256')
773
- .update(canonicalArtifactJsonV2(reviewedInputs))
774
- .digest('hex');
775
- if (authorisation.reviewedInputDigestSha256 !== expectedReviewDigest)
776
- fail('Case-response reviewed-input digest');
777
- const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
778
- iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
779
- integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
780
- integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
781
- integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
782
- const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
783
- integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
784
- enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
785
- boolean(age.refreshRecommended, 'Case-response refresh recommendation');
786
- const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
787
- if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band || age.refreshRecommended !== expectedAge.refreshRecommended)
788
- fail('Case-response observation age');
789
- strings(provenance.limitations, 'Case-response limitations', 8, 600);
790
- const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
791
- if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2' || integrity.scope !== 'packet excluding integrity')
792
- fail('Case-response integrity');
793
- if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
794
- fail('Case-response integrity');
795
- }
2
+ import { assertCaseResponseReviewedInputDigest, inspectCaseResponsePacketSemantics } from "../../packages/cases/case-response-packet-validation.mjs";
3
+ /** Preserve synchronous CLI validation; the offline verifier checks the outer
4
+ * packet digest separately before returning a successful verification report. */
796
5
  export function validateCaseResponsePacket(value) {
797
- if (value.schemaVersion === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION)
798
- return validateVersionedCaseResponsePacket(value, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION);
799
- if (value.schemaVersion === PUBLIC_CASE_RESPONSE_PACKET_VERSION)
800
- return validateCaseResponsePacketV6(value);
801
- if (value.schemaVersion === PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION) {
802
- return validateVersionedCaseResponsePacket(value, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION);
803
- }
804
- if (value.schemaVersion === PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION) {
805
- return validateVersionedCaseResponsePacket(value, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION);
806
- }
807
- if (value.schemaVersion === PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION) {
808
- return validateVersionedCaseResponsePacket(value, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION);
809
- }
810
- if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion < CASE_RESPONSE_PACKET_VERSION) {
811
- throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is not part of the public compatibility boundary; no data was changed.`);
812
- }
813
- if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion > CASE_RESPONSE_PACKET_VERSION) {
814
- throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is newer than the supported version ${CASE_RESPONSE_PACKET_VERSION}; no data was changed.`);
815
- }
816
- validateVersionedCaseResponsePacket(value, CASE_RESPONSE_PACKET_VERSION);
6
+ const pending = inspectCaseResponsePacketSemantics(value);
7
+ if (pending)
8
+ assertCaseResponseReviewedInputDigest(pending, createHash('sha256').update(pending.canonicalInputs).digest('hex'));
817
9
  }