@slicedearth/whoisleuth-cli 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. package/third-party-notices.txt +29 -29
@@ -2,12 +2,13 @@ import { boundedJsonLimitsForBytes, parseBoundedJson, parseBoundedJsonObject } f
2
2
  import { normalizeBoundedSemanticVersion } from "../analysis/semantic-version.mjs";
3
3
  import { canonicalArtifactJsonV2, sha256ArtifactBytes, sha256ArtifactDigestV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
4
4
  import { array, digest, enumeration, exact, fail, integer, iso, strings, text, validateIntegrity } from "../evidence/artifact-structure.mjs";
5
+ import { readImageDerivationDeclaration } from "../evidence/image-regions.mjs";
5
6
  import { SELECTED_FILE_MEDIA_TYPES as INVESTIGATION_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
6
7
  import { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES } from "../contracts/investigation-package-limits.mjs";
7
8
  export { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, INVESTIGATION_FILE_MEDIA_TYPES };
8
9
  export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
9
- export const INVESTIGATION_MANIFEST_VERSION = 3;
10
- export const SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS = [2, INVESTIGATION_MANIFEST_VERSION];
10
+ export const INVESTIGATION_MANIFEST_VERSION = 4;
11
+ export const SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS = [2, 3, INVESTIGATION_MANIFEST_VERSION];
11
12
  // Independently bounds the path-free metadata, not the selected file content.
12
13
  import { MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES } from "../contracts/investigation-package-limits.mjs";
13
14
  export { MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES } from "../contracts/investigation-package-limits.mjs";
@@ -78,7 +79,10 @@ export async function prepareInvestigationManifest(input, generatedAt, applicati
78
79
  throw new TypeError('Manifest artefacts exceed the combined limit.');
79
80
  totalBytes += bytes.byteLength;
80
81
  const mediaType = enumeration(artifact.mediaType ?? (typeof artifact.content === 'string' ? 'application/json' : 'application/octet-stream'), INVESTIGATION_FILE_MEDIA_TYPES, 'Artefact media type');
81
- return { bytes, mediaType, source: sourceDeclaration(artifact.source) };
82
+ const imageDerivation = artifact.imageDerivation == null ? null : readImageDerivationDeclaration(artifact.imageDerivation);
83
+ if (imageDerivation && mediaType !== 'image/png')
84
+ throw new TypeError('Image derivation requires a PNG media declaration.');
85
+ return { bytes, mediaType, source: sourceDeclaration(artifact.source), imageDerivation };
82
86
  });
83
87
  const artifacts = [];
84
88
  for (const [index, file] of selected.entries()) {
@@ -90,7 +94,7 @@ export async function prepareInvestigationManifest(input, generatedAt, applicati
90
94
  byteLength: file.bytes.byteLength,
91
95
  contentDigestSha256: await sha256ArtifactBytes(file.bytes),
92
96
  canonicalDigestSha256: metadata ? await sha256ArtifactDigestV2(metadata.value) : null,
93
- mediaType: file.mediaType, source: file.source,
97
+ mediaType: file.mediaType, source: file.source, imageDerivation: file.imageDerivation,
94
98
  }));
95
99
  }
96
100
  const unsigned = Object.freeze({
@@ -109,6 +113,7 @@ export async function prepareInvestigationManifest(input, generatedAt, applicati
109
113
  'Source identities, observation times and media types are declarations, not independently authenticated evidence. Missing source times remain unknown.',
110
114
  'Packaging events use the local clock. They do not establish earlier custody, a trusted timestamp, a signature, source accuracy or current validity.',
111
115
  'Digests establish content identity. JSON parsing and declared schema metadata do not establish that a source format is supported or its claims are true.',
116
+ 'Image derivation declares an immediate parent fingerprint and editing operations, not proof of transformation or complete redaction. Parent bytes need not be included; missing declarations leave editing history unknown.',
112
117
  ]),
113
118
  });
