@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
@@ -0,0 +1,80 @@
1
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
3
+ export const MAX_DMARC_DISCOVERY_OWNERS = 8;
4
+ function closedRecord(value, keys) {
5
+ if (!value || typeof value !== 'object' || Array.isArray(value)
6
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
7
+ || Reflect.ownKeys(value).length !== keys.length)
8
+ throw new TypeError('DMARC policy context has an invalid structure.');
9
+ const result = {};
10
+ for (const key of keys) {
11
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
12
+ if (!descriptor?.enumerable || !('value' in descriptor))
13
+ throw new TypeError('DMARC policy context requires ordinary data fields.');
14
+ result[key] = descriptor.value;
15
+ }
16
+ return result;
17
+ }
18
+ function owner(value) {
19
+ return typeof value === 'string' && value.length <= 253
20
+ && isValidAsciiHostname(value, { requireDot: false, requireLowercase: true });
21
+ }
22
+ function time(value) {
23
+ if (value === null)
24
+ return null;
25
+ const normalized = typeof value === 'string' && value.length <= 64 ? normalizeExplicitIsoTimestamp(value) : null;
26
+ if (normalized === null)
27
+ throw new TypeError('DMARC policy context has an invalid observation time.');
28
+ return normalized;
29
+ }
30
+ /** Current opted-in review only; absent historical context is never reconstructed. */
31
+ export function normalizeDmarcPolicyContext(value, expectedDomain) {
32
+ if (value === undefined)
33
+ return undefined;
34
+ const input = closedRecord(value, ['version', 'source', 'relation', 'complete', 'owner', 'observedAt', 'policy', 'subdomainPolicy', 'nonexistentPolicy', 'psd', 'testMode', 'discovery']);
35
+ if (input.version !== 1 || input.source !== 'recursive_dns_txt' || typeof input.complete !== 'boolean'
36
+ || typeof input.relation !== 'string' || !['exact', 'inherited', 'undetermined'].includes(input.relation)
37
+ || !Array.isArray(input.discovery) || input.discovery.length < 1 || input.discovery.length > MAX_DMARC_DISCOVERY_OWNERS) {
38
+ throw new TypeError('DMARC policy context has an unsupported version or discovery state.');
39
+ }
40
+ const discovery = [];
41
+ for (let index = 0; index < input.discovery.length; index++) {
42
+ const entry = Object.getOwnPropertyDescriptor(input.discovery, String(index));
43
+ if (!entry?.enumerable || !('value' in entry))
44
+ throw new TypeError('DMARC discovery requires ordinary entries.');
45
+ const row = closedRecord(entry.value, ['owner', 'state', 'observedAt']);
46
+ if (!owner(row.owner) || typeof row.state !== 'string' || !['observed', 'not_found', 'invalid', 'unavailable'].includes(row.state)) {
47
+ throw new TypeError('DMARC discovery has an invalid owner or state.');
48
+ }
49
+ discovery.push(Object.freeze({ owner: row.owner, state: row.state, observedAt: time(row.observedAt) }));
50
+ }
51
+ if (new Set(discovery.map(row => row.owner)).size !== discovery.length
52
+ || (expectedDomain !== undefined && discovery[0].owner !== expectedDomain)
53
+ || discovery.some((row, index) => index > 0 && !discovery[index - 1].owner.endsWith(`.${row.owner}`))
54
+ || (input.complete && discovery.some(row => row.state === 'unavailable'))) {
55
+ throw new TypeError('DMARC discovery does not match one bounded owner walk.');
56
+ }
57
+ const observedAt = time(input.observedAt);
58
+ if (input.relation === 'undetermined') {
59
+ if ([input.owner, observedAt, input.policy, input.subdomainPolicy, input.nonexistentPolicy, input.psd, input.testMode].some(field => field !== null)) {
60
+ throw new TypeError('Undetermined DMARC policy must not declare a selected policy.');
61
+ }
62
+ }
63
+ else {
64
+ const selected = discovery.find(row => row.owner === input.owner);
65
+ if (!input.complete || !owner(input.owner) || !selected || selected.state !== 'observed' || selected.observedAt !== observedAt
66
+ || (input.relation === 'exact') !== (input.owner === discovery[0].owner)
67
+ || [input.policy, input.subdomainPolicy, input.nonexistentPolicy].some(field => typeof field !== 'string' || !['none', 'quarantine', 'reject'].includes(field))
68
+ || typeof input.psd !== 'string' || !['u', 'n', 'y'].includes(input.psd) || typeof input.testMode !== 'boolean') {
69
+ throw new TypeError('DMARC policy does not match a complete selected observation.');
70
+ }
71
+ }
72
+ return Object.freeze({
73
+ version: 1, source: 'recursive_dns_txt', relation: input.relation,
74
+ complete: input.complete, owner: input.owner, observedAt,
75
+ policy: input.policy, subdomainPolicy: input.subdomainPolicy,
76
+ nonexistentPolicy: input.nonexistentPolicy,
77
+ psd: input.psd, testMode: input.testMode,
78
+ discovery: Object.freeze(discovery),
79
+ });
80
+ }
@@ -5,6 +5,21 @@ export const MAX_EVIDENCE_IMAGE_PIXELS = 16 * 1024 * 1024;
5
5
  export const MAX_EVIDENCE_IMAGE_DIMENSION = 10_000;
