@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.
Files changed (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -6,20 +6,20 @@ const STATE_LABELS = Object.freeze({
6
6
  expiring: 'expiring/pending delete',
7
7
  registered: 'registered',
8
8
  });
9
- // Version 8 keeps unreviewed whole-page similarity as visible review context
10
- // without assigning it Risk points. Reviewed favicon and official-asset
11
- // observations remain bounded within the brand-presentation family, generic
12
- // operational properties remain supporting context, and missing evidence never
13
- // contributes points.
14
- export const RISK_MODEL_VERSION = 8;
9
+ // Version 9 requires form-scoped password/destination evidence. Older page-wide
10
+ // flags never establish that a password control belongs to an external form.
11
+ export const RISK_MODEL_VERSION = 9;
15
12
  export const RISK_REVIEW_THRESHOLD = 70;
16
13
  const CURRENT_RISK_MODEL = Object.freeze({
17
14
  modelVersion: RISK_MODEL_VERSION,
18
15
  includePageBaselineMatch: false,
16
+ requireFormAttribution: true,
19
17
  });
18
+ const RISK_MODEL_V8 = Object.freeze({ modelVersion: 8, includePageBaselineMatch: false, requireFormAttribution: false });
20
19
  const RISK_MODEL_V7 = Object.freeze({
21
20
  modelVersion: 7,
22
21
  includePageBaselineMatch: true,
22
+ requireFormAttribution: false,
23
23
  });
24
24
  const RISK_STATE_BASE = Object.freeze({
25
25
  registered: 6,
@@ -104,7 +104,7 @@ function scoreQuality(input) {
104
104
  if ([input.faviconMatch, input.faviconNearMatch, input.reusesOfficialAssets, input.pageBaselineMatch].some(hasBoolean)) {
105
105
  observedFamilies.push('brand-presentation');
106
106
  }
107
- if ([input.hasPasswordField, input.hasExternalFormAction].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
107
+ if ([input.hasPasswordField, input.hasExternalFormAction, input.hasExternalPasswordForm].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
108
108
  observedFamilies.push('credential-lure');
109
109
  }
110
110
  if (calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain).eligibleProviderCount > 0) {
@@ -159,8 +159,8 @@ function explainRiskScoreInternal(input, excludedFamily, model) {
159
159
  if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
160
160
  add('credential-lure', 'Suspicious urgency language observed', 8);
161
161
  }
162
- if (input.hasPasswordField === true && input.hasExternalFormAction === true) {
163
- add('credential-lure', 'Password form submits to an external origin', 10);
162
+ if (model.requireFormAttribution ? input.hasExternalPasswordForm === true : input.hasPasswordField === true && input.hasExternalFormAction === true) {
163
+ add('credential-lure', model.requireFormAttribution ? 'Password form declares an external destination' : 'Password form submits to an external origin', 10);
164
164
  }
165
165
  else if (input.hasPasswordField === true) {
166
166
  add('credential-lure', 'Login/password form present', 5);
@@ -254,6 +254,9 @@ export function computeRiskScore(input) {
254
254
  export function explainRiskScoreV7(input) {
255
255
  return explainRiskScoreInternal(input, null, RISK_MODEL_V7);
256
256
  }
257
+ export function explainRiskScoreV8(input) {
258
+ return explainRiskScoreInternal(input, null, RISK_MODEL_V8);
259
+ }
257
260
  // Retained only for deterministic offline comparison of reviewed calibration
258
261
  // datasets. Runtime lookups always use the current model above.
259
262
  export function explainRiskScoreV6(input) {
@@ -1,7 +1,7 @@
1
1
  import { MAX_SELECTED_FILES, SELECTED_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
2
2
  import { array, enumeration, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
3
3
  import { readRetainedFileReference } from "../evidence/retained-file.mjs";
4
- import { readImageDerivation } from "../evidence/image-regions.mjs";
4
+ import { declareImageDerivation, readImageDerivation } from "../evidence/image-regions.mjs";
5
5
  /** Case provenance is independent of deduplicated immutable content. */
6
6
  export function readCaseAttachment(value) {
7
7
  const item = exactOptional(value, ['id', 'fileName', 'mediaType', 'source', 'observedAt', 'retainedAt', 'digestSha256', 'byteLength'], ['derivation'], 'Case attachment');
@@ -50,6 +50,41 @@ export function mergeCaseAttachments(local, incoming) {
50
50
  export function caseAttachmentReferences(cases) {
51
51
  return cases.flatMap(record => (record.attachments ?? []).map(({ digestSha256, byteLength }) => ({ digestSha256, byteLength })));
52
52
  }
53
+ /** Follow declared parents in one admitted Case's metadata; never resolve files or other Cases. */
54
+ export function reviewCaseImageDerivation(attachment, retained) {
55
+ if (!attachment.derivation)
56
+ return null;
57
+ if (!Array.isArray(retained) || retained.length > MAX_SELECTED_FILES)
58
+ throw new TypeError('Image history exceeds the Case attachment bound.');
59
+ const references = new Map();
60
+ for (const item of retained)
61
+ references.set(item.id, references.has(item.id) ? null : item);
62
+ const steps = [];
63
+ const finish = (termination) => Object.freeze({ steps: Object.freeze(steps), termination });
64
+ const visited = new Set([attachment.id]);
65
+ let current = attachment;
66
+ for (let depth = 0; depth < MAX_SELECTED_FILES; depth++) {
67
+ const derivation = current.derivation;
68
+ const declaration = declareImageDerivation(derivation);
69
+ const parent = references.get(derivation.sourceAttachmentId);
70
+ const matches = parent?.mediaType === 'image/png'
71
+ && parent.digestSha256 === declaration.source.digestSha256
72
+ && parent.byteLength === declaration.source.byteLength;
73
+ const parentReference = parent === undefined ? 'missing' : matches ? 'matching' : 'conflicting';
74
+ steps.push(Object.freeze({ declaration, parentReference, parentHasDeclaredEdits: matches ? Boolean(parent?.derivation) : null }));
75
+ if (parentReference === 'missing')
76
+ return finish('missing_parent');
77
+ if (!matches || !parent)
78
+ return finish('conflicting_parent');
79
+ if (visited.has(parent.id))
80
+ return finish('cycle');
81
+ if (!parent.derivation)
82
+ return finish('no_further_declaration');
83
+ visited.add(parent.id);
84
+ current = parent;
85
+ }
86
+ return finish('limit');
87
+ }
53
88
  export function addCaseAttachments(record, attachments, updatedAt) {
54
89
  iso(updatedAt, 'Case update time');
55
90
  return { ...record, attachments: mergeCaseAttachments(record.attachments, attachments) ?? [], updatedAt };
@@ -0,0 +1,68 @@
1
+ import { MAX_CASE_EVIDENCE_PINS, COLLECTION_RECEIPT_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
2
+ import { parseLookupCollectionReceipt, lookupCollectionReceiptIdentity } from "../evidence/lookup-collection-receipt.mjs";
3
+ import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
+ /** Removing the last pin in a group also removes its context. No other pin edit
5
+ * needs bookkeeping, and normalisation never changes a pin or checkpoint ID. */
6
+ export function readCaseCheckpointReceipts(value, pins, sourceVersion) {
7
+ if (value === undefined)
8
+ return undefined;
9
+ if (sourceVersion != null && sourceVersion < COLLECTION_RECEIPT_CASE_SCHEMA_VERSION) {
10
+ throw new TypeError('Checkpoint collection receipts require Case schema 20.');
11
+ }
12
+ if (!Array.isArray(value) || value.length > MAX_CASE_EVIDENCE_PINS) {
13
+ throw new TypeError('Checkpoint collection receipts must fit the retained evidence-pin limit.');
14
+ }
15
+ const groups = new Set(pins.map(pin => pin.checkpointId).filter(Boolean));
16
+ const entries = new Map();
17
+ for (const raw of value) {
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
19
+ || Object.keys(raw).length !== 2 || !Object.hasOwn(raw, 'checkpointId') || !Object.hasOwn(raw, 'collectionReceipt')
20
+ || typeof raw.checkpointId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(raw.checkpointId)) {
21
+ throw new TypeError('A checkpoint collection receipt is malformed.');
22
+ }
23
+ const collectionReceipt = parseLookupCollectionReceipt(raw.collectionReceipt);
24
+ if (!collectionReceipt || collectionReceipt.targetClass !== 'domain')
25
+ throw new TypeError('A Case checkpoint requires a domain collection receipt.');
26
+ const previous = entries.get(raw.checkpointId);
27
+ if (previous && lookupCollectionReceiptIdentity(previous.collectionReceipt) !== lookupCollectionReceiptIdentity(collectionReceipt)) {
28
+ throw new TypeError('Conflicting collection receipts use the same checkpoint identity. No evidence was changed.');
29
+ }
30
+ entries.set(raw.checkpointId, { checkpointId: raw.checkpointId, collectionReceipt });
31
+ }
32
+ const retained = [...entries.values()].filter(entry => groups.has(entry.checkpointId))
33
+ .sort((a, b) => a.checkpointId < b.checkpointId ? -1 : a.checkpointId > b.checkpointId ? 1 : 0);
34
+ for (const entry of retained) {
35
+ if (pins.some(pin => pin.checkpointId === entry.checkpointId && pin.collectionDepth !== entry.collectionReceipt.requestedDepth)) {
36
+ throw new TypeError('The checkpoint collection receipt does not match its retained pins.');
37
+ }
38
+ }
39
+ return retained.length ? retained : undefined;
40
+ }
41
+ export function mergeCaseCheckpointReceipts(left, right, pins) {
42
+ const before = readCaseCheckpointReceipts(left.checkpointCollectionReceipts, left.evidencePins) ?? [];
43
+ const incoming = readCaseCheckpointReceipts(right.checkpointCollectionReceipts, right.evidencePins) ?? [];
44
+ // Each input is bounded independently. Deduplicate before applying the final
45
+ // group bound so re-importing a full Case remains idempotent.
46
+ const byId = new Map(before.map(entry => [entry.checkpointId, entry]));
47
+ for (const entry of incoming) {
48
+ const previous = byId.get(entry.checkpointId);
49
+ if (previous && lookupCollectionReceiptIdentity(previous.collectionReceipt) !== lookupCollectionReceiptIdentity(entry.collectionReceipt)) {
50
+ throw new TypeError('Conflicting collection receipts use the same checkpoint identity. No evidence was changed.');
51
+ }
52
+ byId.set(entry.checkpointId, entry);
53
+ }
54
+ const groups = new Set(pins.map(pin => pin.checkpointId));
55
+ const retained = [...byId.values()].filter(entry => groups.has(entry.checkpointId));
56
+ for (const entry of retained) {
57
+ // A colliding group ID must not lend its receipt to unrelated retained
58
+ // pins. Identical imports and partial retention remain valid.
59
+ const sourcePins = [before.some(item => item.checkpointId === entry.checkpointId) ? left.evidencePins : [],
60
+ incoming.some(item => item.checkpointId === entry.checkpointId) ? right.evidencePins : []].flat();
61
+ for (const pin of pins.filter(pin => pin.checkpointId === entry.checkpointId)) {
62
+ if (!sourcePins.some(source => source.id === pin.id && canonicalArtifactJsonV2(source) === canonicalArtifactJsonV2(pin))) {
63
+ throw new TypeError('A checkpoint identity belongs to different retained pins. Its collection context cannot be reassigned.');
64
+ }
65
+ }
66
+ }
67
+ return readCaseCheckpointReceipts(retained, pins);
68
+ }
@@ -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
+ }
@@ -115,6 +115,34 @@ export function caseEvidenceLinkIssues(links, pins) {
115
115
  cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
116
116
  }));
117
117
  }
118
+ /** Follow declared sources, not automatic context matches or withdrawn links. */
119
+ export function traceCaseEvidenceSources(links, rootPinIds) {
120
+ array(links, 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
121
+ const roots = [...new Set(array(rootPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS).map(identifier))];
122
+ const neighbours = new Map();
123
+ function add(from, to, linkId) {
124
+ const entries = neighbours.get(from) ?? [];
125
+ entries.push({ linkId, pinId: to });
126
+ neighbours.set(from, entries);
127
+ }
128
+ for (const link of links)
129
+ if (!link.withdrawal) {
130
+ add(link.fromPinId, link.toPinId, link.id);
131
+ if (link.kind === 'shared_source')
132
+ add(link.toPinId, link.fromPinId, link.id);
133
+ }
134
+ const pinIds = new Set(roots), linkIds = new Set();
135
+ // Set iteration visits newly reached pins once, including unresolved references.
136
+ for (const pinId of pinIds)
137
+ for (const edge of neighbours.get(pinId) ?? []) {
138
+ linkIds.add(edge.linkId);
139
+ pinIds.add(edge.pinId);
140
+ }
141
+ return {
142
+ rootPinIds: roots, pinIds: [...pinIds], linkIds: [...linkIds],
143
+ withdrawnLinkIds: links.filter(link => link.withdrawal && (pinIds.has(link.fromPinId) || pinIds.has(link.toPinId))).map(link => link.id),
144
+ };
145
+ }
118
146
  /** Matching context is disclosed, never counted as independent corroboration. */
119
147
  export function caseEvidenceSharedContext(pins) {
120
148
  const groups = new Map();
@@ -133,3 +161,19 @@ export function caseEvidenceSharedContext(pins) {
133
161
  }
134
162
  return [...groups.values()].filter(group => group.pinIds.length > 1);
135
163
  }
164
+ 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.';
165
+ /** Qualify a narrow disclosure without exposing relationship identities or private basis text. */
166
+ export function caseSelectedEvidenceSourceLimitations(pins, links, selectedPinIds) {
167
+ array(pins, 'Retained evidence pins', MAX_CASE_EVIDENCE_PINS);
168
+ array(links ?? [], 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
169
+ array(selectedPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS);
170
+ const requested = new Set(selectedPinIds);
171
+ const selectedPins = pins.filter(pin => requested.has(pin.id));
172
+ const selected = new Set(selectedPins.map(pin => pin.id));
173
+ if (!selected.size)
174
+ return [];
175
+ const declared = links?.some(link => !link.withdrawal
176
+ && (selected.has(link.fromPinId) || selected.has(link.toPinId)));
177
+ return declared || caseEvidenceSharedContext(selectedPins).length
178
+ ? [CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION] : [];
179
+ }
@@ -5,7 +5,8 @@ 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 { COLLECTION_RECEIPT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
9
+ import { parseLookupCollectionReceipt, lookupCollectionReceiptIdentity } from "../evidence/lookup-collection-receipt.mjs";
9
10
  import { validWebObservationMode } from "../evidence/lookup-target.mjs";
10
11
  import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
11
12
  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 +114,7 @@ const DEEP_SIGNAL_FIELDS = [
113
114
  'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
114
115
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
115
116
  'pageBaselineMatch',
117
+ 'hasExternalPasswordForm',
116
118
  ];
117
119
  // Ordered list of the fields that make up a snapshot's *material* identity -
118
120
  // everything except capture timestamps, snapshot id and source. `scanDepth` is
@@ -124,6 +126,7 @@ const MATERIAL_FIELD_ORDER = [
124
126
  'webObservationMode',
125
127
  'scanDepth',
126
128
  'webCollectionQuality',
129
+ 'collectionReceipt',
127
130
  'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
128
131
  'riskFactors', 'opportunityFactors',
129
132
  'registrar', 'createdDate', 'expiryDate', 'nameservers',
@@ -135,6 +138,7 @@ const MATERIAL_FIELD_ORDER = [
135
138
  'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
136
139
  'profileContextState', 'profileContextLimitation',
137
140
  'mutationTypes',
141
+ 'hasExternalPasswordForm',
138
142
  ];
139
143
  // The canonical, comparison-safe value of a material field. Registrar casing,
140
144
  // nameserver order, and sub-day timestamps are collapsed so they can never
@@ -176,7 +180,7 @@ function isEmptyMaterial(value) {
176
180
  }
177
181
  // Fields that describe the capture rather than assert evidence, so they never
178
182
  // on their own keep an otherwise-empty snapshot alive.
179
- const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
183
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'collectionReceipt', 'confidence']);
180
184
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
181
185
  // bare confidence/unknown-availability) is dropped rather than added to a
182
186
  // timeline.
@@ -196,7 +200,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
196
200
  const value = materialValue(field, snapshot, legacyOrder);
197
201
  // Optional collection context does not alter historical fingerprints when
198
202
  // absent; recorded context separates otherwise-identical captures.
199
- if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality') && value === null)
203
+ if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality' || field === 'collectionReceipt' || field === 'hasExternalPasswordForm') && value === null)
200
204
  continue;
201
205
  canonical[field] = value;
202
206
  }
@@ -218,11 +222,30 @@ export function normalizeSnapshot(raw, options = {}) {
218
222
  const built = buildSnapshot(raw, options);
219
223
  return built ? built.snapshot : null;
220
224
  }
225
+ /** Shared artefacts use receipt-free identities, not hashes of private choices.
226
+ * Unchanged historical histories keep their exact identifiers. */
227
+ export function caseEvidenceWithoutCollectionReceipts(record) {
228
+ if (!Array.isArray(record.evidenceHistory))
229
+ return [];
230
+ if (!record.evidenceHistory.some(snapshot => snapshot?.collectionReceipt !== undefined)) {
231
+ return structuredClone(record.evidenceHistory);
232
+ }
233
+ return normalizeEvidenceHistory(record.evidenceHistory.map(snapshot => {
234
+ if (!snapshot || typeof snapshot !== 'object')
235
+ return snapshot;
236
+ const { collectionReceipt: _receipt, ...evidence } = snapshot;
237
+ return evidence;
238
+ }), {
239
+ fallback: null, caseDomain: record.domain,
240
+ });
241
+ }
221
242
  /** @returns {{ snapshot: CaseEvidenceSnapshot, material: string } | null} */
222
243
  function buildSnapshot(raw, options) {
223
244
  if (!raw || typeof raw !== 'object')
224
245
  return null;
225
246
  const record = objectRecord(raw);
247
+ if (record.hasExternalPasswordForm !== undefined && options.sourceVersion != null && options.sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
248
+ throw new TypeError('Password-form attribution requires Case schema 18 or later; historical evidence was not reinterpreted.');
226
249
  if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
227
250
  return null;
228
251
  const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
@@ -232,6 +255,13 @@ function buildSnapshot(raw, options) {
232
255
  // captures declare code-unit ordering; the declaration survives every save.
233
256
  const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
234
257
  const scanDepth = normalizeScanDepth(record.scanDepth);
258
+ const collectionReceipt = parseLookupCollectionReceipt(record.collectionReceipt);
259
+ if (collectionReceipt && options.sourceVersion != null && options.sourceVersion < COLLECTION_RECEIPT_CASE_SCHEMA_VERSION) {
260
+ throw new TypeError('Collection receipts require Case schema 20 or later; historical evidence was not reinterpreted.');
261
+ }
262
+ if (collectionReceipt && (collectionReceipt.targetClass !== 'domain' || collectionReceipt.requestedDepth !== scanDepth)) {
263
+ throw new TypeError('Collection receipt does not match the Case assessment.');
264
+ }
235
265
  const httpSummary = normalizeHttpSummary(record);
236
266
  const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
237
267
  if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
@@ -255,6 +285,7 @@ function buildSnapshot(raw, options) {
255
285
  ...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
256
286
  scanDepth,
257
287
  ...(webCollectionQuality ? { webCollectionQuality } : {}),
288
+ ...(collectionReceipt ? { collectionReceipt } : {}),
258
289
  availability: evidenceString(record.availability),
259
290
  confidence: evidenceString(record.confidence),
260
291
  riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
@@ -288,6 +319,7 @@ function buildSnapshot(raw, options) {
288
319
  reusesOfficialAssets: boolOrNull(record.reusesOfficialAssets),
289
320
  hasPasswordField: boolOrNull(record.hasPasswordField),
290
321
  hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
322
+ ...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: boolOrNull(record.hasExternalPasswordForm) }),
291
323
  phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
292
324
  privacyProtected: boolOrNull(record.privacyProtected),
293
325
  idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
@@ -308,7 +340,7 @@ function buildSnapshot(raw, options) {
308
340
  if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
309
341
  fields.opportunityModelVersion = null;
310
342
  if (webCollectionQuality) {
311
- for (const field of ['hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
343
+ for (const field of ['hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
312
344
  if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
313
345
  fields[field] = null;
314
346
  }
@@ -338,6 +370,7 @@ function buildSnapshot(raw, options) {
338
370
  reusesOfficialAssets: null,
339
371
  hasPasswordField: null,
340
372
  hasExternalFormAction: null,
373
+ ...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: null }),
341
374
  phishingLanguageMatch: null,
342
375
  pageBaselineMatch: null,
343
376
  });
@@ -519,9 +552,14 @@ function buildEvidenceTimeline(history, enforceCollectionQuality) {
519
552
  const incomparableReasons = orderingLimitation
520
553
  ? ['other']
521
554
  : comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
522
- if (comparable && !changes.length && !incomparableReasons.length
523
- && canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
524
- incomparableReasons.push('other');
555
+ const collectionContextChanged = Boolean(comparable
556
+ && lookupCollectionReceiptIdentity(previous.snapshot.collectionReceipt) !== lookupCollectionReceiptIdentity(snapshot.collectionReceipt));
557
+ if (comparable && !changes.length && !incomparableReasons.length) {
558
+ const { collectionReceipt: _beforeReceipt, ...before } = previous.snapshot;
559
+ const { collectionReceipt: _afterReceipt, ...after } = snapshot;
560
+ if (canonicalMaterialString(before) !== canonicalMaterialString(after))
561
+ incomparableReasons.push('other');
562
+ }
525
563
  return {
526
564
  snapshot,
527
565
  isBaseline,
@@ -530,6 +568,7 @@ function buildEvidenceTimeline(history, enforceCollectionQuality) {
530
568
  hasIncomparableChange: incomparableReasons.length > 0,
531
569
  incomparableReasons,
532
570
  orderingLimitation,
571
+ collectionContextChanged,
533
572
  };
534
573
  });
535
574
  }
@@ -583,6 +622,7 @@ const COMPARE_FIELDS = [
583
622
  { field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
584
623
  { field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
585
624
  { field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
625
+ { field: 'hasExternalPasswordForm', scope: 'web', label: 'Password form with declared external destination', type: 'signal', depthGate: 'both-deep' },
586
626
  { field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
587
627
  { field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
588
628
  ];
@@ -809,6 +849,10 @@ function compareField(spec, before, after) {
809
849
  return null;
810
850
  }
811
851
  }
852
+ /** Field scope is owned by the same specification that gates comparisons. */
853
+ export function caseEvidenceFieldScope(field) {
854
+ return COMPARE_FIELDS.find(spec => spec.field === field)?.scope ?? null;
855
+ }
812
856
  /**
813
857
  * Diffs two normalized snapshots into a bounded, stably-ordered list of
814
858
  * material changes. Timestamps, source, id and fingerprint are ignored;