@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.
Files changed (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. 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, exact, text } from "../evidence/artifact-structure.mjs";
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(exact(value, ['targetHostname', 'baselinePinId', 'conditions'], 'Recheck question context'));
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 = exact(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], 'Recheck answer context');
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
+ }