114
119
  const manifest = Object.freeze({ ...unsigned, integrity: Object.freeze({
@@ -124,7 +129,8 @@ export async function buildInvestigationManifest(input, generatedAt, application
124
129
  }
125
130
  export function validateInvestigationManifest(value) {
126
131
  const version = integer(value.version, 'Investigation manifest version', 2, INVESTIGATION_MANIFEST_VERSION);
127
- const current = version === INVESTIGATION_MANIFEST_VERSION;
132
+ const current = version >= 3;
133
+ const withImageDerivation = version >= 4;
128
134
  const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity', ...(current ? ['audience'] : [])], 'Investigation manifest');
129
135
  if (root.schema !== INVESTIGATION_MANIFEST_SCHEMA)
130
136
  fail('Investigation manifest schema');
@@ -148,7 +154,7 @@ export function validateInvestigationManifest(value) {
148
154
  fail('Investigation manifest step count');
149
155
  let totalBytes = 0;
150
156
  for (const [index, candidate] of artifacts.entries()) {
151
- const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256', ...(current ? ['mediaType', 'source'] : [])], `Investigation manifest artifact ${index + 1}`);
157
+ const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256', ...(current ? ['mediaType', 'source'] : []), ...(withImageDerivation ? ['imageDerivation'] : [])], `Investigation manifest artifact ${index + 1}`);
152
158
  if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1 || item.id !== `artifact-${index + 1}`)
153
159
  fail('Investigation manifest artifact order');
154
160
  if (item.schema !== null)
@@ -162,6 +168,11 @@ export function validateInvestigationManifest(value) {
162
168
  digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
163
169
  else if (item.canonicalDigestSha256 !== null || item.schema !== null || item.version !== null)
164
170
  fail('Opaque artefact JSON metadata');
171
+ if (withImageDerivation && item.imageDerivation !== null) {
172
+ readImageDerivationDeclaration(item.imageDerivation);
173
+ if (mediaType !== 'image/png')
174
+ fail('Image derivation media type');
175
+ }
165
176
  if (current) {
166
177
  const source = exact(item.source, ['identity', 'observedAt'], 'Artefact source');
167
178
  if (source.identity !== null)
@@ -189,12 +200,27 @@ export async function readInvestigationManifest(raw) {
189
200
  throw new TypeError('Investigation manifest failed its integrity check.');
190
201
  return value;
191
202
  }
203
+ /** Matches selected manifest entries, not unselected attachment declarations. */
204
+ export function investigationImageParentIncluded(manifest, artifact) {
205
+ if (!('imageDerivation' in artifact) || !artifact.imageDerivation)
206
+ return null;
207
+ const parent = artifact.imageDerivation.source;
208
+ return manifest.artifacts.some(entry => entry.id !== artifact.id
209
+ && entry.contentDigestSha256 === parent.digestSha256 && entry.byteLength === parent.byteLength);
210
+ }
192
211
  export function formatInvestigationManifest(manifest) {
193
212
  const lines = ['Investigation manifest', `Workflow ${manifest.workflow}`, `Tool version ${manifest.application.version}`,
194
213
  `Artifacts ${manifest.summary.artifactCount}`, `Total bytes ${manifest.summary.totalBytes}`,
195
214
  `Configuration ${manifest.configuration.digestSha256 ?? 'not supplied'}`, `Integrity ${manifest.integrity.digestSha256}`, '', 'Ordered artefacts:'];
196
- for (const artifact of manifest.artifacts)
215
+ for (const artifact of manifest.artifacts) {
197
216
  lines.push(` ${artifact.sequence}. ${artifact.schema ?? (artifact.canonicalDigestSha256 ? 'unversioned JSON' : 'opaque file')}${artifact.version ? ` v${artifact.version}` : ''} · ${artifact.contentDigestSha256}`);
217
+ if ('imageDerivation' in artifact && artifact.imageDerivation) {
218
+ const declaration = artifact.imageDerivation;
219
+ lines.push(` Declared image derivation: ${declaration.method}; ${declaration.operations.join(', ')}`, ` Parent: ${declaration.source.digestSha256} · ${declaration.source.byteLength} bytes`, ` Parent entry: ${investigationImageParentIncluded(manifest, artifact) ? 'declared in this manifest' : 'not declared in this manifest'}`, ' Declared edits are not proof of transformation or complete redaction.');
220
+ }
221
+ else if ('mediaType' in artifact && artifact.mediaType.startsWith('image/'))
222
+ lines.push(' Editing history: not declared');
223
+ }
198
224
  lines.push('', 'No source file paths or artefact contents are retained in this manifest.', '');
199
225
  return lines.join('\n');
200
226
  }
@@ -71,12 +71,39 @@ function proseUrl(value) {
71
71
  result = result.slice(0, -1).replace(/[.,;!?]+$/u, '');
72
72
  }
73
73
  }
74
- function displayedHost(value) {
74
+ function admittedDestination(url, registrationDomain, normalisation = []) {
75
+ return { state: 'parsed', hostname: url.hostname, origin: url.origin, registrationDomain,
76
+ hasPrivateLocation: url.pathname !== '/' || Boolean(url.search || url.hash), normalisation };
77
+ }
78
+ export function projectIntakeDestination(value, allowBareHostname = false) {
79
+ const empty = (state) => ({ state, hostname: null, origin: null, registrationDomain: null, hasPrivateLocation: false, normalisation: [] });
80
+ if (value.length > MAX_INTAKE_URL_LENGTH)
81
+ return empty('unsupported');
75
82
  const bounded = value.trim();
76
- if (bounded.length > MAX_INTAKE_URL_LENGTH || /\s/u.test(bounded))
77
- return null;
78
- const parsed = parseCredentialFreeHttpUrl(refangIntakeUrl(HTTP_PREFIX.test(bounded) ? bounded : `https://${bounded}`), MAX_INTAKE_URL_LENGTH);
79
- return parsed && canonicalRegistrableDomain(parsed.hostname) ? parsed.hostname : null;
83
+ if (!bounded)
84
+ return empty('missing');
85
+ if (/[\s\\\p{Cf}]/u.test(bounded))
86
+ return empty('unsupported');
87
+ const explicitScheme = HTTP_PREFIX.test(bounded);
88
+ if (!explicitScheme && !allowBareHostname)
89
+ return empty('unsupported');
90
+ const refanged = refangIntakeUrl(explicitScheme ? bounded : `https://${bounded}`);
91
+ const parsed = parseCredentialFreeHttpUrl(refanged, MAX_INTAKE_URL_LENGTH);
92
+ const registrationDomain = parsed && canonicalRegistrableDomain(parsed.hostname);
93
+ if (!parsed || !registrationDomain || parsed.port)
94
+ return empty('unsupported');
95
+ return admittedDestination(parsed, registrationDomain, [
96
+ ...(!explicitScheme ? ['assumed_https_for_hostname_parsing'] : []),
97
+ ...(refanged !== (explicitScheme ? bounded : `https://${bounded}`) ? ['refanged_scheme_or_authority'] : []),
98
+ ...(/[^\x00-\x7f]/u.test(bounded.split(/[/?#]/u).slice(0, explicitScheme ? 3 : 1).join('/')) ? ['unicode_hostname_to_ascii'] : []),
99
+ ]);
100
+ }
101
+ export function compareIntakeDestinations(displayed, destination) {
102
+ return displayed.state !== 'parsed' || destination.state !== 'parsed' ? 'insufficient_evidence'
103
+ : displayed.hostname === destination.hostname ? 'same_host' : 'different_host';
104
+ }
105
+ function displayedHost(value) {
106
+ return projectIntakeDestination(value, true).hostname;
80
107
  }
81
108
  /** No resolution or request occurs here; embedded targets remain supplied text. */
82
109
  export function createLinkIntake() {
@@ -115,7 +142,8 @@ export function createLinkIntake() {
115
142
  exclude('port');
116
143
  return;
117
144
  }
118
- if (!canonicalRegistrableDomain(url.hostname)) {
145
+ const registrationDomain = canonicalRegistrableDomain(url.hostname);
146
+ if (!registrationDomain) {
119
147
  exclude('host');
120
148
  return;
121
149
  }
@@ -125,8 +153,10 @@ export function createLinkIntake() {
125
153
  return;
126
154
  seen.add(key);
127
155
  const id = `link-${links.length + 1}`;
128
- links.push({ id, origin: url.origin, hostname: url.hostname, registrationDomain: canonicalRegistrableDomain(url.hostname),
129
- source, ...(location ? { location } : {}), parentId, displayedHostname: shown, displayedDestination: shown ? shown === url.hostname ? 'same_host' : 'different_host' : 'not_a_hostname',
156
+ // Compare the already admitted canonical URL, not its differently bounded raw spelling.
157
+ const compared = compareIntakeDestinations(projectIntakeDestination(displayed, true), admittedDestination(url, registrationDomain));
158
+ links.push({ id, origin: url.origin, hostname: url.hostname, registrationDomain,
159
+ source, ...(location ? { location } : {}), parentId, displayedHostname: shown, displayedDestination: compared === 'insufficient_evidence' ? 'not_a_hostname' : compared,
130
160
  hasPrivateLocation: url.pathname !== '/' || Boolean(url.search || url.hash), authorisation: reviewAuthorisationLink(url) });
131
161
  targets.push({ id, exactUrl: url.href });
132
162
  for (const [name, value] of url.searchParams) {
@@ -2,6 +2,8 @@ import PostalMime, {} from 'postal-mime';
2
2
  import { parse, defaultTreeAdapter } from 'parse5';
3
3
  import { sha256ArtifactBytes } from "../evidence/artifact-integrity.mjs";
4
4
  import { createLinkIntake } from "./link-intake.mjs";
5
+ import { createIndicatorIntake, MAX_INTAKE_INDICATOR_TEXT } from "./intake-indicators.mjs";
6
+ import { reviewPhoneCandidates } from "./intake-phones.mjs";
5
7
  import { AUTHENTICATION_METHODS, addressDomains, authenticationServiceDomains, dkimSigningDomains } from "./mail-header-identity.mjs";
6
8
  import { createIntakeReport } from "./intake-report.mjs";
7
9
  export { assertMessageBytes } from "./intake-report.mjs";
@@ -9,24 +11,27 @@ import { requestedActionHints } from "./requested-action-hints.mjs";
9
11
  import { reviewMailAuthentication, aggregateMailAuthentication } from "./mail-authentication-review.mjs";
10
12
  import { MAX_AUTHENTICATION_HEADERS } from "../contracts/mail-authentication.mjs";
11
13
  import { MAX_MESSAGE_INTAKE_BYTES, MAX_MESSAGE_PARTS, MAX_MESSAGE_DEPTH, MAX_MESSAGE_HTML_NODES } from "../contracts/message-intake.mjs";
14
+ const HTML_TEXT_BLOCK = /^(?:address|article|aside|blockquote|body|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tr|ul)$/u;
12
15
  /** All parsing is inert. Only HTTP(S) link targets are offered for deliberate collection. */
13
16
  export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
14
17
  const base = await createIntakeReport(bytes, kind, reviewedAt);
15
18
  if (!['text', 'email', 'calendar', 'qr'].includes(kind))
16
19
  throw new TypeError('Use the selected-file intake entry point for this format.');
17
- const links = createLinkIntake(), identities = [], authenticationClaims = [];
20
+ const links = createLinkIntake(), indicators = createIndicatorIntake(), identities = [], authenticationClaims = [];
18
21
  const actionHints = new Set(), bounds = new Set();
19
22
  const authenticationHeaders = [];
20
23
  const messageParts = [];
21
24
  let omittedAuthenticationHeaders = 0;
22
25
  let partialAuthentication = false;
23
26
  let reviewedParts = 0, unreviewedAttachments = 0, decodedBytes = 0;
24
- const text = (value, source) => {
27
+ const text = (value, source, partId, includeIndicators = true) => {
25
28
  for (const hint of requestedActionHints(value))
26
29
  actionHints.add(hint);
27
30
  links.addText(value, source);
31
+ if (includeIndicators)
32
+ indicators.addText(value, source, { partId, page: null });
28
33
  };
29
- function html(value) {
34
+ function html(value, partId) {
30
35
  // parse5 never renders, loads resources, or runs supplied script.
31
36
  let allocated = 0;
32
37
  const htmlBound = new Error('HTML work bound');
@@ -47,13 +52,31 @@ export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
47
52
  return;
48
53
  }
49
54
  const queue = [tree];
55
+ const indicatorText = [];
56
+ let indicatorCharacters = 0, indicatorOverflow = false;
57
+ const appendIndicatorText = (value) => {
58
+ if (indicatorOverflow)
59
+ return;
60
+ if (value.length > MAX_INTAKE_INDICATOR_TEXT - indicatorCharacters) {
61
+ indicatorOverflow = true;
62
+ indicatorText.length = 0;
63
+ indicators.markPartial();
64
+ return;
65
+ }
66
+ indicatorCharacters += value.length;
67
+ indicatorText.push(value);
68
+ };
50
69
  let nodes = 0;
51
70
  while (queue.length) {
71
+ const node = queue.pop();
72
+ if (node === null) {
73
+ appendIndicatorText('\n');
74
+ continue;
75
+ }
52
76
  if (++nodes > MAX_MESSAGE_HTML_NODES) {
53
77
  bounds.add('HTML nodes');
54
78
  break;
55
79
  }
56
- const node = queue.pop();
57
80
  if ('tagName' in node) {
58
81
  const attrs = new Map(node.attrs.map(attr => [attr.name, attr.value]));
59
82
  if (node.tagName === 'a' && attrs.has('href')) {
@@ -74,15 +97,27 @@ export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
74
97
  links.add(attrs.get('src'), 'html_frame');
75
98
  if (node.tagName === 'script' || node.tagName === 'style')
76
99
  continue;
100
+ if (HTML_TEXT_BLOCK.test(node.tagName)) {
101
+ appendIndicatorText('\n');
102
+ queue.push(null);
103
+ }
104
+ else if (node.tagName === 'br' || node.tagName === 'td' || node.tagName === 'th')
105
+ appendIndicatorText(' ');
106
+ }
107
+ if ('value' in node) {
108
+ text(node.value, 'text', partId, false);
109
+ // Inline markup must not separate a credential label from its value.
110
+ // Block boundaries keep unrelated visible paragraphs independently usable.
111
+ appendIndicatorText(node.value.replace(/\s+/gu, ' '));
77
112
  }
78
- if ('value' in node)
79
- text(node.value, 'text');
80
113
  if ('childNodes' in node)
81
114
  for (let index = node.childNodes.length - 1; index >= 0; index--)
82
115
  queue.push(node.childNodes[index]);
83
116
  }
117
+ if (!indicatorOverflow)
118
+ indicators.addText(indicatorText.join(''), 'text', { partId, page: null });
84
119
  }
85
- function calendar(value) {
120
+ function calendar(value, partId) {
86
121
  const unfolded = value.replace(/\r?\n[ \t]/gu, '');
87
122
  // No attendee names, addresses, descriptions or event identifiers enter the report.
88
123
  for (const line of unfolded.split(/\r?\n/u)) {
@@ -91,7 +126,7 @@ export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
91
126
  continue;
92
127
  const key = line.slice(0, colon).split(';', 1)[0]?.toUpperCase();
93
128
  if (['URL', 'DESCRIPTION', 'LOCATION', 'X-ALT-DESC'].includes(key ?? ''))
94
- text(line.slice(colon + 1).replace(/\\n/giu, '\n').replace(/\\([,;\\])/gu, '$1'), 'calendar');
129
+ text(line.slice(colon + 1).replace(/\\n/giu, '\n').replace(/\\([,;\\])/gu, '$1'), 'calendar', partId);
95
130
  }
96
131
  }
97
132
  function identity(part, role, domains) {
@@ -142,9 +177,9 @@ export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
142
177
  messageParts.push({ part, parentPart, digestSha256: parentPart === null ? base.source.digestSha256 : await sha256ArtifactBytes(input), byteLength: input.byteLength });
143
178
  headers(mail, part);
144
179
  if (mail.text)
145
- text(mail.text, 'text');
180
+ text(mail.text, 'text', `message-${part}`);
146
181
  if (mail.html)
147
- html(mail.html);
182
+ html(mail.html, `message-${part}`);
148
183
  for (const attachment of mail.attachments) {
149
184
  if (reviewedParts >= MAX_MESSAGE_PARTS) {
150
185
  bounds.add('Message parts');
@@ -155,8 +190,9 @@ export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
155
190
  if (attachment.mimeType === 'message/rfc822')
156
191
  await message(body, depth + 1, part);
157
192
  else if (attachment.mimeType === 'text/calendar') {
158
- reviewedParts++;
159
- calendar(new TextDecoder().decode(body));
193
+ const calendarPart = ++reviewedParts;
194
+ messageParts.push({ part: calendarPart, parentPart: part, digestSha256: await sha256ArtifactBytes(body), byteLength: body.byteLength });
195
+ calendar(new TextDecoder().decode(body), `message-${calendarPart}`);
160
196
  }
161
197
  else {
162
198
  unreviewedAttachments++;
@@ -179,17 +215,21 @@ export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
179
215
  reviewedParts = 1;
180
216
  const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
181
217
  if (kind === 'calendar')
182
- calendar(value);
218
+ calendar(value, 'input');
183
219
  else
184
- text(value, 'text');
220
+ text(value, 'text', 'input');
185
221
  }
186
222
  const result = links.result();
187
223
  for (const limitation of result.limitations)
188
224
  bounds.add(limitation);
189
225
  if (result.bounded)
190
226
  bounds.add('Link extraction');
227
+ const indicatorResult = indicators.result();
228
+ if (indicatorResult.indicatorCoverage.state === 'partial')
229
+ bounds.add('Indicator extraction');
230
+ const phoneReview = kind === 'text' ? reviewPhoneCandidates(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes), base.source.digestSha256) : undefined;
191
231
  return { report: { ...base,
192
232
  coverage: { state: bounds.size || unreviewedAttachments || partialAuthentication ? 'partial' : 'reviewed', reviewedParts, unreviewedAttachments, rejectedLinks: result.rejected, boundsReached: [...bounds] },
193
233
  identities, authenticationClaims, authenticationReview: { headers: authenticationHeaders, omittedHeaders: omittedAuthenticationHeaders }, messageParts,
194
- links: result.links, actionHints: [...actionHints] }, targets: result.targets };
234
+ ...indicatorResult, links: result.links, actionHints: [...actionHints] }, targets: result.targets, ...(phoneReview ? { phoneReview } : {}) };
195
235
  }