@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
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/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { lexicalSegments, normalizeHeaderDomain, addressDomains } from "./mail-header-identity.mjs";
|
|
2
|
+
import { MAX_AUTHENTICATION_HEADERS, MAX_AUTHENTICATION_CLAUSES, MAX_AUTHENTICATION_VALUE_LENGTH, MAIL_AUTHENTICATION_METHODS, MAIL_AUTHENTICATION_PROPERTIES } from "../contracts/mail-authentication.mjs";
|
|
3
|
+
const KEYWORD = /^[a-z][a-z0-9_-]{0,31}$/iu;
|
|
4
|
+
const RESULTS = {
|
|
5
|
+
spf: ['pass', 'fail', 'softfail', 'neutral', 'none', 'temperror', 'permerror'],
|
|
6
|
+
dkim: ['pass', 'fail', 'policy', 'neutral', 'none', 'temperror', 'permerror'],
|
|
7
|
+
dmarc: ['pass', 'fail', 'none', 'temperror', 'permerror'],
|
|
8
|
+
arc: ['pass', 'fail', 'none'],
|
|
9
|
+
};
|
|
10
|
+
/** Remove comments, never quoted content. Keep separators so tokens cannot join. */
|
|
11
|
+
function withoutComments(value) {
|
|
12
|
+
let depth = 0, quoted = false, escaped = false, output = '';
|
|
13
|
+
for (const character of value) {
|
|
14
|
+
if (escaped) {
|
|
15
|
+
if (!depth)
|
|
16
|
+
output += character;
|
|
17
|
+
escaped = false;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (character === '\\' && (depth || quoted)) {
|
|
21
|
+
if (!depth)
|
|
22
|
+
output += character;
|
|
23
|
+
escaped = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (depth) {
|
|
27
|
+
if (character === '(')
|
|
28
|
+
depth++;
|
|
29
|
+
else if (character === ')')
|
|
30
|
+
depth--;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (character === '"')
|
|
34
|
+
quoted = !quoted;
|
|
35
|
+
if (!quoted && character === '(') {
|
|
36
|
+
depth = 1;
|
|
37
|
+
output += ' ';
|
|
38
|
+
}
|
|
39
|
+
else if (!quoted && character === ')')
|
|
40
|
+
return null;
|
|
41
|
+
else
|
|
42
|
+
output += character;
|
|
43
|
+
}
|
|
44
|
+
return depth || quoted || escaped ? null : output;
|
|
45
|
+
}
|
|
46
|
+
// Only the restricted, non-personal identifier projection enters a report.
|
|
47
|
+
function serviceId(value) {
|
|
48
|
+
return /^[a-z0-9_.-]{1,253}$/iu.test(value) ? value : null;
|
|
49
|
+
}
|
|
50
|
+
function unquote(value) {
|
|
51
|
+
return value.startsWith('"') ? value.slice(1, -1).replace(/\\(.)/gu, '$1') : value;
|
|
52
|
+
}
|
|
53
|
+
function domainProperty(value) {
|
|
54
|
+
const direct = normalizeHeaderDomain(value) ?? normalizeHeaderDomain(value.startsWith('@') ? value.slice(1) : '');
|
|
55
|
+
if (direct)
|
|
56
|
+
return direct;
|
|
57
|
+
const domains = addressDomains([value]);
|
|
58
|
+
return domains.length === 1 ? domains[0] : null;
|
|
59
|
+
}
|
|
60
|
+
function properties(value) {
|
|
61
|
+
const result = new Map();
|
|
62
|
+
let rest = value.trim(), count = 0;
|
|
63
|
+
while (rest) {
|
|
64
|
+
if (++count > MAX_AUTHENTICATION_CLAUSES)
|
|
65
|
+
return { result, complete: false };
|
|
66
|
+
const match = /^([a-z][a-z0-9_-]*(?:\s*\.\s*[a-z][a-z0-9_-]*)?)\s*=\s*("(?:[^"\\]|\\.)*"|[^\s";]+)(?=\s|$)/iu.exec(rest);
|
|
67
|
+
if (!match)
|
|
68
|
+
return { result, complete: false };
|
|
69
|
+
const key = match[1].replaceAll(/\s/gu, '').toLowerCase();
|
|
70
|
+
const values = result.get(key) ?? [];
|
|
71
|
+
values.push(unquote(match[2]));
|
|
72
|
+
result.set(key, values);
|
|
73
|
+
rest = rest.slice(match[0].length).trim();
|
|
74
|
+
}
|
|
75
|
+
return { result, complete: true };
|
|
76
|
+
}
|
|
77
|
+
function domainsFromProperties(values) {
|
|
78
|
+
const domains = [];
|
|
79
|
+
const duplicateProperties = [];
|
|
80
|
+
let omitted = false;
|
|
81
|
+
for (const property of MAIL_AUTHENTICATION_PROPERTIES) {
|
|
82
|
+
const supplied = values.get(property);
|
|
83
|
+
if (!supplied)
|
|
84
|
+
continue;
|
|
85
|
+
if (supplied.length !== 1) {
|
|
86
|
+
duplicateProperties.push(property);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const domain = domainProperty(supplied[0]);
|
|
90
|
+
if (domain)
|
|
91
|
+
domains.push({ property, domain });
|
|
92
|
+
else
|
|
93
|
+
omitted = true;
|
|
94
|
+
}
|
|
95
|
+
return { domains, duplicateProperties, omitted };
|
|
96
|
+
}
|
|
97
|
+
function readHeader(input, part, headerIndex, duplicateOf) {
|
|
98
|
+
const headerName = input.name;
|
|
99
|
+
const base = { part, headerIndex, headerName, duplicateOf, receiverTrust: 'not_established' };
|
|
100
|
+
const issues = new Set();
|
|
101
|
+
const invalid = (issue) => ({ ...base, authservId: null, state: 'malformed', claims: [], issues: [issue] });
|
|
102
|
+
if (input.value.length > MAX_AUTHENTICATION_VALUE_LENGTH)
|
|
103
|
+
return invalid('Header value exceeds the authentication review bound.');
|
|
104
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]|\p{Default_Ignorable_Code_Point}/u.test(input.value))
|
|
105
|
+
return invalid('Header value contains unsupported control characters.');
|
|
106
|
+
const segments = lexicalSegments(input.value, ';');
|
|
107
|
+
if (!segments)
|
|
108
|
+
return invalid('Header has unbalanced quoting or comments.');
|
|
109
|
+
const clauses = segments.slice(0, MAX_AUTHENTICATION_CLAUSES + 1).map(withoutComments);
|
|
110
|
+
if (clauses.some(value => value === null))
|
|
111
|
+
return invalid('Header comments could not be parsed.');
|
|
112
|
+
if (segments.length > MAX_AUTHENTICATION_CLAUSES + 1)
|
|
113
|
+
issues.add('Some clauses exceed the authentication review bound.');
|
|
114
|
+
if (headerName === 'received-spf') {
|
|
115
|
+
const first = /^\s*([a-z][a-z0-9_-]*)\b(.*)$/iu.exec(clauses.shift() ?? '');
|
|
116
|
+
if (!first || !KEYWORD.test(first[1]))
|
|
117
|
+
return invalid('The reported SPF result is malformed.');
|
|
118
|
+
const supplied = properties([first[2], ...clauses].join(' '));
|
|
119
|
+
const receiver = supplied.result.get('receiver');
|
|
120
|
+
const authservId = receiver?.length === 1 ? serviceId(receiver[0]) : null;
|
|
121
|
+
if (!supplied.complete)
|
|
122
|
+
issues.add('Some SPF properties could not be parsed.');
|
|
123
|
+
if (receiver && (receiver.length !== 1 || !authservId))
|
|
124
|
+
issues.add('The receiver identifier is ambiguous or outside the retained identifier format.');
|
|
125
|
+
const renamed = new Map(supplied.result);
|
|
126
|
+
for (const [key, property] of [['envelope-from', 'smtp.mailfrom'], ['helo', 'smtp.helo']]) {
|
|
127
|
+
if (supplied.result.has(key))
|
|
128
|
+
renamed.set(property, supplied.result.get(key));
|
|
129
|
+
}
|
|
130
|
+
const projected = domainsFromProperties(renamed);
|
|
131
|
+
if (projected.omitted)
|
|
132
|
+
issues.add('Some identity properties cannot be represented as domains.');
|
|
133
|
+
if (projected.duplicateProperties.length)
|
|
134
|
+
issues.add('Repeated identity properties were retained as ambiguous, without selecting a value.');
|
|
135
|
+
const result = first[1].toLowerCase();
|
|
136
|
+
if (!RESULTS.spf.includes(result))
|
|
137
|
+
issues.add('The reported SPF result is unsupported.');
|
|
138
|
+
return { ...base, authservId, state: issues.size ? 'partial' : 'parsed', claims: [{ method: 'spf', methodVersion: 1, result,
|
|
139
|
+
state: RESULTS.spf.includes(result) ? 'reported' : 'unsupported', domains: projected.domains, duplicateProperties: projected.duplicateProperties }], issues: [...issues] };
|
|
140
|
+
}
|
|
141
|
+
const first = /^\s*("(?:[^"\\]|\\.)*"|[^\s";]+)(?:\s+(\d{1,9}))?\s*$/u.exec(clauses.shift() ?? '');
|
|
142
|
+
if (!first)
|
|
143
|
+
return invalid('The authentication service identifier or header version is malformed.');
|
|
144
|
+
const authservId = serviceId(unquote(first[1]));
|
|
145
|
+
if (!authservId)
|
|
146
|
+
issues.add('The service identifier is outside the retained non-personal identifier format.');
|
|
147
|
+
if (first[2] && first[2] !== '1')
|
|
148
|
+
return { ...base, authservId, state: 'unsupported', claims: [], issues: ['The header declares an unsupported authentication-results version.'] };
|
|
149
|
+
if (clauses.length === 1 && clauses[0].trim().toLowerCase() === 'none')
|
|
150
|
+
return { ...base, authservId, state: 'none', claims: [], issues: [...issues] };
|
|
151
|
+
const claims = [];
|
|
152
|
+
if (!clauses.length)
|
|
153
|
+
issues.add('The header does not contain a result clause.');
|
|
154
|
+
for (const clause of clauses) {
|
|
155
|
+
const match = /^\s*([a-z][a-z0-9_-]*)(?:\s*\/\s*(\d{1,9}))?\s*=\s*([a-z][a-z0-9_-]*)(?=\s|$)(.*)$/iu.exec(clause);
|
|
156
|
+
if (!match || !KEYWORD.test(match[1]) || !KEYWORD.test(match[3])) {
|
|
157
|
+
issues.add('A result clause is malformed.');
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const method = match[1].toLowerCase();
|
|
161
|
+
if (!MAIL_AUTHENTICATION_METHODS.includes(method)) {
|
|
162
|
+
issues.add('A reported authentication method is outside this review.');
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const supplied = properties(match[4]);
|
|
166
|
+
const projected = domainsFromProperties(supplied.result);
|
|
167
|
+
if (!supplied.complete)
|
|
168
|
+
issues.add('Some result properties could not be parsed.');
|
|
169
|
+
if (projected.omitted)
|
|
170
|
+
issues.add('Some identity properties cannot be represented as domains.');
|
|
171
|
+
if (projected.duplicateProperties.length)
|
|
172
|
+
issues.add('Repeated identity properties were retained as ambiguous, without selecting a value.');
|
|
173
|
+
const known = method, result = match[3].toLowerCase(), methodVersion = Number(match[2] ?? 1);
|
|
174
|
+
const state = methodVersion === 1 && RESULTS[known].includes(result) ? 'reported' : 'unsupported';
|
|
175
|
+
if (state === 'unsupported')
|
|
176
|
+
issues.add('A method version or result is unsupported.');
|
|
177
|
+
claims.push({ method: known, methodVersion, result, state, domains: projected.domains, duplicateProperties: projected.duplicateProperties });
|
|
178
|
+
}
|
|
179
|
+
return { ...base, authservId, state: issues.size ? claims.length ? 'partial' : 'malformed' : 'parsed', claims, issues: [...issues] };
|
|
180
|
+
}
|
|
181
|
+
/** Physical header order is preserved. Equal service names never imply trust. */
|
|
182
|
+
export function reviewMailAuthentication(fields, part = 1, maximumHeaders = MAX_AUTHENTICATION_HEADERS) {
|
|
183
|
+
if (!Number.isSafeInteger(part) || part < 1 || !Number.isSafeInteger(maximumHeaders) || maximumHeaders < 0 || maximumHeaders > MAX_AUTHENTICATION_HEADERS)
|
|
184
|
+
throw new TypeError('Invalid authentication review bounds.');
|
|
185
|
+
const headers = [], previous = new Map();
|
|
186
|
+
let omittedHeaders = 0;
|
|
187
|
+
for (const [index, field] of fields.entries()) {
|
|
188
|
+
if (!['authentication-results', 'received-spf'].includes(field.name))
|
|
189
|
+
continue;
|
|
190
|
+
if (headers.length >= maximumHeaders) {
|
|
191
|
+
omittedHeaders++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Do not retain an oversized raw value in the temporary duplicate index.
|
|
195
|
+
const key = field.value.length <= MAX_AUTHENTICATION_VALUE_LENGTH ? `${field.name}:${field.value}` : null;
|
|
196
|
+
const duplicateOf = key === null ? null : previous.get(key) ?? null;
|
|
197
|
+
headers.push(readHeader(field, part, index + 1, duplicateOf));
|
|
198
|
+
if (key !== null && duplicateOf === null)
|
|
199
|
+
previous.set(key, index + 1);
|
|
200
|
+
}
|
|
201
|
+
return { headers, omittedHeaders };
|
|
202
|
+
}
|
|
203
|
+
export function selectReceiverTrust(review, selected) {
|
|
204
|
+
if (!Array.isArray(selected) || selected.length > MAX_AUTHENTICATION_HEADERS || new Set(selected).size !== selected.length
|
|
205
|
+
|| selected.some(value => !/^[1-9]\d{0,5}:[1-9]\d{0,5}$/u.test(value)
|
|
206
|
+
|| !review.headers.some(header => `${header.part}:${header.headerIndex}` === value && header.authservId !== null && ['parsed', 'partial', 'none'].includes(header.state)))) {
|
|
207
|
+
throw new TypeError('Select each recognised receiver header by part:header-index; unidentified, malformed and unsupported headers cannot be trusted.');
|
|
208
|
+
}
|
|
209
|
+
return { ...review, headers: review.headers.map(header => ({ ...header, receiverTrust: selected.includes(`${header.part}:${header.headerIndex}`) ? 'analyst_selected' : 'not_established' })) };
|
|
210
|
+
}
|
|
211
|
+
export function authenticationHeaderLabel(header) {
|
|
212
|
+
return `Part ${header.part}, header ${header.headerIndex} · ${header.headerName} · ${header.authservId ?? 'service unidentified'}`;
|
|
213
|
+
}
|
|
214
|
+
/** Compatibility summary only: all parsed sources, with no receiver-trust verdict. */
|
|
215
|
+
export function aggregateMailAuthentication(review) {
|
|
216
|
+
const known = new Set(['fail', 'neutral', 'none', 'pass', 'permerror', 'softfail', 'temperror']);
|
|
217
|
+
return Object.fromEntries(MAIL_AUTHENTICATION_METHODS.map(method => {
|
|
218
|
+
const claims = review.headers.flatMap(header => header.claims.filter(claim => claim.method === method));
|
|
219
|
+
const states = new Set(claims.map(claim => claim.state === 'reported' && known.has(claim.result) ? claim.result : 'unknown'));
|
|
220
|
+
return [method, { state: states.size > 1 ? 'mixed' : [...states][0] ?? 'unknown', observations: claims.length }];
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { isValidAsciiDomainName } from "../contracts/domain-name.mjs";
|
|
2
|
+
import { MAIL_AUTHENTICATION_METHODS } from "../contracts/mail-authentication.mjs";
|
|
3
|
+
export const MAX_MAIL_REVIEW_DOMAINS = 128;
|
|
4
|
+
export const AUTHENTICATION_METHODS = MAIL_AUTHENTICATION_METHODS;
|
|
5
|
+
export function normalizeHeaderDomain(value) {
|
|
6
|
+
const normalized = value.trim().toLowerCase().replace(/^\[|\]$/gu, '').replace(/\.$/u, '');
|
|
7
|
+
return isValidAsciiDomainName(normalized, { requireLowercase: true }) ? normalized : null;
|
|
8
|
+
}
|
|
9
|
+
export function lexicalSegments(value, delimiter, trackAngles = false) {
|
|
10
|
+
const segments = [];
|
|
11
|
+
let start = 0;
|
|
12
|
+
let quoted = false;
|
|
13
|
+
let escaped = false;
|
|
14
|
+
let commentDepth = 0;
|
|
15
|
+
let angleDepth = 0;
|
|
16
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
17
|
+
const character = value[index] ?? '';
|
|
18
|
+
if (escaped) {
|
|
19
|
+
escaped = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
23
|
+
escaped = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (commentDepth > 0) {
|
|
27
|
+
if (character === '(')
|
|
28
|
+
commentDepth += 1;
|
|
29
|
+
else if (character === ')')
|
|
30
|
+
commentDepth -= 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (quoted) {
|
|
34
|
+
if (character === '"')
|
|
35
|
+
quoted = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (character === '"')
|
|
39
|
+
quoted = true;
|
|
40
|
+
else if (character === '(')
|
|
41
|
+
commentDepth = 1;
|
|
42
|
+
else if (character === ')')
|
|
43
|
+
return null;
|
|
44
|
+
else if (trackAngles && character === '<') {
|
|
45
|
+
if (angleDepth !== 0)
|
|
46
|
+
return null;
|
|
47
|
+
angleDepth = 1;
|
|
48
|
+
}
|
|
49
|
+
else if (trackAngles && character === '>') {
|
|
50
|
+
if (angleDepth !== 1)
|
|
51
|
+
return null;
|
|
52
|
+
angleDepth = 0;
|
|
53
|
+
}
|
|
54
|
+
else if (character === delimiter && angleDepth === 0) {
|
|
55
|
+
segments.push(value.slice(start, index));
|
|
56
|
+
start = index + 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (escaped || quoted || commentDepth !== 0 || angleDepth !== 0)
|
|
60
|
+
return null;
|
|
61
|
+
segments.push(value.slice(start));
|
|
62
|
+
return segments;
|
|
63
|
+
}
|
|
64
|
+
function angleAddress(value) {
|
|
65
|
+
let quoted = false;
|
|
66
|
+
let escaped = false;
|
|
67
|
+
let commentDepth = 0;
|
|
68
|
+
let start = -1;
|
|
69
|
+
let end = -1;
|
|
70
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
71
|
+
const character = value[index] ?? '';
|
|
72
|
+
if (escaped) {
|
|
73
|
+
escaped = false;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
77
|
+
escaped = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (commentDepth > 0) {
|
|
81
|
+
if (character === '(')
|
|
82
|
+
commentDepth += 1;
|
|
83
|
+
else if (character === ')')
|
|
84
|
+
commentDepth -= 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (quoted) {
|
|
88
|
+
if (character === '"')
|
|
89
|
+
quoted = false;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (character === '"')
|
|
93
|
+
quoted = true;
|
|
94
|
+
else if (character === '(')
|
|
95
|
+
commentDepth = 1;
|
|
96
|
+
else if (character === ')')
|
|
97
|
+
return null;
|
|
98
|
+
else if (character === '<') {
|
|
99
|
+
if (start >= 0 || end >= 0)
|
|
100
|
+
return null;
|
|
101
|
+
start = index;
|
|
102
|
+
}
|
|
103
|
+
else if (character === '>') {
|
|
104
|
+
if (start < 0 || end >= 0)
|
|
105
|
+
return null;
|
|
106
|
+
end = index;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (escaped || quoted || commentDepth !== 0 || (start >= 0) !== (end >= 0))
|
|
110
|
+
return null;
|
|
111
|
+
return start >= 0 ? value.slice(start + 1, end) : value;
|
|
112
|
+
}
|
|
113
|
+
function addressDomain(value) {
|
|
114
|
+
let candidate = '';
|
|
115
|
+
let quoted = false;
|
|
116
|
+
let escaped = false;
|
|
117
|
+
let commentDepth = 0;
|
|
118
|
+
const separators = [];
|
|
119
|
+
for (const character of value) {
|
|
120
|
+
if (escaped) {
|
|
121
|
+
candidate += character;
|
|
122
|
+
escaped = false;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
126
|
+
if (quoted)
|
|
127
|
+
candidate += character;
|
|
128
|
+
escaped = true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (commentDepth > 0) {
|
|
132
|
+
if (character === '(')
|
|
133
|
+
commentDepth += 1;
|
|
134
|
+
else if (character === ')')
|
|
135
|
+
commentDepth -= 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (quoted) {
|
|
139
|
+
candidate += character;
|
|
140
|
+
if (character === '"')
|
|
141
|
+
quoted = false;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (character === '"') {
|
|
145
|
+
quoted = true;
|
|
146
|
+
candidate += character;
|
|
147
|
+
}
|
|
148
|
+
else if (character === '(')
|
|
149
|
+
commentDepth = 1;
|
|
150
|
+
else if (character === ')')
|
|
151
|
+
return null;
|
|
152
|
+
else {
|
|
153
|
+
if (character === '@')
|
|
154
|
+
separators.push(candidate.length);
|
|
155
|
+
candidate += character;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (escaped || quoted || commentDepth !== 0 || separators.length !== 1)
|
|
159
|
+
return null;
|
|
160
|
+
const separator = separators[0] ?? -1;
|
|
161
|
+
const local = candidate.slice(0, separator).trim();
|
|
162
|
+
const domain = candidate.slice(separator + 1).trim();
|
|
163
|
+
const validLocal = /^(?:[^\s"<>(),:;@]+|"(?:[^"\\]|\\.)+")$/u.test(local);
|
|
164
|
+
if (!validLocal || !domain || /\s/u.test(domain))
|
|
165
|
+
return null;
|
|
166
|
+
return normalizeHeaderDomain(domain);
|
|
167
|
+
}
|
|
168
|
+
export function addressDomains(values) {
|
|
169
|
+
const domains = new Set();
|
|
170
|
+
for (const value of values) {
|
|
171
|
+
const mailboxes = lexicalSegments(value, ',', true);
|
|
172
|
+
if (!mailboxes)
|
|
173
|
+
continue;
|
|
174
|
+
for (const mailbox of mailboxes) {
|
|
175
|
+
const selected = angleAddress(mailbox);
|
|
176
|
+
const domain = selected === null ? null : addressDomain(selected);
|
|
177
|
+
if (domain)
|
|
178
|
+
domains.add(domain);
|
|
179
|
+
if (domains.size >= MAX_MAIL_REVIEW_DOMAINS)
|
|
180
|
+
return [...domains].sort();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...domains].sort();
|
|
184
|
+
}
|
|
185
|
+
export function messageIdDomains(values) {
|
|
186
|
+
return addressDomains(values.map((value) => value.replace(/[<>]/gu, '')));
|
|
187
|
+
}
|
|
188
|
+
export function authenticationServiceDomains(values) {
|
|
189
|
+
const output = new Set();
|
|
190
|
+
for (const value of values) {
|
|
191
|
+
const clauses = lexicalSegments(value, ';');
|
|
192
|
+
const service = normalizeHeaderDomain((clauses?.[0] ?? '').trim().split(/\s/u, 1)[0] ?? '');
|
|
193
|
+
if (service)
|
|
194
|
+
output.add(service);
|
|
195
|
+
}
|
|
196
|
+
return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
|
|
197
|
+
}
|
|
198
|
+
export function dkimSigningDomains(values) {
|
|
199
|
+
const output = new Set();
|
|
200
|
+
for (const value of values) {
|
|
201
|
+
for (const clause of lexicalSegments(value, ';') ?? []) {
|
|
202
|
+
const match = /^\s*d\s*=\s*([^\s;]+)/iu.exec(clause);
|
|
203
|
+
const domain = normalizeHeaderDomain(match?.[1] ?? '');
|
|
204
|
+
if (domain)
|
|
205
|
+
output.add(domain);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
|
|
209
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import PostalMime, {} from 'postal-mime';
|
|
2
|
+
import { parse, defaultTreeAdapter } from 'parse5';
|
|
3
|
+
import { sha256ArtifactBytes } from "../evidence/artifact-integrity.mjs";
|
|
4
|
+
import { createLinkIntake } from "./link-intake.mjs";
|
|
5
|
+
import { AUTHENTICATION_METHODS, addressDomains, authenticationServiceDomains, dkimSigningDomains } from "./mail-header-identity.mjs";
|
|
6
|
+
import { createIntakeReport } from "./intake-report.mjs";
|
|
7
|
+
export { assertMessageBytes } from "./intake-report.mjs";
|
|
8
|
+
import { requestedActionHints } from "./requested-action-hints.mjs";
|
|
9
|
+
import { reviewMailAuthentication, aggregateMailAuthentication } from "./mail-authentication-review.mjs";
|
|
10
|
+
import { MAX_AUTHENTICATION_HEADERS } from "../contracts/mail-authentication.mjs";
|
|
11
|
+
import { MAX_MESSAGE_INTAKE_BYTES, MAX_MESSAGE_PARTS, MAX_MESSAGE_DEPTH, MAX_MESSAGE_HTML_NODES } from "../contracts/message-intake.mjs";
|
|
12
|
+
/** All parsing is inert. Only HTTP(S) link targets are offered for deliberate collection. */
|
|
13
|
+
export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
|
|
14
|
+
const base = await createIntakeReport(bytes, kind, reviewedAt);
|
|
15
|
+
if (!['text', 'email', 'calendar', 'qr'].includes(kind))
|
|
16
|
+
throw new TypeError('Use the selected-file intake entry point for this format.');
|
|
17
|
+
const links = createLinkIntake(), identities = [], authenticationClaims = [];
|
|
18
|
+
const actionHints = new Set(), bounds = new Set();
|
|
19
|
+
const authenticationHeaders = [];
|
|
20
|
+
const messageParts = [];
|
|
21
|
+
let omittedAuthenticationHeaders = 0;
|
|
22
|
+
let partialAuthentication = false;
|
|
23
|
+
let reviewedParts = 0, unreviewedAttachments = 0, decodedBytes = 0;
|
|
24
|
+
const text = (value, source) => {
|
|
25
|
+
for (const hint of requestedActionHints(value))
|
|
26
|
+
actionHints.add(hint);
|
|
27
|
+
links.addText(value, source);
|
|
28
|
+
};
|
|
29
|
+
function html(value) {
|
|
30
|
+
// parse5 never renders, loads resources, or runs supplied script.
|
|
31
|
+
let allocated = 0;
|
|
32
|
+
const htmlBound = new Error('HTML work bound');
|
|
33
|
+
const admit = () => { if (++allocated > MAX_MESSAGE_HTML_NODES)
|
|
34
|
+
throw htmlBound; };
|
|
35
|
+
let tree;
|
|
36
|
+
try {
|
|
37
|
+
tree = parse(value, { treeAdapter: { ...defaultTreeAdapter,
|
|
38
|
+
createElement(...args) { admit(); return defaultTreeAdapter.createElement(...args); },
|
|
39
|
+
createTextNode(...args) { admit(); return defaultTreeAdapter.createTextNode(...args); },
|
|
40
|
+
createCommentNode(...args) { admit(); return defaultTreeAdapter.createCommentNode(...args); },
|
|
41
|
+
} });
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
if (cause !== htmlBound)
|
|
45
|
+
throw cause;
|
|
46
|
+
bounds.add('HTML nodes');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const queue = [tree];
|
|
50
|
+
let nodes = 0;
|
|
51
|
+
while (queue.length) {
|
|
52
|
+
if (++nodes > MAX_MESSAGE_HTML_NODES) {
|
|
53
|
+
bounds.add('HTML nodes');
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const node = queue.pop();
|
|
57
|
+
if ('tagName' in node) {
|
|
58
|
+
const attrs = new Map(node.attrs.map(attr => [attr.name, attr.value]));
|
|
59
|
+
if (node.tagName === 'a' && attrs.has('href')) {
|
|
60
|
+
const shown = [], children = [...node.childNodes];
|
|
61
|
+
let count = 0;
|
|
62
|
+
while (children.length && ++count <= MAX_MESSAGE_HTML_NODES) {
|
|
63
|
+
const child = children.pop();
|
|
64
|
+
if ('value' in child)
|
|
65
|
+
shown.push(child.value);
|
|
66
|
+
if ('childNodes' in child)
|
|
67
|
+
children.push(...child.childNodes);
|
|
68
|
+
}
|
|
69
|
+
links.add(attrs.get('href'), 'html_link', shown.reverse().join('').trim());
|
|
70
|
+
}
|
|
71
|
+
if (node.tagName === 'form' && attrs.has('action'))
|
|
72
|
+
links.add(attrs.get('action'), 'html_form');
|
|
73
|
+
if ((node.tagName === 'iframe' || node.tagName === 'frame') && attrs.has('src'))
|
|
74
|
+
links.add(attrs.get('src'), 'html_frame');
|
|
75
|
+
if (node.tagName === 'script' || node.tagName === 'style')
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if ('value' in node)
|
|
79
|
+
text(node.value, 'text');
|
|
80
|
+
if ('childNodes' in node)
|
|
81
|
+
for (let index = node.childNodes.length - 1; index >= 0; index--)
|
|
82
|
+
queue.push(node.childNodes[index]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function calendar(value) {
|
|
86
|
+
const unfolded = value.replace(/\r?\n[ \t]/gu, '');
|
|
87
|
+
// No attendee names, addresses, descriptions or event identifiers enter the report.
|
|
88
|
+
for (const line of unfolded.split(/\r?\n/u)) {
|
|
89
|
+
const colon = line.indexOf(':');
|
|
90
|
+
if (colon < 0)
|
|
91
|
+
continue;
|
|
92
|
+
const key = line.slice(0, colon).split(';', 1)[0]?.toUpperCase();
|
|
93
|
+
if (['URL', 'DESCRIPTION', 'LOCATION', 'X-ALT-DESC'].includes(key ?? ''))
|
|
94
|
+
text(line.slice(colon + 1).replace(/\\n/giu, '\n').replace(/\\([,;\\])/gu, '$1'), 'calendar');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function identity(part, role, domains) {
|
|
98
|
+
for (const domain of domains)
|
|
99
|
+
identities.push({ part, role, domain });
|
|
100
|
+
}
|
|
101
|
+
function headers(mail, part) {
|
|
102
|
+
const sourceReview = reviewMailAuthentication(mail.headers.map(header => ({ name: header.key, value: header.value })), part, MAX_AUTHENTICATION_HEADERS - authenticationHeaders.length);
|
|
103
|
+
authenticationHeaders.push(...sourceReview.headers);
|
|
104
|
+
omittedAuthenticationHeaders += sourceReview.omittedHeaders;
|
|
105
|
+
partialAuthentication ||= sourceReview.headers.some(header => !['parsed', 'none'].includes(header.state));
|
|
106
|
+
if (sourceReview.omittedHeaders)
|
|
107
|
+
bounds.add('Authentication headers');
|
|
108
|
+
const values = (name) => mail.headers.filter(header => header.key === name).map(header => header.value);
|
|
109
|
+
identity(part, 'from', addressDomains(values('from')));
|
|
110
|
+
identity(part, 'reply_to', addressDomains(values('reply-to')));
|
|
111
|
+
identity(part, 'return_path', addressDomains(values('return-path')));
|
|
112
|
+
identity(part, 'dkim', dkimSigningDomains(values('dkim-signature')));
|
|
113
|
+
const reported = values('authentication-results');
|
|
114
|
+
identity(part, 'authentication_service', authenticationServiceDomains(reported));
|
|
115
|
+
const aggregate = aggregateMailAuthentication(sourceReview);
|
|
116
|
+
for (const method of AUTHENTICATION_METHODS) {
|
|
117
|
+
const claim = aggregate[method];
|
|
118
|
+
if (claim.observations)
|
|
119
|
+
authenticationClaims.push({ part, method, result: claim.state });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function message(input, depth, parentPart) {
|
|
123
|
+
if (depth > MAX_MESSAGE_DEPTH || reviewedParts >= MAX_MESSAGE_PARTS) {
|
|
124
|
+
bounds.add('Message nesting or parts');
|
|
125
|
+
unreviewedAttachments++;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
decodedBytes += input.byteLength;
|
|
129
|
+
if (decodedBytes > MAX_MESSAGE_INTAKE_BYTES * 2) {
|
|
130
|
+
bounds.add('Decoded message bytes');
|
|
131
|
+
unreviewedAttachments++;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let mail;
|
|
135
|
+
try {
|
|
136
|
+
mail = await PostalMime.parse(input, { maxNestingDepth: MAX_MESSAGE_DEPTH, maxHeadersSize: 256 * 1024, rfc822Attachments: true, forceRfc822Attachments: true, attachmentEncoding: 'arraybuffer' });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new TypeError('The message could not be decoded within the MIME nesting and header limits. No input was retained.');
|
|
140
|
+
}
|
|
141
|
+
const part = ++reviewedParts;
|
|
142
|
+
messageParts.push({ part, parentPart, digestSha256: parentPart === null ? base.source.digestSha256 : await sha256ArtifactBytes(input), byteLength: input.byteLength });
|
|
143
|
+
headers(mail, part);
|
|
144
|
+
if (mail.text)
|
|
145
|
+
text(mail.text, 'text');
|
|
146
|
+
if (mail.html)
|
|
147
|
+
html(mail.html);
|
|
148
|
+
for (const attachment of mail.attachments) {
|
|
149
|
+
if (reviewedParts >= MAX_MESSAGE_PARTS) {
|
|
150
|
+
bounds.add('Message parts');
|
|
151
|
+
unreviewedAttachments++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const body = typeof attachment.content === 'string' ? new TextEncoder().encode(attachment.content) : new Uint8Array(attachment.content);
|
|
155
|
+
if (attachment.mimeType === 'message/rfc822')
|
|
156
|
+
await message(body, depth + 1, part);
|
|
157
|
+
else if (attachment.mimeType === 'text/calendar') {
|
|
158
|
+
reviewedParts++;
|
|
159
|
+
calendar(new TextDecoder().decode(body));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
unreviewedAttachments++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (kind === 'email')
|
|
167
|
+
await message(bytes, 0, null);
|
|
168
|
+
else if (kind === 'qr') {
|
|
169
|
+
if (!Array.isArray(qrText) || qrText.length > MAX_MESSAGE_PARTS || qrText.some(value => typeof value !== 'string' || value.length > 8_192))
|
|
170
|
+
throw new TypeError('QR review requires bounded decoded text.');
|
|
171
|
+
reviewedParts = qrText.length;
|
|
172
|
+
for (const value of qrText) {
|
|
173
|
+
for (const hint of requestedActionHints(value))
|
|
174
|
+
actionHints.add(hint);
|
|
175
|
+
links.addQr(value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
reviewedParts = 1;
|
|
180
|
+
const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
181
|
+
if (kind === 'calendar')
|
|
182
|
+
calendar(value);
|
|
183
|
+
else
|
|
184
|
+
text(value, 'text');
|
|
185
|
+
}
|
|
186
|
+
const result = links.result();
|
|
187
|
+
for (const limitation of result.limitations)
|
|
188
|
+
bounds.add(limitation);
|
|
189
|
+
if (result.bounded)
|
|
190
|
+
bounds.add('Link extraction');
|
|
191
|
+
return { report: { ...base,
|
|
192
|
+
coverage: { state: bounds.size || unreviewedAttachments || partialAuthentication ? 'partial' : 'reviewed', reviewedParts, unreviewedAttachments, rejectedLinks: result.rejected, boundsReached: [...bounds] },
|
|
193
|
+
identities, authenticationClaims, authenticationReview: { headers: authenticationHeaders, omittedHeaders: omittedAuthenticationHeaders }, messageParts,
|
|
194
|
+
links: result.links, actionHints: [...actionHints] }, targets: result.targets };
|
|
195
|
+
}
|