@aglyn/shared-util-email 1.0.0-beta.220 → 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 +5 -0
- package/src/lib/hosted-page-screen.js +8 -4
- 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 +13 -0
- package/src/lib/outbound-phishing-screen.js +23 -2
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-util-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.221",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.221",
|
|
29
|
+
"@aglyn/shared-util-http": "1.0.0-beta.221",
|
|
30
30
|
"@msgpack/msgpack": "^3.1.3",
|
|
31
31
|
"@swc/helpers": "0.5.23"
|
|
32
32
|
},
|
package/src/index.d.ts
CHANGED
|
@@ -46,5 +46,6 @@ export * from './lib/sender-readiness';
|
|
|
46
46
|
export * from './lib/outbound-phishing-screen';
|
|
47
47
|
export * from './lib/outbound-screen-gate';
|
|
48
48
|
export * from './lib/hosted-page-screen';
|
|
49
|
+
export * from './lib/link-reputation';
|
|
49
50
|
export * from './lib/email-palette';
|
|
50
51
|
export * from './lib/email-design-chrome';
|
package/src/index.js
CHANGED
|
@@ -73,6 +73,10 @@ export * from "./lib/outbound-screen-gate.js";
|
|
|
73
73
|
// The same screen read over a published page (AGL-3356): lookalike links and
|
|
74
74
|
// embeds, author-defined credential fields, a brand's call to action.
|
|
75
75
|
export * from "./lib/hosted-page-screen.js";
|
|
76
|
+
// The reputation lookup every foreign link a page, an email or a redirect
|
|
77
|
+
// points at passes (AGL-3451). Pure; the lookup is injected by
|
|
78
|
+
// `@aglyn/tenant-data-admin`, which holds the credential and the cache.
|
|
79
|
+
export * from "./lib/link-reputation.js";
|
|
76
80
|
// The site's theme palette as plain data (AGL-3370), so a palette token an
|
|
77
81
|
// author picked resolves in mail. Built without MUI; `shared-ui-theme`'s
|
|
78
82
|
// parity spec holds it equal to the theme.
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport * from './lib/send-email'\n// Who carries the mail (AGL-3080): the provider contract, and the selection\n// of the deployment's provider from the built-ins and any a plugin\n// registered. The providers themselves are imported by their own paths —\n// `@aglyn/shared-util-email/mail-provider-resend`, `…/mail-provider-webhook`\n// — because nothing outside the seam should name one.\nexport * from './lib/mail-provider'\nexport * from './lib/mail-providers'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/tracking-host'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\n// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).\nexport * from './lib/list-unsubscribe'\nexport * from './lib/email-delivery-events'\n// Received mail in the platform's vocabulary, which the CRM's capture route\n// files from (AGL-2657); the provider reads it.\n// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:\n// it holds a `crypto` HMAC, and this barrel is reached from the browser\n// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its\n// own path — `@aglyn/shared-util-email/svix-signature`.\nexport * from './lib/received-email'\nexport * from './lib/email-revenue-window'\n// Exported so the console drift guard can pin it against @aglyn/aglyn.\nexport * from './lib/email-media-src'\n// Same arrangement, same reason (AGL-1223): a copy of `decodeStoredNodes`\n// this scope:shared lib may not import, pinned by `email-nodes-drift.spec.ts`.\nexport * from './lib/stored-email-nodes'\nexport * from './lib/email-merge'\n// The deliverability engine every send path asks (AGL-3328): the gateway an\n// MX names and its ledger, the bounce reader, and the address verdicts. Pure\n// — the DNS lookup and the store are injected by `@aglyn/tenant-data-admin`,\n// so nothing here reaches `node:dns` from the browser.\nexport * from './lib/mail-gateway'\nexport * from './lib/mail-bounce'\nexport * from './lib/email-deliverability'\n// The sender's side of the same engine (AGL-3328): SPF, DKIM and DMARC for\n// the domain mail leaves from, and whether they align. Pure as well; the\n// lookups are `@aglyn/tenant-data-admin`'s.\nexport * from './lib/sender-readiness'\n// The outbound phishing screen a young workspace's campaigns and automated\n// email pass (AGL-3356). Pure; the hold it leads to is `tenant-data-admin`'s.\nexport * from './lib/outbound-phishing-screen'\n// The send seam's gate for that screen — the store a hold writes is injected\n// by `@aglyn/tenant-data-admin`, like the governor's.\nexport * from './lib/outbound-screen-gate'\n// The same screen read over a published page (AGL-3356): lookalike links and\n// embeds, author-defined credential fields, a brand's call to action.\nexport * from './lib/hosted-page-screen'\n// The site's theme palette as plain data (AGL-3370), so a palette token an\n// author picked resolves in mail. Built without MUI; `shared-ui-theme`'s\n// parity spec holds it equal to the theme.\nexport * from './lib/email-palette'\nexport * from './lib/email-design-chrome'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,4EAA4E;AAC5E,mEAAmE;AACnE,yEAAyE;AACzE,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB;AACvC,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,yBAAqB;AACnC,cAAc,2BAAuB;AACrC,cAAc,uBAAmB;AACjC,cAAc,mCAA+B;AAC7C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,6BAAyB;AACvC,cAAc,wBAAoB;AAClC,cAAc,2BAAuB;AACrC,cAAc,0BAAsB;AACpC,yEAAyE;AACzE,cAAc,4BAAwB;AACtC,cAAc,iCAA6B;AAC3C,4EAA4E;AAC5E,gDAAgD;AAChD,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,wDAAwD;AACxD,cAAc,0BAAsB;AACpC,cAAc,gCAA4B;AAC1C,uEAAuE;AACvE,cAAc,2BAAuB;AACrC,0EAA0E;AAC1E,+EAA+E;AAC/E,cAAc,8BAA0B;AACxC,cAAc,uBAAmB;AACjC,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,uDAAuD;AACvD,cAAc,wBAAoB;AAClC,cAAc,uBAAmB;AACjC,cAAc,gCAA4B;AAC1C,2EAA2E;AAC3E,yEAAyE;AACzE,4CAA4C;AAC5C,cAAc,4BAAwB;AACtC,2EAA2E;AAC3E,8EAA8E;AAC9E,cAAc,oCAAgC;AAC9C,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,gCAA4B;AAC1C,6EAA6E;AAC7E,sEAAsE;AACtE,cAAc,8BAA0B;AACxC,2EAA2E;AAC3E,yEAAyE;AACzE,2CAA2C;AAC3C,cAAc,yBAAqB;AACnC,cAAc,+BAA2B"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport * from './lib/send-email'\n// Who carries the mail (AGL-3080): the provider contract, and the selection\n// of the deployment's provider from the built-ins and any a plugin\n// registered. The providers themselves are imported by their own paths —\n// `@aglyn/shared-util-email/mail-provider-resend`, `…/mail-provider-webhook`\n// — because nothing outside the seam should name one.\nexport * from './lib/mail-provider'\nexport * from './lib/mail-providers'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/tracking-host'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\n// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).\nexport * from './lib/list-unsubscribe'\nexport * from './lib/email-delivery-events'\n// Received mail in the platform's vocabulary, which the CRM's capture route\n// files from (AGL-2657); the provider reads it.\n// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:\n// it holds a `crypto` HMAC, and this barrel is reached from the browser\n// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its\n// own path — `@aglyn/shared-util-email/svix-signature`.\nexport * from './lib/received-email'\nexport * from './lib/email-revenue-window'\n// Exported so the console drift guard can pin it against @aglyn/aglyn.\nexport * from './lib/email-media-src'\n// Same arrangement, same reason (AGL-1223): a copy of `decodeStoredNodes`\n// this scope:shared lib may not import, pinned by `email-nodes-drift.spec.ts`.\nexport * from './lib/stored-email-nodes'\nexport * from './lib/email-merge'\n// The deliverability engine every send path asks (AGL-3328): the gateway an\n// MX names and its ledger, the bounce reader, and the address verdicts. Pure\n// — the DNS lookup and the store are injected by `@aglyn/tenant-data-admin`,\n// so nothing here reaches `node:dns` from the browser.\nexport * from './lib/mail-gateway'\nexport * from './lib/mail-bounce'\nexport * from './lib/email-deliverability'\n// The sender's side of the same engine (AGL-3328): SPF, DKIM and DMARC for\n// the domain mail leaves from, and whether they align. Pure as well; the\n// lookups are `@aglyn/tenant-data-admin`'s.\nexport * from './lib/sender-readiness'\n// The outbound phishing screen a young workspace's campaigns and automated\n// email pass (AGL-3356). Pure; the hold it leads to is `tenant-data-admin`'s.\nexport * from './lib/outbound-phishing-screen'\n// The send seam's gate for that screen — the store a hold writes is injected\n// by `@aglyn/tenant-data-admin`, like the governor's.\nexport * from './lib/outbound-screen-gate'\n// The same screen read over a published page (AGL-3356): lookalike links and\n// embeds, author-defined credential fields, a brand's call to action.\nexport * from './lib/hosted-page-screen'\n// The reputation lookup every foreign link a page, an email or a redirect\n// points at passes (AGL-3451). Pure; the lookup is injected by\n// `@aglyn/tenant-data-admin`, which holds the credential and the cache.\nexport * from './lib/link-reputation'\n// The site's theme palette as plain data (AGL-3370), so a palette token an\n// author picked resolves in mail. Built without MUI; `shared-ui-theme`'s\n// parity spec holds it equal to the theme.\nexport * from './lib/email-palette'\nexport * from './lib/email-design-chrome'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,4EAA4E;AAC5E,mEAAmE;AACnE,yEAAyE;AACzE,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB;AACvC,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,yBAAqB;AACnC,cAAc,2BAAuB;AACrC,cAAc,uBAAmB;AACjC,cAAc,mCAA+B;AAC7C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,6BAAyB;AACvC,cAAc,wBAAoB;AAClC,cAAc,2BAAuB;AACrC,cAAc,0BAAsB;AACpC,yEAAyE;AACzE,cAAc,4BAAwB;AACtC,cAAc,iCAA6B;AAC3C,4EAA4E;AAC5E,gDAAgD;AAChD,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,wDAAwD;AACxD,cAAc,0BAAsB;AACpC,cAAc,gCAA4B;AAC1C,uEAAuE;AACvE,cAAc,2BAAuB;AACrC,0EAA0E;AAC1E,+EAA+E;AAC/E,cAAc,8BAA0B;AACxC,cAAc,uBAAmB;AACjC,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,uDAAuD;AACvD,cAAc,wBAAoB;AAClC,cAAc,uBAAmB;AACjC,cAAc,gCAA4B;AAC1C,2EAA2E;AAC3E,yEAAyE;AACzE,4CAA4C;AAC5C,cAAc,4BAAwB;AACtC,2EAA2E;AAC3E,8EAA8E;AAC9E,cAAc,oCAAgC;AAC9C,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,gCAA4B;AAC1C,6EAA6E;AAC7E,sEAAsE;AACtE,cAAc,8BAA0B;AACxC,0EAA0E;AAC1E,+DAA+D;AAC/D,wEAAwE;AACxE,cAAc,2BAAuB;AACrC,2EAA2E;AAC3E,yEAAyE;AACzE,2CAA2C;AAC3C,cAAc,yBAAqB;AACnC,cAAc,+BAA2B"}
|
|
@@ -30,6 +30,11 @@ export interface HostedPageScreenInput {
|
|
|
30
30
|
}
|
|
31
31
|
export interface HostedPageScreenVerdict {
|
|
32
32
|
signals: PhishingScreenSignal[];
|
|
33
|
+
/**
|
|
34
|
+
* Every host the page (or the redirect) links, embeds or posts to, lowercased — what the
|
|
35
|
+
* link reputation lookup reads (AGL-3451), so the page is walked once.
|
|
36
|
+
*/
|
|
37
|
+
linkHosts: string[];
|
|
33
38
|
}
|
|
34
39
|
/**
|
|
35
40
|
* The credential an input asks for, read from the two attributes that decide
|
|
@@ -387,7 +387,8 @@
|
|
|
387
387
|
});
|
|
388
388
|
}
|
|
389
389
|
return {
|
|
390
|
-
signals
|
|
390
|
+
signals,
|
|
391
|
+
linkHosts
|
|
391
392
|
};
|
|
392
393
|
}
|
|
393
394
|
/**
|
|
@@ -459,7 +460,8 @@
|
|
|
459
460
|
const destination = String((_input_destination = input.destination) != null ? _input_destination : '').trim();
|
|
460
461
|
const hosts = linkHostsIn(destination.startsWith('//') ? `https:${destination}` : destination);
|
|
461
462
|
if (!hosts.length) return {
|
|
462
|
-
signals
|
|
463
|
+
signals,
|
|
464
|
+
linkHosts: hosts
|
|
463
465
|
};
|
|
464
466
|
const ownNames = squashScreenText(((_input_ownNames = input.ownNames) != null ? _input_ownNames : []).filter(Boolean).join(' '));
|
|
465
467
|
const ownHosts = ((_input_ownDomains = input.ownDomains) != null ? _input_ownDomains : []).map((domain)=>String(domain != null ? domain : '').trim().toLowerCase().replace(/\.+$/, '')).filter((domain)=>domain.includes('.'));
|
|
@@ -479,7 +481,8 @@
|
|
|
479
481
|
const registrable = registrableDomain(host);
|
|
480
482
|
const leaves = !ownHosts.some((own)=>host === own || host.endsWith(`.${own}`)) && !isAnyOfficialBrandDomain(registrable) && !isCommonLinkDomain(registrable);
|
|
481
483
|
if (!leaves) return {
|
|
482
|
-
signals
|
|
484
|
+
signals,
|
|
485
|
+
linkHosts: hosts
|
|
483
486
|
};
|
|
484
487
|
const source = String((_input_source = input.source) != null ? _input_source : '').trim();
|
|
485
488
|
const words = pathWords(source);
|
|
@@ -505,7 +508,8 @@
|
|
|
505
508
|
}
|
|
506
509
|
}
|
|
507
510
|
return {
|
|
508
|
-
signals
|
|
511
|
+
signals,
|
|
512
|
+
linkHosts: hosts
|
|
509
513
|
};
|
|
510
514
|
}
|
|
511
515
|
/** A path read as words: `/secure-document_access` → `secure document access`. */ function pathWords(path) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/hosted-page-screen.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE HOSTED PAGE SCREEN (AGL-3362).\n *\n * The outbound phishing screen, read over a PUBLISHED PAGE instead of an\n * email: the same brand list, the same lookalike logic, the same tiers\n * (`signalsThatHold`). A fraud actor who could not mail a lure could still\n * host one, and the incident's sibling site published a page at the\n * document-share path `/reviewfile`.\n *\n * Five shapes:\n *\n * 1. **A lookalike link or embed** (`lookalike-link`, STRONG) — any `href`,\n * `src`, form action or URL in the page's authored content whose host\n * wears a brand without being the brand's domain.\n * 2. **A credential field** (`credential-field`, STRONG) — a field the\n * merchant AUTHORED that asks for a password, a card number or a\n * one-time code: its type, its autocomplete token, or its label,\n * placeholder or name. The platform's own sign-in, account and checkout\n * elements render their inputs in code, not in stored props, so they are\n * never read as a field here — which is exactly the sanctioned/unsanctioned\n * line: what the platform collects through its own components is\n * Aglyn's to protect, and what a page's author collects in a form of\n * their own is not something a site may ask a stranger for.\n * 3. **A brand's call to action** (`brand-action-page`, SOFT) — one element\n * whose text names a brand that is not the workspace's AND asks the reader\n * to sign in, verify, confirm or open a document, on a page that links\n * somewhere that is neither the workspace's, nor a brand's own, nor a\n * common social or maps link.\n * 4. **A brand's lure, page-wide** (`brand-lure-page`, SOFT, AGL-3447) — the\n * same brand and link anywhere on the page, beside an account or\n * document-share LURE anywhere on it (\"Secure Document Access Portal\"), or\n * a call to action that leaves the site worded as an action (\"Continue to\n * Document\"). The 2026-10-01 phishing page put Proofpoint's name in its\n * body, the lure in its heading and the action on its button: three\n * elements, none of which shape 3 reads on its own. What it does not count page-wide is\n * a bare \"sign in\" that stays on the site — a hotel that says \"find us on\n * Booking.com\" in one place and has a member sign-in in another is two\n * ordinary sentences.\n * 5. **A call to action that leaves, beside a lure** (`offsite-action-page`,\n * SOFT, AGL-3447) — the page's only call to action, or one worded as an\n * account or document action, sends visitors to a host that is not the\n * site's own, a listed brand's or a common link, on a page that carries an\n * account or document-share lure. No brand needed: a \"Secure Document\n * Access Portal\" whose one button leaves for the kit is the shape whatever\n * name it wears.\n *\n * Shapes 3-5 are soft, so they hold only for a workspace in its first\n * fortnight (`signalsThatHold`); an established workspace's page is held by\n * shapes 1 and 2 alone.\n *\n * The same reading applies to a site REDIRECT ({@link screenSiteRedirect}):\n * a rule that sends a path off the site is a page whose one call to action\n * fires on arrival, and its path is the only text it has.\n *\n * Pure: the caller hands over the composed node tree and the workspace's own\n * names and hosts, and decides what a hold does.\n *=========================================*/\n\nimport {\n ACCOUNT_LURE_PATTERNS,\n DOCUMENT_SHARE_LURE_PATTERNS,\n isWorkspaceOwnBrand,\n linkHostsIn,\n isBrandsOwnDomain,\n lookalikeBrandForHost,\n PHISHING_LURE_PATTERNS,\n PHISHING_SCREEN_BRANDS,\n type PhishingScreenBrand,\n type PhishingScreenSignal,\n registrableDomain,\n isAnyOfficialBrandDomain,\n isCommonLinkDomain,\n squashScreenText,\n visibleTextOf,\n} from './outbound-phishing-screen'\n\nexport interface HostedPageScreenInput {\n /**\n * The page as it will be served — the composed tree, or any object that\n * holds nodes (`{ componentId, props }`) at any depth. Walked, never\n * trusted to have one shape: a screen, a layout's chrome, a grafted\n * component and a form all arrive together.\n */\n nodes: unknown\n /** The workspace's own names, as `screenOutboundEmail` reads them. */\n ownNames?: readonly (string | null | undefined)[]\n /** The workspace's own hosts — a link to one is never \"elsewhere\". */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\nexport interface HostedPageScreenVerdict {\n signals: PhishingScreenSignal[]\n}\n\n/** A node as the walk finds it. */\ninterface WalkedNode {\n componentId: string\n props: Record<string, unknown>\n}\n\n/** The most a page's text is read to, so a huge page costs a bounded scan. */\nconst MAX_PAGE_TEXT = 2_000_000\n/** The most nodes the walk visits. */\nconst MAX_NODES = 20_000\n\n/** Props that describe a field the author defined, rather than content. */\nconst FIELD_PROP_KEYS = ['fieldType', 'fieldName', 'inputType', 'autoComplete', 'autocomplete']\n\n/** Autocomplete tokens (WHATWG) that name a credential. */\nconst CREDENTIAL_AUTOCOMPLETE: Record<string, 'password' | 'card' | 'otp'> = {\n 'current-password': 'password',\n 'new-password': 'password',\n 'cc-number': 'card',\n 'cc-csc': 'card',\n 'cc-exp': 'card',\n 'one-time-code': 'otp',\n}\n\n/** Field wording that asks for a credential, by kind. */\nconst CREDENTIAL_LABELS: ReadonlyArray<{ field: 'password' | 'card' | 'otp'; pattern: RegExp }> = [\n { field: 'password', pattern: /\\bpass\\s?(?:word|code|phrase)s?\\b|\\bpasswd\\b|\\bpin\\s*(?:code|number)?\\s*$/i },\n {\n field: 'card',\n pattern:\n /\\b(?:credit|debit)\\s*card\\b|\\bcard\\s*(?:number|no\\.?|#)|\\bcardnumber\\b|\\bcvv2?\\b|\\bcvc\\b|\\bcard\\s*security\\s*code\\b|\\bcard\\s*expir/i,\n },\n {\n field: 'otp',\n pattern:\n /\\bone[-\\s]?time\\s*(?:pass\\s?code|password|code|pin)\\b|\\botp\\b|\\bverification\\s*code\\b|\\b2fa\\b|\\btwo[-\\s]?factor\\b|\\bauthenticat(?:ion|or)\\s*code\\b|\\bsms\\s*code\\b/i,\n },\n]\n\n/** Raw HTML an author placed that holds a credential input. */\nconst HTML_CREDENTIAL_INPUTS: ReadonlyArray<{ field: 'password' | 'card' | 'otp'; pattern: RegExp }> = [\n { field: 'password', pattern: /<input\\b[^>]*\\btype\\s*=\\s*[\"']?password/i },\n { field: 'password', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:pass(?:word|wd)?|current-password|new-password)\\b/i },\n { field: 'card', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:card_?number|cc-number|cc-csc|cvv|cvc)\\b/i },\n { field: 'otp', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:otp|one-time-code|verification_?code)\\b/i },\n]\n\n/**\n * What an element asks its reader to DO with an account or a document —\n * counted beside a brand in the SAME element, or on a call to action that\n * leaves the site. The email lure phrasing counts too.\n */\nconst PAGE_ACTION_PATTERNS: readonly RegExp[] = [\n /\\b(?:sign|log)[\\s-]?in\\b/i,\n /\\bverify\\b/i,\n /\\bconfirm\\s+(?:your|the)\\b/i,\n /\\bunlock\\b/i,\n /\\benter\\s+your\\b/i,\n /\\b(?:view|review|open|download|access)\\s+(?:the\\s+|your\\s+)?(?:shared\\s+|secure\\s+)?(?:file|document|invoice|statement)s?\\b/i,\n /\\b(?:continue|proceed)\\s+to\\s+(?:the\\s+|your\\s+)?(?:document|file|account|sign[\\s-]?in|log[\\s-]?in)s?\\b/i,\n]\n\n/**\n * The lures a page is read for as a WHOLE (AGL-3447): account and\n * document-share wording. Not the marketplace, booking and parcel lures,\n * which a hotel's \"guest reviews\" and a shop's delivery page carry every day\n * and which still count in one element beside a brand.\n */\nconst PAGE_LURE_PATTERNS: readonly RegExp[] = [...ACCOUNT_LURE_PATTERNS, ...DOCUMENT_SHARE_LURE_PATTERNS]\n\n/**\n * A component that renders a call to action — a button or a link, however a\n * plugin names it (`muiButton`, `muiScreenLink`, `muiLinkBox`). It is one\n * when it carries a target: an `href`, or a `screenId` on the site itself.\n */\nconst CALL_TO_ACTION_COMPONENT = /button|fab|cta|link/i\n\n/** The props a call to action's visible words live in; the first found wins. */\nconst CALL_TO_ACTION_LABEL_KEYS = ['children', 'label', 'text', 'title', 'html']\n\n/** URLs in page text, so prose is read for a brand or a lure without them. */\nconst PROSE_URL_PATTERN = /(?:https?:)?\\/\\/[^\\s\"'<>]+|\\bwww\\.[^\\s\"'<>]+/gi\n\n/** One call to action: what it says, and the host it leaves for (null: it stays). */\ninterface CallToAction {\n label: string\n host: string | null\n}\n\n/** Every node in the tree, breadth-first in document order, bounded. */\nfunction walkNodes(root: unknown): WalkedNode[] {\n const found: WalkedNode[] = []\n const seen = new Set<unknown>()\n const stack: unknown[] = [root]\n for (let head = 0; head < stack.length && found.length < MAX_NODES; head += 1) {\n const value = stack[head]\n if (!value || typeof value !== 'object' || seen.has(value)) continue\n seen.add(value)\n const record = value as Record<string, unknown>\n if (typeof record['componentId'] === 'string') {\n const props = record['props']\n found.push({\n componentId: record['componentId'] as string,\n props: props && typeof props === 'object' ? (props as Record<string, unknown>) : {},\n })\n }\n for (const child of Array.isArray(value) ? value : Object.values(record)) {\n if (child && typeof child === 'object') stack.push(child)\n }\n }\n return found\n}\n\n/** Every string a node's props hold, one level of arrays and objects deep. */\nfunction propStrings(props: Record<string, unknown>, skip: ReadonlySet<string> = new Set()): string[] {\n const out: string[] = []\n const visit = (value: unknown, depth: number) => {\n if (typeof value === 'string') out.push(value)\n else if (depth < 3 && value && typeof value === 'object') {\n for (const entry of Array.isArray(value) ? value : Object.values(value as Record<string, unknown>)) {\n visit(entry, depth + 1)\n }\n }\n }\n for (const [key, value] of Object.entries(props)) {\n if (key === 'sx' || skip.has(key)) continue\n visit(value, 0)\n }\n return out\n}\n\n/** Does this node describe a field its author defined? */\nfunction isAuthoredField(node: WalkedNode): boolean {\n if (/field|input/i.test(node.componentId)) return true\n return FIELD_PROP_KEYS.some((key) => node.props[key] !== undefined)\n}\n\n/** The credential a field asks for, or null. */\nfunction credentialOf(node: WalkedNode): { field: 'password' | 'card' | 'otp'; label: string } | null {\n const text = (key: string) => (typeof node.props[key] === 'string' ? (node.props[key] as string) : '')\n const type = [text('fieldType'), text('type'), text('inputType')].join(' ').toLowerCase()\n const label = text('label') || text('placeholder') || text('fieldName') || text('name')\n if (/\\bpassword\\b/.test(type)) return { field: 'password', label: label || 'password' }\n const autocomplete = (text('autoComplete') || text('autocomplete')).toLowerCase().trim()\n const byToken = CREDENTIAL_AUTOCOMPLETE[autocomplete]\n if (byToken) return { field: byToken, label: label || autocomplete }\n const wording = [text('label'), text('placeholder'), text('fieldName'), text('name'), text('helperText')]\n .filter(Boolean)\n .join(' ')\n for (const { field, pattern } of CREDENTIAL_LABELS) {\n if (pattern.test(wording)) return { field, label: (label || wording).slice(0, 80) }\n }\n return null\n}\n\n/**\n * The credential an input asks for, read from the two attributes that decide\n * it in the browser — its `type` and its `autocomplete` token — or null.\n * For code that builds inputs rather than storing them as page props: the\n * marketplace verifier reads a plugin bundle's `{ type: 'password' }` and\n * `setAttribute('autocomplete', 'cc-number')` with this (AGL-3362).\n */\nexport function credentialForInputAttribute(\n attribute: string,\n value: string,\n): 'password' | 'card' | 'otp' | null {\n const name = String(attribute ?? '').toLowerCase()\n const token = String(value ?? '').trim().toLowerCase()\n if ((name === 'type' || name === 'inputtype') && token === 'password') return 'password'\n if (name === 'autocomplete') return CREDENTIAL_AUTOCOMPLETE[token] ?? null\n return null\n}\n\n/** The credential a piece of raw HTML asks for in an input of its own, or null. */\nexport function credentialInHtml(html: string): 'password' | 'card' | 'otp' | null {\n const text = String(html ?? '')\n if (!text.includes('<')) return null\n return HTML_CREDENTIAL_INPUTS.find(({ pattern }) => pattern.test(text))?.field ?? null\n}\n\n/**\n * Does a submitted field's NAME ask for a credential — a password, a card\n * number or security code, a one-time code? The same wording the page\n * screen holds a field for, read over a name as a form posts it\n * (`card_number`, `cardNumber`, `one-time-code`).\n *\n * For the one path that does not pass a publish: a public form endpoint\n * takes whatever field names a request carries, so a page the screen never\n * saw (third-party code, a hand-built request) could still post a password\n * to it. The endpoint drops such a field rather than store it.\n */\nexport function isCredentialFieldName(name: string): boolean {\n const words = String(name ?? '')\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/[_\\-.[\\]]+/g, ' ')\n .trim()\n if (!words) return false\n if (CREDENTIAL_AUTOCOMPLETE[words.toLowerCase().replace(/\\s+/g, '-')]) return true\n if (/^(?:pass|pwd|ccnum|cc\\s?num|cc\\s?number|cvv2?|cvc|csc)$/i.test(words)) return true\n return CREDENTIAL_LABELS.some(({ pattern }) => pattern.test(words))\n}\n\n/** URLs a page carries in a form Links-by-shape would miss: `//host/…`. */\nfunction protocolRelativeUrls(strings: readonly string[]): string {\n return strings\n .filter((value) => /^\\/\\/[a-z0-9-]+(?:\\.[a-z0-9-]+)+/i.test(value.trim()))\n .map((value) => `https:${value.trim()}`)\n .join('\\n')\n}\n\n/**\n * Screen one page as it will be served. The caller applies the tiers with\n * `signalsThatHold` and decides what a hold does.\n */\nexport function screenHostedPage(input: HostedPageScreenInput): HostedPageScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const nodes = walkNodes(input.nodes)\n const ownNames = squashScreenText((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n const isOwnHost = (host: string) => ownHosts.some((own) => host === own || host.endsWith(`.${own}`))\n\n const nodeStrings = nodes.map((node) => propStrings(node.props))\n let pageText = nodeStrings.map((strings) => strings.join('\\n')).join('\\n')\n pageText = `${pageText}\\n${protocolRelativeUrls(nodeStrings.flat())}`.slice(0, MAX_PAGE_TEXT)\n\n // 1. A lookalike host anywhere the page links, embeds or posts to.\n const linkHosts = linkHostsIn(pageText)\n for (const host of linkHosts) {\n const brand = lookalikeBrandForHost(host)\n if (brand && !isBrandsOwnDomain(brand, ownRegistrables)) {\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // 2. A credential field the page's author defined.\n const credentialSeen = new Set<string>()\n const noteCredential = (field: 'password' | 'card' | 'otp', label: string) => {\n if (credentialSeen.has(field)) return\n credentialSeen.add(field)\n signals.push({ code: 'credential-field', field, label: label.slice(0, 80) })\n }\n nodes.forEach((node, index) => {\n if (isAuthoredField(node)) {\n const credential = credentialOf(node)\n if (credential) noteCredential(credential.field, credential.label)\n }\n for (const html of nodeStrings[index]) {\n if (!html.includes('<')) continue\n for (const { field, pattern } of HTML_CREDENTIAL_INPUTS) {\n if (pattern.test(html)) noteCredential(field, 'an input in the page’s own HTML')\n }\n }\n })\n\n const isElsewhere = (host: string) => {\n if (isOwnHost(host)) return false\n const registrable = registrableDomain(host)\n return !isAnyOfficialBrandDomain(registrable) && !isCommonLinkDomain(registrable)\n }\n const foreign = linkHosts.find(isElsewhere)\n const actions = [...PAGE_ACTION_PATTERNS, ...PHISHING_LURE_PATTERNS]\n const named = new Set<string>()\n const isOthersBrand = (brand: PhishingScreenBrand) =>\n !named.has(brand.id) && !isWorkspaceOwnBrand(brand, ownNames, ownRegistrables)\n\n // 3. A brand's call to action, in one element, on a page that links away.\n if (foreign) {\n for (const strings of nodeStrings) {\n const text = proseOf(strings.join(' '))\n if (!text) continue\n const action = firstMatch(actions, text)\n if (!action) continue\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOthersBrand(brand) && brand.mention.test(text)) {\n named.add(brand.id)\n signals.push({ code: 'brand-action-page', brand: brand.id, action: action.slice(0, 80) })\n }\n }\n }\n }\n\n const prose = proseOf(pageText)\n const pageLure = firstMatch(PAGE_LURE_PATTERNS, prose)\n const ctas = callsToAction(nodes)\n const leaving = ctas.filter((cta): cta is CallToAction & { host: string } =>\n Boolean(cta.host && isElsewhere(cta.host)),\n )\n const wordedLeaving = leaving.find((cta) => firstMatch(actions, cta.label))\n\n // 4. A brand's lure, read over the page as a whole, on a page that links away.\n const brandLure = pageLure ?? (wordedLeaving ? firstMatch(actions, wordedLeaving.label) : undefined)\n if (foreign && brandLure) {\n const host = (wordedLeaving ?? leaving[0])?.host ?? foreign\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOthersBrand(brand) && brand.mention.test(prose)) {\n named.add(brand.id)\n signals.push({ code: 'brand-lure-page', brand: brand.id, lure: brandLure.slice(0, 120), host })\n }\n }\n }\n\n // 5. The page's call to action leaves the site, beside a lure. Its only\n // one, or the one worded as an account or document action.\n const primary = wordedLeaving ?? (ctas.length === 1 ? leaving[0] : undefined)\n if (primary && pageLure) {\n signals.push({\n code: 'offsite-action-page',\n action: (primary.label || primary.host).slice(0, 80),\n lure: pageLure.slice(0, 120),\n host: primary.host,\n })\n }\n\n return { signals }\n}\n\n/**\n * What a visitor reads in page text: its markup taken out the way the email\n * screen takes it out ({@link visibleTextOf}), so a custom HTML block or a\n * rich-text prop cannot split a brand or a lure letter by letter with tags\n * (AGL-3453), and its bare URLs taken out too. Links are still read from\n * the raw text, where an href lives.\n */\nfunction proseOf(text: string): string {\n return visibleTextOf(text).replace(PROSE_URL_PATTERN, ' ').replace(/\\s+/g, ' ').trim()\n}\n\n/** The first pattern's match in the text, or undefined. */\nfunction firstMatch(patterns: readonly RegExp[], text: string): string | undefined {\n if (!text) return undefined\n for (const pattern of patterns) {\n const found = text.match(pattern)?.[0]\n if (found) return found\n }\n return undefined\n}\n\n/** The words a call to action shows, from its label prop, markup taken out. */\nfunction callToActionLabel(props: Record<string, unknown>): string {\n for (const key of CALL_TO_ACTION_LABEL_KEYS) {\n const value = props[key]\n if (typeof value === 'string' && value.trim()) {\n return visibleTextOf(value).split('\\n')[0]\n }\n }\n return ''\n}\n\n/**\n * Every call to action on the page: each button or link that carries a\n * target. A `screenId` is a page of the site and takes precedence over an\n * `href`, as the button renders it; a relative, `mailto:` or `tel:` href has\n * no web host and stays too.\n */\nfunction callsToAction(nodes: readonly WalkedNode[]): CallToAction[] {\n const found: CallToAction[] = []\n for (const node of nodes) {\n if (!CALL_TO_ACTION_COMPONENT.test(node.componentId)) continue\n const screenId = typeof node.props['screenId'] === 'string' ? node.props['screenId'].trim() : ''\n const href = typeof node.props['href'] === 'string' ? node.props['href'].trim() : ''\n if (!screenId && !href) continue\n const absolute = href.startsWith('//') ? `https:${href}` : href\n found.push({\n label: callToActionLabel(node.props),\n host: screenId ? null : (linkHostsIn(absolute)[0] ?? null),\n })\n }\n return found\n}\n\nexport interface SiteRedirectScreenInput {\n /** The path the rule answers — `/secure-document` — or its pattern. */\n source: string\n /** Where it sends the visitor, after any `$n` substitution. */\n destination: string\n /** The workspace's own names, as `screenHostedPage` reads them. */\n ownNames?: readonly (string | null | undefined)[]\n /** The workspace's own hosts — a redirect to one never leaves. */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\n/**\n * Screen one site redirect (AGL-3447): a rule that sends a path off the site\n * is a call to action that fires on arrival, so it is read with the page's\n * rules over the one text it has, its path.\n *\n * - `lookalike-link` (STRONG) — the destination wears a brand it is not.\n * - `offsite-redirect` (SOFT) — the destination is not the site's own, a\n * listed brand's or a common link, and the path reads as an account or\n * document-share lure (`/secure-document-access`).\n * - `brand-lure-link` (SOFT) — the same, and the path names a brand that is\n * not the workspace's (`/paypal-verify-your-account`), the email screen's\n * three-part lure.\n *\n * The caller applies the tiers with `signalsThatHold`, with the workspace's\n * age, and decides what a hold does.\n */\nexport function screenSiteRedirect(input: SiteRedirectScreenInput): HostedPageScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const destination = String(input.destination ?? '').trim()\n const hosts = linkHostsIn(destination.startsWith('//') ? `https:${destination}` : destination)\n if (!hosts.length) return { signals }\n const ownNames = squashScreenText((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n\n for (const host of hosts) {\n const brand = lookalikeBrandForHost(host)\n if (brand && !isBrandsOwnDomain(brand, ownRegistrables)) {\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // The first host is where the browser goes; the rest is a userinfo disguise.\n const host = hosts[0]\n const registrable = registrableDomain(host)\n const leaves =\n !ownHosts.some((own) => host === own || host.endsWith(`.${own}`)) &&\n !isAnyOfficialBrandDomain(registrable) &&\n !isCommonLinkDomain(registrable)\n if (!leaves) return { signals }\n\n const source = String(input.source ?? '').trim()\n const words = pathWords(source)\n const lure = firstMatch(PAGE_LURE_PATTERNS, words)\n if (lure) {\n signals.push({ code: 'offsite-redirect', source: source.slice(0, 120), lure: lure.slice(0, 120), host })\n }\n const anyLure = lure ?? firstMatch(PHISHING_LURE_PATTERNS, words)\n if (anyLure) {\n const brand = PHISHING_SCREEN_BRANDS.find(\n (candidate) =>\n candidate.mention.test(words) && !isWorkspaceOwnBrand(candidate, ownNames, ownRegistrables),\n )\n if (brand) {\n signals.push({ code: 'brand-lure-link', brand: brand.id, lure: anyLure.slice(0, 120), host })\n }\n }\n return { signals }\n}\n\n/** A path read as words: `/secure-document_access` → `secure document access`. */\nfunction pathWords(path: string): string {\n let decoded = path\n try {\n decoded = decodeURIComponent(path)\n } catch {\n // A malformed escape is read as typed.\n }\n return decoded.replace(/[^a-z0-9]+/gi, ' ').trim()\n}\n"],"names":["ACCOUNT_LURE_PATTERNS","DOCUMENT_SHARE_LURE_PATTERNS","isWorkspaceOwnBrand","linkHostsIn","isBrandsOwnDomain","lookalikeBrandForHost","PHISHING_LURE_PATTERNS","PHISHING_SCREEN_BRANDS","registrableDomain","isAnyOfficialBrandDomain","isCommonLinkDomain","squashScreenText","visibleTextOf","MAX_PAGE_TEXT","MAX_NODES","FIELD_PROP_KEYS","CREDENTIAL_AUTOCOMPLETE","CREDENTIAL_LABELS","field","pattern","HTML_CREDENTIAL_INPUTS","PAGE_ACTION_PATTERNS","PAGE_LURE_PATTERNS","CALL_TO_ACTION_COMPONENT","CALL_TO_ACTION_LABEL_KEYS","PROSE_URL_PATTERN","walkNodes","root","found","seen","Set","stack","head","length","value","has","add","record","props","push","componentId","child","Array","isArray","Object","values","propStrings","skip","out","visit","depth","entry","key","entries","isAuthoredField","node","test","some","undefined","credentialOf","text","type","join","toLowerCase","label","autocomplete","trim","byToken","wording","filter","Boolean","slice","credentialForInputAttribute","attribute","name","String","token","credentialInHtml","html","includes","find","isCredentialFieldName","words","replace","protocolRelativeUrls","strings","map","screenHostedPage","input","signals","nodes","ownNames","ownHosts","ownDomains","domain","ownRegistrables","isOwnHost","host","own","endsWith","nodeStrings","pageText","flat","linkHosts","brand","code","id","credentialSeen","noteCredential","forEach","index","credential","isElsewhere","registrable","foreign","actions","named","isOthersBrand","proseOf","action","firstMatch","mention","prose","pageLure","ctas","callsToAction","leaving","cta","wordedLeaving","brandLure","lure","primary","patterns","match","callToActionLabel","split","screenId","href","absolute","startsWith","screenSiteRedirect","destination","hosts","leaves","source","pathWords","anyLure","candidate","path","decoded","decodeURIComponent"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/hosted-page-screen.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE HOSTED PAGE SCREEN (AGL-3362).\n *\n * The outbound phishing screen, read over a PUBLISHED PAGE instead of an\n * email: the same brand list, the same lookalike logic, the same tiers\n * (`signalsThatHold`). A fraud actor who could not mail a lure could still\n * host one, and the incident's sibling site published a page at the\n * document-share path `/reviewfile`.\n *\n * Five shapes:\n *\n * 1. **A lookalike link or embed** (`lookalike-link`, STRONG) — any `href`,\n * `src`, form action or URL in the page's authored content whose host\n * wears a brand without being the brand's domain.\n * 2. **A credential field** (`credential-field`, STRONG) — a field the\n * merchant AUTHORED that asks for a password, a card number or a\n * one-time code: its type, its autocomplete token, or its label,\n * placeholder or name. The platform's own sign-in, account and checkout\n * elements render their inputs in code, not in stored props, so they are\n * never read as a field here — which is exactly the sanctioned/unsanctioned\n * line: what the platform collects through its own components is\n * Aglyn's to protect, and what a page's author collects in a form of\n * their own is not something a site may ask a stranger for.\n * 3. **A brand's call to action** (`brand-action-page`, SOFT) — one element\n * whose text names a brand that is not the workspace's AND asks the reader\n * to sign in, verify, confirm or open a document, on a page that links\n * somewhere that is neither the workspace's, nor a brand's own, nor a\n * common social or maps link.\n * 4. **A brand's lure, page-wide** (`brand-lure-page`, SOFT, AGL-3447) — the\n * same brand and link anywhere on the page, beside an account or\n * document-share LURE anywhere on it (\"Secure Document Access Portal\"), or\n * a call to action that leaves the site worded as an action (\"Continue to\n * Document\"). The 2026-10-01 phishing page put Proofpoint's name in its\n * body, the lure in its heading and the action on its button: three\n * elements, none of which shape 3 reads on its own. What it does not count page-wide is\n * a bare \"sign in\" that stays on the site — a hotel that says \"find us on\n * Booking.com\" in one place and has a member sign-in in another is two\n * ordinary sentences.\n * 5. **A call to action that leaves, beside a lure** (`offsite-action-page`,\n * SOFT, AGL-3447) — the page's only call to action, or one worded as an\n * account or document action, sends visitors to a host that is not the\n * site's own, a listed brand's or a common link, on a page that carries an\n * account or document-share lure. No brand needed: a \"Secure Document\n * Access Portal\" whose one button leaves for the kit is the shape whatever\n * name it wears.\n *\n * Shapes 3-5 are soft, so they hold only for a workspace in its first\n * fortnight (`signalsThatHold`); an established workspace's page is held by\n * shapes 1 and 2 alone.\n *\n * The same reading applies to a site REDIRECT ({@link screenSiteRedirect}):\n * a rule that sends a path off the site is a page whose one call to action\n * fires on arrival, and its path is the only text it has.\n *\n * Pure: the caller hands over the composed node tree and the workspace's own\n * names and hosts, and decides what a hold does.\n *=========================================*/\n\nimport {\n ACCOUNT_LURE_PATTERNS,\n DOCUMENT_SHARE_LURE_PATTERNS,\n isWorkspaceOwnBrand,\n linkHostsIn,\n isBrandsOwnDomain,\n lookalikeBrandForHost,\n PHISHING_LURE_PATTERNS,\n PHISHING_SCREEN_BRANDS,\n type PhishingScreenBrand,\n type PhishingScreenSignal,\n registrableDomain,\n isAnyOfficialBrandDomain,\n isCommonLinkDomain,\n squashScreenText,\n visibleTextOf,\n} from './outbound-phishing-screen'\n\nexport interface HostedPageScreenInput {\n /**\n * The page as it will be served — the composed tree, or any object that\n * holds nodes (`{ componentId, props }`) at any depth. Walked, never\n * trusted to have one shape: a screen, a layout's chrome, a grafted\n * component and a form all arrive together.\n */\n nodes: unknown\n /** The workspace's own names, as `screenOutboundEmail` reads them. */\n ownNames?: readonly (string | null | undefined)[]\n /** The workspace's own hosts — a link to one is never \"elsewhere\". */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\nexport interface HostedPageScreenVerdict {\n signals: PhishingScreenSignal[]\n /**\n * Every host the page (or the redirect) links, embeds or posts to, lowercased — what the\n * link reputation lookup reads (AGL-3451), so the page is walked once.\n */\n linkHosts: string[]\n}\n\n/** A node as the walk finds it. */\ninterface WalkedNode {\n componentId: string\n props: Record<string, unknown>\n}\n\n/** The most a page's text is read to, so a huge page costs a bounded scan. */\nconst MAX_PAGE_TEXT = 2_000_000\n/** The most nodes the walk visits. */\nconst MAX_NODES = 20_000\n\n/** Props that describe a field the author defined, rather than content. */\nconst FIELD_PROP_KEYS = ['fieldType', 'fieldName', 'inputType', 'autoComplete', 'autocomplete']\n\n/** Autocomplete tokens (WHATWG) that name a credential. */\nconst CREDENTIAL_AUTOCOMPLETE: Record<string, 'password' | 'card' | 'otp'> = {\n 'current-password': 'password',\n 'new-password': 'password',\n 'cc-number': 'card',\n 'cc-csc': 'card',\n 'cc-exp': 'card',\n 'one-time-code': 'otp',\n}\n\n/** Field wording that asks for a credential, by kind. */\nconst CREDENTIAL_LABELS: ReadonlyArray<{ field: 'password' | 'card' | 'otp'; pattern: RegExp }> = [\n { field: 'password', pattern: /\\bpass\\s?(?:word|code|phrase)s?\\b|\\bpasswd\\b|\\bpin\\s*(?:code|number)?\\s*$/i },\n {\n field: 'card',\n pattern:\n /\\b(?:credit|debit)\\s*card\\b|\\bcard\\s*(?:number|no\\.?|#)|\\bcardnumber\\b|\\bcvv2?\\b|\\bcvc\\b|\\bcard\\s*security\\s*code\\b|\\bcard\\s*expir/i,\n },\n {\n field: 'otp',\n pattern:\n /\\bone[-\\s]?time\\s*(?:pass\\s?code|password|code|pin)\\b|\\botp\\b|\\bverification\\s*code\\b|\\b2fa\\b|\\btwo[-\\s]?factor\\b|\\bauthenticat(?:ion|or)\\s*code\\b|\\bsms\\s*code\\b/i,\n },\n]\n\n/** Raw HTML an author placed that holds a credential input. */\nconst HTML_CREDENTIAL_INPUTS: ReadonlyArray<{ field: 'password' | 'card' | 'otp'; pattern: RegExp }> = [\n { field: 'password', pattern: /<input\\b[^>]*\\btype\\s*=\\s*[\"']?password/i },\n { field: 'password', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:pass(?:word|wd)?|current-password|new-password)\\b/i },\n { field: 'card', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:card_?number|cc-number|cc-csc|cvv|cvc)\\b/i },\n { field: 'otp', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:otp|one-time-code|verification_?code)\\b/i },\n]\n\n/**\n * What an element asks its reader to DO with an account or a document —\n * counted beside a brand in the SAME element, or on a call to action that\n * leaves the site. The email lure phrasing counts too.\n */\nconst PAGE_ACTION_PATTERNS: readonly RegExp[] = [\n /\\b(?:sign|log)[\\s-]?in\\b/i,\n /\\bverify\\b/i,\n /\\bconfirm\\s+(?:your|the)\\b/i,\n /\\bunlock\\b/i,\n /\\benter\\s+your\\b/i,\n /\\b(?:view|review|open|download|access)\\s+(?:the\\s+|your\\s+)?(?:shared\\s+|secure\\s+)?(?:file|document|invoice|statement)s?\\b/i,\n /\\b(?:continue|proceed)\\s+to\\s+(?:the\\s+|your\\s+)?(?:document|file|account|sign[\\s-]?in|log[\\s-]?in)s?\\b/i,\n]\n\n/**\n * The lures a page is read for as a WHOLE (AGL-3447): account and\n * document-share wording. Not the marketplace, booking and parcel lures,\n * which a hotel's \"guest reviews\" and a shop's delivery page carry every day\n * and which still count in one element beside a brand.\n */\nconst PAGE_LURE_PATTERNS: readonly RegExp[] = [...ACCOUNT_LURE_PATTERNS, ...DOCUMENT_SHARE_LURE_PATTERNS]\n\n/**\n * A component that renders a call to action — a button or a link, however a\n * plugin names it (`muiButton`, `muiScreenLink`, `muiLinkBox`). It is one\n * when it carries a target: an `href`, or a `screenId` on the site itself.\n */\nconst CALL_TO_ACTION_COMPONENT = /button|fab|cta|link/i\n\n/** The props a call to action's visible words live in; the first found wins. */\nconst CALL_TO_ACTION_LABEL_KEYS = ['children', 'label', 'text', 'title', 'html']\n\n/** URLs in page text, so prose is read for a brand or a lure without them. */\nconst PROSE_URL_PATTERN = /(?:https?:)?\\/\\/[^\\s\"'<>]+|\\bwww\\.[^\\s\"'<>]+/gi\n\n/** One call to action: what it says, and the host it leaves for (null: it stays). */\ninterface CallToAction {\n label: string\n host: string | null\n}\n\n/** Every node in the tree, breadth-first in document order, bounded. */\nfunction walkNodes(root: unknown): WalkedNode[] {\n const found: WalkedNode[] = []\n const seen = new Set<unknown>()\n const stack: unknown[] = [root]\n for (let head = 0; head < stack.length && found.length < MAX_NODES; head += 1) {\n const value = stack[head]\n if (!value || typeof value !== 'object' || seen.has(value)) continue\n seen.add(value)\n const record = value as Record<string, unknown>\n if (typeof record['componentId'] === 'string') {\n const props = record['props']\n found.push({\n componentId: record['componentId'] as string,\n props: props && typeof props === 'object' ? (props as Record<string, unknown>) : {},\n })\n }\n for (const child of Array.isArray(value) ? value : Object.values(record)) {\n if (child && typeof child === 'object') stack.push(child)\n }\n }\n return found\n}\n\n/** Every string a node's props hold, one level of arrays and objects deep. */\nfunction propStrings(props: Record<string, unknown>, skip: ReadonlySet<string> = new Set()): string[] {\n const out: string[] = []\n const visit = (value: unknown, depth: number) => {\n if (typeof value === 'string') out.push(value)\n else if (depth < 3 && value && typeof value === 'object') {\n for (const entry of Array.isArray(value) ? value : Object.values(value as Record<string, unknown>)) {\n visit(entry, depth + 1)\n }\n }\n }\n for (const [key, value] of Object.entries(props)) {\n if (key === 'sx' || skip.has(key)) continue\n visit(value, 0)\n }\n return out\n}\n\n/** Does this node describe a field its author defined? */\nfunction isAuthoredField(node: WalkedNode): boolean {\n if (/field|input/i.test(node.componentId)) return true\n return FIELD_PROP_KEYS.some((key) => node.props[key] !== undefined)\n}\n\n/** The credential a field asks for, or null. */\nfunction credentialOf(node: WalkedNode): { field: 'password' | 'card' | 'otp'; label: string } | null {\n const text = (key: string) => (typeof node.props[key] === 'string' ? (node.props[key] as string) : '')\n const type = [text('fieldType'), text('type'), text('inputType')].join(' ').toLowerCase()\n const label = text('label') || text('placeholder') || text('fieldName') || text('name')\n if (/\\bpassword\\b/.test(type)) return { field: 'password', label: label || 'password' }\n const autocomplete = (text('autoComplete') || text('autocomplete')).toLowerCase().trim()\n const byToken = CREDENTIAL_AUTOCOMPLETE[autocomplete]\n if (byToken) return { field: byToken, label: label || autocomplete }\n const wording = [text('label'), text('placeholder'), text('fieldName'), text('name'), text('helperText')]\n .filter(Boolean)\n .join(' ')\n for (const { field, pattern } of CREDENTIAL_LABELS) {\n if (pattern.test(wording)) return { field, label: (label || wording).slice(0, 80) }\n }\n return null\n}\n\n/**\n * The credential an input asks for, read from the two attributes that decide\n * it in the browser — its `type` and its `autocomplete` token — or null.\n * For code that builds inputs rather than storing them as page props: the\n * marketplace verifier reads a plugin bundle's `{ type: 'password' }` and\n * `setAttribute('autocomplete', 'cc-number')` with this (AGL-3362).\n */\nexport function credentialForInputAttribute(\n attribute: string,\n value: string,\n): 'password' | 'card' | 'otp' | null {\n const name = String(attribute ?? '').toLowerCase()\n const token = String(value ?? '').trim().toLowerCase()\n if ((name === 'type' || name === 'inputtype') && token === 'password') return 'password'\n if (name === 'autocomplete') return CREDENTIAL_AUTOCOMPLETE[token] ?? null\n return null\n}\n\n/** The credential a piece of raw HTML asks for in an input of its own, or null. */\nexport function credentialInHtml(html: string): 'password' | 'card' | 'otp' | null {\n const text = String(html ?? '')\n if (!text.includes('<')) return null\n return HTML_CREDENTIAL_INPUTS.find(({ pattern }) => pattern.test(text))?.field ?? null\n}\n\n/**\n * Does a submitted field's NAME ask for a credential — a password, a card\n * number or security code, a one-time code? The same wording the page\n * screen holds a field for, read over a name as a form posts it\n * (`card_number`, `cardNumber`, `one-time-code`).\n *\n * For the one path that does not pass a publish: a public form endpoint\n * takes whatever field names a request carries, so a page the screen never\n * saw (third-party code, a hand-built request) could still post a password\n * to it. The endpoint drops such a field rather than store it.\n */\nexport function isCredentialFieldName(name: string): boolean {\n const words = String(name ?? '')\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/[_\\-.[\\]]+/g, ' ')\n .trim()\n if (!words) return false\n if (CREDENTIAL_AUTOCOMPLETE[words.toLowerCase().replace(/\\s+/g, '-')]) return true\n if (/^(?:pass|pwd|ccnum|cc\\s?num|cc\\s?number|cvv2?|cvc|csc)$/i.test(words)) return true\n return CREDENTIAL_LABELS.some(({ pattern }) => pattern.test(words))\n}\n\n/** URLs a page carries in a form Links-by-shape would miss: `//host/…`. */\nfunction protocolRelativeUrls(strings: readonly string[]): string {\n return strings\n .filter((value) => /^\\/\\/[a-z0-9-]+(?:\\.[a-z0-9-]+)+/i.test(value.trim()))\n .map((value) => `https:${value.trim()}`)\n .join('\\n')\n}\n\n/**\n * Screen one page as it will be served. The caller applies the tiers with\n * `signalsThatHold` and decides what a hold does.\n */\nexport function screenHostedPage(input: HostedPageScreenInput): HostedPageScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const nodes = walkNodes(input.nodes)\n const ownNames = squashScreenText((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n const isOwnHost = (host: string) => ownHosts.some((own) => host === own || host.endsWith(`.${own}`))\n\n const nodeStrings = nodes.map((node) => propStrings(node.props))\n let pageText = nodeStrings.map((strings) => strings.join('\\n')).join('\\n')\n pageText = `${pageText}\\n${protocolRelativeUrls(nodeStrings.flat())}`.slice(0, MAX_PAGE_TEXT)\n\n // 1. A lookalike host anywhere the page links, embeds or posts to.\n const linkHosts = linkHostsIn(pageText)\n for (const host of linkHosts) {\n const brand = lookalikeBrandForHost(host)\n if (brand && !isBrandsOwnDomain(brand, ownRegistrables)) {\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // 2. A credential field the page's author defined.\n const credentialSeen = new Set<string>()\n const noteCredential = (field: 'password' | 'card' | 'otp', label: string) => {\n if (credentialSeen.has(field)) return\n credentialSeen.add(field)\n signals.push({ code: 'credential-field', field, label: label.slice(0, 80) })\n }\n nodes.forEach((node, index) => {\n if (isAuthoredField(node)) {\n const credential = credentialOf(node)\n if (credential) noteCredential(credential.field, credential.label)\n }\n for (const html of nodeStrings[index]) {\n if (!html.includes('<')) continue\n for (const { field, pattern } of HTML_CREDENTIAL_INPUTS) {\n if (pattern.test(html)) noteCredential(field, 'an input in the page’s own HTML')\n }\n }\n })\n\n const isElsewhere = (host: string) => {\n if (isOwnHost(host)) return false\n const registrable = registrableDomain(host)\n return !isAnyOfficialBrandDomain(registrable) && !isCommonLinkDomain(registrable)\n }\n const foreign = linkHosts.find(isElsewhere)\n const actions = [...PAGE_ACTION_PATTERNS, ...PHISHING_LURE_PATTERNS]\n const named = new Set<string>()\n const isOthersBrand = (brand: PhishingScreenBrand) =>\n !named.has(brand.id) && !isWorkspaceOwnBrand(brand, ownNames, ownRegistrables)\n\n // 3. A brand's call to action, in one element, on a page that links away.\n if (foreign) {\n for (const strings of nodeStrings) {\n const text = proseOf(strings.join(' '))\n if (!text) continue\n const action = firstMatch(actions, text)\n if (!action) continue\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOthersBrand(brand) && brand.mention.test(text)) {\n named.add(brand.id)\n signals.push({ code: 'brand-action-page', brand: brand.id, action: action.slice(0, 80) })\n }\n }\n }\n }\n\n const prose = proseOf(pageText)\n const pageLure = firstMatch(PAGE_LURE_PATTERNS, prose)\n const ctas = callsToAction(nodes)\n const leaving = ctas.filter((cta): cta is CallToAction & { host: string } =>\n Boolean(cta.host && isElsewhere(cta.host)),\n )\n const wordedLeaving = leaving.find((cta) => firstMatch(actions, cta.label))\n\n // 4. A brand's lure, read over the page as a whole, on a page that links away.\n const brandLure = pageLure ?? (wordedLeaving ? firstMatch(actions, wordedLeaving.label) : undefined)\n if (foreign && brandLure) {\n const host = (wordedLeaving ?? leaving[0])?.host ?? foreign\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOthersBrand(brand) && brand.mention.test(prose)) {\n named.add(brand.id)\n signals.push({ code: 'brand-lure-page', brand: brand.id, lure: brandLure.slice(0, 120), host })\n }\n }\n }\n\n // 5. The page's call to action leaves the site, beside a lure. Its only\n // one, or the one worded as an account or document action.\n const primary = wordedLeaving ?? (ctas.length === 1 ? leaving[0] : undefined)\n if (primary && pageLure) {\n signals.push({\n code: 'offsite-action-page',\n action: (primary.label || primary.host).slice(0, 80),\n lure: pageLure.slice(0, 120),\n host: primary.host,\n })\n }\n\n return { signals, linkHosts }\n}\n\n/**\n * What a visitor reads in page text: its markup taken out the way the email\n * screen takes it out ({@link visibleTextOf}), so a custom HTML block or a\n * rich-text prop cannot split a brand or a lure letter by letter with tags\n * (AGL-3453), and its bare URLs taken out too. Links are still read from\n * the raw text, where an href lives.\n */\nfunction proseOf(text: string): string {\n return visibleTextOf(text).replace(PROSE_URL_PATTERN, ' ').replace(/\\s+/g, ' ').trim()\n}\n\n/** The first pattern's match in the text, or undefined. */\nfunction firstMatch(patterns: readonly RegExp[], text: string): string | undefined {\n if (!text) return undefined\n for (const pattern of patterns) {\n const found = text.match(pattern)?.[0]\n if (found) return found\n }\n return undefined\n}\n\n/** The words a call to action shows, from its label prop, markup taken out. */\nfunction callToActionLabel(props: Record<string, unknown>): string {\n for (const key of CALL_TO_ACTION_LABEL_KEYS) {\n const value = props[key]\n if (typeof value === 'string' && value.trim()) {\n return visibleTextOf(value).split('\\n')[0]\n }\n }\n return ''\n}\n\n/**\n * Every call to action on the page: each button or link that carries a\n * target. A `screenId` is a page of the site and takes precedence over an\n * `href`, as the button renders it; a relative, `mailto:` or `tel:` href has\n * no web host and stays too.\n */\nfunction callsToAction(nodes: readonly WalkedNode[]): CallToAction[] {\n const found: CallToAction[] = []\n for (const node of nodes) {\n if (!CALL_TO_ACTION_COMPONENT.test(node.componentId)) continue\n const screenId = typeof node.props['screenId'] === 'string' ? node.props['screenId'].trim() : ''\n const href = typeof node.props['href'] === 'string' ? node.props['href'].trim() : ''\n if (!screenId && !href) continue\n const absolute = href.startsWith('//') ? `https:${href}` : href\n found.push({\n label: callToActionLabel(node.props),\n host: screenId ? null : (linkHostsIn(absolute)[0] ?? null),\n })\n }\n return found\n}\n\nexport interface SiteRedirectScreenInput {\n /** The path the rule answers — `/secure-document` — or its pattern. */\n source: string\n /** Where it sends the visitor, after any `$n` substitution. */\n destination: string\n /** The workspace's own names, as `screenHostedPage` reads them. */\n ownNames?: readonly (string | null | undefined)[]\n /** The workspace's own hosts — a redirect to one never leaves. */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\n/**\n * Screen one site redirect (AGL-3447): a rule that sends a path off the site\n * is a call to action that fires on arrival, so it is read with the page's\n * rules over the one text it has, its path.\n *\n * - `lookalike-link` (STRONG) — the destination wears a brand it is not.\n * - `offsite-redirect` (SOFT) — the destination is not the site's own, a\n * listed brand's or a common link, and the path reads as an account or\n * document-share lure (`/secure-document-access`).\n * - `brand-lure-link` (SOFT) — the same, and the path names a brand that is\n * not the workspace's (`/paypal-verify-your-account`), the email screen's\n * three-part lure.\n *\n * The caller applies the tiers with `signalsThatHold`, with the workspace's\n * age, and decides what a hold does.\n */\nexport function screenSiteRedirect(input: SiteRedirectScreenInput): HostedPageScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const destination = String(input.destination ?? '').trim()\n const hosts = linkHostsIn(destination.startsWith('//') ? `https:${destination}` : destination)\n if (!hosts.length) return { signals, linkHosts: hosts }\n const ownNames = squashScreenText((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n\n for (const host of hosts) {\n const brand = lookalikeBrandForHost(host)\n if (brand && !isBrandsOwnDomain(brand, ownRegistrables)) {\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // The first host is where the browser goes; the rest is a userinfo disguise.\n const host = hosts[0]\n const registrable = registrableDomain(host)\n const leaves =\n !ownHosts.some((own) => host === own || host.endsWith(`.${own}`)) &&\n !isAnyOfficialBrandDomain(registrable) &&\n !isCommonLinkDomain(registrable)\n if (!leaves) return { signals, linkHosts: hosts }\n\n const source = String(input.source ?? '').trim()\n const words = pathWords(source)\n const lure = firstMatch(PAGE_LURE_PATTERNS, words)\n if (lure) {\n signals.push({ code: 'offsite-redirect', source: source.slice(0, 120), lure: lure.slice(0, 120), host })\n }\n const anyLure = lure ?? firstMatch(PHISHING_LURE_PATTERNS, words)\n if (anyLure) {\n const brand = PHISHING_SCREEN_BRANDS.find(\n (candidate) =>\n candidate.mention.test(words) && !isWorkspaceOwnBrand(candidate, ownNames, ownRegistrables),\n )\n if (brand) {\n signals.push({ code: 'brand-lure-link', brand: brand.id, lure: anyLure.slice(0, 120), host })\n }\n }\n return { signals, linkHosts: hosts }\n}\n\n/** A path read as words: `/secure-document_access` → `secure document access`. */\nfunction pathWords(path: string): string {\n let decoded = path\n try {\n decoded = decodeURIComponent(path)\n } catch {\n // A malformed escape is read as typed.\n }\n return decoded.replace(/[^a-z0-9]+/gi, ' ').trim()\n}\n"],"names":["ACCOUNT_LURE_PATTERNS","DOCUMENT_SHARE_LURE_PATTERNS","isWorkspaceOwnBrand","linkHostsIn","isBrandsOwnDomain","lookalikeBrandForHost","PHISHING_LURE_PATTERNS","PHISHING_SCREEN_BRANDS","registrableDomain","isAnyOfficialBrandDomain","isCommonLinkDomain","squashScreenText","visibleTextOf","MAX_PAGE_TEXT","MAX_NODES","FIELD_PROP_KEYS","CREDENTIAL_AUTOCOMPLETE","CREDENTIAL_LABELS","field","pattern","HTML_CREDENTIAL_INPUTS","PAGE_ACTION_PATTERNS","PAGE_LURE_PATTERNS","CALL_TO_ACTION_COMPONENT","CALL_TO_ACTION_LABEL_KEYS","PROSE_URL_PATTERN","walkNodes","root","found","seen","Set","stack","head","length","value","has","add","record","props","push","componentId","child","Array","isArray","Object","values","propStrings","skip","out","visit","depth","entry","key","entries","isAuthoredField","node","test","some","undefined","credentialOf","text","type","join","toLowerCase","label","autocomplete","trim","byToken","wording","filter","Boolean","slice","credentialForInputAttribute","attribute","name","String","token","credentialInHtml","html","includes","find","isCredentialFieldName","words","replace","protocolRelativeUrls","strings","map","screenHostedPage","input","signals","nodes","ownNames","ownHosts","ownDomains","domain","ownRegistrables","isOwnHost","host","own","endsWith","nodeStrings","pageText","flat","linkHosts","brand","code","id","credentialSeen","noteCredential","forEach","index","credential","isElsewhere","registrable","foreign","actions","named","isOthersBrand","proseOf","action","firstMatch","mention","prose","pageLure","ctas","callsToAction","leaving","cta","wordedLeaving","brandLure","lure","primary","patterns","match","callToActionLabel","split","screenId","href","absolute","startsWith","screenSiteRedirect","destination","hosts","leaves","source","pathWords","anyLure","candidate","path","decoded","decodeURIComponent"],"mappings":"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"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PhishingScreenSignal } from './outbound-phishing-screen';
|
|
18
|
+
/** The threat lists a host is looked up against. */
|
|
19
|
+
export type LinkThreatType = 'SOCIAL_ENGINEERING' | 'MALWARE' | 'UNWANTED_SOFTWARE';
|
|
20
|
+
export declare const LINK_THREAT_TYPES: readonly LinkThreatType[];
|
|
21
|
+
/** A host the list names, and what it names it for. */
|
|
22
|
+
export interface HostReputationHit {
|
|
23
|
+
host: string;
|
|
24
|
+
threats: LinkThreatType[];
|
|
25
|
+
}
|
|
26
|
+
/** What one lookup answered, host by host. */
|
|
27
|
+
export interface LinkReputationAnswer {
|
|
28
|
+
/** Hosts the list names. */
|
|
29
|
+
hits: HostReputationHit[];
|
|
30
|
+
/** Hosts the list does not name. */
|
|
31
|
+
clean: string[];
|
|
32
|
+
/** Hosts nobody could answer for: an error, a timeout, a switched-off lookup. */
|
|
33
|
+
unknown: string[];
|
|
34
|
+
}
|
|
35
|
+
export interface LinkReputationLookupOptions {
|
|
36
|
+
/** How long the caller will wait, overall. */
|
|
37
|
+
deadlineMs?: number;
|
|
38
|
+
}
|
|
39
|
+
export type LinkReputationLookup = (hosts: readonly string[], options?: LinkReputationLookupOptions) => Promise<LinkReputationAnswer>;
|
|
40
|
+
/** The most foreign hosts one page, message or redirect is looked up for. */
|
|
41
|
+
export declare const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20;
|
|
42
|
+
/** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */
|
|
43
|
+
export declare function setLinkReputationLookup(lookup: LinkReputationLookup | null): void;
|
|
44
|
+
/** The installed lookup, or null. */
|
|
45
|
+
export declare function getLinkReputationLookup(): LinkReputationLookup | null;
|
|
46
|
+
/** Test seam: forget any installed lookup. */
|
|
47
|
+
export declare function resetLinkReputationLookupForTests(): void;
|
|
48
|
+
/** A host as the lookup takes it, or null for anything that is not one. */
|
|
49
|
+
export declare function normalizeReputationHost(host: string): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* The hosts worth asking about: foreign ones. Not the workspace's own, not
|
|
52
|
+
* a domain under one of `excludeDomains` (the platform's own), not a listed
|
|
53
|
+
* brand's own domain and not a common social, maps or review link — those
|
|
54
|
+
* are judged by the screen's rules, and asking about them would only spend
|
|
55
|
+
* lookups. Deduplicated, in the order found, at most
|
|
56
|
+
* {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} unless `max` says otherwise.
|
|
57
|
+
*/
|
|
58
|
+
export declare function foreignHostsForReputation(hosts: readonly string[], options?: {
|
|
59
|
+
ownDomains?: readonly (string | null | undefined)[];
|
|
60
|
+
excludeDomains?: readonly (string | null | undefined)[];
|
|
61
|
+
max?: number;
|
|
62
|
+
}): string[];
|
|
63
|
+
/** A listing, as the screen's signal. */
|
|
64
|
+
export declare function webRiskSignals(hits: readonly HostReputationHit[]): PhishingScreenSignal[];
|
|
65
|
+
/**
|
|
66
|
+
* Look the foreign hosts up through the installed lookup and answer the
|
|
67
|
+
* signals they carry. Nothing installed, nothing foreign, an error: no
|
|
68
|
+
* signals. Never throws.
|
|
69
|
+
*/
|
|
70
|
+
export declare function linkReputationSignals(hosts: readonly string[], options?: {
|
|
71
|
+
ownDomains?: readonly (string | null | undefined)[];
|
|
72
|
+
excludeDomains?: readonly (string | null | undefined)[];
|
|
73
|
+
deadlineMs?: number;
|
|
74
|
+
}): Promise<PhishingScreenSignal[]>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /*==========================================
|
|
17
|
+
* LINK REPUTATION (AGL-3451): what the phishing screen asks about a host
|
|
18
|
+
* nobody's rules can judge.
|
|
19
|
+
*
|
|
20
|
+
* The screen's own rules catch a page or a message that LOOKS like a lure —
|
|
21
|
+
* a brand's lookalike host, a password field, a brand beside a sign-in. The
|
|
22
|
+
* 2026-10-01 harvesters linked to `temps-juenes.com` and
|
|
23
|
+
* `conservascaorvi.com`, which wear no brand at all, so nothing held them.
|
|
24
|
+
* A host already known to be bad anywhere is caught here instead: every
|
|
25
|
+
* FOREIGN host a page, an email or a redirect points at is looked up against
|
|
26
|
+
* a reputation list (Google Web Risk), and a listed one is a
|
|
27
|
+
* `web-risk-link` signal — STRONG, so it holds for every workspace.
|
|
28
|
+
*
|
|
29
|
+
* Split like the send seam's gate: the pure half lives here (which hosts are
|
|
30
|
+
* foreign, how a listing becomes a signal), and the lookup — a network call
|
|
31
|
+
* and a cache — is installed by `@aglyn/tenant-data-admin`
|
|
32
|
+
* (`web-risk.ts`) through {@link setLinkReputationLookup}. Nothing installed
|
|
33
|
+
* means nothing looked up.
|
|
34
|
+
*
|
|
35
|
+
* ## Not evidence when it fails
|
|
36
|
+
*
|
|
37
|
+
* A lookup that errors or times out says nothing about the host. It never
|
|
38
|
+
* holds by itself; the lookup reports the host as unknown and logs a warning.
|
|
39
|
+
*
|
|
40
|
+
* ## Only the host
|
|
41
|
+
*
|
|
42
|
+
* The lookup sees `https://<host>/` and nothing else: never the path, the
|
|
43
|
+
* query, the page or the message. A host is enough to catch a domain that
|
|
44
|
+
* exists to harvest, and it is the least a third party can be told.
|
|
45
|
+
*=========================================*/ import { isAnyOfficialBrandDomain, isCommonLinkDomain, registrableDomain } from "./outbound-phishing-screen.js";
|
|
46
|
+
export const LINK_THREAT_TYPES = [
|
|
47
|
+
'SOCIAL_ENGINEERING',
|
|
48
|
+
'MALWARE',
|
|
49
|
+
'UNWANTED_SOFTWARE'
|
|
50
|
+
];
|
|
51
|
+
/** The most foreign hosts one page, message or redirect is looked up for. */ export const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20;
|
|
52
|
+
let installedLookup = null;
|
|
53
|
+
/** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */ export function setLinkReputationLookup(lookup) {
|
|
54
|
+
installedLookup = lookup;
|
|
55
|
+
}
|
|
56
|
+
/** The installed lookup, or null. */ export function getLinkReputationLookup() {
|
|
57
|
+
return installedLookup;
|
|
58
|
+
}
|
|
59
|
+
/** Test seam: forget any installed lookup. */ export function resetLinkReputationLookupForTests() {
|
|
60
|
+
installedLookup = null;
|
|
61
|
+
}
|
|
62
|
+
const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)+$/;
|
|
63
|
+
/** A host as the lookup takes it, or null for anything that is not one. */ export function normalizeReputationHost(host) {
|
|
64
|
+
const value = String(host != null ? host : '').trim().toLowerCase().replace(/\.+$/, '');
|
|
65
|
+
return value.length <= 253 && HOST_PATTERN.test(value) ? value : null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The hosts worth asking about: foreign ones. Not the workspace's own, not
|
|
69
|
+
* a domain under one of `excludeDomains` (the platform's own), not a listed
|
|
70
|
+
* brand's own domain and not a common social, maps or review link — those
|
|
71
|
+
* are judged by the screen's rules, and asking about them would only spend
|
|
72
|
+
* lookups. Deduplicated, in the order found, at most
|
|
73
|
+
* {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} unless `max` says otherwise.
|
|
74
|
+
*/ export function foreignHostsForReputation(hosts, options = {}) {
|
|
75
|
+
var _options_max;
|
|
76
|
+
const under = (list)=>(list != null ? list : []).map((domain)=>normalizeReputationHost(String(domain != null ? domain : ''))).filter((domain)=>Boolean(domain));
|
|
77
|
+
const own = [
|
|
78
|
+
...under(options.ownDomains),
|
|
79
|
+
...under(options.excludeDomains)
|
|
80
|
+
];
|
|
81
|
+
const max = (_options_max = options.max) != null ? _options_max : MAX_REPUTATION_HOSTS_PER_LOOKUP;
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const raw of hosts){
|
|
84
|
+
if (out.length >= max) break;
|
|
85
|
+
const host = normalizeReputationHost(raw);
|
|
86
|
+
if (!host || out.includes(host)) continue;
|
|
87
|
+
if (own.some((domain)=>host === domain || host.endsWith(`.${domain}`))) continue;
|
|
88
|
+
const registrable = registrableDomain(host);
|
|
89
|
+
if (isAnyOfficialBrandDomain(registrable) || isCommonLinkDomain(registrable)) continue;
|
|
90
|
+
out.push(host);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** A listing, as the screen's signal. */ export function webRiskSignals(hits) {
|
|
95
|
+
return hits.map((hit)=>({
|
|
96
|
+
code: 'web-risk-link',
|
|
97
|
+
host: hit.host,
|
|
98
|
+
threats: [
|
|
99
|
+
...new Set(hit.threats)
|
|
100
|
+
].sort()
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Look the foreign hosts up through the installed lookup and answer the
|
|
105
|
+
* signals they carry. Nothing installed, nothing foreign, an error: no
|
|
106
|
+
* signals. Never throws.
|
|
107
|
+
*/ export async function linkReputationSignals(hosts, options = {}) {
|
|
108
|
+
const lookup = installedLookup;
|
|
109
|
+
if (!lookup) return [];
|
|
110
|
+
const foreign = foreignHostsForReputation(hosts, options);
|
|
111
|
+
if (!foreign.length) return [];
|
|
112
|
+
try {
|
|
113
|
+
const answer = await lookup(foreign, {
|
|
114
|
+
deadlineMs: options.deadlineMs
|
|
115
|
+
});
|
|
116
|
+
return webRiskSignals(answer.hits.filter((hit)=>foreign.includes(hit.host)));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.warn('[link-reputation] the lookup failed — no signal', error);
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
//# sourceMappingURL=link-reputation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/link-reputation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * LINK REPUTATION (AGL-3451): what the phishing screen asks about a host\n * nobody's rules can judge.\n *\n * The screen's own rules catch a page or a message that LOOKS like a lure —\n * a brand's lookalike host, a password field, a brand beside a sign-in. The\n * 2026-10-01 harvesters linked to `temps-juenes.com` and\n * `conservascaorvi.com`, which wear no brand at all, so nothing held them.\n * A host already known to be bad anywhere is caught here instead: every\n * FOREIGN host a page, an email or a redirect points at is looked up against\n * a reputation list (Google Web Risk), and a listed one is a\n * `web-risk-link` signal — STRONG, so it holds for every workspace.\n *\n * Split like the send seam's gate: the pure half lives here (which hosts are\n * foreign, how a listing becomes a signal), and the lookup — a network call\n * and a cache — is installed by `@aglyn/tenant-data-admin`\n * (`web-risk.ts`) through {@link setLinkReputationLookup}. Nothing installed\n * means nothing looked up.\n *\n * ## Not evidence when it fails\n *\n * A lookup that errors or times out says nothing about the host. It never\n * holds by itself; the lookup reports the host as unknown and logs a warning.\n *\n * ## Only the host\n *\n * The lookup sees `https://<host>/` and nothing else: never the path, the\n * query, the page or the message. A host is enough to catch a domain that\n * exists to harvest, and it is the least a third party can be told.\n *=========================================*/\n\nimport type { PhishingScreenSignal } from './outbound-phishing-screen'\nimport {\n isAnyOfficialBrandDomain,\n isCommonLinkDomain,\n registrableDomain,\n} from './outbound-phishing-screen'\n\n/** The threat lists a host is looked up against. */\nexport type LinkThreatType = 'SOCIAL_ENGINEERING' | 'MALWARE' | 'UNWANTED_SOFTWARE'\n\nexport const LINK_THREAT_TYPES: readonly LinkThreatType[] = [\n 'SOCIAL_ENGINEERING',\n 'MALWARE',\n 'UNWANTED_SOFTWARE',\n]\n\n/** A host the list names, and what it names it for. */\nexport interface HostReputationHit {\n host: string\n threats: LinkThreatType[]\n}\n\n/** What one lookup answered, host by host. */\nexport interface LinkReputationAnswer {\n /** Hosts the list names. */\n hits: HostReputationHit[]\n /** Hosts the list does not name. */\n clean: string[]\n /** Hosts nobody could answer for: an error, a timeout, a switched-off lookup. */\n unknown: string[]\n}\n\nexport interface LinkReputationLookupOptions {\n /** How long the caller will wait, overall. */\n deadlineMs?: number\n}\n\nexport type LinkReputationLookup = (\n hosts: readonly string[],\n options?: LinkReputationLookupOptions,\n) => Promise<LinkReputationAnswer>\n\n/** The most foreign hosts one page, message or redirect is looked up for. */\nexport const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20\n\nlet installedLookup: LinkReputationLookup | null = null\n\n/** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setLinkReputationLookup(lookup: LinkReputationLookup | null): void {\n installedLookup = lookup\n}\n\n/** The installed lookup, or null. */\nexport function getLinkReputationLookup(): LinkReputationLookup | null {\n return installedLookup\n}\n\n/** Test seam: forget any installed lookup. */\nexport function resetLinkReputationLookupForTests(): void {\n installedLookup = null\n}\n\nconst HOST_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)+$/\n\n/** A host as the lookup takes it, or null for anything that is not one. */\nexport function normalizeReputationHost(host: string): string | null {\n const value = String(host ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.+$/, '')\n return value.length <= 253 && HOST_PATTERN.test(value) ? value : null\n}\n\n/**\n * The hosts worth asking about: foreign ones. Not the workspace's own, not\n * a domain under one of `excludeDomains` (the platform's own), not a listed\n * brand's own domain and not a common social, maps or review link — those\n * are judged by the screen's rules, and asking about them would only spend\n * lookups. Deduplicated, in the order found, at most\n * {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} unless `max` says otherwise.\n */\nexport function foreignHostsForReputation(\n hosts: readonly string[],\n options: {\n ownDomains?: readonly (string | null | undefined)[]\n excludeDomains?: readonly (string | null | undefined)[]\n max?: number\n } = {},\n): string[] {\n const under = (list: readonly (string | null | undefined)[] | undefined) =>\n (list ?? [])\n .map((domain) => normalizeReputationHost(String(domain ?? '')))\n .filter((domain): domain is string => Boolean(domain))\n const own = [...under(options.ownDomains), ...under(options.excludeDomains)]\n const max = options.max ?? MAX_REPUTATION_HOSTS_PER_LOOKUP\n const out: string[] = []\n for (const raw of hosts) {\n if (out.length >= max) break\n const host = normalizeReputationHost(raw)\n if (!host || out.includes(host)) continue\n if (own.some((domain) => host === domain || host.endsWith(`.${domain}`))) continue\n const registrable = registrableDomain(host)\n if (isAnyOfficialBrandDomain(registrable) || isCommonLinkDomain(registrable)) continue\n out.push(host)\n }\n return out\n}\n\n/** A listing, as the screen's signal. */\nexport function webRiskSignals(hits: readonly HostReputationHit[]): PhishingScreenSignal[] {\n return hits.map((hit) => ({\n code: 'web-risk-link' as const,\n host: hit.host,\n threats: [...new Set(hit.threats)].sort(),\n }))\n}\n\n/**\n * Look the foreign hosts up through the installed lookup and answer the\n * signals they carry. Nothing installed, nothing foreign, an error: no\n * signals. Never throws.\n */\nexport async function linkReputationSignals(\n hosts: readonly string[],\n options: {\n ownDomains?: readonly (string | null | undefined)[]\n excludeDomains?: readonly (string | null | undefined)[]\n deadlineMs?: number\n } = {},\n): Promise<PhishingScreenSignal[]> {\n const lookup = installedLookup\n if (!lookup) return []\n const foreign = foreignHostsForReputation(hosts, options)\n if (!foreign.length) return []\n try {\n const answer = await lookup(foreign, { deadlineMs: options.deadlineMs })\n return webRiskSignals(answer.hits.filter((hit) => foreign.includes(hit.host)))\n } catch (error) {\n console.warn('[link-reputation] the lookup failed — no signal', error)\n return []\n }\n}\n"],"names":["isAnyOfficialBrandDomain","isCommonLinkDomain","registrableDomain","LINK_THREAT_TYPES","MAX_REPUTATION_HOSTS_PER_LOOKUP","installedLookup","setLinkReputationLookup","lookup","getLinkReputationLookup","resetLinkReputationLookupForTests","HOST_PATTERN","normalizeReputationHost","host","value","String","trim","toLowerCase","replace","length","test","foreignHostsForReputation","hosts","options","under","list","map","domain","filter","Boolean","own","ownDomains","excludeDomains","max","out","raw","includes","some","endsWith","registrable","push","webRiskSignals","hits","hit","code","threats","Set","sort","linkReputationSignals","foreign","answer","deadlineMs","error","console","warn"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CA6B2C,GAG3C,SACEA,wBAAwB,EACxBC,kBAAkB,EAClBC,iBAAiB,QACZ,gCAA4B;AAKnC,OAAO,MAAMC,oBAA+C;IAC1D;IACA;IACA;CACD,CAAA;AA4BD,2EAA2E,GAC3E,OAAO,MAAMC,kCAAkC,GAAE;AAEjD,IAAIC,kBAA+C;AAEnD,uEAAuE,GACvE,OAAO,SAASC,wBAAwBC,MAAmC;IACzEF,kBAAkBE;AACpB;AAEA,mCAAmC,GACnC,OAAO,SAASC;IACd,OAAOH;AACT;AAEA,4CAA4C,GAC5C,OAAO,SAASI;IACdJ,kBAAkB;AACpB;AAEA,MAAMK,eAAe;AAErB,yEAAyE,GACzE,OAAO,SAASC,wBAAwBC,IAAY;IAClD,MAAMC,QAAQC,OAAOF,eAAAA,OAAQ,IAC1BG,IAAI,GACJC,WAAW,GACXC,OAAO,CAAC,QAAQ;IACnB,OAAOJ,MAAMK,MAAM,IAAI,OAAOR,aAAaS,IAAI,CAACN,SAASA,QAAQ;AACnE;AAEA;;;;;;;CAOC,GACD,OAAO,SAASO,0BACdC,KAAwB,EACxBC,UAII,CAAC,CAAC;QAOMA;IALZ,MAAMC,QAAQ,CAACC,OACb,CAACA,eAAAA,OAAQ,EAAE,EACRC,GAAG,CAAC,CAACC,SAAWf,wBAAwBG,OAAOY,iBAAAA,SAAU,MACzDC,MAAM,CAAC,CAACD,SAA6BE,QAAQF;IAClD,MAAMG,MAAM;WAAIN,MAAMD,QAAQQ,UAAU;WAAMP,MAAMD,QAAQS,cAAc;KAAE;IAC5E,MAAMC,OAAMV,eAAAA,QAAQU,GAAG,YAAXV,eAAelB;IAC3B,MAAM6B,MAAgB,EAAE;IACxB,KAAK,MAAMC,OAAOb,MAAO;QACvB,IAAIY,IAAIf,MAAM,IAAIc,KAAK;QACvB,MAAMpB,OAAOD,wBAAwBuB;QACrC,IAAI,CAACtB,QAAQqB,IAAIE,QAAQ,CAACvB,OAAO;QACjC,IAAIiB,IAAIO,IAAI,CAAC,CAACV,SAAWd,SAASc,UAAUd,KAAKyB,QAAQ,CAAC,CAAC,CAAC,EAAEX,QAAQ,IAAI;QAC1E,MAAMY,cAAcpC,kBAAkBU;QACtC,IAAIZ,yBAAyBsC,gBAAgBrC,mBAAmBqC,cAAc;QAC9EL,IAAIM,IAAI,CAAC3B;IACX;IACA,OAAOqB;AACT;AAEA,uCAAuC,GACvC,OAAO,SAASO,eAAeC,IAAkC;IAC/D,OAAOA,KAAKhB,GAAG,CAAC,CAACiB,MAAS,CAAA;YACxBC,MAAM;YACN/B,MAAM8B,IAAI9B,IAAI;YACdgC,SAAS;mBAAI,IAAIC,IAAIH,IAAIE,OAAO;aAAE,CAACE,IAAI;QACzC,CAAA;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeC,sBACpB1B,KAAwB,EACxBC,UAII,CAAC,CAAC;IAEN,MAAMf,SAASF;IACf,IAAI,CAACE,QAAQ,OAAO,EAAE;IACtB,MAAMyC,UAAU5B,0BAA0BC,OAAOC;IACjD,IAAI,CAAC0B,QAAQ9B,MAAM,EAAE,OAAO,EAAE;IAC9B,IAAI;QACF,MAAM+B,SAAS,MAAM1C,OAAOyC,SAAS;YAAEE,YAAY5B,QAAQ4B,UAAU;QAAC;QACtE,OAAOV,eAAeS,OAAOR,IAAI,CAACd,MAAM,CAAC,CAACe,MAAQM,QAAQb,QAAQ,CAACO,IAAI9B,IAAI;IAC7E,EAAE,OAAOuC,OAAO;QACdC,QAAQC,IAAI,CAAC,mDAAmDF;QAChE,OAAO,EAAE;IACX;AACF"}
|
|
@@ -158,6 +158,17 @@ export type PhishingScreenSignal = {
|
|
|
158
158
|
host: string;
|
|
159
159
|
lure: string;
|
|
160
160
|
place: 'fragment' | 'parameter';
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A link, embed, redirect or form action whose host Google Web Risk lists
|
|
164
|
+
* as phishing, malware or unwanted software (AGL-3451,
|
|
165
|
+
* `link-reputation.ts`). Strong: a harvester already known to be bad
|
|
166
|
+
* anywhere is bad whatever page or message surrounds it.
|
|
167
|
+
*/
|
|
168
|
+
| {
|
|
169
|
+
code: 'web-risk-link';
|
|
170
|
+
host: string;
|
|
171
|
+
threats: string[];
|
|
161
172
|
};
|
|
162
173
|
export interface PhishingScreenInput {
|
|
163
174
|
subject?: string | null;
|
|
@@ -259,6 +270,8 @@ export declare function isCommonLinkDomain(registrable: string): boolean;
|
|
|
259
270
|
export declare function screenOutboundEmail(input: PhishingScreenInput): PhishingScreenVerdict;
|
|
260
271
|
/** The brand's label for a stored id, for the staff surface. */
|
|
261
272
|
export declare function phishingScreenBrandLabel(id: string): string;
|
|
273
|
+
/** "phishing or social engineering and malware", from Web Risk's threat types. */
|
|
274
|
+
export declare function describeLinkThreats(threats: readonly string[]): string;
|
|
262
275
|
/** One line per signal, in words staff read on the review row. */
|
|
263
276
|
export declare function describePhishingScreenSignals(signals: readonly PhishingScreenSignal[]): string[];
|
|
264
277
|
/** A workspace younger than this many days has the soft rules applied. */
|
|
@@ -1062,6 +1062,21 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
1062
1062
|
var _PHISHING_SCREEN_BRANDS_find;
|
|
1063
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;
|
|
1064
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
|
+
}
|
|
1065
1080
|
/** One line per signal, in words staff read on the review row. */ export function describePhishingScreenSignals(signals) {
|
|
1066
1081
|
return signals.map((signal)=>{
|
|
1067
1082
|
const brand = 'brand' in signal ? phishingScreenBrandLabel(signal.brand) : '';
|
|
@@ -1084,6 +1099,8 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
1084
1099
|
return `Redirects ${signal.source}, a path that reads "${signal.lure}", off the site to ${signal.host}.`;
|
|
1085
1100
|
case 'recipient-prefill-link':
|
|
1086
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)}.`;
|
|
1087
1104
|
default:
|
|
1088
1105
|
return 'Phishing signal.';
|
|
1089
1106
|
}
|
|
@@ -1120,11 +1137,15 @@ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
1120
1137
|
*
|
|
1121
1138
|
* A credential-harvest page (a password, card or one-time-code field that
|
|
1122
1139
|
* is not one of the platform's own sign-in or checkout elements) is STRONG:
|
|
1123
|
-
* 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.
|
|
1124
1144
|
*=========================================*/ /** A workspace younger than this many days has the soft rules applied. */ export const OUTBOUND_REVIEW_YOUNG_DAYS = 14;
|
|
1125
1145
|
/** The signal codes that hold for every workspace. */ export const STRONG_PHISHING_SIGNAL_CODES = new Set([
|
|
1126
1146
|
'lookalike-link',
|
|
1127
|
-
'credential-field'
|
|
1147
|
+
'credential-field',
|
|
1148
|
+
'web-risk-link'
|
|
1128
1149
|
]);
|
|
1129
1150
|
/**
|
|
1130
1151
|
* Hosts that give each customer an account subdomain named after the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-phishing-screen.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * A fraud actor signed up, paid with a card that never met 3-D Secure, and\n * used a workspace to send two kinds of mail: a campaign of Booking.com-style\n * \"guest feedback regarding your property\" lures to hotels, and a workflow\n * that mailed \"Poshmark Order — one of the items from your Seller Account has\n * finally sold\", linking to `poshmark.id63835663.shop`.\n *\n * This module answers one question about a message a YOUNG workspace is\n * about to send: does its content carry a STRONG phishing signal? The caller\n * decides who is young and what happens to a positive answer — the answer is\n * a hold for staff review, never a drop.\n *\n * ## Conservative by construction\n *\n * A false positive costs a real merchant's first campaign a wait for a\n * human; a miss costs strangers their bank logins. Both are real, so only\n * four shapes hold, and each is one a legitimate merchant essentially never\n * produces:\n *\n * 1. **A lookalike link** — a link or reply address whose host carries a\n * brand's name without being the brand's own domain: the brand as a label\n * of an unrelated registrable domain (`poshmark.id63835663.shop`), hyphen-\n * joined to one (`paypal-secure.com`, `secure-paypal.net`), the brand's\n * real domain embedded in a longer host (`booking.com.guest-review.top`),\n * or leetspeak for it (`paypa1.com`). Holds on its own.\n * 2. **A brand in the sender's display name** that is not the workspace's\n * own name — mail that says it is FROM PayPal.\n * 3. **The three-part lure**: a brand named in the copy, AND credential or\n * account-action phrasing (\"verify your account\", \"has finally sold\",\n * \"feedback regarding your property\"), AND a link to a domain that is\n * neither the brand's, nor the workspace's, nor a common social or maps\n * link. Any two of the three are ordinary marketing — \"our book is on\n * Amazon\", \"sign in to your account\", a link to the shop — and pass.\n * 4. **A prefilled sign-in** (AGL-3453): account or password phrasing\n * (\"your password expires today\") beside a link to such a domain that\n * carries the reader's own address, so the harvester's form opens with\n * it filled in: `…/ss/c/…#victim@example.com`.\n *\n * Mentioning a brand, alone, never holds. Brands and lures are read from\n * what the reader SEES ({@link visibleTextOf}), never from the raw HTML, in\n * which a kit splits every letter with a tag; links are read from the raw\n * HTML, where an href lives.\n *\n * ## The brand list is small on purpose\n *\n * The brands phishing kits most often wear, plus the two in the incident, and\n * the file-share and mail-security names a document-share lure wears — a\n * \"Secure Document Access Portal\" under Proofpoint's or SharePoint's name\n * whose one button leaves for the kit (AGL-3447). A brand is added when it\n * has been seen impersonated, not because it is big: every entry is a set of\n * words that can now hold somebody's newsletter.\n * Each carries its REAL domains, so a link to the brand itself is never\n * suspicious, and host tokens chosen so a common word (`booking`, `apple`,\n * `outlook`, `box`, `adobe`) does not match an unrelated host by itself.\n *\n * Pure: no DNS, no store, no clock. Everything the caller knows about the\n * workspace arrives as input.\n *=========================================*/\n\n/** One brand the screen recognizes. */\nexport interface PhishingScreenBrand {\n /** Stable id, as stored on a hold. */\n id: string\n /** How staff read it. */\n label: string\n /** Copy that names the brand — subject, body, sender. Case-insensitive. */\n mention: RegExp\n /**\n * Host labels that name the brand. Matched against each dot-separated\n * label of a link's host, whole or hyphen-joined, after leetspeak is\n * folded — never as a bare substring, which is how `amazonaws` or\n * `bookingengine` would read as a brand.\n */\n hostTokens: readonly string[]\n /**\n * Registrable domains the brand genuinely links from. The FIRST is its\n * primary domain, the one a kit embeds in a longer host.\n */\n officialDomains: readonly string[]\n /**\n * The brand's name is an ordinary word (`apple`, `amazon`), so a host\n * label names the brand only when it IS the word — `apple.example.top` —\n * and never hyphen-joined or leet-folded, which `apple-orchard-farm.com`\n * would otherwise be.\n */\n wordToken?: boolean\n /**\n * Whether `<brand>.<country code>` (and `<brand>.co.<cc>`) is the brand's\n * too — true for the marketplaces that run a site per country.\n */\n countryDomains?: boolean\n /**\n * Registrable domains the brand runs but where OTHER people's content lives —\n * the platform's own tenant apex, where every site is `<name>.<apex>`. A\n * host there never wears the brand as a disguise, so the lookalike rule\n * skips it; but it is not the brand's own domain either, so a link to a\n * neighbor's site still counts as \"somewhere else\" to the lure rules, and a\n * workspace hosted there does not own the brand by being hosted there.\n */\n hostedDomains?: readonly string[]\n}\n\nexport const PHISHING_SCREEN_BRANDS: readonly PhishingScreenBrand[] = [\n {\n id: 'poshmark',\n label: 'Poshmark',\n mention: /\\bposhmark\\b/i,\n hostTokens: ['poshmark'],\n officialDomains: ['poshmark.com', 'posh.mk'],\n },\n {\n id: 'booking',\n label: 'Booking.com',\n // Never the bare word: \"booking\" is every hotel's and every salon's.\n mention: /\\bbooking\\.com\\b/i,\n hostTokens: ['bookingcom', 'booking-com'],\n officialDomains: ['booking.com', 'bstatic.com'],\n },\n {\n id: 'airbnb',\n label: 'Airbnb',\n mention: /\\bairbnb\\b/i,\n hostTokens: ['airbnb'],\n officialDomains: ['airbnb.com', 'abnb.me', 'muscache.com'],\n countryDomains: true,\n },\n {\n id: 'paypal',\n label: 'PayPal',\n mention: /\\bpay\\s?pal\\b/i,\n hostTokens: ['paypal'],\n officialDomains: ['paypal.com', 'paypal.me', 'paypalobjects.com'],\n countryDomains: true,\n },\n {\n id: 'amazon',\n label: 'Amazon',\n mention: /\\bamazon\\b/i,\n hostTokens: ['amazon', 'amzn'],\n wordToken: true,\n officialDomains: [\n 'amazon.com',\n 'amzn.to',\n 'amzn.com',\n 'amazonaws.com',\n 'media-amazon.com',\n 'ssl-images-amazon.com',\n ],\n countryDomains: true,\n },\n {\n id: 'apple',\n label: 'Apple',\n // The fruit is on every bakery's menu; the account is what gets phished.\n mention: /\\bapple\\s?(?:id|support|account)\\b|\\bicloud\\b|\\bitunes\\b/i,\n hostTokens: ['apple', 'appleid', 'icloud', 'itunes'],\n wordToken: true,\n officialDomains: ['apple.com', 'icloud.com', 'apple.co', 'itunes.com', 'me.com', 'mzstatic.com'],\n countryDomains: true,\n },\n {\n id: 'microsoft',\n label: 'Microsoft',\n // Outlook only as the product — \"our 2026 outlook\" is every advisor's\n // newsletter.\n mention:\n /\\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,\n hostTokens: [\n 'microsoft',\n 'microsoft365',\n 'office365',\n 'office-365',\n 'outlook365',\n 'onedrive',\n 'sharepoint',\n 'microsoftonline',\n 'msonline',\n ],\n officialDomains: [\n 'microsoft.com',\n 'office.com',\n 'office365.com',\n 'microsoft365.com',\n 'live.com',\n 'outlook.com',\n 'microsoftonline.com',\n 'sharepoint.com',\n 'sharepointonline.com',\n 'onedrive.com',\n '1drv.ms',\n 'aka.ms',\n ],\n },\n {\n id: 'docusign',\n label: 'DocuSign',\n mention: /\\bdocu\\s?sign\\b/i,\n hostTokens: ['docusign'],\n officialDomains: ['docusign.com', 'docusign.net'],\n },\n {\n id: 'google',\n label: 'Google',\n // The account and the files, never the bare name: \"find us on Google\"\n // and \"Google Maps\" are on every shop's contact page.\n mention: /\\bgoogle\\s?(?:drive|docs?|sheets|slides|workspace|account)\\b|\\bgmail\\b|\\bg\\s?suite\\b/i,\n // The products and the account, never the bare name: Google runs\n // hyphenated hosts of its own that a page embeds, and a bare `google`\n // token would read each as its lookalike. `docs.google.com.<kit>` is\n // still read, by the primary-domain rule.\n hostTokens: [\n 'gmail',\n 'gsuite',\n 'googledocs',\n 'googledrive',\n 'google-docs',\n 'google-drive',\n 'google-workspace',\n 'google-account',\n 'google-login',\n 'google-mail',\n 'docs-google',\n 'drive-google',\n ],\n officialDomains: [\n 'google.com',\n 'goo.gl',\n 'g.co',\n 'g.page',\n 'forms.gle',\n 'gmail.com',\n 'googleapis.com',\n 'gstatic.com',\n 'googleusercontent.com',\n 'withgoogle.com',\n ],\n countryDomains: true,\n },\n {\n id: 'adobe',\n label: 'Adobe',\n // \"Adobe\" alone is a house, a grill and an inn across the Southwest;\n // the products are what a lure names.\n mention:\n /\\badobe\\s?(?:acrobat|sign|id|account|document\\s?cloud|reader|pdf|creative\\s?cloud)\\b|\\bacrobat\\s?(?:reader|sign|pro)\\b|\\bechosign\\b/i,\n hostTokens: ['adobe', 'adobeid', 'adobesign', 'adobeacrobat', 'echosign'],\n wordToken: true,\n officialDomains: [\n 'adobe.com',\n 'adobe.io',\n 'adobe.ly',\n 'adobelogin.com',\n 'adobesign.com',\n 'echosign.com',\n 'acrobat.com',\n 'typekit.net',\n 'adobedtm.com',\n ],\n },\n {\n id: 'dropbox',\n label: 'Dropbox',\n // One word only: a \"drop box\" is by every library's door.\n mention: /\\bdropbox\\b/i,\n hostTokens: ['dropbox'],\n officialDomains: ['dropbox.com', 'db.tt', 'dropboxusercontent.com', 'dropboxstatic.com', 'hellosign.com'],\n },\n {\n // Never the bare word, as an id either — `brandForSubdomainLabel` reads\n // ids as words, and \"box\" is every lunch box's and subscription box's.\n id: 'boxcom',\n label: 'Box',\n mention: /\\bbox\\.com\\b|\\bbox\\s+drive\\b/i,\n hostTokens: ['boxcom', 'box-com'],\n officialDomains: ['box.com', 'box.net', 'boxcdn.net', 'boxcloud.com'],\n },\n {\n id: 'wetransfer',\n label: 'WeTransfer',\n // One word only: \"we transfer\" is a moving company's sentence.\n mention: /\\bwetransfer\\b/i,\n hostTokens: ['wetransfer'],\n officialDomains: ['wetransfer.com', 'we.tl'],\n },\n {\n id: 'sharefile',\n label: 'ShareFile',\n mention: /\\bshare\\s?file\\b/i,\n hostTokens: ['sharefile'],\n officialDomains: ['sharefile.com', 'sharefile.eu', 'citrix.com'],\n },\n {\n // A secure-mail gateway's name is what makes a document lure read as\n // safe: \"Proofpoint Encryption for your sensitive documents\".\n id: 'proofpoint',\n label: 'Proofpoint',\n // One word only: a \"proof point\" is every pitch deck's.\n mention: /\\bproofpoint\\b/i,\n hostTokens: ['proofpoint'],\n officialDomains: ['proofpoint.com', 'urldefense.com', 'pphosted.com', 'ppe-hosted.com'],\n },\n {\n id: 'mimecast',\n label: 'Mimecast',\n mention: /\\bmimecast\\b/i,\n hostTokens: ['mimecast'],\n officialDomains: ['mimecast.com', 'mimecastprotect.com'],\n },\n {\n id: 'netflix',\n label: 'Netflix',\n mention: /\\bnetflix\\b/i,\n hostTokens: ['netflix'],\n officialDomains: ['netflix.com', 'nflxext.com', 'nflximg.net'],\n },\n {\n id: 'wellsfargo',\n label: 'Wells Fargo',\n mention: /\\bwells\\s?fargo\\b/i,\n hostTokens: ['wellsfargo', 'wells-fargo'],\n officialDomains: ['wellsfargo.com'],\n },\n {\n id: 'bankofamerica',\n label: 'Bank of America',\n mention: /\\bbank\\s+of\\s+america\\b/i,\n hostTokens: ['bankofamerica', 'bofa'],\n officialDomains: ['bankofamerica.com', 'bofa.com'],\n },\n {\n id: 'coinbase',\n label: 'Coinbase',\n mention: /\\bcoinbase\\b/i,\n hostTokens: ['coinbase'],\n officialDomains: ['coinbase.com'],\n },\n {\n id: 'usps',\n label: 'USPS',\n mention: /\\busps\\b/i,\n hostTokens: ['usps'],\n officialDomains: ['usps.com'],\n },\n {\n id: 'fedex',\n label: 'FedEx',\n mention: /\\bfed\\s?ex\\b/i,\n hostTokens: ['fedex'],\n officialDomains: ['fedex.com'],\n },\n {\n id: 'dhl',\n label: 'DHL',\n mention: /\\bdhl\\b/i,\n hostTokens: ['dhl'],\n officialDomains: ['dhl.com'],\n countryDomains: true,\n },\n {\n // The platform itself (AGL-3365). A marketplace listing, a page or an\n // email that wears the platform's name — \"Aglyn Support\", a link to\n // `aglyn-billing.com`, \"official Aglyn plugin\" — is the one impersonation\n // every customer is primed to trust. The workspace named for it (the\n // platform's own org) owns it by name, as any brand's own workspace does.\n id: 'aglyn',\n label: 'Aglyn',\n mention: /\\baglyn\\b/i,\n hostTokens: ['aglyn'],\n officialDomains: ['aglyn.com', 'aglyn.io', 'aglyn.dev'],\n hostedDomains: ['aglyn.app'],\n },\n]\n\n/**\n * Account and credential phrasing: the lure that asks the reader to DO\n * something with an account — verify it, confirm it, sign in to it.\n */\nexport const ACCOUNT_LURE_PATTERNS: readonly RegExp[] = [\n /\\bverify\\s+your\\s+(?:account|identity|information|details|payment|email)\\b/i,\n /\\bconfirm\\s+your\\s+(?:account|identity|details|payment|password|billing|information)\\b/i,\n /\\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,\n /\\bunusual\\s+(?:sign[-\\s]?in|login|activity)\\b/i,\n /\\bupdate\\s+your\\s+(?:payment|billing|card|account)\\s*(?:details|information|method)?\\b/i,\n /\\b(?:sign|log)\\s?in\\s+to\\s+(?:your|view|confirm|claim|release)\\b/i,\n // \"Password Expired\", \"your password expires today\", \"will expire\".\n /\\bpassword\\s+(?:has\\s+|will\\s+)?expire/i,\n /\\bkeep\\s+(?:your|my|the)\\s+(?:current\\s+|active\\s+|existing\\s+|same\\s+)?password\\b/i,\n /\\bchange\\s+(?:your\\s+)?password\\s+settings?\\b/i,\n /\\bmailbox\\s+(?:storage\\s+|quota\\s+)?(?:is\\s+)?(?:almost\\s+|nearly\\s+)?(?:full|exceeded)\\b/i,\n /\\bsecurity\\s+alert\\b/i,\n]\n\n/**\n * Document-share phrasing (AGL-3447): the lure that says a file is waiting\n * behind a secure door — \"Secure Document Access Portal\", \"Continue to\n * Document\", \"shared a file with you\", \"encrypted message\". The credential\n * harvest is on the far side of the button, so these are the words the page\n * or the mail itself carries.\n */\nexport const DOCUMENT_SHARE_LURE_PATTERNS: readonly RegExp[] = [\n /\\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,\n /\\b(?:view|access|open)\\s+(?:the\\s+|your\\s+)?secure\\s+(?:file|document|message)s?\\b/i,\n /\\bsecure\\s+(?:file|document|message)s?\\b/i,\n /\\bdocument\\s+access\\b/i,\n /\\baccess\\s+portal\\b/i,\n /\\b(?:continue|proceed)\\s+to\\s+(?:the\\s+|your\\s+)?(?:document|file)s?\\b/i,\n /\\bshared\\s+(?:a|an|the)\\s+(?:file|document|folder)\\s+with\\s+you\\b/i,\n /\\b(?:file|document|folder)s?\\s+(?:has\\s+been|have\\s+been|was|were)\\s+shared\\s+with\\s+you\\b/i,\n /\\bencrypted\\s+(?:message|document|file|email)s?\\b/i,\n]\n\n/**\n * Marketplace, booking and parcel phrasing: the lure that says a sale, a\n * guest or a delivery needs the reader. The incident's Poshmark and\n * Booking.com mail.\n */\nconst SALE_AND_PARCEL_LURE_PATTERNS: readonly RegExp[] = [\n /\\b(?:has|have)\\s+(?:finally\\s+)?sold\\b/i,\n /\\bseller\\s+account\\b/i,\n /\\b(?:guest|customer)\\s+(?:complaint|review|feedback)\\b/i,\n /\\bfeedback\\s+regarding\\s+your\\s+(?:property|listing|stay|booking|reservation)\\b/i,\n /\\bclaim\\s+your\\s+(?:refund|reward|prize|package|payment|funds)\\b/i,\n /\\b(?:delivery|shipment)\\s+(?:failed|attempt|suspended|on\\s+hold)\\b/i,\n /\\b(?:package|parcel)\\s+(?:is\\s+)?(?:on\\s+hold|held|awaiting|undeliverable)\\b/i,\n]\n\n/**\n * Every lure — the half of the three-part lure that asks the reader to act.\n * Only ever counted beside a brand AND a foreign link; alone, several of\n * these are ordinary transactional copy.\n */\nexport const PHISHING_LURE_PATTERNS: readonly RegExp[] = [\n ...ACCOUNT_LURE_PATTERNS,\n ...DOCUMENT_SHARE_LURE_PATTERNS,\n ...SALE_AND_PARCEL_LURE_PATTERNS,\n]\n\n/**\n * Link targets a merchant's mail ordinarily carries that are not theirs:\n * social profiles, maps, review sites, video. Counted as neither foreign nor\n * suspicious in the three-part lure, so \"leave us a review on Google\" with a\n * brand named elsewhere does not become a hold. Never consulted by the\n * lookalike check, which holds on the host's own shape.\n */\nconst COMMON_LINK_DOMAINS: ReadonlySet<string> = new Set([\n 'facebook.com',\n 'fb.com',\n 'instagram.com',\n 'x.com',\n 'twitter.com',\n 'linkedin.com',\n 'youtube.com',\n 'youtu.be',\n 'tiktok.com',\n 'pinterest.com',\n 'google.com',\n 'goo.gl',\n 'g.page',\n 'yelp.com',\n 'tripadvisor.com',\n 'wa.me',\n 'whatsapp.com',\n])\n\n/**\n * Two-label public suffixes, so `evil.co.uk` registers as `evil.co.uk` and\n * not as `co.uk`. Deliberately a short list of the ones mail actually\n * arrives from rather than the whole public suffix list: a suffix missing\n * here makes the registrable domain one label too short, which can only\n * widen what counts as \"the same domain\" for a link — never invent a brand\n * label that is not in the host.\n */\nconst TWO_LABEL_SUFFIXES: ReadonlySet<string> = new Set([\n 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk',\n 'com.au', 'net.au', 'org.au',\n 'co.nz', 'co.jp', 'co.kr', 'co.in', 'co.za', 'co.il', 'co.id', 'co.th',\n 'com.br', 'com.mx', 'com.ar', 'com.tr', 'com.cn', 'com.hk', 'com.sg',\n 'com.tw', 'com.my', 'com.ph', 'com.co', 'com.pe', 'com.sa', 'com.eg',\n])\n\n/** A signal the screen found. */\nexport type PhishingScreenSignal =\n | { code: 'lookalike-link'; brand: string; host: string }\n | { code: 'brand-sender'; brand: string; fromName: string }\n | { code: 'brand-lure-link'; brand: string; lure: string; host: string }\n /**\n * A published page asks for a password, a card number or a one-time code\n * in a field that is not one of the platform's own sign-in or checkout\n * elements (`hosted-page-screen.ts`). Strong.\n */\n | { code: 'credential-field'; field: 'password' | 'card' | 'otp'; label: string }\n /**\n * A published page names a brand that is not the workspace's beside a\n * sign-in, verify or payment call to action (`hosted-page-screen.ts`).\n * Soft.\n */\n | { code: 'brand-action-page'; brand: string; action: string }\n /**\n * The same, read over the page as a WHOLE (AGL-3447): a brand that is not\n * the workspace's anywhere on a page that carries an account or\n * document-share lure anywhere, or whose call to action leaves the site\n * worded as one, and that links away (`hosted-page-screen.ts`). Soft.\n */\n | { code: 'brand-lure-page'; brand: string; lure: string; host: string }\n /**\n * A published page's call to action — its only one, or one worded as an\n * account or document action — sends visitors to a host that is not the\n * site's, a listed brand's or a common link, on a page that carries an\n * account or document-share lure (`hosted-page-screen.ts`, AGL-3447). No\n * brand needed. Soft.\n */\n | { code: 'offsite-action-page'; action: string; lure: string; host: string }\n /**\n * A site redirect whose path reads as an account or document-share lure\n * sends visitors to such a host (`screenSiteRedirect`, AGL-3447). Soft.\n */\n | { code: 'offsite-redirect'; source: string; lure: string; host: string }\n /**\n * Account or password phrasing beside a link elsewhere that carries the\n * reader's own address (AGL-3453). STRONG when the address is the whole\n * fragment (`#victim@example.com`), which only a landing page's script\n * reads; soft when it is a query parameter or part of a longer fragment.\n * The address itself is never stored.\n */\n | { code: 'recipient-prefill-link'; host: string; lure: string; place: 'fragment' | 'parameter' }\n\nexport interface PhishingScreenInput {\n subject?: string | null\n /** The display name in front of the sending address. */\n fromName?: string | null\n /**\n * The ADDRESS the message leaves from (AGL-3362) — a `From:` domain is a\n * link the recipient never has to click, so its host is read by the\n * lookalike rule exactly like a link's: mail from `support@paypa1.com` is\n * the disguise whoever sends it. Not an own domain for the lure's \"is this\n * elsewhere\" test either way; it only ever adds a lookalike signal.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n /**\n * Everything else the recipient reads or clicks: the body, the plain-text\n * part, a design's nodes serialized — any string. Links are found by\n * shape, so a JSON-encoded design is as readable here as HTML.\n */\n bodies?: readonly (string | null | undefined)[]\n /**\n * The workspace's own names — org, site, subdomain. A brand in one of them\n * is the workspace's own, and `brand-sender` / `brand-lure-link` do not\n * fire for it. The lookalike check does not consult names: a workspace\n * NAMED \"Poshmark\" linking to `poshmark.id63835663.shop` is the incident.\n */\n ownNames?: readonly (string | null | undefined)[]\n /**\n * The workspace's own hosts — its subdomain on the tenant apex, custom\n * domain, sending domain. A link to one of them, or below one, is never\n * the \"somewhere else\" of a lure. Matched by host, never by registrable\n * domain: every site's subdomain shares the tenant apex, and a link to a\n * NEIGHBOR's site is somewhere else.\n *\n * Not an exemption from the lookalike check. A host shaped like\n * `paypal-secure.com` is a lookalike whoever attached it, and a workspace\n * that attached it has attached the disguise.\n */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\nexport interface PhishingScreenVerdict {\n /** True when the message should be held for staff review. */\n hold: boolean\n signals: PhishingScreenSignal[]\n}\n\n/** The registrable domain of a host: `a.b.evil.co.uk` → `evil.co.uk`. */\nexport function registrableDomain(host: string): string {\n const labels = String(host ?? '')\n .toLowerCase()\n .replace(/\\.+$/, '')\n .split('.')\n .filter(Boolean)\n if (labels.length <= 2) return labels.join('.')\n const lastTwo = labels.slice(-2).join('.')\n return TWO_LABEL_SUFFIXES.has(lastTwo)\n ? labels.slice(-3).join('.')\n : lastTwo\n}\n\n/** `<label>.<suffix>` of a registrable domain, split once. */\nfunction splitRegistrable(registrable: string): { label: string; suffix: string } {\n const dot = registrable.indexOf('.')\n return dot < 0\n ? { label: registrable, suffix: '' }\n : { label: registrable.slice(0, dot), suffix: registrable.slice(dot + 1) }\n}\n\nfunction isOfficialDomain(brand: PhishingScreenBrand, registrable: string): boolean {\n if (brand.officialDomains.includes(registrable)) return true\n if (!brand.countryDomains) return false\n const base = splitRegistrable(brand.officialDomains[0]).label\n const { label, suffix } = splitRegistrable(registrable)\n return label === base && (/^[a-z]{2}$/.test(suffix) || TWO_LABEL_SUFFIXES.has(suffix))\n}\n\n/**\n * Is the workspace the brand itself, by DOMAIN: one of its own domains is\n * one of the brand's official ones (Aglyn's site on aglyn.com linking to\n * its own account on a video or help-desk vendor). Deliberately not by name — a workspace can call itself\n * \"Poshmark\", but it cannot serve on poshmark.com.\n */\nexport function isBrandsOwnDomain(\n brand: PhishingScreenBrand,\n ownRegistrables: ReadonlySet<string>,\n): boolean {\n return [...ownRegistrables].some((registrable) => isOfficialDomain(brand, registrable))\n}\n\n/** Every brand's real domains, for \"is this link just the brand itself\". */\nfunction isAnyOfficialDomain(registrable: string): boolean {\n return PHISHING_SCREEN_BRANDS.some((brand) => isOfficialDomain(brand, registrable))\n}\n\n/** Leetspeak folded, so `paypa1` reads as `paypal`. */\nfunction foldLeet(label: string): string {\n return label\n .replace(/0/g, 'o')\n .replace(/1/g, 'l')\n .replace(/3/g, 'e')\n .replace(/4/g, 'a')\n .replace(/5/g, 's')\n .replace(/7/g, 't')\n}\n\n/** Does one host label name the brand — whole, or hyphen-joined? */\nfunction labelNamesBrand(label: string, token: string, wordToken: boolean): boolean {\n if (wordToken) return label === token\n for (const candidate of new Set([label, foldLeet(label)])) {\n if (candidate === token) return true\n const parts = candidate.split('-')\n // A hyphen-joined token (`booking-com`) spans parts, so it is matched\n // as a run of parts rather than one of them.\n const tokenParts = token.split('-')\n for (let at = 0; at + tokenParts.length <= parts.length; at += 1) {\n if (tokenParts.every((part, offset) => parts[at + offset] === part)) {\n return true\n }\n }\n }\n return false\n}\n\n/**\n * Video and audio platforms whose account subdomains and embed URLs a page\n * or email plays media from: `player.vimeo.com`, `www.youtube.com/embed/…`.\n * A video plugin's own hosts are the plugin's to declare, not listed here. Anyone may embed or link media from these, so a\n * host on one is never a lookalike, whatever name its subdomain carries.\n */\nexport const MEDIA_EMBED_HOSTS: ReadonlySet<string> = new Set([\n 'vimeo.com', 'vimeocdn.com', 'youtube.com', 'youtu.be',\n 'youtube-nocookie.com', 'ytimg.com', 'loom.com', 'vidyard.com', 'brightcove.net',\n 'brightcove.com', 'jwplayer.com', 'jwpcdn.com', 'mux.com', 'cloudflarestream.com',\n 'videodelivery.net', 'dailymotion.com', 'twitch.tv', 'spotify.com', 'soundcloud.com',\n 'podbean.com', 'buzzsprout.com', 'simplecast.com', 'transistor.fm', 'anchor.fm',\n])\n\n/** The brand a host impersonates, or null. */\nexport function lookalikeBrandForHost(host: string): PhishingScreenBrand | null {\n const normalized = String(host ?? '').toLowerCase().replace(/\\.+$/, '')\n if (!normalized.includes('.')) return null\n const registrable = registrableDomain(normalized)\n if (MEDIA_EMBED_HOSTS.has(registrable)) return null\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOfficialDomain(brand, registrable)) continue\n if (brand.hostedDomains?.includes(registrable)) continue\n const labels = normalized.split('.')\n if (\n labels.some((label) =>\n brand.hostTokens.some((token) => labelNamesBrand(label, token, brand.wordToken === true)),\n )\n ) {\n return brand\n }\n // The brand's primary domain embedded in a longer, different one:\n // `booking.com.guest-review.top`, `paypal.com-secure.net`. Only the\n // primary: a short secondary (`me.com`, `aka.ms`) would match too much.\n const primary = brand.officialDomains[0]\n if (\n normalized.startsWith(`${primary}.`) ||\n normalized.startsWith(`${primary}-`) ||\n normalized.includes(`.${primary}.`) ||\n normalized.includes(`.${primary}-`)\n ) {\n return brand\n }\n }\n return null\n}\n\n/**\n * The few entity forms a phishing kit uses to keep a URL away from a naive\n * scanner (`.` for a dot, `&` inside a query). Decoded before links\n * are read; everything else is left alone.\n */\nfunction decodeEntities(text: string): string {\n return text\n .replace(/&#x([0-9a-f]{1,6});/gi, (_, hex: string) => safeCodePoint(parseInt(hex, 16)))\n .replace(/&#(\\d{1,7});/g, (_, dec: string) => safeCodePoint(parseInt(dec, 10)))\n .replace(/&/gi, '&')\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isFinite(value) && value > 0 && value <= 0x10ffff\n ? String.fromCodePoint(value)\n : ''\n}\n\n/** Named entities a body uses for spacing, quoting and invisible splitting. */\nconst NAMED_ENTITIES: Record<string, string> = {\n nbsp: ' ',\n ensp: ' ',\n emsp: ' ',\n thinsp: ' ',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n shy: '',\n zwnj: '',\n zwj: '',\n}\n\n/*\n * Markup is read in single forward passes, never with a pattern that can\n * rescan to the end of the body from every `<`: the screen reads every\n * tenant message, and the tenant writes the body, so a million unclosed\n * `<` must cost a linear read.\n */\n/** An element whose contents a reader never sees: the start of its opening tag. */\nconst UNSEEN_ELEMENT_OPEN = /<(style|script|head|template)\\b/gi\n/** The name at the start of a tag that breaks a line or a block: a reader sees a gap there. */\nconst BREAKING_TAG_NAME =\n /^\\/?(?:br|p|div|li|ul|ol|tr|td|th|table|tbody|thead|h[1-6]|section|article|header|footer|blockquote|hr|center)\\b/i\n/** Characters that render as nothing, used to split a word for a scanner. */\nconst INVISIBLE_CHARACTERS = /[\\u00ad\\u200b-\\u200d\\u2060\\ufeff]/g\n\n/**\n * The text between the tags, joined with NO separator — or, with `breaks`,\n * with a space where a line or block breaks. A comment is dropped; a `<`\n * with no `>` after it is text.\n */\nfunction stripMarkup(html: string, breaks: boolean): string {\n let text = ''\n let at = 0\n while (at < html.length) {\n const open = html.indexOf('<', at)\n if (open < 0) return text + html.slice(at)\n text += html.slice(at, open)\n if (html.startsWith('<!--', open)) {\n const end = html.indexOf('-->', open + 4)\n if (end < 0) return text\n at = end + 3\n continue\n }\n const close = html.indexOf('>', open + 1)\n if (close < 0) return text + html.slice(open)\n if (breaks && BREAKING_TAG_NAME.test(html.slice(open + 1, Math.min(close, open + 12)))) text += ' '\n at = close + 1\n }\n return text\n}\n\n/** The markup with every unseen element and its contents taken out, in one pass. */\nfunction dropUnseenElements(html: string): string {\n const lower = html.toLowerCase()\n const open = new RegExp(UNSEEN_ELEMENT_OPEN.source, 'gi')\n let kept = ''\n let at = 0\n // Each search starts at or after the last; what it found (or that it found\n // nothing) still answers the next one, so no stretch is scanned twice.\n const found = new Map<string, number>()\n const find = (needle: string, from: number) => {\n const known = found.get(needle)\n if (known !== undefined && (known < 0 || known >= from)) return known\n const position = lower.indexOf(needle, from)\n found.set(needle, position)\n return position\n }\n for (let match = open.exec(html); match; match = open.exec(html)) {\n // No `>` after it: neither this nor anything later is a tag.\n const tagEnd = find('>', open.lastIndex)\n if (tagEnd < 0) break\n kept += `${html.slice(at, match.index)} `\n const name = match[1].toLowerCase()\n const close = find(`</${name}`, tagEnd + 1)\n // A head nobody closed ends where the body starts, as a browser reads it.\n const body = name === 'head' ? find('<body', tagEnd + 1) : -1\n if (body >= 0 && (close < 0 || body < close)) {\n at = body\n } else {\n const end = close < 0 ? -1 : find('>', close)\n // Never closed: only the opening tag goes, and what follows is still\n // read, so an unclosed `<style>` cannot hide the lure after it.\n at = end < 0 ? tagEnd + 1 : end + 1\n }\n open.lastIndex = at\n }\n return kept + html.slice(at)\n}\n\n/**\n * What a reader SEES in a body (AGL-3453): its text with the markup taken\n * out, the way a mail client or a browser draws it.\n *\n * A kit splits every letter of a brand and a lure with a tag —\n * `G<SPAN class=…>o<SPAN …>o…gle`, `P<SPAN …>a<SPAN …>s…word E<SPAN …>x…pired`\n * — so the raw HTML never contains the word a reader reads. Tags are\n * therefore removed with NO separator, which rejoins the letters; `<style>`,\n * `<script>` and `<head>` are dropped with their contents; comments and\n * zero-width characters are dropped; entities are decoded; whitespace is\n * collapsed. A second reading that puts a space where a line or block\n * breaks is added when it differs, so two words in two paragraphs are still\n * two words. Links are NOT read from this: an href lives inside a tag.\n */\nexport function visibleTextOf(html: string | null | undefined): string {\n const text = String(html ?? '')\n const source = text.includes('<') ? dropUnseenElements(text) : text\n const read = (breaks: boolean) =>\n decodeVisibleEntities(stripMarkup(source, breaks))\n .replace(INVISIBLE_CHARACTERS, '')\n .replace(/\\s+/g, ' ')\n .trim()\n const joined = read(false)\n const broken = read(true)\n return broken === joined ? joined : `${joined}\\n${broken}`\n}\n\n/** {@link decodeEntities}, after the named entities a body reads with. */\nfunction decodeVisibleEntities(text: string): string {\n return decodeEntities(\n text.replace(/&([a-z]{2,8});/gi, (entity, name: string) => NAMED_ENTITIES[name.toLowerCase()] ?? entity),\n )\n}\n\nconst URL_PATTERN = /\\b(?:https?:\\/\\/|www\\.)[^\\s\"'<>()\\\\[\\]{}]+/gi\n\n/** The hosts every link in the text points at, lowercased. */\nexport function linkHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).match(URL_PATTERN) ?? []) {\n let rest = match.replace(/^https?:\\/\\//i, '')\n rest = rest.split(/[/?#]/)[0]\n // `https://paypal.com@evil.top/` resolves to evil.top — and the part\n // before the `@` is exactly the disguise, so it is kept as a host too.\n const at = rest.lastIndexOf('@')\n const disguise = at >= 0 ? rest.slice(0, at) : ''\n rest = at >= 0 ? rest.slice(at + 1) : rest\n for (const candidate of [rest, disguise]) {\n const host = candidate.replace(/:\\d+$/, '').replace(/\\.+$/, '').toLowerCase()\n // A merge tag inside a URL is not a host anybody can visit yet.\n if (host && host.includes('.') && !host.includes('{') && !host.includes('%7b')) {\n hosts.add(host)\n }\n }\n }\n return [...hosts]\n}\n\n/** One link: the host it goes to, and its query and fragment, URL-decoded. */\ninterface LinkTarget {\n host: string\n query: string\n fragment: string\n}\n\n/** Every link in the text, with what it carries after its path. */\nfunction linkTargetsIn(text: string): LinkTarget[] {\n const targets: LinkTarget[] = []\n for (const match of decodeEntities(String(text ?? '')).match(URL_PATTERN) ?? []) {\n // The first host is where the browser goes; a userinfo disguise is not.\n const [host] = linkHostsIn(match)\n if (!host) continue\n const hash = match.indexOf('#')\n const beforeHash = hash < 0 ? match : match.slice(0, hash)\n const question = beforeHash.indexOf('?')\n targets.push({\n host,\n query: question < 0 ? '' : decodeUrlPart(beforeHash.slice(question + 1)),\n fragment: hash < 0 ? '' : decodeUrlPart(match.slice(hash + 1)),\n })\n }\n return targets\n}\n\nfunction decodeUrlPart(part: string): string {\n try {\n return decodeURIComponent(part)\n } catch {\n return part\n }\n}\n\nconst EMAIL_ADDRESS = /^[a-z0-9._%+-]+@[a-z0-9-]+(?:\\.[a-z0-9-]+)+$/i\nconst EMAIL_ADDRESS_IN_TEXT = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\\.[a-z0-9-]+)+)/gi\nconst BASE64_TEXT = /^[a-z0-9+/_-]{8,}={0,2}$/i\n\n/** A fragment read as base64, the other way a kit carries the address, or ''. */\nfunction base64Text(fragment: string): string {\n const decode = (globalThis as { atob?: (data: string) => string }).atob\n if (!decode || !BASE64_TEXT.test(fragment)) return ''\n try {\n return decode(fragment.replace(/-/g, '+').replace(/_/g, '/'))\n } catch {\n return ''\n }\n}\n\n/**\n * Where a link carries a reader's own address (AGL-3453), or null.\n *\n * - `fragment`: the WHOLE fragment is an address, plain or base64 —\n * `…#victim@example.com`. A fragment never reaches the server; the only\n * thing that reads it is the landing page's script, filling a sign-in\n * form so the reader sees their own address and types the password.\n * - `parameter`: an address that is not the workspace's own inside a query\n * or a longer fragment — `?email=victim@example.com`, a hash route's\n * `#/login?email=…`. Ordinary in a sign-up or a SaaS login link, so softer.\n */\nfunction prefilledAddressIn(\n target: LinkTarget,\n isOwnHost: (host: string) => boolean,\n): 'fragment' | 'parameter' | null {\n const fragment = target.fragment.trim()\n if (fragment && (EMAIL_ADDRESS.test(fragment) || EMAIL_ADDRESS.test(base64Text(fragment).trim()))) {\n return 'fragment'\n }\n for (const part of [target.query, target.fragment]) {\n // An address is short; a tracking token can be kilobytes of the\n // characters an address is made of, so only the head of one is read.\n if (!part.includes('@')) continue\n for (const match of part.slice(0, 4096).matchAll(EMAIL_ADDRESS_IN_TEXT)) {\n if (!isOwnHost(match[1].toLowerCase())) return 'parameter'\n }\n }\n return null\n}\n\nconst ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\\.[a-z0-9-]+)+)/gi\n\n/** The host of every email address in the text — a reply-to, a `mailto:`. */\nfunction addressHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).matchAll(ADDRESS_HOST_PATTERN)) {\n hosts.add(match[1].toLowerCase().replace(/\\.+$/, ''))\n }\n return [...hosts]\n}\n\n/** Lowercased alphanumerics only, so \"Wells-Fargo Advisors\" contains `wellsfargo`. */\nfunction squash(text: string): string {\n return text.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\n/** Is this brand the workspace's own, by name or by a domain it holds? */\nfunction isOwnBrand(\n brand: PhishingScreenBrand,\n ownNames: string,\n ownRegistrables: ReadonlySet<string>,\n): boolean {\n const labels = [brand.id, squash(brand.label), ...brand.hostTokens.map(squash)]\n if (labels.some((label) => label && ownNames.includes(label))) return true\n return [...ownRegistrables].some((registrable) => isOfficialDomain(brand, registrable))\n}\n\n/*\n * The same judgements, for the other surfaces that read content with this\n * screen's data (`hosted-page-screen.ts`). Exported under these names rather\n * than copied, so a page and an email can never disagree about what a\n * brand's own domain is or whose brand a name is.\n */\n\n/** `squash`: lowercased alphanumerics, the form own-name matching reads. */\nexport const squashScreenText = squash\n/** Is this brand the workspace's own, by name (squashed) or by a domain it holds? */\nexport const isWorkspaceOwnBrand = isOwnBrand\n/** Is this registrable domain one of any listed brand's own? */\nexport const isAnyOfficialBrandDomain = isAnyOfficialDomain\n/** Is this registrable domain a social, maps or review link merchants ordinarily carry? */\nexport function isCommonLinkDomain(registrable: string): boolean {\n return COMMON_LINK_DOMAINS.has(registrable)\n}\n\n/**\n * Screen one outbound message. See the module header for what holds and why.\n */\nexport function screenOutboundEmail(input: PhishingScreenInput): PhishingScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const ownNames = squash((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n const isOwnHost = (host: string) =>\n ownHosts.some((own) => host === own || host.endsWith(`.${own}`))\n\n const parts = [input.subject, input.preheader, ...(input.bodies ?? [])].filter(\n (part): part is string => typeof part === 'string' && part.length > 0,\n )\n // Links are read from the raw copy, where an href lives; brands and lures\n // from what a reader sees, where a tag cannot split a word (AGL-3453).\n const copy = parts.join('\\n')\n const visibleCopy = parts.map(visibleTextOf).join('\\n')\n const replyTo = Array.isArray(input.replyTo)\n ? input.replyTo.join(' ')\n : String(input.replyTo ?? '')\n const linkHosts = linkHostsIn(copy)\n\n // 1. A lookalike host, anywhere a recipient could click, write back, or\n // read as the sender.\n const seenLookalike = new Set<string>()\n const fromAddress = String(input.fromAddress ?? '')\n for (const host of [...linkHosts, ...addressHostsIn(`${copy}\\n${replyTo}\\n${fromAddress}`)]) {\n if (seenLookalike.has(host)) continue\n const brand = lookalikeBrandForHost(host)\n if (brand && !isBrandsOwnDomain(brand, ownRegistrables)) {\n seenLookalike.add(host)\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // 2. A brand in the sender's display name that is not the workspace's.\n const fromName = String(input.fromName ?? '').trim()\n if (fromName) {\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(fromName) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({ code: 'brand-sender', brand: brand.id, fromName: fromName.slice(0, 120) })\n }\n }\n }\n\n // 3. Brand + lure + a link to somewhere that is nobody's we know.\n const isElsewhere = (host: string) => {\n if (isOwnHost(host)) return false\n const registrable = registrableDomain(host)\n return !isAnyOfficialDomain(registrable) && !COMMON_LINK_DOMAINS.has(registrable)\n }\n const lure = PHISHING_LURE_PATTERNS.map((pattern) => visibleCopy.match(pattern)?.[0])\n .find(Boolean)\n if (lure) {\n const foreign = linkHosts.find(isElsewhere)\n if (foreign) {\n const namedInCopy = `${visibleCopy}\\n${fromName}`\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(namedInCopy) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({\n code: 'brand-lure-link',\n brand: brand.id,\n lure: lure.slice(0, 120),\n host: foreign,\n })\n break\n }\n }\n }\n }\n\n // 4. An account lure beside a link elsewhere that carries the reader's own\n // address, filled in for the sign-in form at the far end (AGL-3453).\n const accountLure = ACCOUNT_LURE_PATTERNS.map((pattern) => visibleCopy.match(pattern)?.[0]).find(Boolean)\n if (accountLure) {\n let prefill: { host: string; place: 'fragment' | 'parameter' } | null = null\n for (const target of linkTargetsIn(copy)) {\n if (!isElsewhere(target.host)) continue\n const place = prefilledAddressIn(target, isOwnHost)\n if (place && (!prefill || (place === 'fragment' && prefill.place !== 'fragment'))) {\n prefill = { host: target.host, place }\n }\n }\n if (prefill) {\n signals.push({\n code: 'recipient-prefill-link',\n host: prefill.host,\n lure: accountLure.slice(0, 120),\n place: prefill.place,\n })\n }\n }\n\n return { hold: signals.length > 0, signals }\n}\n\n/** The brand's label for a stored id, for the staff surface. */\nexport function phishingScreenBrandLabel(id: string): string {\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.id === id)?.label ?? id\n}\n\n/** One line per signal, in words staff read on the review row. */\nexport function describePhishingScreenSignals(\n signals: readonly PhishingScreenSignal[],\n): string[] {\n return signals.map((signal) => {\n const brand = 'brand' in signal ? phishingScreenBrandLabel(signal.brand) : ''\n switch (signal.code) {\n case 'lookalike-link':\n return `Links to ${signal.host}, which wears the ${brand} name but is not ${brand}'s domain.`\n case 'brand-sender':\n return `Sends as \"${signal.fromName}\", naming ${brand}, which is not this workspace.`\n case 'brand-lure-link':\n return `Names ${brand}, asks the reader to act (\"${signal.lure}\"), and links to ${signal.host}.`\n case 'credential-field':\n return `Asks for a ${\n signal.field === 'card' ? 'card number' : signal.field === 'otp' ? 'one-time code' : 'password'\n } in its own field (\"${signal.label}\"), outside the platform's sign-in and checkout elements.`\n case 'brand-action-page':\n return `Names ${brand} beside a call to action (\"${signal.action}\"), and this workspace is not ${brand}.`\n case 'brand-lure-page':\n return `Names ${brand} on a page that asks visitors to act (\"${signal.lure}\") and links to ${signal.host}, and this workspace is not ${brand}.`\n case 'offsite-action-page':\n return `Its call to action (\"${signal.action}\") sends visitors off the site to ${signal.host}, on a page that reads \"${signal.lure}\".`\n case 'offsite-redirect':\n return `Redirects ${signal.source}, a path that reads \"${signal.lure}\", off the site to ${signal.host}.`\n case 'recipient-prefill-link':\n return `Links to ${signal.host} with the recipient's own address in the ${\n signal.place === 'fragment' ? 'fragment' : 'link'\n }, so a sign-in form there opens filled in, beside \"${signal.lure}\".`\n default:\n return 'Phishing signal.'\n }\n })\n}\n\n/*==========================================\n * RISK TIERS: who a signal holds for (AGL-3356, widened to every surface).\n *\n * The screen above finds signals; this decides which of them hold, the same\n * way on every surface that asks — an email at the send seam, a campaign\n * before it is claimed, a page at publish, a subdomain at creation.\n *\n * - STRONG: a lookalike host. A link to `poshmark.id63835663.shop` or\n * `paypa1.com` is a disguise whoever wears it and however old the\n * workspace is, so it holds for EVERY workspace, on every message and\n * page, transactional mail included. A merchant has no ordinary reason to\n * link to a host shaped like somebody else's brand.\n * - STRONG, too: account or password phrasing beside a link elsewhere whose\n * whole FRAGMENT is the reader's address (AGL-3453). A fragment never\n * reaches a server, so the address there is for the landing page's script\n * to fill a sign-in form with; a merchant's own reset mail links to its\n * own domain, and a SaaS login link puts the address in its query, which\n * is soft.\n * - SOFT: a brand in the sender name, the three-part lure, a brand's name\n * beside a sign-in or payment call to action on a page (in one element,\n * or anywhere on a page that carries a lure), a call to action or a\n * redirect that leaves the site beside account or document-share wording,\n * the reader's address in a query beside account phrasing.\n * Each is shaped like phishing but also like a clumsy legitimate message,\n * so they hold\n * only for a workspace in its first {@link OUTBOUND_REVIEW_YOUNG_DAYS}\n * days — the incident's window — and NEVER for mail the recipient's own\n * act made owed ({@link OutboundScreenPolicy.owed}).\n *\n * A credential-harvest page (a password, card or one-time-code field that\n * is not one of the platform's own sign-in or checkout elements) is STRONG:\n * see `hosted-page-screen.ts`.\n *=========================================*/\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport const OUTBOUND_REVIEW_YOUNG_DAYS = 14\n\n/** Which tier a signal belongs to. */\nexport type PhishingSignalTier = 'strong' | 'soft'\n\n/** The signal codes that hold for every workspace. */\nexport const STRONG_PHISHING_SIGNAL_CODES: ReadonlySet<string> = new Set([\n 'lookalike-link',\n 'credential-field',\n])\n\n/**\n * Hosts that give each customer an account subdomain named after the\n * customer: `acme.zendesk.com` is Acme's own help desk. A brand's name there is usually the brand itself, so a\n * lookalike on one of these is SOFT — held for a young workspace, never a\n * takedown of an established one's page or mail. A lookalike on any other\n * host (`poshmark.id63835663.shop`) stays STRONG.\n */\nexport const ACCOUNT_SUBDOMAIN_HOSTS: ReadonlySet<string> = new Set([\n 'vimeo.com', 'vimeocdn.com', 'zendesk.com', 'freshdesk.com',\n 'helpscoutdocs.com', 'intercom.help', 'statuspage.io', 'atlassian.net', 'myshopify.com',\n 'squarespace.com', 'wixsite.com', 'webflow.io', 'hubspotpagebuilder.com', 'substack.com',\n 'typeform.com', 'calendly.com', 'gitbook.io', 'readme.io', 'notion.site', 'github.io',\n 'cloudfront.net', 'kajabi.com', 'thinkific.com', 'teachable.com', 'mailchimpsites.com',\n])\n\nexport function phishingSignalTier(signal: {\n code: string\n host?: string\n place?: string\n}): PhishingSignalTier {\n if (signal?.code === 'recipient-prefill-link') {\n return signal.place === 'fragment' ? 'strong' : 'soft'\n }\n if (!STRONG_PHISHING_SIGNAL_CODES.has(signal?.code)) return 'soft'\n if (\n signal.code === 'lookalike-link' &&\n typeof signal.host === 'string' &&\n ACCOUNT_SUBDOMAIN_HOSTS.has(registrableDomain(signal.host))\n ) {\n return 'soft'\n }\n return 'strong'\n}\n\n/** Is a workspace of this age (days, `null` = unreadable) screened by the soft rules? */\nexport function isYoungWorkspaceAge(ageDays: number | null | undefined): boolean {\n return typeof ageDays === 'number' && Number.isFinite(ageDays) && ageDays < OUTBOUND_REVIEW_YOUNG_DAYS\n}\n\nexport interface OutboundScreenPolicy {\n /**\n * The workspace's age in days, or `null` when its creation date cannot be\n * read — an org that predates the field, which is an EXISTING customer and\n * is not young.\n */\n ageDays: number | null\n /**\n * The recipient's own act made this message owed: a receipt for their\n * order, a confirmation of their booking, a reset of their password. The\n * soft rules never hold it. The lookalike rule still does — a receipt that\n * links to a Poshmark lookalike is the disguise, not the receipt.\n */\n owed?: boolean\n}\n\n/** The signals that HOLD under the tiers, for this workspace and message. */\nexport function signalsThatHold<T extends { code: string; host?: string }>(\n signals: readonly T[],\n policy: OutboundScreenPolicy,\n): T[] {\n const soft = isYoungWorkspaceAge(policy.ageDays) && !policy.owed\n return signals.filter((signal) => phishingSignalTier(signal) === 'strong' || soft)\n}\n\n/**\n * The same brand, lookalike and tier logic applied to a HOST NAME a workspace\n * asks for — a subdomain on the tenant apex, or a custom domain it attaches.\n *\n * Words a brand's impersonator hyphen-joins to its name. A subdomain that\n * joins a brand's word to one of these (`booking-review`, `apple-support`,\n * `amazon-account`) is the incident's shape even where the brand's word is\n * an ordinary one — which the lookalike rule alone deliberately lets pass,\n * so a salon can be `tanyas-booking` and a bakery `apple-pie-co`.\n */\nexport const BRAND_IMPERSONATION_WORDS: ReadonlySet<string> = new Set([\n 'account', 'accounts', 'auth', 'billing', 'help', 'helpdesk', 'id', 'login',\n 'logon', 'order', 'orders', 'pay', 'payment', 'payments', 'refund', 'resolution',\n 'review', 'reviews', 'secure', 'security', 'seller', 'service', 'signin',\n 'support', 'update', 'verify', 'verification', 'wallet',\n])\n\n/**\n * The brand a requested subdomain label wears, or null.\n *\n * Refused for EVERY workspace, at creation and at rename — the tier is\n * strong, because nothing is lost by asking a real bakery to pick\n * `apple-pie-co` instead of `apple-support`, and a brand's name on our apex\n * is a disguise that outlives every later screen. Two shapes:\n *\n * 1. the label is a lookalike host label ({@link lookalikeBrandForHost} on\n * `<label>.<apex>`): `poshmark`, `paypal-secure`, `paypa1`, `appleid`;\n * 2. a brand's word hyphen-joined to an impersonation word\n * ({@link BRAND_IMPERSONATION_WORDS}): `booking-review`, `apple-support`.\n */\nexport function brandForSubdomainLabel(\n label: string,\n apex = 'example.invalid',\n): PhishingScreenBrand | null {\n const normalized = String(label ?? '').trim().toLowerCase()\n if (!normalized) return null\n const byHost = lookalikeBrandForHost(`${normalized}.${apex}`)\n if (byHost) return byHost\n const parts = foldLeet(normalized).split('-').filter(Boolean)\n if (parts.length < 2 || !parts.some((part) => BRAND_IMPERSONATION_WORDS.has(part))) {\n return null\n }\n for (const brand of PHISHING_SCREEN_BRANDS) {\n const words = [brand.id, ...brand.hostTokens].filter((word) => !word.includes('-'))\n if (parts.some((part) => words.includes(part))) return brand\n }\n return null\n}\n\n/**\n * The platform's own entry in {@link PHISHING_SCREEN_BRANDS} (AGL-3365). A\n * workspace owns every other brand by carrying its name or domain; the\n * platform's is owned only by the platform's staff, because a workspace NAMED\n * for the platform is the cheapest disguise there is.\n */\nexport const PLATFORM_PHISHING_BRAND_ID = 'aglyn'\n\n/** The platform's brand entry. */\nexport function platformPhishingBrand(): PhishingScreenBrand {\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.id === PLATFORM_PHISHING_BRAND_ID) as PhishingScreenBrand\n}\n\n/**\n * Words that make a name claim to SPEAK FOR a brand rather than mention it:\n * \"PayPal Support\", \"Aglyn Official\", \"Apple ID Security Team\".\n */\nconst OFFICIAL_CLAIM_PATTERN =\n /\\b(?:official|staff|team|verified|certified|support|helpdesk|admin|administrator|security|billing|trust|safety|compliance|core|headquarters|hq)\\b/i\n\n/**\n * The brand a display NAME claims to speak for (AGL-3365), or null.\n *\n * For names a stranger reads as an identity — a marketplace publisher, a\n * listing's title — rather than copy. A name that merely mentions a brand\n * (\"Sync for PayPal\", \"Bookings for Airbnb hosts\") passes: that is how an\n * integration is named. A name that pairs the brand with a claim of\n * officialness (\"PayPal Support\", \"Aglyn Official Plugins\") is the disguise.\n */\nexport function brandClaimedByName(name: string): PhishingScreenBrand | null {\n const text = String(name ?? '')\n if (!text.trim() || !OFFICIAL_CLAIM_PATTERN.test(text)) return null\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.mention.test(text)) ?? null\n}\n"],"names":["PHISHING_SCREEN_BRANDS","id","label","mention","hostTokens","officialDomains","countryDomains","wordToken","hostedDomains","ACCOUNT_LURE_PATTERNS","DOCUMENT_SHARE_LURE_PATTERNS","SALE_AND_PARCEL_LURE_PATTERNS","PHISHING_LURE_PATTERNS","COMMON_LINK_DOMAINS","Set","TWO_LABEL_SUFFIXES","registrableDomain","host","labels","String","toLowerCase","replace","split","filter","Boolean","length","join","lastTwo","slice","has","splitRegistrable","registrable","dot","indexOf","suffix","isOfficialDomain","brand","includes","base","test","isBrandsOwnDomain","ownRegistrables","some","isAnyOfficialDomain","foldLeet","labelNamesBrand","token","candidate","parts","tokenParts","at","every","part","offset","MEDIA_EMBED_HOSTS","lookalikeBrandForHost","normalized","primary","startsWith","decodeEntities","text","_","hex","safeCodePoint","parseInt","dec","value","Number","isFinite","fromCodePoint","NAMED_ENTITIES","nbsp","ensp","emsp","thinsp","lt","gt","quot","apos","shy","zwnj","zwj","UNSEEN_ELEMENT_OPEN","BREAKING_TAG_NAME","INVISIBLE_CHARACTERS","stripMarkup","html","breaks","open","end","close","Math","min","dropUnseenElements","lower","RegExp","source","kept","found","Map","find","needle","from","known","get","undefined","position","set","match","exec","tagEnd","lastIndex","index","name","body","visibleTextOf","read","decodeVisibleEntities","trim","joined","broken","entity","URL_PATTERN","linkHostsIn","hosts","rest","lastIndexOf","disguise","add","linkTargetsIn","targets","hash","beforeHash","question","push","query","decodeUrlPart","fragment","decodeURIComponent","EMAIL_ADDRESS","EMAIL_ADDRESS_IN_TEXT","BASE64_TEXT","base64Text","decode","globalThis","atob","prefilledAddressIn","target","isOwnHost","matchAll","ADDRESS_HOST_PATTERN","addressHostsIn","squash","isOwnBrand","ownNames","map","squashScreenText","isWorkspaceOwnBrand","isAnyOfficialBrandDomain","isCommonLinkDomain","screenOutboundEmail","input","signals","ownHosts","ownDomains","domain","own","endsWith","subject","preheader","bodies","copy","visibleCopy","replyTo","Array","isArray","linkHosts","seenLookalike","fromAddress","code","fromName","isElsewhere","lure","pattern","foreign","namedInCopy","accountLure","prefill","place","hold","phishingScreenBrandLabel","describePhishingScreenSignals","signal","field","action","OUTBOUND_REVIEW_YOUNG_DAYS","STRONG_PHISHING_SIGNAL_CODES","ACCOUNT_SUBDOMAIN_HOSTS","phishingSignalTier","isYoungWorkspaceAge","ageDays","signalsThatHold","policy","soft","owed","BRAND_IMPERSONATION_WORDS","brandForSubdomainLabel","apex","byHost","words","word","PLATFORM_PHISHING_BRAND_ID","platformPhishingBrand","OFFICIAL_CLAIM_PATTERN","brandClaimedByName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-phishing-screen.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * A fraud actor signed up, paid with a card that never met 3-D Secure, and\n * used a workspace to send two kinds of mail: a campaign of Booking.com-style\n * \"guest feedback regarding your property\" lures to hotels, and a workflow\n * that mailed \"Poshmark Order — one of the items from your Seller Account has\n * finally sold\", linking to `poshmark.id63835663.shop`.\n *\n * This module answers one question about a message a YOUNG workspace is\n * about to send: does its content carry a STRONG phishing signal? The caller\n * decides who is young and what happens to a positive answer — the answer is\n * a hold for staff review, never a drop.\n *\n * ## Conservative by construction\n *\n * A false positive costs a real merchant's first campaign a wait for a\n * human; a miss costs strangers their bank logins. Both are real, so only\n * four shapes hold, and each is one a legitimate merchant essentially never\n * produces:\n *\n * 1. **A lookalike link** — a link or reply address whose host carries a\n * brand's name without being the brand's own domain: the brand as a label\n * of an unrelated registrable domain (`poshmark.id63835663.shop`), hyphen-\n * joined to one (`paypal-secure.com`, `secure-paypal.net`), the brand's\n * real domain embedded in a longer host (`booking.com.guest-review.top`),\n * or leetspeak for it (`paypa1.com`). Holds on its own.\n * 2. **A brand in the sender's display name** that is not the workspace's\n * own name — mail that says it is FROM PayPal.\n * 3. **The three-part lure**: a brand named in the copy, AND credential or\n * account-action phrasing (\"verify your account\", \"has finally sold\",\n * \"feedback regarding your property\"), AND a link to a domain that is\n * neither the brand's, nor the workspace's, nor a common social or maps\n * link. Any two of the three are ordinary marketing — \"our book is on\n * Amazon\", \"sign in to your account\", a link to the shop — and pass.\n * 4. **A prefilled sign-in** (AGL-3453): account or password phrasing\n * (\"your password expires today\") beside a link to such a domain that\n * carries the reader's own address, so the harvester's form opens with\n * it filled in: `…/ss/c/…#victim@example.com`.\n *\n * Mentioning a brand, alone, never holds. Brands and lures are read from\n * what the reader SEES ({@link visibleTextOf}), never from the raw HTML, in\n * which a kit splits every letter with a tag; links are read from the raw\n * HTML, where an href lives.\n *\n * ## The brand list is small on purpose\n *\n * The brands phishing kits most often wear, plus the two in the incident, and\n * the file-share and mail-security names a document-share lure wears — a\n * \"Secure Document Access Portal\" under Proofpoint's or SharePoint's name\n * whose one button leaves for the kit (AGL-3447). A brand is added when it\n * has been seen impersonated, not because it is big: every entry is a set of\n * words that can now hold somebody's newsletter.\n * Each carries its REAL domains, so a link to the brand itself is never\n * suspicious, and host tokens chosen so a common word (`booking`, `apple`,\n * `outlook`, `box`, `adobe`) does not match an unrelated host by itself.\n *\n * Pure: no DNS, no store, no clock. Everything the caller knows about the\n * workspace arrives as input.\n *=========================================*/\n\n/** One brand the screen recognizes. */\nexport interface PhishingScreenBrand {\n /** Stable id, as stored on a hold. */\n id: string\n /** How staff read it. */\n label: string\n /** Copy that names the brand — subject, body, sender. Case-insensitive. */\n mention: RegExp\n /**\n * Host labels that name the brand. Matched against each dot-separated\n * label of a link's host, whole or hyphen-joined, after leetspeak is\n * folded — never as a bare substring, which is how `amazonaws` or\n * `bookingengine` would read as a brand.\n */\n hostTokens: readonly string[]\n /**\n * Registrable domains the brand genuinely links from. The FIRST is its\n * primary domain, the one a kit embeds in a longer host.\n */\n officialDomains: readonly string[]\n /**\n * The brand's name is an ordinary word (`apple`, `amazon`), so a host\n * label names the brand only when it IS the word — `apple.example.top` —\n * and never hyphen-joined or leet-folded, which `apple-orchard-farm.com`\n * would otherwise be.\n */\n wordToken?: boolean\n /**\n * Whether `<brand>.<country code>` (and `<brand>.co.<cc>`) is the brand's\n * too — true for the marketplaces that run a site per country.\n */\n countryDomains?: boolean\n /**\n * Registrable domains the brand runs but where OTHER people's content lives —\n * the platform's own tenant apex, where every site is `<name>.<apex>`. A\n * host there never wears the brand as a disguise, so the lookalike rule\n * skips it; but it is not the brand's own domain either, so a link to a\n * neighbor's site still counts as \"somewhere else\" to the lure rules, and a\n * workspace hosted there does not own the brand by being hosted there.\n */\n hostedDomains?: readonly string[]\n}\n\nexport const PHISHING_SCREEN_BRANDS: readonly PhishingScreenBrand[] = [\n {\n id: 'poshmark',\n label: 'Poshmark',\n mention: /\\bposhmark\\b/i,\n hostTokens: ['poshmark'],\n officialDomains: ['poshmark.com', 'posh.mk'],\n },\n {\n id: 'booking',\n label: 'Booking.com',\n // Never the bare word: \"booking\" is every hotel's and every salon's.\n mention: /\\bbooking\\.com\\b/i,\n hostTokens: ['bookingcom', 'booking-com'],\n officialDomains: ['booking.com', 'bstatic.com'],\n },\n {\n id: 'airbnb',\n label: 'Airbnb',\n mention: /\\bairbnb\\b/i,\n hostTokens: ['airbnb'],\n officialDomains: ['airbnb.com', 'abnb.me', 'muscache.com'],\n countryDomains: true,\n },\n {\n id: 'paypal',\n label: 'PayPal',\n mention: /\\bpay\\s?pal\\b/i,\n hostTokens: ['paypal'],\n officialDomains: ['paypal.com', 'paypal.me', 'paypalobjects.com'],\n countryDomains: true,\n },\n {\n id: 'amazon',\n label: 'Amazon',\n mention: /\\bamazon\\b/i,\n hostTokens: ['amazon', 'amzn'],\n wordToken: true,\n officialDomains: [\n 'amazon.com',\n 'amzn.to',\n 'amzn.com',\n 'amazonaws.com',\n 'media-amazon.com',\n 'ssl-images-amazon.com',\n ],\n countryDomains: true,\n },\n {\n id: 'apple',\n label: 'Apple',\n // The fruit is on every bakery's menu; the account is what gets phished.\n mention: /\\bapple\\s?(?:id|support|account)\\b|\\bicloud\\b|\\bitunes\\b/i,\n hostTokens: ['apple', 'appleid', 'icloud', 'itunes'],\n wordToken: true,\n officialDomains: ['apple.com', 'icloud.com', 'apple.co', 'itunes.com', 'me.com', 'mzstatic.com'],\n countryDomains: true,\n },\n {\n id: 'microsoft',\n label: 'Microsoft',\n // Outlook only as the product — \"our 2026 outlook\" is every advisor's\n // newsletter.\n mention:\n /\\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,\n hostTokens: [\n 'microsoft',\n 'microsoft365',\n 'office365',\n 'office-365',\n 'outlook365',\n 'onedrive',\n 'sharepoint',\n 'microsoftonline',\n 'msonline',\n ],\n officialDomains: [\n 'microsoft.com',\n 'office.com',\n 'office365.com',\n 'microsoft365.com',\n 'live.com',\n 'outlook.com',\n 'microsoftonline.com',\n 'sharepoint.com',\n 'sharepointonline.com',\n 'onedrive.com',\n '1drv.ms',\n 'aka.ms',\n ],\n },\n {\n id: 'docusign',\n label: 'DocuSign',\n mention: /\\bdocu\\s?sign\\b/i,\n hostTokens: ['docusign'],\n officialDomains: ['docusign.com', 'docusign.net'],\n },\n {\n id: 'google',\n label: 'Google',\n // The account and the files, never the bare name: \"find us on Google\"\n // and \"Google Maps\" are on every shop's contact page.\n mention: /\\bgoogle\\s?(?:drive|docs?|sheets|slides|workspace|account)\\b|\\bgmail\\b|\\bg\\s?suite\\b/i,\n // The products and the account, never the bare name: Google runs\n // hyphenated hosts of its own that a page embeds, and a bare `google`\n // token would read each as its lookalike. `docs.google.com.<kit>` is\n // still read, by the primary-domain rule.\n hostTokens: [\n 'gmail',\n 'gsuite',\n 'googledocs',\n 'googledrive',\n 'google-docs',\n 'google-drive',\n 'google-workspace',\n 'google-account',\n 'google-login',\n 'google-mail',\n 'docs-google',\n 'drive-google',\n ],\n officialDomains: [\n 'google.com',\n 'goo.gl',\n 'g.co',\n 'g.page',\n 'forms.gle',\n 'gmail.com',\n 'googleapis.com',\n 'gstatic.com',\n 'googleusercontent.com',\n 'withgoogle.com',\n ],\n countryDomains: true,\n },\n {\n id: 'adobe',\n label: 'Adobe',\n // \"Adobe\" alone is a house, a grill and an inn across the Southwest;\n // the products are what a lure names.\n mention:\n /\\badobe\\s?(?:acrobat|sign|id|account|document\\s?cloud|reader|pdf|creative\\s?cloud)\\b|\\bacrobat\\s?(?:reader|sign|pro)\\b|\\bechosign\\b/i,\n hostTokens: ['adobe', 'adobeid', 'adobesign', 'adobeacrobat', 'echosign'],\n wordToken: true,\n officialDomains: [\n 'adobe.com',\n 'adobe.io',\n 'adobe.ly',\n 'adobelogin.com',\n 'adobesign.com',\n 'echosign.com',\n 'acrobat.com',\n 'typekit.net',\n 'adobedtm.com',\n ],\n },\n {\n id: 'dropbox',\n label: 'Dropbox',\n // One word only: a \"drop box\" is by every library's door.\n mention: /\\bdropbox\\b/i,\n hostTokens: ['dropbox'],\n officialDomains: ['dropbox.com', 'db.tt', 'dropboxusercontent.com', 'dropboxstatic.com', 'hellosign.com'],\n },\n {\n // Never the bare word, as an id either — `brandForSubdomainLabel` reads\n // ids as words, and \"box\" is every lunch box's and subscription box's.\n id: 'boxcom',\n label: 'Box',\n mention: /\\bbox\\.com\\b|\\bbox\\s+drive\\b/i,\n hostTokens: ['boxcom', 'box-com'],\n officialDomains: ['box.com', 'box.net', 'boxcdn.net', 'boxcloud.com'],\n },\n {\n id: 'wetransfer',\n label: 'WeTransfer',\n // One word only: \"we transfer\" is a moving company's sentence.\n mention: /\\bwetransfer\\b/i,\n hostTokens: ['wetransfer'],\n officialDomains: ['wetransfer.com', 'we.tl'],\n },\n {\n id: 'sharefile',\n label: 'ShareFile',\n mention: /\\bshare\\s?file\\b/i,\n hostTokens: ['sharefile'],\n officialDomains: ['sharefile.com', 'sharefile.eu', 'citrix.com'],\n },\n {\n // A secure-mail gateway's name is what makes a document lure read as\n // safe: \"Proofpoint Encryption for your sensitive documents\".\n id: 'proofpoint',\n label: 'Proofpoint',\n // One word only: a \"proof point\" is every pitch deck's.\n mention: /\\bproofpoint\\b/i,\n hostTokens: ['proofpoint'],\n officialDomains: ['proofpoint.com', 'urldefense.com', 'pphosted.com', 'ppe-hosted.com'],\n },\n {\n id: 'mimecast',\n label: 'Mimecast',\n mention: /\\bmimecast\\b/i,\n hostTokens: ['mimecast'],\n officialDomains: ['mimecast.com', 'mimecastprotect.com'],\n },\n {\n id: 'netflix',\n label: 'Netflix',\n mention: /\\bnetflix\\b/i,\n hostTokens: ['netflix'],\n officialDomains: ['netflix.com', 'nflxext.com', 'nflximg.net'],\n },\n {\n id: 'wellsfargo',\n label: 'Wells Fargo',\n mention: /\\bwells\\s?fargo\\b/i,\n hostTokens: ['wellsfargo', 'wells-fargo'],\n officialDomains: ['wellsfargo.com'],\n },\n {\n id: 'bankofamerica',\n label: 'Bank of America',\n mention: /\\bbank\\s+of\\s+america\\b/i,\n hostTokens: ['bankofamerica', 'bofa'],\n officialDomains: ['bankofamerica.com', 'bofa.com'],\n },\n {\n id: 'coinbase',\n label: 'Coinbase',\n mention: /\\bcoinbase\\b/i,\n hostTokens: ['coinbase'],\n officialDomains: ['coinbase.com'],\n },\n {\n id: 'usps',\n label: 'USPS',\n mention: /\\busps\\b/i,\n hostTokens: ['usps'],\n officialDomains: ['usps.com'],\n },\n {\n id: 'fedex',\n label: 'FedEx',\n mention: /\\bfed\\s?ex\\b/i,\n hostTokens: ['fedex'],\n officialDomains: ['fedex.com'],\n },\n {\n id: 'dhl',\n label: 'DHL',\n mention: /\\bdhl\\b/i,\n hostTokens: ['dhl'],\n officialDomains: ['dhl.com'],\n countryDomains: true,\n },\n {\n // The platform itself (AGL-3365). A marketplace listing, a page or an\n // email that wears the platform's name — \"Aglyn Support\", a link to\n // `aglyn-billing.com`, \"official Aglyn plugin\" — is the one impersonation\n // every customer is primed to trust. The workspace named for it (the\n // platform's own org) owns it by name, as any brand's own workspace does.\n id: 'aglyn',\n label: 'Aglyn',\n mention: /\\baglyn\\b/i,\n hostTokens: ['aglyn'],\n officialDomains: ['aglyn.com', 'aglyn.io', 'aglyn.dev'],\n hostedDomains: ['aglyn.app'],\n },\n]\n\n/**\n * Account and credential phrasing: the lure that asks the reader to DO\n * something with an account — verify it, confirm it, sign in to it.\n */\nexport const ACCOUNT_LURE_PATTERNS: readonly RegExp[] = [\n /\\bverify\\s+your\\s+(?:account|identity|information|details|payment|email)\\b/i,\n /\\bconfirm\\s+your\\s+(?:account|identity|details|payment|password|billing|information)\\b/i,\n /\\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,\n /\\bunusual\\s+(?:sign[-\\s]?in|login|activity)\\b/i,\n /\\bupdate\\s+your\\s+(?:payment|billing|card|account)\\s*(?:details|information|method)?\\b/i,\n /\\b(?:sign|log)\\s?in\\s+to\\s+(?:your|view|confirm|claim|release)\\b/i,\n // \"Password Expired\", \"your password expires today\", \"will expire\".\n /\\bpassword\\s+(?:has\\s+|will\\s+)?expire/i,\n /\\bkeep\\s+(?:your|my|the)\\s+(?:current\\s+|active\\s+|existing\\s+|same\\s+)?password\\b/i,\n /\\bchange\\s+(?:your\\s+)?password\\s+settings?\\b/i,\n /\\bmailbox\\s+(?:storage\\s+|quota\\s+)?(?:is\\s+)?(?:almost\\s+|nearly\\s+)?(?:full|exceeded)\\b/i,\n /\\bsecurity\\s+alert\\b/i,\n]\n\n/**\n * Document-share phrasing (AGL-3447): the lure that says a file is waiting\n * behind a secure door — \"Secure Document Access Portal\", \"Continue to\n * Document\", \"shared a file with you\", \"encrypted message\". The credential\n * harvest is on the far side of the button, so these are the words the page\n * or the mail itself carries.\n */\nexport const DOCUMENT_SHARE_LURE_PATTERNS: readonly RegExp[] = [\n /\\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,\n /\\b(?:view|access|open)\\s+(?:the\\s+|your\\s+)?secure\\s+(?:file|document|message)s?\\b/i,\n /\\bsecure\\s+(?:file|document|message)s?\\b/i,\n /\\bdocument\\s+access\\b/i,\n /\\baccess\\s+portal\\b/i,\n /\\b(?:continue|proceed)\\s+to\\s+(?:the\\s+|your\\s+)?(?:document|file)s?\\b/i,\n /\\bshared\\s+(?:a|an|the)\\s+(?:file|document|folder)\\s+with\\s+you\\b/i,\n /\\b(?:file|document|folder)s?\\s+(?:has\\s+been|have\\s+been|was|were)\\s+shared\\s+with\\s+you\\b/i,\n /\\bencrypted\\s+(?:message|document|file|email)s?\\b/i,\n]\n\n/**\n * Marketplace, booking and parcel phrasing: the lure that says a sale, a\n * guest or a delivery needs the reader. The incident's Poshmark and\n * Booking.com mail.\n */\nconst SALE_AND_PARCEL_LURE_PATTERNS: readonly RegExp[] = [\n /\\b(?:has|have)\\s+(?:finally\\s+)?sold\\b/i,\n /\\bseller\\s+account\\b/i,\n /\\b(?:guest|customer)\\s+(?:complaint|review|feedback)\\b/i,\n /\\bfeedback\\s+regarding\\s+your\\s+(?:property|listing|stay|booking|reservation)\\b/i,\n /\\bclaim\\s+your\\s+(?:refund|reward|prize|package|payment|funds)\\b/i,\n /\\b(?:delivery|shipment)\\s+(?:failed|attempt|suspended|on\\s+hold)\\b/i,\n /\\b(?:package|parcel)\\s+(?:is\\s+)?(?:on\\s+hold|held|awaiting|undeliverable)\\b/i,\n]\n\n/**\n * Every lure — the half of the three-part lure that asks the reader to act.\n * Only ever counted beside a brand AND a foreign link; alone, several of\n * these are ordinary transactional copy.\n */\nexport const PHISHING_LURE_PATTERNS: readonly RegExp[] = [\n ...ACCOUNT_LURE_PATTERNS,\n ...DOCUMENT_SHARE_LURE_PATTERNS,\n ...SALE_AND_PARCEL_LURE_PATTERNS,\n]\n\n/**\n * Link targets a merchant's mail ordinarily carries that are not theirs:\n * social profiles, maps, review sites, video. Counted as neither foreign nor\n * suspicious in the three-part lure, so \"leave us a review on Google\" with a\n * brand named elsewhere does not become a hold. Never consulted by the\n * lookalike check, which holds on the host's own shape.\n */\nconst COMMON_LINK_DOMAINS: ReadonlySet<string> = new Set([\n 'facebook.com',\n 'fb.com',\n 'instagram.com',\n 'x.com',\n 'twitter.com',\n 'linkedin.com',\n 'youtube.com',\n 'youtu.be',\n 'tiktok.com',\n 'pinterest.com',\n 'google.com',\n 'goo.gl',\n 'g.page',\n 'yelp.com',\n 'tripadvisor.com',\n 'wa.me',\n 'whatsapp.com',\n])\n\n/**\n * Two-label public suffixes, so `evil.co.uk` registers as `evil.co.uk` and\n * not as `co.uk`. Deliberately a short list of the ones mail actually\n * arrives from rather than the whole public suffix list: a suffix missing\n * here makes the registrable domain one label too short, which can only\n * widen what counts as \"the same domain\" for a link — never invent a brand\n * label that is not in the host.\n */\nconst TWO_LABEL_SUFFIXES: ReadonlySet<string> = new Set([\n 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk',\n 'com.au', 'net.au', 'org.au',\n 'co.nz', 'co.jp', 'co.kr', 'co.in', 'co.za', 'co.il', 'co.id', 'co.th',\n 'com.br', 'com.mx', 'com.ar', 'com.tr', 'com.cn', 'com.hk', 'com.sg',\n 'com.tw', 'com.my', 'com.ph', 'com.co', 'com.pe', 'com.sa', 'com.eg',\n])\n\n/** A signal the screen found. */\nexport type PhishingScreenSignal =\n | { code: 'lookalike-link'; brand: string; host: string }\n | { code: 'brand-sender'; brand: string; fromName: string }\n | { code: 'brand-lure-link'; brand: string; lure: string; host: string }\n /**\n * A published page asks for a password, a card number or a one-time code\n * in a field that is not one of the platform's own sign-in or checkout\n * elements (`hosted-page-screen.ts`). Strong.\n */\n | { code: 'credential-field'; field: 'password' | 'card' | 'otp'; label: string }\n /**\n * A published page names a brand that is not the workspace's beside a\n * sign-in, verify or payment call to action (`hosted-page-screen.ts`).\n * Soft.\n */\n | { code: 'brand-action-page'; brand: string; action: string }\n /**\n * The same, read over the page as a WHOLE (AGL-3447): a brand that is not\n * the workspace's anywhere on a page that carries an account or\n * document-share lure anywhere, or whose call to action leaves the site\n * worded as one, and that links away (`hosted-page-screen.ts`). Soft.\n */\n | { code: 'brand-lure-page'; brand: string; lure: string; host: string }\n /**\n * A published page's call to action — its only one, or one worded as an\n * account or document action — sends visitors to a host that is not the\n * site's, a listed brand's or a common link, on a page that carries an\n * account or document-share lure (`hosted-page-screen.ts`, AGL-3447). No\n * brand needed. Soft.\n */\n | { code: 'offsite-action-page'; action: string; lure: string; host: string }\n /**\n * A site redirect whose path reads as an account or document-share lure\n * sends visitors to such a host (`screenSiteRedirect`, AGL-3447). Soft.\n */\n | { code: 'offsite-redirect'; source: string; lure: string; host: string }\n /**\n * Account or password phrasing beside a link elsewhere that carries the\n * reader's own address (AGL-3453). STRONG when the address is the whole\n * fragment (`#victim@example.com`), which only a landing page's script\n * reads; soft when it is a query parameter or part of a longer fragment.\n * The address itself is never stored.\n */\n | { code: 'recipient-prefill-link'; host: string; lure: string; place: 'fragment' | 'parameter' }\n /**\n * A link, embed, redirect or form action whose host Google Web Risk lists\n * as phishing, malware or unwanted software (AGL-3451,\n * `link-reputation.ts`). Strong: a harvester already known to be bad\n * anywhere is bad whatever page or message surrounds it.\n */\n | { code: 'web-risk-link'; host: string; threats: string[] }\n\nexport interface PhishingScreenInput {\n subject?: string | null\n /** The display name in front of the sending address. */\n fromName?: string | null\n /**\n * The ADDRESS the message leaves from (AGL-3362) — a `From:` domain is a\n * link the recipient never has to click, so its host is read by the\n * lookalike rule exactly like a link's: mail from `support@paypa1.com` is\n * the disguise whoever sends it. Not an own domain for the lure's \"is this\n * elsewhere\" test either way; it only ever adds a lookalike signal.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n /**\n * Everything else the recipient reads or clicks: the body, the plain-text\n * part, a design's nodes serialized — any string. Links are found by\n * shape, so a JSON-encoded design is as readable here as HTML.\n */\n bodies?: readonly (string | null | undefined)[]\n /**\n * The workspace's own names — org, site, subdomain. A brand in one of them\n * is the workspace's own, and `brand-sender` / `brand-lure-link` do not\n * fire for it. The lookalike check does not consult names: a workspace\n * NAMED \"Poshmark\" linking to `poshmark.id63835663.shop` is the incident.\n */\n ownNames?: readonly (string | null | undefined)[]\n /**\n * The workspace's own hosts — its subdomain on the tenant apex, custom\n * domain, sending domain. A link to one of them, or below one, is never\n * the \"somewhere else\" of a lure. Matched by host, never by registrable\n * domain: every site's subdomain shares the tenant apex, and a link to a\n * NEIGHBOR's site is somewhere else.\n *\n * Not an exemption from the lookalike check. A host shaped like\n * `paypal-secure.com` is a lookalike whoever attached it, and a workspace\n * that attached it has attached the disguise.\n */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\nexport interface PhishingScreenVerdict {\n /** True when the message should be held for staff review. */\n hold: boolean\n signals: PhishingScreenSignal[]\n}\n\n/** The registrable domain of a host: `a.b.evil.co.uk` → `evil.co.uk`. */\nexport function registrableDomain(host: string): string {\n const labels = String(host ?? '')\n .toLowerCase()\n .replace(/\\.+$/, '')\n .split('.')\n .filter(Boolean)\n if (labels.length <= 2) return labels.join('.')\n const lastTwo = labels.slice(-2).join('.')\n return TWO_LABEL_SUFFIXES.has(lastTwo)\n ? labels.slice(-3).join('.')\n : lastTwo\n}\n\n/** `<label>.<suffix>` of a registrable domain, split once. */\nfunction splitRegistrable(registrable: string): { label: string; suffix: string } {\n const dot = registrable.indexOf('.')\n return dot < 0\n ? { label: registrable, suffix: '' }\n : { label: registrable.slice(0, dot), suffix: registrable.slice(dot + 1) }\n}\n\nfunction isOfficialDomain(brand: PhishingScreenBrand, registrable: string): boolean {\n if (brand.officialDomains.includes(registrable)) return true\n if (!brand.countryDomains) return false\n const base = splitRegistrable(brand.officialDomains[0]).label\n const { label, suffix } = splitRegistrable(registrable)\n return label === base && (/^[a-z]{2}$/.test(suffix) || TWO_LABEL_SUFFIXES.has(suffix))\n}\n\n/**\n * Is the workspace the brand itself, by DOMAIN: one of its own domains is\n * one of the brand's official ones (Aglyn's site on aglyn.com linking to\n * its own account on a video or help-desk vendor). Deliberately not by name — a workspace can call itself\n * \"Poshmark\", but it cannot serve on poshmark.com.\n */\nexport function isBrandsOwnDomain(\n brand: PhishingScreenBrand,\n ownRegistrables: ReadonlySet<string>,\n): boolean {\n return [...ownRegistrables].some((registrable) => isOfficialDomain(brand, registrable))\n}\n\n/** Every brand's real domains, for \"is this link just the brand itself\". */\nfunction isAnyOfficialDomain(registrable: string): boolean {\n return PHISHING_SCREEN_BRANDS.some((brand) => isOfficialDomain(brand, registrable))\n}\n\n/** Leetspeak folded, so `paypa1` reads as `paypal`. */\nfunction foldLeet(label: string): string {\n return label\n .replace(/0/g, 'o')\n .replace(/1/g, 'l')\n .replace(/3/g, 'e')\n .replace(/4/g, 'a')\n .replace(/5/g, 's')\n .replace(/7/g, 't')\n}\n\n/** Does one host label name the brand — whole, or hyphen-joined? */\nfunction labelNamesBrand(label: string, token: string, wordToken: boolean): boolean {\n if (wordToken) return label === token\n for (const candidate of new Set([label, foldLeet(label)])) {\n if (candidate === token) return true\n const parts = candidate.split('-')\n // A hyphen-joined token (`booking-com`) spans parts, so it is matched\n // as a run of parts rather than one of them.\n const tokenParts = token.split('-')\n for (let at = 0; at + tokenParts.length <= parts.length; at += 1) {\n if (tokenParts.every((part, offset) => parts[at + offset] === part)) {\n return true\n }\n }\n }\n return false\n}\n\n/**\n * Video and audio platforms whose account subdomains and embed URLs a page\n * or email plays media from: `player.vimeo.com`, `www.youtube.com/embed/…`.\n * A video plugin's own hosts are the plugin's to declare, not listed here. Anyone may embed or link media from these, so a\n * host on one is never a lookalike, whatever name its subdomain carries.\n */\nexport const MEDIA_EMBED_HOSTS: ReadonlySet<string> = new Set([\n 'vimeo.com', 'vimeocdn.com', 'youtube.com', 'youtu.be',\n 'youtube-nocookie.com', 'ytimg.com', 'loom.com', 'vidyard.com', 'brightcove.net',\n 'brightcove.com', 'jwplayer.com', 'jwpcdn.com', 'mux.com', 'cloudflarestream.com',\n 'videodelivery.net', 'dailymotion.com', 'twitch.tv', 'spotify.com', 'soundcloud.com',\n 'podbean.com', 'buzzsprout.com', 'simplecast.com', 'transistor.fm', 'anchor.fm',\n])\n\n/** The brand a host impersonates, or null. */\nexport function lookalikeBrandForHost(host: string): PhishingScreenBrand | null {\n const normalized = String(host ?? '').toLowerCase().replace(/\\.+$/, '')\n if (!normalized.includes('.')) return null\n const registrable = registrableDomain(normalized)\n if (MEDIA_EMBED_HOSTS.has(registrable)) return null\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOfficialDomain(brand, registrable)) continue\n if (brand.hostedDomains?.includes(registrable)) continue\n const labels = normalized.split('.')\n if (\n labels.some((label) =>\n brand.hostTokens.some((token) => labelNamesBrand(label, token, brand.wordToken === true)),\n )\n ) {\n return brand\n }\n // The brand's primary domain embedded in a longer, different one:\n // `booking.com.guest-review.top`, `paypal.com-secure.net`. Only the\n // primary: a short secondary (`me.com`, `aka.ms`) would match too much.\n const primary = brand.officialDomains[0]\n if (\n normalized.startsWith(`${primary}.`) ||\n normalized.startsWith(`${primary}-`) ||\n normalized.includes(`.${primary}.`) ||\n normalized.includes(`.${primary}-`)\n ) {\n return brand\n }\n }\n return null\n}\n\n/**\n * The few entity forms a phishing kit uses to keep a URL away from a naive\n * scanner (`.` for a dot, `&` inside a query). Decoded before links\n * are read; everything else is left alone.\n */\nfunction decodeEntities(text: string): string {\n return text\n .replace(/&#x([0-9a-f]{1,6});/gi, (_, hex: string) => safeCodePoint(parseInt(hex, 16)))\n .replace(/&#(\\d{1,7});/g, (_, dec: string) => safeCodePoint(parseInt(dec, 10)))\n .replace(/&/gi, '&')\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isFinite(value) && value > 0 && value <= 0x10ffff\n ? String.fromCodePoint(value)\n : ''\n}\n\n/** Named entities a body uses for spacing, quoting and invisible splitting. */\nconst NAMED_ENTITIES: Record<string, string> = {\n nbsp: ' ',\n ensp: ' ',\n emsp: ' ',\n thinsp: ' ',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n shy: '',\n zwnj: '',\n zwj: '',\n}\n\n/*\n * Markup is read in single forward passes, never with a pattern that can\n * rescan to the end of the body from every `<`: the screen reads every\n * tenant message, and the tenant writes the body, so a million unclosed\n * `<` must cost a linear read.\n */\n/** An element whose contents a reader never sees: the start of its opening tag. */\nconst UNSEEN_ELEMENT_OPEN = /<(style|script|head|template)\\b/gi\n/** The name at the start of a tag that breaks a line or a block: a reader sees a gap there. */\nconst BREAKING_TAG_NAME =\n /^\\/?(?:br|p|div|li|ul|ol|tr|td|th|table|tbody|thead|h[1-6]|section|article|header|footer|blockquote|hr|center)\\b/i\n/** Characters that render as nothing, used to split a word for a scanner. */\nconst INVISIBLE_CHARACTERS = /[\\u00ad\\u200b-\\u200d\\u2060\\ufeff]/g\n\n/**\n * The text between the tags, joined with NO separator — or, with `breaks`,\n * with a space where a line or block breaks. A comment is dropped; a `<`\n * with no `>` after it is text.\n */\nfunction stripMarkup(html: string, breaks: boolean): string {\n let text = ''\n let at = 0\n while (at < html.length) {\n const open = html.indexOf('<', at)\n if (open < 0) return text + html.slice(at)\n text += html.slice(at, open)\n if (html.startsWith('<!--', open)) {\n const end = html.indexOf('-->', open + 4)\n if (end < 0) return text\n at = end + 3\n continue\n }\n const close = html.indexOf('>', open + 1)\n if (close < 0) return text + html.slice(open)\n if (breaks && BREAKING_TAG_NAME.test(html.slice(open + 1, Math.min(close, open + 12)))) text += ' '\n at = close + 1\n }\n return text\n}\n\n/** The markup with every unseen element and its contents taken out, in one pass. */\nfunction dropUnseenElements(html: string): string {\n const lower = html.toLowerCase()\n const open = new RegExp(UNSEEN_ELEMENT_OPEN.source, 'gi')\n let kept = ''\n let at = 0\n // Each search starts at or after the last; what it found (or that it found\n // nothing) still answers the next one, so no stretch is scanned twice.\n const found = new Map<string, number>()\n const find = (needle: string, from: number) => {\n const known = found.get(needle)\n if (known !== undefined && (known < 0 || known >= from)) return known\n const position = lower.indexOf(needle, from)\n found.set(needle, position)\n return position\n }\n for (let match = open.exec(html); match; match = open.exec(html)) {\n // No `>` after it: neither this nor anything later is a tag.\n const tagEnd = find('>', open.lastIndex)\n if (tagEnd < 0) break\n kept += `${html.slice(at, match.index)} `\n const name = match[1].toLowerCase()\n const close = find(`</${name}`, tagEnd + 1)\n // A head nobody closed ends where the body starts, as a browser reads it.\n const body = name === 'head' ? find('<body', tagEnd + 1) : -1\n if (body >= 0 && (close < 0 || body < close)) {\n at = body\n } else {\n const end = close < 0 ? -1 : find('>', close)\n // Never closed: only the opening tag goes, and what follows is still\n // read, so an unclosed `<style>` cannot hide the lure after it.\n at = end < 0 ? tagEnd + 1 : end + 1\n }\n open.lastIndex = at\n }\n return kept + html.slice(at)\n}\n\n/**\n * What a reader SEES in a body (AGL-3453): its text with the markup taken\n * out, the way a mail client or a browser draws it.\n *\n * A kit splits every letter of a brand and a lure with a tag —\n * `G<SPAN class=…>o<SPAN …>o…gle`, `P<SPAN …>a<SPAN …>s…word E<SPAN …>x…pired`\n * — so the raw HTML never contains the word a reader reads. Tags are\n * therefore removed with NO separator, which rejoins the letters; `<style>`,\n * `<script>` and `<head>` are dropped with their contents; comments and\n * zero-width characters are dropped; entities are decoded; whitespace is\n * collapsed. A second reading that puts a space where a line or block\n * breaks is added when it differs, so two words in two paragraphs are still\n * two words. Links are NOT read from this: an href lives inside a tag.\n */\nexport function visibleTextOf(html: string | null | undefined): string {\n const text = String(html ?? '')\n const source = text.includes('<') ? dropUnseenElements(text) : text\n const read = (breaks: boolean) =>\n decodeVisibleEntities(stripMarkup(source, breaks))\n .replace(INVISIBLE_CHARACTERS, '')\n .replace(/\\s+/g, ' ')\n .trim()\n const joined = read(false)\n const broken = read(true)\n return broken === joined ? joined : `${joined}\\n${broken}`\n}\n\n/** {@link decodeEntities}, after the named entities a body reads with. */\nfunction decodeVisibleEntities(text: string): string {\n return decodeEntities(\n text.replace(/&([a-z]{2,8});/gi, (entity, name: string) => NAMED_ENTITIES[name.toLowerCase()] ?? entity),\n )\n}\n\nconst URL_PATTERN = /\\b(?:https?:\\/\\/|www\\.)[^\\s\"'<>()\\\\[\\]{}]+/gi\n\n/** The hosts every link in the text points at, lowercased. */\nexport function linkHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).match(URL_PATTERN) ?? []) {\n let rest = match.replace(/^https?:\\/\\//i, '')\n rest = rest.split(/[/?#]/)[0]\n // `https://paypal.com@evil.top/` resolves to evil.top — and the part\n // before the `@` is exactly the disguise, so it is kept as a host too.\n const at = rest.lastIndexOf('@')\n const disguise = at >= 0 ? rest.slice(0, at) : ''\n rest = at >= 0 ? rest.slice(at + 1) : rest\n for (const candidate of [rest, disguise]) {\n const host = candidate.replace(/:\\d+$/, '').replace(/\\.+$/, '').toLowerCase()\n // A merge tag inside a URL is not a host anybody can visit yet.\n if (host && host.includes('.') && !host.includes('{') && !host.includes('%7b')) {\n hosts.add(host)\n }\n }\n }\n return [...hosts]\n}\n\n/** One link: the host it goes to, and its query and fragment, URL-decoded. */\ninterface LinkTarget {\n host: string\n query: string\n fragment: string\n}\n\n/** Every link in the text, with what it carries after its path. */\nfunction linkTargetsIn(text: string): LinkTarget[] {\n const targets: LinkTarget[] = []\n for (const match of decodeEntities(String(text ?? '')).match(URL_PATTERN) ?? []) {\n // The first host is where the browser goes; a userinfo disguise is not.\n const [host] = linkHostsIn(match)\n if (!host) continue\n const hash = match.indexOf('#')\n const beforeHash = hash < 0 ? match : match.slice(0, hash)\n const question = beforeHash.indexOf('?')\n targets.push({\n host,\n query: question < 0 ? '' : decodeUrlPart(beforeHash.slice(question + 1)),\n fragment: hash < 0 ? '' : decodeUrlPart(match.slice(hash + 1)),\n })\n }\n return targets\n}\n\nfunction decodeUrlPart(part: string): string {\n try {\n return decodeURIComponent(part)\n } catch {\n return part\n }\n}\n\nconst EMAIL_ADDRESS = /^[a-z0-9._%+-]+@[a-z0-9-]+(?:\\.[a-z0-9-]+)+$/i\nconst EMAIL_ADDRESS_IN_TEXT = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\\.[a-z0-9-]+)+)/gi\nconst BASE64_TEXT = /^[a-z0-9+/_-]{8,}={0,2}$/i\n\n/** A fragment read as base64, the other way a kit carries the address, or ''. */\nfunction base64Text(fragment: string): string {\n const decode = (globalThis as { atob?: (data: string) => string }).atob\n if (!decode || !BASE64_TEXT.test(fragment)) return ''\n try {\n return decode(fragment.replace(/-/g, '+').replace(/_/g, '/'))\n } catch {\n return ''\n }\n}\n\n/**\n * Where a link carries a reader's own address (AGL-3453), or null.\n *\n * - `fragment`: the WHOLE fragment is an address, plain or base64 —\n * `…#victim@example.com`. A fragment never reaches the server; the only\n * thing that reads it is the landing page's script, filling a sign-in\n * form so the reader sees their own address and types the password.\n * - `parameter`: an address that is not the workspace's own inside a query\n * or a longer fragment — `?email=victim@example.com`, a hash route's\n * `#/login?email=…`. Ordinary in a sign-up or a SaaS login link, so softer.\n */\nfunction prefilledAddressIn(\n target: LinkTarget,\n isOwnHost: (host: string) => boolean,\n): 'fragment' | 'parameter' | null {\n const fragment = target.fragment.trim()\n if (fragment && (EMAIL_ADDRESS.test(fragment) || EMAIL_ADDRESS.test(base64Text(fragment).trim()))) {\n return 'fragment'\n }\n for (const part of [target.query, target.fragment]) {\n // An address is short; a tracking token can be kilobytes of the\n // characters an address is made of, so only the head of one is read.\n if (!part.includes('@')) continue\n for (const match of part.slice(0, 4096).matchAll(EMAIL_ADDRESS_IN_TEXT)) {\n if (!isOwnHost(match[1].toLowerCase())) return 'parameter'\n }\n }\n return null\n}\n\nconst ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\\.[a-z0-9-]+)+)/gi\n\n/** The host of every email address in the text — a reply-to, a `mailto:`. */\nfunction addressHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).matchAll(ADDRESS_HOST_PATTERN)) {\n hosts.add(match[1].toLowerCase().replace(/\\.+$/, ''))\n }\n return [...hosts]\n}\n\n/** Lowercased alphanumerics only, so \"Wells-Fargo Advisors\" contains `wellsfargo`. */\nfunction squash(text: string): string {\n return text.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\n/** Is this brand the workspace's own, by name or by a domain it holds? */\nfunction isOwnBrand(\n brand: PhishingScreenBrand,\n ownNames: string,\n ownRegistrables: ReadonlySet<string>,\n): boolean {\n const labels = [brand.id, squash(brand.label), ...brand.hostTokens.map(squash)]\n if (labels.some((label) => label && ownNames.includes(label))) return true\n return [...ownRegistrables].some((registrable) => isOfficialDomain(brand, registrable))\n}\n\n/*\n * The same judgements, for the other surfaces that read content with this\n * screen's data (`hosted-page-screen.ts`). Exported under these names rather\n * than copied, so a page and an email can never disagree about what a\n * brand's own domain is or whose brand a name is.\n */\n\n/** `squash`: lowercased alphanumerics, the form own-name matching reads. */\nexport const squashScreenText = squash\n/** Is this brand the workspace's own, by name (squashed) or by a domain it holds? */\nexport const isWorkspaceOwnBrand = isOwnBrand\n/** Is this registrable domain one of any listed brand's own? */\nexport const isAnyOfficialBrandDomain = isAnyOfficialDomain\n/** Is this registrable domain a social, maps or review link merchants ordinarily carry? */\nexport function isCommonLinkDomain(registrable: string): boolean {\n return COMMON_LINK_DOMAINS.has(registrable)\n}\n\n/**\n * Screen one outbound message. See the module header for what holds and why.\n */\nexport function screenOutboundEmail(input: PhishingScreenInput): PhishingScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const ownNames = squash((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n const isOwnHost = (host: string) =>\n ownHosts.some((own) => host === own || host.endsWith(`.${own}`))\n\n const parts = [input.subject, input.preheader, ...(input.bodies ?? [])].filter(\n (part): part is string => typeof part === 'string' && part.length > 0,\n )\n // Links are read from the raw copy, where an href lives; brands and lures\n // from what a reader sees, where a tag cannot split a word (AGL-3453).\n const copy = parts.join('\\n')\n const visibleCopy = parts.map(visibleTextOf).join('\\n')\n const replyTo = Array.isArray(input.replyTo)\n ? input.replyTo.join(' ')\n : String(input.replyTo ?? '')\n const linkHosts = linkHostsIn(copy)\n\n // 1. A lookalike host, anywhere a recipient could click, write back, or\n // read as the sender.\n const seenLookalike = new Set<string>()\n const fromAddress = String(input.fromAddress ?? '')\n for (const host of [...linkHosts, ...addressHostsIn(`${copy}\\n${replyTo}\\n${fromAddress}`)]) {\n if (seenLookalike.has(host)) continue\n const brand = lookalikeBrandForHost(host)\n if (brand && !isBrandsOwnDomain(brand, ownRegistrables)) {\n seenLookalike.add(host)\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // 2. A brand in the sender's display name that is not the workspace's.\n const fromName = String(input.fromName ?? '').trim()\n if (fromName) {\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(fromName) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({ code: 'brand-sender', brand: brand.id, fromName: fromName.slice(0, 120) })\n }\n }\n }\n\n // 3. Brand + lure + a link to somewhere that is nobody's we know.\n const isElsewhere = (host: string) => {\n if (isOwnHost(host)) return false\n const registrable = registrableDomain(host)\n return !isAnyOfficialDomain(registrable) && !COMMON_LINK_DOMAINS.has(registrable)\n }\n const lure = PHISHING_LURE_PATTERNS.map((pattern) => visibleCopy.match(pattern)?.[0])\n .find(Boolean)\n if (lure) {\n const foreign = linkHosts.find(isElsewhere)\n if (foreign) {\n const namedInCopy = `${visibleCopy}\\n${fromName}`\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(namedInCopy) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({\n code: 'brand-lure-link',\n brand: brand.id,\n lure: lure.slice(0, 120),\n host: foreign,\n })\n break\n }\n }\n }\n }\n\n // 4. An account lure beside a link elsewhere that carries the reader's own\n // address, filled in for the sign-in form at the far end (AGL-3453).\n const accountLure = ACCOUNT_LURE_PATTERNS.map((pattern) => visibleCopy.match(pattern)?.[0]).find(Boolean)\n if (accountLure) {\n let prefill: { host: string; place: 'fragment' | 'parameter' } | null = null\n for (const target of linkTargetsIn(copy)) {\n if (!isElsewhere(target.host)) continue\n const place = prefilledAddressIn(target, isOwnHost)\n if (place && (!prefill || (place === 'fragment' && prefill.place !== 'fragment'))) {\n prefill = { host: target.host, place }\n }\n }\n if (prefill) {\n signals.push({\n code: 'recipient-prefill-link',\n host: prefill.host,\n lure: accountLure.slice(0, 120),\n place: prefill.place,\n })\n }\n }\n\n return { hold: signals.length > 0, signals }\n}\n\n/** The brand's label for a stored id, for the staff surface. */\nexport function phishingScreenBrandLabel(id: string): string {\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.id === id)?.label ?? id\n}\n\n/** Web Risk's threat types, in the words staff read (AGL-3451). */\nconst LINK_THREAT_WORDS: Record<string, string> = {\n SOCIAL_ENGINEERING: 'phishing or social engineering',\n MALWARE: 'malware',\n UNWANTED_SOFTWARE: 'unwanted software',\n}\n\n/** \"phishing or social engineering and malware\", from Web Risk's threat types. */\nexport function describeLinkThreats(threats: readonly string[]): string {\n const words = [...new Set(threats)].map((threat) => LINK_THREAT_WORDS[threat] ?? threat.toLowerCase())\n if (!words.length) return 'unsafe'\n return words.length === 1 ? words[0] : `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`\n}\n\n/** One line per signal, in words staff read on the review row. */\nexport function describePhishingScreenSignals(\n signals: readonly PhishingScreenSignal[],\n): string[] {\n return signals.map((signal) => {\n const brand = 'brand' in signal ? phishingScreenBrandLabel(signal.brand) : ''\n switch (signal.code) {\n case 'lookalike-link':\n return `Links to ${signal.host}, which wears the ${brand} name but is not ${brand}'s domain.`\n case 'brand-sender':\n return `Sends as \"${signal.fromName}\", naming ${brand}, which is not this workspace.`\n case 'brand-lure-link':\n return `Names ${brand}, asks the reader to act (\"${signal.lure}\"), and links to ${signal.host}.`\n case 'credential-field':\n return `Asks for a ${\n signal.field === 'card' ? 'card number' : signal.field === 'otp' ? 'one-time code' : 'password'\n } in its own field (\"${signal.label}\"), outside the platform's sign-in and checkout elements.`\n case 'brand-action-page':\n return `Names ${brand} beside a call to action (\"${signal.action}\"), and this workspace is not ${brand}.`\n case 'brand-lure-page':\n return `Names ${brand} on a page that asks visitors to act (\"${signal.lure}\") and links to ${signal.host}, and this workspace is not ${brand}.`\n case 'offsite-action-page':\n return `Its call to action (\"${signal.action}\") sends visitors off the site to ${signal.host}, on a page that reads \"${signal.lure}\".`\n case 'offsite-redirect':\n return `Redirects ${signal.source}, a path that reads \"${signal.lure}\", off the site to ${signal.host}.`\n case 'recipient-prefill-link':\n return `Links to ${signal.host} with the recipient's own address in the ${\n signal.place === 'fragment' ? 'fragment' : 'link'\n }, so a sign-in form there opens filled in, beside \"${signal.lure}\".`\n case 'web-risk-link':\n return `Links to ${signal.host}, which Google Web Risk lists as ${describeLinkThreats(signal.threats)}.`\n default:\n return 'Phishing signal.'\n }\n })\n}\n\n/*==========================================\n * RISK TIERS: who a signal holds for (AGL-3356, widened to every surface).\n *\n * The screen above finds signals; this decides which of them hold, the same\n * way on every surface that asks — an email at the send seam, a campaign\n * before it is claimed, a page at publish, a subdomain at creation.\n *\n * - STRONG: a lookalike host. A link to `poshmark.id63835663.shop` or\n * `paypa1.com` is a disguise whoever wears it and however old the\n * workspace is, so it holds for EVERY workspace, on every message and\n * page, transactional mail included. A merchant has no ordinary reason to\n * link to a host shaped like somebody else's brand.\n * - STRONG, too: account or password phrasing beside a link elsewhere whose\n * whole FRAGMENT is the reader's address (AGL-3453). A fragment never\n * reaches a server, so the address there is for the landing page's script\n * to fill a sign-in form with; a merchant's own reset mail links to its\n * own domain, and a SaaS login link puts the address in its query, which\n * is soft.\n * - SOFT: a brand in the sender name, the three-part lure, a brand's name\n * beside a sign-in or payment call to action on a page (in one element,\n * or anywhere on a page that carries a lure), a call to action or a\n * redirect that leaves the site beside account or document-share wording,\n * the reader's address in a query beside account phrasing.\n * Each is shaped like phishing but also like a clumsy legitimate message,\n * so they hold\n * only for a workspace in its first {@link OUTBOUND_REVIEW_YOUNG_DAYS}\n * days — the incident's window — and NEVER for mail the recipient's own\n * act made owed ({@link OutboundScreenPolicy.owed}).\n *\n * A credential-harvest page (a password, card or one-time-code field that\n * is not one of the platform's own sign-in or checkout elements) is STRONG:\n * see `hosted-page-screen.ts`. So is a link to a host Google Web Risk lists\n * (`web-risk-link`, `link-reputation.ts`): it holds for every workspace, and\n * an account-subdomain host does not soften it, because the listing names\n * that host itself.\n *=========================================*/\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport const OUTBOUND_REVIEW_YOUNG_DAYS = 14\n\n/** Which tier a signal belongs to. */\nexport type PhishingSignalTier = 'strong' | 'soft'\n\n/** The signal codes that hold for every workspace. */\nexport const STRONG_PHISHING_SIGNAL_CODES: ReadonlySet<string> = new Set([\n 'lookalike-link',\n 'credential-field',\n 'web-risk-link',\n])\n\n/**\n * Hosts that give each customer an account subdomain named after the\n * customer: `acme.zendesk.com` is Acme's own help desk. A brand's name there is usually the brand itself, so a\n * lookalike on one of these is SOFT — held for a young workspace, never a\n * takedown of an established one's page or mail. A lookalike on any other\n * host (`poshmark.id63835663.shop`) stays STRONG.\n */\nexport const ACCOUNT_SUBDOMAIN_HOSTS: ReadonlySet<string> = new Set([\n 'vimeo.com', 'vimeocdn.com', 'zendesk.com', 'freshdesk.com',\n 'helpscoutdocs.com', 'intercom.help', 'statuspage.io', 'atlassian.net', 'myshopify.com',\n 'squarespace.com', 'wixsite.com', 'webflow.io', 'hubspotpagebuilder.com', 'substack.com',\n 'typeform.com', 'calendly.com', 'gitbook.io', 'readme.io', 'notion.site', 'github.io',\n 'cloudfront.net', 'kajabi.com', 'thinkific.com', 'teachable.com', 'mailchimpsites.com',\n])\n\nexport function phishingSignalTier(signal: {\n code: string\n host?: string\n place?: string\n}): PhishingSignalTier {\n if (signal?.code === 'recipient-prefill-link') {\n return signal.place === 'fragment' ? 'strong' : 'soft'\n }\n if (!STRONG_PHISHING_SIGNAL_CODES.has(signal?.code)) return 'soft'\n if (\n signal.code === 'lookalike-link' &&\n typeof signal.host === 'string' &&\n ACCOUNT_SUBDOMAIN_HOSTS.has(registrableDomain(signal.host))\n ) {\n return 'soft'\n }\n return 'strong'\n}\n\n/** Is a workspace of this age (days, `null` = unreadable) screened by the soft rules? */\nexport function isYoungWorkspaceAge(ageDays: number | null | undefined): boolean {\n return typeof ageDays === 'number' && Number.isFinite(ageDays) && ageDays < OUTBOUND_REVIEW_YOUNG_DAYS\n}\n\nexport interface OutboundScreenPolicy {\n /**\n * The workspace's age in days, or `null` when its creation date cannot be\n * read — an org that predates the field, which is an EXISTING customer and\n * is not young.\n */\n ageDays: number | null\n /**\n * The recipient's own act made this message owed: a receipt for their\n * order, a confirmation of their booking, a reset of their password. The\n * soft rules never hold it. The lookalike rule still does — a receipt that\n * links to a Poshmark lookalike is the disguise, not the receipt.\n */\n owed?: boolean\n}\n\n/** The signals that HOLD under the tiers, for this workspace and message. */\nexport function signalsThatHold<T extends { code: string; host?: string }>(\n signals: readonly T[],\n policy: OutboundScreenPolicy,\n): T[] {\n const soft = isYoungWorkspaceAge(policy.ageDays) && !policy.owed\n return signals.filter((signal) => phishingSignalTier(signal) === 'strong' || soft)\n}\n\n/**\n * The same brand, lookalike and tier logic applied to a HOST NAME a workspace\n * asks for — a subdomain on the tenant apex, or a custom domain it attaches.\n *\n * Words a brand's impersonator hyphen-joins to its name. A subdomain that\n * joins a brand's word to one of these (`booking-review`, `apple-support`,\n * `amazon-account`) is the incident's shape even where the brand's word is\n * an ordinary one — which the lookalike rule alone deliberately lets pass,\n * so a salon can be `tanyas-booking` and a bakery `apple-pie-co`.\n */\nexport const BRAND_IMPERSONATION_WORDS: ReadonlySet<string> = new Set([\n 'account', 'accounts', 'auth', 'billing', 'help', 'helpdesk', 'id', 'login',\n 'logon', 'order', 'orders', 'pay', 'payment', 'payments', 'refund', 'resolution',\n 'review', 'reviews', 'secure', 'security', 'seller', 'service', 'signin',\n 'support', 'update', 'verify', 'verification', 'wallet',\n])\n\n/**\n * The brand a requested subdomain label wears, or null.\n *\n * Refused for EVERY workspace, at creation and at rename — the tier is\n * strong, because nothing is lost by asking a real bakery to pick\n * `apple-pie-co` instead of `apple-support`, and a brand's name on our apex\n * is a disguise that outlives every later screen. Two shapes:\n *\n * 1. the label is a lookalike host label ({@link lookalikeBrandForHost} on\n * `<label>.<apex>`): `poshmark`, `paypal-secure`, `paypa1`, `appleid`;\n * 2. a brand's word hyphen-joined to an impersonation word\n * ({@link BRAND_IMPERSONATION_WORDS}): `booking-review`, `apple-support`.\n */\nexport function brandForSubdomainLabel(\n label: string,\n apex = 'example.invalid',\n): PhishingScreenBrand | null {\n const normalized = String(label ?? '').trim().toLowerCase()\n if (!normalized) return null\n const byHost = lookalikeBrandForHost(`${normalized}.${apex}`)\n if (byHost) return byHost\n const parts = foldLeet(normalized).split('-').filter(Boolean)\n if (parts.length < 2 || !parts.some((part) => BRAND_IMPERSONATION_WORDS.has(part))) {\n return null\n }\n for (const brand of PHISHING_SCREEN_BRANDS) {\n const words = [brand.id, ...brand.hostTokens].filter((word) => !word.includes('-'))\n if (parts.some((part) => words.includes(part))) return brand\n }\n return null\n}\n\n/**\n * The platform's own entry in {@link PHISHING_SCREEN_BRANDS} (AGL-3365). A\n * workspace owns every other brand by carrying its name or domain; the\n * platform's is owned only by the platform's staff, because a workspace NAMED\n * for the platform is the cheapest disguise there is.\n */\nexport const PLATFORM_PHISHING_BRAND_ID = 'aglyn'\n\n/** The platform's brand entry. */\nexport function platformPhishingBrand(): PhishingScreenBrand {\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.id === PLATFORM_PHISHING_BRAND_ID) as PhishingScreenBrand\n}\n\n/**\n * Words that make a name claim to SPEAK FOR a brand rather than mention it:\n * \"PayPal Support\", \"Aglyn Official\", \"Apple ID Security Team\".\n */\nconst OFFICIAL_CLAIM_PATTERN =\n /\\b(?:official|staff|team|verified|certified|support|helpdesk|admin|administrator|security|billing|trust|safety|compliance|core|headquarters|hq)\\b/i\n\n/**\n * The brand a display NAME claims to speak for (AGL-3365), or null.\n *\n * For names a stranger reads as an identity — a marketplace publisher, a\n * listing's title — rather than copy. A name that merely mentions a brand\n * (\"Sync for PayPal\", \"Bookings for Airbnb hosts\") passes: that is how an\n * integration is named. A name that pairs the brand with a claim of\n * officialness (\"PayPal Support\", \"Aglyn Official Plugins\") is the disguise.\n */\nexport function brandClaimedByName(name: string): PhishingScreenBrand | null {\n const text = String(name ?? '')\n if (!text.trim() || !OFFICIAL_CLAIM_PATTERN.test(text)) return null\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.mention.test(text)) ?? null\n}\n"],"names":["PHISHING_SCREEN_BRANDS","id","label","mention","hostTokens","officialDomains","countryDomains","wordToken","hostedDomains","ACCOUNT_LURE_PATTERNS","DOCUMENT_SHARE_LURE_PATTERNS","SALE_AND_PARCEL_LURE_PATTERNS","PHISHING_LURE_PATTERNS","COMMON_LINK_DOMAINS","Set","TWO_LABEL_SUFFIXES","registrableDomain","host","labels","String","toLowerCase","replace","split","filter","Boolean","length","join","lastTwo","slice","has","splitRegistrable","registrable","dot","indexOf","suffix","isOfficialDomain","brand","includes","base","test","isBrandsOwnDomain","ownRegistrables","some","isAnyOfficialDomain","foldLeet","labelNamesBrand","token","candidate","parts","tokenParts","at","every","part","offset","MEDIA_EMBED_HOSTS","lookalikeBrandForHost","normalized","primary","startsWith","decodeEntities","text","_","hex","safeCodePoint","parseInt","dec","value","Number","isFinite","fromCodePoint","NAMED_ENTITIES","nbsp","ensp","emsp","thinsp","lt","gt","quot","apos","shy","zwnj","zwj","UNSEEN_ELEMENT_OPEN","BREAKING_TAG_NAME","INVISIBLE_CHARACTERS","stripMarkup","html","breaks","open","end","close","Math","min","dropUnseenElements","lower","RegExp","source","kept","found","Map","find","needle","from","known","get","undefined","position","set","match","exec","tagEnd","lastIndex","index","name","body","visibleTextOf","read","decodeVisibleEntities","trim","joined","broken","entity","URL_PATTERN","linkHostsIn","hosts","rest","lastIndexOf","disguise","add","linkTargetsIn","targets","hash","beforeHash","question","push","query","decodeUrlPart","fragment","decodeURIComponent","EMAIL_ADDRESS","EMAIL_ADDRESS_IN_TEXT","BASE64_TEXT","base64Text","decode","globalThis","atob","prefilledAddressIn","target","isOwnHost","matchAll","ADDRESS_HOST_PATTERN","addressHostsIn","squash","isOwnBrand","ownNames","map","squashScreenText","isWorkspaceOwnBrand","isAnyOfficialBrandDomain","isCommonLinkDomain","screenOutboundEmail","input","signals","ownHosts","ownDomains","domain","own","endsWith","subject","preheader","bodies","copy","visibleCopy","replyTo","Array","isArray","linkHosts","seenLookalike","fromAddress","code","fromName","isElsewhere","lure","pattern","foreign","namedInCopy","accountLure","prefill","place","hold","phishingScreenBrandLabel","LINK_THREAT_WORDS","SOCIAL_ENGINEERING","MALWARE","UNWANTED_SOFTWARE","describeLinkThreats","threats","words","threat","describePhishingScreenSignals","signal","field","action","OUTBOUND_REVIEW_YOUNG_DAYS","STRONG_PHISHING_SIGNAL_CODES","ACCOUNT_SUBDOMAIN_HOSTS","phishingSignalTier","isYoungWorkspaceAge","ageDays","signalsThatHold","policy","soft","owed","BRAND_IMPERSONATION_WORDS","brandForSubdomainLabel","apex","byHost","word","PLATFORM_PHISHING_BRAND_ID","platformPhishingBrand","OFFICIAL_CLAIM_PATTERN","brandClaimedByName"],"mappings":"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"}
|
|
@@ -79,9 +79,10 @@ export interface TenantMessageScreenRefusal {
|
|
|
79
79
|
detail: string;
|
|
80
80
|
}
|
|
81
81
|
/**
|
|
82
|
-
* Screen one tenant message: the pure screen,
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* Screen one tenant message: the pure screen, the reputation of every
|
|
83
|
+
* foreign host it links to (AGL-3451), the tiers, then — only when a signal
|
|
84
|
+
* holds — the installed gate, which files or reads the review row. `null`
|
|
85
|
+
* when it may go.
|
|
85
86
|
*
|
|
86
87
|
* The ONE door for every transport. `sendEmail` asks it for every message a
|
|
87
88
|
* site sends through the platform's provider, and a sender on another
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
*
|
|
35
35
|
* Nothing installed is UNGATED, the governor's posture: a deployment that
|
|
36
36
|
* never installs the store must still send.
|
|
37
|
-
*=========================================*/ import {
|
|
37
|
+
*=========================================*/ import { linkReputationSignals } from "./link-reputation.js";
|
|
38
|
+
import { linkHostsIn, screenOutboundEmail, signalsThatHold } from "./outbound-phishing-screen.js";
|
|
38
39
|
let installedGate = null;
|
|
39
40
|
/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */ export function setOutboundScreenGate(gate) {
|
|
40
41
|
installedGate = gate;
|
|
@@ -46,9 +47,10 @@ let installedGate = null;
|
|
|
46
47
|
installedGate = null;
|
|
47
48
|
}
|
|
48
49
|
/**
|
|
49
|
-
* Screen one tenant message: the pure screen,
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* Screen one tenant message: the pure screen, the reputation of every
|
|
51
|
+
* foreign host it links to (AGL-3451), the tiers, then — only when a signal
|
|
52
|
+
* holds — the installed gate, which files or reads the review row. `null`
|
|
53
|
+
* when it may go.
|
|
52
54
|
*
|
|
53
55
|
* The ONE door for every transport. `sendEmail` asks it for every message a
|
|
54
56
|
* site sends through the platform's provider, and a sender on another
|
|
@@ -71,7 +73,19 @@ let installedGate = null;
|
|
|
71
73
|
ownNames: workspace.ownNames,
|
|
72
74
|
ownDomains: workspace.ownDomains
|
|
73
75
|
});
|
|
74
|
-
|
|
76
|
+
// Every foreign host the message links to, against the reputation list
|
|
77
|
+
// (AGL-3451). A listed host is a strong signal; a lookup that fails is
|
|
78
|
+
// none. Nothing installed looks nothing up.
|
|
79
|
+
const reputation = await linkReputationSignals(linkHostsIn([
|
|
80
|
+
input.subject,
|
|
81
|
+
...input.bodies
|
|
82
|
+
].filter(Boolean).join('\n')), {
|
|
83
|
+
ownDomains: workspace.ownDomains
|
|
84
|
+
});
|
|
85
|
+
const holding = signalsThatHold([
|
|
86
|
+
...verdict.signals,
|
|
87
|
+
...reputation
|
|
88
|
+
], {
|
|
75
89
|
ageDays: workspace.ageDays,
|
|
76
90
|
owed: input.owed === true
|
|
77
91
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-screen-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE OUTBOUND SCREEN ON THE SEND SEAM (AGL-3362).\n *\n * Every tenant message leaves through `sendEmail`, and every tenant sender\n * but the campaign core resolves its identity through `hostSendingIdentity`,\n * which stamps the verdict with the {@link SendingWorkspace} it read. So the\n * screen is asked HERE, once, for the CRM's one-off mail, inbox replies,\n * newsletters, member posts, cart and restock sweeps, flow steps, receipts —\n * and any marketplace plugin that sends for a site — with no copy of it in\n * any of them.\n *\n * Split the way the governor, the marketing gate and the deliverability\n * preflight are: the DECISION is pure and lives in this library\n * (`screenOutboundEmail` finds signals, `signalsThatHold` applies the tiers);\n * what a hold WRITES — the abuse-queue row, the staff alert, the release\n * lookup — is a store, so it is injected by `@aglyn/tenant-data-admin`\n * through {@link setOutboundScreenGate}. The gate is only called when a\n * signal holds, so a clean message costs no read.\n *\n * Nothing installed is UNGATED, the governor's posture: a deployment that\n * never installs the store must still send.\n *=========================================*/\n\nimport {\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from './outbound-phishing-screen'\n\n/**\n * The workspace a tenant message belongs to, as the screen needs it. Stamped\n * on the sending identity by `hostSendingIdentity` from the host and org\n * documents it already reads, so the screen costs no read of its own.\n */\nexport interface SendingWorkspace {\n hostId: string\n orgId: string | null\n /** Days since the org was created; `null` when unreadable (an existing customer). */\n ageDays: number | null\n /** The org's and site's names and the subdomain — a brand in one is its own. */\n ownNames: string[]\n /** The site's hosts — a link to one is never \"elsewhere\". */\n ownDomains: string[]\n}\n\nexport interface OutboundScreenGateRequest {\n workspace: SendingWorkspace\n /** The signals that HOLD, after the tiers. Never empty. */\n signals: PhishingScreenSignal[]\n subject: string\n fromName: string | null\n /** The sender's `context`, so the reviewer can tell which feature sent it. */\n context: string | null\n /**\n * A review row a caller's own screen held and staff RELEASED for this\n * content (a campaign, an automation step). The gate honors it only when\n * that row really is a release for this site — a caller cannot mint one.\n */\n releasedReviewId: string | null\n}\n\nexport interface OutboundScreenGateVerdict {\n outcome: 'send' | 'held' | 'rejected'\n /** The `HS-…` reference staff and the merchant quote. */\n reference?: string | null\n}\n\nexport type OutboundScreenGate = (\n request: OutboundScreenGateRequest,\n) => Promise<OutboundScreenGateVerdict>\n\nlet installedGate: OutboundScreenGate | null = null\n\n/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setOutboundScreenGate(gate: OutboundScreenGate | null): void {\n installedGate = gate\n}\n\n/** The installed gate, or null when nothing has been installed. */\nexport function getOutboundScreenGate(): OutboundScreenGate | null {\n return installedGate\n}\n\n/** Test seam: forget any installed gate. */\nexport function resetOutboundScreenGateForTests(): void {\n installedGate = null\n}\n\nexport interface TenantMessageScreenInput {\n workspace: SendingWorkspace\n subject: string\n fromName?: string | null\n /** The address it leaves from — a lookalike `From:` domain holds for everyone. */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n /** Everything the recipient reads or clicks: the text part, the HTML. */\n bodies: readonly (string | null | undefined)[]\n /** The recipient's own act made this owed (see `SendEmailOptions.owedFor`). */\n owed?: boolean\n context?: string | null\n releasedReviewId?: string | null\n}\n\n/** Why a tenant message may not leave. */\nexport interface TenantMessageScreenRefusal {\n outcome: 'held' | 'rejected'\n reference: string | null\n /** The sentence a merchant reads. */\n detail: string\n}\n\n/**\n * Screen one tenant message: the pure screen, its tiers, then — only when a\n * signal holds — the installed gate, which files or reads the review row.\n * `null` when it may go.\n *\n * The ONE door for every transport. `sendEmail` asks it for every message a\n * site sends through the platform's provider, and a sender on another\n * transport (Outreach's connected mailboxes) asks it the same way, so a\n * second transport is not a second screen.\n *\n * Fails OPEN on a gate that is missing, throws or answers nothing, the\n * posture of every other control on the send path: an outage on the review\n * queue must not become an outage on every site's mail.\n */\nexport async function screenTenantMessage(\n input: TenantMessageScreenInput,\n label = 'email',\n): Promise<TenantMessageScreenRefusal | null> {\n const { workspace } = input\n if (!workspace?.hostId) return null\n const verdict = screenOutboundEmail({\n subject: input.subject,\n fromName: input.fromName ?? null,\n fromAddress: input.fromAddress ?? null,\n replyTo: input.replyTo ?? null,\n bodies: input.bodies,\n ownNames: workspace.ownNames,\n ownDomains: workspace.ownDomains,\n })\n const holding = signalsThatHold(verdict.signals, {\n ageDays: workspace.ageDays,\n owed: input.owed === true,\n })\n if (!holding.length) return null\n const gate = getOutboundScreenGate()\n if (!gate) {\n console.warn(`${label} matched the phishing screen, and no review store is installed — allowing`)\n return null\n }\n let answer: OutboundScreenGateVerdict | null\n try {\n answer = await gate({\n workspace,\n signals: holding,\n subject: String(input.subject ?? ''),\n fromName: input.fromName ?? null,\n context: input.context ?? null,\n releasedReviewId: input.releasedReviewId ?? null,\n })\n } catch (error) {\n console.error(`${label} phishing review failed — allowing`, error)\n answer = null\n }\n if (!answer || answer.outcome === 'send') return null\n const reference = answer.reference ?? null\n const cited = reference ? ` (${reference})` : ''\n return {\n outcome: answer.outcome,\n reference,\n detail:\n answer.outcome === 'rejected'\n ? `Staff review rejected email like this from this site${cited}, so it was not sent.`\n : 'This email is held for staff review because it may impersonate another ' +\n `business${cited}. Nothing was sent.`,\n }\n}\n"],"names":["screenOutboundEmail","signalsThatHold","installedGate","setOutboundScreenGate","gate","getOutboundScreenGate","resetOutboundScreenGateForTests","screenTenantMessage","input","label","answer","workspace","hostId","verdict","subject","fromName","fromAddress","replyTo","bodies","ownNames","ownDomains","holding","signals","ageDays","owed","length","console","warn","String","context","releasedReviewId","error","outcome","reference","cited","detail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;2CAqB2C,GAE3C,SAEEA,mBAAmB,EACnBC,eAAe,QACV,gCAA4B;AA4CnC,IAAIC,gBAA2C;AAE/C,6EAA6E,GAC7E,OAAO,SAASC,sBAAsBC,IAA+B;IACnEF,gBAAgBE;AAClB;AAEA,iEAAiE,GACjE,OAAO,SAASC;IACd,OAAOH;AACT;AAEA,0CAA0C,GAC1C,OAAO,SAASI;IACdJ,gBAAgB;AAClB;AAyBA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeK,oBACpBC,KAA+B,EAC/BC,QAAQ,OAAO;QAMHD,iBACGA,oBACJA,gBA8BOE;IApClB,MAAM,EAAEC,SAAS,EAAE,GAAGH;IACtB,IAAI,EAACG,6BAAAA,UAAWC,MAAM,GAAE,OAAO;IAC/B,MAAMC,UAAUb,oBAAoB;QAClCc,SAASN,MAAMM,OAAO;QACtBC,QAAQ,GAAEP,kBAAAA,MAAMO,QAAQ,YAAdP,kBAAkB;QAC5BQ,WAAW,GAAER,qBAAAA,MAAMQ,WAAW,YAAjBR,qBAAqB;QAClCS,OAAO,GAAET,iBAAAA,MAAMS,OAAO,YAAbT,iBAAiB;QAC1BU,QAAQV,MAAMU,MAAM;QACpBC,UAAUR,UAAUQ,QAAQ;QAC5BC,YAAYT,UAAUS,UAAU;IAClC;IACA,MAAMC,UAAUpB,gBAAgBY,QAAQS,OAAO,EAAE;QAC/CC,SAASZ,UAAUY,OAAO;QAC1BC,MAAMhB,MAAMgB,IAAI,KAAK;IACvB;IACA,IAAI,CAACH,QAAQI,MAAM,EAAE,OAAO;IAC5B,MAAMrB,OAAOC;IACb,IAAI,CAACD,MAAM;QACTsB,QAAQC,IAAI,CAAC,GAAGlB,MAAM,yEAAyE,CAAC;QAChG,OAAO;IACT;IACA,IAAIC;IACJ,IAAI;YAIgBF,gBACNA,kBACDA,gBACSA;QANpBE,SAAS,MAAMN,KAAK;YAClBO;YACAW,SAASD;YACTP,SAASc,QAAOpB,iBAAAA,MAAMM,OAAO,YAAbN,iBAAiB;YACjCO,QAAQ,GAAEP,mBAAAA,MAAMO,QAAQ,YAAdP,mBAAkB;YAC5BqB,OAAO,GAAErB,iBAAAA,MAAMqB,OAAO,YAAbrB,iBAAiB;YAC1BsB,gBAAgB,GAAEtB,0BAAAA,MAAMsB,gBAAgB,YAAtBtB,0BAA0B;QAC9C;IACF,EAAE,OAAOuB,OAAO;QACdL,QAAQK,KAAK,CAAC,GAAGtB,MAAM,kCAAkC,CAAC,EAAEsB;QAC5DrB,SAAS;IACX;IACA,IAAI,CAACA,UAAUA,OAAOsB,OAAO,KAAK,QAAQ,OAAO;IACjD,MAAMC,aAAYvB,oBAAAA,OAAOuB,SAAS,YAAhBvB,oBAAoB;IACtC,MAAMwB,QAAQD,YAAY,CAAC,EAAE,EAAEA,UAAU,CAAC,CAAC,GAAG;IAC9C,OAAO;QACLD,SAAStB,OAAOsB,OAAO;QACvBC;QACAE,QACEzB,OAAOsB,OAAO,KAAK,aACf,CAAC,oDAAoD,EAAEE,MAAM,qBAAqB,CAAC,GACnF,4EACA,CAAC,QAAQ,EAAEA,MAAM,mBAAmB,CAAC;IAC7C;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-screen-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE OUTBOUND SCREEN ON THE SEND SEAM (AGL-3362).\n *\n * Every tenant message leaves through `sendEmail`, and every tenant sender\n * but the campaign core resolves its identity through `hostSendingIdentity`,\n * which stamps the verdict with the {@link SendingWorkspace} it read. So the\n * screen is asked HERE, once, for the CRM's one-off mail, inbox replies,\n * newsletters, member posts, cart and restock sweeps, flow steps, receipts —\n * and any marketplace plugin that sends for a site — with no copy of it in\n * any of them.\n *\n * Split the way the governor, the marketing gate and the deliverability\n * preflight are: the DECISION is pure and lives in this library\n * (`screenOutboundEmail` finds signals, `signalsThatHold` applies the tiers);\n * what a hold WRITES — the abuse-queue row, the staff alert, the release\n * lookup — is a store, so it is injected by `@aglyn/tenant-data-admin`\n * through {@link setOutboundScreenGate}. The gate is only called when a\n * signal holds, so a clean message costs no read.\n *\n * Nothing installed is UNGATED, the governor's posture: a deployment that\n * never installs the store must still send.\n *=========================================*/\n\nimport { linkReputationSignals } from './link-reputation'\nimport {\n linkHostsIn,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from './outbound-phishing-screen'\n\n/**\n * The workspace a tenant message belongs to, as the screen needs it. Stamped\n * on the sending identity by `hostSendingIdentity` from the host and org\n * documents it already reads, so the screen costs no read of its own.\n */\nexport interface SendingWorkspace {\n hostId: string\n orgId: string | null\n /** Days since the org was created; `null` when unreadable (an existing customer). */\n ageDays: number | null\n /** The org's and site's names and the subdomain — a brand in one is its own. */\n ownNames: string[]\n /** The site's hosts — a link to one is never \"elsewhere\". */\n ownDomains: string[]\n}\n\nexport interface OutboundScreenGateRequest {\n workspace: SendingWorkspace\n /** The signals that HOLD, after the tiers. Never empty. */\n signals: PhishingScreenSignal[]\n subject: string\n fromName: string | null\n /** The sender's `context`, so the reviewer can tell which feature sent it. */\n context: string | null\n /**\n * A review row a caller's own screen held and staff RELEASED for this\n * content (a campaign, an automation step). The gate honors it only when\n * that row really is a release for this site — a caller cannot mint one.\n */\n releasedReviewId: string | null\n}\n\nexport interface OutboundScreenGateVerdict {\n outcome: 'send' | 'held' | 'rejected'\n /** The `HS-…` reference staff and the merchant quote. */\n reference?: string | null\n}\n\nexport type OutboundScreenGate = (\n request: OutboundScreenGateRequest,\n) => Promise<OutboundScreenGateVerdict>\n\nlet installedGate: OutboundScreenGate | null = null\n\n/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setOutboundScreenGate(gate: OutboundScreenGate | null): void {\n installedGate = gate\n}\n\n/** The installed gate, or null when nothing has been installed. */\nexport function getOutboundScreenGate(): OutboundScreenGate | null {\n return installedGate\n}\n\n/** Test seam: forget any installed gate. */\nexport function resetOutboundScreenGateForTests(): void {\n installedGate = null\n}\n\nexport interface TenantMessageScreenInput {\n workspace: SendingWorkspace\n subject: string\n fromName?: string | null\n /** The address it leaves from — a lookalike `From:` domain holds for everyone. */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n /** Everything the recipient reads or clicks: the text part, the HTML. */\n bodies: readonly (string | null | undefined)[]\n /** The recipient's own act made this owed (see `SendEmailOptions.owedFor`). */\n owed?: boolean\n context?: string | null\n releasedReviewId?: string | null\n}\n\n/** Why a tenant message may not leave. */\nexport interface TenantMessageScreenRefusal {\n outcome: 'held' | 'rejected'\n reference: string | null\n /** The sentence a merchant reads. */\n detail: string\n}\n\n/**\n * Screen one tenant message: the pure screen, the reputation of every\n * foreign host it links to (AGL-3451), the tiers, then — only when a signal\n * holds — the installed gate, which files or reads the review row. `null`\n * when it may go.\n *\n * The ONE door for every transport. `sendEmail` asks it for every message a\n * site sends through the platform's provider, and a sender on another\n * transport (Outreach's connected mailboxes) asks it the same way, so a\n * second transport is not a second screen.\n *\n * Fails OPEN on a gate that is missing, throws or answers nothing, the\n * posture of every other control on the send path: an outage on the review\n * queue must not become an outage on every site's mail.\n */\nexport async function screenTenantMessage(\n input: TenantMessageScreenInput,\n label = 'email',\n): Promise<TenantMessageScreenRefusal | null> {\n const { workspace } = input\n if (!workspace?.hostId) return null\n const verdict = screenOutboundEmail({\n subject: input.subject,\n fromName: input.fromName ?? null,\n fromAddress: input.fromAddress ?? null,\n replyTo: input.replyTo ?? null,\n bodies: input.bodies,\n ownNames: workspace.ownNames,\n ownDomains: workspace.ownDomains,\n })\n // Every foreign host the message links to, against the reputation list\n // (AGL-3451). A listed host is a strong signal; a lookup that fails is\n // none. Nothing installed looks nothing up.\n const reputation = await linkReputationSignals(\n linkHostsIn([input.subject, ...input.bodies].filter(Boolean).join('\\n')),\n { ownDomains: workspace.ownDomains },\n )\n const holding = signalsThatHold([...verdict.signals, ...reputation], {\n ageDays: workspace.ageDays,\n owed: input.owed === true,\n })\n if (!holding.length) return null\n const gate = getOutboundScreenGate()\n if (!gate) {\n console.warn(`${label} matched the phishing screen, and no review store is installed — allowing`)\n return null\n }\n let answer: OutboundScreenGateVerdict | null\n try {\n answer = await gate({\n workspace,\n signals: holding,\n subject: String(input.subject ?? ''),\n fromName: input.fromName ?? null,\n context: input.context ?? null,\n releasedReviewId: input.releasedReviewId ?? null,\n })\n } catch (error) {\n console.error(`${label} phishing review failed — allowing`, error)\n answer = null\n }\n if (!answer || answer.outcome === 'send') return null\n const reference = answer.reference ?? null\n const cited = reference ? ` (${reference})` : ''\n return {\n outcome: answer.outcome,\n reference,\n detail:\n answer.outcome === 'rejected'\n ? `Staff review rejected email like this from this site${cited}, so it was not sent.`\n : 'This email is held for staff review because it may impersonate another ' +\n `business${cited}. Nothing was sent.`,\n }\n}\n"],"names":["linkReputationSignals","linkHostsIn","screenOutboundEmail","signalsThatHold","installedGate","setOutboundScreenGate","gate","getOutboundScreenGate","resetOutboundScreenGateForTests","screenTenantMessage","input","label","answer","workspace","hostId","verdict","subject","fromName","fromAddress","replyTo","bodies","ownNames","ownDomains","reputation","filter","Boolean","join","holding","signals","ageDays","owed","length","console","warn","String","context","releasedReviewId","error","outcome","reference","cited","detail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;2CAqB2C,GAE3C,SAASA,qBAAqB,QAAQ,uBAAmB;AACzD,SACEC,WAAW,EAEXC,mBAAmB,EACnBC,eAAe,QACV,gCAA4B;AA4CnC,IAAIC,gBAA2C;AAE/C,6EAA6E,GAC7E,OAAO,SAASC,sBAAsBC,IAA+B;IACnEF,gBAAgBE;AAClB;AAEA,iEAAiE,GACjE,OAAO,SAASC;IACd,OAAOH;AACT;AAEA,0CAA0C,GAC1C,OAAO,SAASI;IACdJ,gBAAgB;AAClB;AAyBA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeK,oBACpBC,KAA+B,EAC/BC,QAAQ,OAAO;QAMHD,iBACGA,oBACJA,gBAqCOE;IA3ClB,MAAM,EAAEC,SAAS,EAAE,GAAGH;IACtB,IAAI,EAACG,6BAAAA,UAAWC,MAAM,GAAE,OAAO;IAC/B,MAAMC,UAAUb,oBAAoB;QAClCc,SAASN,MAAMM,OAAO;QACtBC,QAAQ,GAAEP,kBAAAA,MAAMO,QAAQ,YAAdP,kBAAkB;QAC5BQ,WAAW,GAAER,qBAAAA,MAAMQ,WAAW,YAAjBR,qBAAqB;QAClCS,OAAO,GAAET,iBAAAA,MAAMS,OAAO,YAAbT,iBAAiB;QAC1BU,QAAQV,MAAMU,MAAM;QACpBC,UAAUR,UAAUQ,QAAQ;QAC5BC,YAAYT,UAAUS,UAAU;IAClC;IACA,uEAAuE;IACvE,uEAAuE;IACvE,4CAA4C;IAC5C,MAAMC,aAAa,MAAMvB,sBACvBC,YAAY;QAACS,MAAMM,OAAO;WAAKN,MAAMU,MAAM;KAAC,CAACI,MAAM,CAACC,SAASC,IAAI,CAAC,QAClE;QAAEJ,YAAYT,UAAUS,UAAU;IAAC;IAErC,MAAMK,UAAUxB,gBAAgB;WAAIY,QAAQa,OAAO;WAAKL;KAAW,EAAE;QACnEM,SAAShB,UAAUgB,OAAO;QAC1BC,MAAMpB,MAAMoB,IAAI,KAAK;IACvB;IACA,IAAI,CAACH,QAAQI,MAAM,EAAE,OAAO;IAC5B,MAAMzB,OAAOC;IACb,IAAI,CAACD,MAAM;QACT0B,QAAQC,IAAI,CAAC,GAAGtB,MAAM,yEAAyE,CAAC;QAChG,OAAO;IACT;IACA,IAAIC;IACJ,IAAI;YAIgBF,gBACNA,kBACDA,gBACSA;QANpBE,SAAS,MAAMN,KAAK;YAClBO;YACAe,SAASD;YACTX,SAASkB,QAAOxB,iBAAAA,MAAMM,OAAO,YAAbN,iBAAiB;YACjCO,QAAQ,GAAEP,mBAAAA,MAAMO,QAAQ,YAAdP,mBAAkB;YAC5ByB,OAAO,GAAEzB,iBAAAA,MAAMyB,OAAO,YAAbzB,iBAAiB;YAC1B0B,gBAAgB,GAAE1B,0BAAAA,MAAM0B,gBAAgB,YAAtB1B,0BAA0B;QAC9C;IACF,EAAE,OAAO2B,OAAO;QACdL,QAAQK,KAAK,CAAC,GAAG1B,MAAM,kCAAkC,CAAC,EAAE0B;QAC5DzB,SAAS;IACX;IACA,IAAI,CAACA,UAAUA,OAAO0B,OAAO,KAAK,QAAQ,OAAO;IACjD,MAAMC,aAAY3B,oBAAAA,OAAO2B,SAAS,YAAhB3B,oBAAoB;IACtC,MAAM4B,QAAQD,YAAY,CAAC,EAAE,EAAEA,UAAU,CAAC,CAAC,GAAG;IAC9C,OAAO;QACLD,SAAS1B,OAAO0B,OAAO;QACvBC;QACAE,QACE7B,OAAO0B,OAAO,KAAK,aACf,CAAC,oDAAoD,EAAEE,MAAM,qBAAqB,CAAC,GACnF,4EACA,CAAC,QAAQ,EAAEA,MAAM,mBAAmB,CAAC;IAC7C;AACF"}
|