@slicedearth/whoisleuth-cli 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +29 -29
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
}
|