@slicedearth/whoisleuth-cli 2.5.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- 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/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -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/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- 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-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- 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-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -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 +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- 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-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- 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/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -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 +40 -21
- 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/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- 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 +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -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 +30 -30
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { parseBoundedJson } from "../analysis/bounded-json.mjs";
|
|
2
|
+
import { exact, exactOptional, enumeration, text, array, integer } from "../evidence/artifact-structure.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
+
import { INTAKE_DISTRIBUTION_CHANNELS, MAX_INTAKE_INDICATORS, MAX_INTAKE_INDICATOR_CANDIDATES, INTAKE_TEXT_BASES, INTAKE_PHONE_ROLES, MAX_INTAKE_URL_LENGTH, } from "../contracts/message-intake.mjs";
|
|
5
|
+
import { canonicalIpAddress } from "../contracts/ip-address.mjs";
|
|
6
|
+
import { phoneCandidateValue, validatePhoneReview, MAX_SELECTED_INTAKE_PHONES, MAX_INTAKE_PHONE_CHARACTERS } from "./intake-phones.mjs";
|
|
7
|
+
import { compareIntakeDestinations, projectIntakeDestination } from "./link-intake.mjs";
|
|
8
|
+
export const INTAKE_CONTEXT_SCHEMA = 'whoisleuth.intake-context';
|
|
9
|
+
export const INTAKE_CONTEXT_VERSION = 1;
|
|
10
|
+
export const INTAKE_CONTEXT_SELECTION_VERSION = 2;
|
|
11
|
+
export const MAX_INTAKE_CONTEXT_BYTES = 8_192;
|
|
12
|
+
export const INTAKE_SELECTED_EVIDENCE_LIMITATIONS = [
|
|
13
|
+
'Selected contact values are supplied candidates, not verified numbers, ownership, common control or independent corroboration.',
|
|
14
|
+
'Source labels, roles, country context and observation times are analyst declarations; a digest identifies selected bytes, not authorship or capture time.',
|
|
15
|
+
'Selected phone values and private source text are excluded from public and trusted Case summaries and are not mapped to STIX or MISP; use the deliberately selected private review only.',
|
|
16
|
+
'Destination pairs compare supplied hostnames only. Exact entered URLs are not retained, redirects are not followed, and a mismatch does not establish phishing.',
|
|
17
|
+
];
|
|
18
|
+
function label(value, name, maximum, nullable = true) {
|
|
19
|
+
if (nullable && (value === null || value === ''))
|
|
20
|
+
return null;
|
|
21
|
+
const result = text(value, name, maximum);
|
|
22
|
+
if (!result.trim())
|
|
23
|
+
throw new TypeError(`${name} cannot be blank.`);
|
|
24
|
+
if (/@|:\/\/|[?&][^\s=]+=/u.test(result))
|
|
25
|
+
throw new TypeError(`${name} must be a non-sensitive label, not an address or exact URL.`);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function declarationFields(input) {
|
|
29
|
+
const observedAt = input.observedAt === null ? null : normalizeExplicitIsoTimestamp(input.observedAt);
|
|
30
|
+
if (input.observedAt !== null && !observedAt)
|
|
31
|
+
throw new TypeError('Declared observation time requires an explicit timezone.');
|
|
32
|
+
const sourceLabel = label(input.sourceLabel, 'Selected evidence source label', 160, false);
|
|
33
|
+
if (/\p{Cf}|\d[\d ()+\-\u00a0]{5,}\d/u.test(sourceLabel))
|
|
34
|
+
throw new TypeError('Use a non-sensitive source label, not a contact number or identifier.');
|
|
35
|
+
return { sourceLabel, observedAt, basis: enumeration(input.basis, INTAKE_TEXT_BASES, 'Selected text basis') };
|
|
36
|
+
}
|
|
37
|
+
export function readIntakePhoneDeclaration(raw) {
|
|
38
|
+
const input = exact(raw, ['sourceLabel', 'observedAt', 'basis', 'role', 'countryCallingCode'], 'Phone declaration');
|
|
39
|
+
if (input.countryCallingCode !== null && (typeof input.countryCallingCode !== 'string' || !/^\+[1-9][0-9]{0,2}$/u.test(input.countryCallingCode)))
|
|
40
|
+
throw new TypeError('Country calling context must be an explicitly supplied + prefix of one to three digits, or unknown.');
|
|
41
|
+
return { ...declarationFields(input), role: enumeration(input.role, INTAKE_PHONE_ROLES, 'Phone role'), countryCallingCode: input.countryCallingCode };
|
|
42
|
+
}
|
|
43
|
+
function destinationDeclaration(raw, displayed) {
|
|
44
|
+
const input = exact(raw, ['sourceLabel', 'observedAt', 'basis', 'role'], 'Destination declaration');
|
|
45
|
+
return { ...declarationFields(input), role: enumeration(input.role, displayed ? ['displayed_claim'] : ['claimed_landing', 'supplied_redirect'], 'Destination evidence role') };
|
|
46
|
+
}
|
|
47
|
+
function phoneMeaning(original, declaration) {
|
|
48
|
+
const parsed = phoneCandidateValue(original);
|
|
49
|
+
if (parsed.state === 'unsupported')
|
|
50
|
+
throw new TypeError('Unsupported phone candidates cannot be selected.');
|
|
51
|
+
const conflict = parsed.canonical && declaration.countryCallingCode && !parsed.canonical.startsWith(declaration.countryCallingCode);
|
|
52
|
+
return { canonical: conflict ? null : parsed.canonical, extension: parsed.extension,
|
|
53
|
+
state: conflict ? 'country_context_conflict' : parsed.state };
|
|
54
|
+
}
|
|
55
|
+
/** A deliberate projection: unselected candidates and exact paired URLs never enter the report. */
|
|
56
|
+
export function withIntakeSelectedEvidence(result, raw) {
|
|
57
|
+
if (result.report.schemaVersion !== 2)
|
|
58
|
+
throw new TypeError('Historical intake reports require a fresh review before selecting evidence.');
|
|
59
|
+
if (raw === null) {
|
|
60
|
+
const { selectedEvidence: _removed, ...report } = result.report;
|
|
61
|
+
return report;
|
|
62
|
+
}
|
|
63
|
+
const input = exact(raw, ['sourceDigestSha256', 'phones', 'destinationPair'], 'Selected intake evidence');
|
|
64
|
+
if (input.sourceDigestSha256 !== result.report.source.digestSha256)
|
|
65
|
+
throw new TypeError('Selected evidence does not identify this complete input digest.');
|
|
66
|
+
validatePhoneReview(result);
|
|
67
|
+
const phones = [];
|
|
68
|
+
const seenSpans = new Set(), groups = new Map();
|
|
69
|
+
for (const rawSelection of array(input.phones, 'Selected phone spans', MAX_SELECTED_INTAKE_PHONES)) {
|
|
70
|
+
const selection = exact(rawSelection, ['start', 'end', 'declaration'], 'Selected phone span');
|
|
71
|
+
if (!result.phoneReview)
|
|
72
|
+
throw new TypeError('Phone selections require a fresh plaintext review.');
|
|
73
|
+
const start = integer(selection.start, 'Phone span start', 0, result.phoneReview.sourceTextLength), end = integer(selection.end, 'Phone span end', start + 1, result.phoneReview.sourceTextLength);
|
|
74
|
+
const candidate = result.phoneReview.candidates.find(item => item.start === start && item.end === end);
|
|
75
|
+
const span = `${start}:${end}`;
|
|
76
|
+
if (!candidate || seenSpans.has(span))
|
|
77
|
+
throw new TypeError('Select each exact reviewed phone span at most once.');
|
|
78
|
+
seenSpans.add(span);
|
|
79
|
+
const declaration = readIntakePhoneDeclaration(selection.declaration), meaning = phoneMeaning(candidate.original, declaration);
|
|
80
|
+
// Group only safely canonicalised equivalent observations; keep every exact source occurrence.
|
|
81
|
+
const key = meaning.canonical ? JSON.stringify([meaning.canonical, meaning.extension, declaration]) : span;
|
|
82
|
+
const occurrence = { original: candidate.original, start, end }, existing = groups.get(key);
|
|
83
|
+
if (existing === undefined) {
|
|
84
|
+
groups.set(key, phones.length);
|
|
85
|
+
phones.push({ ...meaning, declaration, occurrences: [occurrence] });
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
phones[existing].occurrences.push(occurrence);
|
|
89
|
+
}
|
|
90
|
+
let destinationPair = null;
|
|
91
|
+
if (input.destinationPair !== null) {
|
|
92
|
+
const pair = exact(input.destinationPair, ['displayed', 'destination', 'displayedDeclaration', 'destinationDeclaration'], 'Supplied destination pair');
|
|
93
|
+
for (const value of [pair.displayed, pair.destination])
|
|
94
|
+
if (typeof value !== 'string' || value.length > MAX_INTAKE_URL_LENGTH)
|
|
95
|
+
throw new TypeError('Supplied destination values exceed the bounded text limit.');
|
|
96
|
+
const displayed = projectIntakeDestination(pair.displayed, true), destination = projectIntakeDestination(pair.destination, true);
|
|
97
|
+
destinationPair = { displayed, destination, displayedDeclaration: destinationDeclaration(pair.displayedDeclaration, true), destinationDeclaration: destinationDeclaration(pair.destinationDeclaration, false), state: compareIntakeDestinations(displayed, destination) };
|
|
98
|
+
}
|
|
99
|
+
const selectedEvidence = { version: 1, sourceDigestSha256: result.report.source.digestSha256,
|
|
100
|
+
phoneCoverage: result.phoneReview ? { state: result.phoneReview.state, candidatesReviewed: result.phoneReview.candidatesReviewed, candidatesShown: result.phoneReview.candidates.length } : { state: 'not_reviewed', candidatesReviewed: 0, candidatesShown: 0 },
|
|
101
|
+
sourceTextLength: result.phoneReview?.sourceTextLength ?? null, offsetUnit: 'utf16_code_unit', phones, destinationPair, limitations: [...INTAKE_SELECTED_EVIDENCE_LIMITATIONS] };
|
|
102
|
+
const report = { ...result.report, selectedEvidence };
|
|
103
|
+
validateIntakeSelectedEvidence(report);
|
|
104
|
+
return report;
|
|
105
|
+
}
|
|
106
|
+
function validateDestinationProjection(raw) {
|
|
107
|
+
const input = exact(raw, ['state', 'hostname', 'origin', 'registrationDomain', 'hasPrivateLocation', 'normalisation'], 'Retained destination projection');
|
|
108
|
+
enumeration(input.state, ['parsed', 'missing', 'unsupported'], 'Destination state');
|
|
109
|
+
if (typeof input.hasPrivateLocation !== 'boolean')
|
|
110
|
+
throw new TypeError('Destination privacy state is malformed.');
|
|
111
|
+
const flags = array(input.normalisation, 'Destination normalisation', 3);
|
|
112
|
+
for (const flag of flags)
|
|
113
|
+
enumeration(flag, ['assumed_https_for_hostname_parsing', 'refanged_scheme_or_authority', 'unicode_hostname_to_ascii'], 'Destination normalisation');
|
|
114
|
+
if (new Set(flags).size !== flags.length)
|
|
115
|
+
throw new TypeError('Destination normalisation is duplicated.');
|
|
116
|
+
if (input.state === 'parsed') {
|
|
117
|
+
if (typeof input.origin !== 'string')
|
|
118
|
+
throw new TypeError('Retained destination origin is malformed.');
|
|
119
|
+
const parsed = projectIntakeDestination(input.origin);
|
|
120
|
+
if (parsed.state !== 'parsed' || parsed.origin !== input.origin || parsed.hostname !== input.hostname || parsed.registrationDomain !== input.registrationDomain || parsed.hasPrivateLocation)
|
|
121
|
+
throw new TypeError('Retained destinations must contain only a canonical origin and hostname.');
|
|
122
|
+
}
|
|
123
|
+
else if (input.hostname !== null || input.origin !== null || input.registrationDomain !== null || input.hasPrivateLocation || flags.length)
|
|
124
|
+
throw new TypeError('An unavailable destination cannot declare parsed values.');
|
|
125
|
+
return input;
|
|
126
|
+
}
|
|
127
|
+
export function validateIntakeSelectedEvidence(report) {
|
|
128
|
+
if (!Object.hasOwn(report, 'selectedEvidence'))
|
|
129
|
+
return;
|
|
130
|
+
if (report.schemaVersion !== 2)
|
|
131
|
+
throw new TypeError('Selected evidence requires intake version 2.');
|
|
132
|
+
const input = exact(report.selectedEvidence, ['version', 'sourceDigestSha256', 'sourceTextLength', 'phoneCoverage', 'offsetUnit', 'phones', 'destinationPair', 'limitations'], 'Retained selected evidence');
|
|
133
|
+
if (input.version !== 1 || input.sourceDigestSha256 !== report.source.digestSha256 || input.offsetUnit !== 'utf16_code_unit')
|
|
134
|
+
throw new TypeError('Unsupported selected-evidence version or source identity.');
|
|
135
|
+
const sourceLength = input.sourceTextLength === null ? null : integer(input.sourceTextLength, 'Selected source text length', 0, report.source.byteLength);
|
|
136
|
+
if (sourceLength !== null && report.source.kind !== 'text')
|
|
137
|
+
throw new TypeError('Only plaintext has phone source spans.');
|
|
138
|
+
const coverage = exact(input.phoneCoverage, ['state', 'candidatesReviewed', 'candidatesShown'], 'Retained phone coverage');
|
|
139
|
+
enumeration(coverage.state, ['reviewed', 'partial', 'not_reviewed'], 'Retained phone coverage');
|
|
140
|
+
const reviewed = integer(coverage.candidatesReviewed, 'Reviewed phone candidates', 0, MAX_INTAKE_INDICATOR_CANDIDATES), shown = integer(coverage.candidatesShown, 'Shown phone candidates', 0, MAX_INTAKE_INDICATORS);
|
|
141
|
+
if (reviewed < shown || (coverage.state === 'not_reviewed' ? sourceLength !== null || reviewed !== 0 || shown !== 0 : sourceLength === null))
|
|
142
|
+
throw new TypeError('Retained phone coverage is inconsistent.');
|
|
143
|
+
let occurrences = 0;
|
|
144
|
+
const spans = new Set();
|
|
145
|
+
for (const rawPhone of array(input.phones, 'Retained phone groups', MAX_SELECTED_INTAKE_PHONES)) {
|
|
146
|
+
if (report.source.kind !== 'text' || sourceLength === null)
|
|
147
|
+
throw new TypeError('Retained phones require plaintext source identity.');
|
|
148
|
+
const phone = exact(rawPhone, ['canonical', 'extension', 'state', 'declaration', 'occurrences'], 'Retained phone');
|
|
149
|
+
const declaration = readIntakePhoneDeclaration(phone.declaration);
|
|
150
|
+
for (const rawOccurrence of array(phone.occurrences, 'Retained phone occurrences', MAX_SELECTED_INTAKE_PHONES, 1)) {
|
|
151
|
+
if (++occurrences > MAX_SELECTED_INTAKE_PHONES)
|
|
152
|
+
throw new TypeError('Selected phone occurrences exceed their aggregate limit.');
|
|
153
|
+
const occurrence = exact(rawOccurrence, ['original', 'start', 'end'], 'Retained phone occurrence');
|
|
154
|
+
const start = integer(occurrence.start, 'Phone span start', 0, sourceLength), end = integer(occurrence.end, 'Phone span end', start + 1, sourceLength);
|
|
155
|
+
if (typeof occurrence.original !== 'string' || occurrence.original.length !== end - start || occurrence.original.length > MAX_INTAKE_PHONE_CHARACTERS || spans.has(`${start}:${end}`))
|
|
156
|
+
throw new TypeError('Retained phone occurrence is malformed or duplicated.');
|
|
157
|
+
spans.add(`${start}:${end}`);
|
|
158
|
+
const parsed = phoneMeaning(occurrence.original, declaration);
|
|
159
|
+
if (phone.canonical !== parsed.canonical || phone.extension !== parsed.extension || phone.state !== parsed.state)
|
|
160
|
+
throw new TypeError('Retained phone meaning contradicts its original text or declaration.');
|
|
161
|
+
}
|
|
162
|
+
if (phone.canonical === null && phone.occurrences.length !== 1)
|
|
163
|
+
throw new TypeError('Ambiguous phone occurrences cannot be merged.');
|
|
164
|
+
}
|
|
165
|
+
if (occurrences > shown)
|
|
166
|
+
throw new TypeError('Selected phone occurrences exceed the reviewed candidates.');
|
|
167
|
+
if (input.destinationPair !== null) {
|
|
168
|
+
const pair = exact(input.destinationPair, ['displayed', 'destination', 'displayedDeclaration', 'destinationDeclaration', 'state'], 'Retained destination pair');
|
|
169
|
+
const displayed = validateDestinationProjection(pair.displayed), destination = validateDestinationProjection(pair.destination);
|
|
170
|
+
destinationDeclaration(pair.displayedDeclaration, true);
|
|
171
|
+
destinationDeclaration(pair.destinationDeclaration, false);
|
|
172
|
+
if (pair.state !== compareIntakeDestinations(displayed, destination))
|
|
173
|
+
throw new TypeError('Destination comparison contradicts its retained hosts.');
|
|
174
|
+
}
|
|
175
|
+
if (JSON.stringify(input.limitations) !== JSON.stringify(INTAKE_SELECTED_EVIDENCE_LIMITATIONS))
|
|
176
|
+
throw new TypeError('Selected evidence must retain its privacy and source limitations.');
|
|
177
|
+
}
|
|
178
|
+
/** Existing v1 context remains supported; v2 selections are bound to the current transient review. */
|
|
179
|
+
export function applyIntakeContextInput(result, input) {
|
|
180
|
+
const parsed = parseBoundedJson(input, { label: 'Intake context input', maximumBytes: MAX_INTAKE_CONTEXT_BYTES });
|
|
181
|
+
const root = exactOptional(parsed, ['schema', 'version', 'context'], ['review'], 'Intake context input');
|
|
182
|
+
if (root.schema !== INTAKE_CONTEXT_SCHEMA)
|
|
183
|
+
throw new TypeError('Unsupported intake context schema.');
|
|
184
|
+
if (root.version === 1)
|
|
185
|
+
return withIntakeDistributionContext(result.report, parseIntakeContextInput(input));
|
|
186
|
+
if (root.version !== INTAKE_CONTEXT_SELECTION_VERSION)
|
|
187
|
+
throw new TypeError('Unsupported intake context version.');
|
|
188
|
+
const report = withIntakeDistributionContext(result.report, root.context);
|
|
189
|
+
return Object.hasOwn(root, 'review') ? withIntakeSelectedEvidence({ ...result, report }, root.review) : report;
|
|
190
|
+
}
|
|
191
|
+
export function readIntakeDistributionContext(raw) {
|
|
192
|
+
if (raw === null)
|
|
193
|
+
return null;
|
|
194
|
+
const input = exact(raw, ['channel', 'observedAt', 'sourceLabel', 'reference', 'observerLabel', 'vantageLabel'], 'Supplied distribution context');
|
|
195
|
+
const observedAt = input.observedAt === null ? null : normalizeExplicitIsoTimestamp(input.observedAt);
|
|
196
|
+
if (input.observedAt !== null && !observedAt)
|
|
197
|
+
throw new TypeError('Declared observation time requires an explicit timezone.');
|
|
198
|
+
return {
|
|
199
|
+
channel: enumeration(input.channel, INTAKE_DISTRIBUTION_CHANNELS, 'Distribution channel'),
|
|
200
|
+
observedAt,
|
|
201
|
+
sourceLabel: label(input.sourceLabel, 'Context source label', 160, false),
|
|
202
|
+
reference: label(input.reference, 'Context reference', 160),
|
|
203
|
+
observerLabel: label(input.observerLabel, 'Observer label', 80),
|
|
204
|
+
vantageLabel: label(input.vantageLabel, 'Vantage label', 80),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export function parseIntakeContextInput(input) {
|
|
208
|
+
const root = exact(parseBoundedJson(input, {
|
|
209
|
+
label: 'Intake context input',
|
|
210
|
+
maximumBytes: MAX_INTAKE_CONTEXT_BYTES,
|
|
211
|
+
}), ['schema', 'version', 'context'], 'Intake context input');
|
|
212
|
+
if (root.schema !== INTAKE_CONTEXT_SCHEMA || root.version !== INTAKE_CONTEXT_VERSION)
|
|
213
|
+
throw new TypeError('Unsupported intake context schema or version.');
|
|
214
|
+
const result = readIntakeDistributionContext(root.context);
|
|
215
|
+
if (!result)
|
|
216
|
+
throw new TypeError('Supply an explicit distribution context.');
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
export function withIntakeDistributionContext(report, raw) {
|
|
220
|
+
if (report.schemaVersion !== 2)
|
|
221
|
+
throw new TypeError('Historical intake reports cannot acquire new declarations without a fresh review.');
|
|
222
|
+
return { ...report, distributionContext: readIntakeDistributionContext(raw) };
|
|
223
|
+
}
|
|
224
|
+
/** Validate only the versioned additions; this is not a general report importer. */
|
|
225
|
+
export function validateIntakeExtensions(report) {
|
|
226
|
+
if (report.schemaVersion === 1) {
|
|
227
|
+
if (['indicators', 'indicatorCoverage', 'distributionContext', 'selectedEvidence'].some((key) => Object.hasOwn(report, key)))
|
|
228
|
+
throw new TypeError('Version-1 intake reports cannot declare version-2 evidence.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (report.schemaVersion !== 2)
|
|
232
|
+
throw new TypeError('Unsupported intake report version.');
|
|
233
|
+
const locations = new Set([
|
|
234
|
+
'input',
|
|
235
|
+
...report.messageParts.map((part) => `message-${part.part}`),
|
|
236
|
+
...(report.documentReview?.parts.map((part) => part.id) ?? []),
|
|
237
|
+
]);
|
|
238
|
+
array(report.indicators, 'Extracted indicators', MAX_INTAKE_INDICATORS).forEach((raw, index) => {
|
|
239
|
+
const value = exact(raw, ['id', 'kind', 'value', 'source', 'location', 'basis'], 'Extracted indicator');
|
|
240
|
+
const kind = enumeration(value.kind, ['ipv4', 'ipv6', 'md5', 'sha1', 'sha256'], 'Indicator kind');
|
|
241
|
+
const content = text(value.value, 'Indicator value', 64);
|
|
242
|
+
const location = exact(value.location, ['partId', 'page'], 'Indicator location');
|
|
243
|
+
if (value.id !== `indicator-${index + 1}` ||
|
|
244
|
+
value.basis !== 'literal_text' ||
|
|
245
|
+
!locations.has(String(location.partId)))
|
|
246
|
+
throw new TypeError('Indicator source reference is not present in this review.');
|
|
247
|
+
if (location.page !== null)
|
|
248
|
+
integer(location.page, 'Indicator page', 1, 100_000);
|
|
249
|
+
const source = enumeration(value.source, ['text', 'calendar', 'document_text'], 'Indicator source');
|
|
250
|
+
if (kind === 'ipv4' || kind === 'ipv6') {
|
|
251
|
+
if (canonicalIpAddress(content) !== content || content.includes(':') !== (kind === 'ipv6'))
|
|
252
|
+
throw new TypeError('Indicator IP address is not canonical.');
|
|
253
|
+
}
|
|
254
|
+
else if (!new RegExp(`^[a-f0-9]{${{ md5: 32, sha1: 40, sha256: 64 }[kind]}}$`, 'u').test(content))
|
|
255
|
+
throw new TypeError('Indicator hash is malformed.');
|
|
256
|
+
const part = report.documentReview?.parts.find((part) => part.id === location.partId);
|
|
257
|
+
if (part ? location.page !== part.page : location.page !== null)
|
|
258
|
+
throw new TypeError('Indicator page does not match its source part.');
|
|
259
|
+
const messageParts = report.messageParts.filter((part) => `message-${part.part}` === location.partId);
|
|
260
|
+
const validSource = source === 'document_text'
|
|
261
|
+
? (report.source.kind === 'pdf' || report.source.kind === 'docx') && part?.kind === 'text'
|
|
262
|
+
: report.source.kind === 'email'
|
|
263
|
+
? messageParts.length === 1
|
|
264
|
+
: location.partId === 'input' && report.source.kind === source;
|
|
265
|
+
if (!validSource)
|
|
266
|
+
throw new TypeError('Indicator source kind does not match the reviewed text part.');
|
|
267
|
+
});
|
|
268
|
+
const coverage = exact(report.indicatorCoverage, ['state', 'candidatesReviewed'], 'Indicator coverage');
|
|
269
|
+
enumeration(coverage.state, ['reviewed', 'partial', 'not_reviewed'], 'Indicator coverage');
|
|
270
|
+
const candidatesReviewed = integer(coverage.candidatesReviewed, 'Indicator candidate count', 0, MAX_INTAKE_INDICATOR_CANDIDATES);
|
|
271
|
+
if (candidatesReviewed < report.indicators.length ||
|
|
272
|
+
(coverage.state === 'not_reviewed' && candidatesReviewed !== 0))
|
|
273
|
+
throw new TypeError('Indicator coverage contradicts the retained observations.');
|
|
274
|
+
if (report.source.kind === 'identity' ||
|
|
275
|
+
report.source.kind === 'har' ||
|
|
276
|
+
report.source.kind === 'qr') {
|
|
277
|
+
if (report.indicators.length ||
|
|
278
|
+
coverage.state !== 'not_reviewed' ||
|
|
279
|
+
coverage.candidatesReviewed !== 0)
|
|
280
|
+
throw new TypeError('This input keeps its existing sensitive-data projection; indicator text was not reviewed.');
|
|
281
|
+
}
|
|
282
|
+
readIntakeDistributionContext(report.distributionContext);
|
|
283
|
+
validateIntakeSelectedEvidence(report);
|
|
284
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { canonicalIpAddress } from "../contracts/ip-address.mjs";
|
|
2
|
+
import { MAX_INTAKE_INDICATORS, MAX_INTAKE_INDICATOR_CANDIDATES, } from "../contracts/message-intake.mjs";
|
|
3
|
+
export const MAX_INTAKE_INDICATOR_TEXT = 2 * 1024 * 1024;
|
|
4
|
+
export function minimiseIndicatorToken(token) {
|
|
5
|
+
if (token.includes('//') || token.includes('@'))
|
|
6
|
+
return ' ';
|
|
7
|
+
const unwrapped = token.replace(/^[([{,;]+|[)\]},;.!?]+$/gu, '');
|
|
8
|
+
// A bare IPv6 address or explicit hash label is an observation, not a URI.
|
|
9
|
+
// Other schemes include opaque forms such as data: and urn:, whose commas
|
|
10
|
+
// and nested hash labels must not enter the later indicator scanners.
|
|
11
|
+
if (unwrapped.length <= 98 && canonicalIpAddress(unwrapped))
|
|
12
|
+
return token;
|
|
13
|
+
// Field prefixes such as value= must not conceal an opaque URI. Match at
|
|
14
|
+
// token boundaries, not just the beginning, before any punctuation splitting.
|
|
15
|
+
for (const match of unwrapped.matchAll(/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*)(?::|\[:\])/giu)) {
|
|
16
|
+
if (!/^(?:md5|sha-?1|sha-?256)$/iu.test(match[1]))
|
|
17
|
+
return ' ';
|
|
18
|
+
}
|
|
19
|
+
return token;
|
|
20
|
+
}
|
|
21
|
+
/** Literal selected text only. A syntactic indicator is not a network target or threat verdict. */
|
|
22
|
+
export function createIndicatorIntake() {
|
|
23
|
+
const indicators = [], seen = new Set();
|
|
24
|
+
let candidatesReviewed = 0, textReviewed = 0, partial = false, reviewed = false;
|
|
25
|
+
function admit(kind, value, source, location) {
|
|
26
|
+
const key = JSON.stringify([kind, value, source, location]);
|
|
27
|
+
if (seen.has(key))
|
|
28
|
+
return;
|
|
29
|
+
if (indicators.length >= MAX_INTAKE_INDICATORS) {
|
|
30
|
+
partial = true;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
seen.add(key);
|
|
34
|
+
indicators.push({
|
|
35
|
+
id: `indicator-${indicators.length + 1}`,
|
|
36
|
+
kind,
|
|
37
|
+
value,
|
|
38
|
+
source,
|
|
39
|
+
location: { ...location },
|
|
40
|
+
basis: 'literal_text',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function candidate() {
|
|
44
|
+
if (candidatesReviewed >= MAX_INTAKE_INDICATOR_CANDIDATES) {
|
|
45
|
+
partial = true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
candidatesReviewed++;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
markPartial() {
|
|
53
|
+
reviewed = true;
|
|
54
|
+
partial = true;
|
|
55
|
+
},
|
|
56
|
+
addText(value, source, location) {
|
|
57
|
+
reviewed = true;
|
|
58
|
+
if (partial || value.length > MAX_INTAKE_INDICATOR_TEXT - textReviewed) {
|
|
59
|
+
partial = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
textReviewed += value.length;
|
|
63
|
+
// Exclude complete URLs (including rejected/defanged URLs), addresses and
|
|
64
|
+
// credential-bearing lines before either scanner. Never scan raw HAR or
|
|
65
|
+
// identity-provider objects, message headers, script or style content.
|
|
66
|
+
const minimised = value
|
|
67
|
+
.replace(/^.*\b(?:authorization|proxy-authorization|cookie|set-cookie|password|passwd|access[_ -]?token|refresh[_ -]?token|session[_ -]?id|secret|api[_ -]?key|token)\s*[:=].*$/gimu, ' ')
|
|
68
|
+
.replace(/[^\s<>"'`]+/gu, minimiseIndicatorToken);
|
|
69
|
+
// Hashes require an explicit algorithm label. Bare opaque hexadecimal
|
|
70
|
+
// strings may be session material and are deliberately not interpreted.
|
|
71
|
+
for (const match of minimised.matchAll(/\b(md5|sha-?1|sha-?256)[ \t]*(?::|=|[ \t])[ \t]*([a-f0-9]+)\b/giu)) {
|
|
72
|
+
if (!candidate())
|
|
73
|
+
return;
|
|
74
|
+
const kind = match[1].toLowerCase().replace('-', '');
|
|
75
|
+
const hash = match[2].toLowerCase();
|
|
76
|
+
if (hash.length === { md5: 32, sha1: 40, sha256: 64 }[kind])
|
|
77
|
+
admit(kind, hash, source, location);
|
|
78
|
+
}
|
|
79
|
+
for (const match of minimised.matchAll(/[^\s<>"'`()\[\]{},;]+/gu)) {
|
|
80
|
+
const token = match[0];
|
|
81
|
+
if (!token.includes('.') && !token.includes(':'))
|
|
82
|
+
continue;
|
|
83
|
+
if (!candidate())
|
|
84
|
+
return;
|
|
85
|
+
if (token.length > 98)
|
|
86
|
+
continue;
|
|
87
|
+
const raw = token.replace(/[.!?]+$/u, '');
|
|
88
|
+
const address = canonicalIpAddress(raw);
|
|
89
|
+
if (address)
|
|
90
|
+
admit(address.includes(':') ? 'ipv6' : 'ipv4', address, source, location);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
result() {
|
|
94
|
+
return {
|
|
95
|
+
indicators: [...indicators],
|
|
96
|
+
indicatorCoverage: {
|
|
97
|
+
state: partial
|
|
98
|
+
? 'partial'
|
|
99
|
+
: reviewed
|
|
100
|
+
? 'reviewed'
|
|
101
|
+
: 'not_reviewed',
|
|
102
|
+
candidatesReviewed,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Version-1 reports have no indicator coverage; absence is not reinterpreted. */
|
|
109
|
+
export function intakeIndicators(report) {
|
|
110
|
+
return report.schemaVersion === 2 ? report.indicators : [];
|
|
111
|
+
}
|
|
112
|
+
export function intakeIndicatorSource(report, indicator) {
|
|
113
|
+
const document = report.documentReview?.parts.find((part) => part.id === indicator.location.partId);
|
|
114
|
+
const message = report.messageParts.find((part) => `message-${part.part}` === indicator.location.partId);
|
|
115
|
+
return document?.digestSha256 ?? message?.digestSha256 ?? report.source.digestSha256;
|
|
116
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { MAX_INTAKE_INDICATORS, MAX_INTAKE_INDICATOR_CANDIDATES } from "../contracts/message-intake.mjs";
|
|
2
|
+
import { exact, array, digest, integer, enumeration } from "../evidence/artifact-structure.mjs";
|
|
3
|
+
import { MAX_INTAKE_INDICATOR_TEXT, minimiseIndicatorToken } from "./intake-indicators.mjs";
|
|
4
|
+
export const MAX_INTAKE_PHONE_CHARACTERS = 96;
|
|
5
|
+
export const MAX_SELECTED_INTAKE_PHONES = 64;
|
|
6
|
+
const CREDENTIAL_LINE = /\b(?:authorization|proxy-authorization|cookie|set-cookie|password|passwd|access[_ -]?token|refresh[_ -]?token|session[_ -]?id|secret|api[_ -]?key|token)\s*[:=]/iu;
|
|
7
|
+
const PHONE_LABEL = /\b(?:phone|telephone|tel|call|support|contact|fax)\s*[:=]?\s*$/iu;
|
|
8
|
+
const NOT_PHONE_LABEL = /\b(?:date|order|account|invoice|reference|id|md5|sha-?1|sha-?256)\s*[:=#]?\s*$/iu;
|
|
9
|
+
const SEPARATORS = /[ ()\t\u00a0\u202f\-\u2010-\u2014]/gu;
|
|
10
|
+
const UNSUPPORTED = /[^+0-9 ()\t\u00a0\u202f\-\u2010-\u2014]/u;
|
|
11
|
+
/** Bounded by the admitted text length; unterminated markup excludes the remainder. */
|
|
12
|
+
function excludeMarkup(value, mark) {
|
|
13
|
+
function tagEnd(start) {
|
|
14
|
+
let quote = null;
|
|
15
|
+
for (let cursor = start + 1; cursor < value.length; cursor++) {
|
|
16
|
+
const character = value[cursor];
|
|
17
|
+
if (quote) {
|
|
18
|
+
if (character === quote)
|
|
19
|
+
quote = null;
|
|
20
|
+
}
|
|
21
|
+
else if (character === '"' || character === "'")
|
|
22
|
+
quote = character;
|
|
23
|
+
else if (character === '>')
|
|
24
|
+
return cursor + 1;
|
|
25
|
+
}
|
|
26
|
+
return value.length;
|
|
27
|
+
}
|
|
28
|
+
let cursor = 0;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const start = value.indexOf('<', cursor);
|
|
31
|
+
if (start < 0)
|
|
32
|
+
return;
|
|
33
|
+
let end = tagEnd(start);
|
|
34
|
+
const rawTag = /^<(script|style)(?=[\t\n\f\r />])/iu.exec(value.slice(start, start + 9))?.[1]?.toLowerCase();
|
|
35
|
+
if (rawTag) {
|
|
36
|
+
let closing = value.indexOf('</', end);
|
|
37
|
+
while (closing >= 0) {
|
|
38
|
+
const nameEnd = closing + 2 + rawTag.length;
|
|
39
|
+
if (value.slice(closing + 2, nameEnd).toLowerCase() === rawTag && /[\t\n\f\r />]/u.test(value[nameEnd] ?? '')) {
|
|
40
|
+
end = tagEnd(closing);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
closing = value.indexOf('</', closing + 2);
|
|
44
|
+
}
|
|
45
|
+
if (closing < 0)
|
|
46
|
+
end = value.length;
|
|
47
|
+
}
|
|
48
|
+
mark(start, end);
|
|
49
|
+
cursor = end;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Syntax only, not numbering-plan validation or ownership/reachability evidence. */
|
|
53
|
+
export function phoneCandidateValue(original) {
|
|
54
|
+
let body = original;
|
|
55
|
+
const suffix = /[ \t\u00a0\u202f]*(?:ext\.?|x)[ \t\u00a0\u202f]*([0-9]{1,6})$/iu.exec(body);
|
|
56
|
+
const extension = suffix?.[1] ?? null;
|
|
57
|
+
if (suffix)
|
|
58
|
+
body = body.slice(0, suffix.index).trimEnd();
|
|
59
|
+
const digits = body.replace(SEPARATORS, '');
|
|
60
|
+
const issues = [];
|
|
61
|
+
if (original.length > MAX_INTAKE_PHONE_CHARACTERS)
|
|
62
|
+
issues.push('The candidate exceeds the supported character limit.');
|
|
63
|
+
if (UNSUPPORTED.test(body))
|
|
64
|
+
issues.push('Unsupported digits, controls or punctuation were preserved, not normalised.');
|
|
65
|
+
if (!/^\+?[0-9]{7,15}$/u.test(digits))
|
|
66
|
+
issues.push('Only seven to fifteen ASCII digits with supported separators are reviewable.');
|
|
67
|
+
if ((body.match(/[0-9]{7,}/gu)?.length ?? 0) > 1)
|
|
68
|
+
issues.push('Separate long digit runs are not joined into a phone number.');
|
|
69
|
+
if (body.includes('(') !== body.includes(')') || (body.includes('(') && body.indexOf('(') > body.indexOf(')')) || (body.match(/\(/gu)?.length ?? 0) > 1 || (body.match(/\)/gu)?.length ?? 0) > 1)
|
|
70
|
+
issues.push('Parentheses are unsupported or unbalanced.');
|
|
71
|
+
if (/^\+0/u.test(digits))
|
|
72
|
+
issues.push('An international candidate cannot begin with zero.');
|
|
73
|
+
if (issues.length)
|
|
74
|
+
return { canonical: null, extension, state: 'unsupported', issues };
|
|
75
|
+
return { canonical: digits.startsWith('+') ? digits : null, extension,
|
|
76
|
+
state: digits.startsWith('+') ? 'international_candidate' : 'national_ambiguous', issues: [] };
|
|
77
|
+
}
|
|
78
|
+
/** Selected plaintext only. The original string is never normalised before indexing. */
|
|
79
|
+
export function reviewPhoneCandidates(value, sourceDigestSha256) {
|
|
80
|
+
digest(sourceDigestSha256, 'Phone input digest');
|
|
81
|
+
const candidates = [];
|
|
82
|
+
let candidatesReviewed = 0, partial = false;
|
|
83
|
+
if (value.length > MAX_INTAKE_INDICATOR_TEXT)
|
|
84
|
+
partial = true;
|
|
85
|
+
else {
|
|
86
|
+
// Mark excluded ranges without changing string lengths or source offsets.
|
|
87
|
+
const excluded = new Uint8Array(value.length);
|
|
88
|
+
const mark = (start, end) => excluded.fill(1, start, end);
|
|
89
|
+
for (const line of value.matchAll(/[^\r\n]+/gu))
|
|
90
|
+
if (CREDENTIAL_LINE.test(line[0]))
|
|
91
|
+
mark(line.index, line.index + line[0].length);
|
|
92
|
+
for (const token of value.matchAll(/[^\s<>"'`]+/gu))
|
|
93
|
+
if (minimiseIndicatorToken(token[0]) === ' ')
|
|
94
|
+
mark(token.index, token.index + token[0].length);
|
|
95
|
+
// Pasted markup is not a request to inspect attributes or executable content.
|
|
96
|
+
excludeMarkup(value, mark);
|
|
97
|
+
for (const match of value.matchAll(/[+\uFF0B(]?[\p{Nd}][\p{Nd} ()\t\u00a0\u202f\-\u2010-\u2014\p{Cf}]{5,}(?:(?:ext\.?|x)[ \t\u00a0\u202f]*[\p{Nd}]+)?/giu)) {
|
|
98
|
+
if (candidatesReviewed >= MAX_INTAKE_INDICATOR_CANDIDATES || candidates.length >= MAX_INTAKE_INDICATORS) {
|
|
99
|
+
partial = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
candidatesReviewed++;
|
|
103
|
+
const original = match[0].trimEnd(), start = match.index, end = start + original.length;
|
|
104
|
+
if (original.length > MAX_INTAKE_PHONE_CHARACTERS) {
|
|
105
|
+
partial = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (excluded.subarray(start, end).some(Boolean))
|
|
109
|
+
continue;
|
|
110
|
+
const before = value.slice(Math.max(0, start - 48), start);
|
|
111
|
+
const after = value.slice(end, end + 2);
|
|
112
|
+
// Sentence punctuation is outside the original span; embedded dots still reject.
|
|
113
|
+
if (/[\p{L}\p{N}_./:@+-]$/u.test(before)
|
|
114
|
+
|| (/^[\p{L}\p{N}_./:@+-]/u.test(after) && !/^\.(?:\s|$)/u.test(after)))
|
|
115
|
+
continue;
|
|
116
|
+
if (NOT_PHONE_LABEL.test(before) || /^\d{4}[- ]\d{1,2}[- ]\d{1,2}$/u.test(original) || /^\d{1,2}[- ]\d{1,2}[- ]\d{2,4}$/u.test(original))
|
|
117
|
+
continue;
|
|
118
|
+
if (!/^[+\uFF0B]/u.test(original) && !PHONE_LABEL.test(before))
|
|
119
|
+
continue;
|
|
120
|
+
const parsed = phoneCandidateValue(original);
|
|
121
|
+
candidates.push({ id: `phone-${candidates.length + 1}`, original, start, end, ...parsed });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { version: 1, sourceDigestSha256, sourceTextLength: value.length, offsetUnit: 'utf16_code_unit',
|
|
125
|
+
state: partial ? 'partial' : 'reviewed', candidatesReviewed, candidates };
|
|
126
|
+
}
|
|
127
|
+
/** Validate transient worker data separately from serialisable report evidence. */
|
|
128
|
+
export function validatePhoneReview(result) {
|
|
129
|
+
if (result.phoneReview === undefined)
|
|
130
|
+
return;
|
|
131
|
+
if (result.report.source.kind !== 'text' || result.report.schemaVersion !== 2)
|
|
132
|
+
throw new TypeError('Phone review requires selected plaintext.');
|
|
133
|
+
const review = exact(result.phoneReview, ['version', 'sourceDigestSha256', 'sourceTextLength', 'offsetUnit', 'state', 'candidatesReviewed', 'candidates'], 'Phone review');
|
|
134
|
+
if (review.version !== 1 || review.sourceDigestSha256 !== result.report.source.digestSha256 || review.offsetUnit !== 'utf16_code_unit')
|
|
135
|
+
throw new TypeError('Phone review source identity is inconsistent.');
|
|
136
|
+
const length = integer(review.sourceTextLength, 'Phone source length', 0, result.report.source.byteLength);
|
|
137
|
+
enumeration(review.state, ['reviewed', 'partial'], 'Phone review coverage');
|
|
138
|
+
const reviewed = integer(review.candidatesReviewed, 'Phone candidate count', 0, MAX_INTAKE_INDICATOR_CANDIDATES);
|
|
139
|
+
let previousEnd = 0;
|
|
140
|
+
const candidates = array(review.candidates, 'Phone candidates', MAX_INTAKE_INDICATORS);
|
|
141
|
+
if (reviewed < candidates.length)
|
|
142
|
+
throw new TypeError('Phone review counts are inconsistent.');
|
|
143
|
+
if (length > MAX_INTAKE_INDICATOR_TEXT && (review.state !== 'partial' || reviewed || candidates.length))
|
|
144
|
+
throw new TypeError('Overlong plaintext cannot claim phone extraction.');
|
|
145
|
+
candidates.forEach((raw, index) => {
|
|
146
|
+
const item = exact(raw, ['id', 'original', 'start', 'end', 'canonical', 'extension', 'state', 'issues'], 'Phone candidate');
|
|
147
|
+
const start = integer(item.start, 'Phone span start', previousEnd, length), end = integer(item.end, 'Phone span end', start + 1, length);
|
|
148
|
+
if (item.id !== `phone-${index + 1}` || typeof item.original !== 'string' || item.original.length !== end - start || item.original.length > MAX_INTAKE_PHONE_CHARACTERS)
|
|
149
|
+
throw new TypeError('Phone candidate span is inconsistent.');
|
|
150
|
+
const parsed = phoneCandidateValue(item.original);
|
|
151
|
+
if (item.canonical !== parsed.canonical || item.extension !== parsed.extension || item.state !== parsed.state || JSON.stringify(item.issues) !== JSON.stringify(parsed.issues))
|
|
152
|
+
throw new TypeError('Phone candidate interpretation is inconsistent.');
|
|
153
|
+
previousEnd = end;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -15,5 +15,6 @@ export async function createIntakeReport(bytes, kind, reviewedAt) {
|
|
|
15
15
|
source: { kind, digestSha256: await sha256ArtifactBytes(bytes), byteLength: bytes.byteLength },
|
|
16
16
|
coverage: { state: 'reviewed', reviewedParts: 0, unreviewedAttachments: 0, rejectedLinks: 0, boundsReached: [] },
|
|
17
17
|
identities: [], authenticationClaims: [], authenticationReview: { headers: [], omittedHeaders: 0 }, messageParts: [],
|
|
18
|
-
links: [],
|
|
18
|
+
links: [], indicators: [], indicatorCoverage: { state: 'not_reviewed', candidatesReviewed: 0 }, distributionContext: null,
|
|
19
|
+
actionHints: [], identityRecovery: { reportedActions: [], nextSteps: [] } };
|
|
19
20
|
}
|