@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.
@@ -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
- * three shapes hold, and each is one a legitimate merchant essentially never
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. A
57
- * brand is added when it has been seen impersonated, not because it is big:
58
- * every entry is a set of words that can now hold somebody's newsletter.
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
- mention: /\bmicrosoft\b|\boffice\s?365\b|\bonedrive\b|\bsharepoint\b/i,
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-action and credential phrasing — the half of a lure that asks the
305
- * reader to DO something with an account. Only ever counted beside a brand
306
- * AND a foreign link; alone, several of these are ordinary transactional copy.
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
- /\bsecurity\s+alert\b/i
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 copy = [
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).join('\n');
587
- const decodedCopy = decodeEntities(copy);
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 _decodedCopy_match;
625
- return (_decodedCopy_match = decodedCopy.match(pattern)) == null ? void 0 : _decodedCopy_match[0];
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((host)=>{
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 = `${decodedCopy}\n${fromName}`;
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. Each is shaped
691
- * like phishing but also like a clumsy legitimate message, so they hold
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';