6
6
  // Each selected region emits at most four solid rectangles; no nested effects.
7
7
  export const MAX_EVIDENCE_IMAGE_REGIONS = 64;
8
+ export function readImageDerivationDeclaration(raw) {
9
+ const value = exact(raw, ['method', 'source', 'operations'], 'Image derivation declaration');
10
+ if (value.method !== 'png-regions-v1')
11
+ throw new TypeError('This image derivation method is unsupported.');
12
+ const operations = Array.from(array(value.operations, 'Image operations', 2, 1), operation => enumeration(operation, ['redact', 'outline'], 'Image operation'));
13
+ if (new Set(operations).size !== operations.length)
14
+ throw new TypeError('Image operations must be unique.');
15
+ return Object.freeze({ method: value.method, source: readRetainedFileReference(value.source), operations: Object.freeze(operations) });
16
+ }
17
+ /** Retain the immediate parent's fingerprint, without identifiers or region coordinates. */
18
+ export function declareImageDerivation(raw) {
19
+ const derivation = readImageDerivation(raw);
20
+ const operations = ['redact', 'outline'].filter(kind => derivation.plan.regions.some(region => region.kind === kind));
21
+ return readImageDerivationDeclaration({ method: derivation.method, source: derivation.source, operations });
22
+ }
8
23
  export function readEvidenceImageDimensions(width, height) {
9
24
  const result = {
10
25
  width: integer(width, 'Image width', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
@@ -0,0 +1,140 @@
1
+ // A bounded account of one completed Lookup operation. This records collector
2
+ // execution, not proof of network delivery, independent verification or history.
3
+ export const LOOKUP_SOURCE_IDS = Object.freeze([
4
+ 'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
5
+ 'network_context', 'security_txt', 'external_intelligence',
6
+ 'malware_host_intelligence', 'malware_ioc_intelligence',
7
+ ]);
8
+ export const LOOKUP_SOURCE_STATES = Object.freeze([
9
+ 'success', 'partial', 'not_found', 'skipped', 'error', 'unsupported',
10
+ 'not_applicable', 'unavailable', 'rate_limited', 'complete', 'disabled', 'stale',
11
+ ]);
12
+ export const LOOKUP_COLLECTION_RECEIPT_VERSION = 1;
13
+ export const LOOKUP_CHOICE_KEYS = Object.freeze([
14
+ 'securityTxt', 'externalIntelligence', 'malwareHostIntelligence', 'malwareIocIntelligence',
15
+ ]);
16
+ export const LOOKUP_CHOICE_SOURCES = Object.freeze({
17
+ securityTxt: 'security_txt', externalIntelligence: 'external_intelligence',
18
+ malwareHostIntelligence: 'malware_host_intelligence', malwareIocIntelligence: 'malware_ioc_intelligence',
19
+ });
20
+ const ADMISSIONS = ['admitted', 'disabled', 'unsupported', 'not_applicable', 'prerequisite_unavailable', 'unknown'];
21
+ function fields(value, keys) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value))
23
+ throw new TypeError('Collection receipt must contain ordinary objects.');
24
+ const prototype = Object.getPrototypeOf(value);
25
+ if (prototype !== Object.prototype && prototype !== null)
26
+ throw new TypeError('Collection receipt must contain ordinary objects.');
27
+ const own = Reflect.ownKeys(value);
28
+ if (own.length !== keys.length || own.some(key => typeof key !== 'string' || !keys.includes(key))) {
29
+ throw new TypeError('Collection receipt contains an unsupported field set.');
30
+ }
31
+ const output = {};
32
+ for (const key of keys) {
33
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
34
+ if (!descriptor?.enumerable || !('value' in descriptor))
35
+ throw new TypeError('Collection receipt contains an invalid field.');
36
+ output[key] = descriptor.value;
37
+ }
38
+ return output;
39
+ }
40
+ function oneOf(value, options) {
41
+ if (typeof value !== 'string' || !options.includes(value))
42
+ throw new TypeError('Collection receipt contains an invalid state.');
43
+ return value;
44
+ }
45
+ function quality(value) {
46
+ if (value !== null && typeof value !== 'boolean')
47
+ throw new TypeError('Collection receipt quality must be explicit or unknown.');
48
+ return value;
49
+ }
50
+ export function lookupCollectionChoices(input) {
51
+ return Object.freeze({
52
+ securityTxt: input.securityTxt === true,
53
+ externalIntelligence: input.externalIntelligence === true,
54
+ malwareHostIntelligence: input.malwareHostIntelligence === true,
55
+ malwareIocIntelligence: input.malwareIocIntelligence === true,
56
+ });
57
+ }
58
+ /** Absence is historical unknown, never reconstructed as a negative choice. */
59
+ export function parseLookupCollectionReceipt(raw) {
60
+ if (raw === undefined)
61
+ return null;
62
+ const value = fields(raw, ['version', 'requestedDepth', 'targetClass', 'webSelection', 'originalChoices', 'sources']);
63
+ if (value.version !== LOOKUP_COLLECTION_RECEIPT_VERSION)
64
+ throw new TypeError('Collection receipt version is unsupported.');
65
+ const choices = fields(value.originalChoices, LOOKUP_CHOICE_KEYS);
66
+ if (Object.values(choices).some(choice => typeof choice !== 'boolean'))
67
+ throw new TypeError('Collection receipt choices must be explicit.');
68
+ const requestedDepth = oneOf(value.requestedDepth, ['fast', 'deep']);
69
+ const targetClass = oneOf(value.targetClass, ['domain', 'ipv4', 'ipv6', 'asn']);
70
+ const webSelection = oneOf(value.webSelection, ['hostname', 'selected_url', 'not_applicable']);
71
+ if ((targetClass !== 'domain' && webSelection !== 'not_applicable') || (webSelection === 'selected_url' && requestedDepth !== 'deep')) {
72
+ throw new TypeError('Collection receipt target and web selection disagree.');
73
+ }
74
+ if (!Array.isArray(value.sources) || Object.getPrototypeOf(value.sources) !== Array.prototype
75
+ || value.sources.length !== LOOKUP_SOURCE_IDS.length
76
+ || Reflect.ownKeys(value.sources).length !== LOOKUP_SOURCE_IDS.length + 1)
77
+ throw new TypeError('Collection receipt source inventory is invalid.');
78
+ const bySource = new Map();
79
+ for (let index = 0; index < LOOKUP_SOURCE_IDS.length; index++) {
80
+ const descriptor = Object.getOwnPropertyDescriptor(value.sources, String(index));
81
+ if (!descriptor?.enumerable || !('value' in descriptor))
82
+ throw new TypeError('Collection receipt source entry is invalid.');
83
+ const row = fields(descriptor.value, ['source', 'intent', 'admission', 'attempt', 'settlement', 'complete', 'truncated']);
84
+ const source = oneOf(row.source, LOOKUP_SOURCE_IDS);
85
+ if (bySource.has(source))
86
+ throw new TypeError('Collection receipt source is duplicated.');
87
+ const attempt = oneOf(row.attempt, ['started', 'not_started', 'unknown']);
88
+ const admission = oneOf(row.admission, ADMISSIONS);
89
+ const complete = quality(row.complete), truncated = quality(row.truncated);
90
+ const intent = oneOf(row.intent, ['requested', 'not_requested']);
91
+ const settlement = oneOf(row.settlement, [...LOOKUP_SOURCE_STATES, 'unknown']);
92
+ if (attempt === 'started' && admission !== 'admitted'
93
+ || attempt === 'started' && intent !== 'requested'
94
+ || attempt !== 'started' && (complete !== null || truncated !== null)
95
+ || attempt === 'not_started' && settlement !== 'unknown'
96
+ || complete === true && !['success', 'complete', 'not_found'].includes(settlement)
97
+ || complete === true && truncated === true)
98
+ throw new TypeError('Collection receipt execution and quality disagree.');
99
+ bySource.set(source, Object.freeze({
100
+ source, intent, admission, attempt, settlement, complete, truncated,
101
+ }));
102
+ }
103
+ for (const choice of LOOKUP_CHOICE_KEYS) {
104
+ if ((bySource.get(LOOKUP_CHOICE_SOURCES[choice]).intent === 'requested') !== choices[choice]) {
105
+ throw new TypeError('Collection receipt choice and source intent disagree.');
106
+ }
107
+ }
108
+ return Object.freeze({ version: LOOKUP_COLLECTION_RECEIPT_VERSION, requestedDepth, targetClass, webSelection,
109
+ originalChoices: lookupCollectionChoices(choices),
110
+ sources: Object.freeze(LOOKUP_SOURCE_IDS.map(source => bySource.get(source))),
111
+ });
112
+ }
113
+ /** Bind a validated execution account to this submission's pre-filter choices.
114
+ * A browser may omit an ineligible optional request; the server cannot know
115
+ * which unchecked/disabled control led to that omission. Do not invent why. */
116
+ export function bindLookupCollectionChoices(raw, originalChoices) {
117
+ const receipt = parseLookupCollectionReceipt(raw);
118
+ if (!receipt)
119
+ return null;
120
+ const choices = fields(originalChoices, LOOKUP_CHOICE_KEYS);
121
+ if (Object.values(choices).some(choice => typeof choice !== 'boolean'))
122
+ throw new TypeError('Collection receipt choices must be explicit.');
123
+ const sources = receipt.sources.map(row => {
124
+ const choice = LOOKUP_CHOICE_KEYS.find(key => LOOKUP_CHOICE_SOURCES[key] === row.source);
125
+ if (!choice || choices[choice] === receipt.originalChoices[choice])
126
+ return { ...row };
127
+ if (receipt.originalChoices[choice] || choices[choice] !== true || row.attempt !== 'not_started') {
128
+ throw new TypeError('Collection receipt does not match the submitted choices.');
129
+ }
130
+ return { ...row, intent: 'requested', admission: 'unknown' };
131
+ });
132
+ const bound = { ...receipt, originalChoices: lookupCollectionChoices(choices), sources };
133
+ parseLookupCollectionReceipt(bound);
134
+ return bound;
135
+ }
136
+ /** Stable, payload-free material comparison. Parsing also removes key order as a difference. */
137
+ export function lookupCollectionReceiptIdentity(raw) {
138
+ const receipt = parseLookupCollectionReceipt(raw);
139
+ return receipt ? JSON.stringify(receipt) : null;
140
+ }
@@ -0,0 +1,74 @@
1
+ import { LOOKUP_CHOICE_KEYS, LOOKUP_CHOICE_SOURCES, parseLookupCollectionReceipt } from "./lookup-collection-receipt.mjs";
2
+ export const LOOKUP_SOURCE_LABELS = Object.freeze({
3
+ rdap: 'Registry RDAP', whois: 'WHOIS chain', domain_evidence: 'Domain evidence',
4
+ reverse_dns: 'Reverse DNS', registrar_rdap: 'Registrar RDAP', network_context: 'Network context',
5
+ security_txt: 'security.txt', external_intelligence: 'Archived web intelligence',
6
+ malware_host_intelligence: 'Malware host intelligence', malware_ioc_intelligence: 'Malware infrastructure intelligence',
7
+ });
8
+ const ADMISSION_LABELS = Object.freeze({
9
+ admitted: 'Admitted', disabled: 'Disabled by policy', unsupported: 'Unsupported', not_applicable: 'Not applicable',
10
+ prerequisite_unavailable: 'Required earlier evidence unavailable', unknown: 'Admission not recorded',
11
+ });
12
+ const ATTEMPT_LABELS = Object.freeze({ started: 'Collector started', not_started: 'Collector not started', unknown: 'Collector start unknown' });
13
+ /** Inert guidance from recorded states, never an admission or retry decision. */
14
+ export function lookupSourceStateExplanation(state, source, receipt) {
15
+ if (receipt && receipt.source === source && receipt.attempt === 'not_started') {
16
+ if (receipt.intent === 'not_requested')
17
+ return 'Not requested in this Lookup. Select it explicitly in a new Lookup if needed.';
18
+ if (receipt.admission === 'disabled')
19
+ return 'Collection was disabled by policy. Retrying does not change that policy.';
20
+ if (receipt.admission === 'prerequisite_unavailable')
21
+ return 'Required earlier evidence was unavailable, so this collector did not start. Review that source first.';
22
+ if (receipt.admission === 'not_applicable')
23
+ return 'This collector did not apply to the submitted target or mode.';
24
+ if (receipt.admission === 'unsupported')
25
+ return 'Collection was not supported for this target; retrying unchanged inputs will not add evidence.';
26
+ }
27
+ switch (state) {
28
+ case 'success':
29
+ case 'complete':
30
+ return 'Collection completed within this source’s scope. Review its observations and time below.';
31
+ case 'not_found':
32
+ return 'This source returned no matching record. Its authority and search scope determine what that means.';
33
+ case 'partial':
34
+ case 'inconclusive':
35
+ return 'Evidence is incomplete or inconclusive. Missing fields remain unknown; a later retry may help if source conditions change.';
36
+ case 'error':
37
+ case 'unavailable':
38
+ return 'Usable evidence was not obtained. This is not an absence finding; retry may help after the reported problem changes.';
39
+ case 'rate_limited':
40
+ return 'The source reported a rate limit. Wait for its permitted retry window; no reset time is assumed.';
41
+ case 'unsupported':
42
+ return source === 'rdap' || source === 'whois'
43
+ ? 'No applicable service was recorded by registry policy or discovery. Retrying unchanged discovery may not add evidence.'
44
+ : 'This source does not support the target or request. Use an applicable source rather than repeating the same request.';
45
+ case 'skipped':
46
+ return 'This source was not queried under the selected mode or access policy. Review its source details before requesting it again.';
47
+ case 'disabled':
48
+ return 'Collection was disabled by policy. Retrying does not change that policy.';
49
+ case 'not_applicable':
50
+ return 'This source does not apply to the submitted target or mode.';
51
+ case 'stale':
52
+ return 'Retained evidence is out of date for this review. A deliberate refresh may provide a newer observation.';
53
+ default:
54
+ return 'The source outcome was not retained. Its request history and retry usefulness are unknown.';
55
+ }
56
+ }
57
+ /** One presentation of the closed receipt vocabulary for browser and terminal. */
58
+ export function lookupCollectionReceiptReview(raw) {
59
+ const receipt = parseLookupCollectionReceipt(raw);
60
+ if (!receipt)
61
+ return null;
62
+ return {
63
+ receipt,
64
+ selection: `${receipt.requestedDepth === 'deep' ? 'Deep' : 'Fast'} Lookup · ${receipt.webSelection === 'selected_url'
65
+ ? 'selected URL' : receipt.webSelection === 'hostname' ? 'hostname' : 'no web target'}`,
66
+ choices: LOOKUP_CHOICE_KEYS.map(choice => ({ label: LOOKUP_SOURCE_LABELS[LOOKUP_CHOICE_SOURCES[choice]], selected: receipt.originalChoices[choice] })),
67
+ sources: receipt.sources.map(row => ({
68
+ id: row.source, label: LOOKUP_SOURCE_LABELS[row.source],
69
+ summary: `${row.intent === 'requested' ? 'Requested' : 'Not requested'} · ${ADMISSION_LABELS[row.admission]} · ${ATTEMPT_LABELS[row.attempt]}`,
70
+ outcome: `Source result: ${row.settlement.replaceAll('_', ' ')} · ${row.complete === null ? 'completeness unknown' : row.complete ? 'complete' : 'not complete'} · ${row.truncated === null ? 'truncation unknown' : row.truncated ? 'truncated' : 'not truncated'}`,
71
+ explanation: lookupSourceStateExplanation(row.settlement, row.source, row),
72
+ })),
73
+ };
74
+ }
@@ -1,6 +1,7 @@
1
1
  // Shared collision-resistant identities for bounded, already normalised records.
