@slicedearth/whoisleuth-cli 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/arguments.mjs +21 -5
- package/cli/artifact-validation/case-response.mjs +771 -0
- package/cli/artifact-validation/investigation-capsule.mjs +426 -0
- package/cli/artifact-validation/lookup-evidence.mjs +718 -0
- package/cli/artifact-validation/signed-review.mjs +598 -0
- package/cli/artifact-validation/structure-primitives.mjs +105 -0
- package/cli/artifact-verify.mjs +1 -1
- package/cli/assurance-command-runner.mjs +324 -0
- package/cli/command-reference.mjs +1175 -843
- package/cli/completion.mjs +56 -9
- package/cli/discriminated-command-handlers.mjs +11 -0
- package/cli/doctor.mjs +2 -2
- package/cli/evidence-command-runner.mjs +41 -31
- package/cli/export-evidence.mjs +1 -1
- package/cli/fail-policy.mjs +33 -8
- package/cli/formatters/terminal-command-formats.mjs +437 -0
- package/cli/formatters/terminal-lookup.mjs +758 -0
- package/cli/formatters/terminal-metadata.mjs +58 -0
- package/cli/formatters/terminal-shared.mjs +83 -0
- package/cli/formatters/terminal.mjs +5 -1232
- package/cli/history-command-runner.mjs +132 -0
- package/cli/inline-command-families.mjs +45 -0
- package/cli/inline-command-runner.mjs +55 -0
- package/cli/investigation-plan.mjs +9 -2
- package/cli/investigation-run.mjs +114 -9
- package/cli/lookup-brief.mjs +141 -23
- package/cli/lookup-browser-view.mjs +289 -0
- package/cli/lookup-browser.mjs +3 -288
- package/cli/mail-review.mjs +2 -6
- package/cli/offline-artifact-validation.mjs +19 -0
- package/cli/review-command-runner.mjs +269 -0
- package/cli/runner.mjs +15 -972
- package/cli/saved-lookup.mjs +19 -2
- package/cli/support-command-runner.mjs +229 -0
- package/cli/workflow-command-runner.mjs +129 -0
- package/docs/cli-reference.md +17 -10
- package/docs/cli.md +5 -3
- package/lib/availability.mjs +12 -9
- package/lib/bounded-contract-normalizers.mjs +2 -9
- package/lib/browser-library-profile.mjs +1 -2
- package/lib/client-behavior-profile.mjs +1 -2
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-posture-analysis.mjs +10 -1
- package/lib/domain-posture.mjs +51 -23
- package/lib/evidence-export.mjs +46 -5
- package/lib/evidence-report-markdown.mjs +4 -0
- package/lib/evidence-report.mjs +50 -0
- package/lib/generated/cisa-kev-catalog.mjs +34 -4
- package/lib/generated/registrar-standing-catalogue.mjs +367 -0
- package/lib/json-record.mjs +12 -0
- package/lib/lookup-child-profile-contract.mjs +858 -0
- package/lib/lookup-contract-primitives.mjs +98 -0
- package/lib/lookup-diagnostics.mjs +96 -0
- package/lib/lookup-network-evidence-bounds.mjs +1 -0
- package/lib/lookup-response.mjs +367 -0
- package/lib/lookup.mjs +28 -443
- package/lib/page-role-profile.mjs +1 -3
- package/lib/project-metadata.mjs +1 -1
- package/lib/registrar-standing-catalogue-contract.mjs +70 -0
- package/lib/registrar-standing-contract.mjs +300 -0
- package/lib/registrar-standing.mjs +269 -0
- package/lib/registry-insights.mjs +2 -8
- package/lib/risk-scoring.mjs +26 -10
- package/lib/sslbl-certificates.generated.mjs +138 -136
- package/lib/structured-data-identity.mjs +1 -3
- package/lib/technology-evidence-role.mjs +51 -0
- package/lib/threat-intelligence-runtime.mjs +3 -3
- package/lib/threat-intelligence-types.mjs +13 -0
- package/lib/threatfox-intelligence.mjs +1 -3
- package/lib/tls-intelligence.mjs +1 -2
- package/lib/urlhaus-intelligence.mjs +1 -3
- package/lib/urlscan-intelligence.mjs +1 -3
- package/lib/website-security-posture.mjs +1 -2
- package/lib/website-technology.mjs +72 -23
- package/lib/whois-common-formats.mjs +276 -0
- package/lib/whois-dialects.mjs +534 -0
- package/lib/whois-parser-support.mjs +305 -0
- package/lib/whois-parser.mjs +135 -0
- package/lib/whois.mjs +6 -1187
- package/package.json +3 -3
- package/packages/cases/case-evidence-model.mjs +9 -0
- package/packages/cases/case-migration-model.mjs +17 -11
- package/packages/cases/case-response-model.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +297 -112
- package/packages/comparison/page-similarity.mjs +23 -0
- package/packages/contracts/case-portability.mjs +227 -63
- package/packages/contracts/cli-lookup.mjs +4 -3
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/investigation-portability.mjs +1 -1
- package/packages/contracts/risk-calibration.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +34 -6
- package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
- package/packages/investigation/lookup-claim-readiness.mjs +2 -2
- package/packages/workspace/brand-profile-model.mjs +1 -1
- package/packages/workspace/website-snapshot-model.mjs +79 -6
- package/third-party-notices.txt +1 -1
- package/cli/artifact-structure.mjs +0 -2507
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_ASSERTION_STATES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PIN_COMPLETENESS, CASE_PROVIDER_OUTCOMES, } from "./case-response-model.mjs";
|
|
4
4
|
import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
|
|
5
5
|
import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
|
|
6
|
-
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_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
|
|
7
|
-
export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
|
|
6
|
+
import { CASE_RESPONSE_PACKET_SCHEMA, 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, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
|
|
7
|
+
export { CASE_RESPONSE_PACKET_SCHEMA, 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, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
|
|
8
8
|
export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
|
|
9
9
|
version: 1,
|
|
10
10
|
owner: 'case',
|
|
@@ -65,7 +65,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
65
65
|
evidenceOrder: ['Incident facts', 'Registration evidence', 'Selected observations', 'Action history'],
|
|
66
66
|
includedEvidence: ['Exact URLs', 'Observation time', 'Case disposition', 'Selected evidence pins', 'Registrar contact provenance'],
|
|
67
67
|
excludedEvidence: ['Raw WHOIS or RDAP payloads', 'Unselected contacts', 'Provider secrets', 'Unrelated analyst notes'],
|
|
68
|
-
redactions: ['Remove unnecessary personal registration data', '
|
|
68
|
+
redactions: ['Remove unnecessary personal registration data', 'Credential-bearing URLs are rejected; review query strings and fragments for secrets before sharing'],
|
|
69
69
|
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
70
70
|
followUpFields: ['Registrar reference', 'Acknowledgement time', 'Resolution or transfer outcome'],
|
|
71
71
|
},
|
|
@@ -79,7 +79,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
79
79
|
evidenceOrder: ['Incident facts', 'Registry publication', 'Prior escalation', 'Selected observations'],
|
|
80
80
|
includedEvidence: ['Exact URLs', 'Observation time', 'Registry contact provenance', 'Selected evidence pins', 'Prior action references'],
|
|
81
81
|
excludedEvidence: ['Raw registry payloads', 'Unselected contacts', 'Secrets', 'Unsupported ownership claims'],
|
|
82
|
-
redactions: ['Remove unnecessary personal registration data', '
|
|
82
|
+
redactions: ['Remove unnecessary personal registration data', 'Credential-bearing URLs are rejected; review query strings and fragments for secrets before sharing'],
|
|
83
83
|
attachments: ['Reviewed response packet', 'Optional normalised case report', 'Optional registrar reference'],
|
|
84
84
|
followUpFields: ['Registry reference', 'Registrar referral', 'Delegation or status outcome'],
|
|
85
85
|
},
|
|
@@ -93,7 +93,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
93
93
|
evidenceOrder: ['Incident facts', 'Observed endpoint context', 'Selected web evidence', 'Action history'],
|
|
94
94
|
includedEvidence: ['Exact URLs', 'Observation time', 'Selected HTTP, TLS, DNS, or page-identity pins', 'Contact provenance'],
|
|
95
95
|
excludedEvidence: ['Raw response bodies', 'Cookies', 'Secrets', 'Unproven origin-host claims'],
|
|
96
|
-
redactions: ['
|
|
96
|
+
redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, cookies, and unrelated contacts before sharing'],
|
|
97
97
|
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
98
98
|
followUpFields: ['Provider ticket', 'Content status', 'Infrastructure change outcome'],
|
|
99
99
|
},
|
|
@@ -107,7 +107,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
107
107
|
evidenceOrder: ['Incident facts', 'Selected observations', 'Analyst reasoning', 'Action history'],
|
|
108
108
|
includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Contact source and limitations'],
|
|
109
109
|
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected case notes', 'Legal conclusions'],
|
|
110
|
-
redactions: ['
|
|
110
|
+
redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, personal data, and unrelated identifiers before sharing'],
|
|
111
111
|
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
112
112
|
followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
|
|
113
113
|
},
|
|
@@ -121,7 +121,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
121
121
|
evidenceOrder: ['Exact URLs', 'Observed behaviour', 'Selected corroborating evidence', 'Limitations'],
|
|
122
122
|
includedEvidence: ['Exact URLs', 'Observation time', 'Observed harm', 'Selected evidence pins', 'Contradictions and cautions'],
|
|
123
123
|
excludedEvidence: ['Raw provider payloads', 'Secrets', 'Unselected personal data', 'Automated maliciousness claims'],
|
|
124
|
-
redactions: ['
|
|
124
|
+
redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, and unrelated contacts before sharing'],
|
|
125
125
|
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
126
126
|
followUpFields: ['Submission reference', 'Review state', 'Listing or delisting outcome'],
|
|
127
127
|
},
|
|
@@ -135,7 +135,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
135
135
|
evidenceOrder: ['Decision packet', 'Selected evidence', 'Incident facts', 'Action and investigation trail'],
|
|
136
136
|
includedEvidence: ['Case disposition', 'Selected evidence pins', 'Analyst decisions and assertions', 'Action history', 'Exact URLs when relevant'],
|
|
137
137
|
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported attribution'],
|
|
138
|
-
redactions: ['
|
|
138
|
+
redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, and unnecessary personal data before sharing'],
|
|
139
139
|
attachments: ['Reviewed response packet', 'Normalised case report when required by internal policy'],
|
|
140
140
|
followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
|
|
141
141
|
},
|
|
@@ -204,43 +204,109 @@ function normalizeLimitations(value) {
|
|
|
204
204
|
}
|
|
205
205
|
return [...unique];
|
|
206
206
|
}
|
|
207
|
-
function
|
|
208
|
-
if (
|
|
209
|
-
return
|
|
207
|
+
function actionContactKind(type) {
|
|
208
|
+
if (type === 'registrar_report')
|
|
209
|
+
return 'registrar';
|
|
210
|
+
if (type === 'registry_report')
|
|
211
|
+
return 'registry';
|
|
212
|
+
if (type === 'network_hosting_report')
|
|
213
|
+
return 'network_hosting';
|
|
214
|
+
if (type === 'security_contact_report')
|
|
215
|
+
return 'security_txt';
|
|
216
|
+
return 'manual';
|
|
217
|
+
}
|
|
218
|
+
function routeFreshness(observedAt, generatedAt) {
|
|
219
|
+
if (!observedAt)
|
|
220
|
+
return 'unknown';
|
|
221
|
+
const routeAge = Date.parse(generatedAt) - Date.parse(observedAt);
|
|
222
|
+
return routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
|
|
223
|
+
|| routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
|
|
224
|
+
? 'stale'
|
|
225
|
+
: 'current';
|
|
226
|
+
}
|
|
227
|
+
function selectedPacketAction(caseRecord, input) {
|
|
228
|
+
const actionId = typeof input.actionId === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(input.actionId)
|
|
229
|
+
? input.actionId
|
|
230
|
+
: null;
|
|
231
|
+
return actionId ? caseRecord.actions.find((action) => action.id === actionId) ?? null : null;
|
|
232
|
+
}
|
|
233
|
+
function packetActionLineage(caseRecord, input) {
|
|
234
|
+
const selected = selectedPacketAction(caseRecord, input);
|
|
235
|
+
if (!selected)
|
|
236
|
+
return { selected: null, actions: [], ids: [], complete: true };
|
|
237
|
+
const byId = new Map(caseRecord.actions.map((action) => [action.id, action]));
|
|
238
|
+
const ids = [];
|
|
210
239
|
const seen = new Set();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
let current = selected;
|
|
241
|
+
while (current && ids.length < MAX_RESPONSE_ACTION_HISTORY && !seen.has(current.id)) {
|
|
242
|
+
ids.push(current.id);
|
|
243
|
+
seen.add(current.id);
|
|
244
|
+
current = current.originActionId ? byId.get(current.originActionId) : undefined;
|
|
245
|
+
}
|
|
246
|
+
const complete = !current;
|
|
247
|
+
return {
|
|
248
|
+
selected,
|
|
249
|
+
actions: ids.map((id) => byId.get(id)).filter(Boolean),
|
|
250
|
+
ids,
|
|
251
|
+
complete,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function bindPacketRoute(caseRecord, input, generatedAt) {
|
|
255
|
+
const profile = responsePacketProfile(input.profile);
|
|
256
|
+
const lineage = packetActionLineage(caseRecord, input);
|
|
257
|
+
const action = lineage.selected;
|
|
258
|
+
if (!action) {
|
|
259
|
+
return {
|
|
260
|
+
actionBinding: {
|
|
261
|
+
state: 'not_selected', selectedActionId: null, lineageActionIds: [],
|
|
262
|
+
limitations: ['No retained Case action is selected for this packet.'],
|
|
263
|
+
},
|
|
264
|
+
recipientRoute: null,
|
|
265
|
+
contacts: [],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const kind = actionContactKind(action.type);
|
|
269
|
+
const compatible = profile.requiredContactKind
|
|
270
|
+
? kind === profile.requiredContactKind
|
|
271
|
+
: profile.id === 'browser_blocklist'
|
|
272
|
+
? kind === 'manual'
|
|
273
|
+
: true;
|
|
274
|
+
if (!compatible) {
|
|
275
|
+
return {
|
|
276
|
+
actionBinding: {
|
|
277
|
+
state: 'selected', selectedActionId: action.id, lineageActionIds: [...lineage.ids],
|
|
278
|
+
limitations: [`The selected action does not match the ${profile.label.toLowerCase()} route requirement.`],
|
|
279
|
+
},
|
|
280
|
+
recipientRoute: null,
|
|
281
|
+
contacts: [],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const observedAt = timestamp(action.routeObservedAt);
|
|
285
|
+
const recipientRoute = {
|
|
286
|
+
actionId: action.id,
|
|
287
|
+
kind,
|
|
288
|
+
contact: text(action.recipient, 320),
|
|
289
|
+
source: text(action.contactSource, 120) || 'analyst supplied',
|
|
290
|
+
observedAt,
|
|
291
|
+
freshness: routeFreshness(observedAt, generatedAt),
|
|
292
|
+
limitations: normalizeLimitations(action.contactLimitations),
|
|
293
|
+
};
|
|
294
|
+
const contacts = kind === 'manual' ? [] : [{
|
|
229
295
|
kind,
|
|
230
|
-
contact,
|
|
231
|
-
source:
|
|
296
|
+
contact: recipientRoute.contact,
|
|
297
|
+
source: recipientRoute.source,
|
|
232
298
|
observedAt,
|
|
233
|
-
freshness:
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
299
|
+
freshness: recipientRoute.freshness,
|
|
300
|
+
limitations: [...recipientRoute.limitations],
|
|
301
|
+
}];
|
|
302
|
+
return {
|
|
303
|
+
actionBinding: {
|
|
304
|
+
state: 'selected', selectedActionId: action.id, lineageActionIds: [...lineage.ids],
|
|
305
|
+
limitations: lineage.complete ? [] : ['The origin-action lineage exceeded the packet action bound or contained a cycle.'],
|
|
306
|
+
},
|
|
307
|
+
recipientRoute,
|
|
308
|
+
contacts,
|
|
309
|
+
};
|
|
244
310
|
}
|
|
245
311
|
function normalizeSelectedEvidence(caseRecord, value) {
|
|
246
312
|
if (!Array.isArray(value))
|
|
@@ -292,14 +358,11 @@ function readinessOverride(value, key) {
|
|
|
292
358
|
limitations: normalizeLimitations(item.limitations),
|
|
293
359
|
};
|
|
294
360
|
}
|
|
295
|
-
function buildResponseReadiness(caseRecord, input, generatedAt, contacts, selectedEvidence, contradictions) {
|
|
361
|
+
function buildResponseReadiness(caseRecord, input, generatedAt, contacts, recipientRoute, selectedEvidence, contradictions) {
|
|
296
362
|
const profile = responsePacketProfile(input.profile);
|
|
297
363
|
const urls = normalizeUrls(input.abusiveUrls);
|
|
298
364
|
const observedAt = timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null;
|
|
299
365
|
const age = observedAt ? observationAge(observedAt, generatedAt) : null;
|
|
300
|
-
const requiredContact = profile.requiredContactKind
|
|
301
|
-
? contacts.find((contact) => contact.kind === profile.requiredContactKind) ?? null
|
|
302
|
-
: contacts[0] ?? null;
|
|
303
366
|
const infrastructure = readinessOverride(input.readiness, 'infrastructureResponsibility');
|
|
304
367
|
const authority = readinessOverride(input.readiness, 'authorityReview');
|
|
305
368
|
const contradictionReview = readinessOverride(input.readiness, 'contradictionsReview');
|
|
@@ -342,10 +405,10 @@ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, select
|
|
|
342
405
|
},
|
|
343
406
|
{
|
|
344
407
|
id: 'recipient_route', label: 'Recipient-route provenance and freshness',
|
|
345
|
-
state: !
|
|
346
|
-
detail: !
|
|
408
|
+
state: !recipientRoute ? 'not_provided' : recipientRoute.freshness === 'stale' ? 'stale' : recipientRoute.freshness === 'current' && recipientRoute.source ? 'complete' : 'partial',
|
|
409
|
+
detail: !recipientRoute ? 'No profile-appropriate recipient route is selected.' : `The selected route provenance is ${recipientRoute.source}; freshness is ${recipientRoute.freshness}.`,
|
|
347
410
|
requiredForAuthorisation: external,
|
|
348
|
-
limitations:
|
|
411
|
+
limitations: recipientRoute?.limitations ?? [],
|
|
349
412
|
},
|
|
350
413
|
{
|
|
351
414
|
id: 'authority_review', label: 'Authority review',
|
|
@@ -397,9 +460,9 @@ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, select
|
|
|
397
460
|
}
|
|
398
461
|
export function buildCaseResponseReadiness(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
399
462
|
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
400
|
-
const
|
|
463
|
+
const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
|
|
401
464
|
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
402
|
-
return buildResponseReadiness(caseRecord, input, normalizedGeneratedAt, contacts, selectedEvidence, normalizeContradictions(caseRecord));
|
|
465
|
+
return buildResponseReadiness(caseRecord, input, normalizedGeneratedAt, binding.contacts, binding.recipientRoute, selectedEvidence, normalizeContradictions(caseRecord));
|
|
403
466
|
}
|
|
404
467
|
function normalizeArtefactReferences(value) {
|
|
405
468
|
if (!Array.isArray(value))
|
|
@@ -443,13 +506,16 @@ export function responsePacketProfile(value) {
|
|
|
443
506
|
}
|
|
444
507
|
export function buildResponsePacketProfilePreview(caseRecord, input) {
|
|
445
508
|
const profile = responsePacketProfile(input.profile);
|
|
446
|
-
const
|
|
509
|
+
const binding = bindPacketRoute(caseRecord, input, caseRecord.updatedAt);
|
|
447
510
|
const missingEvidence = [
|
|
448
511
|
...(!normalizeUrls(input.abusiveUrls).length ? ['At least one exact HTTP(S) URL'] : []),
|
|
449
512
|
...(!timestamp(input.observedAt) && !caseRecord.evidenceHistory.at(-1)?.capturedAt ? ['Observation time'] : []),
|
|
450
513
|
...(!normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds).length ? ['Explicitly selected evidence pin'] : []),
|
|
451
|
-
...(profile.
|
|
452
|
-
? [
|
|
514
|
+
...(profile.id !== 'internal_soc' && binding.actionBinding.state !== 'selected'
|
|
515
|
+
? ['Selected retained Case action']
|
|
516
|
+
: []),
|
|
517
|
+
...(profile.id !== 'internal_soc' && !binding.recipientRoute
|
|
518
|
+
? [`${profile.requiredContactKind ? contactLabel(profile.requiredContactKind) : 'Manual submission'} contact route`]
|
|
453
519
|
: []),
|
|
454
520
|
];
|
|
455
521
|
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH) || 'domain activity';
|
|
@@ -472,7 +538,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
472
538
|
const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
|
|
473
539
|
const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
|
|
474
540
|
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
475
|
-
const
|
|
541
|
+
const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
|
|
476
542
|
const urls = normalizeUrls(input.abusiveUrls);
|
|
477
543
|
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
478
544
|
const requiredComplete = Boolean(text(input.category, MAX_ABUSE_CATEGORY_LENGTH)
|
|
@@ -485,6 +551,10 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
485
551
|
.filter((item) => item.kind === 'contradiction' && item.state === 'open')
|
|
486
552
|
.length;
|
|
487
553
|
const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
|
|
554
|
+
const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
|
|
555
|
+
const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
|
|
556
|
+
const responseDisposition = ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition);
|
|
557
|
+
const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
|
|
488
558
|
const profile = responsePacketProfile(input.profile);
|
|
489
559
|
const checks = [
|
|
490
560
|
{
|
|
@@ -506,38 +576,48 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
506
576
|
{
|
|
507
577
|
id: 'analyst_decision',
|
|
508
578
|
label: 'Analyst decision',
|
|
509
|
-
state:
|
|
510
|
-
detail:
|
|
511
|
-
? `${
|
|
512
|
-
:
|
|
579
|
+
state: evidenceLinkedDecisionCount ? 'pass' : 'caution',
|
|
580
|
+
detail: evidenceLinkedDecisionCount
|
|
581
|
+
? `${evidenceLinkedDecisionCount} analyst decision${evidenceLinkedDecisionCount === 1 ? '' : 's'} cite retained evidence and record the escalation rationale.`
|
|
582
|
+
: caseRecord.decisions.length
|
|
583
|
+
? 'Analyst decisions are recorded, but none cites a retained evidence pin.'
|
|
584
|
+
: 'No explicit analyst decision explains why external reporting is appropriate.',
|
|
513
585
|
},
|
|
514
586
|
{
|
|
515
587
|
id: 'recipient_route',
|
|
516
588
|
label: 'Recipient route',
|
|
517
|
-
state:
|
|
518
|
-
detail:
|
|
519
|
-
?
|
|
589
|
+
state: binding.recipientRoute ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
|
|
590
|
+
detail: binding.recipientRoute
|
|
591
|
+
? `The selected ${contactLabel(binding.recipientRoute.kind)} route is bound to action ${binding.recipientRoute.actionId}.`
|
|
520
592
|
: 'No contact route is included; identify and review the intended recipient before sending.',
|
|
521
593
|
},
|
|
522
594
|
{
|
|
523
595
|
id: 'profile_recipient',
|
|
524
596
|
label: 'Audience-specific recipient',
|
|
525
|
-
state: profile.
|
|
526
|
-
?
|
|
527
|
-
: 'pass',
|
|
597
|
+
state: profile.id === 'internal_soc'
|
|
598
|
+
? 'pass'
|
|
599
|
+
: binding.recipientRoute ? 'pass' : 'block',
|
|
528
600
|
detail: profile.requiredContactKind
|
|
529
|
-
?
|
|
601
|
+
? binding.recipientRoute?.kind === profile.requiredContactKind
|
|
530
602
|
? `The ${contactLabel(profile.requiredContactKind)} route required by the ${profile.label.toLowerCase()} is present.`
|
|
531
603
|
: `The ${profile.label.toLowerCase()} expects a separately attributed ${contactLabel(profile.requiredContactKind)} route.`
|
|
532
|
-
:
|
|
604
|
+
: profile.id === 'browser_blocklist'
|
|
605
|
+
? binding.recipientRoute
|
|
606
|
+
? 'A manually reviewed submission destination is bound to the selected Case action.'
|
|
607
|
+
: 'The browser or blocklist profile requires a manually reviewed submission destination recorded as a Case action.'
|
|
608
|
+
: `${profile.label} has no fixed external contact-kind requirement.`,
|
|
533
609
|
},
|
|
534
610
|
{
|
|
535
611
|
id: 'case_disposition',
|
|
536
612
|
label: 'Case disposition',
|
|
537
|
-
state:
|
|
538
|
-
detail:
|
|
539
|
-
? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}.`
|
|
540
|
-
:
|
|
613
|
+
state: responseDisposition && caseRecord.reviewReasonCode && evidenceLinkedDecisionCount ? 'pass' : 'caution',
|
|
614
|
+
detail: responseDisposition && caseRecord.reviewReasonCode && evidenceLinkedDecisionCount
|
|
615
|
+
? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')} with the reviewed reason ${caseRecord.reviewReasonCode.replaceAll('_', ' ')} and an evidence-linked decision.`
|
|
616
|
+
: !responseDisposition
|
|
617
|
+
? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}; confirm it before external use.`
|
|
618
|
+
: !caseRecord.reviewReasonCode
|
|
619
|
+
? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}, but no reviewed reason is recorded.`
|
|
620
|
+
: 'The case disposition and reason are recorded, but no analyst decision cites retained evidence.',
|
|
541
621
|
},
|
|
542
622
|
{
|
|
543
623
|
id: 'evidence_freshness',
|
|
@@ -557,13 +637,23 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
557
637
|
? `${openContradictions} open contradiction${openContradictions === 1 ? '' : 's'} should be addressed or disclosed.`
|
|
558
638
|
: 'No open contradictory-evidence assertion is recorded.',
|
|
559
639
|
},
|
|
640
|
+
{
|
|
641
|
+
id: 'packet_action',
|
|
642
|
+
label: 'Packet action',
|
|
643
|
+
state: binding.actionBinding.state === 'selected' ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
|
|
644
|
+
detail: binding.actionBinding.state === 'selected'
|
|
645
|
+
? `Action ${binding.actionBinding.selectedActionId} owns this packet and its retained origin lineage.`
|
|
646
|
+
: 'Select the retained Case action this packet prepares or documents.',
|
|
647
|
+
},
|
|
560
648
|
{
|
|
561
649
|
id: 'action_tracking',
|
|
562
650
|
label: 'Action tracking',
|
|
563
|
-
state:
|
|
564
|
-
detail:
|
|
565
|
-
? `${
|
|
566
|
-
:
|
|
651
|
+
state: reviewedActionCount ? 'pass' : 'caution',
|
|
652
|
+
detail: reviewedActionCount
|
|
653
|
+
? `${reviewedActionCount} reviewed or later-stage action${reviewedActionCount === 1 ? ' is' : 's are'} tracked; ${actionSummary.overdue} overdue and ${actionSummary.followUpDue} due for follow-up.`
|
|
654
|
+
: actionSummary.total
|
|
655
|
+
? `${actionSummary.total} action${actionSummary.total === 1 ? ' remains' : 's remain'} before reviewed state.`
|
|
656
|
+
: 'No reviewed case action is recorded for ownership, submission, or follow-up.',
|
|
567
657
|
},
|
|
568
658
|
];
|
|
569
659
|
const counts = {
|
|
@@ -572,7 +662,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
572
662
|
pass: checks.filter((item) => item.state === 'pass').length,
|
|
573
663
|
};
|
|
574
664
|
return {
|
|
575
|
-
version:
|
|
665
|
+
version: 3,
|
|
576
666
|
status: counts.block ? 'needs_input' : counts.caution ? 'review_cautions' : 'ready_for_review',
|
|
577
667
|
canExport: counts.block === 0,
|
|
578
668
|
counts,
|
|
@@ -580,16 +670,17 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
580
670
|
actionSummary,
|
|
581
671
|
};
|
|
582
672
|
}
|
|
583
|
-
function normalizeActionHistory(caseRecord) {
|
|
584
|
-
const
|
|
673
|
+
function normalizeActionHistory(caseRecord, input) {
|
|
674
|
+
const lineage = packetActionLineage(caseRecord, input);
|
|
675
|
+
const omitted = Math.max(0, caseRecord.actions.length - lineage.actions.length);
|
|
585
676
|
return {
|
|
586
|
-
actions:
|
|
587
|
-
.slice(-MAX_RESPONSE_ACTION_HISTORY)
|
|
677
|
+
actions: lineage.actions
|
|
588
678
|
.map((action) => ({
|
|
589
679
|
actionId: action.id,
|
|
590
680
|
type: text(action.type, 80),
|
|
591
681
|
recipient: text(action.recipient, 320),
|
|
592
682
|
contactSource: text(action.contactSource, 120),
|
|
683
|
+
routeObservedAt: timestamp(action.routeObservedAt),
|
|
593
684
|
state: text(action.state, 80),
|
|
594
685
|
reference: text(action.reference, 500) || null,
|
|
595
686
|
providerOutcome: text(action.providerOutcome, 80) || null,
|
|
@@ -616,18 +707,37 @@ function normalizeActionHistory(caseRecord) {
|
|
|
616
707
|
updatedAt: timestamp(action.updatedAt) ?? caseRecord.updatedAt,
|
|
617
708
|
})),
|
|
618
709
|
omitted,
|
|
619
|
-
limitations:
|
|
620
|
-
`${omitted}
|
|
621
|
-
'
|
|
622
|
-
]
|
|
710
|
+
limitations: [
|
|
711
|
+
...(omitted ? [`${omitted} Case response action${omitted === 1 ? '' : 's'} outside the retained selected-action lineage ${omitted === 1 ? 'was' : 'were'} excluded from this packet.`] : []),
|
|
712
|
+
...(!lineage.complete ? ['The selected action origin lineage exceeded the packet bound or contained a cycle; provider-outcome time is withheld.'] : []),
|
|
713
|
+
],
|
|
714
|
+
actionBinding: lineage.selected ? {
|
|
715
|
+
state: 'selected',
|
|
716
|
+
selectedActionId: lineage.selected.id,
|
|
717
|
+
lineageActionIds: [...lineage.ids],
|
|
718
|
+
limitations: lineage.complete ? [] : ['The origin-action lineage is incomplete.'],
|
|
719
|
+
} : {
|
|
720
|
+
state: 'not_selected', selectedActionId: null, lineageActionIds: [],
|
|
721
|
+
limitations: ['No retained Case action is selected for this packet.'],
|
|
722
|
+
},
|
|
723
|
+
lineageComplete: lineage.complete,
|
|
623
724
|
};
|
|
624
725
|
}
|
|
625
|
-
function normalizeResponseLifecycle(caseRecord,
|
|
626
|
-
const
|
|
627
|
-
const
|
|
726
|
+
function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
|
|
727
|
+
const scopedIds = new Set(actionIds);
|
|
728
|
+
const scopedRecord = {
|
|
729
|
+
...caseRecord,
|
|
730
|
+
actions: caseRecord.actions.filter((action) => scopedIds.has(action.id)),
|
|
731
|
+
closures: {
|
|
732
|
+
...caseRecord.closures,
|
|
733
|
+
records: caseRecord.closures.records.filter((closure) => closure.actionId !== null && scopedIds.has(closure.actionId)),
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
const summary = buildCaseResponseLifecycleSummary(scopedRecord);
|
|
737
|
+
const providerOutcomeState = lineageComplete ? summary.providerOutcomeState : 'ambiguous';
|
|
628
738
|
return {
|
|
629
739
|
providerOutcomeState,
|
|
630
|
-
latestProviderOutcome:
|
|
740
|
+
latestProviderOutcome: lineageComplete && summary.latestProviderOutcome ? { ...summary.latestProviderOutcome } : null,
|
|
631
741
|
observedChangeState: summary.observedChangeState,
|
|
632
742
|
latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect } : null,
|
|
633
743
|
latestObservedChangeAt: summary.latestObservedChangeAt,
|
|
@@ -641,7 +751,7 @@ function normalizeResponseLifecycle(caseRecord, escalationHistoryOmitted) {
|
|
|
641
751
|
'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
|
|
642
752
|
'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
|
|
643
753
|
'Times are withheld when the corresponding typed event is missing or ambiguous.',
|
|
644
|
-
...(
|
|
754
|
+
...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
|
|
645
755
|
],
|
|
646
756
|
};
|
|
647
757
|
}
|
|
@@ -666,11 +776,11 @@ async function sha256(value) {
|
|
|
666
776
|
export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
667
777
|
const profile = buildResponsePacketProfilePreview(caseRecord, input);
|
|
668
778
|
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
|
|
669
|
-
const contacts = normalizeContacts(input.contacts, generatedAt);
|
|
670
779
|
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
671
780
|
const contradictions = normalizeContradictions(caseRecord);
|
|
672
|
-
const
|
|
673
|
-
const
|
|
781
|
+
const binding = bindPacketRoute(caseRecord, input, generatedAt);
|
|
782
|
+
const readiness = buildResponseReadiness(caseRecord, input, generatedAt, binding.contacts, binding.recipientRoute, selectedEvidence, contradictions);
|
|
783
|
+
const escalation = normalizeActionHistory(caseRecord, input);
|
|
674
784
|
return {
|
|
675
785
|
contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
|
|
676
786
|
version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
|
|
@@ -698,7 +808,9 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
|
698
808
|
observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
|
|
699
809
|
observedAt: timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null,
|
|
700
810
|
},
|
|
701
|
-
contacts,
|
|
811
|
+
contacts: binding.contacts,
|
|
812
|
+
recipientRoute: binding.recipientRoute,
|
|
813
|
+
actionBinding: escalation.actionBinding,
|
|
702
814
|
selectedEvidence,
|
|
703
815
|
contradictions,
|
|
704
816
|
readiness,
|
|
@@ -706,7 +818,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
|
706
818
|
escalationHistory: escalation.actions,
|
|
707
819
|
escalationHistoryOmitted: escalation.omitted,
|
|
708
820
|
escalationHistoryLimitations: escalation.limitations,
|
|
709
|
-
responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.
|
|
821
|
+
responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.actionBinding.lineageActionIds, escalation.lineageComplete),
|
|
710
822
|
};
|
|
711
823
|
}
|
|
712
824
|
const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
|
|
@@ -716,6 +828,8 @@ const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
|
|
|
716
828
|
'case',
|
|
717
829
|
'incident',
|
|
718
830
|
'contacts',
|
|
831
|
+
'recipientRoute',
|
|
832
|
+
'actionBinding',
|
|
719
833
|
'selectedEvidence',
|
|
720
834
|
'contradictions',
|
|
721
835
|
'readiness',
|
|
@@ -725,6 +839,7 @@ const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
|
|
|
725
839
|
'escalationHistoryLimitations',
|
|
726
840
|
'responseLifecycle',
|
|
727
841
|
]);
|
|
842
|
+
const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze(CASE_RESPONSE_REVIEW_INPUT_KEYS.filter((key) => key !== 'recipientRoute' && key !== 'actionBinding'));
|
|
728
843
|
function exactReviewRecord(value, keys, label) {
|
|
729
844
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
730
845
|
throw new TypeError(`${label} must be an exact object.`);
|
|
@@ -832,11 +947,20 @@ function recursivelyFreezeReviewValue(value) {
|
|
|
832
947
|
return Object.freeze(value);
|
|
833
948
|
}
|
|
834
949
|
export function validateCaseResponseReviewInputs(value) {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
950
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
951
|
+
throw new TypeError('Case-response review inputs must be an exact object.');
|
|
952
|
+
}
|
|
953
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
954
|
+
if (!descriptors.contract || !Object.hasOwn(descriptors.contract, 'value')
|
|
955
|
+
|| !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
|
|
956
|
+
throw new TypeError('Case-response review inputs must not contain accessors.');
|
|
957
|
+
}
|
|
958
|
+
const current = descriptors.version.value === CASE_RESPONSE_REVIEW_INPUTS_VERSION;
|
|
959
|
+
const publishedV2 = descriptors.version.value === PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
|
|
960
|
+
if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA || (!current && !publishedV2)) {
|
|
838
961
|
throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
|
|
839
962
|
}
|
|
963
|
+
const source = exactReviewRecord(value, current ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
|
|
840
964
|
const profile = exactReviewRecord(source.profile, [
|
|
841
965
|
'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
|
|
842
966
|
'excludedEvidence', 'redactions',
|
|
@@ -880,6 +1004,38 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
880
1004
|
reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
|
|
881
1005
|
reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
|
|
882
1006
|
}
|
|
1007
|
+
if (current && source.recipientRoute !== null) {
|
|
1008
|
+
const route = exactReviewRecord(source.recipientRoute, [
|
|
1009
|
+
'actionId', 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
|
|
1010
|
+
], 'Case-response recipient route');
|
|
1011
|
+
reviewText(route.actionId, 64, 'Case-response recipient action id');
|
|
1012
|
+
reviewEnum(route.kind, [...RESPONSE_CONTACT_KINDS, 'manual'], 'Case-response recipient kind');
|
|
1013
|
+
reviewText(route.contact, 320, 'Case-response recipient value');
|
|
1014
|
+
reviewText(route.source, 120, 'Case-response recipient source');
|
|
1015
|
+
reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
|
|
1016
|
+
reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
|
|
1017
|
+
reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
|
|
1018
|
+
}
|
|
1019
|
+
let currentLineageActionIds = null;
|
|
1020
|
+
if (current) {
|
|
1021
|
+
const actionBinding = exactReviewRecord(source.actionBinding, [
|
|
1022
|
+
'state', 'selectedActionId', 'lineageActionIds', 'limitations',
|
|
1023
|
+
], 'Case-response action binding');
|
|
1024
|
+
const bindingState = reviewEnum(actionBinding.state, ['selected', 'not_selected'], 'Case-response action binding state');
|
|
1025
|
+
const selectedActionId = reviewText(actionBinding.selectedActionId, 64, 'Case-response selected action id', true);
|
|
1026
|
+
const lineageActionIds = reviewStrings(actionBinding.lineageActionIds, MAX_RESPONSE_ACTION_HISTORY, 64, 'Case-response action lineage');
|
|
1027
|
+
currentLineageActionIds = lineageActionIds;
|
|
1028
|
+
if ((bindingState === 'selected') !== Boolean(selectedActionId)
|
|
1029
|
+
|| (selectedActionId !== null && lineageActionIds[0] !== selectedActionId)
|
|
1030
|
+
|| new Set(lineageActionIds).size !== lineageActionIds.length) {
|
|
1031
|
+
throw new TypeError('Case-response action binding is inconsistent.');
|
|
1032
|
+
}
|
|
1033
|
+
reviewStrings(actionBinding.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action-binding limitations');
|
|
1034
|
+
if (source.recipientRoute !== null
|
|
1035
|
+
&& source.recipientRoute.actionId !== selectedActionId) {
|
|
1036
|
+
throw new TypeError('Case-response recipient route is not bound to the selected action.');
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
883
1039
|
for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
|
|
884
1040
|
const evidence = exactReviewRecord(candidate, [
|
|
885
1041
|
'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
|
|
@@ -951,15 +1107,23 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
951
1107
|
reviewCount(reference.byteLength, 100 * 1024 * 1024, 'Case-response artefact byte length');
|
|
952
1108
|
reviewStrings(reference.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response artefact limitations');
|
|
953
1109
|
}
|
|
1110
|
+
const escalationActionIds = [];
|
|
954
1111
|
for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
|
|
955
1112
|
const action = exactReviewRecord(candidate, [
|
|
956
1113
|
'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
|
|
1114
|
+
...(current ? ['routeObservedAt'] : []),
|
|
957
1115
|
'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
|
|
958
1116
|
'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
|
|
959
1117
|
], 'Case-response escalation action');
|
|
960
1118
|
for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
|
|
961
1119
|
reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
|
|
962
1120
|
}
|
|
1121
|
+
if (escalationActionIds.includes(action.actionId)) {
|
|
1122
|
+
throw new TypeError('Case-response escalation action identities must be unique.');
|
|
1123
|
+
}
|
|
1124
|
+
escalationActionIds.push(action.actionId);
|
|
1125
|
+
if (current)
|
|
1126
|
+
reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
|
|
963
1127
|
reviewEnum(action.type, CASE_ACTION_TYPES, 'Case-response action type');
|
|
964
1128
|
reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
|
|
965
1129
|
for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
|
|
@@ -989,6 +1153,11 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
989
1153
|
reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
|
|
990
1154
|
}
|
|
991
1155
|
}
|
|
1156
|
+
if (currentLineageActionIds
|
|
1157
|
+
&& (currentLineageActionIds.length !== escalationActionIds.length
|
|
1158
|
+
|| escalationActionIds.some((id, index) => currentLineageActionIds?.[index] !== id))) {
|
|
1159
|
+
throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
|
|
1160
|
+
}
|
|
992
1161
|
reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
|
|
993
1162
|
reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
|
|
994
1163
|
validateReviewLifecycle(source.responseLifecycle);
|
|
@@ -1068,9 +1237,11 @@ function escapeMarkdown(value) {
|
|
|
1068
1237
|
}
|
|
1069
1238
|
function contactLabel(value) {
|
|
1070
1239
|
if (value === 'network_hosting')
|
|
1071
|
-
return '
|
|
1240
|
+
return 'Observed endpoint network registration';
|
|
1072
1241
|
if (value === 'security_txt')
|
|
1073
1242
|
return 'security.txt';
|
|
1243
|
+
if (value === 'manual')
|
|
1244
|
+
return 'Manual submission';
|
|
1074
1245
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1075
1246
|
}
|
|
1076
1247
|
export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
@@ -1086,6 +1257,8 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1086
1257
|
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
1087
1258
|
const reviewMaterial = buildCaseResponseReviewInputs(caseRecord, input, normalizedGeneratedAt);
|
|
1088
1259
|
const contacts = reviewMaterial.contacts;
|
|
1260
|
+
const recipientRoute = reviewMaterial.recipientRoute;
|
|
1261
|
+
const actionBinding = reviewMaterial.actionBinding;
|
|
1089
1262
|
const selectedEvidence = reviewMaterial.selectedEvidence;
|
|
1090
1263
|
const contradictions = reviewMaterial.contradictions;
|
|
1091
1264
|
const readiness = reviewMaterial.readiness;
|
|
@@ -1141,6 +1314,8 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1141
1314
|
observedAt,
|
|
1142
1315
|
},
|
|
1143
1316
|
contacts,
|
|
1317
|
+
recipientRoute,
|
|
1318
|
+
actionBinding,
|
|
1144
1319
|
selectedEvidence,
|
|
1145
1320
|
contradictions,
|
|
1146
1321
|
readiness,
|
|
@@ -1189,24 +1364,26 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1189
1364
|
'',
|
|
1190
1365
|
...abusiveUrls.map((url) => `- ${escapeMarkdown(url)}`),
|
|
1191
1366
|
'',
|
|
1192
|
-
'##
|
|
1367
|
+
'## Selected response route',
|
|
1193
1368
|
'',
|
|
1194
|
-
...(
|
|
1195
|
-
?
|
|
1196
|
-
`### ${contactLabel(
|
|
1369
|
+
...(recipientRoute
|
|
1370
|
+
? [
|
|
1371
|
+
`### ${contactLabel(recipientRoute.kind)}`,
|
|
1197
1372
|
'',
|
|
1198
|
-
`-
|
|
1199
|
-
`-
|
|
1200
|
-
`-
|
|
1201
|
-
`-
|
|
1373
|
+
`- Case action: ${escapeMarkdown(recipientRoute.actionId)}`,
|
|
1374
|
+
`- Contact: ${escapeMarkdown(recipientRoute.contact)}`,
|
|
1375
|
+
`- Source: ${escapeMarkdown(recipientRoute.source)}`,
|
|
1376
|
+
`- Route observed: ${recipientRoute.observedAt ?? 'Not provided'} (${recipientRoute.freshness})`,
|
|
1377
|
+
`- Limitations: ${recipientRoute.limitations.length ? recipientRoute.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
|
|
1202
1378
|
'',
|
|
1203
|
-
]
|
|
1204
|
-
: ['No
|
|
1205
|
-
'##
|
|
1379
|
+
]
|
|
1380
|
+
: ['No profile-appropriate response route was bound to this packet.', '']),
|
|
1381
|
+
'## Selected action lineage',
|
|
1206
1382
|
'',
|
|
1207
1383
|
...(escalationHistory.length
|
|
1208
1384
|
? escalationHistory.flatMap((action) => [
|
|
1209
1385
|
`- ${escapeMarkdown(action.type.replaceAll('_', ' '))} to ${escapeMarkdown(action.recipient)} · ${escapeMarkdown(action.state.replaceAll('_', ' '))} · updated ${action.updatedAt}`,
|
|
1386
|
+
` - Route observed: ${action.routeObservedAt ?? 'Not provided'}`,
|
|
1210
1387
|
...(action.reference ? [` - Reference: ${escapeMarkdown(action.reference)}`] : []),
|
|
1211
1388
|
...(action.providerOutcome ? [` - Typed provider outcome: ${escapeMarkdown(action.providerOutcome.replaceAll('_', ' '))}`] : []),
|
|
1212
1389
|
...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
|
|
@@ -1221,8 +1398,8 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1221
1398
|
...(action.historyOmitted ? [` - ${action.historyOmitted} earlier transition event${action.historyOmitted === 1 ? '' : 's'} omitted by bound.`] : []),
|
|
1222
1399
|
...action.historyLimitations.map((limitation) => ` - History limitation: ${escapeMarkdown(limitation)}`),
|
|
1223
1400
|
])
|
|
1224
|
-
: ['No
|
|
1225
|
-
...(escalationHistoryOmitted ? [`-
|
|
1401
|
+
: ['No Case action was selected.']),
|
|
1402
|
+
...(escalationHistoryOmitted ? [`- Unrelated Case actions excluded from packet: ${escalationHistoryOmitted}`] : []),
|
|
1226
1403
|
...escalationHistoryLimitations.map((limitation) => `- Packet history limitation: ${escapeMarkdown(limitation)}`),
|
|
1227
1404
|
'',
|
|
1228
1405
|
'## Readiness and authorisation',
|
|
@@ -1270,7 +1447,7 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1270
1447
|
...profile.checklist.map((item) => `- Checklist: ${escapeMarkdown(item)}`),
|
|
1271
1448
|
...profile.includedEvidence.map((item) => `- Included: ${escapeMarkdown(item)}`),
|
|
1272
1449
|
...profile.excludedEvidence.map((item) => `- Excluded: ${escapeMarkdown(item)}`),
|
|
1273
|
-
...profile.redactions.map((item) => `- Redaction: ${escapeMarkdown(item)}`),
|
|
1450
|
+
...profile.redactions.map((item) => `- Redaction to confirm: ${escapeMarkdown(item)}`),
|
|
1274
1451
|
...profile.attachments.map((item) => `- Attachment expectation: ${escapeMarkdown(item)}`),
|
|
1275
1452
|
...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
|
|
1276
1453
|
];
|
|
@@ -1290,6 +1467,14 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1290
1467
|
'Exact URLs:',
|
|
1291
1468
|
...abusiveUrls.map((url) => `- ${url}`),
|
|
1292
1469
|
'',
|
|
1470
|
+
'Selected evidence:',
|
|
1471
|
+
...(selectedEvidence.length
|
|
1472
|
+
? selectedEvidence.map((item) => `- ${item.label} — ${item.source}, observed ${item.observedAt} (${item.completeness})`)
|
|
1473
|
+
: ['- No Case evidence pin was selected.']),
|
|
1474
|
+
'',
|
|
1475
|
+
`Reviewed packet SHA-256: ${digestSha256}`,
|
|
1476
|
+
'Attach the reviewed packet (and any separately reviewed evidence files) through the recipient’s approved submission channel; this message does not embed or transmit attachments.',
|
|
1477
|
+
'',
|
|
1293
1478
|
responseLifecycle.latestProviderOutcome
|
|
1294
1479
|
? `Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`
|
|
1295
1480
|
: `Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
|