@slicedearth/whoisleuth-cli 1.47.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
- package/third-party-notices.txt +14 -14
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -255
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
// Pure abuse-evidence packet builder. It creates local review artifacts only:
|
|
2
|
+
// no network requests, mailto links, submissions, or provider side effects.
|
|
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
|
+
import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
|
|
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, };
|
|
8
|
+
export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
|
|
9
|
+
version: 1,
|
|
10
|
+
owner: 'case',
|
|
11
|
+
inputs: Object.freeze([
|
|
12
|
+
'incident_fields',
|
|
13
|
+
'evidence_pins',
|
|
14
|
+
'analyst_decisions',
|
|
15
|
+
'analyst_assertions',
|
|
16
|
+
'recipient_routes',
|
|
17
|
+
'case_disposition',
|
|
18
|
+
'case_actions',
|
|
19
|
+
]),
|
|
20
|
+
lookupDecisionFacts: 'unavailable',
|
|
21
|
+
limitation: 'Lookup Decision Facts are transient and are not copied into browser-local cases. Case-response preflight evaluates only explicit case-owned records and analyst-entered incident context; it does not reconstruct Decision Facts from weaker saved fields.',
|
|
22
|
+
});
|
|
23
|
+
export const RESPONSE_CONTACT_KINDS = [
|
|
24
|
+
'registrar',
|
|
25
|
+
'registry',
|
|
26
|
+
'network_hosting',
|
|
27
|
+
'security_txt',
|
|
28
|
+
];
|
|
29
|
+
export const RESPONSE_PACKET_PROFILE_IDS = [
|
|
30
|
+
'registrar',
|
|
31
|
+
'registry',
|
|
32
|
+
'network_hosting',
|
|
33
|
+
'security_contact',
|
|
34
|
+
'browser_blocklist',
|
|
35
|
+
'internal_soc',
|
|
36
|
+
];
|
|
37
|
+
export const RESPONSE_READINESS_STATES = ['complete', 'partial', 'stale', 'unavailable', 'not_provided'];
|
|
38
|
+
export const RESPONSE_READINESS_ROW_IDS = [
|
|
39
|
+
'observed_behaviour',
|
|
40
|
+
'exact_url',
|
|
41
|
+
'observation_time',
|
|
42
|
+
'capture_provenance',
|
|
43
|
+
'infrastructure_responsibility',
|
|
44
|
+
'recipient_route',
|
|
45
|
+
'authority_review',
|
|
46
|
+
'selected_evidence',
|
|
47
|
+
'contradictions',
|
|
48
|
+
'source_limitations',
|
|
49
|
+
];
|
|
50
|
+
export const RESPONSE_AUTHORISATION_CONFIRMATION_IDS = [
|
|
51
|
+
'selectedEvidence',
|
|
52
|
+
'recipientScope',
|
|
53
|
+
'privacyRedactions',
|
|
54
|
+
'analystAuthority',
|
|
55
|
+
'evidenceFreshness',
|
|
56
|
+
];
|
|
57
|
+
export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
58
|
+
{
|
|
59
|
+
id: 'registrar',
|
|
60
|
+
label: 'Registrar review',
|
|
61
|
+
audience: 'Domain registrar abuse or compliance team',
|
|
62
|
+
subjectPrefix: 'Reviewed domain abuse report',
|
|
63
|
+
requiredContactKind: 'registrar',
|
|
64
|
+
checklist: ['Confirm registrar of record', 'Include exact URLs and observation time', 'Review registrant-data necessity before sharing'],
|
|
65
|
+
evidenceOrder: ['Incident facts', 'Registration evidence', 'Selected observations', 'Action history'],
|
|
66
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Case disposition', 'Selected evidence pins', 'Registrar contact provenance'],
|
|
67
|
+
excludedEvidence: ['Raw WHOIS or RDAP payloads', 'Unselected contacts', 'Provider secrets', 'Unrelated analyst notes'],
|
|
68
|
+
redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
|
|
69
|
+
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
70
|
+
followUpFields: ['Registrar reference', 'Acknowledgement time', 'Resolution or transfer outcome'],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'registry',
|
|
74
|
+
label: 'Registry review',
|
|
75
|
+
audience: 'Domain registry abuse or compliance team',
|
|
76
|
+
subjectPrefix: 'Reviewed registry abuse report',
|
|
77
|
+
requiredContactKind: 'registry',
|
|
78
|
+
checklist: ['Confirm the registry is an appropriate escalation route', 'Include registrar response or reference when available', 'Disclose contradictory evidence'],
|
|
79
|
+
evidenceOrder: ['Incident facts', 'Registry publication', 'Prior escalation', 'Selected observations'],
|
|
80
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Registry contact provenance', 'Selected evidence pins', 'Prior action references'],
|
|
81
|
+
excludedEvidence: ['Raw registry payloads', 'Unselected contacts', 'Secrets', 'Unsupported ownership claims'],
|
|
82
|
+
redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
|
|
83
|
+
attachments: ['Reviewed response packet', 'Optional normalised case report', 'Optional registrar reference'],
|
|
84
|
+
followUpFields: ['Registry reference', 'Registrar referral', 'Delegation or status outcome'],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'network_hosting',
|
|
88
|
+
label: 'Hosting or network review',
|
|
89
|
+
audience: 'Hosting provider, CDN, network, or infrastructure operator',
|
|
90
|
+
subjectPrefix: 'Reviewed hosted-content abuse report',
|
|
91
|
+
requiredContactKind: 'network_hosting',
|
|
92
|
+
checklist: ['Confirm the observed endpoint and collection time', 'Avoid claiming the observed edge is the origin', 'Include exact affected URLs'],
|
|
93
|
+
evidenceOrder: ['Incident facts', 'Observed endpoint context', 'Selected web evidence', 'Action history'],
|
|
94
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Selected HTTP, TLS, DNS, or page-identity pins', 'Contact provenance'],
|
|
95
|
+
excludedEvidence: ['Raw response bodies', 'Cookies', 'Secrets', 'Unproven origin-host claims'],
|
|
96
|
+
redactions: ['Remove credentials, query secrets, fragments, cookies, and unrelated contacts'],
|
|
97
|
+
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
98
|
+
followUpFields: ['Provider ticket', 'Content status', 'Infrastructure change outcome'],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'security_contact',
|
|
102
|
+
label: 'Security contact review',
|
|
103
|
+
audience: 'Published security contact or affected service security team',
|
|
104
|
+
subjectPrefix: 'Reviewed security finding',
|
|
105
|
+
requiredContactKind: 'security_txt',
|
|
106
|
+
checklist: ['Confirm the contact policy covers this report', 'Respect the published communication policy', 'Separate observed facts from hypotheses'],
|
|
107
|
+
evidenceOrder: ['Incident facts', 'Selected observations', 'Analyst reasoning', 'Action history'],
|
|
108
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Contact source and limitations'],
|
|
109
|
+
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected case notes', 'Legal conclusions'],
|
|
110
|
+
redactions: ['Remove credentials, tokens, personal data, and unrelated identifiers'],
|
|
111
|
+
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
112
|
+
followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'browser_blocklist',
|
|
116
|
+
label: 'Browser or blocklist review',
|
|
117
|
+
audience: 'Browser-safety, reputation, or blocklist reviewer',
|
|
118
|
+
subjectPrefix: 'Reviewed unsafe-site report',
|
|
119
|
+
requiredContactKind: null,
|
|
120
|
+
checklist: ['Use the recipient submission form or documented route manually', 'Include exact URLs and current observation time', 'Disclose source gaps and false-positive context'],
|
|
121
|
+
evidenceOrder: ['Exact URLs', 'Observed behaviour', 'Selected corroborating evidence', 'Limitations'],
|
|
122
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Observed harm', 'Selected evidence pins', 'Contradictions and cautions'],
|
|
123
|
+
excludedEvidence: ['Raw provider payloads', 'Secrets', 'Unselected personal data', 'Automated maliciousness claims'],
|
|
124
|
+
redactions: ['Remove credentials, tokens, URL fragments, and unrelated contacts'],
|
|
125
|
+
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
126
|
+
followUpFields: ['Submission reference', 'Review state', 'Listing or delisting outcome'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'internal_soc',
|
|
130
|
+
label: 'Internal SOC handoff',
|
|
131
|
+
audience: 'Internal security operations or incident-response team',
|
|
132
|
+
subjectPrefix: 'Reviewed domain investigation handoff',
|
|
133
|
+
requiredContactKind: null,
|
|
134
|
+
checklist: ['Identify the internal owner', 'Separate verified facts, hypotheses, unknowns, and contradictions', 'Record the next reviewed action'],
|
|
135
|
+
evidenceOrder: ['Decision packet', 'Selected evidence', 'Incident facts', 'Action and investigation trail'],
|
|
136
|
+
includedEvidence: ['Case disposition', 'Selected evidence pins', 'Analyst decisions and assertions', 'Action history', 'Exact URLs when relevant'],
|
|
137
|
+
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported attribution'],
|
|
138
|
+
redactions: ['Remove credentials, tokens, and personal data not required for the internal decision'],
|
|
139
|
+
attachments: ['Reviewed response packet', 'Normalised case report when required by internal policy'],
|
|
140
|
+
followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
|
|
144
|
+
const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
|
|
145
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
146
|
+
const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
|
|
147
|
+
function text(value, maximum) {
|
|
148
|
+
if (typeof value !== 'string')
|
|
149
|
+
return '';
|
|
150
|
+
return value.replace(CONTROL_REPLACE_RE, ' ').trim().slice(0, maximum);
|
|
151
|
+
}
|
|
152
|
+
function compareCodeUnits(left, right) {
|
|
153
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
154
|
+
}
|
|
155
|
+
function timestamp(value) {
|
|
156
|
+
if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
|
|
157
|
+
return null;
|
|
158
|
+
const parsed = Date.parse(value);
|
|
159
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
160
|
+
}
|
|
161
|
+
function normalizeExactUrl(value) {
|
|
162
|
+
const candidate = text(value, MAX_EXACT_URL_LENGTH);
|
|
163
|
+
if (!candidate)
|
|
164
|
+
return null;
|
|
165
|
+
try {
|
|
166
|
+
const parsed = new URL(candidate);
|
|
167
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
168
|
+
return null;
|
|
169
|
+
return parsed.toString();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function normalizeUrls(value) {
|
|
176
|
+
const source = Array.isArray(value)
|
|
177
|
+
? value
|
|
178
|
+
: typeof value === 'string'
|
|
179
|
+
? value.split(/\r?\n/u)
|
|
180
|
+
: [];
|
|
181
|
+
const unique = new Set();
|
|
182
|
+
for (const item of source.slice(0, MAX_ABUSIVE_URLS * 2)) {
|
|
183
|
+
const normalized = normalizeExactUrl(item);
|
|
184
|
+
if (normalized)
|
|
185
|
+
unique.add(normalized);
|
|
186
|
+
if (unique.size >= MAX_ABUSIVE_URLS)
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
return [...unique];
|
|
190
|
+
}
|
|
191
|
+
function normalizeLimitations(value) {
|
|
192
|
+
const source = Array.isArray(value)
|
|
193
|
+
? value
|
|
194
|
+
: typeof value === 'string'
|
|
195
|
+
? value.split(/\r?\n/u)
|
|
196
|
+
: [];
|
|
197
|
+
const unique = new Set();
|
|
198
|
+
for (const item of source.slice(0, 16)) {
|
|
199
|
+
const normalized = text(item, 240);
|
|
200
|
+
if (normalized)
|
|
201
|
+
unique.add(normalized);
|
|
202
|
+
if (unique.size >= 8)
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
return [...unique];
|
|
206
|
+
}
|
|
207
|
+
function normalizeContacts(value, generatedAt) {
|
|
208
|
+
if (!Array.isArray(value))
|
|
209
|
+
return [];
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
const contacts = [];
|
|
212
|
+
for (const raw of value.slice(0, MAX_RESPONSE_CONTACTS * 2)) {
|
|
213
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
214
|
+
continue;
|
|
215
|
+
const item = raw;
|
|
216
|
+
const kind = typeof item.kind === 'string' && CONTACT_KINDS.has(item.kind)
|
|
217
|
+
? item.kind
|
|
218
|
+
: null;
|
|
219
|
+
const contact = text(item.contact, 320);
|
|
220
|
+
if (!kind || !contact)
|
|
221
|
+
continue;
|
|
222
|
+
const key = `${kind}\u0000${contact.toLowerCase()}`;
|
|
223
|
+
if (seen.has(key))
|
|
224
|
+
continue;
|
|
225
|
+
seen.add(key);
|
|
226
|
+
const observedAt = timestamp(item.observedAt);
|
|
227
|
+
const routeAge = observedAt && generatedAt ? Date.parse(generatedAt) - Date.parse(observedAt) : null;
|
|
228
|
+
contacts.push({
|
|
229
|
+
kind,
|
|
230
|
+
contact,
|
|
231
|
+
source: text(item.source, 120) || 'analyst supplied',
|
|
232
|
+
observedAt,
|
|
233
|
+
freshness: routeAge === null
|
|
234
|
+
? 'unknown'
|
|
235
|
+
: routeAge < -300_000 || routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
|
|
236
|
+
? 'stale'
|
|
237
|
+
: 'current',
|
|
238
|
+
limitations: normalizeLimitations(item.limitations),
|
|
239
|
+
});
|
|
240
|
+
if (contacts.length >= MAX_RESPONSE_CONTACTS)
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
return contacts;
|
|
244
|
+
}
|
|
245
|
+
function normalizeSelectedEvidence(caseRecord, value) {
|
|
246
|
+
if (!Array.isArray(value))
|
|
247
|
+
return [];
|
|
248
|
+
const ids = new Set();
|
|
249
|
+
for (const candidate of value.slice(0, MAX_RESPONSE_SELECTED_EVIDENCE * 2)) {
|
|
250
|
+
if (typeof candidate === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(candidate))
|
|
251
|
+
ids.add(candidate);
|
|
252
|
+
if (ids.size >= MAX_RESPONSE_SELECTED_EVIDENCE)
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
return caseRecord.evidencePins
|
|
256
|
+
.filter((pin) => ids.has(pin.id))
|
|
257
|
+
.slice(0, MAX_RESPONSE_SELECTED_EVIDENCE)
|
|
258
|
+
.map((pin) => ({
|
|
259
|
+
id: pin.id,
|
|
260
|
+
label: text(pin.label, 80),
|
|
261
|
+
source: text(pin.source, 120),
|
|
262
|
+
observedAt: timestamp(pin.observedAt) ?? caseRecord.updatedAt,
|
|
263
|
+
completeness: text(pin.completeness, 40),
|
|
264
|
+
limitations: normalizeLimitations(pin.limitations),
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
function normalizeContradictions(caseRecord) {
|
|
268
|
+
return caseRecord.assertions
|
|
269
|
+
.filter((assertion) => assertion.kind === 'contradiction')
|
|
270
|
+
.slice(-MAX_RESPONSE_CONTRADICTIONS)
|
|
271
|
+
.map((assertion) => ({
|
|
272
|
+
id: assertion.id,
|
|
273
|
+
statement: text(assertion.statement, 2_000),
|
|
274
|
+
state: text(assertion.state, 40),
|
|
275
|
+
limitations: normalizeLimitations(assertion.rationale ? [assertion.rationale] : []),
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
function readinessOverride(value, key) {
|
|
279
|
+
const root = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
280
|
+
const candidate = root[key];
|
|
281
|
+
const item = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
|
|
282
|
+
? candidate
|
|
283
|
+
: {};
|
|
284
|
+
if (typeof item.state !== 'string' || !RESPONSE_READINESS_STATES.includes(item.state))
|
|
285
|
+
return null;
|
|
286
|
+
const detail = text(item.detail, 500);
|
|
287
|
+
if (!detail && !['unavailable', 'not_provided'].includes(item.state))
|
|
288
|
+
return null;
|
|
289
|
+
return {
|
|
290
|
+
state: item.state,
|
|
291
|
+
detail,
|
|
292
|
+
limitations: normalizeLimitations(item.limitations),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function buildResponseReadiness(caseRecord, input, generatedAt, contacts, selectedEvidence, contradictions) {
|
|
296
|
+
const profile = responsePacketProfile(input.profile);
|
|
297
|
+
const urls = normalizeUrls(input.abusiveUrls);
|
|
298
|
+
const observedAt = timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null;
|
|
299
|
+
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
|
+
const infrastructure = readinessOverride(input.readiness, 'infrastructureResponsibility');
|
|
304
|
+
const authority = readinessOverride(input.readiness, 'authorityReview');
|
|
305
|
+
const contradictionReview = readinessOverride(input.readiness, 'contradictionsReview');
|
|
306
|
+
const sourceLimits = readinessOverride(input.readiness, 'sourceLimitations');
|
|
307
|
+
const external = profile.id !== 'internal_soc';
|
|
308
|
+
const responsibilityRequired = ['registrar', 'registry', 'network_hosting'].includes(profile.id);
|
|
309
|
+
const rows = [
|
|
310
|
+
{
|
|
311
|
+
id: 'observed_behaviour', label: 'Observed behaviour',
|
|
312
|
+
state: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH) ? 'complete' : 'not_provided',
|
|
313
|
+
detail: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH) ? 'A bounded observed-behaviour description is present.' : 'No observed-behaviour description is present.',
|
|
314
|
+
requiredForAuthorisation: true, limitations: [],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: 'exact_url', label: 'Exact URL',
|
|
318
|
+
state: urls.length ? 'complete' : 'not_provided',
|
|
319
|
+
detail: urls.length ? `${urls.length} exact HTTP(S) URL${urls.length === 1 ? ' is' : 's are'} selected.` : 'No exact HTTP(S) URL is selected.',
|
|
320
|
+
requiredForAuthorisation: true, limitations: [],
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: 'observation_time', label: 'Observation time',
|
|
324
|
+
state: !observedAt ? 'not_provided' : age?.refreshRecommended ? 'stale' : 'complete',
|
|
325
|
+
detail: !observedAt ? 'No observation time is available.' : age?.refreshRecommended ? `The observation is ${age.band.replaceAll('_', ' ')}.` : `The observation is ${age?.band.replaceAll('_', ' ')}.`,
|
|
326
|
+
requiredForAuthorisation: true,
|
|
327
|
+
limitations: age?.refreshRecommended ? ['Freshness requires deliberate analyst review before external use.'] : [],
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: 'capture_provenance', label: 'Capture provenance',
|
|
331
|
+
state: !selectedEvidence.length ? 'unavailable' : selectedEvidence.every((item) => item.source && item.observedAt) ? 'complete' : 'partial',
|
|
332
|
+
detail: selectedEvidence.length ? 'Selected evidence retains source and observation metadata.' : 'No selected evidence is available for capture-provenance review.',
|
|
333
|
+
requiredForAuthorisation: true,
|
|
334
|
+
limitations: selectedEvidence.flatMap((item) => item.limitations).slice(0, 8),
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: 'infrastructure_responsibility', label: 'Infrastructure-responsibility evidence',
|
|
338
|
+
state: infrastructure?.state ?? 'not_provided',
|
|
339
|
+
detail: infrastructure?.detail || 'Responsibility has not been asserted from retained evidence.',
|
|
340
|
+
requiredForAuthorisation: responsibilityRequired,
|
|
341
|
+
limitations: infrastructure?.limitations ?? ['Observed infrastructure does not by itself prove provider responsibility, ownership, or control.'],
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: 'recipient_route', label: 'Recipient-route provenance and freshness',
|
|
345
|
+
state: !requiredContact ? 'not_provided' : requiredContact.freshness === 'stale' ? 'stale' : requiredContact.freshness === 'current' && requiredContact.source ? 'complete' : 'partial',
|
|
346
|
+
detail: !requiredContact ? 'No profile-appropriate recipient route is selected.' : `The selected route provenance is ${requiredContact.source}; freshness is ${requiredContact.freshness}.`,
|
|
347
|
+
requiredForAuthorisation: external,
|
|
348
|
+
limitations: requiredContact?.limitations ?? [],
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: 'authority_review', label: 'Authority review',
|
|
352
|
+
state: authority?.state ?? 'not_provided',
|
|
353
|
+
detail: authority?.detail || 'Analyst authority has not been recorded for this exact response scope.',
|
|
354
|
+
requiredForAuthorisation: true,
|
|
355
|
+
limitations: authority?.limitations ?? [],
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: 'selected_evidence', label: 'Selected evidence',
|
|
359
|
+
state: !selectedEvidence.length ? 'not_provided' : selectedEvidence.every((item) => item.completeness === 'complete') ? 'complete' : 'partial',
|
|
360
|
+
detail: selectedEvidence.length ? `${selectedEvidence.length} evidence pin${selectedEvidence.length === 1 ? ' is' : 's are'} explicitly selected.` : 'No evidence pin is explicitly selected.',
|
|
361
|
+
requiredForAuthorisation: true,
|
|
362
|
+
limitations: selectedEvidence.flatMap((item) => item.limitations).slice(0, 8),
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: 'contradictions', label: 'Contradictions',
|
|
366
|
+
state: contradictions.some((item) => item.state === 'open')
|
|
367
|
+
? 'partial'
|
|
368
|
+
: contradictions.length ? 'complete' : contradictionReview?.state ?? 'not_provided',
|
|
369
|
+
detail: contradictions.some((item) => item.state === 'open')
|
|
370
|
+
? 'Open contradictions remain and must be disclosed or resolved.'
|
|
371
|
+
: contradictions.length
|
|
372
|
+
? 'Every retained contradiction assertion is resolved.'
|
|
373
|
+
: contradictionReview?.detail || 'No explicit contradiction review is retained for this exact packet.',
|
|
374
|
+
requiredForAuthorisation: true,
|
|
375
|
+
limitations: contradictions.length
|
|
376
|
+
? contradictions.filter((item) => item.state === 'open').flatMap((item) => item.limitations).slice(0, 8)
|
|
377
|
+
: contradictionReview?.limitations ?? ['The absence of a retained contradiction assertion does not establish that contradictory evidence was reviewed or absent.'],
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
id: 'source_limitations', label: 'Source limitations',
|
|
381
|
+
state: sourceLimits?.state ?? (selectedEvidence.some((item) => item.limitations.length) || contacts.some((contact) => contact.limitations.length) ? 'partial' : 'not_provided'),
|
|
382
|
+
detail: sourceLimits?.detail || (selectedEvidence.some((item) => item.limitations.length) || contacts.some((contact) => contact.limitations.length) ? 'Source limitations are retained for review.' : 'No additional source limitation was supplied.'),
|
|
383
|
+
requiredForAuthorisation: true,
|
|
384
|
+
limitations: sourceLimits?.limitations ?? [...selectedEvidence.flatMap((item) => item.limitations), ...contacts.flatMap((contact) => contact.limitations)].slice(0, 8),
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
const counts = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, rows.filter((row) => row.state === state).length]));
|
|
388
|
+
return {
|
|
389
|
+
profileId: profile.id,
|
|
390
|
+
rows,
|
|
391
|
+
counts,
|
|
392
|
+
limitations: [
|
|
393
|
+
'Readiness describes the exact local draft inputs. It does not authorise or submit a response and does not promise a provider outcome.',
|
|
394
|
+
'Partial, stale, unavailable, and not-provided rows remain explicit and never become completeness by inference.',
|
|
395
|
+
],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
export function buildCaseResponseReadiness(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
399
|
+
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
400
|
+
const contacts = normalizeContacts(input.contacts, normalizedGeneratedAt);
|
|
401
|
+
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
402
|
+
return buildResponseReadiness(caseRecord, input, normalizedGeneratedAt, contacts, selectedEvidence, normalizeContradictions(caseRecord));
|
|
403
|
+
}
|
|
404
|
+
function normalizeArtefactReferences(value) {
|
|
405
|
+
if (!Array.isArray(value))
|
|
406
|
+
return [];
|
|
407
|
+
const byId = new Map();
|
|
408
|
+
for (const candidate of value.slice(0, MAX_RESPONSE_ARTEFACT_REFERENCES * 2)) {
|
|
409
|
+
const item = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
|
|
410
|
+
? candidate
|
|
411
|
+
: {};
|
|
412
|
+
const label = text(item.label, 120);
|
|
413
|
+
const mediaType = text(item.mediaType, 120).toLowerCase();
|
|
414
|
+
const capturedAt = timestamp(item.capturedAt);
|
|
415
|
+
const source = text(item.source, 120);
|
|
416
|
+
const digestSha256 = text(item.digestSha256, 64).toLowerCase();
|
|
417
|
+
if (!label || !mediaType || !capturedAt || !source || !/^[a-f0-9]{64}$/u.test(digestSha256))
|
|
418
|
+
continue;
|
|
419
|
+
const id = typeof item.id === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(item.id)
|
|
420
|
+
? item.id
|
|
421
|
+
: `artefact-${digestSha256.slice(0, 20)}`;
|
|
422
|
+
const byteLength = typeof item.byteLength === 'number' && Number.isSafeInteger(item.byteLength) && item.byteLength >= 0 && item.byteLength <= 100 * 1024 * 1024
|
|
423
|
+
? item.byteLength
|
|
424
|
+
: null;
|
|
425
|
+
const normalized = { id, label, mediaType, capturedAt, source, digestSha256, byteLength, limitations: normalizeLimitations(item.limitations) };
|
|
426
|
+
const existing = byId.get(id);
|
|
427
|
+
if (!existing || JSON.stringify(normalized) < JSON.stringify(existing))
|
|
428
|
+
byId.set(id, normalized);
|
|
429
|
+
}
|
|
430
|
+
return [...byId.values()]
|
|
431
|
+
.sort((left, right) => Date.parse(left.capturedAt) - Date.parse(right.capturedAt) || compareCodeUnits(left.id, right.id))
|
|
432
|
+
.slice(0, MAX_RESPONSE_ARTEFACT_REFERENCES);
|
|
433
|
+
}
|
|
434
|
+
function normalizeConfirmations(value) {
|
|
435
|
+
const item = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
436
|
+
return Object.fromEntries(RESPONSE_AUTHORISATION_CONFIRMATION_IDS.map((id) => [id, item[id] === true]));
|
|
437
|
+
}
|
|
438
|
+
export function responsePacketProfile(value) {
|
|
439
|
+
const id = typeof value === 'string' && RESPONSE_PROFILE_IDS.has(value)
|
|
440
|
+
? value
|
|
441
|
+
: 'internal_soc';
|
|
442
|
+
return RESPONSE_PACKET_PROFILES.find((profile) => profile.id === id) ?? RESPONSE_PACKET_PROFILES.at(-1);
|
|
443
|
+
}
|
|
444
|
+
export function buildResponsePacketProfilePreview(caseRecord, input) {
|
|
445
|
+
const profile = responsePacketProfile(input.profile);
|
|
446
|
+
const contacts = normalizeContacts(input.contacts);
|
|
447
|
+
const missingEvidence = [
|
|
448
|
+
...(!normalizeUrls(input.abusiveUrls).length ? ['At least one exact HTTP(S) URL'] : []),
|
|
449
|
+
...(!timestamp(input.observedAt) && !caseRecord.evidenceHistory.at(-1)?.capturedAt ? ['Observation time'] : []),
|
|
450
|
+
...(!normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds).length ? ['Explicitly selected evidence pin'] : []),
|
|
451
|
+
...(profile.requiredContactKind && !contacts.some((contact) => contact.kind === profile.requiredContactKind)
|
|
452
|
+
? [`${contactLabel(profile.requiredContactKind)} contact route`]
|
|
453
|
+
: []),
|
|
454
|
+
];
|
|
455
|
+
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH) || 'domain activity';
|
|
456
|
+
return {
|
|
457
|
+
id: profile.id,
|
|
458
|
+
label: profile.label,
|
|
459
|
+
audience: profile.audience,
|
|
460
|
+
subject: `${profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
|
|
461
|
+
checklist: [...profile.checklist],
|
|
462
|
+
evidenceOrder: [...profile.evidenceOrder],
|
|
463
|
+
includedEvidence: [...profile.includedEvidence],
|
|
464
|
+
excludedEvidence: [...profile.excludedEvidence],
|
|
465
|
+
redactions: [...profile.redactions],
|
|
466
|
+
attachments: [...profile.attachments],
|
|
467
|
+
followUpFields: [...profile.followUpFields],
|
|
468
|
+
missingEvidence,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
472
|
+
const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
|
|
473
|
+
const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
|
|
474
|
+
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
475
|
+
const contacts = normalizeContacts(input.contacts, normalizedGeneratedAt);
|
|
476
|
+
const urls = normalizeUrls(input.abusiveUrls);
|
|
477
|
+
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
478
|
+
const requiredComplete = Boolean(text(input.category, MAX_ABUSE_CATEGORY_LENGTH)
|
|
479
|
+
&& text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH)
|
|
480
|
+
&& urls.length
|
|
481
|
+
&& text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH)
|
|
482
|
+
&& observedAt);
|
|
483
|
+
const age = observedAt ? observationAge(observedAt, normalizedGeneratedAt) : null;
|
|
484
|
+
const openContradictions = caseRecord.assertions
|
|
485
|
+
.filter((item) => item.kind === 'contradiction' && item.state === 'open')
|
|
486
|
+
.length;
|
|
487
|
+
const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
|
|
488
|
+
const profile = responsePacketProfile(input.profile);
|
|
489
|
+
const checks = [
|
|
490
|
+
{
|
|
491
|
+
id: 'required_incident_fields',
|
|
492
|
+
label: 'Incident facts',
|
|
493
|
+
state: requiredComplete ? 'pass' : 'block',
|
|
494
|
+
detail: requiredComplete
|
|
495
|
+
? `${urls.length} exact HTTP(S) URL${urls.length === 1 ? '' : 's'} and the required incident context are present.`
|
|
496
|
+
: 'Category, affected party, an exact HTTP(S) URL, observed harm, and observation time are required.',
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
id: 'evidence_pins',
|
|
500
|
+
label: 'Selected evidence',
|
|
501
|
+
state: selectedEvidence.length ? 'pass' : 'caution',
|
|
502
|
+
detail: selectedEvidence.length
|
|
503
|
+
? `${selectedEvidence.length} explicitly selected evidence pin${selectedEvidence.length === 1 ? '' : 's'} will remain separately attributable.`
|
|
504
|
+
: 'No evidence pin is selected for this exact draft; export remains a draft with a caution.',
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
id: 'analyst_decision',
|
|
508
|
+
label: 'Analyst decision',
|
|
509
|
+
state: caseRecord.decisions.length ? 'pass' : 'caution',
|
|
510
|
+
detail: caseRecord.decisions.length
|
|
511
|
+
? `${caseRecord.decisions.length} analyst decision${caseRecord.decisions.length === 1 ? '' : 's'} record the escalation rationale.`
|
|
512
|
+
: 'No explicit analyst decision explains why external reporting is appropriate.',
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
id: 'recipient_route',
|
|
516
|
+
label: 'Recipient route',
|
|
517
|
+
state: contacts.length ? 'pass' : 'caution',
|
|
518
|
+
detail: contacts.length
|
|
519
|
+
? `${contacts.length} separately attributed contact route${contacts.length === 1 ? ' is' : 's are'} included.`
|
|
520
|
+
: 'No contact route is included; identify and review the intended recipient before sending.',
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
id: 'profile_recipient',
|
|
524
|
+
label: 'Audience-specific recipient',
|
|
525
|
+
state: profile.requiredContactKind
|
|
526
|
+
? contacts.some((contact) => contact.kind === profile.requiredContactKind) ? 'pass' : 'caution'
|
|
527
|
+
: 'pass',
|
|
528
|
+
detail: profile.requiredContactKind
|
|
529
|
+
? contacts.some((contact) => contact.kind === profile.requiredContactKind)
|
|
530
|
+
? `The ${contactLabel(profile.requiredContactKind)} route required by the ${profile.label.toLowerCase()} is present.`
|
|
531
|
+
: `The ${profile.label.toLowerCase()} expects a separately attributed ${contactLabel(profile.requiredContactKind)} route.`
|
|
532
|
+
: `${profile.label} has no fixed external contact-kind requirement; confirm the intended manual destination.`,
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
id: 'case_disposition',
|
|
536
|
+
label: 'Case disposition',
|
|
537
|
+
state: ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition) ? 'pass' : 'caution',
|
|
538
|
+
detail: ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition)
|
|
539
|
+
? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}.`
|
|
540
|
+
: `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}; confirm it before external use.`,
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: 'evidence_freshness',
|
|
544
|
+
label: 'Evidence freshness',
|
|
545
|
+
state: age?.refreshRecommended ? 'caution' : observedAt ? 'pass' : 'block',
|
|
546
|
+
detail: age
|
|
547
|
+
? age.refreshRecommended
|
|
548
|
+
? `The selected observation is ${age.band.replaceAll('_', ' ')} and should be refreshed before submission.`
|
|
549
|
+
: `The selected observation is ${age.band.replaceAll('_', ' ')}.`
|
|
550
|
+
: 'A valid observation time is required.',
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
id: 'contradictory_evidence',
|
|
554
|
+
label: 'Contradictory evidence',
|
|
555
|
+
state: openContradictions ? 'caution' : 'pass',
|
|
556
|
+
detail: openContradictions
|
|
557
|
+
? `${openContradictions} open contradiction${openContradictions === 1 ? '' : 's'} should be addressed or disclosed.`
|
|
558
|
+
: 'No open contradictory-evidence assertion is recorded.',
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
id: 'action_tracking',
|
|
562
|
+
label: 'Action tracking',
|
|
563
|
+
state: actionSummary.total ? 'pass' : 'caution',
|
|
564
|
+
detail: actionSummary.total
|
|
565
|
+
? `${actionSummary.total} reviewed action${actionSummary.total === 1 ? ' is' : 's are'} tracked; ${actionSummary.overdue} overdue and ${actionSummary.followUpDue} due for follow-up.`
|
|
566
|
+
: 'No reviewed case action is recorded for ownership, submission, or follow-up.',
|
|
567
|
+
},
|
|
568
|
+
];
|
|
569
|
+
const counts = {
|
|
570
|
+
block: checks.filter((item) => item.state === 'block').length,
|
|
571
|
+
caution: checks.filter((item) => item.state === 'caution').length,
|
|
572
|
+
pass: checks.filter((item) => item.state === 'pass').length,
|
|
573
|
+
};
|
|
574
|
+
return {
|
|
575
|
+
version: 2,
|
|
576
|
+
status: counts.block ? 'needs_input' : counts.caution ? 'review_cautions' : 'ready_for_review',
|
|
577
|
+
canExport: counts.block === 0,
|
|
578
|
+
counts,
|
|
579
|
+
checks,
|
|
580
|
+
actionSummary,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function normalizeActionHistory(caseRecord) {
|
|
584
|
+
const omitted = Math.max(0, caseRecord.actions.length - MAX_RESPONSE_ACTION_HISTORY);
|
|
585
|
+
return {
|
|
586
|
+
actions: caseRecord.actions
|
|
587
|
+
.slice(-MAX_RESPONSE_ACTION_HISTORY)
|
|
588
|
+
.map((action) => ({
|
|
589
|
+
actionId: action.id,
|
|
590
|
+
type: text(action.type, 80),
|
|
591
|
+
recipient: text(action.recipient, 320),
|
|
592
|
+
contactSource: text(action.contactSource, 120),
|
|
593
|
+
state: text(action.state, 80),
|
|
594
|
+
reference: text(action.reference, 500) || null,
|
|
595
|
+
providerOutcome: text(action.providerOutcome, 80) || null,
|
|
596
|
+
outcomeDetail: text(action.outcome, 2000) || null,
|
|
597
|
+
originActionId: text(action.originActionId, 64) || null,
|
|
598
|
+
historyOmitted: action.historyOmitted,
|
|
599
|
+
historyLimitations: normalizeLimitations(action.historyLimitations),
|
|
600
|
+
transitions: action.history.map((event) => ({
|
|
601
|
+
id: event.id,
|
|
602
|
+
previousState: event.previousState,
|
|
603
|
+
nextState: event.nextState,
|
|
604
|
+
occurredAt: event.occurredAt,
|
|
605
|
+
sourceClass: event.sourceClass,
|
|
606
|
+
provenance: event.provenance,
|
|
607
|
+
reference: event.reference,
|
|
608
|
+
evidencePinId: event.evidencePinId,
|
|
609
|
+
limitations: [...event.limitations],
|
|
610
|
+
providerOutcome: event.providerOutcome,
|
|
611
|
+
outcomeDetail: event.outcomeDetail,
|
|
612
|
+
originActionId: event.originActionId,
|
|
613
|
+
applied: event.applied,
|
|
614
|
+
})),
|
|
615
|
+
createdAt: timestamp(action.createdAt) ?? caseRecord.createdAt,
|
|
616
|
+
updatedAt: timestamp(action.updatedAt) ?? caseRecord.updatedAt,
|
|
617
|
+
})),
|
|
618
|
+
omitted,
|
|
619
|
+
limitations: omitted ? [
|
|
620
|
+
`${omitted} earlier Case response action${omitted === 1 ? '' : 's'} omitted from this bounded packet projection.`,
|
|
621
|
+
'Provider-outcome time is withheld because an omitted action could contain a later or concurrent typed event.',
|
|
622
|
+
] : [],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function normalizeResponseLifecycle(caseRecord, escalationHistoryOmitted) {
|
|
626
|
+
const summary = buildCaseResponseLifecycleSummary(caseRecord);
|
|
627
|
+
const providerOutcomeState = escalationHistoryOmitted ? 'ambiguous' : summary.providerOutcomeState;
|
|
628
|
+
return {
|
|
629
|
+
providerOutcomeState,
|
|
630
|
+
latestProviderOutcome: !escalationHistoryOmitted && summary.latestProviderOutcome ? { ...summary.latestProviderOutcome } : null,
|
|
631
|
+
observedChangeState: summary.observedChangeState,
|
|
632
|
+
latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect } : null,
|
|
633
|
+
latestObservedChangeAt: summary.latestObservedChangeAt,
|
|
634
|
+
closure: summary.latestClosure ? {
|
|
635
|
+
id: summary.latestClosure.id,
|
|
636
|
+
reason: summary.latestClosure.reason,
|
|
637
|
+
createdAt: summary.latestClosure.createdAt,
|
|
638
|
+
limitations: [...summary.latestClosure.limitations],
|
|
639
|
+
} : null,
|
|
640
|
+
limitations: [
|
|
641
|
+
'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
|
|
642
|
+
'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
|
|
643
|
+
'Times are withheld when the corresponding typed event is missing or ambiguous.',
|
|
644
|
+
...(escalationHistoryOmitted ? ['Provider-outcome time is withheld because the bounded packet omits earlier response actions.'] : []),
|
|
645
|
+
],
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function observationAge(observedAt, generatedAt) {
|
|
649
|
+
const ageSeconds = Math.floor((Date.parse(generatedAt) - Date.parse(observedAt)) / 1000);
|
|
650
|
+
if (ageSeconds < -300) {
|
|
651
|
+
return { ageSeconds, band: 'future_or_clock_skew', refreshRecommended: true };
|
|
652
|
+
}
|
|
653
|
+
if (ageSeconds < 86_400) {
|
|
654
|
+
return { ageSeconds: Math.max(0, ageSeconds), band: 'under_24_hours', refreshRecommended: false };
|
|
655
|
+
}
|
|
656
|
+
if (ageSeconds <= 604_800) {
|
|
657
|
+
return { ageSeconds, band: 'one_to_seven_days', refreshRecommended: false };
|
|
658
|
+
}
|
|
659
|
+
return { ageSeconds, band: 'over_seven_days', refreshRecommended: true };
|
|
660
|
+
}
|
|
661
|
+
async function sha256(value) {
|
|
662
|
+
const bytes = new TextEncoder().encode(value);
|
|
663
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
664
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
665
|
+
}
|
|
666
|
+
export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
667
|
+
const profile = buildResponsePacketProfilePreview(caseRecord, input);
|
|
668
|
+
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
|
|
669
|
+
const contacts = normalizeContacts(input.contacts, generatedAt);
|
|
670
|
+
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
671
|
+
const contradictions = normalizeContradictions(caseRecord);
|
|
672
|
+
const readiness = buildResponseReadiness(caseRecord, input, generatedAt, contacts, selectedEvidence, contradictions);
|
|
673
|
+
const escalation = normalizeActionHistory(caseRecord);
|
|
674
|
+
return {
|
|
675
|
+
contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
|
|
676
|
+
version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
|
|
677
|
+
profile: {
|
|
678
|
+
id: profile.id,
|
|
679
|
+
label: profile.label,
|
|
680
|
+
audience: profile.audience,
|
|
681
|
+
subject: profile.subject,
|
|
682
|
+
checklist: profile.checklist,
|
|
683
|
+
includedEvidence: profile.includedEvidence,
|
|
684
|
+
excludedEvidence: profile.excludedEvidence,
|
|
685
|
+
redactions: profile.redactions,
|
|
686
|
+
},
|
|
687
|
+
case: {
|
|
688
|
+
id: caseRecord.id,
|
|
689
|
+
domain: caseRecord.domain,
|
|
690
|
+
status: caseRecord.status,
|
|
691
|
+
disposition: caseRecord.disposition,
|
|
692
|
+
updatedAt: caseRecord.updatedAt,
|
|
693
|
+
},
|
|
694
|
+
incident: {
|
|
695
|
+
category,
|
|
696
|
+
affectedParty: text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH),
|
|
697
|
+
abusiveUrls: normalizeUrls(input.abusiveUrls),
|
|
698
|
+
observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
|
|
699
|
+
observedAt: timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null,
|
|
700
|
+
},
|
|
701
|
+
contacts,
|
|
702
|
+
selectedEvidence,
|
|
703
|
+
contradictions,
|
|
704
|
+
readiness,
|
|
705
|
+
artefactReferences: normalizeArtefactReferences(input.artefactReferences),
|
|
706
|
+
escalationHistory: escalation.actions,
|
|
707
|
+
escalationHistoryOmitted: escalation.omitted,
|
|
708
|
+
escalationHistoryLimitations: escalation.limitations,
|
|
709
|
+
responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.omitted),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
|
|
713
|
+
'contract',
|
|
714
|
+
'version',
|
|
715
|
+
'profile',
|
|
716
|
+
'case',
|
|
717
|
+
'incident',
|
|
718
|
+
'contacts',
|
|
719
|
+
'selectedEvidence',
|
|
720
|
+
'contradictions',
|
|
721
|
+
'readiness',
|
|
722
|
+
'artefactReferences',
|
|
723
|
+
'escalationHistory',
|
|
724
|
+
'escalationHistoryOmitted',
|
|
725
|
+
'escalationHistoryLimitations',
|
|
726
|
+
'responseLifecycle',
|
|
727
|
+
]);
|
|
728
|
+
function exactReviewRecord(value, keys, label) {
|
|
729
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
730
|
+
throw new TypeError(`${label} must be an exact object.`);
|
|
731
|
+
}
|
|
732
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
733
|
+
if (ownKeys.length !== keys.length
|
|
734
|
+
|| ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key))) {
|
|
735
|
+
throw new TypeError(`${label} contains an undeclared field.`);
|
|
736
|
+
}
|
|
737
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
738
|
+
if (keys.some((key) => !descriptors[key] || !Object.hasOwn(descriptors[key], 'value'))) {
|
|
739
|
+
throw new TypeError(`${label} must not contain accessors.`);
|
|
740
|
+
}
|
|
741
|
+
return value;
|
|
742
|
+
}
|
|
743
|
+
function boundedReviewArray(value, maximum, label) {
|
|
744
|
+
if (!Array.isArray(value) || value.length > maximum) {
|
|
745
|
+
throw new TypeError(`${label} exceeds its declared array bound.`);
|
|
746
|
+
}
|
|
747
|
+
const ownKeys = Reflect.ownKeys(value).filter((key) => key !== 'length');
|
|
748
|
+
if (ownKeys.length !== value.length
|
|
749
|
+
|| ownKeys.some((key, index) => key !== String(index))) {
|
|
750
|
+
throw new TypeError(`${label} must be a dense ordinary array.`);
|
|
751
|
+
}
|
|
752
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
753
|
+
if (ownKeys.some((key) => typeof key !== 'string' || !Object.hasOwn(descriptors[key], 'value'))) {
|
|
754
|
+
throw new TypeError(`${label} must not contain accessors.`);
|
|
755
|
+
}
|
|
756
|
+
return value;
|
|
757
|
+
}
|
|
758
|
+
function reviewText(value, maximum, label, nullable = false) {
|
|
759
|
+
if (nullable && value === null)
|
|
760
|
+
return null;
|
|
761
|
+
if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value)) {
|
|
762
|
+
throw new TypeError(`${label} is not a bounded text value.`);
|
|
763
|
+
}
|
|
764
|
+
return value;
|
|
765
|
+
}
|
|
766
|
+
function reviewStrings(value, maximum, itemMaximum, label) {
|
|
767
|
+
const items = boundedReviewArray(value, maximum, label);
|
|
768
|
+
for (const item of items)
|
|
769
|
+
reviewText(item, itemMaximum, `${label} item`);
|
|
770
|
+
return items;
|
|
771
|
+
}
|
|
772
|
+
function reviewCount(value, maximum, label) {
|
|
773
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
|
774
|
+
throw new TypeError(`${label} is not a bounded count.`);
|
|
775
|
+
}
|
|
776
|
+
return value;
|
|
777
|
+
}
|
|
778
|
+
function reviewEnum(value, values, label) {
|
|
779
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
780
|
+
throw new TypeError(`${label} contains an unsupported value.`);
|
|
781
|
+
}
|
|
782
|
+
return value;
|
|
783
|
+
}
|
|
784
|
+
function reviewNullableEnum(value, values, label) {
|
|
785
|
+
return value === null ? null : reviewEnum(value, values, label);
|
|
786
|
+
}
|
|
787
|
+
function validateReviewLifecycle(value) {
|
|
788
|
+
const lifecycle = exactReviewRecord(value, [
|
|
789
|
+
'providerOutcomeState', 'latestProviderOutcome', 'observedChangeState',
|
|
790
|
+
'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations',
|
|
791
|
+
], 'Case-response review lifecycle');
|
|
792
|
+
reviewEnum(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response provider-outcome state');
|
|
793
|
+
reviewEnum(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response observed-change state');
|
|
794
|
+
reviewText(lifecycle.latestObservedChangeAt, 64, 'Case-response latest observed change', true);
|
|
795
|
+
reviewStrings(lifecycle.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response lifecycle limitations');
|
|
796
|
+
if (lifecycle.latestProviderOutcome !== null) {
|
|
797
|
+
const outcome = exactReviewRecord(lifecycle.latestProviderOutcome, [
|
|
798
|
+
'actionId', 'eventId', 'outcome', 'occurredAt', 'reference',
|
|
799
|
+
], 'Case-response latest provider outcome');
|
|
800
|
+
reviewText(outcome.actionId, 64, 'Case-response provider action id');
|
|
801
|
+
reviewText(outcome.eventId, 64, 'Case-response provider event id');
|
|
802
|
+
reviewEnum(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
|
|
803
|
+
reviewText(outcome.occurredAt, 64, 'Case-response provider outcome time');
|
|
804
|
+
reviewText(outcome.reference, MAX_RESPONSE_REFERENCE_LENGTH, 'Case-response provider reference', true);
|
|
805
|
+
}
|
|
806
|
+
if (lifecycle.latestObservedEffect !== null) {
|
|
807
|
+
const effect = exactReviewRecord(lifecycle.latestObservedEffect, [
|
|
808
|
+
'reviewId', 'state', 'observedAt', 'sourceClass', 'source',
|
|
809
|
+
], 'Case-response latest observed effect');
|
|
810
|
+
reviewText(effect.reviewId, 64, 'Case-response observed-effect review id');
|
|
811
|
+
reviewEnum(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response observed-effect state');
|
|
812
|
+
reviewText(effect.observedAt, 64, 'Case-response observed-effect time');
|
|
813
|
+
reviewEnum(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response observed-effect source class');
|
|
814
|
+
reviewText(effect.source, MAX_RESPONSE_VALUE_LENGTH, 'Case-response observed-effect source');
|
|
815
|
+
}
|
|
816
|
+
if (lifecycle.closure !== null) {
|
|
817
|
+
const closure = exactReviewRecord(lifecycle.closure, [
|
|
818
|
+
'id', 'reason', 'createdAt', 'limitations',
|
|
819
|
+
], 'Case-response latest closure');
|
|
820
|
+
reviewText(closure.id, 64, 'Case-response closure id');
|
|
821
|
+
reviewEnum(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
|
|
822
|
+
reviewText(closure.createdAt, 64, 'Case-response closure time');
|
|
823
|
+
reviewStrings(closure.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response closure limitations');
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function recursivelyFreezeReviewValue(value) {
|
|
827
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value))
|
|
828
|
+
return value;
|
|
829
|
+
for (const child of Object.values(value)) {
|
|
830
|
+
recursivelyFreezeReviewValue(child);
|
|
831
|
+
}
|
|
832
|
+
return Object.freeze(value);
|
|
833
|
+
}
|
|
834
|
+
export function validateCaseResponseReviewInputs(value) {
|
|
835
|
+
const source = exactReviewRecord(value, CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
|
|
836
|
+
if (source.contract !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA
|
|
837
|
+
|| source.version !== CASE_RESPONSE_REVIEW_INPUTS_VERSION) {
|
|
838
|
+
throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
|
|
839
|
+
}
|
|
840
|
+
const profile = exactReviewRecord(source.profile, [
|
|
841
|
+
'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
|
|
842
|
+
'excludedEvidence', 'redactions',
|
|
843
|
+
], 'Case-response review profile');
|
|
844
|
+
if (typeof profile.id !== 'string' || !RESPONSE_PROFILE_IDS.has(profile.id)) {
|
|
845
|
+
throw new TypeError('Case-response review profile is unsupported.');
|
|
846
|
+
}
|
|
847
|
+
for (const key of ['label', 'audience', 'subject']) {
|
|
848
|
+
reviewText(profile[key], 500, `Case-response profile ${key}`);
|
|
849
|
+
}
|
|
850
|
+
for (const key of ['checklist', 'includedEvidence', 'excludedEvidence', 'redactions']) {
|
|
851
|
+
reviewStrings(profile[key], 32, 500, `Case-response profile ${key}`);
|
|
852
|
+
}
|
|
853
|
+
const caseRecord = exactReviewRecord(source.case, [
|
|
854
|
+
'id', 'domain', 'status', 'disposition', 'updatedAt',
|
|
855
|
+
], 'Case-response review Case');
|
|
856
|
+
reviewText(caseRecord.id, 64, 'Case-response Case id');
|
|
857
|
+
reviewText(caseRecord.domain, 253, 'Case-response Case domain');
|
|
858
|
+
reviewText(caseRecord.status, 64, 'Case-response Case status');
|
|
859
|
+
reviewText(caseRecord.disposition, 64, 'Case-response Case disposition');
|
|
860
|
+
reviewText(caseRecord.updatedAt, 64, 'Case-response Case update time');
|
|
861
|
+
const incident = exactReviewRecord(source.incident, [
|
|
862
|
+
'category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt',
|
|
863
|
+
], 'Case-response review incident');
|
|
864
|
+
reviewText(incident.category, MAX_ABUSE_CATEGORY_LENGTH, 'Case-response category');
|
|
865
|
+
reviewText(incident.affectedParty, MAX_AFFECTED_PARTY_LENGTH, 'Case-response affected party');
|
|
866
|
+
reviewStrings(incident.abusiveUrls, MAX_ABUSIVE_URLS, MAX_EXACT_URL_LENGTH, 'Case-response abusive URLs');
|
|
867
|
+
reviewText(incident.observedHarm, MAX_RESPONSE_HARM_LENGTH, 'Case-response observed harm');
|
|
868
|
+
reviewText(incident.observedAt, 64, 'Case-response observation time', true);
|
|
869
|
+
for (const candidate of boundedReviewArray(source.contacts, MAX_RESPONSE_CONTACTS, 'Case-response contacts')) {
|
|
870
|
+
const contact = exactReviewRecord(candidate, [
|
|
871
|
+
'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
|
|
872
|
+
], 'Case-response contact');
|
|
873
|
+
if (typeof contact.kind !== 'string' || !CONTACT_KINDS.has(contact.kind)
|
|
874
|
+
|| typeof contact.freshness !== 'string'
|
|
875
|
+
|| !['current', 'stale', 'unknown'].includes(contact.freshness)) {
|
|
876
|
+
throw new TypeError('Case-response contact contains an unsupported enum.');
|
|
877
|
+
}
|
|
878
|
+
reviewText(contact.contact, 320, 'Case-response contact value');
|
|
879
|
+
reviewText(contact.source, 120, 'Case-response contact source');
|
|
880
|
+
reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
|
|
881
|
+
reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
|
|
882
|
+
}
|
|
883
|
+
for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
|
|
884
|
+
const evidence = exactReviewRecord(candidate, [
|
|
885
|
+
'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
|
|
886
|
+
], 'Case-response selected evidence item');
|
|
887
|
+
reviewText(evidence.id, 64, 'Case-response evidence id');
|
|
888
|
+
reviewText(evidence.label, 80, 'Case-response evidence label');
|
|
889
|
+
reviewText(evidence.source, 120, 'Case-response evidence source');
|
|
890
|
+
reviewText(evidence.observedAt, 64, 'Case-response evidence observation time');
|
|
891
|
+
reviewEnum(evidence.completeness, CASE_PIN_COMPLETENESS, 'Case-response evidence completeness');
|
|
892
|
+
reviewStrings(evidence.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response evidence limitations');
|
|
893
|
+
}
|
|
894
|
+
for (const candidate of boundedReviewArray(source.contradictions, MAX_RESPONSE_CONTRADICTIONS, 'Case-response contradictions')) {
|
|
895
|
+
const contradiction = exactReviewRecord(candidate, [
|
|
896
|
+
'id', 'statement', 'state', 'limitations',
|
|
897
|
+
], 'Case-response contradiction');
|
|
898
|
+
reviewText(contradiction.id, 64, 'Case-response contradiction id');
|
|
899
|
+
reviewText(contradiction.statement, 2_000, 'Case-response contradiction statement');
|
|
900
|
+
reviewEnum(contradiction.state, CASE_ASSERTION_STATES, 'Case-response contradiction state');
|
|
901
|
+
reviewStrings(contradiction.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contradiction limitations');
|
|
902
|
+
}
|
|
903
|
+
const readiness = exactReviewRecord(source.readiness, [
|
|
904
|
+
'profileId', 'rows', 'counts', 'limitations',
|
|
905
|
+
], 'Case-response readiness');
|
|
906
|
+
if (typeof readiness.profileId !== 'string' || !RESPONSE_PROFILE_IDS.has(readiness.profileId)) {
|
|
907
|
+
throw new TypeError('Case-response readiness profile is unsupported.');
|
|
908
|
+
}
|
|
909
|
+
const readinessRows = boundedReviewArray(readiness.rows, RESPONSE_READINESS_ROW_IDS.length, 'Case-response readiness rows');
|
|
910
|
+
const readinessIds = new Set();
|
|
911
|
+
const readinessCounts = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
|
|
912
|
+
for (const candidate of readinessRows) {
|
|
913
|
+
const row = exactReviewRecord(candidate, [
|
|
914
|
+
'id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations',
|
|
915
|
+
], 'Case-response readiness row');
|
|
916
|
+
if (typeof row.id !== 'string'
|
|
917
|
+
|| !RESPONSE_READINESS_ROW_IDS.includes(row.id)
|
|
918
|
+
|| readinessIds.has(row.id)
|
|
919
|
+
|| typeof row.state !== 'string'
|
|
920
|
+
|| !RESPONSE_READINESS_STATES.includes(row.state)
|
|
921
|
+
|| typeof row.requiredForAuthorisation !== 'boolean') {
|
|
922
|
+
throw new TypeError('Case-response readiness row is unsupported or duplicated.');
|
|
923
|
+
}
|
|
924
|
+
readinessIds.add(row.id);
|
|
925
|
+
readinessCounts[row.state] += 1;
|
|
926
|
+
reviewText(row.label, 120, 'Case-response readiness label');
|
|
927
|
+
reviewText(row.detail, 500, 'Case-response readiness detail');
|
|
928
|
+
reviewStrings(row.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
|
|
929
|
+
}
|
|
930
|
+
const counts = exactReviewRecord(readiness.counts, RESPONSE_READINESS_STATES, 'Case-response readiness counts');
|
|
931
|
+
for (const state of RESPONSE_READINESS_STATES) {
|
|
932
|
+
if (reviewCount(counts[state], RESPONSE_READINESS_ROW_IDS.length, `Case-response readiness ${state} count`) !== readinessCounts[state]) {
|
|
933
|
+
throw new TypeError('Case-response readiness counts do not match the exact rows.');
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
reviewStrings(readiness.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
|
|
937
|
+
for (const candidate of boundedReviewArray(source.artefactReferences, MAX_RESPONSE_ARTEFACT_REFERENCES, 'Case-response artefact references')) {
|
|
938
|
+
const reference = exactReviewRecord(candidate, [
|
|
939
|
+
'id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256',
|
|
940
|
+
'byteLength', 'limitations',
|
|
941
|
+
], 'Case-response artefact reference');
|
|
942
|
+
reviewText(reference.id, 64, 'Case-response artefact id');
|
|
943
|
+
reviewText(reference.label, 120, 'Case-response artefact label');
|
|
944
|
+
reviewText(reference.mediaType, 120, 'Case-response artefact media type');
|
|
945
|
+
reviewText(reference.capturedAt, 64, 'Case-response artefact capture time');
|
|
946
|
+
reviewText(reference.source, 120, 'Case-response artefact source');
|
|
947
|
+
if (typeof reference.digestSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(reference.digestSha256)) {
|
|
948
|
+
throw new TypeError('Case-response artefact digest is invalid.');
|
|
949
|
+
}
|
|
950
|
+
if (reference.byteLength !== null)
|
|
951
|
+
reviewCount(reference.byteLength, 100 * 1024 * 1024, 'Case-response artefact byte length');
|
|
952
|
+
reviewStrings(reference.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response artefact limitations');
|
|
953
|
+
}
|
|
954
|
+
for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
|
|
955
|
+
const action = exactReviewRecord(candidate, [
|
|
956
|
+
'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
|
|
957
|
+
'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
|
|
958
|
+
'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
|
|
959
|
+
], 'Case-response escalation action');
|
|
960
|
+
for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
|
|
961
|
+
reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
|
|
962
|
+
}
|
|
963
|
+
reviewEnum(action.type, CASE_ACTION_TYPES, 'Case-response action type');
|
|
964
|
+
reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
|
|
965
|
+
for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
|
|
966
|
+
reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`, true);
|
|
967
|
+
}
|
|
968
|
+
reviewNullableEnum(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action provider outcome');
|
|
969
|
+
reviewCount(action.historyOmitted, MAX_CASE_ACTION_EVENTS_PER_CASE, 'Case-response omitted transition count');
|
|
970
|
+
reviewStrings(action.historyLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action history limitations');
|
|
971
|
+
for (const candidateTransition of boundedReviewArray(action.transitions, MAX_CASE_ACTION_EVENTS_PER_ACTION, 'Case-response transitions')) {
|
|
972
|
+
const transition = exactReviewRecord(candidateTransition, [
|
|
973
|
+
'id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance',
|
|
974
|
+
'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail',
|
|
975
|
+
'originActionId', 'applied',
|
|
976
|
+
], 'Case-response transition');
|
|
977
|
+
for (const key of ['id', 'occurredAt', 'provenance']) {
|
|
978
|
+
reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`);
|
|
979
|
+
}
|
|
980
|
+
reviewNullableEnum(transition.previousState, CASE_ACTION_STATES, 'Case-response previous action state');
|
|
981
|
+
reviewEnum(transition.nextState, CASE_ACTION_STATES, 'Case-response next action state');
|
|
982
|
+
reviewEnum(transition.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response transition source class');
|
|
983
|
+
for (const key of ['reference', 'evidencePinId', 'outcomeDetail', 'originActionId']) {
|
|
984
|
+
reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`, true);
|
|
985
|
+
}
|
|
986
|
+
reviewNullableEnum(transition.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response transition provider outcome');
|
|
987
|
+
if (typeof transition.applied !== 'boolean')
|
|
988
|
+
throw new TypeError('Case-response transition applied state is invalid.');
|
|
989
|
+
reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
|
|
993
|
+
reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
|
|
994
|
+
validateReviewLifecycle(source.responseLifecycle);
|
|
995
|
+
return recursivelyFreezeReviewValue(structuredClone(source));
|
|
996
|
+
}
|
|
997
|
+
export async function buildCaseResponseReviewDigest(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
998
|
+
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
999
|
+
return sha256(canonicalArtifactJsonV2(validateCaseResponseReviewInputs(buildCaseResponseReviewInputs(caseRecord, input, normalizedGeneratedAt))));
|
|
1000
|
+
}
|
|
1001
|
+
function buildResponseAuthorisation(input, reviewedInputDigestSha256, readiness, generatedAt) {
|
|
1002
|
+
const root = input.authorisation && typeof input.authorisation === 'object' && !Array.isArray(input.authorisation)
|
|
1003
|
+
? input.authorisation
|
|
1004
|
+
: {};
|
|
1005
|
+
const suppliedReviewDigestSha256 = typeof root.reviewedInputDigestSha256 === 'string'
|
|
1006
|
+
&& /^[a-f0-9]{64}$/u.test(root.reviewedInputDigestSha256)
|
|
1007
|
+
? root.reviewedInputDigestSha256
|
|
1008
|
+
: null;
|
|
1009
|
+
const confirmations = normalizeConfirmations(root.confirmations);
|
|
1010
|
+
const missingConfirmations = RESPONSE_AUTHORISATION_CONFIRMATION_IDS.filter((id) => !confirmations[id]);
|
|
1011
|
+
const digestMatches = suppliedReviewDigestSha256 === reviewedInputDigestSha256;
|
|
1012
|
+
const suppliedConfirmedAt = timestamp(root.confirmedAt);
|
|
1013
|
+
const confirmedAt = suppliedConfirmedAt
|
|
1014
|
+
&& Date.parse(suppliedConfirmedAt) <= Date.parse(generatedAt) + MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
|
|
1015
|
+
? suppliedConfirmedAt
|
|
1016
|
+
: null;
|
|
1017
|
+
const missingRequiredInputs = readiness.rows.filter((row) => row.requiredForAuthorisation
|
|
1018
|
+
&& (row.state === 'not_provided' || row.state === 'unavailable'));
|
|
1019
|
+
const authorityReady = readiness.rows.find((row) => row.id === 'authority_review')?.state === 'complete';
|
|
1020
|
+
const authorised = digestMatches && Boolean(confirmedAt) && !missingConfirmations.length && !missingRequiredInputs.length && authorityReady;
|
|
1021
|
+
return {
|
|
1022
|
+
status: authorised ? 'authorised' : 'draft',
|
|
1023
|
+
reviewedInputDigestSha256,
|
|
1024
|
+
suppliedReviewDigestSha256,
|
|
1025
|
+
digestMatches,
|
|
1026
|
+
confirmedAt: authorised ? confirmedAt : null,
|
|
1027
|
+
confirmations,
|
|
1028
|
+
missingConfirmations,
|
|
1029
|
+
limitations: [
|
|
1030
|
+
authorised
|
|
1031
|
+
? 'These confirmations apply only to the canonical reviewed-input digest in this packet. They do not submit the packet or promise any recipient or provider outcome.'
|
|
1032
|
+
: 'This is a draft. It is not authorised for external use by WHOISleuth.',
|
|
1033
|
+
...(!digestMatches && suppliedReviewDigestSha256 ? ['Material inputs changed after review; the supplied authorisation digest is stale.'] : []),
|
|
1034
|
+
...(!suppliedReviewDigestSha256 ? ['No exact reviewed-input digest is bound to the confirmations.'] : []),
|
|
1035
|
+
...(!confirmedAt ? ['No valid confirmation time is bound to the reviewed inputs.'] : []),
|
|
1036
|
+
...(missingRequiredInputs.length ? [`${missingRequiredInputs.length} required readiness row${missingRequiredInputs.length === 1 ? ' is' : 's are'} unavailable or not provided.`] : []),
|
|
1037
|
+
...(!authorityReady ? ['Authority review must be complete before this packet can be authorised.'] : []),
|
|
1038
|
+
],
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
export async function verifyCaseResponsePacketIntegrity(packet) {
|
|
1042
|
+
try {
|
|
1043
|
+
assertBoundedJsonStructure(packet, 'Case-response packet');
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
if (!packet || typeof packet !== 'object' || Array.isArray(packet))
|
|
1049
|
+
return false;
|
|
1050
|
+
const root = packet;
|
|
1051
|
+
const integrityValue = root.integrity;
|
|
1052
|
+
if (!integrityValue || typeof integrityValue !== 'object' || Array.isArray(integrityValue))
|
|
1053
|
+
return false;
|
|
1054
|
+
const integrity = integrityValue;
|
|
1055
|
+
const { integrity: _integrity, ...unsigned } = root;
|
|
1056
|
+
if (!SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS.includes(root.schemaVersion)
|
|
1057
|
+
|| integrity.algorithm !== 'SHA-256'
|
|
1058
|
+
|| integrity.canonicalization !== SORTED_JSON_V2
|
|
1059
|
+
|| integrity.scope !== 'packet excluding integrity'
|
|
1060
|
+
|| typeof integrity.digestSha256 !== 'string'
|
|
1061
|
+
|| !/^[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
return integrity.digestSha256 === await sha256(canonicalArtifactJsonV2(unsigned));
|
|
1065
|
+
}
|
|
1066
|
+
function escapeMarkdown(value) {
|
|
1067
|
+
return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
|
|
1068
|
+
}
|
|
1069
|
+
function contactLabel(value) {
|
|
1070
|
+
if (value === 'network_hosting')
|
|
1071
|
+
return 'Network or hosting';
|
|
1072
|
+
if (value === 'security_txt')
|
|
1073
|
+
return 'security.txt';
|
|
1074
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1075
|
+
}
|
|
1076
|
+
export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
1077
|
+
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
|
|
1078
|
+
const affectedParty = text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH);
|
|
1079
|
+
const abusiveUrls = normalizeUrls(input.abusiveUrls);
|
|
1080
|
+
const observedHarm = text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH);
|
|
1081
|
+
const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
|
|
1082
|
+
const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
|
|
1083
|
+
if (!category || !affectedParty || !abusiveUrls.length || !observedHarm || !observedAt) {
|
|
1084
|
+
throw new Error('Category, affected party, at least one exact HTTP(S) URL, observed harm, and an observation time are required.');
|
|
1085
|
+
}
|
|
1086
|
+
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
1087
|
+
const reviewMaterial = buildCaseResponseReviewInputs(caseRecord, input, normalizedGeneratedAt);
|
|
1088
|
+
const contacts = reviewMaterial.contacts;
|
|
1089
|
+
const selectedEvidence = reviewMaterial.selectedEvidence;
|
|
1090
|
+
const contradictions = reviewMaterial.contradictions;
|
|
1091
|
+
const readiness = reviewMaterial.readiness;
|
|
1092
|
+
const artefactReferences = reviewMaterial.artefactReferences;
|
|
1093
|
+
const escalationHistory = reviewMaterial.escalationHistory;
|
|
1094
|
+
const escalationHistoryOmitted = reviewMaterial.escalationHistoryOmitted;
|
|
1095
|
+
const escalationHistoryLimitations = reviewMaterial.escalationHistoryLimitations;
|
|
1096
|
+
const responseLifecycle = reviewMaterial.responseLifecycle;
|
|
1097
|
+
const reviewedInputDigestSha256 = await sha256(canonicalArtifactJsonV2(reviewMaterial));
|
|
1098
|
+
const authorisation = buildResponseAuthorisation(input, reviewedInputDigestSha256, readiness, normalizedGeneratedAt);
|
|
1099
|
+
const age = observationAge(observedAt, normalizedGeneratedAt);
|
|
1100
|
+
const preflight = buildCaseResponsePreflight(caseRecord, input, normalizedGeneratedAt);
|
|
1101
|
+
const profile = buildResponsePacketProfilePreview(caseRecord, input);
|
|
1102
|
+
const limitations = [
|
|
1103
|
+
authorisation.status === 'authorised'
|
|
1104
|
+
? 'This packet is bound to explicit confirmations for the exact reviewed-input digest. It still requires deliberate manual use.'
|
|
1105
|
+
: 'This packet is a local draft with cautions and is not authorised for external use by WHOISleuth.',
|
|
1106
|
+
'WHOISleuth did not submit this packet or verify that any listed contact is monitored.',
|
|
1107
|
+
...(!contacts.length ? ['No escalation contact was included.'] : []),
|
|
1108
|
+
...(age.refreshRecommended ? ['The selected observation is over seven days old or appears to be in the future. Refresh evidence before submission.'] : []),
|
|
1109
|
+
];
|
|
1110
|
+
const unsigned = {
|
|
1111
|
+
schema: CASE_RESPONSE_PACKET_SCHEMA,
|
|
1112
|
+
schemaVersion: CASE_RESPONSE_PACKET_VERSION,
|
|
1113
|
+
generatedAt: normalizedGeneratedAt,
|
|
1114
|
+
reviewRequired: true,
|
|
1115
|
+
submissionPerformed: false,
|
|
1116
|
+
profile: {
|
|
1117
|
+
id: profile.id,
|
|
1118
|
+
label: profile.label,
|
|
1119
|
+
audience: profile.audience,
|
|
1120
|
+
subject: profile.subject,
|
|
1121
|
+
checklist: profile.checklist,
|
|
1122
|
+
evidenceOrder: profile.evidenceOrder,
|
|
1123
|
+
includedEvidence: profile.includedEvidence,
|
|
1124
|
+
excludedEvidence: profile.excludedEvidence,
|
|
1125
|
+
redactions: profile.redactions,
|
|
1126
|
+
attachments: profile.attachments,
|
|
1127
|
+
followUpFields: profile.followUpFields,
|
|
1128
|
+
},
|
|
1129
|
+
case: {
|
|
1130
|
+
id: caseRecord.id,
|
|
1131
|
+
domain: caseRecord.domain,
|
|
1132
|
+
status: caseRecord.status,
|
|
1133
|
+
disposition: caseRecord.disposition,
|
|
1134
|
+
updatedAt: caseRecord.updatedAt,
|
|
1135
|
+
},
|
|
1136
|
+
incident: {
|
|
1137
|
+
category,
|
|
1138
|
+
affectedParty,
|
|
1139
|
+
abusiveUrls,
|
|
1140
|
+
observedHarm,
|
|
1141
|
+
observedAt,
|
|
1142
|
+
},
|
|
1143
|
+
contacts,
|
|
1144
|
+
selectedEvidence,
|
|
1145
|
+
contradictions,
|
|
1146
|
+
readiness,
|
|
1147
|
+
artefactReferences,
|
|
1148
|
+
authorisation,
|
|
1149
|
+
preflight,
|
|
1150
|
+
escalationHistory,
|
|
1151
|
+
escalationHistoryOmitted,
|
|
1152
|
+
escalationHistoryLimitations,
|
|
1153
|
+
responseLifecycle,
|
|
1154
|
+
provenance: {
|
|
1155
|
+
latestEvidenceCapturedAt: latestEvidence?.capturedAt ?? null,
|
|
1156
|
+
evidencePinCount: caseRecord.evidencePins.length,
|
|
1157
|
+
decisionCount: caseRecord.decisions.length,
|
|
1158
|
+
assertionCount: caseRecord.assertions.length,
|
|
1159
|
+
observationAge: age,
|
|
1160
|
+
limitations,
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
const digestSha256 = await sha256(canonicalArtifactJsonV2(unsigned));
|
|
1164
|
+
const json = {
|
|
1165
|
+
...unsigned,
|
|
1166
|
+
integrity: {
|
|
1167
|
+
algorithm: 'SHA-256',
|
|
1168
|
+
canonicalization: SORTED_JSON_V2,
|
|
1169
|
+
scope: 'packet excluding integrity',
|
|
1170
|
+
digestSha256,
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
const lines = [
|
|
1174
|
+
`# ${escapeMarkdown(profile.label)} packet`,
|
|
1175
|
+
'',
|
|
1176
|
+
`**Domain:** ${escapeMarkdown(caseRecord.domain)}`,
|
|
1177
|
+
`**Category:** ${escapeMarkdown(category)}`,
|
|
1178
|
+
`**Affected party:** ${escapeMarkdown(affectedParty)}`,
|
|
1179
|
+
`**Observed at (UTC):** ${observedAt}`,
|
|
1180
|
+
`**Generated at (UTC):** ${normalizedGeneratedAt}`,
|
|
1181
|
+
`**Audience:** ${escapeMarkdown(profile.audience)}`,
|
|
1182
|
+
`**Suggested subject:** ${escapeMarkdown(profile.subject)}`,
|
|
1183
|
+
'',
|
|
1184
|
+
'## Observed harm',
|
|
1185
|
+
'',
|
|
1186
|
+
escapeMarkdown(observedHarm),
|
|
1187
|
+
'',
|
|
1188
|
+
'## Exact abusive URLs',
|
|
1189
|
+
'',
|
|
1190
|
+
...abusiveUrls.map((url) => `- ${escapeMarkdown(url)}`),
|
|
1191
|
+
'',
|
|
1192
|
+
'## Escalation contacts',
|
|
1193
|
+
'',
|
|
1194
|
+
...(contacts.length
|
|
1195
|
+
? contacts.flatMap((contact) => [
|
|
1196
|
+
`### ${contactLabel(contact.kind)}`,
|
|
1197
|
+
'',
|
|
1198
|
+
`- Contact: ${escapeMarkdown(contact.contact)}`,
|
|
1199
|
+
`- Source: ${escapeMarkdown(contact.source)}`,
|
|
1200
|
+
`- Route observed: ${contact.observedAt ?? 'Not provided'} (${contact.freshness})`,
|
|
1201
|
+
`- Limitations: ${contact.limitations.length ? contact.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
|
|
1202
|
+
'',
|
|
1203
|
+
])
|
|
1204
|
+
: ['No escalation contact was included.', '']),
|
|
1205
|
+
'## Escalation history',
|
|
1206
|
+
'',
|
|
1207
|
+
...(escalationHistory.length
|
|
1208
|
+
? escalationHistory.flatMap((action) => [
|
|
1209
|
+
`- ${escapeMarkdown(action.type.replaceAll('_', ' '))} to ${escapeMarkdown(action.recipient)} · ${escapeMarkdown(action.state.replaceAll('_', ' '))} · updated ${action.updatedAt}`,
|
|
1210
|
+
...(action.reference ? [` - Reference: ${escapeMarkdown(action.reference)}`] : []),
|
|
1211
|
+
...(action.providerOutcome ? [` - Typed provider outcome: ${escapeMarkdown(action.providerOutcome.replaceAll('_', ' '))}`] : []),
|
|
1212
|
+
...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
|
|
1213
|
+
...(action.originActionId ? [` - Originating action: ${escapeMarkdown(action.originActionId)}`] : []),
|
|
1214
|
+
...action.transitions.flatMap((event) => [
|
|
1215
|
+
` - ${event.occurredAt}: ${escapeMarkdown(event.previousState ?? 'none')} → ${escapeMarkdown(event.nextState)} · ${escapeMarkdown(event.sourceClass)} · ${escapeMarkdown(event.provenance)}${event.providerOutcome ? ` · ${escapeMarkdown(event.providerOutcome.replaceAll('_', ' '))}` : ''}${event.applied ? '' : ' · retained conflict'}`,
|
|
1216
|
+
...(event.reference ? [` - Reference: ${escapeMarkdown(event.reference)}`] : []),
|
|
1217
|
+
...(event.evidencePinId ? [` - Evidence pin: ${escapeMarkdown(event.evidencePinId)}`] : []),
|
|
1218
|
+
...(event.originActionId ? [` - Originating action: ${escapeMarkdown(event.originActionId)}`] : []),
|
|
1219
|
+
...event.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
|
|
1220
|
+
]),
|
|
1221
|
+
...(action.historyOmitted ? [` - ${action.historyOmitted} earlier transition event${action.historyOmitted === 1 ? '' : 's'} omitted by bound.`] : []),
|
|
1222
|
+
...action.historyLimitations.map((limitation) => ` - History limitation: ${escapeMarkdown(limitation)}`),
|
|
1223
|
+
])
|
|
1224
|
+
: ['No reviewed case actions were recorded.']),
|
|
1225
|
+
...(escalationHistoryOmitted ? [`- Earlier actions omitted from packet projection: ${escalationHistoryOmitted}`] : []),
|
|
1226
|
+
...escalationHistoryLimitations.map((limitation) => `- Packet history limitation: ${escapeMarkdown(limitation)}`),
|
|
1227
|
+
'',
|
|
1228
|
+
'## Readiness and authorisation',
|
|
1229
|
+
'',
|
|
1230
|
+
`- Packet state: ${authorisation.status}`,
|
|
1231
|
+
`- Reviewed-input SHA-256: ${authorisation.reviewedInputDigestSha256}`,
|
|
1232
|
+
`- Supplied review digest matches: ${authorisation.digestMatches ? 'yes' : 'no'}`,
|
|
1233
|
+
...RESPONSE_AUTHORISATION_CONFIRMATION_IDS.map((id) => `- Confirmation ${id}: ${authorisation.confirmations[id] ? 'yes' : 'no'}`),
|
|
1234
|
+
...readiness.rows.flatMap((row) => [
|
|
1235
|
+
`- ${escapeMarkdown(row.label)} [${row.state}]: ${escapeMarkdown(row.detail)}`,
|
|
1236
|
+
...row.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
|
|
1237
|
+
]),
|
|
1238
|
+
...authorisation.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
1239
|
+
'',
|
|
1240
|
+
'## Selected evidence and integrity references',
|
|
1241
|
+
'',
|
|
1242
|
+
...(selectedEvidence.length ? selectedEvidence.map((item) => `- ${escapeMarkdown(item.id)} · ${escapeMarkdown(item.label)} · ${escapeMarkdown(item.source)} · ${item.observedAt} · ${escapeMarkdown(item.completeness)}`) : ['- No evidence pin was explicitly selected.']),
|
|
1243
|
+
...artefactReferences.map((item) => `- ${escapeMarkdown(item.id)} · ${escapeMarkdown(item.label)} · SHA-256 ${item.digestSha256} · captured ${item.capturedAt}`),
|
|
1244
|
+
'',
|
|
1245
|
+
'## Provider outcome and independent effect',
|
|
1246
|
+
'',
|
|
1247
|
+
responseLifecycle.latestProviderOutcome
|
|
1248
|
+
? `- Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${escapeMarkdown(responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' '))})`
|
|
1249
|
+
: `- Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
|
|
1250
|
+
responseLifecycle.latestObservedChangeAt
|
|
1251
|
+
? `- Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
|
|
1252
|
+
: `- Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
|
|
1253
|
+
...(responseLifecycle.latestObservedEffect ? [`- Latest independent review: ${escapeMarkdown(responseLifecycle.latestObservedEffect.state.replaceAll('_', ' '))} · ${responseLifecycle.latestObservedEffect.observedAt} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.source)}`] : []),
|
|
1254
|
+
...responseLifecycle.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
1255
|
+
'',
|
|
1256
|
+
'## Review and provenance',
|
|
1257
|
+
'',
|
|
1258
|
+
`- Preflight: ${preflight.status.replaceAll('_', ' ')} (${preflight.counts.pass} pass, ${preflight.counts.caution} caution, ${preflight.counts.block} block)`,
|
|
1259
|
+
...preflight.checks.map((check) => `- ${escapeMarkdown(check.label)} [${check.state}]: ${escapeMarkdown(check.detail)}`),
|
|
1260
|
+
...limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
1261
|
+
`- Case evidence pins: ${caseRecord.evidencePins.length}`,
|
|
1262
|
+
`- Case decision records: ${caseRecord.decisions.length}`,
|
|
1263
|
+
`- Case structured assertions: ${caseRecord.assertions.length}`,
|
|
1264
|
+
`- Observation-age band at export: ${age.band.replaceAll('_', ' ')}`,
|
|
1265
|
+
`- Canonical packet SHA-256: ${digestSha256}`,
|
|
1266
|
+
'- Digest scope: canonical sorted JSON packet excluding the integrity object',
|
|
1267
|
+
'',
|
|
1268
|
+
'## Audience profile',
|
|
1269
|
+
'',
|
|
1270
|
+
...profile.checklist.map((item) => `- Checklist: ${escapeMarkdown(item)}`),
|
|
1271
|
+
...profile.includedEvidence.map((item) => `- Included: ${escapeMarkdown(item)}`),
|
|
1272
|
+
...profile.excludedEvidence.map((item) => `- Excluded: ${escapeMarkdown(item)}`),
|
|
1273
|
+
...profile.redactions.map((item) => `- Redaction: ${escapeMarkdown(item)}`),
|
|
1274
|
+
...profile.attachments.map((item) => `- Attachment expectation: ${escapeMarkdown(item)}`),
|
|
1275
|
+
...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
|
|
1276
|
+
];
|
|
1277
|
+
const markdown = `${lines.join('\n').trim()}\n`;
|
|
1278
|
+
const email = [
|
|
1279
|
+
`Subject: ${profile.subject}`,
|
|
1280
|
+
'',
|
|
1281
|
+
'Hello,',
|
|
1282
|
+
'',
|
|
1283
|
+
`I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
|
|
1284
|
+
`Affected party: ${affectedParty}`,
|
|
1285
|
+
`Observed at (UTC): ${observedAt}`,
|
|
1286
|
+
'',
|
|
1287
|
+
'Observed harm:',
|
|
1288
|
+
observedHarm,
|
|
1289
|
+
'',
|
|
1290
|
+
'Exact URLs:',
|
|
1291
|
+
...abusiveUrls.map((url) => `- ${url}`),
|
|
1292
|
+
'',
|
|
1293
|
+
responseLifecycle.latestProviderOutcome
|
|
1294
|
+
? `Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`
|
|
1295
|
+
: `Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
|
|
1296
|
+
responseLifecycle.latestObservedChangeAt
|
|
1297
|
+
? `Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
|
|
1298
|
+
: `Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
|
|
1299
|
+
'',
|
|
1300
|
+
'Please review this report under the applicable abuse and acceptable-use policies.',
|
|
1301
|
+
'',
|
|
1302
|
+
authorisation.status === 'authorised'
|
|
1303
|
+
? 'This locally prepared packet is bound to explicit review confirmations. It was not submitted automatically and does not promise any provider outcome.'
|
|
1304
|
+
: 'This is an unauthorised local draft with cautions. It was not submitted automatically and does not promise any provider outcome.',
|
|
1305
|
+
].join('\n');
|
|
1306
|
+
return { json, markdown, email: `${email}\n` };
|
|
1307
|
+
}
|
|
1308
|
+
export function caseResponsePacketFilename(domain, format, generatedAt) {
|
|
1309
|
+
const safeDomain = domain.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 80) || 'case';
|
|
1310
|
+
const date = (timestamp(generatedAt) || new Date().toISOString()).slice(0, 10);
|
|
1311
|
+
return `whoisleuth-response-${safeDomain}-${date}.${format}`;
|
|
1312
|
+
}
|