2
2
  // Platform cryptography remains the owner of signatures and authentication.
3
3
  export const MAX_IDENTITY_DIGEST_BYTES = 1024 * 1024;
4
+ export const MAX_INCREMENTAL_DIGEST_BYTES = 256 * 1024 * 1024;
4
5
  const SHA256_CONSTANTS = Object.freeze([
5
6
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
6
7
  0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
@@ -14,27 +15,21 @@ const SHA256_CONSTANTS = Object.freeze([
14
15
  function rotateRight(value, bits) {
15
16
  return (value >>> bits) | (value << (32 - bits));
16
17
  }
17
- export function sha256IdentityHex(bytes) {
18
- if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
19
- throw new RangeError('Record identity exceeds its byte limit.');
20
- // This digest is a collision-resistant record identity, not a password,
21
- // signature, MAC, or substitute for the platform cryptography used there.
22
- const paddedLength = Math.ceil((bytes.byteLength + 9) / 64) * 64;
23
- const padded = new Uint8Array(paddedLength);
24
- padded.set(bytes);
25
- padded[bytes.byteLength] = 0x80;
26
- const view = new DataView(padded.buffer);
27
- const bitLength = bytes.byteLength * 8;
28
- view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000), false);
29
- view.setUint32(paddedLength - 4, bitLength >>> 0, false);
18
+ /** Incremental content identity only; never authentication or publisher proof. */
19
+ export function createIncrementalSha256(maximumBytes = MAX_IDENTITY_DIGEST_BYTES) {
20
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0 || maximumBytes > MAX_INCREMENTAL_DIGEST_BYTES)
21
+ throw new RangeError('Content identity has an invalid byte limit.');
30
22
  const state = new Uint32Array([
31
23
  0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
32
24
  0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
33
25
  ]);
34
26
  const words = new Uint32Array(64);
35
- for (let offset = 0; offset < paddedLength; offset += 64) {
27
+ const pending = new Uint8Array(64);
28
+ let pendingBytes = 0, totalBytes = 0, finished = false;
29
+ function block(bytes, offset) {
30
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 64);
36
31
  for (let index = 0; index < 16; index += 1)
37
- words[index] = view.getUint32(offset + index * 4, false);
32
+ words[index] = view.getUint32(index * 4, false);
38
33
  for (let index = 16; index < 64; index += 1) {
39
34
  const left = words[index - 15];
40
35
  const right = words[index - 2];
@@ -68,5 +63,54 @@ export function sha256IdentityHex(bytes) {
68
63
  state[6] = (state[6] + g) >>> 0;
69
64
  state[7] = (state[7] + h) >>> 0;
70
65
  }
71
- return [...state].map((value) => value.toString(16).padStart(8, '0')).join('');
66
+ return Object.freeze({
67
+ update(bytes) {
68
+ if (finished)
69
+ throw new Error('Content identity is already finalised.');
70
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength > maximumBytes - totalBytes)
71
+ throw new RangeError('Content identity exceeds its byte limit.');
72
+ totalBytes += bytes.byteLength;
73
+ let offset = 0;
74
+ if (pendingBytes) {
75
+ const length = Math.min(64 - pendingBytes, bytes.byteLength);
76
+ pending.set(bytes.subarray(0, length), pendingBytes);
77
+ pendingBytes += length;
78
+ offset += length;
79
+ if (pendingBytes === 64) {
80
+ block(pending, 0);
81
+ pendingBytes = 0;
82
+ }
83
+ }
84
+ while (offset + 64 <= bytes.byteLength) {
85
+ block(bytes, offset);
86
+ offset += 64;
87
+ }
88
+ if (offset < bytes.byteLength) {
89
+ pending.set(bytes.subarray(offset), 0);
90
+ pendingBytes = bytes.byteLength - offset;
91
+ }
92
+ },
93
+ digestHex() {
94
+ if (finished)
95
+ throw new Error('Content identity is already finalised.');
96
+ finished = true;
97
+ const padding = new Uint8Array(pendingBytes < 56 ? 64 : 128);
98
+ padding.set(pending.subarray(0, pendingBytes));
99
+ padding[pendingBytes] = 0x80;
100
+ const view = new DataView(padding.buffer), bits = totalBytes * 8;
101
+ view.setUint32(padding.length - 8, Math.floor(bits / 0x1_0000_0000), false);
102
+ view.setUint32(padding.length - 4, bits >>> 0, false);
103
+ block(padding, 0);
104
+ if (padding.length === 128)
105
+ block(padding, 64);
106
+ return [...state].map(value => value.toString(16).padStart(8, '0')).join('');
107
+ },
108
+ });
109
+ }
110
+ export function sha256IdentityHex(bytes) {
111
+ if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
112
+ throw new RangeError('Record identity exceeds its byte limit.');
113
+ const digest = createIncrementalSha256();
114
+ digest.update(bytes);
115
+ return digest.digestHex();
72
116
  }
@@ -0,0 +1,51 @@
1
+ const TECHNOLOGY_EVIDENCE_ROLE_ORDER = Object.freeze([
2
+ 'observed_edge',
3
+ 'application_platform',
4
+ 'framework_runtime',
5
+ 'embedded_dependency',
6
+ ]);
7
+ const TECHNOLOGY_EVIDENCE_ROLE_SET = new Set(TECHNOLOGY_EVIDENCE_ROLE_ORDER);
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : {};
12
+ }
13
+ function technologyEvidenceRoles(value) {
14
+ const finding = record(value);
15
+ const roles = new Set();
16
+ let hasExplicitRole = false;
17
+ for (const role of Array.isArray(finding.roles) ? finding.roles.slice(0, 4) : []) {
18
+ if (typeof role === 'string' && TECHNOLOGY_EVIDENCE_ROLE_SET.has(role)) {
19
+ roles.add(role);
20
+ hasExplicitRole = true;
21
+ }
22
+ }
23
+ const evidenceValues = Array.isArray(finding.evidence) ? finding.evidence.slice(0, 4) : [];
24
+ for (const evidenceValue of evidenceValues) {
25
+ const evidence = record(evidenceValue);
26
+ if (typeof evidence.role === 'string'
27
+ && TECHNOLOGY_EVIDENCE_ROLE_SET.has(evidence.role)) {
28
+ roles.add(evidence.role);
29
+ hasExplicitRole = true;
30
+ }
31
+ }
32
+ if (!hasExplicitRole) {
33
+ const resourceOriginOnly = evidenceValues.length > 0
34
+ && evidenceValues.every((evidence) => (String(record(evidence).source).toLowerCase() === 'resource origin'));
35
+ if (evidenceValues.some((evidence) => String(record(evidence).source).toLowerCase() === 'resource origin')) {
36
+ roles.add('embedded_dependency');
37
+ }
38
+ if (!resourceOriginOnly) {
39
+ const category = String(finding.category).toLowerCase();
40
+ if (category === 'delivery platform')
41
+ roles.add('observed_edge');
42
+ else if (['application runtime', 'web framework', 'static site generator', 'web server'].includes(category)) {
43
+ roles.add('framework_runtime');
44
+ }
45
+ else
46
+ roles.add('application_platform');
47
+ }
48
+ }
49
+ return TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => roles.has(role));
50
+ }
51
+ export { TECHNOLOGY_EVIDENCE_ROLE_ORDER, technologyEvidenceRoles, };
@@ -7,6 +7,7 @@ import { sha256IdentityHex } from "../evidence/record-identity.mjs";
7
7
  import { MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, normalizeCaseEvidencePins, normalizeCaseSightings, } from "../cases/case-response-model.mjs";
8
8
  import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
9
9
  import { hasUnsafeRetainedText } from "./retained-text.mjs";
10
+ import { readInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
10
11
  export { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, };
11
12
  export const EXTERNAL_FINDING_CATEGORIES = [
12
13
  'certificate',
@@ -33,6 +34,7 @@ const FINDING_KEYS = new Set([
33
34
  'limitations',
34
35
  'reference',
35
36
  'structuredObservation',
37
+ 'infrastructureObservation',
36
38
  ]);
37
39
  const STRUCTURED_OBSERVATION_KEYS = new Set([
38
40
  'sourceSchema',
@@ -185,14 +187,14 @@ function structuredObservation(value, index) {
185
187
  function findingKey(finding, sourceName) {
186
188
  return canonicalArtifactJsonV2({ sourceName, finding });
187
189
  }
188
- export function parseExternalFindingsDocument(value) {
190
+ export function parseExternalFindingsDocument(value, observeOrigin) {
189
191
  const root = record(value);
190
192
  if (!root || !hasOnlyKeys(root, ROOT_KEYS)) {
191
193
  throw new Error('External findings must use the documented object shape without additional top-level fields.');
192
194
  }
193
195
  if (root.schema !== EXTERNAL_FINDINGS_SCHEMA
194
- || root.schemaVersion !== EXTERNAL_FINDINGS_VERSION) {
195
- throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version ${EXTERNAL_FINDINGS_VERSION}.`);
196
+ || (root.schemaVersion !== 4 && root.schemaVersion !== 5)) {
197
+ throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 4 or 5.`);
196
198
  }
197
199
  const sourceValue = record(root.source);
198
200
  if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
@@ -207,7 +209,8 @@ export function parseExternalFindingsDocument(value) {
207
209
  throw new Error(`External findings must contain between 1 and ${MAX_EXTERNAL_FINDINGS} findings.`);
208
210
  }
209
211
  const normalized = [];
210
- const seen = new Set();
212
+ let retainedBytes = new TextEncoder().encode(JSON.stringify({ schema: root.schema, schemaVersion: root.schemaVersion, source, findings: [] })).byteLength + 1;
213
+ const seen = new Map();
211
214
  const domainCounts = new Map();
212
215
  for (const [index, raw] of root.findings.entries()) {
213
216
  const item = record(raw);
@@ -228,7 +231,7 @@ export function parseExternalFindingsDocument(value) {
228
231
  if (evidenceClass !== 'deployment_observation' && evidenceClass !== 'provider_report') {
229
232
  throw new Error(`Finding ${index + 1} evidence class is unsupported.`);
230
233
  }
231
- const finding = {
234
+ let finding = {
232
235
  domain,
233
236
  category: item.category,
234
237
  evidenceClass,
@@ -239,10 +242,28 @@ export function parseExternalFindingsDocument(value) {
239
242
  reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
240
243
  structuredObservation: structuredObservation(item.structuredObservation, index),
241
244
  };
245
+ if (Object.hasOwn(item, 'infrastructureObservation')) {
246
+ if (root.schemaVersion !== 5)
247
+ throw new TypeError('Infrastructure observations require external-findings version 5.');
248
+ const infrastructureObservation = readInfrastructureObservation(item.infrastructureObservation);
249
+ if (infrastructureObservation.target !== domain || infrastructureObservation.observedAt !== finding.observedAt)
250
+ throw new TypeError('Infrastructure snapshot must match its finding target and observation time.');
251
+ if (finding.structuredObservation)
252
+ throw new TypeError('Infrastructure snapshots cannot also declare a single structured observation.');
253
+ finding = { ...finding, infrastructureObservation };
254
+ }
255
+ if (root.schemaVersion === 5) {
256
+ retainedBytes += new TextEncoder().encode(JSON.stringify(finding)).byteLength + 1;
257
+ if (retainedBytes > MAX_EXTERNAL_FINDINGS_IMPORT_BYTES)
258
+ throw new TypeError('External findings exceed the retained aggregate byte bound.');
259
+ }
242
260
  const key = findingKey(finding, source.name);
243
- if (seen.has(key))
261
+ const previous = seen.get(key);
262
+ if (previous) {
263
+ observeOrigin?.(previous, index);
244
264
  continue;
245
- seen.add(key);
265
+ }
266
+ seen.set(key, finding);
246
267
  const count = (domainCounts.get(domain) ?? 0) + 1;
247
268
  if (count > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
248
269
  throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-finding per-domain limit.`);
@@ -252,17 +273,20 @@ export function parseExternalFindingsDocument(value) {
252
273
  throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
253
274
  }
254
275
  normalized.push(finding);
276
+ observeOrigin?.(finding, index);
255
277
  }
256
278
  if (!normalized.length)
257
279
  throw new Error('External findings did not contain a unique valid finding.');
258
280
  return {
259
281
  schema: EXTERNAL_FINDINGS_SCHEMA,
260
- schemaVersion: EXTERNAL_FINDINGS_VERSION,
282
+ schemaVersion: root.schemaVersion,
261
283
  source,
262
284
  findings: normalized,
263
285
  };
264
286
  }
265
287
  export function serializeExternalFindingsDocument(value) {
288
+ if (record(value)?.schemaVersion === 5)
289
+ return `${JSON.stringify(parseExternalFindingsDocument(value))}\n`;
266
290
  const serialised = JSON.stringify(value, null, 2);
267
291
  if (typeof serialised !== 'string') {
268
292
  throw new TypeError('External findings must be JSON-serialisable.');
@@ -294,6 +318,12 @@ function alreadyRetained(recordValue, projection) {
294
318
  });
295
319
  }
296
320
  function structuredPinFields(finding) {
321
+ if (finding.infrastructureObservation)
322
+ return {
323
+ field: 'infrastructure.snapshot', category: 'infrastructure',
324
+ sourceSchema: { collection: 'external_observations', schema: finding.infrastructureObservation.schema, version: finding.infrastructureObservation.version },
325
+ infrastructureObservation: finding.infrastructureObservation,
326
+ };
297
327
  const observation = finding.structuredObservation;
298
328
  if (!observation)
299
329
  return {};
@@ -348,6 +378,7 @@ export function externalFindingCaseProjection(finding, source) {
348
378
  const omittedLimitations = finding.limitations.filter((item) => !sourceLimitations.includes(item)).length;
349
379
  const pin = normalizeCaseEvidencePins([{
350
380
  ...structuredPinFields(finding),
381
+ observationHostname: finding.domain,
351
382
  importContentSha256: identitySha256,
352
383
  label: `External ${finding.category} finding`,
353
384
  value,
@@ -428,17 +459,6 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
428
459
  duplicatesSkipped,
429
460
  };
430
461
  }
431
- function targetedFinding(finding, caseDomain) {
432
- if (finding.domain === caseDomain)
433
- return finding;
434
- const prefix = `Captured hostname ${finding.domain}. `;
435
- const maximumSummaryLength = MAX_EXTERNAL_FINDING_SUMMARY_LENGTH;
436
- const remaining = maximumSummaryLength - prefix.length;
437
- const summary = remaining > 1 && finding.summary.length > remaining
438
- ? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
439
- : `${prefix}${finding.summary}`;
440
- return { ...finding, domain: caseDomain, summary };
441
- }
442
462
  export function externalFindingsCaseTargets(document, caseDomain) {
443
463
  if (canonicalRegistrableDomain(caseDomain) !== caseDomain) {
444
464
  throw new Error('The selected Case does not have a canonical registrable-domain identity.');
@@ -468,8 +488,7 @@ export function mergeExternalFindingsIntoCase(current, caseId, document, now = n
468
488
  let record = selected;
469
489
  let findingsAdded = 0;
470
490
  let duplicatesSkipped = 0;
471
- for (const original of document.findings) {
472
- const finding = targetedFinding(original, selected.domain);
491
+ for (const finding of document.findings) {
473
492
  const merged = mergeExternalFindingIntoCase(cases, selected.id, finding, document.source, now);
474
493
  cases = merged.cases;
475
494
  record = merged.record;
@@ -0,0 +1,14 @@
1
+ const TERMINAL_UNSAFE_JSON_RE = /[\u007f-\u009f\u2028\u2029]|\p{Default_Ignorable_Code_Point}/gu;
2
+ function jsonUnicodeEscape(value) {
3
+ const codePoint = value.codePointAt(0);
4
+ if (codePoint <= 0xffff)
5
+ return `\\u${codePoint.toString(16).padStart(4, '0')}`;
6
+ const offset = codePoint - 0x10000;
7
+ const high = 0xd800 + (offset >> 10);
8
+ const low = 0xdc00 + (offset & 0x3ff);
9
+ return `\\u${high.toString(16).padStart(4, '0')}\\u${low.toString(16).padStart(4, '0')}`;
10
+ }
11
+ /** Escape display controls without changing the parsed JSON values. */
12
+ export function terminalSafeJson(value, space) {
13
+ return JSON.stringify(value, null, space).replace(TERMINAL_UNSAFE_JSON_RE, jsonUnicodeEscape);
14
+ }