@aglyn/shared-util-email 1.0.0-beta.221 → 1.0.0-beta.222
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/lib/email-media-src.d.ts +5 -1
- package/src/lib/email-media-src.js +49 -3
- package/src/lib/email-media-src.js.map +1 -1
- package/src/lib/email-render.js +6 -1
- package/src/lib/email-render.js.map +1 -1
- package/src/lib/hosted-page-screen.d.ts +7 -0
- package/src/lib/hosted-page-screen.js +12 -6
- package/src/lib/hosted-page-screen.js.map +1 -1
- package/src/lib/link-reputation.d.ts +79 -9
- package/src/lib/link-reputation.js +143 -16
- package/src/lib/link-reputation.js.map +1 -1
- package/src/lib/outbound-phishing-screen.d.ts +12 -1
- package/src/lib/outbound-phishing-screen.js +18 -1
- package/src/lib/outbound-phishing-screen.js.map +1 -1
- package/src/lib/outbound-screen-gate.js +6 -5
- package/src/lib/outbound-screen-gate.js.map +1 -1
- package/src/lib/system-email-catalog.js +103 -0
- package/src/lib/system-email-catalog.js.map +1 -1
|
@@ -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 * 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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"}
|
|
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 linkUrlsIn,\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 * The same links whole, as written (`linkUrlsIn`), for the lookup's\n * `'url'` mode (AGL-3459). Query strings are still on them:\n * `foreignLinksForReputation` drops them before anything is looked up or\n * stored.\n */\n linkUrls: 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, linkUrls: linkUrlsIn(pageText) }\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 absolute = destination.startsWith('//') ? `https:${destination}` : destination\n const hosts = linkHostsIn(absolute)\n const linkUrls = linkUrlsIn(absolute)\n if (!hosts.length) return { signals, linkHosts: hosts, linkUrls }\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, linkUrls }\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, linkUrls }\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","linkUrlsIn","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","linkUrls","patterns","match","callToActionLabel","split","screenId","href","absolute","startsWith","screenSiteRedirect","destination","hosts","leaves","source","pathWords","anyLure","candidate","path","decoded","decodeURIComponent"],"mappings":"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"}
|
|
@@ -18,27 +18,49 @@ import type { PhishingScreenSignal } from './outbound-phishing-screen';
|
|
|
18
18
|
/** The threat lists a host is looked up against. */
|
|
19
19
|
export type LinkThreatType = 'SOCIAL_ENGINEERING' | 'MALWARE' | 'UNWANTED_SOFTWARE';
|
|
20
20
|
export declare const LINK_THREAT_TYPES: readonly LinkThreatType[];
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* A host the list names, or one address on it, and what it names it for.
|
|
23
|
+
* `url` is set when the listing is the address's, not the host's.
|
|
24
|
+
*/
|
|
22
25
|
export interface HostReputationHit {
|
|
23
26
|
host: string;
|
|
24
27
|
threats: LinkThreatType[];
|
|
28
|
+
/** The address the list names (scheme, host and path), when it is one page on a clean host. */
|
|
29
|
+
url?: string;
|
|
25
30
|
}
|
|
26
|
-
/** What one lookup answered, host by host. */
|
|
31
|
+
/** What one lookup answered, host by host and address by address. */
|
|
27
32
|
export interface LinkReputationAnswer {
|
|
28
|
-
/** Hosts the list names. */
|
|
33
|
+
/** Hosts, and addresses, the list names. */
|
|
29
34
|
hits: HostReputationHit[];
|
|
30
|
-
/** Hosts the list does not name. */
|
|
35
|
+
/** Hosts and addresses the list does not name. */
|
|
31
36
|
clean: string[];
|
|
32
|
-
/** Hosts nobody could answer for: an error, a timeout, a switched-off lookup. */
|
|
37
|
+
/** Hosts and addresses nobody could answer for: an error, a timeout, a switched-off lookup. */
|
|
33
38
|
unknown: string[];
|
|
34
39
|
}
|
|
35
40
|
export interface LinkReputationLookupOptions {
|
|
36
41
|
/** How long the caller will wait, overall. */
|
|
37
42
|
deadlineMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The links' addresses, as {@link normalizeReputationLink} reduced them —
|
|
45
|
+
* looked up only in `'url'` mode, and only on the hosts asked about.
|
|
46
|
+
*/
|
|
47
|
+
urls?: readonly string[];
|
|
38
48
|
}
|
|
39
49
|
export type LinkReputationLookup = (hosts: readonly string[], options?: LinkReputationLookupOptions) => Promise<LinkReputationAnswer>;
|
|
40
50
|
/** The most foreign hosts one page, message or redirect is looked up for. */
|
|
41
51
|
export declare const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20;
|
|
52
|
+
/**
|
|
53
|
+
* The most link addresses (path included) one page, message or redirect is
|
|
54
|
+
* looked up for in `'url'` mode, beside its hosts (AGL-3459). With the hosts,
|
|
55
|
+
* at most forty calls, all inside the same per-page deadline.
|
|
56
|
+
*/
|
|
57
|
+
export declare const MAX_REPUTATION_URLS_PER_LOOKUP = 20;
|
|
58
|
+
/**
|
|
59
|
+
* The longest path an address keeps. Longer is cut back to the last `/`
|
|
60
|
+
* within it, so what is looked up is still a path the link really starts
|
|
61
|
+
* with — Web Risk lists a kit by its path prefix.
|
|
62
|
+
*/
|
|
63
|
+
export declare const MAX_REPUTATION_URL_PATH_LENGTH = 512;
|
|
42
64
|
/** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */
|
|
43
65
|
export declare function setLinkReputationLookup(lookup: LinkReputationLookup | null): void;
|
|
44
66
|
/** The installed lookup, or null. */
|
|
@@ -47,6 +69,52 @@ export declare function getLinkReputationLookup(): LinkReputationLookup | null;
|
|
|
47
69
|
export declare function resetLinkReputationLookupForTests(): void;
|
|
48
70
|
/** A host as the lookup takes it, or null for anything that is not one. */
|
|
49
71
|
export declare function normalizeReputationHost(host: string): string | null;
|
|
72
|
+
/** A link reduced to what may be looked up: its host, and its address without query or fragment. */
|
|
73
|
+
export interface ReputationLink {
|
|
74
|
+
host: string;
|
|
75
|
+
/** `scheme://host[:port]/path` — never a query string, a fragment or a `user:password@`. */
|
|
76
|
+
url: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A link reduced to what Web Risk may be asked about it (AGL-3459), or null
|
|
80
|
+
* for anything that is not an `http(s)` link to a real host.
|
|
81
|
+
*
|
|
82
|
+
* - `www.…`, `//…` and a bare host are read as `https://`;
|
|
83
|
+
* - the query string, the fragment and any `user:password@` are dropped —
|
|
84
|
+
* that is where a recipient's address and a session's token live;
|
|
85
|
+
* - the host is lowercased, a default port dropped, and `.` and `..`
|
|
86
|
+
* segments resolved (WHATWG URL rules);
|
|
87
|
+
* - the path stops before the first segment carrying an email address or an
|
|
88
|
+
* unrendered merge tag, and is cut back to a `/` within
|
|
89
|
+
* {@link MAX_REPUTATION_URL_PATH_LENGTH}.
|
|
90
|
+
*/
|
|
91
|
+
export declare function normalizeReputationLink(link: string): ReputationLink | null;
|
|
92
|
+
/** Is this address a host's front door — nothing a host lookup did not already ask? */
|
|
93
|
+
export declare function isRootReputationUrl(url: string): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* The addresses worth asking about beside `hosts`: those on one of them
|
|
96
|
+
* whose path is more than `/`, each once, taken a host at a time — every
|
|
97
|
+
* host's first address, then every host's second — up to `max`
|
|
98
|
+
* ({@link MAX_REPUTATION_URLS_PER_LOOKUP}). A link that does not normalize
|
|
99
|
+
* is skipped.
|
|
100
|
+
*/
|
|
101
|
+
export declare function pickReputationUrls(links: readonly string[], hosts: readonly string[], max?: number): string[];
|
|
102
|
+
/** The foreign hosts a set of links points at, and the addresses on them worth asking about. */
|
|
103
|
+
export interface ForeignReputationLinks {
|
|
104
|
+
hosts: string[];
|
|
105
|
+
urls: string[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* {@link foreignHostsForReputation} over links — hosts or whole links — and
|
|
109
|
+
* the addresses on those hosts ({@link pickReputationUrls}). The addresses
|
|
110
|
+
* are only ever looked up in `'url'` mode; the lookup decides.
|
|
111
|
+
*/
|
|
112
|
+
export declare function foreignLinksForReputation(links: readonly string[], options?: {
|
|
113
|
+
ownDomains?: readonly (string | null | undefined)[];
|
|
114
|
+
excludeDomains?: readonly (string | null | undefined)[];
|
|
115
|
+
max?: number;
|
|
116
|
+
maxUrls?: number;
|
|
117
|
+
}): ForeignReputationLinks;
|
|
50
118
|
/**
|
|
51
119
|
* The hosts worth asking about: foreign ones. Not the workspace's own, not
|
|
52
120
|
* a domain under one of `excludeDomains` (the platform's own), not a listed
|
|
@@ -63,11 +131,13 @@ export declare function foreignHostsForReputation(hosts: readonly string[], opti
|
|
|
63
131
|
/** A listing, as the screen's signal. */
|
|
64
132
|
export declare function webRiskSignals(hits: readonly HostReputationHit[]): PhishingScreenSignal[];
|
|
65
133
|
/**
|
|
66
|
-
* Look the foreign
|
|
67
|
-
* signals they carry.
|
|
68
|
-
*
|
|
134
|
+
* Look the foreign links up through the installed lookup and answer the
|
|
135
|
+
* signals they carry. `links` are hosts or whole links (`linkUrlsIn`): the
|
|
136
|
+
* hosts are asked about, and their addresses too when the lookup is in
|
|
137
|
+
* `'url'` mode. Nothing installed, nothing foreign, an error: no signals.
|
|
138
|
+
* Never throws.
|
|
69
139
|
*/
|
|
70
|
-
export declare function linkReputationSignals(
|
|
140
|
+
export declare function linkReputationSignals(links: readonly string[], options?: {
|
|
71
141
|
ownDomains?: readonly (string | null | undefined)[];
|
|
72
142
|
excludeDomains?: readonly (string | null | undefined)[];
|
|
73
143
|
deadlineMs?: number;
|
|
@@ -37,18 +37,47 @@
|
|
|
37
37
|
* A lookup that errors or times out says nothing about the host. It never
|
|
38
38
|
* holds by itself; the lookup reports the host as unknown and logs a warning.
|
|
39
39
|
*
|
|
40
|
-
* ##
|
|
40
|
+
* ## The host, and — only when switched on — the path
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* exists to harvest, and it is the least a third party can be told.
|
|
45
|
-
|
|
42
|
+
* By default (`platformSettings/webRisk.lookupMode: 'host'`) the lookup sees
|
|
43
|
+
* `https://<host>/` and nothing else. A host is enough to catch a domain
|
|
44
|
+
* that exists to harvest, and it is the least a third party can be told.
|
|
45
|
+
*
|
|
46
|
+
* A phishing kit as often sits at a path on a compromised site that is
|
|
47
|
+
* otherwise legitimate, which a host lookup reads as clean. In `'url'` mode
|
|
48
|
+
* (AGL-3459) each link's address is looked up too, as
|
|
49
|
+
* {@link normalizeReputationLink} reduces it: scheme, host and path. Never
|
|
50
|
+
* the query string or the fragment, which is where a recipient's address
|
|
51
|
+
* and a session's token live; never a `user:password@`; and the path stops
|
|
52
|
+
* before any segment that carries an email address or an unrendered merge
|
|
53
|
+
* tag. Sending paths makes Google a recipient for a new purpose, so the
|
|
54
|
+
* switch is flipped only once the Subprocessors page names it.
|
|
55
|
+
*
|
|
56
|
+
* ## The budget
|
|
57
|
+
*
|
|
58
|
+
* One page, message or redirect asks about at most
|
|
59
|
+
* {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} hosts and, in `'url'` mode, at most
|
|
60
|
+
* {@link MAX_REPUTATION_URLS_PER_LOOKUP} addresses, deduplicated and taken a
|
|
61
|
+
* host at a time ({@link pickReputationUrls}), so a long list of links to
|
|
62
|
+
* one site cannot crowd out the one link to another.
|
|
63
|
+
*=========================================*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
64
|
+
import { isAnyOfficialBrandDomain, isCommonLinkDomain, registrableDomain } from "./outbound-phishing-screen.js";
|
|
46
65
|
export const LINK_THREAT_TYPES = [
|
|
47
66
|
'SOCIAL_ENGINEERING',
|
|
48
67
|
'MALWARE',
|
|
49
68
|
'UNWANTED_SOFTWARE'
|
|
50
69
|
];
|
|
51
70
|
/** The most foreign hosts one page, message or redirect is looked up for. */ export const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20;
|
|
71
|
+
/**
|
|
72
|
+
* The most link addresses (path included) one page, message or redirect is
|
|
73
|
+
* looked up for in `'url'` mode, beside its hosts (AGL-3459). With the hosts,
|
|
74
|
+
* at most forty calls, all inside the same per-page deadline.
|
|
75
|
+
*/ export const MAX_REPUTATION_URLS_PER_LOOKUP = 20;
|
|
76
|
+
/**
|
|
77
|
+
* The longest path an address keeps. Longer is cut back to the last `/`
|
|
78
|
+
* within it, so what is looked up is still a path the link really starts
|
|
79
|
+
* with — Web Risk lists a kit by its path prefix.
|
|
80
|
+
*/ export const MAX_REPUTATION_URL_PATH_LENGTH = 512;
|
|
52
81
|
let installedLookup = null;
|
|
53
82
|
/** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */ export function setLinkReputationLookup(lookup) {
|
|
54
83
|
installedLookup = lookup;
|
|
@@ -64,6 +93,99 @@ const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0
|
|
|
64
93
|
const value = String(host != null ? host : '').trim().toLowerCase().replace(/\.+$/, '');
|
|
65
94
|
return value.length <= 253 && HOST_PATTERN.test(value) ? value : null;
|
|
66
95
|
}
|
|
96
|
+
/** A path segment that carries an email address, or a merge tag nobody has filled in. */ const PRIVATE_SEGMENT = /@|%40|\{|%7b|\}|%7d/i;
|
|
97
|
+
/**
|
|
98
|
+
* A link reduced to what Web Risk may be asked about it (AGL-3459), or null
|
|
99
|
+
* for anything that is not an `http(s)` link to a real host.
|
|
100
|
+
*
|
|
101
|
+
* - `www.…`, `//…` and a bare host are read as `https://`;
|
|
102
|
+
* - the query string, the fragment and any `user:password@` are dropped —
|
|
103
|
+
* that is where a recipient's address and a session's token live;
|
|
104
|
+
* - the host is lowercased, a default port dropped, and `.` and `..`
|
|
105
|
+
* segments resolved (WHATWG URL rules);
|
|
106
|
+
* - the path stops before the first segment carrying an email address or an
|
|
107
|
+
* unrendered merge tag, and is cut back to a `/` within
|
|
108
|
+
* {@link MAX_REPUTATION_URL_PATH_LENGTH}.
|
|
109
|
+
*/ export function normalizeReputationLink(link) {
|
|
110
|
+
let text = String(link != null ? link : '').trim();
|
|
111
|
+
if (!text) return null;
|
|
112
|
+
if (text.startsWith('//')) text = `https:${text}`;
|
|
113
|
+
else if (/^www\./i.test(text)) text = `https://${text}`;
|
|
114
|
+
else if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(text)) {
|
|
115
|
+
if (!/^https?:\/\//i.test(text)) return null;
|
|
116
|
+
} else text = `https://${text}`;
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = new URL(text);
|
|
120
|
+
} catch (unused) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
|
|
124
|
+
const host = normalizeReputationHost(parsed.hostname);
|
|
125
|
+
if (!host) return null;
|
|
126
|
+
const kept = [];
|
|
127
|
+
for (const segment of parsed.pathname.split('/')){
|
|
128
|
+
if (PRIVATE_SEGMENT.test(segment)) {
|
|
129
|
+
kept.push('');
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
kept.push(segment);
|
|
133
|
+
}
|
|
134
|
+
let path = kept.join('/') || '/';
|
|
135
|
+
if (path.length > MAX_REPUTATION_URL_PATH_LENGTH) {
|
|
136
|
+
path = path.slice(0, path.lastIndexOf('/', MAX_REPUTATION_URL_PATH_LENGTH - 1) + 1);
|
|
137
|
+
}
|
|
138
|
+
const port = parsed.port ? `:${parsed.port}` : '';
|
|
139
|
+
return {
|
|
140
|
+
host,
|
|
141
|
+
url: `${parsed.protocol}//${host}${port}${path}`
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Is this address a host's front door — nothing a host lookup did not already ask? */ export function isRootReputationUrl(url) {
|
|
145
|
+
return /^https?:\/\/[^/]+\/$/.test(url);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The addresses worth asking about beside `hosts`: those on one of them
|
|
149
|
+
* whose path is more than `/`, each once, taken a host at a time — every
|
|
150
|
+
* host's first address, then every host's second — up to `max`
|
|
151
|
+
* ({@link MAX_REPUTATION_URLS_PER_LOOKUP}). A link that does not normalize
|
|
152
|
+
* is skipped.
|
|
153
|
+
*/ export function pickReputationUrls(links, hosts, max = MAX_REPUTATION_URLS_PER_LOOKUP) {
|
|
154
|
+
const byHost = new Map(hosts.map((host)=>[
|
|
155
|
+
host,
|
|
156
|
+
[]
|
|
157
|
+
]));
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
for (const raw of links){
|
|
160
|
+
const link = normalizeReputationLink(raw);
|
|
161
|
+
if (!link || seen.has(link.url) || isRootReputationUrl(link.url)) continue;
|
|
162
|
+
const list = byHost.get(link.host);
|
|
163
|
+
if (!list) continue;
|
|
164
|
+
seen.add(link.url);
|
|
165
|
+
list.push(link.url);
|
|
166
|
+
}
|
|
167
|
+
const picked = [];
|
|
168
|
+
for(let round = 0; picked.length < max; round += 1){
|
|
169
|
+
const before = picked.length;
|
|
170
|
+
for (const list of byHost.values()){
|
|
171
|
+
if (round < list.length && picked.length < max) picked.push(list[round]);
|
|
172
|
+
}
|
|
173
|
+
if (picked.length === before) break;
|
|
174
|
+
}
|
|
175
|
+
return picked;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* {@link foreignHostsForReputation} over links — hosts or whole links — and
|
|
179
|
+
* the addresses on those hosts ({@link pickReputationUrls}). The addresses
|
|
180
|
+
* are only ever looked up in `'url'` mode; the lookup decides.
|
|
181
|
+
*/ export function foreignLinksForReputation(links, options = {}) {
|
|
182
|
+
const parsed = links.map((link)=>normalizeReputationLink(link)).filter((link)=>Boolean(link));
|
|
183
|
+
const hosts = foreignHostsForReputation(parsed.map((link)=>link.host), options);
|
|
184
|
+
return {
|
|
185
|
+
hosts,
|
|
186
|
+
urls: pickReputationUrls(parsed.map((link)=>link.url), hosts, options.maxUrls)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
67
189
|
/**
|
|
68
190
|
* The hosts worth asking about: foreign ones. Not the workspace's own, not
|
|
69
191
|
* a domain under one of `excludeDomains` (the platform's own), not a listed
|
|
@@ -92,28 +214,33 @@ const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0
|
|
|
92
214
|
return out;
|
|
93
215
|
}
|
|
94
216
|
/** A listing, as the screen's signal. */ export function webRiskSignals(hits) {
|
|
95
|
-
return hits.map((hit)=>({
|
|
217
|
+
return hits.map((hit)=>_extends({
|
|
96
218
|
code: 'web-risk-link',
|
|
97
219
|
host: hit.host,
|
|
98
220
|
threats: [
|
|
99
221
|
...new Set(hit.threats)
|
|
100
222
|
].sort()
|
|
101
|
-
}
|
|
223
|
+
}, hit.url ? {
|
|
224
|
+
url: hit.url
|
|
225
|
+
} : {}));
|
|
102
226
|
}
|
|
103
227
|
/**
|
|
104
|
-
* Look the foreign
|
|
105
|
-
* signals they carry.
|
|
106
|
-
*
|
|
107
|
-
|
|
228
|
+
* Look the foreign links up through the installed lookup and answer the
|
|
229
|
+
* signals they carry. `links` are hosts or whole links (`linkUrlsIn`): the
|
|
230
|
+
* hosts are asked about, and their addresses too when the lookup is in
|
|
231
|
+
* `'url'` mode. Nothing installed, nothing foreign, an error: no signals.
|
|
232
|
+
* Never throws.
|
|
233
|
+
*/ export async function linkReputationSignals(links, options = {}) {
|
|
108
234
|
const lookup = installedLookup;
|
|
109
235
|
if (!lookup) return [];
|
|
110
|
-
const foreign =
|
|
111
|
-
if (!foreign.length) return [];
|
|
236
|
+
const foreign = foreignLinksForReputation(links, options);
|
|
237
|
+
if (!foreign.hosts.length) return [];
|
|
112
238
|
try {
|
|
113
|
-
const answer = await lookup(foreign, {
|
|
114
|
-
deadlineMs: options.deadlineMs
|
|
239
|
+
const answer = await lookup(foreign.hosts, {
|
|
240
|
+
deadlineMs: options.deadlineMs,
|
|
241
|
+
urls: foreign.urls
|
|
115
242
|
});
|
|
116
|
-
return webRiskSignals(answer.hits.filter((hit)=>foreign.includes(hit.host)));
|
|
243
|
+
return webRiskSignals(answer.hits.filter((hit)=>foreign.hosts.includes(hit.host) && (!hit.url || foreign.urls.includes(hit.url))));
|
|
117
244
|
} catch (error) {
|
|
118
245
|
console.warn('[link-reputation] the lookup failed — no signal', error);
|
|
119
246
|
return [];
|
|
@@ -1 +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"}
|
|
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 * ## The host, and — only when switched on — the path\n *\n * By default (`platformSettings/webRisk.lookupMode: 'host'`) the lookup sees\n * `https://<host>/` and nothing else. A host is enough to catch a domain\n * that exists to harvest, and it is the least a third party can be told.\n *\n * A phishing kit as often sits at a path on a compromised site that is\n * otherwise legitimate, which a host lookup reads as clean. In `'url'` mode\n * (AGL-3459) each link's address is looked up too, as\n * {@link normalizeReputationLink} reduces it: scheme, host and path. Never\n * the query string or the fragment, which is where a recipient's address\n * and a session's token live; never a `user:password@`; and the path stops\n * before any segment that carries an email address or an unrendered merge\n * tag. Sending paths makes Google a recipient for a new purpose, so the\n * switch is flipped only once the Subprocessors page names it.\n *\n * ## The budget\n *\n * One page, message or redirect asks about at most\n * {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} hosts and, in `'url'` mode, at most\n * {@link MAX_REPUTATION_URLS_PER_LOOKUP} addresses, deduplicated and taken a\n * host at a time ({@link pickReputationUrls}), so a long list of links to\n * one site cannot crowd out the one link to another.\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/**\n * A host the list names, or one address on it, and what it names it for.\n * `url` is set when the listing is the address's, not the host's.\n */\nexport interface HostReputationHit {\n host: string\n threats: LinkThreatType[]\n /** The address the list names (scheme, host and path), when it is one page on a clean host. */\n url?: string\n}\n\n/** What one lookup answered, host by host and address by address. */\nexport interface LinkReputationAnswer {\n /** Hosts, and addresses, the list names. */\n hits: HostReputationHit[]\n /** Hosts and addresses the list does not name. */\n clean: string[]\n /** Hosts and addresses 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 * The links' addresses, as {@link normalizeReputationLink} reduced them —\n * looked up only in `'url'` mode, and only on the hosts asked about.\n */\n urls?: readonly string[]\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/**\n * The most link addresses (path included) one page, message or redirect is\n * looked up for in `'url'` mode, beside its hosts (AGL-3459). With the hosts,\n * at most forty calls, all inside the same per-page deadline.\n */\nexport const MAX_REPUTATION_URLS_PER_LOOKUP = 20\n/**\n * The longest path an address keeps. Longer is cut back to the last `/`\n * within it, so what is looked up is still a path the link really starts\n * with — Web Risk lists a kit by its path prefix.\n */\nexport const MAX_REPUTATION_URL_PATH_LENGTH = 512\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/** A link reduced to what may be looked up: its host, and its address without query or fragment. */\nexport interface ReputationLink {\n host: string\n /** `scheme://host[:port]/path` — never a query string, a fragment or a `user:password@`. */\n url: string\n}\n\n/** A path segment that carries an email address, or a merge tag nobody has filled in. */\nconst PRIVATE_SEGMENT = /@|%40|\\{|%7b|\\}|%7d/i\n\n/**\n * A link reduced to what Web Risk may be asked about it (AGL-3459), or null\n * for anything that is not an `http(s)` link to a real host.\n *\n * - `www.…`, `//…` and a bare host are read as `https://`;\n * - the query string, the fragment and any `user:password@` are dropped —\n * that is where a recipient's address and a session's token live;\n * - the host is lowercased, a default port dropped, and `.` and `..`\n * segments resolved (WHATWG URL rules);\n * - the path stops before the first segment carrying an email address or an\n * unrendered merge tag, and is cut back to a `/` within\n * {@link MAX_REPUTATION_URL_PATH_LENGTH}.\n */\nexport function normalizeReputationLink(link: string): ReputationLink | null {\n let text = String(link ?? '').trim()\n if (!text) return null\n if (text.startsWith('//')) text = `https:${text}`\n else if (/^www\\./i.test(text)) text = `https://${text}`\n else if (/^[a-z][a-z0-9+.-]*:(?!\\d)/i.test(text)) {\n if (!/^https?:\\/\\//i.test(text)) return null\n } else text = `https://${text}`\n let parsed: URL\n try {\n parsed = new URL(text)\n } catch {\n return null\n }\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null\n const host = normalizeReputationHost(parsed.hostname)\n if (!host) return null\n const kept: string[] = []\n for (const segment of parsed.pathname.split('/')) {\n if (PRIVATE_SEGMENT.test(segment)) {\n kept.push('')\n break\n }\n kept.push(segment)\n }\n let path = kept.join('/') || '/'\n if (path.length > MAX_REPUTATION_URL_PATH_LENGTH) {\n path = path.slice(0, path.lastIndexOf('/', MAX_REPUTATION_URL_PATH_LENGTH - 1) + 1)\n }\n const port = parsed.port ? `:${parsed.port}` : ''\n return { host, url: `${parsed.protocol}//${host}${port}${path}` }\n}\n\n/** Is this address a host's front door — nothing a host lookup did not already ask? */\nexport function isRootReputationUrl(url: string): boolean {\n return /^https?:\\/\\/[^/]+\\/$/.test(url)\n}\n\n/**\n * The addresses worth asking about beside `hosts`: those on one of them\n * whose path is more than `/`, each once, taken a host at a time — every\n * host's first address, then every host's second — up to `max`\n * ({@link MAX_REPUTATION_URLS_PER_LOOKUP}). A link that does not normalize\n * is skipped.\n */\nexport function pickReputationUrls(\n links: readonly string[],\n hosts: readonly string[],\n max: number = MAX_REPUTATION_URLS_PER_LOOKUP,\n): string[] {\n const byHost = new Map<string, string[]>(hosts.map((host) => [host, []]))\n const seen = new Set<string>()\n for (const raw of links) {\n const link = normalizeReputationLink(raw)\n if (!link || seen.has(link.url) || isRootReputationUrl(link.url)) continue\n const list = byHost.get(link.host)\n if (!list) continue\n seen.add(link.url)\n list.push(link.url)\n }\n const picked: string[] = []\n for (let round = 0; picked.length < max; round += 1) {\n const before = picked.length\n for (const list of byHost.values()) {\n if (round < list.length && picked.length < max) picked.push(list[round])\n }\n if (picked.length === before) break\n }\n return picked\n}\n\n/** The foreign hosts a set of links points at, and the addresses on them worth asking about. */\nexport interface ForeignReputationLinks {\n hosts: string[]\n urls: string[]\n}\n\n/**\n * {@link foreignHostsForReputation} over links — hosts or whole links — and\n * the addresses on those hosts ({@link pickReputationUrls}). The addresses\n * are only ever looked up in `'url'` mode; the lookup decides.\n */\nexport function foreignLinksForReputation(\n links: readonly string[],\n options: {\n ownDomains?: readonly (string | null | undefined)[]\n excludeDomains?: readonly (string | null | undefined)[]\n max?: number\n maxUrls?: number\n } = {},\n): ForeignReputationLinks {\n const parsed = links\n .map((link) => normalizeReputationLink(link))\n .filter((link): link is ReputationLink => Boolean(link))\n const hosts = foreignHostsForReputation(\n parsed.map((link) => link.host),\n options,\n )\n return { hosts, urls: pickReputationUrls(parsed.map((link) => link.url), hosts, options.maxUrls) }\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 ...(hit.url ? { url: hit.url } : {}),\n }))\n}\n\n/**\n * Look the foreign links up through the installed lookup and answer the\n * signals they carry. `links` are hosts or whole links (`linkUrlsIn`): the\n * hosts are asked about, and their addresses too when the lookup is in\n * `'url'` mode. Nothing installed, nothing foreign, an error: no signals.\n * Never throws.\n */\nexport async function linkReputationSignals(\n links: 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 = foreignLinksForReputation(links, options)\n if (!foreign.hosts.length) return []\n try {\n const answer = await lookup(foreign.hosts, { deadlineMs: options.deadlineMs, urls: foreign.urls })\n return webRiskSignals(\n answer.hits.filter(\n (hit) => foreign.hosts.includes(hit.host) && (!hit.url || foreign.urls.includes(hit.url)),\n ),\n )\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","MAX_REPUTATION_URLS_PER_LOOKUP","MAX_REPUTATION_URL_PATH_LENGTH","installedLookup","setLinkReputationLookup","lookup","getLinkReputationLookup","resetLinkReputationLookupForTests","HOST_PATTERN","normalizeReputationHost","host","value","String","trim","toLowerCase","replace","length","test","PRIVATE_SEGMENT","normalizeReputationLink","link","text","startsWith","parsed","URL","protocol","hostname","kept","segment","pathname","split","push","path","join","slice","lastIndexOf","port","url","isRootReputationUrl","pickReputationUrls","links","hosts","max","byHost","Map","map","seen","Set","raw","has","list","get","add","picked","round","before","values","foreignLinksForReputation","options","filter","Boolean","foreignHostsForReputation","urls","maxUrls","under","domain","own","ownDomains","excludeDomains","out","includes","some","endsWith","registrable","webRiskSignals","hits","hit","code","threats","sort","linkReputationSignals","foreign","answer","deadlineMs","error","console","warn"],"mappings":"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"}
|