@slicedearth/whoisleuth-cli 2.5.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +40 -21
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +30 -30
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import { PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
1
|
+
import { COLLECTION_RECEIPT_CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, MAX_CASE_IMPORT_BYTES } from "../contracts/case-portability.mjs";
|
|
2
2
|
import { caseStatusIsClosed } from "./case-record-decisions.mjs";
|
|
3
|
+
import { caseEvidenceWithoutCollectionReceipts } from "./case-evidence-model.mjs";
|
|
4
|
+
import { assertBoundedJsonStructure, boundedJsonLimitsForBytes, scanBoundedJson } from "../analysis/bounded-json.mjs";
|
|
5
|
+
/** Check the whole shared envelope, including otherwise unrecognised nesting.
|
|
6
|
+
* Writer projection alone cannot enforce the audience of an imported pack. */
|
|
7
|
+
export function assertCaseCollectionReceiptAudience(root, audience) {
|
|
8
|
+
if (audience === 'internal')
|
|
9
|
+
return;
|
|
10
|
+
const limits = boundedJsonLimitsForBytes(MAX_CASE_IMPORT_BYTES);
|
|
11
|
+
assertBoundedJsonStructure(root, 'Shared Case pack', limits);
|
|
12
|
+
scanBoundedJson(JSON.stringify(root), limits, entry => {
|
|
13
|
+
if (entry.kind === 'key' && (entry.value === 'collectionReceipt' || entry.value === 'checkpointCollectionReceipts')) {
|
|
14
|
+
throw new TypeError('Shared Case packs cannot contain private collection receipts.');
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
3
18
|
const PRESERVE = Object.freeze({
|
|
4
19
|
durable: 'preserve', internal: 'preserve', trusted: 'preserve', public: 'preserve',
|
|
5
20
|
});
|
|
@@ -101,16 +116,38 @@ const CASE_FIELD_RULES = Object.freeze({
|
|
|
101
116
|
},
|
|
102
117
|
}),
|
|
103
118
|
source: preservedField('source'),
|
|
104
|
-
evidenceHistory: fieldRule('evidenceHistory',
|
|
105
|
-
|
|
119
|
+
evidenceHistory: fieldRule('evidenceHistory', SHARED_REDACT, (record, profile) => (profile === 'trusted' || profile === 'public'
|
|
120
|
+
? caseEvidenceWithoutCollectionReceipts(record) : structuredClone(record.evidenceHistory)), {
|
|
121
|
+
nestedSensitiveFields: ['collectionReceipt'],
|
|
122
|
+
audienceExclusions: {
|
|
123
|
+
trusted: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
|
|
124
|
+
public: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
evidencePins: fieldRule('evidencePins', PUBLIC_REDACT, (record, profile) => structuredClone(record.evidencePins).map(pin => {
|
|
128
|
+
if (profile !== 'public')
|
|
129
|
+
return pin;
|
|
130
|
+
const { responseObject: _scope, infrastructureObservation: _infrastructure, ...publicPin } = pin;
|
|
131
|
+
return publicPin;
|
|
132
|
+
}), { nestedSensitiveFields: ['responseObject', 'infrastructureObservation'], audienceExclusions: { public: { label: 'Exact-object evidence scope and infrastructure observations', order: 4, sinceVersion: 18 } } }),
|
|
133
|
+
checkpointCollectionReceipts: fieldRule('checkpointCollectionReceipts', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public' ? undefined : structuredClone(record.checkpointCollectionReceipts)), { audienceExclusions: {
|
|
134
|
+
trusted: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
|
|
135
|
+
public: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
|
|
136
|
+
} }),
|
|
106
137
|
evidenceLinks: fieldRule('evidenceLinks', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? undefined : structuredClone(record.evidenceLinks)), { audienceExclusions: { public: { label: 'Analyst evidence relationships', order: 4, sinceVersion: EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } } }),
|
|
107
138
|
decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
|
|
108
139
|
actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
|
|
109
140
|
? []
|
|
110
141
|
: profile === 'trusted'
|
|
111
|
-
? structuredClone(record.actions).map((item) =>
|
|
142
|
+
? structuredClone(record.actions).map((item) => {
|
|
143
|
+
const { correction: _privateCorrection, ...shared } = item;
|
|
144
|
+
return { ...shared, recipient: '[redacted]', history: shared.history.map(event => {
|
|
145
|
+
const { packetReceipt: _privateReceipt, ...sharedEvent } = event;
|
|
146
|
+
return sharedEvent;
|
|
147
|
+
}) };
|
|
148
|
+
})
|
|
112
149
|
: structuredClone(record.actions)), {
|
|
113
|
-
nestedSensitiveFields: ['recipient'],
|
|
150
|
+
nestedSensitiveFields: ['recipient', 'responseObjects', 'correction', 'packetReceipt'],
|
|
114
151
|
audienceExclusions: {
|
|
115
152
|
trusted: { label: 'Recipient values', order: 2 },
|
|
116
153
|
public: { label: 'Actions and recipient values', order: 3 },
|
|
@@ -191,6 +228,7 @@ const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
|
|
|
191
228
|
});
|
|
192
229
|
function currentAudienceExclusions(audience, caseVersion) {
|
|
193
230
|
const entries = [
|
|
231
|
+
...(audience === 'trusted' && caseVersion >= 19 ? [{ label: 'Private correction content and exact delivery receipts', order: 2 }] : []),
|
|
194
232
|
...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
|
|
195
233
|
OUTSIDE_SCHEMA_EXCLUSIONS[audience],
|
|
196
234
|
];
|
|
@@ -1,7 +1,29 @@
|
|
|
1
|
-
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, PUBLISHED_V2_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
1
|
+
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, PUBLISHED_V2_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, DELIVERY_CORRECTION_CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
2
2
|
/** Adapt declared historical fields before ordinary current-record recovery. */
|
|
3
3
|
export function caseRecordVersionInput(record, sourceVersion) {
|
|
4
4
|
const declared = sourceVersion != null;
|
|
5
|
+
if (declared && sourceVersion < DELIVERY_CORRECTION_CASE_SCHEMA_VERSION && Array.isArray(record.actions)
|
|
6
|
+
&& record.actions.some(action => action && typeof action === 'object' && (action.correction !== undefined
|
|
7
|
+
|| Array.isArray(action.history) && action.history.some((event) => event?.packetReceipt !== undefined)))) {
|
|
8
|
+
throw new TypeError('Delivery receipts and corrections require Case schema 19; historical deliveries were not reinterpreted.');
|
|
9
|
+
}
|
|
10
|
+
if (declared && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION) {
|
|
11
|
+
const items = (value) => Array.isArray(value) ? value.filter((item) => !!item && typeof item === 'object' && !Array.isArray(item)) : [];
|
|
12
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
13
|
+
const pins = items(record.evidencePins);
|
|
14
|
+
const actions = items(record.actions);
|
|
15
|
+
const reviews = items(object(record.observedEffects).reviews);
|
|
16
|
+
const closures = items(object(record.closures).records);
|
|
17
|
+
const questions = [...items(record.assertions), ...reviews].map(item => object(item.recheck));
|
|
18
|
+
const events = actions.flatMap(action => items(action.history));
|
|
19
|
+
if (pins.some(pin => pin.responseObject !== undefined || pin.infrastructureObservation !== undefined)
|
|
20
|
+
|| actions.some(action => action.responseObjects !== undefined)
|
|
21
|
+
|| events.some(event => event.responseObjects !== undefined || event.objectOutcome !== undefined)
|
|
22
|
+
|| [...reviews, ...closures, ...questions].some(item => item.responseObject !== undefined || item.objectOutcome !== undefined)
|
|
23
|
+
|| [...items(record.evidenceHistory), object(record.evidence)].some(snapshot => snapshot.hasExternalPasswordForm !== undefined)) {
|
|
24
|
+
throw new TypeError('Object response and password-form attribution fields require Case schema 18; historical evidence was not reinterpreted.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
5
27
|
if (record.evidenceLinks !== undefined && declared && sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
|
|
6
28
|
throw new TypeError('Evidence relationships require the current Case schema.');
|
|
7
29
|
}
|
|
@@ -8,15 +8,19 @@
|
|
|
8
8
|
// contains raw RDAP/WHOIS material. This module deliberately excludes all raw
|
|
9
9
|
// registry/web responses, contacts, cookies, screenshots, and authentication
|
|
10
10
|
// data. Reports contain only the normalized case record.
|
|
11
|
-
import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
|
|
11
|
+
import { caseEvidenceTimeline, caseEvidenceWithoutCollectionReceipts, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
|
|
12
12
|
import { httpSecurityHeaderLabel } from "./http-summary.mjs";
|
|
13
13
|
import { webCollectionScoreLimitation } from "../evidence/collection-quality.mjs";
|
|
14
14
|
import { analystInteroperabilityTags } from "../analysis/analyst-taxonomy.mjs";
|
|
15
15
|
import { caseEvidenceLinkIssues } from "./case-evidence-links.mjs";
|
|
16
|
-
import { caseTypeSummary } from "./case-
|
|
16
|
+
import { caseTypeSummary } from "./case-type-presentation.mjs";
|
|
17
|
+
import { caseClosureHistoryQualification } from "./case-response-outcomes.mjs";
|
|
17
18
|
import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../analysis/portable-generator.mjs";
|
|
18
19
|
import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
|
|
19
20
|
import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
|
|
21
|
+
import { EVIDENCE_FOLLOW_UP_CASE_REPORT_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
22
|
+
import { OBJECT_RESPONSE_CASE_REPORT_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
23
|
+
import { CASE_DELIVERY_RECEIPT_LIMITATION } from "./case-packet-correction.mjs";
|
|
20
24
|
import { CASE_RECHECK_CONDITIONS } from "./case-recheck-model.mjs";
|
|
21
25
|
import { escapeCaseMarkdownInline as escapeMarkdownInline } from "./case-markdown.mjs";
|
|
22
26
|
import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
@@ -145,6 +149,7 @@ function pickKnownSnapshotFields(snapshot) {
|
|
|
145
149
|
reusesOfficialAssets: snapshot.reusesOfficialAssets,
|
|
146
150
|
hasPasswordField: snapshot.hasPasswordField,
|
|
147
151
|
hasExternalFormAction: snapshot.hasExternalFormAction,
|
|
152
|
+
...(snapshot.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: snapshot.hasExternalPasswordForm }),
|
|
148
153
|
phishingLanguageMatch: snapshot.phishingLanguageMatch,
|
|
149
154
|
privacyProtected: snapshot.privacyProtected ?? null,
|
|
150
155
|
idnReferenceMatch: snapshot.idnReferenceMatch ?? null,
|
|
@@ -173,9 +178,10 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
173
178
|
const now = generatedAt || new Date().toISOString();
|
|
174
179
|
const generator = buildPortableGeneratorMetadata(options.applicationVersion);
|
|
175
180
|
// --- Build JSON report ---
|
|
176
|
-
const
|
|
181
|
+
const sharedEvidence = { ...caseRecord, evidenceHistory: caseEvidenceWithoutCollectionReceipts(caseRecord) };
|
|
182
|
+
const timeline = caseEvidenceTimeline(sharedEvidence.evidenceHistory);
|
|
177
183
|
const timelineEntries = projectReportTimeline(timeline);
|
|
178
|
-
const selection = currentCaseEvidence(
|
|
184
|
+
const selection = currentCaseEvidence(sharedEvidence);
|
|
179
185
|
const latest = selection.snapshot;
|
|
180
186
|
const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
|
|
181
187
|
const responseLifecycle = buildCaseResponseLifecycleSummary(caseRecord);
|
|
@@ -279,6 +285,10 @@ export function buildCaseReportVerificationProjection(caseRecord, options, schem
|
|
|
279
285
|
const generated = buildCaseReport(caseRecord, options).json;
|
|
280
286
|
if (schemaVersion === CASE_REPORT_SCHEMA_VERSION)
|
|
281
287
|
return generated;
|
|
288
|
+
if (schemaVersion === OBJECT_RESPONSE_CASE_REPORT_SCHEMA_VERSION)
|
|
289
|
+
return { ...generated, schemaVersion };
|
|
290
|
+
if (schemaVersion === EVIDENCE_FOLLOW_UP_CASE_REPORT_SCHEMA_VERSION)
|
|
291
|
+
return { ...generated, schemaVersion };
|
|
282
292
|
if (caseRecord.evidenceHistory.some(snapshot => snapshot.webCollectionQuality !== undefined)) {
|
|
283
293
|
throw new TypeError('Published report formats cannot declare newer collection-quality fields.');
|
|
284
294
|
}
|
|
@@ -499,6 +509,8 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
499
509
|
for (const pin of response.evidencePins) {
|
|
500
510
|
lines.push(`- **${escapeMarkdownInline(pin.label)}:** ${escapeMarkdownInline(pin.value)}`);
|
|
501
511
|
lines.push(` Source: ${escapeMarkdownInline(pin.source)}; observed ${escapeMarkdownInline(pin.observedAt ?? 'Time unavailable')}; completeness ${escapeMarkdownInline(pin.completeness)}.`);
|
|
512
|
+
if (pin.responseObject)
|
|
513
|
+
lines.push(` Exact object: ${escapeMarkdownInline(pin.responseObject.kind)} · ${escapeMarkdownInline(pin.responseObject.identifier)}`);
|
|
502
514
|
if (pin.limitations.length)
|
|
503
515
|
lines.push(` Limitations: ${escapeMarkdownInline(pin.limitations.join('; '))}`);
|
|
504
516
|
}
|
|
@@ -587,6 +599,7 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
587
599
|
for (const action of response.actions) {
|
|
588
600
|
lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
|
|
589
601
|
lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
|
|
602
|
+
lines.push(` Object binding: ${action.responseObjects?.length ? action.responseObjects.map(object => `${escapeMarkdownInline(object.kind)} · ${escapeMarkdownInline(object.identifier)}`).join('; ') : 'Unknown; historical or unbound actions do not establish exact-object coverage.'}`);
|
|
590
603
|
if (action.routeObservedAt)
|
|
591
604
|
lines.push(` Route reviewed: ${escapeMarkdownInline(action.routeObservedAt)}`);
|
|
592
605
|
if (action.routeReviewAfter)
|
|
@@ -595,6 +608,8 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
595
608
|
lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
|
|
596
609
|
if (action.amendment)
|
|
597
610
|
lines.push(` Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}; request events: ${action.amendment.requestEventIds.map(escapeMarkdownInline).join(', ')}`);
|
|
611
|
+
if (action.correction)
|
|
612
|
+
lines.push(` ${action.correction.purpose === 'correction' ? 'Correction request' : 'Retraction request'} for delivery event ${escapeMarkdownInline(action.correction.deliveryEventId)}; packet v${action.correction.packetVersion} SHA-256: ${action.correction.packetDigestSha256}`, ` Reason: ${escapeMarkdownInline(action.correction.reason)}`, ` Previous statement: ${escapeMarkdownInline(action.correction.previousStatement)}`, ` Corrected statement: ${escapeMarkdownInline(action.correction.correctedStatement || 'Request to retract the previous statement')}`, ` Corrected evidence pins: ${action.correction.evidencePinIds.map(escapeMarkdownInline).join(', ')}`, ' Preparation is not delivery, recipient acceptance, restoration or independent recheck.');
|
|
598
613
|
for (const event of action.history)
|
|
599
614
|
if (event.evidenceRequest) {
|
|
600
615
|
const request = event.evidenceRequest;
|
|
@@ -616,8 +631,14 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
616
631
|
lines.push(` - ${escapeMarkdownInline(event.occurredAt)} · ${escapeMarkdownInline(event.previousState ?? 'none')} → ${escapeMarkdownInline(event.nextState)} · ${escapeMarkdownInline(event.sourceClass)} · ${event.applied ? 'applied' : 'retained conflict'}`);
|
|
617
632
|
if (event.providerOutcome)
|
|
618
633
|
lines.push(` Provider outcome at this time: ${escapeMarkdownInline(event.providerOutcome.replaceAll('_', ' '))}`);
|
|
634
|
+
if (event.responseObjects?.length)
|
|
635
|
+
lines.push(` Objects at this event: ${event.responseObjects.map(object => `${escapeMarkdownInline(object.kind)} · ${escapeMarkdownInline(object.identifier)}`).join('; ')}`);
|
|
636
|
+
if (event.objectOutcome)
|
|
637
|
+
lines.push(` Source-reported object outcome: ${escapeMarkdownInline(event.objectOutcome)}; not independent verification or causation.`);
|
|
619
638
|
if (event.reference)
|
|
620
639
|
lines.push(` Event reference: ${escapeMarkdownInline(event.reference)}`);
|
|
640
|
+
if (event.packetReceipt)
|
|
641
|
+
lines.push(` Retained packet v${event.packetReceipt.packetVersion}: ${event.packetReceipt.packetDigestSha256}; recipient ${escapeMarkdownInline(event.packetReceipt.recipient)}; audience ${escapeMarkdownInline(event.packetReceipt.profile)}; generated ${event.packetReceipt.packetGeneratedAt}`, ` ${CASE_DELIVERY_RECEIPT_LIMITATION}`);
|
|
621
642
|
if (event.evidencePinId)
|
|
622
643
|
lines.push(` Evidence pin: ${escapeMarkdownInline(event.evidencePinId)}`);
|
|
623
644
|
if (event.limitations.length)
|
|
@@ -667,6 +688,10 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
667
688
|
lines.push('- No independent observed-effect review recorded.');
|
|
668
689
|
for (const review of response.observedEffects.reviews) {
|
|
669
690
|
lines.push(`- **${escapeMarkdownInline(review.state.replaceAll('_', ' '))}** (${escapeMarkdownInline(review.observedAt)}): ${escapeMarkdownInline(review.source)}; class ${escapeMarkdownInline(review.sourceClass)}; completeness ${escapeMarkdownInline(review.completeness)}.`);
|
|
691
|
+
if (review.responseObject)
|
|
692
|
+
lines.push(` Exact object: ${escapeMarkdownInline(review.responseObject.kind)} · ${escapeMarkdownInline(review.responseObject.identifier)}`);
|
|
693
|
+
if (review.objectOutcome)
|
|
694
|
+
lines.push(` Independently recorded object outcome: ${escapeMarkdownInline(review.objectOutcome)}; point-in-time observation, not report causation or recurrence.`);
|
|
670
695
|
if (review.recheck) {
|
|
671
696
|
lines.push(` Question ${escapeMarkdownInline(review.recheck.questionId)}: ${escapeMarkdownInline(review.recheck.question)}`);
|
|
672
697
|
lines.push(` Recheck target: ${escapeMarkdownInline(review.recheck.targetHostname)}; retained comparison conditions: ${escapeMarkdownInline(review.recheck.conditions)}`);
|
|
@@ -689,13 +714,19 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
689
714
|
lines.push('- No deliberate analyst closure recorded.');
|
|
690
715
|
for (const closure of response.closures.records) {
|
|
691
716
|
lines.push(`- **Closure · ${escapeMarkdownInline(closure.reason.replaceAll('_', ' '))}** (${escapeMarkdownInline(closure.createdAt)}): ${escapeMarkdownInline(closure.summary)}`);
|
|
717
|
+
lines.push(` Scope: ${closure.responseObject ? `${escapeMarkdownInline(closure.responseObject.kind)} · ${escapeMarkdownInline(closure.responseObject.identifier)}; other objects and the Case remain independent.` : 'Whole Case analyst decision.'}`);
|
|
692
718
|
if (closure.observedEffectReviewId)
|
|
693
719
|
lines.push(` Independent review: ${escapeMarkdownInline(closure.observedEffectReviewId)}`);
|
|
694
720
|
if (closure.actionId)
|
|
695
721
|
lines.push(` Provider action: ${escapeMarkdownInline(closure.actionId)}`);
|
|
722
|
+
const qualification = caseClosureHistoryQualification(closure, response.actions);
|
|
723
|
+
if (qualification)
|
|
724
|
+
lines.push(` History qualification: ${escapeMarkdownInline(qualification)}`);
|
|
696
725
|
if (closure.limitations.length)
|
|
697
726
|
lines.push(` Limitations: ${escapeMarkdownInline(closure.limitations.join('; '))}`);
|
|
698
727
|
}
|
|
728
|
+
if (response.closures.omitted)
|
|
729
|
+
lines.push(`- Closure records not retained: ${response.closures.omitted}. ${escapeMarkdownInline(response.closures.limitations.join('; '))}`);
|
|
699
730
|
lines.push('');
|
|
700
731
|
// Analyst notes (only when included)
|
|
701
732
|
if (report.case.notesIncluded && Array.isArray(report.case.notes) && report.case.notes.length > 0) {
|
|
@@ -57,7 +57,7 @@ export function assertEvidenceRequestEvent(request, event) {
|
|
|
57
57
|
export function submittedPacketReceipts(action) {
|
|
58
58
|
return action.history.flatMap(event => {
|
|
59
59
|
const match = /^response-packet-sha256:([a-f0-9]{64})$/u.exec(event.reference ?? '');
|
|
60
|
-
return event.applied && event.previousState
|
|
60
|
+
return event.applied && ['authorised', 'submitted'].includes(event.previousState ?? '') && event.nextState === 'submitted'
|
|
61
61
|
&& event.sourceClass === 'analyst' && match
|
|
62
62
|
? [{ eventId: event.id, digestSha256: match[1], occurredAt: event.occurredAt }] : [];
|
|
63
63
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Append-only response actions: transitions, reconciliation and bounded history.
|
|
2
2
|
import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertEvidenceRequestTransition, assertPacketAmendment } from "./case-requested-evidence.mjs";
|
|
3
|
+
import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "./case-packet-correction.mjs";
|
|
3
4
|
import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
|
|
4
5
|
import { emailRecipient } from "../evidence/email-recipient.mjs";
|
|
5
6
|
import { responseRouteFreshness } from "./response-route-freshness.mjs";
|
|
7
|
+
import { readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
6
8
|
import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
7
9
|
import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
|
|
8
10
|
import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
|
|
@@ -160,6 +162,12 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
160
162
|
if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
161
163
|
throw new Error('Requested evidence requires Case schema 17.');
|
|
162
164
|
const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
|
|
165
|
+
if (item.packetReceipt !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
|
|
166
|
+
throw new TypeError('Delivery receipts require Case schema 19.');
|
|
167
|
+
const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
|
|
168
|
+
assertDeliveryPacketReceipt(packetReceipt, { previousState, nextState, sourceClass, occurredAt,
|
|
169
|
+
reference: typeof item.reference === 'string' ? item.reference : null,
|
|
170
|
+
responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }, actionId);
|
|
163
171
|
assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
|
|
164
172
|
const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
165
173
|
? item.providerOutcome
|
|
@@ -189,7 +197,10 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
189
197
|
: null;
|
|
190
198
|
const eventMaterial = {
|
|
191
199
|
previousState,
|
|
200
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
|
|
201
|
+
...(item.objectOutcome === undefined ? {} : { objectOutcome: readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion) }),
|
|
192
202
|
...(evidenceRequest ? { evidenceRequest } : {}),
|
|
203
|
+
...(packetReceipt ? { packetReceipt } : {}),
|
|
193
204
|
nextState,
|
|
194
205
|
occurredAt,
|
|
195
206
|
sourceClass,
|
|
@@ -206,6 +217,16 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
206
217
|
? item.originActionId
|
|
207
218
|
: null,
|
|
208
219
|
};
|
|
220
|
+
if (eventMaterial.objectOutcome) {
|
|
221
|
+
if (!['submitted', 'acknowledged', 'terminal'].includes(nextState) || previousState === 'authorised')
|
|
222
|
+
throw new TypeError('Object outcomes require a separate attributed event after submission.');
|
|
223
|
+
if (sourceClass !== 'provider' && sourceClass !== 'analyst')
|
|
224
|
+
throw new TypeError('Object outcomes require an attributed provider or analyst event.');
|
|
225
|
+
if (!eventMaterial.responseObjects?.length)
|
|
226
|
+
throw new TypeError('Select the objects concerned by this provider outcome; no complete-action resolution is inferred.');
|
|
227
|
+
for (const object of eventMaterial.responseObjects)
|
|
228
|
+
assertCaseObjectOutcome(eventMaterial.objectOutcome, object);
|
|
229
|
+
}
|
|
209
230
|
return {
|
|
210
231
|
id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
|
|
211
232
|
...eventMaterial,
|
|
@@ -373,6 +394,11 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
373
394
|
if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
374
395
|
throw new Error('Packet amendments require Case schema 17.');
|
|
375
396
|
const amendment = readCasePacketAmendment(item.amendment);
|
|
397
|
+
if (item.correction !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
|
|
398
|
+
throw new TypeError('Packet corrections require Case schema 19.');
|
|
399
|
+
const correction = readCasePacketCorrection(item.correction);
|
|
400
|
+
if (correction && amendment)
|
|
401
|
+
throw new TypeError('Self-initiated correction and provider-request amendment are separate purposes.');
|
|
376
402
|
const applied = history.history.filter((event) => event.applied);
|
|
377
403
|
const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
|
|
378
404
|
const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
|
|
@@ -387,7 +413,9 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
387
413
|
: null;
|
|
388
414
|
return {
|
|
389
415
|
id: actionId,
|
|
416
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
|
|
390
417
|
...(amendment ? { amendment } : {}),
|
|
418
|
+
...(correction ? { correction } : {}),
|
|
391
419
|
type: legacyPlatformReview
|
|
392
420
|
? 'platform_report'
|
|
393
421
|
: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
|
|
@@ -575,9 +603,14 @@ export function appendCaseAction(current, raw, now) {
|
|
|
575
603
|
const amendment = readCasePacketAmendment(item.amendment);
|
|
576
604
|
if (amendment)
|
|
577
605
|
assertPacketAmendment(current, originActionId, amendment);
|
|
606
|
+
const correction = readCasePacketCorrection(item.correction);
|
|
607
|
+
if (correction)
|
|
608
|
+
correctionDelivery(current, { originActionId, correction,
|
|
609
|
+
recipient: text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH), responseObjects: readCaseResponseObjects(item.responseObjects) });
|
|
578
610
|
const history = [{
|
|
579
611
|
id: freshId('action-event'),
|
|
580
612
|
previousState: null,
|
|
613
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects) }),
|
|
581
614
|
nextState: 'drafting',
|
|
582
615
|
occurredAt: now,
|
|
583
616
|
sourceClass: 'analyst',
|
|
@@ -602,11 +635,22 @@ export function appendCaseAction(current, raw, now) {
|
|
|
602
635
|
throw new Error('An action requires a recipient or internal owner.');
|
|
603
636
|
return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
604
637
|
}
|
|
638
|
+
export function providerOutcomeRequiresObjectScope(outcome) {
|
|
639
|
+
return outcome === 'provider_reports_resolved' || outcome === 'partially_remediated';
|
|
640
|
+
}
|
|
605
641
|
export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
|
|
606
642
|
const action = current.find((item) => item.id === actionId);
|
|
607
643
|
if (!action)
|
|
608
644
|
throw new Error('That case action no longer exists.');
|
|
609
645
|
const item = record(raw);
|
|
646
|
+
const selectedObjects = readCaseResponseObjects(item.responseObjects);
|
|
647
|
+
const outcome = readCaseResponseObjectOutcome(item.objectOutcome);
|
|
648
|
+
if (selectedObjects?.some(object => !action.responseObjects?.some(bound => sameCaseResponseObject(bound, object))))
|
|
649
|
+
throw new TypeError('A transition may concern only objects explicitly bound to its action.');
|
|
650
|
+
if (outcome && !selectedObjects?.length)
|
|
651
|
+
throw new TypeError('Explicitly select the affected objects for this outcome; it is not applied to every action object.');
|
|
652
|
+
if (action.responseObjects?.length && providerOutcomeRequiresObjectScope(item.providerOutcome) && !selectedObjects?.length)
|
|
653
|
+
throw new TypeError('Explicitly select the objects covered by this provider-reported outcome; other action objects remain unchecked.');
|
|
610
654
|
const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
|
|
611
655
|
? item.nextState
|
|
612
656
|
: null;
|
|
@@ -637,9 +681,18 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
637
681
|
if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
|
|
638
682
|
assertPacketAmendment(current, action.originActionId, action.amendment);
|
|
639
683
|
}
|
|
684
|
+
if (action.correction && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState))
|
|
685
|
+
correctionDelivery(current, action);
|
|
686
|
+
const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
|
|
687
|
+
if (packetReceipt && (packetReceipt.recipient !== action.recipient
|
|
688
|
+
|| JSON.stringify(packetReceipt.responseObjects) !== JSON.stringify(action.responseObjects ?? [])))
|
|
689
|
+
throw new TypeError('The delivered packet receipt no longer matches this action recipient or scope.');
|
|
690
|
+
if (action.correction && nextState === 'submitted' && !packetReceipt)
|
|
691
|
+
throw new TypeError('Record the separately prepared correction packet receipt only after actual delivery.');
|
|
640
692
|
const occurredAt = optionalIso(item.occurredAt) ?? now;
|
|
641
693
|
const event = normalizeActionEvent({
|
|
642
694
|
...item,
|
|
695
|
+
...(selectedObjects !== undefined ? { responseObjects: selectedObjects } : action.responseObjects === undefined ? {} : { responseObjects: action.responseObjects }),
|
|
643
696
|
id: freshId('action-event'),
|
|
644
697
|
previousState: action.state,
|
|
645
698
|
nextState,
|
|
@@ -658,7 +711,7 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
658
711
|
return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
|
|
659
712
|
}
|
|
660
713
|
const ACTION_REVIEW_MATERIAL_FIELDS = [
|
|
661
|
-
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment',
|
|
714
|
+
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment', 'correction', 'responseObjects',
|
|
662
715
|
];
|
|
663
716
|
export function updateCaseAction(current, raw, now, validPinIds) {
|
|
664
717
|
const patch = record(raw);
|
|
@@ -689,6 +742,8 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
689
742
|
.some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
|
|
690
743
|
if (materialChanged && updated.amendment)
|
|
691
744
|
assertPacketAmendment(current, updated.originActionId, updated.amendment);
|
|
745
|
+
if (materialChanged && updated.correction)
|
|
746
|
+
correctionDelivery(current, updated);
|
|
692
747
|
if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
|
|
693
748
|
throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
|
|
694
749
|
}
|
|
@@ -702,6 +757,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
702
757
|
occurredAt: now,
|
|
703
758
|
sourceClass: 'browser_local',
|
|
704
759
|
provenance: 'material_action_change',
|
|
760
|
+
...(updated.responseObjects === undefined ? {} : { responseObjects: updated.responseObjects }),
|
|
705
761
|
reference: null,
|
|
706
762
|
evidencePinId: null,
|
|
707
763
|
limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
|
|
@@ -7,9 +7,12 @@ import { CASE_ASSERTION_EXTERNAL_ENTITY_TYPES, CASE_ASSERTION_EXTERNAL_FORMATS,
|
|
|
7
7
|
import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
|
|
8
8
|
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
9
9
|
import { readCaseRecheckContext } from "./case-recheck-model.mjs";
|
|
10
|
+
import { readCaseResponseObject } from "./case-response-object.mjs";
|
|
11
|
+
import { normalizeInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
|
|
10
12
|
export * from "./case-response-records.mjs";
|
|
11
13
|
export * from "./case-response-actions.mjs";
|
|
12
14
|
export * from "./case-response-outcomes.mjs";
|
|
15
|
+
export * from "./case-response-object.mjs";
|
|
13
16
|
export { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
|
|
14
17
|
const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
|
|
15
18
|
const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
|
|
@@ -108,6 +111,8 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
|
|
|
108
111
|
}
|
|
109
112
|
function normalizePin(raw, fallback, options = {}) {
|
|
110
113
|
const item = record(raw);
|
|
114
|
+
if (item.infrastructureObservation !== undefined && options.sourceVersion != null && options.sourceVersion < 18)
|
|
115
|
+
throw new TypeError('Infrastructure observations require Case schema 18 or later.');
|
|
111
116
|
const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
|
|
112
117
|
const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
|
|
113
118
|
if (!label || !value)
|
|
@@ -141,6 +146,8 @@ function normalizePin(raw, fallback, options = {}) {
|
|
|
141
146
|
: null,
|
|
142
147
|
limitations: limitations(item.limitations),
|
|
143
148
|
createdAt,
|
|
149
|
+
...(item.responseObject === undefined ? {} : { responseObject: readCaseResponseObject(item.responseObject, options.sourceVersion) }),
|
|
150
|
+
...(item.infrastructureObservation === undefined ? {} : { infrastructureObservation: normalizeInfrastructureObservation(item.infrastructureObservation) }),
|
|
144
151
|
};
|
|
145
152
|
normalized.certificateObservation = options.allowCertificateObservation === false
|
|
146
153
|
? null
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Explicit object scope, not ownership, collection or proof of remediation.
|
|
2
|
+
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
3
|
+
import { OBJECT_RESPONSE_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
4
|
+
import { array, enumeration, exact, text } from "../evidence/artifact-structure.mjs";
|
|
5
|
+
export const CASE_RESPONSE_OBJECT_KINDS = ['domain', 'hostname', 'page', 'redirect', 'advertisement', 'social_account', 'social_post', 'messaging', 'storefront', 'app', 'other'];
|
|
6
|
+
export const CASE_RESPONSE_OBJECT_OUTCOMES = ['removed', 'restricted', 'suspended', 'delisted', 'warning', 'transferred', 'restored', 'disputed'];
|
|
7
|
+
export const MAX_CASE_RESPONSE_OBJECT_IDENTIFIER = 1_979;
|
|
8
|
+
export const MAX_CASE_RESPONSE_OBJECTS = 20;
|
|
9
|
+
export function readCaseResponseObjects(value, sourceVersion) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
13
|
+
throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
|
|
14
|
+
const objects = array(value, 'Response objects', MAX_CASE_RESPONSE_OBJECTS).map(item => readCaseResponseObject(item, sourceVersion));
|
|
15
|
+
const keyed = objects.map(object => [JSON.stringify(object), object]);
|
|
16
|
+
if (new Set(keyed.map(([key]) => key)).size !== keyed.length)
|
|
17
|
+
throw new TypeError('Response objects must be unique.');
|
|
18
|
+
return Object.freeze(keyed.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, object]) => object));
|
|
19
|
+
}
|
|
20
|
+
/** Historical absence stays absent; a declared historical format cannot carry new fields. */
|
|
21
|
+
export function readCaseResponseObject(value, sourceVersion) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
25
|
+
throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
|
|
26
|
+
const item = exact(value, ['kind', 'identifier', 'incidentTargetId'], 'Response object');
|
|
27
|
+
const kind = enumeration(item.kind, CASE_RESPONSE_OBJECT_KINDS, 'Response object kind');
|
|
28
|
+
const identifier = text(item.identifier, 'Response object identifier', MAX_CASE_RESPONSE_OBJECT_IDENTIFIER);
|
|
29
|
+
if (!identifier || identifier.trim() !== identifier || /[\u0000-\u001f\u007f]/u.test(identifier))
|
|
30
|
+
throw new TypeError('Response object identifier must be a bounded single-line value.');
|
|
31
|
+
const incidentTargetId = item.incidentTargetId === null ? null : text(item.incidentTargetId, 'Incident object identity', 64);
|
|
32
|
+
if (incidentTargetId !== null && !/^[A-Za-z0-9_-]{1,64}$/u.test(incidentTargetId))
|
|
33
|
+
throw new TypeError('Incident object identity is invalid.');
|
|
34
|
+
if (kind === 'domain' || kind === 'hostname') {
|
|
35
|
+
if (identifier !== identifier.toLowerCase() || !isValidAsciiHostname(identifier) || incidentTargetId !== null)
|
|
36
|
+
throw new TypeError('Domain and hostname scope require a normalised hostname and no incident-link identity.');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(identifier);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new TypeError('Select an exact retained HTTP(S) incident link for this object.');
|
|
45
|
+
}
|
|
46
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.toString() !== identifier || !incidentTargetId)
|
|
47
|
+
throw new TypeError('Select an exact retained HTTP(S) incident link without credentials for this object.');
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze({ kind, identifier, incidentTargetId });
|
|
50
|
+
}
|
|
51
|
+
export function readCaseResponseObjectOutcome(value, sourceVersion) {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
55
|
+
throw new TypeError('Object-specific outcomes require Case schema 18 or later.');
|
|
56
|
+
return enumeration(value, CASE_RESPONSE_OBJECT_OUTCOMES, 'Response object outcome');
|
|
57
|
+
}
|
|
58
|
+
export function sameCaseResponseObject(left, right) {
|
|
59
|
+
return left !== undefined && right !== undefined && left.kind === right.kind && left.identifier === right.identifier && left.incidentTargetId === right.incidentTargetId;
|
|
60
|
+
}
|
|
61
|
+
/** Explicit retained snapshots permit follow-up without retargeting edited links. */
|
|
62
|
+
export function retainedCaseResponseObjects(record) {
|
|
63
|
+
const objects = [...(record.actions ?? []).flatMap(action => [...action.responseObjects ?? [], ...action.history.flatMap(event => event.responseObjects ?? [])]),
|
|
64
|
+
...(record.observedEffects?.reviews ?? []).flatMap(review => review.responseObject ? [review.responseObject] : []),
|
|
65
|
+
...(record.evidencePins ?? []).flatMap(pin => pin.responseObject ? [pin.responseObject] : []),
|
|
66
|
+
...(record.closures?.records ?? []).flatMap(closure => closure.responseObject ? [closure.responseObject] : []),
|
|
67
|
+
...(record.assertions ?? []).flatMap(assertion => assertion.recheck?.responseObject ? [assertion.recheck.responseObject] : [])];
|
|
68
|
+
return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()];
|
|
69
|
+
}
|
|
70
|
+
/** New identities require current metadata; exact historical identities remain usable. */
|
|
71
|
+
export function assertCaseResponseObject(object, record) {
|
|
72
|
+
if (!object)
|
|
73
|
+
return;
|
|
74
|
+
if (object.kind === 'domain') {
|
|
75
|
+
if (object.identifier !== record.domain)
|
|
76
|
+
throw new TypeError('The response domain must match this Case.');
|
|
77
|
+
}
|
|
78
|
+
else if (object.kind === 'hostname') {
|
|
79
|
+
if (object.identifier !== record.domain && !object.identifier.endsWith(`.${record.domain}`))
|
|
80
|
+
throw new TypeError('The response hostname must belong to this Case domain.');
|
|
81
|
+
}
|
|
82
|
+
else if (!record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier)
|
|
83
|
+
&& !retainedCaseResponseObjects(record).some(retained => sameCaseResponseObject(retained, object))) {
|
|
84
|
+
throw new TypeError('The selected exact incident object changed or is not retained in this Case. Refresh and review it before saving.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function assertCaseObjectOutcome(outcome, object) {
|
|
88
|
+
if (!outcome)
|
|
89
|
+
return;
|
|
90
|
+
if (!object)
|
|
91
|
+
throw new TypeError('A typed object outcome requires explicit object scope.');
|
|
92
|
+
if (outcome === 'suspended' && object.kind !== 'domain')
|
|
93
|
+
throw new TypeError('Domain suspension requires domain scope.');
|
|
94
|
+
if (outcome === 'restricted' && object.kind !== 'social_account' && object.kind !== 'app' && object.kind !== 'other')
|
|
95
|
+
throw new TypeError('Account restriction requires account, app or explicitly other scope.');
|
|
96
|
+
}
|
|
97
|
+
export function caseResponseObjectChoices(record) {
|
|
98
|
+
const objects = [{ kind: 'domain', identifier: record.domain, incidentTargetId: null }];
|
|
99
|
+
const hosts = new Set([...(record.evidencePins ?? []).map(pin => pin.observationHostname), ...(record.evidenceHistory ?? []).map(snapshot => snapshot.inputHostname)].filter((value) => Boolean(value)));
|
|
100
|
+
for (const identifier of hosts)
|
|
101
|
+
if (identifier === record.domain || identifier.endsWith(`.${record.domain}`))
|
|
102
|
+
objects.push({ kind: 'hostname', identifier, incidentTargetId: null });
|
|
103
|
+
for (const target of record.workflowMetadata?.incidentTargets ?? [])
|
|
104
|
+
for (const kind of CASE_RESPONSE_OBJECT_KINDS.filter(kind => kind !== 'domain' && kind !== 'hostname'))
|
|
105
|
+
objects.push({ kind, identifier: target.url, incidentTargetId: target.id });
|
|
106
|
+
objects.push(...retainedCaseResponseObjects(record));
|
|
107
|
+
return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()].map(object => {
|
|
108
|
+
const historical = Boolean(object.incidentTargetId && !record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier));
|
|
109
|
+
return { value: JSON.stringify(object), label: `${object.kind.replaceAll('_', ' ')} · ${object.identifier}${historical ? ' · historical / no current incident link' : ''}`, object };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export function selectedCaseResponseObject(record, value) {
|
|
113
|
+
if (!value)
|
|
114
|
+
return undefined;
|
|
115
|
+
const object = caseResponseObjectChoices(record).find(choice => choice.value === value)?.object;
|
|
116
|
+
if (!object)
|
|
117
|
+
throw new TypeError('The selected incident object changed. Refresh and review it before saving.');
|
|
118
|
+
return readCaseResponseObject(object);
|
|
119
|
+
}
|