@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- 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/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -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/dns-delegation-health.mjs +23 -7
- 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-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- 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-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -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 +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- 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.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- 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/image-regions.mjs +15 -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 +32 -17
- 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/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- 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 +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -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 +29 -29
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { projectCaseForAudience } from "./case-record-projection.mjs";
|
|
2
|
+
import { readEditableCaseExport } from "./case-export-input.mjs";
|
|
3
|
+
import { array, enumeration, exact, iso, text } from "../evidence/artifact-structure.mjs";
|
|
4
|
+
import { escapeCaseMarkdownInline } from "./case-markdown.mjs";
|
|
5
|
+
import { MAX_CASE_ASSERTIONS, MAX_CASE_EVIDENCE_PINS } from "../contracts/case-portability.mjs";
|
|
6
|
+
import { caseSelectedEvidenceSourceLimitations } from "./case-evidence-links.mjs";
|
|
7
|
+
export const CASE_CONTAINMENT_INPUT_SCHEMA = 'whoisleuth.internal-containment.input';
|
|
8
|
+
export const CASE_CONTAINMENT_HANDOFF_SCHEMA = 'whoisleuth.internal-containment';
|
|
9
|
+
export const CASE_CONTAINMENT_VERSION = 1;
|
|
10
|
+
export const MAX_CONTAINMENT_ASSERTIONS = 20;
|
|
11
|
+
export const MAX_CONTAINMENT_PINS = 40;
|
|
12
|
+
export const MAX_CONTAINMENT_OUTPUT_BYTES = 128 * 1024;
|
|
13
|
+
export const CONTAINMENT_RECIPIENT_ROLES = [
|
|
14
|
+
'security_operations',
|
|
15
|
+
'identity_response',
|
|
16
|
+
'endpoint_response',
|
|
17
|
+
'network_response',
|
|
18
|
+
];
|
|
19
|
+
const ROLE_GUIDANCE = {
|
|
20
|
+
security_operations: [
|
|
21
|
+
'Confirm the responsible internal owner and track the selected requests independently of external removal.',
|
|
22
|
+
'Separate reported actions, retained observations and unresolved evidence before authorising a control.',
|
|
23
|
+
],
|
|
24
|
+
identity_response: [
|
|
25
|
+
'Review the selected requests against authorised account, session and application-grant evidence in the identity system.',
|
|
26
|
+
'Do not treat a reported password entry, sign-in approval or provider resolution as proof of account compromise or recovery.',
|
|
27
|
+
],
|
|
28
|
+
endpoint_response: [
|
|
29
|
+
'Review the selected requests against authorised device evidence before deciding whether isolation, collection or remediation is needed.',
|
|
30
|
+
'Do not execute supplied commands or attachments; a reported execution is not a verified device outcome.',
|
|
31
|
+
],
|
|
32
|
+
network_response: [
|
|
33
|
+
'Review exact scope, collateral effects and an appropriate review/expiry period before applying an approved network control.',
|
|
34
|
+
'Shared infrastructure and literal indicators do not establish ownership, maliciousness or an appropriate blocking scope.',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
function uniqueIds(value, maximum, label) {
|
|
38
|
+
const ids = array(value, label, maximum).map((id) => text(id, label, 240));
|
|
39
|
+
if (new Set(ids).size !== ids.length)
|
|
40
|
+
throw new TypeError(`${label} must be unique.`);
|
|
41
|
+
return ids;
|
|
42
|
+
}
|
|
43
|
+
export function readContainmentSelection(raw) {
|
|
44
|
+
const value = exact(raw, ['audience', 'recipientRole', 'assertionIds', 'evidencePinIds'], 'Containment handoff selection');
|
|
45
|
+
return {
|
|
46
|
+
audience: enumeration(value.audience, ['internal', 'trusted', 'public'], 'Handoff audience'),
|
|
47
|
+
recipientRole: enumeration(value.recipientRole, CONTAINMENT_RECIPIENT_ROLES, 'Internal recipient role'),
|
|
48
|
+
assertionIds: uniqueIds(value.assertionIds, MAX_CONTAINMENT_ASSERTIONS, 'Selected follow-ups'),
|
|
49
|
+
evidencePinIds: uniqueIds(value.evidencePinIds, MAX_CONTAINMENT_PINS, 'Selected supporting pins'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Select retained assertions; never infer completed containment from Case or provider status. */
|
|
53
|
+
export function previewCaseContainmentHandoff(record, raw, generatedAt) {
|
|
54
|
+
iso(generatedAt, 'Handoff generation time');
|
|
55
|
+
const selection = readContainmentSelection(raw);
|
|
56
|
+
array(record.assertions, 'Retained Case assertions', MAX_CASE_ASSERTIONS);
|
|
57
|
+
array(record.evidencePins, 'Retained Case pins', MAX_CASE_EVIDENCE_PINS);
|
|
58
|
+
const followUps = record.assertions.filter((item) => item.kind === 'next_step');
|
|
59
|
+
const original = selection.assertionIds.map((id) => {
|
|
60
|
+
const matches = followUps.filter((item) => item.id === id);
|
|
61
|
+
if (matches.length !== 1)
|
|
62
|
+
throw new TypeError('A selected follow-up is missing or ambiguous; refresh the selection.');
|
|
63
|
+
return matches[0];
|
|
64
|
+
});
|
|
65
|
+
const linkedIds = new Set(original.flatMap((item) => item.evidencePinIds));
|
|
66
|
+
for (const id of selection.evidencePinIds) {
|
|
67
|
+
if (!linkedIds.has(id) || record.evidencePins.filter((pin) => pin.id === id).length !== 1)
|
|
68
|
+
throw new TypeError('Select only uniquely retained pins linked to the selected follow-ups.');
|
|
69
|
+
}
|
|
70
|
+
const permitted = selection.audience !== 'public';
|
|
71
|
+
// Reuse the canonical audience policy before constructing a narrow projection.
|
|
72
|
+
const projected = projectCaseForAudience(record, selection.audience);
|
|
73
|
+
const pins = permitted
|
|
74
|
+
? projected.evidencePins.filter((pin) => selection.evidencePinIds.includes(pin.id))
|
|
75
|
+
: [];
|
|
76
|
+
const assertions = permitted
|
|
77
|
+
? original.map((item) => {
|
|
78
|
+
const selected = projected.assertions.find((assertion) => assertion.id === item.id);
|
|
79
|
+
return {
|
|
80
|
+
id: selected.id,
|
|
81
|
+
kind: 'next_step',
|
|
82
|
+
statement: selected.statement,
|
|
83
|
+
rationale: selected.rationale,
|
|
84
|
+
state: selected.state,
|
|
85
|
+
createdAt: selected.createdAt,
|
|
86
|
+
updatedAt: selected.updatedAt,
|
|
87
|
+
provenance: selected.provenance ?? null,
|
|
88
|
+
recheck: selected.recheck ?? null,
|
|
89
|
+
evidence: selected.evidencePinIds.map((id) => ({
|
|
90
|
+
evidencePinId: id,
|
|
91
|
+
stance: selected.evidenceRelations?.find((relation) => relation.evidencePinId === id)
|
|
92
|
+
?.stance ?? 'not_recorded',
|
|
93
|
+
state: pins.some((pin) => pin.id === id)
|
|
94
|
+
? 'included'
|
|
95
|
+
: record.evidencePins.some((pin) => pin.id === id)
|
|
96
|
+
? 'not_selected'
|
|
97
|
+
: 'unavailable',
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
})
|
|
101
|
+
: [];
|
|
102
|
+
const missingContext = assertions.some((item) => !item.evidence.length || item.evidence.some((link) => link.state !== 'included'));
|
|
103
|
+
const report = {
|
|
104
|
+
schema: CASE_CONTAINMENT_HANDOFF_SCHEMA,
|
|
105
|
+
version: CASE_CONTAINMENT_VERSION,
|
|
106
|
+
generatedAt,
|
|
107
|
+
audience: selection.audience,
|
|
108
|
+
recipientRole: selection.recipientRole,
|
|
109
|
+
case: { id: projected.id, domain: projected.domain, status: projected.status },
|
|
110
|
+
state: !permitted ||
|
|
111
|
+
!assertions.length ||
|
|
112
|
+
missingContext ||
|
|
113
|
+
pins.some((pin) => pin.completeness !== 'complete' || pin.truncated === true)
|
|
114
|
+
? 'partial'
|
|
115
|
+
: 'reviewed',
|
|
116
|
+
reviewRequired: true,
|
|
117
|
+
actionsPerformed: false,
|
|
118
|
+
assertions,
|
|
119
|
+
evidencePins: pins,
|
|
120
|
+
disclosure: {
|
|
121
|
+
exportAllowed: permitted && assertions.length > 0,
|
|
122
|
+
included: permitted
|
|
123
|
+
? [
|
|
124
|
+
'Case identifier, domain and recorded status',
|
|
125
|
+
'Only selected next-step statements, rationale, states and recording times',
|
|
126
|
+
'Exact selected supporting pins and separately qualified evidence relationships',
|
|
127
|
+
]
|
|
128
|
+
: ['Case identifier, domain and recorded status only'],
|
|
129
|
+
excluded: [
|
|
130
|
+
'Unselected assertions and evidence',
|
|
131
|
+
'Case title, notes, contacts, action recipients and original files',
|
|
132
|
+
'Raw upstream responses, automatic assignments and control execution',
|
|
133
|
+
...(permitted ? [] : ['Internal follow-up statements and their supporting pins']),
|
|
134
|
+
],
|
|
135
|
+
caution: 'Selected statements and pin values can contain sensitive analyst text or exact scope. Review each included value for the intended recipient; minimisation is not automatic anonymisation.',
|
|
136
|
+
},
|
|
137
|
+
nextSteps: [...ROLE_GUIDANCE[selection.recipientRole]],
|
|
138
|
+
limitations: [
|
|
139
|
+
'These are retained analyst requests, not verified assignments, executed controls or independent recovery results.',
|
|
140
|
+
'Open and resolved assertion states are preserved as recorded. External provider resolution, object closure and Case status do not resolve open internal requests.',
|
|
141
|
+
'Unselected or missing supporting pins remain explicit. A linked pin may support, contradict or leave a request unresolved; linkage does not establish truth.',
|
|
142
|
+
'This local handoff sends no message, changes no Case state and includes no original attachment bytes.',
|
|
143
|
+
...caseSelectedEvidenceSourceLimitations(pins, record.evidenceLinks, pins.map(pin => pin.id)),
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
if (new TextEncoder().encode(JSON.stringify(report)).byteLength > MAX_CONTAINMENT_OUTPUT_BYTES)
|
|
147
|
+
throw new TypeError('The selected handoff exceeds 128 KiB. Choose fewer follow-ups or pins; nothing was silently omitted.');
|
|
148
|
+
return report;
|
|
149
|
+
}
|
|
150
|
+
export function buildCaseContainmentHandoff(record, selection, generatedAt, disclosureReviewed) {
|
|
151
|
+
const report = previewCaseContainmentHandoff(record, selection, generatedAt);
|
|
152
|
+
if (!disclosureReviewed || !report.disclosure.exportAllowed)
|
|
153
|
+
throw new TypeError('Review the audience disclosure and select at least one retained follow-up for an internal or trusted handoff.');
|
|
154
|
+
return report;
|
|
155
|
+
}
|
|
156
|
+
export function readCaseContainmentInput(raw, generatedAt) {
|
|
157
|
+
const input = exact(raw, ['schema', 'version', 'caseExport', 'caseId', 'selection', 'disclosureReviewed'], 'Containment handoff input');
|
|
158
|
+
if (input.schema !== CASE_CONTAINMENT_INPUT_SCHEMA || input.version !== CASE_CONTAINMENT_VERSION)
|
|
159
|
+
throw new TypeError('Unsupported containment handoff input version.');
|
|
160
|
+
const caseId = text(input.caseId, 'Selected Case ID', 240);
|
|
161
|
+
const selected = readEditableCaseExport(JSON.stringify(input.caseExport)).filter((record) => record.id === caseId);
|
|
162
|
+
if (selected.length !== 1)
|
|
163
|
+
throw new TypeError('Select one unambiguous Case from the supported export.');
|
|
164
|
+
if (input.disclosureReviewed !== true)
|
|
165
|
+
throw new TypeError('The selected handoff requires an explicit audience-disclosure review.');
|
|
166
|
+
return buildCaseContainmentHandoff(selected[0], readContainmentSelection(input.selection), generatedAt, true);
|
|
167
|
+
}
|
|
168
|
+
export function formatCaseContainmentHandoff(report) {
|
|
169
|
+
const escape = escapeCaseMarkdownInline;
|
|
170
|
+
const lines = [
|
|
171
|
+
`# Internal containment handoff`,
|
|
172
|
+
'',
|
|
173
|
+
`Audience: ${report.audience} · ${report.recipientRole.replaceAll('_', ' ')}`,
|
|
174
|
+
`Case: ${escape(report.case.domain)} · ${escape(report.case.id)} · recorded status ${escape(report.case.status)}`,
|
|
175
|
+
`Generated: ${report.generatedAt} · coverage ${report.state} · no actions performed`,
|
|
176
|
+
'',
|
|
177
|
+
'## Selected retained requests',
|
|
178
|
+
'',
|
|
179
|
+
];
|
|
180
|
+
for (const item of report.assertions) {
|
|
181
|
+
lines.push(`- ${escape(item.statement)} — ${item.state} (${escape(item.id)})`);
|
|
182
|
+
if (item.rationale)
|
|
183
|
+
lines.push(` Rationale: ${escape(item.rationale)}`);
|
|
184
|
+
lines.push(` Recorded ${escape(item.createdAt)}; updated ${escape(item.updatedAt)}.`);
|
|
185
|
+
for (const link of item.evidence)
|
|
186
|
+
lines.push(` Evidence ${escape(link.evidencePinId)}: ${link.stance}; ${link.state.replaceAll('_', ' ')}.`);
|
|
187
|
+
if (!item.evidence.length)
|
|
188
|
+
lines.push(' No supporting context was linked.');
|
|
189
|
+
}
|
|
190
|
+
lines.push('', '## Selected supporting evidence', '');
|
|
191
|
+
for (const pin of report.evidencePins)
|
|
192
|
+
lines.push(`- ${escape(pin.label)} (${escape(pin.id)}): ${escape(pin.value)}`, ` Source: ${escape(pin.source)}; observed ${escape(pin.observedAt ?? 'unknown')}; ${pin.completeness}${pin.truncated ? '; truncated' : ''}.`, ...pin.limitations.map((value) => ` Limitation: ${escape(value)}`));
|
|
193
|
+
lines.push('', '## Recipient review', '', ...report.nextSteps.map((value) => `- ${value}`), '', '## Boundaries', '', ...report.limitations.map((value) => `- ${value}`));
|
|
194
|
+
return `${lines.join('\n')}\n`;
|
|
195
|
+
}
|
|
@@ -133,3 +133,19 @@ export function caseEvidenceSharedContext(pins) {
|
|
|
133
133
|
}
|
|
134
134
|
return [...groups.values()].filter(group => group.pinIds.length > 1);
|
|
135
135
|
}
|
|
136
|
+
export const CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION = 'Selected evidence has retained shared-source or derivation context. Multiple references do not establish independent observations; underlying reporting or collection methods may remain unknown. Relationship details are not included in this output.';
|
|
137
|
+
/** Qualify a narrow disclosure without exposing relationship identities or private basis text. */
|
|
138
|
+
export function caseSelectedEvidenceSourceLimitations(pins, links, selectedPinIds) {
|
|
139
|
+
array(pins, 'Retained evidence pins', MAX_CASE_EVIDENCE_PINS);
|
|
140
|
+
array(links ?? [], 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
|
|
141
|
+
array(selectedPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS);
|
|
142
|
+
const requested = new Set(selectedPinIds);
|
|
143
|
+
const selectedPins = pins.filter(pin => requested.has(pin.id));
|
|
144
|
+
const selected = new Set(selectedPins.map(pin => pin.id));
|
|
145
|
+
if (!selected.size)
|
|
146
|
+
return [];
|
|
147
|
+
const declared = links?.some(link => !link.withdrawal
|
|
148
|
+
&& (selected.has(link.fromPinId) || selected.has(link.toPinId)));
|
|
149
|
+
return declared || caseEvidenceSharedContext(selectedPins).length
|
|
150
|
+
? [CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION] : [];
|
|
151
|
+
}
|
|
@@ -5,7 +5,7 @@ import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scorin
|
|
|
5
5
|
import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
|
|
6
6
|
import { latestObservationCohort } from "../evidence/latest-observations.mjs";
|
|
7
7
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
8
|
-
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
8
|
+
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
9
9
|
import { validWebObservationMode } from "../evidence/lookup-target.mjs";
|
|
10
10
|
import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
|
|
11
11
|
import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
|
|
@@ -113,6 +113,7 @@ const DEEP_SIGNAL_FIELDS = [
|
|
|
113
113
|
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
114
114
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
|
|
115
115
|
'pageBaselineMatch',
|
|
116
|
+
'hasExternalPasswordForm',
|
|
116
117
|
];
|
|
117
118
|
// Ordered list of the fields that make up a snapshot's *material* identity -
|
|
118
119
|
// everything except capture timestamps, snapshot id and source. `scanDepth` is
|
|
@@ -135,6 +136,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
135
136
|
'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
136
137
|
'profileContextState', 'profileContextLimitation',
|
|
137
138
|
'mutationTypes',
|
|
139
|
+
'hasExternalPasswordForm',
|
|
138
140
|
];
|
|
139
141
|
// The canonical, comparison-safe value of a material field. Registrar casing,
|
|
140
142
|
// nameserver order, and sub-day timestamps are collapsed so they can never
|
|
@@ -196,7 +198,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
|
|
|
196
198
|
const value = materialValue(field, snapshot, legacyOrder);
|
|
197
199
|
// Optional collection context does not alter historical fingerprints when
|
|
198
200
|
// absent; recorded context separates otherwise-identical captures.
|
|
199
|
-
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality') && value === null)
|
|
201
|
+
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality' || field === 'hasExternalPasswordForm') && value === null)
|
|
200
202
|
continue;
|
|
201
203
|
canonical[field] = value;
|
|
202
204
|
}
|
|
@@ -223,6 +225,8 @@ function buildSnapshot(raw, options) {
|
|
|
223
225
|
if (!raw || typeof raw !== 'object')
|
|
224
226
|
return null;
|
|
225
227
|
const record = objectRecord(raw);
|
|
228
|
+
if (record.hasExternalPasswordForm !== undefined && options.sourceVersion != null && options.sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
229
|
+
throw new TypeError('Password-form attribution requires Case schema 18 or later; historical evidence was not reinterpreted.');
|
|
226
230
|
if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
|
|
227
231
|
return null;
|
|
228
232
|
const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
@@ -288,6 +292,7 @@ function buildSnapshot(raw, options) {
|
|
|
288
292
|
reusesOfficialAssets: boolOrNull(record.reusesOfficialAssets),
|
|
289
293
|
hasPasswordField: boolOrNull(record.hasPasswordField),
|
|
290
294
|
hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
|
|
295
|
+
...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: boolOrNull(record.hasExternalPasswordForm) }),
|
|
291
296
|
phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
|
|
292
297
|
privacyProtected: boolOrNull(record.privacyProtected),
|
|
293
298
|
idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
|
|
@@ -308,7 +313,7 @@ function buildSnapshot(raw, options) {
|
|
|
308
313
|
if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
|
|
309
314
|
fields.opportunityModelVersion = null;
|
|
310
315
|
if (webCollectionQuality) {
|
|
311
|
-
for (const field of ['hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
|
|
316
|
+
for (const field of ['hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
|
|
312
317
|
if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
|
|
313
318
|
fields[field] = null;
|
|
314
319
|
}
|
|
@@ -338,6 +343,7 @@ function buildSnapshot(raw, options) {
|
|
|
338
343
|
reusesOfficialAssets: null,
|
|
339
344
|
hasPasswordField: null,
|
|
340
345
|
hasExternalFormAction: null,
|
|
346
|
+
...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: null }),
|
|
341
347
|
phishingLanguageMatch: null,
|
|
342
348
|
pageBaselineMatch: null,
|
|
343
349
|
});
|
|
@@ -583,6 +589,7 @@ const COMPARE_FIELDS = [
|
|
|
583
589
|
{ field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
|
|
584
590
|
{ field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
|
|
585
591
|
{ field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
|
|
592
|
+
{ field: 'hasExternalPasswordForm', scope: 'web', label: 'Password form with declared external destination', type: 'signal', depthGate: 'both-deep' },
|
|
586
593
|
{ field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
|
|
587
594
|
{ field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
|
|
588
595
|
];
|
|
@@ -809,6 +816,10 @@ function compareField(spec, before, after) {
|
|
|
809
816
|
return null;
|
|
810
817
|
}
|
|
811
818
|
}
|
|
819
|
+
/** Field scope is owned by the same specification that gates comparisons. */
|
|
820
|
+
export function caseEvidenceFieldScope(field) {
|
|
821
|
+
return COMPARE_FIELDS.find(spec => spec.field === field)?.scope ?? null;
|
|
822
|
+
}
|
|
812
823
|
/**
|
|
813
824
|
* Diffs two normalized snapshots into a bounded, stably-ordered list of
|
|
814
825
|
* material changes. Timestamps, source, id and fingerprint are ignored;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { retainedCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
2
|
+
export function buildCaseIncidentCoverage(record) {
|
|
3
|
+
const objects = new Map();
|
|
4
|
+
const explicit = retainedCaseResponseObjects(record);
|
|
5
|
+
for (const target of record.workflowMetadata?.incidentTargets ?? []) {
|
|
6
|
+
const matching = explicit.filter(object => object.incidentTargetId === target.id && object.identifier === target.url);
|
|
7
|
+
for (const object of matching.length ? matching : [{ kind: 'other', identifier: target.url, incidentTargetId: target.id }])
|
|
8
|
+
objects.set(JSON.stringify(object), { target, object, targetRetained: true });
|
|
9
|
+
}
|
|
10
|
+
for (const object of explicit)
|
|
11
|
+
if (!objects.has(JSON.stringify(object)))
|
|
12
|
+
objects.set(JSON.stringify(object), {
|
|
13
|
+
object, targetRetained: false,
|
|
14
|
+
target: { id: object.incidentTargetId ?? `${object.kind}-${object.identifier}`, url: object.identifier, state: 'open', createdAt: record.createdAt ?? '', updatedAt: record.updatedAt ?? '' },
|
|
15
|
+
});
|
|
16
|
+
return [...objects.values()].map(({ target, object, targetRetained }) => {
|
|
17
|
+
const actions = (record.actions ?? []).filter(action => action.responseObjects?.some(bound => sameCaseResponseObject(bound, object)));
|
|
18
|
+
const reviews = (record.observedEffects?.reviews ?? []).filter(review => sameCaseResponseObject(review.responseObject, object));
|
|
19
|
+
const latestAt = Math.max(...reviews.map(review => Date.parse(review.observedAt)));
|
|
20
|
+
const latest = reviews.filter(review => Date.parse(review.observedAt) === latestAt);
|
|
21
|
+
return { target, targetRetained, responseObject: object,
|
|
22
|
+
hostname: object.kind === 'domain' || object.kind === 'hostname' ? object.identifier : new URL(object.identifier).hostname,
|
|
23
|
+
actionCoverage: actions.length ? 'bound' : 'unknown',
|
|
24
|
+
actionIds: actions.map(action => action.id),
|
|
25
|
+
providerEvents: (record.actions ?? []).flatMap(action => action.history.filter(event => event.applied
|
|
26
|
+
&& event.responseObjects?.some(bound => sameCaseResponseObject(bound, object))
|
|
27
|
+
&& (event.providerOutcome || event.objectOutcome || event.nextState === 'acknowledged'))
|
|
28
|
+
.map(event => ({ actionId: action.id, eventId: event.id, state: event.nextState, outcome: event.objectOutcome ?? event.providerOutcome, occurredAt: event.occurredAt, sourceClass: event.sourceClass }))),
|
|
29
|
+
observationCoverage: !reviews.length ? 'unknown' : new Set(latest.map(review => `${review.state}\u0000${review.objectOutcome ?? ''}`)).size > 1 ? 'ambiguous' : 'available',
|
|
30
|
+
reviews,
|
|
31
|
+
closures: (record.closures?.records ?? []).filter(closure => sameCaseResponseObject(closure.responseObject, object)), };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Local delivery linkage, not authentication, external acceptance or nonrepudiation.
|
|
2
|
+
import { array, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
|
|
3
|
+
import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
|
|
4
|
+
import { MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS } from "../contracts/case-portability.mjs";
|
|
5
|
+
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
6
|
+
import { readCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
7
|
+
import { RESPONSE_PACKET_PROFILE_IDS } from "./case-response-packet-vocabulary.mjs";
|
|
8
|
+
export const CASE_DELIVERY_RECEIPT_LIMITATION = 'This retained local receipt identifies canonical packet JSON and analyst-recorded delivery, not delivered message or PDF bytes, external receipt, factual truth, authorship authentication or provider acceptance.';
|
|
9
|
+
const id = (value, label) => {
|
|
10
|
+
const result = text(value, label, 64);
|
|
11
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(result))
|
|
12
|
+
throw new TypeError(`${label} is invalid.`);
|
|
13
|
+
return result;
|
|
14
|
+
};
|
|
15
|
+
const digest = (value) => {
|
|
16
|
+
if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
|
|
17
|
+
throw new TypeError('Select an exact recorded packet digest.');
|
|
18
|
+
return value;
|
|
19
|
+
};
|
|
20
|
+
const packetVersion = (value) => {
|
|
21
|
+
if (!SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS.some(version => version === value))
|
|
22
|
+
throw new TypeError('The recorded packet version is unsupported.');
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const statement = (value, label, required = true) => {
|
|
26
|
+
const result = text(value, label, MAX_RESPONSE_RATIONALE_LENGTH, !required).trim();
|
|
27
|
+
if (required && !result)
|
|
28
|
+
throw new TypeError(`${label} is required.`);
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
export function readCaseDeliveryPacketReceipt(value) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
assertWorkspaceInputGraph(value, 'Delivery packet receipt');
|
|
35
|
+
const row = exact(value, ['version', 'caseId', 'actionId', 'target', 'packetDigestSha256', 'packetVersion', 'packetGeneratedAt', 'recipient', 'profile', 'responseObjects'], 'Delivery packet receipt');
|
|
36
|
+
if (row.version !== 1)
|
|
37
|
+
throw new TypeError('The delivery receipt version is unsupported.');
|
|
38
|
+
const target = text(row.target, 'Receipt target', 253);
|
|
39
|
+
if (!isValidAsciiHostname(target) || target !== target.toLowerCase())
|
|
40
|
+
throw new TypeError('The delivery receipt target is invalid.');
|
|
41
|
+
iso(row.packetGeneratedAt, 'Packet generation time');
|
|
42
|
+
const recipient = text(row.recipient, 'Receipt recipient', MAX_RESPONSE_RECIPIENT_LENGTH).trim();
|
|
43
|
+
if (!recipient)
|
|
44
|
+
throw new TypeError('The receipt recipient is required.');
|
|
45
|
+
const responseObjects = readCaseResponseObjects(row.responseObjects);
|
|
46
|
+
if (!responseObjects?.length)
|
|
47
|
+
throw new TypeError('The receipt requires an explicit nonempty delivered object scope.');
|
|
48
|
+
return Object.freeze({ version: 1, caseId: id(row.caseId, 'Receipt Case'), actionId: id(row.actionId, 'Receipt action'), target,
|
|
49
|
+
packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion), packetGeneratedAt: row.packetGeneratedAt,
|
|
50
|
+
recipient, profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Receipt audience'), responseObjects });
|
|
51
|
+
}
|
|
52
|
+
export function readCasePacketCorrection(value) {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
assertWorkspaceInputGraph(value, 'Packet correction');
|
|
56
|
+
const row = exact(value, ['version', 'purpose', 'deliveryEventId', 'packetDigestSha256', 'packetVersion', 'reason', 'previousStatement', 'correctedStatement', 'evidencePinIds', 'profile'], 'Packet correction');
|
|
57
|
+
if (row.version !== 1)
|
|
58
|
+
throw new TypeError('The correction version is unsupported.');
|
|
59
|
+
const purpose = enumeration(row.purpose, ['correction', 'retraction_request'], 'Correction purpose');
|
|
60
|
+
const pins = array(row.evidencePinIds, 'Correction evidence', MAX_DECISION_PIN_REFERENCES).map(value => id(value, 'Correction pin'));
|
|
61
|
+
if (!pins.length || new Set(pins).size !== pins.length)
|
|
62
|
+
throw new TypeError('Select distinct retained correction evidence pins.');
|
|
63
|
+
return Object.freeze({ version: 1, purpose, deliveryEventId: id(row.deliveryEventId, 'Original delivery event'),
|
|
64
|
+
packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion),
|
|
65
|
+
reason: statement(row.reason, 'Correction reason'), previousStatement: statement(row.previousStatement, 'Previous statement'),
|
|
66
|
+
correctedStatement: statement(row.correctedStatement, 'Corrected statement', purpose === 'correction'),
|
|
67
|
+
evidencePinIds: Object.freeze(pins), profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Correction audience') });
|
|
68
|
+
}
|
|
69
|
+
export function assertDeliveryPacketReceipt(receipt, event, actionId) {
|
|
70
|
+
if (!receipt)
|
|
71
|
+
return;
|
|
72
|
+
if (receipt.actionId !== actionId || !['authorised', 'submitted'].includes(event.previousState ?? '') || event.nextState !== 'submitted' || event.sourceClass !== 'analyst'
|
|
73
|
+
|| event.reference !== `response-packet-sha256:${receipt.packetDigestSha256}`
|
|
74
|
+
|| JSON.stringify(event.responseObjects ?? []) !== JSON.stringify(receipt.responseObjects)
|
|
75
|
+
|| (event.occurredAt && Date.parse(event.occurredAt) < Date.parse(receipt.packetGeneratedAt)))
|
|
76
|
+
throw new TypeError('The packet receipt must match its exact analyst-recorded delivery and object scope.');
|
|
77
|
+
}
|
|
78
|
+
export function correctionDelivery(actions, action, context) {
|
|
79
|
+
const correction = action.correction;
|
|
80
|
+
if (!correction)
|
|
81
|
+
return null;
|
|
82
|
+
const original = actions.find(item => item.id === action.originActionId);
|
|
83
|
+
const event = original?.history.find(item => item.id === correction.deliveryEventId);
|
|
84
|
+
const receipt = event?.packetReceipt;
|
|
85
|
+
if (!original || !event?.applied || !receipt)
|
|
86
|
+
throw new TypeError('An exact-linked correction requires the retained receipt of the explicitly selected delivery. Legacy digest-only or omitted receipts cannot authorise it.');
|
|
87
|
+
assertDeliveryPacketReceipt(receipt, event, original.id);
|
|
88
|
+
if (receipt.recipient === '[redacted]' || action.recipient === '[redacted]' || receipt.recipient !== action.recipient
|
|
89
|
+
|| original.recipient !== receipt.recipient || correction.profile !== receipt.profile
|
|
90
|
+
|| correction.packetDigestSha256 !== receipt.packetDigestSha256 || correction.packetVersion !== receipt.packetVersion
|
|
91
|
+
|| !action.responseObjects?.length || action.responseObjects.some(object => !receipt.responseObjects.some(previous => sameCaseResponseObject(previous, object))))
|
|
92
|
+
throw new TypeError('Correction recipient, version, digest, audience and object subset must match the selected delivered packet.');
|
|
93
|
+
if (context && (receipt.caseId !== context.caseId || receipt.target !== context.target || (context.profile && context.profile !== correction.profile)
|
|
94
|
+
|| (context.selectedPinIds && correction.evidencePinIds.some(pin => !context.selectedPinIds.has(pin)))))
|
|
95
|
+
throw new TypeError('Review the current Case target, audience and every retained correction pin before preparation.');
|
|
96
|
+
return { original, event, receipt };
|
|
97
|
+
}
|
|
98
|
+
/** Called only with the exact completed packet; never uses a later editable draft. */
|
|
99
|
+
export function deliveryReceiptForPacket(packet) {
|
|
100
|
+
const action = packet.escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId);
|
|
101
|
+
if (!action || !packet.recipientRoute || packet.authorisation.status !== 'authorised' || !action.responseObjects?.length)
|
|
102
|
+
throw new TypeError('A retained exact receipt requires an authorised action-bound packet with explicit object scope.');
|
|
103
|
+
return readCaseDeliveryPacketReceipt({ version: 1, caseId: packet.case.id, actionId: action.actionId, target: packet.case.domain,
|
|
104
|
+
packetDigestSha256: packet.integrity.digestSha256, packetVersion: packet.schemaVersion, packetGeneratedAt: packet.generatedAt,
|
|
105
|
+
recipient: packet.recipientRoute.contact, profile: packet.profile.id, responseObjects: action.responseObjects });
|
|
106
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
2
2
|
import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
3
|
-
import { enumeration,
|
|
3
|
+
import { enumeration, exactOptional, text } from "../evidence/artifact-structure.mjs";
|
|
4
|
+
import { readCaseResponseObject, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
4
5
|
import { casePinHasCompleteObservation } from "./case-evidence-quality.mjs";
|
|
5
6
|
export const CASE_RECHECK_CONDITIONS = Object.freeze({
|
|
6
7
|
unknown: 'Not established',
|
|
@@ -13,7 +14,7 @@ function reference(value, label) {
|
|
|
13
14
|
throw new TypeError(`${label} is invalid.`);
|
|
14
15
|
return id;
|
|
15
16
|
}
|
|
16
|
-
function contextFields(item) {
|
|
17
|
+
function contextFields(item, sourceVersion) {
|
|
17
18
|
const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
|
|
18
19
|
if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
|
|
19
20
|
throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
|
|
@@ -21,10 +22,16 @@ function contextFields(item) {
|
|
|
21
22
|
const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
|
|
22
23
|
if (!conditions.trim())
|
|
23
24
|
throw new TypeError('Describe the conditions needed to compare the observations.');
|
|
25
|
+
const responseObject = readCaseResponseObject(item.responseObject, sourceVersion);
|
|
26
|
+
if (responseObject && (responseObject.kind === 'domain' || responseObject.kind === 'hostname'
|
|
27
|
+
? responseObject.identifier : new URL(responseObject.identifier).hostname) !== targetHostname) {
|
|
28
|
+
throw new TypeError('The recheck target hostname must match the exact object snapshot.');
|
|
29
|
+
}
|
|
24
30
|
return Object.freeze({
|
|
25
31
|
targetHostname,
|
|
26
32
|
conditions,
|
|
27
33
|
baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin'),
|
|
34
|
+
...(responseObject ? { responseObject } : {}),
|
|
28
35
|
});
|
|
29
36
|
}
|
|
30
37
|
/** Optional current-format context; historical records have no invented question. */
|
|
@@ -33,19 +40,19 @@ export function readCaseRecheckContext(value, sourceVersion) {
|
|
|
33
40
|
return undefined;
|
|
34
41
|
if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
|
|
35
42
|
throw new TypeError('This Case format cannot contain structured recheck questions.');
|
|
36
|
-
return contextFields(
|
|
43
|
+
return contextFields(exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions'], ['responseObject'], 'Recheck question context'), sourceVersion);
|
|
37
44
|
}
|
|
38
45
|
export function readCaseRecheckAnswerContext(value, sourceVersion) {
|
|
39
46
|
if (value === undefined)
|
|
40
47
|
return undefined;
|
|
41
48
|
if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
|
|
42
49
|
throw new TypeError('This Case format cannot contain structured recheck answers.');
|
|
43
|
-
const item =
|
|
50
|
+
const item = exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], ['responseObject'], 'Recheck answer context');
|
|
44
51
|
const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
|
|
45
52
|
if (!question.trim())
|
|
46
53
|
throw new TypeError('A recheck answer requires its original question.');
|
|
47
54
|
return Object.freeze({
|
|
48
|
-
...contextFields(item),
|
|
55
|
+
...contextFields(item, sourceVersion),
|
|
49
56
|
questionId: reference(item.questionId, 'Recheck question ID'),
|
|
50
57
|
question,
|
|
51
58
|
conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison'),
|
|
@@ -92,6 +99,10 @@ const RECHECK_COMPARISON_MESSAGES = {
|
|
|
92
99
|
later_observation_needed: 'A later source observation is needed; reviewing the baseline again is not a recheck.',
|
|
93
100
|
baseline_target_mismatch: 'The baseline concerns a different hostname.',
|
|
94
101
|
different_field_or_source: 'The observations use different fields or sources.',
|
|
102
|
+
missing_object_baseline: 'An object-specific baseline is required for this exact-object comparison.',
|
|
103
|
+
baseline_object_mismatch: 'The baseline does not explicitly concern this same object.',
|
|
104
|
+
current_object_mismatch: 'The current observation does not explicitly concern this same object.',
|
|
105
|
+
incomplete_object_baseline: 'The exact-object baseline must be a complete source observation.',
|
|
95
106
|
};
|
|
96
107
|
/** Admissibility depends on these reasons, not on presentation warnings. */
|
|
97
108
|
export function caseRecheckComparisonBlockers(context, pins, current) {
|
|
@@ -102,6 +113,16 @@ export function caseRecheckComparisonBlockers(context, pins, current) {
|
|
|
102
113
|
: 'unconfirmed_conditions');
|
|
103
114
|
}
|
|
104
115
|
const baseline = pins.find((pin) => pin.id === context.baselinePinId);
|
|
116
|
+
if (context.responseObject) {
|
|
117
|
+
if (!baseline)
|
|
118
|
+
blockers.push('missing_object_baseline');
|
|
119
|
+
else if (!sameCaseResponseObject(baseline.responseObject, context.responseObject))
|
|
120
|
+
blockers.push('baseline_object_mismatch');
|
|
121
|
+
if (baseline && !casePinHasCompleteObservation(baseline))
|
|
122
|
+
blockers.push('incomplete_object_baseline');
|
|
123
|
+
if (!current || !sameCaseResponseObject(current.responseObject, context.responseObject))
|
|
124
|
+
blockers.push('current_object_mismatch');
|
|
125
|
+
}
|
|
105
126
|
if (context.baselinePinId && !baseline)
|
|
106
127
|
blockers.push('missing_baseline');
|
|
107
128
|
if (current) {
|
|
@@ -147,3 +168,12 @@ export function assertRecheckNonReproduction(state, context, completeness, pins,
|
|
|
147
168
|
throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
|
|
148
169
|
}
|
|
149
170
|
}
|
|
171
|
+
export const COMPARATIVE_CASE_OBJECT_OUTCOMES = Object.freeze(['removed', 'restricted', 'suspended', 'delisted', 'transferred', 'restored']);
|
|
172
|
+
/** Technical state changes need an exact-object baseline; procedural dispute is distinct. */
|
|
173
|
+
export function assertCaseObjectObservationOutcome(outcome, context, completeness, pins, current, observedAt) {
|
|
174
|
+
if (!outcome || !COMPARATIVE_CASE_OBJECT_OUTCOMES.includes(outcome))
|
|
175
|
+
return;
|
|
176
|
+
if (!context?.responseObject)
|
|
177
|
+
throw new TypeError('An independent object state change requires a saved exact-object baseline question and comparable evidence.');
|
|
178
|
+
assertRecheckNonReproduction('not_reproduced', context, completeness, pins, current, observedAt);
|
|
179
|
+
}
|