@aglyn/shared-util-email 1.0.0-beta.219 → 1.0.0-beta.221
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/package.json +3 -3
- package/src/index.d.ts +1 -0
- package/src/index.js +4 -0
- package/src/index.js.map +1 -1
- package/src/lib/hosted-page-screen.d.ts +32 -0
- package/src/lib/hosted-page-screen.js +233 -24
- package/src/lib/hosted-page-screen.js.map +1 -1
- package/src/lib/link-reputation.d.ts +74 -0
- package/src/lib/link-reputation.js +123 -0
- package/src/lib/link-reputation.js.map +1 -0
- package/src/lib/outbound-phishing-screen.d.ts +93 -3
- package/src/lib/outbound-phishing-screen.js +480 -31
- package/src/lib/outbound-phishing-screen.js.map +1 -1
- package/src/lib/outbound-screen-gate.d.ts +4 -3
- package/src/lib/outbound-screen-gate.js +19 -5
- package/src/lib/outbound-screen-gate.js.map +1 -1
- package/src/lib/risk-notice-catalog.d.ts +32 -2
- package/src/lib/risk-notice-catalog.js +86 -55
- package/src/lib/risk-notice-catalog.js.map +1 -1
- package/src/lib/send-email.d.ts +23 -0
- package/src/lib/send-email.js +30 -3
- package/src/lib/send-email.js.map +1 -1
- package/src/lib/sending-domain.d.ts +15 -0
- package/src/lib/sending-domain.js.map +1 -1
- package/src/lib/system-email-catalog.js +34 -8
- package/src/lib/system-email-catalog.js.map +1 -1
- package/src/lib/tenant-emails.generated.js +73 -18
- package/src/lib/tenant-emails.generated.js.map +1 -1
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*
|
|
32
32
|
* A false positive costs a real merchant's first campaign a wait for a
|
|
33
33
|
* human; a miss costs strangers their bank logins. Both are real, so only
|
|
34
|
-
*
|
|
34
|
+
* four shapes hold, and each is one a legitimate merchant essentially never
|
|
35
35
|
* produces:
|
|
36
36
|
*
|
|
37
37
|
* 1. **A lookalike link** — a link or reply address whose host carries a
|
|
@@ -48,17 +48,27 @@
|
|
|
48
48
|
* neither the brand's, nor the workspace's, nor a common social or maps
|
|
49
49
|
* link. Any two of the three are ordinary marketing — "our book is on
|
|
50
50
|
* Amazon", "sign in to your account", a link to the shop — and pass.
|
|
51
|
+
* 4. **A prefilled sign-in** (AGL-3453): account or password phrasing
|
|
52
|
+
* ("your password expires today") beside a link to such a domain that
|
|
53
|
+
* carries the reader's own address, so the harvester's form opens with
|
|
54
|
+
* it filled in: `…/ss/c/…#victim@example.com`.
|
|
51
55
|
*
|
|
52
|
-
* Mentioning a brand, alone, never holds.
|
|
56
|
+
* Mentioning a brand, alone, never holds. Brands and lures are read from
|
|
57
|
+
* what the reader SEES ({@link visibleTextOf}), never from the raw HTML, in
|
|
58
|
+
* which a kit splits every letter with a tag; links are read from the raw
|
|
59
|
+
* HTML, where an href lives.
|
|
53
60
|
*
|
|
54
61
|
* ## The brand list is small on purpose
|
|
55
62
|
*
|
|
56
|
-
* The brands phishing kits most often wear, plus the two in the incident
|
|
57
|
-
*
|
|
58
|
-
*
|
|
63
|
+
* The brands phishing kits most often wear, plus the two in the incident, and
|
|
64
|
+
* the file-share and mail-security names a document-share lure wears — a
|
|
65
|
+
* "Secure Document Access Portal" under Proofpoint's or SharePoint's name
|
|
66
|
+
* whose one button leaves for the kit (AGL-3447). A brand is added when it
|
|
67
|
+
* has been seen impersonated, not because it is big: every entry is a set of
|
|
68
|
+
* words that can now hold somebody's newsletter.
|
|
59
69
|
* Each carries its REAL domains, so a link to the brand itself is never
|
|
60
70
|
* suspicious, and host tokens chosen so a common word (`booking`, `apple`,
|
|
61
|
-
* `outlook`) does not match an unrelated host by itself.
|
|
71
|
+
* `outlook`, `box`, `adobe`) does not match an unrelated host by itself.
|
|
62
72
|
*
|
|
63
73
|
* Pure: no DNS, no store, no clock. Everything the caller knows about the
|
|
64
74
|
* workspace arrives as input.
|
|
@@ -161,10 +171,15 @@
|
|
|
161
171
|
{
|
|
162
172
|
id: 'microsoft',
|
|
163
173
|
label: 'Microsoft',
|
|
164
|
-
|
|
174
|
+
// Outlook only as the product — "our 2026 outlook" is every advisor's
|
|
175
|
+
// newsletter.
|
|
176
|
+
mention: /\bmicrosoft\b|\b(?:office|ms|m)\s?365\b|\bonedrive\b|\bsharepoint\b|\boutlook(?:\.com|\s+(?:web\s+app|web\s+access|online|account|365))\b/i,
|
|
165
177
|
hostTokens: [
|
|
166
178
|
'microsoft',
|
|
179
|
+
'microsoft365',
|
|
167
180
|
'office365',
|
|
181
|
+
'office-365',
|
|
182
|
+
'outlook365',
|
|
168
183
|
'onedrive',
|
|
169
184
|
'sharepoint',
|
|
170
185
|
'microsoftonline',
|
|
@@ -174,10 +189,12 @@
|
|
|
174
189
|
'microsoft.com',
|
|
175
190
|
'office.com',
|
|
176
191
|
'office365.com',
|
|
192
|
+
'microsoft365.com',
|
|
177
193
|
'live.com',
|
|
178
194
|
'outlook.com',
|
|
179
195
|
'microsoftonline.com',
|
|
180
196
|
'sharepoint.com',
|
|
197
|
+
'sharepointonline.com',
|
|
181
198
|
'onedrive.com',
|
|
182
199
|
'1drv.ms',
|
|
183
200
|
'aka.ms'
|
|
@@ -195,6 +212,158 @@
|
|
|
195
212
|
'docusign.net'
|
|
196
213
|
]
|
|
197
214
|
},
|
|
215
|
+
{
|
|
216
|
+
id: 'google',
|
|
217
|
+
label: 'Google',
|
|
218
|
+
// The account and the files, never the bare name: "find us on Google"
|
|
219
|
+
// and "Google Maps" are on every shop's contact page.
|
|
220
|
+
mention: /\bgoogle\s?(?:drive|docs?|sheets|slides|workspace|account)\b|\bgmail\b|\bg\s?suite\b/i,
|
|
221
|
+
// The products and the account, never the bare name: Google runs
|
|
222
|
+
// hyphenated hosts of its own that a page embeds, and a bare `google`
|
|
223
|
+
// token would read each as its lookalike. `docs.google.com.<kit>` is
|
|
224
|
+
// still read, by the primary-domain rule.
|
|
225
|
+
hostTokens: [
|
|
226
|
+
'gmail',
|
|
227
|
+
'gsuite',
|
|
228
|
+
'googledocs',
|
|
229
|
+
'googledrive',
|
|
230
|
+
'google-docs',
|
|
231
|
+
'google-drive',
|
|
232
|
+
'google-workspace',
|
|
233
|
+
'google-account',
|
|
234
|
+
'google-login',
|
|
235
|
+
'google-mail',
|
|
236
|
+
'docs-google',
|
|
237
|
+
'drive-google'
|
|
238
|
+
],
|
|
239
|
+
officialDomains: [
|
|
240
|
+
'google.com',
|
|
241
|
+
'goo.gl',
|
|
242
|
+
'g.co',
|
|
243
|
+
'g.page',
|
|
244
|
+
'forms.gle',
|
|
245
|
+
'gmail.com',
|
|
246
|
+
'googleapis.com',
|
|
247
|
+
'gstatic.com',
|
|
248
|
+
'googleusercontent.com',
|
|
249
|
+
'withgoogle.com'
|
|
250
|
+
],
|
|
251
|
+
countryDomains: true
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'adobe',
|
|
255
|
+
label: 'Adobe',
|
|
256
|
+
// "Adobe" alone is a house, a grill and an inn across the Southwest;
|
|
257
|
+
// the products are what a lure names.
|
|
258
|
+
mention: /\badobe\s?(?:acrobat|sign|id|account|document\s?cloud|reader|pdf|creative\s?cloud)\b|\bacrobat\s?(?:reader|sign|pro)\b|\bechosign\b/i,
|
|
259
|
+
hostTokens: [
|
|
260
|
+
'adobe',
|
|
261
|
+
'adobeid',
|
|
262
|
+
'adobesign',
|
|
263
|
+
'adobeacrobat',
|
|
264
|
+
'echosign'
|
|
265
|
+
],
|
|
266
|
+
wordToken: true,
|
|
267
|
+
officialDomains: [
|
|
268
|
+
'adobe.com',
|
|
269
|
+
'adobe.io',
|
|
270
|
+
'adobe.ly',
|
|
271
|
+
'adobelogin.com',
|
|
272
|
+
'adobesign.com',
|
|
273
|
+
'echosign.com',
|
|
274
|
+
'acrobat.com',
|
|
275
|
+
'typekit.net',
|
|
276
|
+
'adobedtm.com'
|
|
277
|
+
]
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'dropbox',
|
|
281
|
+
label: 'Dropbox',
|
|
282
|
+
// One word only: a "drop box" is by every library's door.
|
|
283
|
+
mention: /\bdropbox\b/i,
|
|
284
|
+
hostTokens: [
|
|
285
|
+
'dropbox'
|
|
286
|
+
],
|
|
287
|
+
officialDomains: [
|
|
288
|
+
'dropbox.com',
|
|
289
|
+
'db.tt',
|
|
290
|
+
'dropboxusercontent.com',
|
|
291
|
+
'dropboxstatic.com',
|
|
292
|
+
'hellosign.com'
|
|
293
|
+
]
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
// Never the bare word, as an id either — `brandForSubdomainLabel` reads
|
|
297
|
+
// ids as words, and "box" is every lunch box's and subscription box's.
|
|
298
|
+
id: 'boxcom',
|
|
299
|
+
label: 'Box',
|
|
300
|
+
mention: /\bbox\.com\b|\bbox\s+drive\b/i,
|
|
301
|
+
hostTokens: [
|
|
302
|
+
'boxcom',
|
|
303
|
+
'box-com'
|
|
304
|
+
],
|
|
305
|
+
officialDomains: [
|
|
306
|
+
'box.com',
|
|
307
|
+
'box.net',
|
|
308
|
+
'boxcdn.net',
|
|
309
|
+
'boxcloud.com'
|
|
310
|
+
]
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'wetransfer',
|
|
314
|
+
label: 'WeTransfer',
|
|
315
|
+
// One word only: "we transfer" is a moving company's sentence.
|
|
316
|
+
mention: /\bwetransfer\b/i,
|
|
317
|
+
hostTokens: [
|
|
318
|
+
'wetransfer'
|
|
319
|
+
],
|
|
320
|
+
officialDomains: [
|
|
321
|
+
'wetransfer.com',
|
|
322
|
+
'we.tl'
|
|
323
|
+
]
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: 'sharefile',
|
|
327
|
+
label: 'ShareFile',
|
|
328
|
+
mention: /\bshare\s?file\b/i,
|
|
329
|
+
hostTokens: [
|
|
330
|
+
'sharefile'
|
|
331
|
+
],
|
|
332
|
+
officialDomains: [
|
|
333
|
+
'sharefile.com',
|
|
334
|
+
'sharefile.eu',
|
|
335
|
+
'citrix.com'
|
|
336
|
+
]
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
// A secure-mail gateway's name is what makes a document lure read as
|
|
340
|
+
// safe: "Proofpoint Encryption for your sensitive documents".
|
|
341
|
+
id: 'proofpoint',
|
|
342
|
+
label: 'Proofpoint',
|
|
343
|
+
// One word only: a "proof point" is every pitch deck's.
|
|
344
|
+
mention: /\bproofpoint\b/i,
|
|
345
|
+
hostTokens: [
|
|
346
|
+
'proofpoint'
|
|
347
|
+
],
|
|
348
|
+
officialDomains: [
|
|
349
|
+
'proofpoint.com',
|
|
350
|
+
'urldefense.com',
|
|
351
|
+
'pphosted.com',
|
|
352
|
+
'ppe-hosted.com'
|
|
353
|
+
]
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: 'mimecast',
|
|
357
|
+
label: 'Mimecast',
|
|
358
|
+
mention: /\bmimecast\b/i,
|
|
359
|
+
hostTokens: [
|
|
360
|
+
'mimecast'
|
|
361
|
+
],
|
|
362
|
+
officialDomains: [
|
|
363
|
+
'mimecast.com',
|
|
364
|
+
'mimecastprotect.com'
|
|
365
|
+
]
|
|
366
|
+
},
|
|
198
367
|
{
|
|
199
368
|
id: 'netflix',
|
|
200
369
|
label: 'Netflix',
|
|
@@ -301,26 +470,60 @@
|
|
|
301
470
|
}
|
|
302
471
|
];
|
|
303
472
|
/**
|
|
304
|
-
* Account
|
|
305
|
-
*
|
|
306
|
-
|
|
307
|
-
*/ export const PHISHING_LURE_PATTERNS = [
|
|
473
|
+
* Account and credential phrasing: the lure that asks the reader to DO
|
|
474
|
+
* something with an account — verify it, confirm it, sign in to it.
|
|
475
|
+
*/ export const ACCOUNT_LURE_PATTERNS = [
|
|
308
476
|
/\bverify\s+your\s+(?:account|identity|information|details|payment|email)\b/i,
|
|
309
477
|
/\bconfirm\s+your\s+(?:account|identity|details|payment|password|billing|information)\b/i,
|
|
310
|
-
/\b(?:account|payment|card)\s+(?:has\s+been\s+|is\s+|was\s+)?(?:suspended|locked|limited|restricted|disabled|on\s+hold)\b/i,
|
|
478
|
+
/\b(?:account|payment|card)\s+(?:has\s+been\s+|is\s+|was\s+|will\s+be\s+)?(?:suspended|locked|limited|restricted|disabled|deactivated|terminated|on\s+hold)\b/i,
|
|
311
479
|
/\bunusual\s+(?:sign[-\s]?in|login|activity)\b/i,
|
|
312
480
|
/\bupdate\s+your\s+(?:payment|billing|card|account)\s*(?:details|information|method)?\b/i,
|
|
313
481
|
/\b(?:sign|log)\s?in\s+to\s+(?:your|view|confirm|claim|release)\b/i,
|
|
482
|
+
// "Password Expired", "your password expires today", "will expire".
|
|
483
|
+
/\bpassword\s+(?:has\s+|will\s+)?expire/i,
|
|
484
|
+
/\bkeep\s+(?:your|my|the)\s+(?:current\s+|active\s+|existing\s+|same\s+)?password\b/i,
|
|
485
|
+
/\bchange\s+(?:your\s+)?password\s+settings?\b/i,
|
|
486
|
+
/\bmailbox\s+(?:storage\s+|quota\s+)?(?:is\s+)?(?:almost\s+|nearly\s+)?(?:full|exceeded)\b/i,
|
|
487
|
+
/\bsecurity\s+alert\b/i
|
|
488
|
+
];
|
|
489
|
+
/**
|
|
490
|
+
* Document-share phrasing (AGL-3447): the lure that says a file is waiting
|
|
491
|
+
* behind a secure door — "Secure Document Access Portal", "Continue to
|
|
492
|
+
* Document", "shared a file with you", "encrypted message". The credential
|
|
493
|
+
* harvest is on the far side of the button, so these are the words the page
|
|
494
|
+
* or the mail itself carries.
|
|
495
|
+
*/ export const DOCUMENT_SHARE_LURE_PATTERNS = [
|
|
496
|
+
/\b(?:document|invoice|agreement)s?\s+(?:is\s+|are\s+)?(?:ready|waiting|pending)\s+(?:for\s+)?(?:your\s+)?(?:review|signature|to\s+sign)\b/i,
|
|
497
|
+
/\b(?:view|access|open)\s+(?:the\s+|your\s+)?secure\s+(?:file|document|message)s?\b/i,
|
|
498
|
+
/\bsecure\s+(?:file|document|message)s?\b/i,
|
|
499
|
+
/\bdocument\s+access\b/i,
|
|
500
|
+
/\baccess\s+portal\b/i,
|
|
501
|
+
/\b(?:continue|proceed)\s+to\s+(?:the\s+|your\s+)?(?:document|file)s?\b/i,
|
|
502
|
+
/\bshared\s+(?:a|an|the)\s+(?:file|document|folder)\s+with\s+you\b/i,
|
|
503
|
+
/\b(?:file|document|folder)s?\s+(?:has\s+been|have\s+been|was|were)\s+shared\s+with\s+you\b/i,
|
|
504
|
+
/\bencrypted\s+(?:message|document|file|email)s?\b/i
|
|
505
|
+
];
|
|
506
|
+
/**
|
|
507
|
+
* Marketplace, booking and parcel phrasing: the lure that says a sale, a
|
|
508
|
+
* guest or a delivery needs the reader. The incident's Poshmark and
|
|
509
|
+
* Booking.com mail.
|
|
510
|
+
*/ const SALE_AND_PARCEL_LURE_PATTERNS = [
|
|
314
511
|
/\b(?:has|have)\s+(?:finally\s+)?sold\b/i,
|
|
315
512
|
/\bseller\s+account\b/i,
|
|
316
513
|
/\b(?:guest|customer)\s+(?:complaint|review|feedback)\b/i,
|
|
317
514
|
/\bfeedback\s+regarding\s+your\s+(?:property|listing|stay|booking|reservation)\b/i,
|
|
318
|
-
/\b(?:document|invoice|agreement)s?\s+(?:is\s+|are\s+)?(?:ready|waiting|pending)\s+(?:for\s+)?(?:your\s+)?(?:review|signature|to\s+sign)\b/i,
|
|
319
|
-
/\bpassword\s+(?:will\s+)?expire/i,
|
|
320
515
|
/\bclaim\s+your\s+(?:refund|reward|prize|package|payment|funds)\b/i,
|
|
321
516
|
/\b(?:delivery|shipment)\s+(?:failed|attempt|suspended|on\s+hold)\b/i,
|
|
322
|
-
/\b(?:package|parcel)\s+(?:is\s+)?(?:on\s+hold|held|awaiting|undeliverable)\b/i
|
|
323
|
-
|
|
517
|
+
/\b(?:package|parcel)\s+(?:is\s+)?(?:on\s+hold|held|awaiting|undeliverable)\b/i
|
|
518
|
+
];
|
|
519
|
+
/**
|
|
520
|
+
* Every lure — the half of the three-part lure that asks the reader to act.
|
|
521
|
+
* Only ever counted beside a brand AND a foreign link; alone, several of
|
|
522
|
+
* these are ordinary transactional copy.
|
|
523
|
+
*/ export const PHISHING_LURE_PATTERNS = [
|
|
524
|
+
...ACCOUNT_LURE_PATTERNS,
|
|
525
|
+
...DOCUMENT_SHARE_LURE_PATTERNS,
|
|
526
|
+
...SALE_AND_PARCEL_LURE_PATTERNS
|
|
324
527
|
];
|
|
325
528
|
/**
|
|
326
529
|
* Link targets a merchant's mail ordinarily carries that are not theirs:
|
|
@@ -508,6 +711,114 @@ function isOfficialDomain(brand, registrable) {
|
|
|
508
711
|
function safeCodePoint(value) {
|
|
509
712
|
return Number.isFinite(value) && value > 0 && value <= 0x10ffff ? String.fromCodePoint(value) : '';
|
|
510
713
|
}
|
|
714
|
+
/** Named entities a body uses for spacing, quoting and invisible splitting. */ const NAMED_ENTITIES = {
|
|
715
|
+
nbsp: ' ',
|
|
716
|
+
ensp: ' ',
|
|
717
|
+
emsp: ' ',
|
|
718
|
+
thinsp: ' ',
|
|
719
|
+
lt: '<',
|
|
720
|
+
gt: '>',
|
|
721
|
+
quot: '"',
|
|
722
|
+
apos: "'",
|
|
723
|
+
shy: '',
|
|
724
|
+
zwnj: '',
|
|
725
|
+
zwj: ''
|
|
726
|
+
};
|
|
727
|
+
/*
|
|
728
|
+
* Markup is read in single forward passes, never with a pattern that can
|
|
729
|
+
* rescan to the end of the body from every `<`: the screen reads every
|
|
730
|
+
* tenant message, and the tenant writes the body, so a million unclosed
|
|
731
|
+
* `<` must cost a linear read.
|
|
732
|
+
*/ /** An element whose contents a reader never sees: the start of its opening tag. */ const UNSEEN_ELEMENT_OPEN = /<(style|script|head|template)\b/gi;
|
|
733
|
+
/** The name at the start of a tag that breaks a line or a block: a reader sees a gap there. */ const BREAKING_TAG_NAME = /^\/?(?:br|p|div|li|ul|ol|tr|td|th|table|tbody|thead|h[1-6]|section|article|header|footer|blockquote|hr|center)\b/i;
|
|
734
|
+
/** Characters that render as nothing, used to split a word for a scanner. */ const INVISIBLE_CHARACTERS = /[\u00ad\u200b-\u200d\u2060\ufeff]/g;
|
|
735
|
+
/**
|
|
736
|
+
* The text between the tags, joined with NO separator — or, with `breaks`,
|
|
737
|
+
* with a space where a line or block breaks. A comment is dropped; a `<`
|
|
738
|
+
* with no `>` after it is text.
|
|
739
|
+
*/ function stripMarkup(html, breaks) {
|
|
740
|
+
let text = '';
|
|
741
|
+
let at = 0;
|
|
742
|
+
while(at < html.length){
|
|
743
|
+
const open = html.indexOf('<', at);
|
|
744
|
+
if (open < 0) return text + html.slice(at);
|
|
745
|
+
text += html.slice(at, open);
|
|
746
|
+
if (html.startsWith('<!--', open)) {
|
|
747
|
+
const end = html.indexOf('-->', open + 4);
|
|
748
|
+
if (end < 0) return text;
|
|
749
|
+
at = end + 3;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
const close = html.indexOf('>', open + 1);
|
|
753
|
+
if (close < 0) return text + html.slice(open);
|
|
754
|
+
if (breaks && BREAKING_TAG_NAME.test(html.slice(open + 1, Math.min(close, open + 12)))) text += ' ';
|
|
755
|
+
at = close + 1;
|
|
756
|
+
}
|
|
757
|
+
return text;
|
|
758
|
+
}
|
|
759
|
+
/** The markup with every unseen element and its contents taken out, in one pass. */ function dropUnseenElements(html) {
|
|
760
|
+
const lower = html.toLowerCase();
|
|
761
|
+
const open = new RegExp(UNSEEN_ELEMENT_OPEN.source, 'gi');
|
|
762
|
+
let kept = '';
|
|
763
|
+
let at = 0;
|
|
764
|
+
// Each search starts at or after the last; what it found (or that it found
|
|
765
|
+
// nothing) still answers the next one, so no stretch is scanned twice.
|
|
766
|
+
const found = new Map();
|
|
767
|
+
const find = (needle, from)=>{
|
|
768
|
+
const known = found.get(needle);
|
|
769
|
+
if (known !== undefined && (known < 0 || known >= from)) return known;
|
|
770
|
+
const position = lower.indexOf(needle, from);
|
|
771
|
+
found.set(needle, position);
|
|
772
|
+
return position;
|
|
773
|
+
};
|
|
774
|
+
for(let match = open.exec(html); match; match = open.exec(html)){
|
|
775
|
+
// No `>` after it: neither this nor anything later is a tag.
|
|
776
|
+
const tagEnd = find('>', open.lastIndex);
|
|
777
|
+
if (tagEnd < 0) break;
|
|
778
|
+
kept += `${html.slice(at, match.index)} `;
|
|
779
|
+
const name = match[1].toLowerCase();
|
|
780
|
+
const close = find(`</${name}`, tagEnd + 1);
|
|
781
|
+
// A head nobody closed ends where the body starts, as a browser reads it.
|
|
782
|
+
const body = name === 'head' ? find('<body', tagEnd + 1) : -1;
|
|
783
|
+
if (body >= 0 && (close < 0 || body < close)) {
|
|
784
|
+
at = body;
|
|
785
|
+
} else {
|
|
786
|
+
const end = close < 0 ? -1 : find('>', close);
|
|
787
|
+
// Never closed: only the opening tag goes, and what follows is still
|
|
788
|
+
// read, so an unclosed `<style>` cannot hide the lure after it.
|
|
789
|
+
at = end < 0 ? tagEnd + 1 : end + 1;
|
|
790
|
+
}
|
|
791
|
+
open.lastIndex = at;
|
|
792
|
+
}
|
|
793
|
+
return kept + html.slice(at);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* What a reader SEES in a body (AGL-3453): its text with the markup taken
|
|
797
|
+
* out, the way a mail client or a browser draws it.
|
|
798
|
+
*
|
|
799
|
+
* A kit splits every letter of a brand and a lure with a tag —
|
|
800
|
+
* `G<SPAN class=…>o<SPAN …>o…gle`, `P<SPAN …>a<SPAN …>s…word E<SPAN …>x…pired`
|
|
801
|
+
* — so the raw HTML never contains the word a reader reads. Tags are
|
|
802
|
+
* therefore removed with NO separator, which rejoins the letters; `<style>`,
|
|
803
|
+
* `<script>` and `<head>` are dropped with their contents; comments and
|
|
804
|
+
* zero-width characters are dropped; entities are decoded; whitespace is
|
|
805
|
+
* collapsed. A second reading that puts a space where a line or block
|
|
806
|
+
* breaks is added when it differs, so two words in two paragraphs are still
|
|
807
|
+
* two words. Links are NOT read from this: an href lives inside a tag.
|
|
808
|
+
*/ export function visibleTextOf(html) {
|
|
809
|
+
const text = String(html != null ? html : '');
|
|
810
|
+
const source = text.includes('<') ? dropUnseenElements(text) : text;
|
|
811
|
+
const read = (breaks)=>decodeVisibleEntities(stripMarkup(source, breaks)).replace(INVISIBLE_CHARACTERS, '').replace(/\s+/g, ' ').trim();
|
|
812
|
+
const joined = read(false);
|
|
813
|
+
const broken = read(true);
|
|
814
|
+
return broken === joined ? joined : `${joined}\n${broken}`;
|
|
815
|
+
}
|
|
816
|
+
/** {@link decodeEntities}, after the named entities a body reads with. */ function decodeVisibleEntities(text) {
|
|
817
|
+
return decodeEntities(text.replace(/&([a-z]{2,8});/gi, (entity, name)=>{
|
|
818
|
+
var _NAMED_ENTITIES_name_toLowerCase;
|
|
819
|
+
return (_NAMED_ENTITIES_name_toLowerCase = NAMED_ENTITIES[name.toLowerCase()]) != null ? _NAMED_ENTITIES_name_toLowerCase : entity;
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
511
822
|
const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s"'<>()\\[\]{}]+/gi;
|
|
512
823
|
/** The hosts every link in the text points at, lowercased. */ export function linkHostsIn(text) {
|
|
513
824
|
var _decodeEntities_match;
|
|
@@ -535,6 +846,71 @@ const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s"'<>()\\[\]{}]+/gi;
|
|
|
535
846
|
...hosts
|
|
536
847
|
];
|
|
537
848
|
}
|
|
849
|
+
/** Every link in the text, with what it carries after its path. */ function linkTargetsIn(text) {
|
|
850
|
+
var _decodeEntities_match;
|
|
851
|
+
const targets = [];
|
|
852
|
+
for (const match of (_decodeEntities_match = decodeEntities(String(text != null ? text : '')).match(URL_PATTERN)) != null ? _decodeEntities_match : []){
|
|
853
|
+
// The first host is where the browser goes; a userinfo disguise is not.
|
|
854
|
+
const [host] = linkHostsIn(match);
|
|
855
|
+
if (!host) continue;
|
|
856
|
+
const hash = match.indexOf('#');
|
|
857
|
+
const beforeHash = hash < 0 ? match : match.slice(0, hash);
|
|
858
|
+
const question = beforeHash.indexOf('?');
|
|
859
|
+
targets.push({
|
|
860
|
+
host,
|
|
861
|
+
query: question < 0 ? '' : decodeUrlPart(beforeHash.slice(question + 1)),
|
|
862
|
+
fragment: hash < 0 ? '' : decodeUrlPart(match.slice(hash + 1))
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return targets;
|
|
866
|
+
}
|
|
867
|
+
function decodeUrlPart(part) {
|
|
868
|
+
try {
|
|
869
|
+
return decodeURIComponent(part);
|
|
870
|
+
} catch (unused) {
|
|
871
|
+
return part;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const EMAIL_ADDRESS = /^[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
|
|
875
|
+
const EMAIL_ADDRESS_IN_TEXT = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
876
|
+
const BASE64_TEXT = /^[a-z0-9+/_-]{8,}={0,2}$/i;
|
|
877
|
+
/** A fragment read as base64, the other way a kit carries the address, or ''. */ function base64Text(fragment) {
|
|
878
|
+
const decode = globalThis.atob;
|
|
879
|
+
if (!decode || !BASE64_TEXT.test(fragment)) return '';
|
|
880
|
+
try {
|
|
881
|
+
return decode(fragment.replace(/-/g, '+').replace(/_/g, '/'));
|
|
882
|
+
} catch (unused) {
|
|
883
|
+
return '';
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Where a link carries a reader's own address (AGL-3453), or null.
|
|
888
|
+
*
|
|
889
|
+
* - `fragment`: the WHOLE fragment is an address, plain or base64 —
|
|
890
|
+
* `…#victim@example.com`. A fragment never reaches the server; the only
|
|
891
|
+
* thing that reads it is the landing page's script, filling a sign-in
|
|
892
|
+
* form so the reader sees their own address and types the password.
|
|
893
|
+
* - `parameter`: an address that is not the workspace's own inside a query
|
|
894
|
+
* or a longer fragment — `?email=victim@example.com`, a hash route's
|
|
895
|
+
* `#/login?email=…`. Ordinary in a sign-up or a SaaS login link, so softer.
|
|
896
|
+
*/ function prefilledAddressIn(target, isOwnHost) {
|
|
897
|
+
const fragment = target.fragment.trim();
|
|
898
|
+
if (fragment && (EMAIL_ADDRESS.test(fragment) || EMAIL_ADDRESS.test(base64Text(fragment).trim()))) {
|
|
899
|
+
return 'fragment';
|
|
900
|
+
}
|
|
901
|
+
for (const part of [
|
|
902
|
+
target.query,
|
|
903
|
+
target.fragment
|
|
904
|
+
]){
|
|
905
|
+
// An address is short; a tracking token can be kilobytes of the
|
|
906
|
+
// characters an address is made of, so only the head of one is read.
|
|
907
|
+
if (!part.includes('@')) continue;
|
|
908
|
+
for (const match of part.slice(0, 4096).matchAll(EMAIL_ADDRESS_IN_TEXT)){
|
|
909
|
+
if (!isOwnHost(match[1].toLowerCase())) return 'parameter';
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
538
914
|
const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
539
915
|
/** The host of every email address in the text — a reply-to, a `mailto:`. */ function addressHostsIn(text) {
|
|
540
916
|
const hosts = new Set();
|
|
@@ -579,12 +955,15 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
579
955
|
const ownHosts = ((_input_ownDomains = input.ownDomains) != null ? _input_ownDomains : []).map((domain)=>String(domain != null ? domain : '').trim().toLowerCase().replace(/\.+$/, '')).filter((domain)=>domain.includes('.'));
|
|
580
956
|
const ownRegistrables = new Set(ownHosts.map(registrableDomain));
|
|
581
957
|
const isOwnHost = (host)=>ownHosts.some((own)=>host === own || host.endsWith(`.${own}`));
|
|
582
|
-
const
|
|
958
|
+
const parts = [
|
|
583
959
|
input.subject,
|
|
584
960
|
input.preheader,
|
|
585
961
|
...(_input_bodies = input.bodies) != null ? _input_bodies : []
|
|
586
|
-
].filter((part)=>typeof part === 'string' && part.length > 0)
|
|
587
|
-
|
|
962
|
+
].filter((part)=>typeof part === 'string' && part.length > 0);
|
|
963
|
+
// Links are read from the raw copy, where an href lives; brands and lures
|
|
964
|
+
// from what a reader sees, where a tag cannot split a word (AGL-3453).
|
|
965
|
+
const copy = parts.join('\n');
|
|
966
|
+
const visibleCopy = parts.map(visibleTextOf).join('\n');
|
|
588
967
|
const replyTo = Array.isArray(input.replyTo) ? input.replyTo.join(' ') : String((_input_replyTo = input.replyTo) != null ? _input_replyTo : '');
|
|
589
968
|
const linkHosts = linkHostsIn(copy);
|
|
590
969
|
// 1. A lookalike host, anywhere a recipient could click, write back, or
|
|
@@ -620,18 +999,19 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
620
999
|
}
|
|
621
1000
|
}
|
|
622
1001
|
// 3. Brand + lure + a link to somewhere that is nobody's we know.
|
|
1002
|
+
const isElsewhere = (host)=>{
|
|
1003
|
+
if (isOwnHost(host)) return false;
|
|
1004
|
+
const registrable = registrableDomain(host);
|
|
1005
|
+
return !isAnyOfficialDomain(registrable) && !COMMON_LINK_DOMAINS.has(registrable);
|
|
1006
|
+
};
|
|
623
1007
|
const lure = PHISHING_LURE_PATTERNS.map((pattern)=>{
|
|
624
|
-
var
|
|
625
|
-
return (
|
|
1008
|
+
var _visibleCopy_match;
|
|
1009
|
+
return (_visibleCopy_match = visibleCopy.match(pattern)) == null ? void 0 : _visibleCopy_match[0];
|
|
626
1010
|
}).find(Boolean);
|
|
627
1011
|
if (lure) {
|
|
628
|
-
const foreign = linkHosts.find(
|
|
629
|
-
if (isOwnHost(host)) return false;
|
|
630
|
-
const registrable = registrableDomain(host);
|
|
631
|
-
return !isAnyOfficialDomain(registrable) && !COMMON_LINK_DOMAINS.has(registrable);
|
|
632
|
-
});
|
|
1012
|
+
const foreign = linkHosts.find(isElsewhere);
|
|
633
1013
|
if (foreign) {
|
|
634
|
-
const namedInCopy = `${
|
|
1014
|
+
const namedInCopy = `${visibleCopy}\n${fromName}`;
|
|
635
1015
|
for (const brand of PHISHING_SCREEN_BRANDS){
|
|
636
1016
|
if (brand.mention.test(namedInCopy) && !isOwnBrand(brand, ownNames, ownRegistrables)) {
|
|
637
1017
|
signals.push({
|
|
@@ -645,6 +1025,33 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
645
1025
|
}
|
|
646
1026
|
}
|
|
647
1027
|
}
|
|
1028
|
+
// 4. An account lure beside a link elsewhere that carries the reader's own
|
|
1029
|
+
// address, filled in for the sign-in form at the far end (AGL-3453).
|
|
1030
|
+
const accountLure = ACCOUNT_LURE_PATTERNS.map((pattern)=>{
|
|
1031
|
+
var _visibleCopy_match;
|
|
1032
|
+
return (_visibleCopy_match = visibleCopy.match(pattern)) == null ? void 0 : _visibleCopy_match[0];
|
|
1033
|
+
}).find(Boolean);
|
|
1034
|
+
if (accountLure) {
|
|
1035
|
+
let prefill = null;
|
|
1036
|
+
for (const target of linkTargetsIn(copy)){
|
|
1037
|
+
if (!isElsewhere(target.host)) continue;
|
|
1038
|
+
const place = prefilledAddressIn(target, isOwnHost);
|
|
1039
|
+
if (place && (!prefill || place === 'fragment' && prefill.place !== 'fragment')) {
|
|
1040
|
+
prefill = {
|
|
1041
|
+
host: target.host,
|
|
1042
|
+
place
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (prefill) {
|
|
1047
|
+
signals.push({
|
|
1048
|
+
code: 'recipient-prefill-link',
|
|
1049
|
+
host: prefill.host,
|
|
1050
|
+
lure: accountLure.slice(0, 120),
|
|
1051
|
+
place: prefill.place
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
648
1055
|
return {
|
|
649
1056
|
hold: signals.length > 0,
|
|
650
1057
|
signals
|
|
@@ -655,6 +1062,21 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
655
1062
|
var _PHISHING_SCREEN_BRANDS_find;
|
|
656
1063
|
return (_ref = (_PHISHING_SCREEN_BRANDS_find = PHISHING_SCREEN_BRANDS.find((brand)=>brand.id === id)) == null ? void 0 : _PHISHING_SCREEN_BRANDS_find.label) != null ? _ref : id;
|
|
657
1064
|
}
|
|
1065
|
+
/** Web Risk's threat types, in the words staff read (AGL-3451). */ const LINK_THREAT_WORDS = {
|
|
1066
|
+
SOCIAL_ENGINEERING: 'phishing or social engineering',
|
|
1067
|
+
MALWARE: 'malware',
|
|
1068
|
+
UNWANTED_SOFTWARE: 'unwanted software'
|
|
1069
|
+
};
|
|
1070
|
+
/** "phishing or social engineering and malware", from Web Risk's threat types. */ export function describeLinkThreats(threats) {
|
|
1071
|
+
const words = [
|
|
1072
|
+
...new Set(threats)
|
|
1073
|
+
].map((threat)=>{
|
|
1074
|
+
var _LINK_THREAT_WORDS_threat;
|
|
1075
|
+
return (_LINK_THREAT_WORDS_threat = LINK_THREAT_WORDS[threat]) != null ? _LINK_THREAT_WORDS_threat : threat.toLowerCase();
|
|
1076
|
+
});
|
|
1077
|
+
if (!words.length) return 'unsafe';
|
|
1078
|
+
return words.length === 1 ? words[0] : `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`;
|
|
1079
|
+
}
|
|
658
1080
|
/** One line per signal, in words staff read on the review row. */ export function describePhishingScreenSignals(signals) {
|
|
659
1081
|
return signals.map((signal)=>{
|
|
660
1082
|
const brand = 'brand' in signal ? phishingScreenBrandLabel(signal.brand) : '';
|
|
@@ -669,6 +1091,16 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
669
1091
|
return `Asks for a ${signal.field === 'card' ? 'card number' : signal.field === 'otp' ? 'one-time code' : 'password'} in its own field ("${signal.label}"), outside the platform's sign-in and checkout elements.`;
|
|
670
1092
|
case 'brand-action-page':
|
|
671
1093
|
return `Names ${brand} beside a call to action ("${signal.action}"), and this workspace is not ${brand}.`;
|
|
1094
|
+
case 'brand-lure-page':
|
|
1095
|
+
return `Names ${brand} on a page that asks visitors to act ("${signal.lure}") and links to ${signal.host}, and this workspace is not ${brand}.`;
|
|
1096
|
+
case 'offsite-action-page':
|
|
1097
|
+
return `Its call to action ("${signal.action}") sends visitors off the site to ${signal.host}, on a page that reads "${signal.lure}".`;
|
|
1098
|
+
case 'offsite-redirect':
|
|
1099
|
+
return `Redirects ${signal.source}, a path that reads "${signal.lure}", off the site to ${signal.host}.`;
|
|
1100
|
+
case 'recipient-prefill-link':
|
|
1101
|
+
return `Links to ${signal.host} with the recipient's own address in the ${signal.place === 'fragment' ? 'fragment' : 'link'}, so a sign-in form there opens filled in, beside "${signal.lure}".`;
|
|
1102
|
+
case 'web-risk-link':
|
|
1103
|
+
return `Links to ${signal.host}, which Google Web Risk lists as ${describeLinkThreats(signal.threats)}.`;
|
|
672
1104
|
default:
|
|
673
1105
|
return 'Phishing signal.';
|
|
674
1106
|
}
|
|
@@ -686,20 +1118,34 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
686
1118
|
* workspace is, so it holds for EVERY workspace, on every message and
|
|
687
1119
|
* page, transactional mail included. A merchant has no ordinary reason to
|
|
688
1120
|
* link to a host shaped like somebody else's brand.
|
|
1121
|
+
* - STRONG, too: account or password phrasing beside a link elsewhere whose
|
|
1122
|
+
* whole FRAGMENT is the reader's address (AGL-3453). A fragment never
|
|
1123
|
+
* reaches a server, so the address there is for the landing page's script
|
|
1124
|
+
* to fill a sign-in form with; a merchant's own reset mail links to its
|
|
1125
|
+
* own domain, and a SaaS login link puts the address in its query, which
|
|
1126
|
+
* is soft.
|
|
689
1127
|
* - SOFT: a brand in the sender name, the three-part lure, a brand's name
|
|
690
|
-
* beside a sign-in or payment call to action on a page
|
|
691
|
-
*
|
|
1128
|
+
* beside a sign-in or payment call to action on a page (in one element,
|
|
1129
|
+
* or anywhere on a page that carries a lure), a call to action or a
|
|
1130
|
+
* redirect that leaves the site beside account or document-share wording,
|
|
1131
|
+
* the reader's address in a query beside account phrasing.
|
|
1132
|
+
* Each is shaped like phishing but also like a clumsy legitimate message,
|
|
1133
|
+
* so they hold
|
|
692
1134
|
* only for a workspace in its first {@link OUTBOUND_REVIEW_YOUNG_DAYS}
|
|
693
1135
|
* days — the incident's window — and NEVER for mail the recipient's own
|
|
694
1136
|
* act made owed ({@link OutboundScreenPolicy.owed}).
|
|
695
1137
|
*
|
|
696
1138
|
* A credential-harvest page (a password, card or one-time-code field that
|
|
697
1139
|
* is not one of the platform's own sign-in or checkout elements) is STRONG:
|
|
698
|
-
* see `hosted-page-screen.ts`.
|
|
1140
|
+
* see `hosted-page-screen.ts`. So is a link to a host Google Web Risk lists
|
|
1141
|
+
* (`web-risk-link`, `link-reputation.ts`): it holds for every workspace, and
|
|
1142
|
+
* an account-subdomain host does not soften it, because the listing names
|
|
1143
|
+
* that host itself.
|
|
699
1144
|
*=========================================*/ /** A workspace younger than this many days has the soft rules applied. */ export const OUTBOUND_REVIEW_YOUNG_DAYS = 14;
|
|
700
1145
|
/** The signal codes that hold for every workspace. */ export const STRONG_PHISHING_SIGNAL_CODES = new Set([
|
|
701
1146
|
'lookalike-link',
|
|
702
|
-
'credential-field'
|
|
1147
|
+
'credential-field',
|
|
1148
|
+
'web-risk-link'
|
|
703
1149
|
]);
|
|
704
1150
|
/**
|
|
705
1151
|
* Hosts that give each customer an account subdomain named after the
|
|
@@ -735,6 +1181,9 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
735
1181
|
'mailchimpsites.com'
|
|
736
1182
|
]);
|
|
737
1183
|
export function phishingSignalTier(signal) {
|
|
1184
|
+
if ((signal == null ? void 0 : signal.code) === 'recipient-prefill-link') {
|
|
1185
|
+
return signal.place === 'fragment' ? 'strong' : 'soft';
|
|
1186
|
+
}
|
|
738
1187
|
if (!STRONG_PHISHING_SIGNAL_CODES.has(signal == null ? void 0 : signal.code)) return 'soft';
|
|
739
1188
|
if (signal.code === 'lookalike-link' && typeof signal.host === 'string' && ACCOUNT_SUBDOMAIN_HOSTS.has(registrableDomain(signal.host))) {
|
|
740
1189
|
return 'soft';
|