import { BrowserInfo, findBrowserInfo } from "./BrowserInfo"; import { findCampaignInfo } from "./CampaignInfo"; import AttachmentOpen from "./events/AttachmentOpen"; import Click from "./events/Click"; import Download from "./events/Download"; import Extension from "./events/Extension"; import FileDownload from "./events/FileDownload"; import FileOpen from "./events/FileOpen"; import Input from "./events/Input"; import Microphone from "./events/Microphone"; import Notification from "./events/Notification"; import PageLeft from "./events/PageLeft"; import PageLoaded from "./events/PageLoaded"; import PageRead from "./events/PageRead"; import Submit from "./events/Submit"; import Webcam from "./events/Webcam"; import Location from "./events/Location"; import Clipboard from "./events/Clipboard"; import CommandCopied from "./events/CommandCopied"; import ConsentGranted from "./events/ConsentGranted"; import IEvent from "./intefaces/IEvent"; import { IEventPayloadDraft } from "./intefaces/IEventPayload"; import Remote from "./Remote"; import EventSender from "./EventSender"; import Logger from "./Logger"; import IOptions from "./intefaces/IOption"; import ICampaignInfo from "./intefaces/ICampaignInfo"; import { createTranslator, resolveLocale } from "./i18n"; import { scanPage } from "./tutorial/AttackDetector"; import { Tutorial } from "./tutorial/Tutorial"; import { Detection } from "./tutorial/types"; // How long the overlay gets to prove it is actually on screen before the // redirect happens anyway. const TUTORIAL_WATCHDOG_MS = 1200; interface EventSubscription { eventName: string; callback: (event: IEvent) => void; } export class Manager { private readonly supportedTypes : string[] = ["email", "password", "tel", "text", "given-name", "name", "family-name", "street-address", "cc-name", "cc-given-name", "cc-family-name", "cc-number", "cc-exp", "cc-exp-month", "cc-exp-year", "cc-csc", "cc-type"]; private readonly supportedEvents = { "attachment_opened": AttachmentOpen, "button_clicked": Click, "download": Download, "extension_installed": Extension, "file_download": FileDownload, "file_open": FileOpen, "input_filled": Input, "page_left": PageLeft, "page_loaded": PageLoaded, "page_read": PageRead, "mic_accepted": Microphone, "notification_accepted": Notification, "form_submitted": Submit, "webcam_accepted": Webcam, "location_accepted": Location, "clipboard_accepted": Clipboard, "command_copied": CommandCopied, "consent_granted": ConsentGranted, }; private readonly browserInfoPromise: Promise; private readonly campaignInfo: ICampaignInfo; private readonly redirectUrl: string; private readonly shouldRedirect: boolean; private readonly sender: EventSender; private readonly source: string; private readonly token: string; private subscriptions: EventSubscription[] = []; private logger: Logger; private handlers = []; private disabledEvents = []; private activeEvents: IEvent[]; private extraPayload: object = {}; private readonly tutorialEnabled: boolean; private readonly tutorialSkippable: boolean; private readonly locale?: string; private tutorial: Tutorial | null = null; constructor(remote: Remote, { eventsToInclude = [], eventsToExclude = [], source, redirectUrl, shouldRedirect, extraPayload, debug = false, tutorial = false, tutorialSkippable = true, locale }: IOptions) { this.logger = new Logger(debug); this.tutorialEnabled = tutorial; this.tutorialSkippable = tutorialSkippable; this.locale = locale; this.sender = new EventSender(remote, this.logger); [this.token, this.campaignInfo] = findCampaignInfo(); this.browserInfoPromise = findBrowserInfo(); this.activeEvents = this.decideActiveEvents(eventsToInclude, eventsToExclude); this.logger.info(`Enabled events: ${eventsToInclude.join(" | ")}`); this.source = source; this.redirectUrl = redirectUrl; this.shouldRedirect = shouldRedirect; this.extraPayload = extraPayload; if (this.campaignInfo?.download_type) { this.checkDownload().then(() => { // }); } } /** * Decides which events should be active based on the provided lists of events to include and exclude. * * @param {string[]} eventsToInclude - The list of event names to include. * @param {string[]} eventsToExclude - The list of event names to exclude. * @returns {IEvent[]} - The list of active events. */ private decideActiveEvents(eventsToInclude: string[], eventsToExclude: string[]): IEvent[] { if (eventsToInclude.length) { return eventsToInclude.map(name => this.getEvent(name)).filter(event => event !== null); } const activeEvents = Object.keys(this.supportedEvents).map(name => this.getEvent(name)).filter(event => event !== null); if (eventsToExclude.length) { return activeEvents.filter(event => !eventsToExclude.includes(event.name)); } return activeEvents; } /** * Retrieves an event instance by its name. * * @param {string} name - The name of the event to retrieve. * @returns {IEvent | null} - The event instance if found, otherwise null. */ private getEvent(name: string) { if (! this.supportedEvents[name]) { return null; } return new (this.supportedEvents[name]); } /** * Checks the type of download specified in the campaign information and triggers the corresponding event. * * @returns {Promise} - A promise that resolves when the appropriate event is triggered. */ private checkDownload() { switch (this.campaignInfo.download_type) { case "file": return this.trigger("file_opened"); case "attachment": return this.trigger("attachment_opened"); } } /** * Starts listening for the active events and sets up the event handlers. */ public listen() { let i = 0; for(const activeEvent of this.activeEvents) { if (! activeEvent.trigger) { this.logger.info(`The active event ${activeEvent.name} does not have a trigger. Skipping...`); } else { this.logger.info(`Listening for event @${activeEvent.trigger} (${activeEvent.name})`); activeEvent.source.addEventListener(activeEvent.trigger, this.handlers[i++] = (event: Event) => this.handle(activeEvent, event)); } } } /** * Stops listening for the active events and removes the event handlers. */ public stop() { let i = 0; for(const activeEvent of this.activeEvents) { if (! activeEvent.trigger) { continue; } this.logger.info(`Stopping listening for event @${activeEvent.trigger} (${activeEvent.name})`); activeEvent.source.removeEventListener(activeEvent.trigger, this.handlers[i++]); } } /** * Manually triggers the specified event by its name. * * @param {string} eventName - The name of the event to trigger. * @returns {Promise} - A promise that resolves when the event is executed. * @throws {Error} - Throws an error if the event is unsupported. */ public trigger(eventName: string): Promise { const activeEvent = this.getEvent(eventName); if (!activeEvent) { throw new Error(`Unsupported event ${eventName}. Please choose one of ${Object.keys(this.supportedEvents).join(", ")}`); } return this.executeEvent(activeEvent, null, false); } /** * Finds the type of the input element associated with the active event. * * @param {IEvent} activeEvent - The active event to find the type for. * @param {Event} [event] - The optional event object. * @returns {string | null} - The type of the input element if found, otherwise null. */ private findType(activeEvent: IEvent, event?: Event): string | null { if (activeEvent.typeOf) { return activeEvent.typeOf(event); } if (!activeEvent.hasTypes || !event) { return null; } const inputElement = event.target as HTMLInputElement; const type = inputElement.getAttribute("autocomplete") || inputElement.type; if (this.supportedTypes.indexOf(type) !== -1) { return type; } return null; } /** * Finds the name of the active event, optionally including the type if available. * * @param {IEvent} activeEvent - The active event to find the name for. * @param {Event} [event] - The optional event object. * @returns {string} - The name of the active event, optionally including the type. */ private findName(activeEvent: IEvent, event?: Event): string { const type = this.findType(activeEvent, event); if (!type) { return activeEvent.name; } return `${activeEvent.name}-${type}`; } /** * Packs the event data into an `IEventPayloadDraft` object. * * @param {string} type - The type of the event. * @param {IEvent} activeEvent - The active event to be packed. * @param {BrowserInfo} browserInfo - The resolved browser information. * @returns {IEventPayloadDraft} - The packed event payload. */ private packEvent(type: string, activeEvent: IEvent, browserInfo: BrowserInfo): IEventPayloadDraft { return { "data": { ...browserInfo, type, }, "source": this.source, "timestamp": Math.floor(Date.now() / 1000), "ats_header": this.token, "event": activeEvent.name.toLowerCase(), "sg_message_id": this.campaignInfo.ats_instance_id, ...this.extraPayload, }; } /** * Handles the active event by executing it, optionally validating it first. * * @param {IEvent} activeEvent - The active event to handle. * @param {Event} [event] - The optional event object. * @param {boolean} [shouldValidate=true] - Whether to validate the event before handling it. */ private handle(activeEvent: IEvent, event?: Event, shouldValidate = true): void { this.executeEvent(activeEvent, event, shouldValidate) .catch(e => this.logger.error(e)); } /** * Checks if the active event is valid, optionally validating it first. * * @param {IEvent} activeEvent - The active event to check. * @param {Event} event - The event object. * @param {boolean} [shouldValidate=true] - Whether to validate the event before checking it. * @throws {Error} - Throws an error if the event is not valid and should be validated. */ private checkEvent(activeEvent: IEvent, event: Event, shouldValidate = true): void { if (! activeEvent.isValid(event) && shouldValidate) { throw new Error(`Event @${activeEvent.trigger} (${activeEvent.name}) not valid...`); } } /** * Checks if the active event allows multiple instances and prevents duplicates. * * @param {IEvent} activeEvent - The active event to check. * @param {Event} [event] - The optional event object. * @throws {Error} - Throws an error if the event does not allow multiple instances and a duplicate is found. */ private checkMultiple(activeEvent: IEvent, event?: Event): void { if (!activeEvent.allowMultiple) { const name = this.findName(activeEvent, event); if (this.disabledEvents.includes(name)) { throw new Error(`Preventing duplicate event @${activeEvent.trigger} (${name}).`); } this.disabledEvents.push(name); } } private shouldSkipDedupedEvent(activeEvent: IEvent, event?: Event): boolean { if (!activeEvent.shouldDedup || !event || !(event.target instanceof HTMLElement)) { return false; } const attributeName = `data-ats-${activeEvent.name.replace(/_/g, "-")}-recorded`; if (event.target.hasAttribute(attributeName)) { this.logger.info(`Preventing duplicate event @${activeEvent.trigger} (${activeEvent.name}).`); return true; } event.target.setAttribute(attributeName, "true"); return false; } /** * Executes the active event, optionally validating it first. * * @param {IEvent} activeEvent - The active event to execute. * @param {Event} [event] - The optional event object. * @param {boolean} [shouldValidate=true] - Whether to validate the event before executing it. * @returns {Promise} - A promise that resolves when the event is executed. */ private async executeEvent(activeEvent: IEvent, event?: Event, shouldValidate = true): Promise { this.logger.info(`Event @${activeEvent.trigger} (${activeEvent.name}) triggered...`); try { this.checkEvent(activeEvent, event, shouldValidate); } catch (e) { this.logger.error(e); throw e; } if (event && activeEvent.isBlocking) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); } if (activeEvent.perform) { // Before the duplicate check: a second click on a copy button // still copies, it just isn't recorded again. try { activeEvent.perform(event); } catch (e) { this.logger.error(e); } } try { this.checkMultiple(activeEvent, event); } catch(e) { this.logger.error(e); throw e; } if (this.shouldSkipDedupedEvent(activeEvent, event)) { return; } const type = this.findType(activeEvent, event); // Scan the page before anything is awaited: the page's own handlers run // first and often replace the form with a spinner, which would leave // nothing to explain by the time the request finishes. const detection = activeEvent.redirectOnFinish && this.shouldRedirect && this.tutorialEnabled ? this.scanSafely(activeEvent) : null; try { // A throwing subscriber must never strand the victim: the original // action was already cancelled by preventDefault above. this.triggerSubscription(activeEvent); } catch (e) { this.logger.error(e); } try { const browserInfo = await this.browserInfoPromise; const payload = this.packEvent(type, activeEvent, browserInfo); const result = await this.sender.send(payload, { maxAttempts: activeEvent.redirectOnFinish && this.shouldRedirect ? 1 : undefined, }); if (result) { this.logger.info(result.body); } } catch (e) { this.logger.error(e); } finally { if (activeEvent.redirectOnFinish && this.shouldRedirect) { this.finishEvent(detection); } } } /** * Scans the page for the tutorial without ever letting a scan failure * escape into the redirect path. * * @param {IEvent} activeEvent - The event being executed. * @returns {Detection | null} - The detection, or null if scanning failed. */ private scanSafely(activeEvent: IEvent): Detection | null { try { // The campaign token may name the brand its landing page imitates; // otherwise the page itself is inspected. return scanPage(activeEvent.name, document, this.campaignInfo?.ats_brand); } catch (e) { this.logger.error(e); return null; } } /** * Finishes a redirecting event: opens the educational tutorial when it * is enabled (the redirect then happens once the user completes or * skips it), otherwise redirects immediately. * * @param {Detection | null} detection - The page scan taken before sending. */ private finishEvent(detection: Detection | null): void { if (!this.tutorialEnabled || !detection) { this.performRedirect(); return; } try { const tutorial = this.getTutorial(); if (tutorial.isOpen) { return; } tutorial.open(detection); // The overlay can be neutered by the page it sits on — hidden by a // CSS rule, removed by a MutationObserver, or left unstyled by a // strict CSP. If it isn't actually on screen shortly after opening, // fall back to the redirect rather than stranding the victim. window.setTimeout(() => { if (!tutorial.isUsable) { this.logger.error("Tutorial overlay is not usable, redirecting instead."); this.performRedirect(); } }, TUTORIAL_WATCHDOG_MS); } catch (e) { this.logger.error(e); this.performRedirect(); } } private performRedirect(): void { // Merge the current query params into the redirect URL instead of // concatenating. redirectUrl may already carry a query string (e.g. a // router link with edu/pill_id/locale); a naive `${redirectUrl}${search}` // produces a second "?" that swallows our params (notably the tk token). const dest = new URL(this.redirectUrl, window.location.origin); new URLSearchParams(window.location.search).forEach((value, key) => { dest.searchParams.set(key, value); }); window.location.href = dest.toString(); } private getTutorial(): Tutorial { if (!this.tutorial) { const resolved = resolveLocale(this.locale, this.campaignInfo?.ats_locale, navigator.language); this.logger.info(`Tutorial locale: ${resolved}`); this.tutorial = new Tutorial(createTranslator(resolved), this.tutorialSkippable, () => this.performRedirect(), this.logger); } return this.tutorial; } get supportedEventNames(): string[] { return Object.keys(this.supportedEvents); } /** * Subscribes to the specified event with a callback function. * * @param {string} eventName - The name of the event to subscribe to. * @param {function(IEvent): void} callback - The callback function to be executed when the event is triggered. * @throws {Error} - Throws an error if the event is unsupported. */ public subscribe(eventName: string, callback: (event: IEvent) => void) { if (! this.supportedEventNames.includes(eventName)) { throw new Error(`Unsupported event: ${eventName}`); } this.subscriptions.push({ eventName, callback}); } /** * Unsubscribes from the specified event by removing the callback function. * * @param {string} eventName - The name of the event to unsubscribe from. * @param {function(IEvent): void} callback - The callback function to be removed. * @throws {Error} - Throws an error if the event is unsupported. */ public unsubscribe(eventName: string, callback: (event: IEvent) => void) { if (! this.supportedEventNames.includes(eventName)) { throw new Error(`Unsupported event: ${eventName}`); } // find the event in the subscriptions array and remove it const eventIndex = this.subscriptions.findIndex(subscription => subscription.eventName === eventName && subscription.callback === callback); if (eventIndex >= 0) { this.subscriptions.splice(eventIndex, 1); } } /** * Triggers the subscription callbacks for the specified event. * * @param {IEvent} event - The event for which to trigger the subscription callbacks. */ public triggerSubscription(event: IEvent) { const subscriptions = this.subscriptions.filter(subscription => subscription.eventName === event.name); subscriptions.forEach(subscription => subscription.callback(event)); } }