@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
import { renderCaseResponsePacket } from "./case-response-packet-render.mjs";
|
|
2
|
+
import { responseContactLabel as contactLabel } from "./case-response-packet-vocabulary.mjs";
|
|
1
3
|
// Pure abuse-evidence packet builder. It creates local review artifacts only:
|
|
2
4
|
// no network requests, mailto links, submissions, or provider side effects.
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
5
|
+
import { assertBoundedJsonStructure, } from "../../lib/bounded-json.mjs";
|
|
6
|
+
import { CASE_RESPONSE_PACKET_SCHEMA, 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_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
|
|
7
|
+
import { SORTED_JSON_V2, canonicalArtifactJsonV2, } from "../evidence/artifact-integrity.mjs";
|
|
8
|
+
import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
|
|
9
|
+
import { latestCaseEvidence, } from "./case-evidence-model.mjs";
|
|
10
|
+
import {} from "./case-model.mjs";
|
|
11
|
+
import { caseDispositionSupportsDefensiveResponse, } from "./case-record-operations.mjs";
|
|
12
|
+
import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, } from "./case-response-model.mjs";
|
|
13
|
+
import { RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_STATES, } from "./case-response-packet-vocabulary.mjs";
|
|
14
|
+
import { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
|
|
15
|
+
import { responseRouteFreshness, } from "./response-route-freshness.mjs";
|
|
16
|
+
export * from "./case-response-packet-vocabulary.mjs";
|
|
17
|
+
export { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
|
|
18
|
+
export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
|
|
19
|
+
export { LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, } from "../contracts/case-portability.mjs";
|
|
8
20
|
export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
|
|
9
21
|
version: 1,
|
|
10
22
|
owner: 'case',
|
|
@@ -18,42 +30,8 @@ export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
|
|
|
18
30
|
'case_actions',
|
|
19
31
|
]),
|
|
20
32
|
lookupDecisionFacts: 'unavailable',
|
|
21
|
-
limitation: 'Lookup Decision Facts are transient and are not copied into
|
|
33
|
+
limitation: 'Lookup Decision Facts are transient and are not copied into saved 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
34
|
});
|
|
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
35
|
export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
58
36
|
{
|
|
59
37
|
id: 'registrar',
|
|
@@ -111,6 +89,20 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
111
89
|
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
112
90
|
followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
|
|
113
91
|
},
|
|
92
|
+
{
|
|
93
|
+
id: 'application_platform',
|
|
94
|
+
label: 'Platform report review',
|
|
95
|
+
audience: 'Application-platform safety, impersonation, commerce, or rights team',
|
|
96
|
+
subjectPrefix: 'Reviewed platform report',
|
|
97
|
+
requiredContactKind: 'application_platform',
|
|
98
|
+
checklist: ['Confirm the exact account or content URL', 'Choose the route matching the evidence and reporter authority', 'Review the provider notice before sharing identity or rights-owner details'],
|
|
99
|
+
evidenceOrder: ['Incident facts', 'Exact platform URLs', 'Selected observations', 'Rights or authority evidence', 'Action history'],
|
|
100
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Route provenance and review deadline', 'Relevant rights or authority context'],
|
|
101
|
+
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported policy or legal conclusions'],
|
|
102
|
+
redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, identity documents, and unrelated personal data before sharing'],
|
|
103
|
+
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
104
|
+
followUpFields: ['Provider reference', 'Acknowledgement time', 'Policy response', 'Independent observed effect'],
|
|
105
|
+
},
|
|
114
106
|
{
|
|
115
107
|
id: 'browser_blocklist',
|
|
116
108
|
label: 'Browser or blocklist review',
|
|
@@ -140,7 +132,6 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
140
132
|
followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
|
|
141
133
|
},
|
|
142
134
|
]);
|
|
143
|
-
const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
|
|
144
135
|
const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
|
|
145
136
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
146
137
|
const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
|
|
@@ -153,15 +144,19 @@ function compareCodeUnits(left, right) {
|
|
|
153
144
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
154
145
|
}
|
|
155
146
|
function timestamp(value) {
|
|
156
|
-
|
|
157
|
-
return null;
|
|
158
|
-
const parsed = Date.parse(value);
|
|
159
|
-
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
147
|
+
return normalizeExplicitIsoTimestamp(value);
|
|
160
148
|
}
|
|
161
|
-
function normalizeExactUrl(value) {
|
|
162
|
-
|
|
149
|
+
function normalizeExactUrl(value, rejectOverlong) {
|
|
150
|
+
if (typeof value !== 'string' || CONTROL_RE.test(value))
|
|
151
|
+
return null;
|
|
152
|
+
const candidate = value.trim();
|
|
163
153
|
if (!candidate)
|
|
164
154
|
return null;
|
|
155
|
+
if (candidate.length > MAX_EXACT_URL_LENGTH) {
|
|
156
|
+
if (rejectOverlong)
|
|
157
|
+
throw new Error(`Each exact HTTP(S) URL is limited to ${MAX_EXACT_URL_LENGTH} characters; shorten or remove the overlong URL before continuing.`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
165
160
|
try {
|
|
166
161
|
const parsed = new URL(candidate);
|
|
167
162
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
@@ -172,7 +167,7 @@ function normalizeExactUrl(value) {
|
|
|
172
167
|
return null;
|
|
173
168
|
}
|
|
174
169
|
}
|
|
175
|
-
function normalizeUrls(value) {
|
|
170
|
+
function normalizeUrls(value, rejectOverlong = false) {
|
|
176
171
|
const source = Array.isArray(value)
|
|
177
172
|
? value
|
|
178
173
|
: typeof value === 'string'
|
|
@@ -180,7 +175,7 @@ function normalizeUrls(value) {
|
|
|
180
175
|
: [];
|
|
181
176
|
const unique = new Set();
|
|
182
177
|
for (const item of source.slice(0, MAX_ABUSIVE_URLS * 2)) {
|
|
183
|
-
const normalized = normalizeExactUrl(item);
|
|
178
|
+
const normalized = normalizeExactUrl(item, rejectOverlong);
|
|
184
179
|
if (normalized)
|
|
185
180
|
unique.add(normalized);
|
|
186
181
|
if (unique.size >= MAX_ABUSIVE_URLS)
|
|
@@ -213,17 +208,10 @@ function actionContactKind(type) {
|
|
|
213
208
|
return 'network_hosting';
|
|
214
209
|
if (type === 'security_contact_report')
|
|
215
210
|
return 'security_txt';
|
|
211
|
+
if (type === 'platform_report')
|
|
212
|
+
return 'application_platform';
|
|
216
213
|
return 'manual';
|
|
217
214
|
}
|
|
218
|
-
function routeFreshness(observedAt, generatedAt) {
|
|
219
|
-
if (!observedAt)
|
|
220
|
-
return 'unknown';
|
|
221
|
-
const routeAge = Date.parse(generatedAt) - Date.parse(observedAt);
|
|
222
|
-
return routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
|
|
223
|
-
|| routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
|
|
224
|
-
? 'stale'
|
|
225
|
-
: 'current';
|
|
226
|
-
}
|
|
227
215
|
function selectedPacketAction(caseRecord, input) {
|
|
228
216
|
const actionId = typeof input.actionId === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(input.actionId)
|
|
229
217
|
? input.actionId
|
|
@@ -282,13 +270,15 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
|
|
|
282
270
|
};
|
|
283
271
|
}
|
|
284
272
|
const observedAt = timestamp(action.routeObservedAt);
|
|
273
|
+
const reviewAfter = timestamp(action.routeReviewAfter);
|
|
285
274
|
const recipientRoute = {
|
|
286
275
|
actionId: action.id,
|
|
287
276
|
kind,
|
|
288
277
|
contact: text(action.recipient, 320),
|
|
289
278
|
source: text(action.contactSource, 120) || 'analyst supplied',
|
|
290
279
|
observedAt,
|
|
291
|
-
|
|
280
|
+
reviewAfter,
|
|
281
|
+
freshness: responseRouteFreshness(observedAt, reviewAfter, generatedAt),
|
|
292
282
|
limitations: normalizeLimitations(action.contactLimitations),
|
|
293
283
|
};
|
|
294
284
|
const contacts = kind === 'manual' ? [] : [{
|
|
@@ -296,6 +286,7 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
|
|
|
296
286
|
contact: recipientRoute.contact,
|
|
297
287
|
source: recipientRoute.source,
|
|
298
288
|
observedAt,
|
|
289
|
+
reviewAfter,
|
|
299
290
|
freshness: recipientRoute.freshness,
|
|
300
291
|
limitations: [...recipientRoute.limitations],
|
|
301
292
|
}];
|
|
@@ -325,7 +316,9 @@ function normalizeSelectedEvidence(caseRecord, value) {
|
|
|
325
316
|
id: pin.id,
|
|
326
317
|
label: text(pin.label, 80),
|
|
327
318
|
source: text(pin.source, 120),
|
|
328
|
-
|
|
319
|
+
...(pin.observationHostname ? { observationHostname: pin.observationHostname } : {}),
|
|
320
|
+
...(pin.webObservationMode ? { webObservationMode: pin.webObservationMode } : {}),
|
|
321
|
+
observedAt: timestamp(pin.observedAt),
|
|
329
322
|
completeness: text(pin.completeness, 40),
|
|
330
323
|
limitations: normalizeLimitations(pin.limitations),
|
|
331
324
|
}));
|
|
@@ -358,10 +351,10 @@ function readinessOverride(value, key) {
|
|
|
358
351
|
limitations: normalizeLimitations(item.limitations),
|
|
359
352
|
};
|
|
360
353
|
}
|
|
361
|
-
function buildResponseReadiness(
|
|
354
|
+
function buildResponseReadiness(input, generatedAt, contacts, recipientRoute, selectedEvidence, contradictions) {
|
|
362
355
|
const profile = responsePacketProfile(input.profile);
|
|
363
356
|
const urls = normalizeUrls(input.abusiveUrls);
|
|
364
|
-
const observedAt = timestamp(input.observedAt)
|
|
357
|
+
const observedAt = timestamp(input.observedAt);
|
|
365
358
|
const age = observedAt ? observationAge(observedAt, generatedAt) : null;
|
|
366
359
|
const infrastructure = readinessOverride(input.readiness, 'infrastructureResponsibility');
|
|
367
360
|
const authority = readinessOverride(input.readiness, 'authorityReview');
|
|
@@ -392,7 +385,10 @@ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, recipi
|
|
|
392
385
|
{
|
|
393
386
|
id: 'capture_provenance', label: 'Capture provenance',
|
|
394
387
|
state: !selectedEvidence.length ? 'unavailable' : selectedEvidence.every((item) => item.source && item.observedAt) ? 'complete' : 'partial',
|
|
395
|
-
detail: selectedEvidence.length ? '
|
|
388
|
+
detail: !selectedEvidence.length ? 'No selected evidence is available for capture-provenance review.'
|
|
389
|
+
: selectedEvidence.every((item) => item.source && item.observedAt)
|
|
390
|
+
? 'Selected evidence retains source and observation metadata.'
|
|
391
|
+
: 'Selected evidence is retained, but some source or observation-time metadata is unavailable.',
|
|
396
392
|
requiredForAuthorisation: true,
|
|
397
393
|
limitations: selectedEvidence.flatMap((item) => item.limitations).slice(0, 8),
|
|
398
394
|
},
|
|
@@ -462,7 +458,7 @@ export function buildCaseResponseReadiness(caseRecord, input, generatedAt = new
|
|
|
462
458
|
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
463
459
|
const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
|
|
464
460
|
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
465
|
-
return buildResponseReadiness(
|
|
461
|
+
return buildResponseReadiness(input, normalizedGeneratedAt, binding.contacts, binding.recipientRoute, selectedEvidence, normalizeContradictions(caseRecord));
|
|
466
462
|
}
|
|
467
463
|
function normalizeArtefactReferences(value) {
|
|
468
464
|
if (!Array.isArray(value))
|
|
@@ -509,7 +505,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
|
|
|
509
505
|
const binding = bindPacketRoute(caseRecord, input, caseRecord.updatedAt);
|
|
510
506
|
const missingEvidence = [
|
|
511
507
|
...(!normalizeUrls(input.abusiveUrls).length ? ['At least one exact HTTP(S) URL'] : []),
|
|
512
|
-
...(!timestamp(input.observedAt)
|
|
508
|
+
...(!timestamp(input.observedAt) ? ['Observation time'] : []),
|
|
513
509
|
...(!normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds).length ? ['Explicitly selected evidence pin'] : []),
|
|
514
510
|
...(profile.id !== 'internal_soc' && binding.actionBinding.state !== 'selected'
|
|
515
511
|
? ['Selected retained Case action']
|
|
@@ -535,8 +531,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
|
|
|
535
531
|
};
|
|
536
532
|
}
|
|
537
533
|
export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
538
|
-
const
|
|
539
|
-
const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
|
|
534
|
+
const observedAt = timestamp(input.observedAt);
|
|
540
535
|
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
541
536
|
const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
|
|
542
537
|
const urls = normalizeUrls(input.abusiveUrls);
|
|
@@ -553,7 +548,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
553
548
|
const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
|
|
554
549
|
const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
|
|
555
550
|
const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
|
|
556
|
-
const responseDisposition =
|
|
551
|
+
const responseDisposition = caseDispositionSupportsDefensiveResponse(caseRecord.disposition);
|
|
557
552
|
const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
|
|
558
553
|
const profile = responsePacketProfile(input.profile);
|
|
559
554
|
const checks = [
|
|
@@ -607,6 +602,8 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
607
602
|
: 'The browser or blocklist profile requires a manually reviewed submission destination recorded as a Case action.'
|
|
608
603
|
: `${profile.label} has no fixed external contact-kind requirement.`,
|
|
609
604
|
},
|
|
605
|
+
// Packet v9 retains its established machine-token wording for immutable
|
|
606
|
+
// fixture compatibility; interactive Case surfaces use the canonical label.
|
|
610
607
|
{
|
|
611
608
|
id: 'case_disposition',
|
|
612
609
|
label: 'Case disposition',
|
|
@@ -681,6 +678,7 @@ function normalizeActionHistory(caseRecord, input) {
|
|
|
681
678
|
recipient: text(action.recipient, 320),
|
|
682
679
|
contactSource: text(action.contactSource, 120),
|
|
683
680
|
routeObservedAt: timestamp(action.routeObservedAt),
|
|
681
|
+
routeReviewAfter: timestamp(action.routeReviewAfter),
|
|
684
682
|
state: text(action.state, 80),
|
|
685
683
|
reference: text(action.reference, 500) || null,
|
|
686
684
|
providerOutcome: text(action.providerOutcome, 80) || null,
|
|
@@ -751,6 +749,7 @@ function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
|
|
|
751
749
|
'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
|
|
752
750
|
'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
|
|
753
751
|
'Times are withheld when the corresponding typed event is missing or ambiguous.',
|
|
752
|
+
...(!summary.latestObservedEffect && scopedRecord.observedEffects.reviews.length ? ['A single latest independent review cannot be selected from the retained observation times; all retained reviews remain separately attributed.'] : []),
|
|
754
753
|
...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
|
|
755
754
|
],
|
|
756
755
|
};
|
|
@@ -779,7 +778,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
|
779
778
|
const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
|
|
780
779
|
const contradictions = normalizeContradictions(caseRecord);
|
|
781
780
|
const binding = bindPacketRoute(caseRecord, input, generatedAt);
|
|
782
|
-
const readiness = buildResponseReadiness(
|
|
781
|
+
const readiness = buildResponseReadiness(input, generatedAt, binding.contacts, binding.recipientRoute, selectedEvidence, contradictions);
|
|
783
782
|
const escalation = normalizeActionHistory(caseRecord, input);
|
|
784
783
|
return {
|
|
785
784
|
contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
|
|
@@ -804,9 +803,9 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
|
804
803
|
incident: {
|
|
805
804
|
category,
|
|
806
805
|
affectedParty: text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH),
|
|
807
|
-
abusiveUrls: normalizeUrls(input.abusiveUrls),
|
|
806
|
+
abusiveUrls: normalizeUrls(input.abusiveUrls, true),
|
|
808
807
|
observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
|
|
809
|
-
observedAt: timestamp(input.observedAt)
|
|
808
|
+
observedAt: timestamp(input.observedAt),
|
|
810
809
|
},
|
|
811
810
|
contacts: binding.contacts,
|
|
812
811
|
recipientRoute: binding.recipientRoute,
|
|
@@ -821,348 +820,6 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
|
821
820
|
responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.actionBinding.lineageActionIds, escalation.lineageComplete),
|
|
822
821
|
};
|
|
823
822
|
}
|
|
824
|
-
const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
|
|
825
|
-
'contract',
|
|
826
|
-
'version',
|
|
827
|
-
'profile',
|
|
828
|
-
'case',
|
|
829
|
-
'incident',
|
|
830
|
-
'contacts',
|
|
831
|
-
'recipientRoute',
|
|
832
|
-
'actionBinding',
|
|
833
|
-
'selectedEvidence',
|
|
834
|
-
'contradictions',
|
|
835
|
-
'readiness',
|
|
836
|
-
'artefactReferences',
|
|
837
|
-
'escalationHistory',
|
|
838
|
-
'escalationHistoryOmitted',
|
|
839
|
-
'escalationHistoryLimitations',
|
|
840
|
-
'responseLifecycle',
|
|
841
|
-
]);
|
|
842
|
-
const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze(CASE_RESPONSE_REVIEW_INPUT_KEYS.filter((key) => key !== 'recipientRoute' && key !== 'actionBinding'));
|
|
843
|
-
function exactReviewRecord(value, keys, label) {
|
|
844
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
845
|
-
throw new TypeError(`${label} must be an exact object.`);
|
|
846
|
-
}
|
|
847
|
-
const ownKeys = Reflect.ownKeys(value);
|
|
848
|
-
if (ownKeys.length !== keys.length
|
|
849
|
-
|| ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key))) {
|
|
850
|
-
throw new TypeError(`${label} contains an undeclared field.`);
|
|
851
|
-
}
|
|
852
|
-
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
853
|
-
if (keys.some((key) => !descriptors[key] || !Object.hasOwn(descriptors[key], 'value'))) {
|
|
854
|
-
throw new TypeError(`${label} must not contain accessors.`);
|
|
855
|
-
}
|
|
856
|
-
return value;
|
|
857
|
-
}
|
|
858
|
-
function boundedReviewArray(value, maximum, label) {
|
|
859
|
-
if (!Array.isArray(value) || value.length > maximum) {
|
|
860
|
-
throw new TypeError(`${label} exceeds its declared array bound.`);
|
|
861
|
-
}
|
|
862
|
-
const ownKeys = Reflect.ownKeys(value).filter((key) => key !== 'length');
|
|
863
|
-
if (ownKeys.length !== value.length
|
|
864
|
-
|| ownKeys.some((key, index) => key !== String(index))) {
|
|
865
|
-
throw new TypeError(`${label} must be a dense ordinary array.`);
|
|
866
|
-
}
|
|
867
|
-
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
868
|
-
if (ownKeys.some((key) => typeof key !== 'string' || !Object.hasOwn(descriptors[key], 'value'))) {
|
|
869
|
-
throw new TypeError(`${label} must not contain accessors.`);
|
|
870
|
-
}
|
|
871
|
-
return value;
|
|
872
|
-
}
|
|
873
|
-
function reviewText(value, maximum, label, nullable = false) {
|
|
874
|
-
if (nullable && value === null)
|
|
875
|
-
return null;
|
|
876
|
-
if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value)) {
|
|
877
|
-
throw new TypeError(`${label} is not a bounded text value.`);
|
|
878
|
-
}
|
|
879
|
-
return value;
|
|
880
|
-
}
|
|
881
|
-
function reviewStrings(value, maximum, itemMaximum, label) {
|
|
882
|
-
const items = boundedReviewArray(value, maximum, label);
|
|
883
|
-
for (const item of items)
|
|
884
|
-
reviewText(item, itemMaximum, `${label} item`);
|
|
885
|
-
return items;
|
|
886
|
-
}
|
|
887
|
-
function reviewCount(value, maximum, label) {
|
|
888
|
-
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
|
889
|
-
throw new TypeError(`${label} is not a bounded count.`);
|
|
890
|
-
}
|
|
891
|
-
return value;
|
|
892
|
-
}
|
|
893
|
-
function reviewEnum(value, values, label) {
|
|
894
|
-
if (typeof value !== 'string' || !values.includes(value)) {
|
|
895
|
-
throw new TypeError(`${label} contains an unsupported value.`);
|
|
896
|
-
}
|
|
897
|
-
return value;
|
|
898
|
-
}
|
|
899
|
-
function reviewNullableEnum(value, values, label) {
|
|
900
|
-
return value === null ? null : reviewEnum(value, values, label);
|
|
901
|
-
}
|
|
902
|
-
function validateReviewLifecycle(value) {
|
|
903
|
-
const lifecycle = exactReviewRecord(value, [
|
|
904
|
-
'providerOutcomeState', 'latestProviderOutcome', 'observedChangeState',
|
|
905
|
-
'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations',
|
|
906
|
-
], 'Case-response review lifecycle');
|
|
907
|
-
reviewEnum(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response provider-outcome state');
|
|
908
|
-
reviewEnum(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response observed-change state');
|
|
909
|
-
reviewText(lifecycle.latestObservedChangeAt, 64, 'Case-response latest observed change', true);
|
|
910
|
-
reviewStrings(lifecycle.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response lifecycle limitations');
|
|
911
|
-
if (lifecycle.latestProviderOutcome !== null) {
|
|
912
|
-
const outcome = exactReviewRecord(lifecycle.latestProviderOutcome, [
|
|
913
|
-
'actionId', 'eventId', 'outcome', 'occurredAt', 'reference',
|
|
914
|
-
], 'Case-response latest provider outcome');
|
|
915
|
-
reviewText(outcome.actionId, 64, 'Case-response provider action id');
|
|
916
|
-
reviewText(outcome.eventId, 64, 'Case-response provider event id');
|
|
917
|
-
reviewEnum(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
|
|
918
|
-
reviewText(outcome.occurredAt, 64, 'Case-response provider outcome time');
|
|
919
|
-
reviewText(outcome.reference, MAX_RESPONSE_REFERENCE_LENGTH, 'Case-response provider reference', true);
|
|
920
|
-
}
|
|
921
|
-
if (lifecycle.latestObservedEffect !== null) {
|
|
922
|
-
const effect = exactReviewRecord(lifecycle.latestObservedEffect, [
|
|
923
|
-
'reviewId', 'state', 'observedAt', 'sourceClass', 'source',
|
|
924
|
-
], 'Case-response latest observed effect');
|
|
925
|
-
reviewText(effect.reviewId, 64, 'Case-response observed-effect review id');
|
|
926
|
-
reviewEnum(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response observed-effect state');
|
|
927
|
-
reviewText(effect.observedAt, 64, 'Case-response observed-effect time');
|
|
928
|
-
reviewEnum(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response observed-effect source class');
|
|
929
|
-
reviewText(effect.source, MAX_RESPONSE_VALUE_LENGTH, 'Case-response observed-effect source');
|
|
930
|
-
}
|
|
931
|
-
if (lifecycle.closure !== null) {
|
|
932
|
-
const closure = exactReviewRecord(lifecycle.closure, [
|
|
933
|
-
'id', 'reason', 'createdAt', 'limitations',
|
|
934
|
-
], 'Case-response latest closure');
|
|
935
|
-
reviewText(closure.id, 64, 'Case-response closure id');
|
|
936
|
-
reviewEnum(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
|
|
937
|
-
reviewText(closure.createdAt, 64, 'Case-response closure time');
|
|
938
|
-
reviewStrings(closure.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response closure limitations');
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
function recursivelyFreezeReviewValue(value) {
|
|
942
|
-
if (!value || typeof value !== 'object' || Object.isFrozen(value))
|
|
943
|
-
return value;
|
|
944
|
-
for (const child of Object.values(value)) {
|
|
945
|
-
recursivelyFreezeReviewValue(child);
|
|
946
|
-
}
|
|
947
|
-
return Object.freeze(value);
|
|
948
|
-
}
|
|
949
|
-
export function validateCaseResponseReviewInputs(value) {
|
|
950
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
951
|
-
throw new TypeError('Case-response review inputs must be an exact object.');
|
|
952
|
-
}
|
|
953
|
-
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
954
|
-
if (!descriptors.contract || !Object.hasOwn(descriptors.contract, 'value')
|
|
955
|
-
|| !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
|
|
956
|
-
throw new TypeError('Case-response review inputs must not contain accessors.');
|
|
957
|
-
}
|
|
958
|
-
const current = descriptors.version.value === CASE_RESPONSE_REVIEW_INPUTS_VERSION;
|
|
959
|
-
const publishedV2 = descriptors.version.value === PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
|
|
960
|
-
if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA || (!current && !publishedV2)) {
|
|
961
|
-
throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
|
|
962
|
-
}
|
|
963
|
-
const source = exactReviewRecord(value, current ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
|
|
964
|
-
const profile = exactReviewRecord(source.profile, [
|
|
965
|
-
'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
|
|
966
|
-
'excludedEvidence', 'redactions',
|
|
967
|
-
], 'Case-response review profile');
|
|
968
|
-
if (typeof profile.id !== 'string' || !RESPONSE_PROFILE_IDS.has(profile.id)) {
|
|
969
|
-
throw new TypeError('Case-response review profile is unsupported.');
|
|
970
|
-
}
|
|
971
|
-
for (const key of ['label', 'audience', 'subject']) {
|
|
972
|
-
reviewText(profile[key], 500, `Case-response profile ${key}`);
|
|
973
|
-
}
|
|
974
|
-
for (const key of ['checklist', 'includedEvidence', 'excludedEvidence', 'redactions']) {
|
|
975
|
-
reviewStrings(profile[key], 32, 500, `Case-response profile ${key}`);
|
|
976
|
-
}
|
|
977
|
-
const caseRecord = exactReviewRecord(source.case, [
|
|
978
|
-
'id', 'domain', 'status', 'disposition', 'updatedAt',
|
|
979
|
-
], 'Case-response review Case');
|
|
980
|
-
reviewText(caseRecord.id, 64, 'Case-response Case id');
|
|
981
|
-
reviewText(caseRecord.domain, 253, 'Case-response Case domain');
|
|
982
|
-
reviewText(caseRecord.status, 64, 'Case-response Case status');
|
|
983
|
-
reviewText(caseRecord.disposition, 64, 'Case-response Case disposition');
|
|
984
|
-
reviewText(caseRecord.updatedAt, 64, 'Case-response Case update time');
|
|
985
|
-
const incident = exactReviewRecord(source.incident, [
|
|
986
|
-
'category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt',
|
|
987
|
-
], 'Case-response review incident');
|
|
988
|
-
reviewText(incident.category, MAX_ABUSE_CATEGORY_LENGTH, 'Case-response category');
|
|
989
|
-
reviewText(incident.affectedParty, MAX_AFFECTED_PARTY_LENGTH, 'Case-response affected party');
|
|
990
|
-
reviewStrings(incident.abusiveUrls, MAX_ABUSIVE_URLS, MAX_EXACT_URL_LENGTH, 'Case-response abusive URLs');
|
|
991
|
-
reviewText(incident.observedHarm, MAX_RESPONSE_HARM_LENGTH, 'Case-response observed harm');
|
|
992
|
-
reviewText(incident.observedAt, 64, 'Case-response observation time', true);
|
|
993
|
-
for (const candidate of boundedReviewArray(source.contacts, MAX_RESPONSE_CONTACTS, 'Case-response contacts')) {
|
|
994
|
-
const contact = exactReviewRecord(candidate, [
|
|
995
|
-
'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
|
|
996
|
-
], 'Case-response contact');
|
|
997
|
-
if (typeof contact.kind !== 'string' || !CONTACT_KINDS.has(contact.kind)
|
|
998
|
-
|| typeof contact.freshness !== 'string'
|
|
999
|
-
|| !['current', 'stale', 'unknown'].includes(contact.freshness)) {
|
|
1000
|
-
throw new TypeError('Case-response contact contains an unsupported enum.');
|
|
1001
|
-
}
|
|
1002
|
-
reviewText(contact.contact, 320, 'Case-response contact value');
|
|
1003
|
-
reviewText(contact.source, 120, 'Case-response contact source');
|
|
1004
|
-
reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
|
|
1005
|
-
reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
|
|
1006
|
-
}
|
|
1007
|
-
if (current && source.recipientRoute !== null) {
|
|
1008
|
-
const route = exactReviewRecord(source.recipientRoute, [
|
|
1009
|
-
'actionId', 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
|
|
1010
|
-
], 'Case-response recipient route');
|
|
1011
|
-
reviewText(route.actionId, 64, 'Case-response recipient action id');
|
|
1012
|
-
reviewEnum(route.kind, [...RESPONSE_CONTACT_KINDS, 'manual'], 'Case-response recipient kind');
|
|
1013
|
-
reviewText(route.contact, 320, 'Case-response recipient value');
|
|
1014
|
-
reviewText(route.source, 120, 'Case-response recipient source');
|
|
1015
|
-
reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
|
|
1016
|
-
reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
|
|
1017
|
-
reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
|
|
1018
|
-
}
|
|
1019
|
-
let currentLineageActionIds = null;
|
|
1020
|
-
if (current) {
|
|
1021
|
-
const actionBinding = exactReviewRecord(source.actionBinding, [
|
|
1022
|
-
'state', 'selectedActionId', 'lineageActionIds', 'limitations',
|
|
1023
|
-
], 'Case-response action binding');
|
|
1024
|
-
const bindingState = reviewEnum(actionBinding.state, ['selected', 'not_selected'], 'Case-response action binding state');
|
|
1025
|
-
const selectedActionId = reviewText(actionBinding.selectedActionId, 64, 'Case-response selected action id', true);
|
|
1026
|
-
const lineageActionIds = reviewStrings(actionBinding.lineageActionIds, MAX_RESPONSE_ACTION_HISTORY, 64, 'Case-response action lineage');
|
|
1027
|
-
currentLineageActionIds = lineageActionIds;
|
|
1028
|
-
if ((bindingState === 'selected') !== Boolean(selectedActionId)
|
|
1029
|
-
|| (selectedActionId !== null && lineageActionIds[0] !== selectedActionId)
|
|
1030
|
-
|| new Set(lineageActionIds).size !== lineageActionIds.length) {
|
|
1031
|
-
throw new TypeError('Case-response action binding is inconsistent.');
|
|
1032
|
-
}
|
|
1033
|
-
reviewStrings(actionBinding.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action-binding limitations');
|
|
1034
|
-
if (source.recipientRoute !== null
|
|
1035
|
-
&& source.recipientRoute.actionId !== selectedActionId) {
|
|
1036
|
-
throw new TypeError('Case-response recipient route is not bound to the selected action.');
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
|
|
1040
|
-
const evidence = exactReviewRecord(candidate, [
|
|
1041
|
-
'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
|
|
1042
|
-
], 'Case-response selected evidence item');
|
|
1043
|
-
reviewText(evidence.id, 64, 'Case-response evidence id');
|
|
1044
|
-
reviewText(evidence.label, 80, 'Case-response evidence label');
|
|
1045
|
-
reviewText(evidence.source, 120, 'Case-response evidence source');
|
|
1046
|
-
reviewText(evidence.observedAt, 64, 'Case-response evidence observation time');
|
|
1047
|
-
reviewEnum(evidence.completeness, CASE_PIN_COMPLETENESS, 'Case-response evidence completeness');
|
|
1048
|
-
reviewStrings(evidence.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response evidence limitations');
|
|
1049
|
-
}
|
|
1050
|
-
for (const candidate of boundedReviewArray(source.contradictions, MAX_RESPONSE_CONTRADICTIONS, 'Case-response contradictions')) {
|
|
1051
|
-
const contradiction = exactReviewRecord(candidate, [
|
|
1052
|
-
'id', 'statement', 'state', 'limitations',
|
|
1053
|
-
], 'Case-response contradiction');
|
|
1054
|
-
reviewText(contradiction.id, 64, 'Case-response contradiction id');
|
|
1055
|
-
reviewText(contradiction.statement, 2_000, 'Case-response contradiction statement');
|
|
1056
|
-
reviewEnum(contradiction.state, CASE_ASSERTION_STATES, 'Case-response contradiction state');
|
|
1057
|
-
reviewStrings(contradiction.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contradiction limitations');
|
|
1058
|
-
}
|
|
1059
|
-
const readiness = exactReviewRecord(source.readiness, [
|
|
1060
|
-
'profileId', 'rows', 'counts', 'limitations',
|
|
1061
|
-
], 'Case-response readiness');
|
|
1062
|
-
if (typeof readiness.profileId !== 'string' || !RESPONSE_PROFILE_IDS.has(readiness.profileId)) {
|
|
1063
|
-
throw new TypeError('Case-response readiness profile is unsupported.');
|
|
1064
|
-
}
|
|
1065
|
-
const readinessRows = boundedReviewArray(readiness.rows, RESPONSE_READINESS_ROW_IDS.length, 'Case-response readiness rows');
|
|
1066
|
-
const readinessIds = new Set();
|
|
1067
|
-
const readinessCounts = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
|
|
1068
|
-
for (const candidate of readinessRows) {
|
|
1069
|
-
const row = exactReviewRecord(candidate, [
|
|
1070
|
-
'id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations',
|
|
1071
|
-
], 'Case-response readiness row');
|
|
1072
|
-
if (typeof row.id !== 'string'
|
|
1073
|
-
|| !RESPONSE_READINESS_ROW_IDS.includes(row.id)
|
|
1074
|
-
|| readinessIds.has(row.id)
|
|
1075
|
-
|| typeof row.state !== 'string'
|
|
1076
|
-
|| !RESPONSE_READINESS_STATES.includes(row.state)
|
|
1077
|
-
|| typeof row.requiredForAuthorisation !== 'boolean') {
|
|
1078
|
-
throw new TypeError('Case-response readiness row is unsupported or duplicated.');
|
|
1079
|
-
}
|
|
1080
|
-
readinessIds.add(row.id);
|
|
1081
|
-
readinessCounts[row.state] += 1;
|
|
1082
|
-
reviewText(row.label, 120, 'Case-response readiness label');
|
|
1083
|
-
reviewText(row.detail, 500, 'Case-response readiness detail');
|
|
1084
|
-
reviewStrings(row.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
|
|
1085
|
-
}
|
|
1086
|
-
const counts = exactReviewRecord(readiness.counts, RESPONSE_READINESS_STATES, 'Case-response readiness counts');
|
|
1087
|
-
for (const state of RESPONSE_READINESS_STATES) {
|
|
1088
|
-
if (reviewCount(counts[state], RESPONSE_READINESS_ROW_IDS.length, `Case-response readiness ${state} count`) !== readinessCounts[state]) {
|
|
1089
|
-
throw new TypeError('Case-response readiness counts do not match the exact rows.');
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
reviewStrings(readiness.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
|
|
1093
|
-
for (const candidate of boundedReviewArray(source.artefactReferences, MAX_RESPONSE_ARTEFACT_REFERENCES, 'Case-response artefact references')) {
|
|
1094
|
-
const reference = exactReviewRecord(candidate, [
|
|
1095
|
-
'id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256',
|
|
1096
|
-
'byteLength', 'limitations',
|
|
1097
|
-
], 'Case-response artefact reference');
|
|
1098
|
-
reviewText(reference.id, 64, 'Case-response artefact id');
|
|
1099
|
-
reviewText(reference.label, 120, 'Case-response artefact label');
|
|
1100
|
-
reviewText(reference.mediaType, 120, 'Case-response artefact media type');
|
|
1101
|
-
reviewText(reference.capturedAt, 64, 'Case-response artefact capture time');
|
|
1102
|
-
reviewText(reference.source, 120, 'Case-response artefact source');
|
|
1103
|
-
if (typeof reference.digestSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(reference.digestSha256)) {
|
|
1104
|
-
throw new TypeError('Case-response artefact digest is invalid.');
|
|
1105
|
-
}
|
|
1106
|
-
if (reference.byteLength !== null)
|
|
1107
|
-
reviewCount(reference.byteLength, 100 * 1024 * 1024, 'Case-response artefact byte length');
|
|
1108
|
-
reviewStrings(reference.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response artefact limitations');
|
|
1109
|
-
}
|
|
1110
|
-
const escalationActionIds = [];
|
|
1111
|
-
for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
|
|
1112
|
-
const action = exactReviewRecord(candidate, [
|
|
1113
|
-
'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
|
|
1114
|
-
...(current ? ['routeObservedAt'] : []),
|
|
1115
|
-
'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
|
|
1116
|
-
'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
|
|
1117
|
-
], 'Case-response escalation action');
|
|
1118
|
-
for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
|
|
1119
|
-
reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
|
|
1120
|
-
}
|
|
1121
|
-
if (escalationActionIds.includes(action.actionId)) {
|
|
1122
|
-
throw new TypeError('Case-response escalation action identities must be unique.');
|
|
1123
|
-
}
|
|
1124
|
-
escalationActionIds.push(action.actionId);
|
|
1125
|
-
if (current)
|
|
1126
|
-
reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
|
|
1127
|
-
reviewEnum(action.type, CASE_ACTION_TYPES, 'Case-response action type');
|
|
1128
|
-
reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
|
|
1129
|
-
for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
|
|
1130
|
-
reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`, true);
|
|
1131
|
-
}
|
|
1132
|
-
reviewNullableEnum(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action provider outcome');
|
|
1133
|
-
reviewCount(action.historyOmitted, MAX_CASE_ACTION_EVENTS_PER_CASE, 'Case-response omitted transition count');
|
|
1134
|
-
reviewStrings(action.historyLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action history limitations');
|
|
1135
|
-
for (const candidateTransition of boundedReviewArray(action.transitions, MAX_CASE_ACTION_EVENTS_PER_ACTION, 'Case-response transitions')) {
|
|
1136
|
-
const transition = exactReviewRecord(candidateTransition, [
|
|
1137
|
-
'id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance',
|
|
1138
|
-
'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail',
|
|
1139
|
-
'originActionId', 'applied',
|
|
1140
|
-
], 'Case-response transition');
|
|
1141
|
-
for (const key of ['id', 'occurredAt', 'provenance']) {
|
|
1142
|
-
reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`);
|
|
1143
|
-
}
|
|
1144
|
-
reviewNullableEnum(transition.previousState, CASE_ACTION_STATES, 'Case-response previous action state');
|
|
1145
|
-
reviewEnum(transition.nextState, CASE_ACTION_STATES, 'Case-response next action state');
|
|
1146
|
-
reviewEnum(transition.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response transition source class');
|
|
1147
|
-
for (const key of ['reference', 'evidencePinId', 'outcomeDetail', 'originActionId']) {
|
|
1148
|
-
reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`, true);
|
|
1149
|
-
}
|
|
1150
|
-
reviewNullableEnum(transition.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response transition provider outcome');
|
|
1151
|
-
if (typeof transition.applied !== 'boolean')
|
|
1152
|
-
throw new TypeError('Case-response transition applied state is invalid.');
|
|
1153
|
-
reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
if (currentLineageActionIds
|
|
1157
|
-
&& (currentLineageActionIds.length !== escalationActionIds.length
|
|
1158
|
-
|| escalationActionIds.some((id, index) => currentLineageActionIds?.[index] !== id))) {
|
|
1159
|
-
throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
|
|
1160
|
-
}
|
|
1161
|
-
reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
|
|
1162
|
-
reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
|
|
1163
|
-
validateReviewLifecycle(source.responseLifecycle);
|
|
1164
|
-
return recursivelyFreezeReviewValue(structuredClone(source));
|
|
1165
|
-
}
|
|
1166
823
|
export async function buildCaseResponseReviewDigest(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
1167
824
|
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
1168
825
|
return sha256(canonicalArtifactJsonV2(validateCaseResponseReviewInputs(buildCaseResponseReviewInputs(caseRecord, input, normalizedGeneratedAt))));
|
|
@@ -1232,25 +889,13 @@ export async function verifyCaseResponsePacketIntegrity(packet) {
|
|
|
1232
889
|
}
|
|
1233
890
|
return integrity.digestSha256 === await sha256(canonicalArtifactJsonV2(unsigned));
|
|
1234
891
|
}
|
|
1235
|
-
function escapeMarkdown(value) {
|
|
1236
|
-
return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
|
|
1237
|
-
}
|
|
1238
|
-
function contactLabel(value) {
|
|
1239
|
-
if (value === 'network_hosting')
|
|
1240
|
-
return 'Observed endpoint network registration';
|
|
1241
|
-
if (value === 'security_txt')
|
|
1242
|
-
return 'security.txt';
|
|
1243
|
-
if (value === 'manual')
|
|
1244
|
-
return 'Manual submission';
|
|
1245
|
-
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1246
|
-
}
|
|
1247
892
|
export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
1248
893
|
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
|
|
1249
894
|
const affectedParty = text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH);
|
|
1250
|
-
const abusiveUrls = normalizeUrls(input.abusiveUrls);
|
|
895
|
+
const abusiveUrls = normalizeUrls(input.abusiveUrls, true);
|
|
1251
896
|
const observedHarm = text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH);
|
|
1252
|
-
const latestEvidence = caseRecord
|
|
1253
|
-
const observedAt = timestamp(input.observedAt)
|
|
897
|
+
const latestEvidence = latestCaseEvidence(caseRecord);
|
|
898
|
+
const observedAt = timestamp(input.observedAt);
|
|
1254
899
|
if (!category || !affectedParty || !abusiveUrls.length || !observedHarm || !observedAt) {
|
|
1255
900
|
throw new Error('Category, affected party, at least one exact HTTP(S) URL, observed harm, and an observation time are required.');
|
|
1256
901
|
}
|
|
@@ -1345,150 +990,7 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1345
990
|
digestSha256,
|
|
1346
991
|
},
|
|
1347
992
|
};
|
|
1348
|
-
|
|
1349
|
-
`# ${escapeMarkdown(profile.label)} packet`,
|
|
1350
|
-
'',
|
|
1351
|
-
`**Domain:** ${escapeMarkdown(caseRecord.domain)}`,
|
|
1352
|
-
`**Category:** ${escapeMarkdown(category)}`,
|
|
1353
|
-
`**Affected party:** ${escapeMarkdown(affectedParty)}`,
|
|
1354
|
-
`**Observed at (UTC):** ${observedAt}`,
|
|
1355
|
-
`**Generated at (UTC):** ${normalizedGeneratedAt}`,
|
|
1356
|
-
`**Audience:** ${escapeMarkdown(profile.audience)}`,
|
|
1357
|
-
`**Suggested subject:** ${escapeMarkdown(profile.subject)}`,
|
|
1358
|
-
'',
|
|
1359
|
-
'## Observed harm',
|
|
1360
|
-
'',
|
|
1361
|
-
escapeMarkdown(observedHarm),
|
|
1362
|
-
'',
|
|
1363
|
-
'## Exact abusive URLs',
|
|
1364
|
-
'',
|
|
1365
|
-
...abusiveUrls.map((url) => `- ${escapeMarkdown(url)}`),
|
|
1366
|
-
'',
|
|
1367
|
-
'## Selected response route',
|
|
1368
|
-
'',
|
|
1369
|
-
...(recipientRoute
|
|
1370
|
-
? [
|
|
1371
|
-
`### ${contactLabel(recipientRoute.kind)}`,
|
|
1372
|
-
'',
|
|
1373
|
-
`- Case action: ${escapeMarkdown(recipientRoute.actionId)}`,
|
|
1374
|
-
`- Contact: ${escapeMarkdown(recipientRoute.contact)}`,
|
|
1375
|
-
`- Source: ${escapeMarkdown(recipientRoute.source)}`,
|
|
1376
|
-
`- Route observed: ${recipientRoute.observedAt ?? 'Not provided'} (${recipientRoute.freshness})`,
|
|
1377
|
-
`- Limitations: ${recipientRoute.limitations.length ? recipientRoute.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
|
|
1378
|
-
'',
|
|
1379
|
-
]
|
|
1380
|
-
: ['No profile-appropriate response route was bound to this packet.', '']),
|
|
1381
|
-
'## Selected action lineage',
|
|
1382
|
-
'',
|
|
1383
|
-
...(escalationHistory.length
|
|
1384
|
-
? escalationHistory.flatMap((action) => [
|
|
1385
|
-
`- ${escapeMarkdown(action.type.replaceAll('_', ' '))} to ${escapeMarkdown(action.recipient)} · ${escapeMarkdown(action.state.replaceAll('_', ' '))} · updated ${action.updatedAt}`,
|
|
1386
|
-
` - Route observed: ${action.routeObservedAt ?? 'Not provided'}`,
|
|
1387
|
-
...(action.reference ? [` - Reference: ${escapeMarkdown(action.reference)}`] : []),
|
|
1388
|
-
...(action.providerOutcome ? [` - Typed provider outcome: ${escapeMarkdown(action.providerOutcome.replaceAll('_', ' '))}`] : []),
|
|
1389
|
-
...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
|
|
1390
|
-
...(action.originActionId ? [` - Originating action: ${escapeMarkdown(action.originActionId)}`] : []),
|
|
1391
|
-
...action.transitions.flatMap((event) => [
|
|
1392
|
-
` - ${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'}`,
|
|
1393
|
-
...(event.reference ? [` - Reference: ${escapeMarkdown(event.reference)}`] : []),
|
|
1394
|
-
...(event.evidencePinId ? [` - Evidence pin: ${escapeMarkdown(event.evidencePinId)}`] : []),
|
|
1395
|
-
...(event.originActionId ? [` - Originating action: ${escapeMarkdown(event.originActionId)}`] : []),
|
|
1396
|
-
...event.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
|
|
1397
|
-
]),
|
|
1398
|
-
...(action.historyOmitted ? [` - ${action.historyOmitted} earlier transition event${action.historyOmitted === 1 ? '' : 's'} omitted by bound.`] : []),
|
|
1399
|
-
...action.historyLimitations.map((limitation) => ` - History limitation: ${escapeMarkdown(limitation)}`),
|
|
1400
|
-
])
|
|
1401
|
-
: ['No Case action was selected.']),
|
|
1402
|
-
...(escalationHistoryOmitted ? [`- Unrelated Case actions excluded from packet: ${escalationHistoryOmitted}`] : []),
|
|
1403
|
-
...escalationHistoryLimitations.map((limitation) => `- Packet history limitation: ${escapeMarkdown(limitation)}`),
|
|
1404
|
-
'',
|
|
1405
|
-
'## Readiness and authorisation',
|
|
1406
|
-
'',
|
|
1407
|
-
`- Packet state: ${authorisation.status}`,
|
|
1408
|
-
`- Reviewed-input SHA-256: ${authorisation.reviewedInputDigestSha256}`,
|
|
1409
|
-
`- Supplied review digest matches: ${authorisation.digestMatches ? 'yes' : 'no'}`,
|
|
1410
|
-
...RESPONSE_AUTHORISATION_CONFIRMATION_IDS.map((id) => `- Confirmation ${id}: ${authorisation.confirmations[id] ? 'yes' : 'no'}`),
|
|
1411
|
-
...readiness.rows.flatMap((row) => [
|
|
1412
|
-
`- ${escapeMarkdown(row.label)} [${row.state}]: ${escapeMarkdown(row.detail)}`,
|
|
1413
|
-
...row.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
|
|
1414
|
-
]),
|
|
1415
|
-
...authorisation.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
1416
|
-
'',
|
|
1417
|
-
'## Selected evidence and integrity references',
|
|
1418
|
-
'',
|
|
1419
|
-
...(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.']),
|
|
1420
|
-
...artefactReferences.map((item) => `- ${escapeMarkdown(item.id)} · ${escapeMarkdown(item.label)} · SHA-256 ${item.digestSha256} · captured ${item.capturedAt}`),
|
|
1421
|
-
'',
|
|
1422
|
-
'## Provider outcome and independent effect',
|
|
1423
|
-
'',
|
|
1424
|
-
responseLifecycle.latestProviderOutcome
|
|
1425
|
-
? `- Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${escapeMarkdown(responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' '))})`
|
|
1426
|
-
: `- Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
|
|
1427
|
-
responseLifecycle.latestObservedChangeAt
|
|
1428
|
-
? `- Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
|
|
1429
|
-
: `- Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
|
|
1430
|
-
...(responseLifecycle.latestObservedEffect ? [`- Latest independent review: ${escapeMarkdown(responseLifecycle.latestObservedEffect.state.replaceAll('_', ' '))} · ${responseLifecycle.latestObservedEffect.observedAt} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.source)}`] : []),
|
|
1431
|
-
...responseLifecycle.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
1432
|
-
'',
|
|
1433
|
-
'## Review and provenance',
|
|
1434
|
-
'',
|
|
1435
|
-
`- Preflight: ${preflight.status.replaceAll('_', ' ')} (${preflight.counts.pass} pass, ${preflight.counts.caution} caution, ${preflight.counts.block} block)`,
|
|
1436
|
-
...preflight.checks.map((check) => `- ${escapeMarkdown(check.label)} [${check.state}]: ${escapeMarkdown(check.detail)}`),
|
|
1437
|
-
...limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
1438
|
-
`- Case evidence pins: ${caseRecord.evidencePins.length}`,
|
|
1439
|
-
`- Case decision records: ${caseRecord.decisions.length}`,
|
|
1440
|
-
`- Case structured assertions: ${caseRecord.assertions.length}`,
|
|
1441
|
-
`- Observation-age band at export: ${age.band.replaceAll('_', ' ')}`,
|
|
1442
|
-
`- Canonical packet SHA-256: ${digestSha256}`,
|
|
1443
|
-
'- Digest scope: canonical sorted JSON packet excluding the integrity object',
|
|
1444
|
-
'',
|
|
1445
|
-
'## Audience profile',
|
|
1446
|
-
'',
|
|
1447
|
-
...profile.checklist.map((item) => `- Checklist: ${escapeMarkdown(item)}`),
|
|
1448
|
-
...profile.includedEvidence.map((item) => `- Included: ${escapeMarkdown(item)}`),
|
|
1449
|
-
...profile.excludedEvidence.map((item) => `- Excluded: ${escapeMarkdown(item)}`),
|
|
1450
|
-
...profile.redactions.map((item) => `- Redaction to confirm: ${escapeMarkdown(item)}`),
|
|
1451
|
-
...profile.attachments.map((item) => `- Attachment expectation: ${escapeMarkdown(item)}`),
|
|
1452
|
-
...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
|
|
1453
|
-
];
|
|
1454
|
-
const markdown = `${lines.join('\n').trim()}\n`;
|
|
1455
|
-
const email = [
|
|
1456
|
-
`Subject: ${profile.subject}`,
|
|
1457
|
-
'',
|
|
1458
|
-
'Hello,',
|
|
1459
|
-
'',
|
|
1460
|
-
`I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
|
|
1461
|
-
`Affected party: ${affectedParty}`,
|
|
1462
|
-
`Observed at (UTC): ${observedAt}`,
|
|
1463
|
-
'',
|
|
1464
|
-
'Observed harm:',
|
|
1465
|
-
observedHarm,
|
|
1466
|
-
'',
|
|
1467
|
-
'Exact URLs:',
|
|
1468
|
-
...abusiveUrls.map((url) => `- ${url}`),
|
|
1469
|
-
'',
|
|
1470
|
-
'Selected evidence:',
|
|
1471
|
-
...(selectedEvidence.length
|
|
1472
|
-
? selectedEvidence.map((item) => `- ${item.label} — ${item.source}, observed ${item.observedAt} (${item.completeness})`)
|
|
1473
|
-
: ['- No Case evidence pin was selected.']),
|
|
1474
|
-
'',
|
|
1475
|
-
`Reviewed packet SHA-256: ${digestSha256}`,
|
|
1476
|
-
'Attach the reviewed packet (and any separately reviewed evidence files) through the recipient’s approved submission channel; this message does not embed or transmit attachments.',
|
|
1477
|
-
'',
|
|
1478
|
-
responseLifecycle.latestProviderOutcome
|
|
1479
|
-
? `Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`
|
|
1480
|
-
: `Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
|
|
1481
|
-
responseLifecycle.latestObservedChangeAt
|
|
1482
|
-
? `Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
|
|
1483
|
-
: `Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
|
|
1484
|
-
'',
|
|
1485
|
-
'Please review this report under the applicable abuse and acceptable-use policies.',
|
|
1486
|
-
'',
|
|
1487
|
-
authorisation.status === 'authorised'
|
|
1488
|
-
? 'This locally prepared packet is bound to explicit review confirmations. It was not submitted automatically and does not promise any provider outcome.'
|
|
1489
|
-
: 'This is an unauthorised local draft with cautions. It was not submitted automatically and does not promise any provider outcome.',
|
|
1490
|
-
].join('\n');
|
|
1491
|
-
return { json, markdown, email: `${email}\n` };
|
|
993
|
+
return { json, ...renderCaseResponsePacket(json) };
|
|
1492
994
|
}
|
|
1493
995
|
export function caseResponsePacketFilename(domain, format, generatedAt) {
|
|
1494
996
|
const safeDomain = domain.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 80) || 'case';
|