diff --git a/injected/integration-test/breakage-reporting.spec.js b/injected/integration-test/breakage-reporting.spec.js index 2935f0c575..a94d2dc2d9 100644 --- a/injected/integration-test/breakage-reporting.spec.js +++ b/injected/integration-test/breakage-reporting.spec.js @@ -3,22 +3,106 @@ import { ResultsCollector } from './page-objects/results-collector.js'; const HTML = '/breakage-reporting/index.html'; const CONFIG = './integration-test/test-pages/breakage-reporting/config/config.json'; -test('Breakage Reporting Feature', async ({ page }, testInfo) => { - const collector = ResultsCollector.create(page, testInfo.project.use); - await collector.load(HTML, CONFIG); - - const breakageFeature = new BreakageReportingSpec(page); - await breakageFeature.navigate(); - - await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); - await collector.waitForMessage('breakageReportResult'); - const calls = await collector.outgoingMessages(); - - expect(calls.length).toBe(1); - const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); - expect(result.params?.jsPerformance.length).toBe(1); - expect(result.params?.jsPerformance[0]).toBeGreaterThan(0); - expect(result.params?.referrer).toBe('http://localhost:3220/breakage-reporting/index.html'); + +test.describe('Breakage Reporting Feature', () => { + test('collects basic metrics without detectors', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigate(); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + expect(calls.length).toBe(1); + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.jsPerformance.length).toBe(1); + expect(result.params?.jsPerformance[0]).toBeGreaterThan(0); + expect(result.params?.referrer).toBe('http://localhost:3220/breakage-reporting/index.html'); + }); + + test('detects no challenges on clean page', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/no-challenge.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(false); + expect(result.params?.detectorData?.fraudDetection.detected).toBe(false); + }); + + test('detects Cloudflare challenge', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/captcha-cloudflare.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(true); + expect(result.params?.detectorData?.botDetection.results.length).toBeGreaterThan(0); + + const cloudflareResult = result.params?.detectorData?.botDetection.results[0]; + expect(cloudflareResult.vendor).toBe('Cloudflare'); + expect(cloudflareResult.challengeType).toBe('cloudflare'); + expect(cloudflareResult.detected).toBe(true); + }); + + test('detects reCAPTCHA challenge', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/captcha-recaptcha.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(true); + + const recaptchaResult = result.params?.detectorData?.botDetection.results.find( + r => r.challengeType === 'recaptcha' + ); + expect(recaptchaResult).toBeDefined(); + expect(recaptchaResult.vendor).toBe('reCAPTCHA'); + expect(recaptchaResult.detected).toBe(true); + }); + + test('detects Fraud challenge (PerimeterX)', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/fraud-px.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.fraudDetection.detected).toBe(true); + + const fraudResult = result.params?.detectorData?.fraudDetection.results[0]; + expect(fraudResult.alertId).toBe('px'); + }); }); export class BreakageReportingSpec { @@ -30,10 +114,14 @@ export class BreakageReportingSpec { } async navigate() { - await this.page.evaluate(() => { - window.location.href = '/breakage-reporting/pages/ref.html'; - }); - await this.page.waitForURL('**/ref.html'); + await this.navigateToPage('/breakage-reporting/pages/ref.html'); + } + + async navigateToPage(url) { + await this.page.evaluate((targetUrl) => { + window.location.href = targetUrl; + }, url); + await this.page.waitForURL(`**${url}`); // Wait for first paint event to ensure we can get the performance metrics await this.page.evaluate(() => { diff --git a/injected/integration-test/test-pages/breakage-reporting/config/config.json b/injected/integration-test/test-pages/breakage-reporting/config/config.json index 11e2165348..39ce43c6f7 100644 --- a/injected/integration-test/test-pages/breakage-reporting/config/config.json +++ b/injected/integration-test/test-pages/breakage-reporting/config/config.json @@ -6,6 +6,33 @@ "breakageReporting": { "state": "enabled", "exceptions": [] + }, + "webInterferenceDetection": { + "state": "enabled", + "exceptions": [], + "settings": { + "interferenceTypes": { + "botDetection": { + "cloudflare": { + "state": "enabled", + "vendor": "Cloudflare", + "selectors": ["#challenge-running", ".cf-browser-verification"] + }, + "recaptcha": { + "state": "enabled", + "vendor": "reCAPTCHA", + "selectors": [".g-recaptcha", "iframe[src*='google.com/recaptcha']"] + } + }, + "fraudDetection": { + "px": { + "state": "enabled", + "vendor": "PerimeterX", + "selectors": ["#px-captcha", "[id^='px-']"] + } + } + } + } } } } diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html new file mode 100644 index 0000000000..3423d36dad --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html @@ -0,0 +1,23 @@ + + + + + + Mock Cloudflare Challenge + + +

Mock Cloudflare Challenge Page

+ + +
+

Checking your browser before accessing example.com

+

This process is automatic. Your browser will redirect shortly.

+
+ + + + + diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/captcha-recaptcha.html b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-recaptcha.html new file mode 100644 index 0000000000..45b458812e --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-recaptcha.html @@ -0,0 +1,18 @@ + + + + + + Mock reCAPTCHA Challenge + + +

Mock reCAPTCHA Challenge Page

+ + +
+ + + + + + diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/fraud-px.html b/injected/integration-test/test-pages/breakage-reporting/pages/fraud-px.html new file mode 100644 index 0000000000..303d920d76 --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/fraud-px.html @@ -0,0 +1,19 @@ + + + + + Mock PerimeterX Fraud Page + + + +

Mock PerimeterX Page

+ +
+ + diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/no-challenge.html b/injected/integration-test/test-pages/breakage-reporting/pages/no-challenge.html new file mode 100644 index 0000000000..a8647de43f --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/no-challenge.html @@ -0,0 +1,13 @@ + + + + + + No Challenge Page + + +

Clean Page - No Challenges

+

This page has no bot detection or fraud detection challenges.

+ + + diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md new file mode 100644 index 0000000000..7482f20bb6 --- /dev/null +++ b/injected/src/detectors/README.md @@ -0,0 +1,169 @@ +# Web Interference Detection + +This directory contains web interference detection functionality for content-scope-scripts. Detectors identify CAPTCHAs, fraud warnings, and other interference patterns to support breakage reporting and PIR automation. + +## Architecture + +The system provides simple detection utilities that can be called on-demand: + +- **Detection utilities** - Pure functions (`runBotDetection`, `runFraudDetection`) that scan DOM when called +- **Direct imports** - Features (breakage reporting, PIR) import detection functions directly +- **`WebInterferenceDetection`** - Optional ContentFeature wrapper for messaging (PIR use, not currently bundled) + + +## Directory Layout + +``` +detectors/ +├── detections/ +│ ├── bot-detection.js # CAPTCHA/bot detection utility +│ └── fraud-detection.js # fraud/phishing warning utility +├── utils/ +│ └── detection-utils.js # DOM helpers (selectors, text matching, visibility) +└── default-config.js # fallback detector settings +``` + +## How It Works + +### 1. On-Demand Detection + +Detectors are simple functions that scan the DOM when called: + +1. Feature imports detector function (e.g., `runBotDetection`) +2. Feature calls detector with config when needed (e.g., user submits breakage report) +3. Detector scans DOM and returns results immediately (~few ms) +4. No caching - each call is fresh + +### 2. Configuration + +Detectors are configured via `privacy-configuration/features/web-interference-detection.json`: + +```json +{ + "state": "enabled", + "settings": { + "autoRunDelayMs": 100, + "interferenceTypes": { + "botDetection": { + "hcaptcha": { + "state": "enabled", + "vendor": "hcaptcha", + "selectors": [".h-captcha"], + "windowProperties": ["hcaptcha"] + } + }, + "fraudDetection": { + "phishingWarning": { + "state": "enabled", + "type": "phishing", + "selectors": [".warning-banner"] + } + } + } + } +} +``` + +**Domain-specific configuration** using `conditionalChanges`: + +```json +{ + "settings": { + "conditionalChanges": [ + { + "condition": { + "urlPattern": "https://*.example.com/*" + }, + "patchSettings": [ + { + "op": "add", + "path": "/interferenceTypes/customDetector", + "value": { "state": "enabled", "selectors": [".custom"] } + } + ] + } + ] + } +} +``` + +The framework automatically applies conditional changes based on the current URL before passing settings to the feature. + +### 3. Using Detection Results + +**Breakage reporting** (internal feature): + +```javascript +import { runBotDetection, runFraudDetection } from '../detectors/detections/bot-detection.js'; + +// Get detector config from privacy-configuration +const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes'); + +if (detectorSettings) { + const result = { + botDetection: runBotDetection(detectorSettings.botDetection), + fraudDetection: runFraudDetection(detectorSettings.fraudDetection), + }; +} +``` + +**PIR/native** (via messaging, when `WebInterferenceDetection` is bundled): + +```javascript +// Via messaging +this.messaging.request('detectInterference', { + types: ['botDetection', 'fraudDetection'] +}); +``` + +## Adding New Detectors + +1. **Create detection utility** in `detections/`: + +```javascript +// detections/my-detector.js +export function runMyDetection(config = {}) { + // Run detection logic + const detected = checkSelectors(config.selectors); + + return { + detected, + type: 'myDetector', + results: [...], + }; +} +``` + +2. **Use in breakage reporting or other feature**: + +```javascript +// features/breakage-reporting.js +import { runMyDetection } from '../detectors/detections/my-detector.js'; + +const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes'); +if (detectorSettings?.myDetector) { + result.myDetectorData = runMyDetection(detectorSettings.myDetector); +} +``` + +3. **Add config** to `web-interference-detection.json`: + +```json +{ + "settings": { + "interferenceTypes": { + "myDetector": { + "state": "enabled", + "selectors": [".my-selector"] + } + } + } +} +``` + +## Performance + +- Detectors are simple DOM queries - typically < 5ms +- No caching overhead or stale results +- Only run when explicitly needed (e.g., breakage report submitted) +- Future: If frequent polling is needed, add a shared caching wrapper diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js new file mode 100644 index 0000000000..e569ce19e7 --- /dev/null +++ b/injected/src/detectors/default-config.js @@ -0,0 +1,53 @@ +export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ + botDetection: { + cloudflareTurnstile: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['turnstile'], + statusSelectors: [ + { + status: 'solved', + selectors: ['[data-state="success"]'], + }, + { + status: 'failed', + selectors: ['[data-state="error"]'], + }, + ], + }, + cloudflareChallengePage: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['#challenge-form', '.cf-browser-verification', '#cf-wrapper', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['_cf_chl_opt', '__CF$cv$params', 'cfjsd'], + }, + hcaptcha: { + state: 'enabled', + vendor: 'hcaptcha', + selectors: [ + '.h-captcha', + '[data-hcaptcha-widget-id]', + 'script[src*="hcaptcha.com"]', + 'script[src*="assets.hcaptcha.com"]', + ], + windowProperties: ['hcaptcha'], + }, + }, + fraudDetection: { + phishingWarning: { + state: 'enabled', + type: 'phishing', + selectors: ['.warning-banner', '#security-alert'], + textPatterns: ['suspicious.*activity', 'unusual.*login', 'verify.*account'], + textSources: ['innerText'], + }, + accountSuspension: { + state: 'enabled', + type: 'suspension', + selectors: ['.account-suspended', '#suspension-notice'], + textPatterns: ['account.*suspended', 'access.*restricted'], + textSources: ['innerText'], + }, + }, +}); diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js new file mode 100644 index 0000000000..7c87d92853 --- /dev/null +++ b/injected/src/detectors/detections/bot-detection.js @@ -0,0 +1,45 @@ +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +/** + * Run bot detection. + * @param {Record} config + */ +export function runBotDetection(config = {}) { + const results = Object.entries(config) + .filter(([_, challengeConfig]) => challengeConfig?.state === 'enabled') + .map(([challengeId, challengeConfig]) => { + const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); + if (!detected) { + return null; + } + + const challengeStatus = findStatus(challengeConfig.statusSelectors); + return { + detected: true, + vendor: challengeConfig.vendor, + challengeType: challengeId, + challengeStatus, + }; + }) + .filter(Boolean); + + return { + detected: results.length > 0, + type: 'botDetection', + results, + }; +} + +function findStatus(statusSelectors) { + if (!Array.isArray(statusSelectors)) { + return null; + } + + const match = statusSelectors.find((statusConfig) => { + const { selectors, textPatterns, textSources } = statusConfig; + return matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); + }); + + return match?.status ?? null; +} + diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js new file mode 100644 index 0000000000..16494fa633 --- /dev/null +++ b/injected/src/detectors/detections/fraud-detection.js @@ -0,0 +1,31 @@ +import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; + +/** + * Run fraud detection. + * @param {Record} config + */ +export function runFraudDetection(config = {}) { + const results = Object.entries(config) + .filter(([_, alertConfig]) => alertConfig?.state === 'enabled') + .map(([alertId, alertConfig]) => { + const detected = + checkSelectorsWithVisibility(alertConfig.selectors) || + checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); + if (!detected) { + return null; + } + + return { + detected: true, + alertId, + category: alertConfig.type, + }; + }) + .filter(Boolean); + + return { + detected: results.length > 0, + type: 'fraudDetection', + results, + }; +} diff --git a/injected/src/detectors/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js new file mode 100644 index 0000000000..7419596b7d --- /dev/null +++ b/injected/src/detectors/utils/detection-utils.js @@ -0,0 +1,117 @@ +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => document.querySelector(selector)); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectorsWithVisibility(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => { + const element = document.querySelector(selector); + return element && isVisible(element); + }); +} + +/** + * @param {string[]} [properties] + * @returns {boolean} + */ +export function checkWindowProperties(properties) { + if (!properties || !Array.isArray(properties)) { + return false; + } + return properties.some((prop) => typeof window?.[prop] !== 'undefined'); +} + +/** + * @param {Element} element + * @returns {boolean} + */ +export function isVisible(element) { + const computedStyle = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0.5 && + rect.height > 0.5 && + computedStyle.display !== 'none' && + computedStyle.visibility !== 'hidden' && + +computedStyle.opacity > 0.05 + ); +} + +/** + * @param {Element} element + * @param {string[]} [sources] + * @returns {string} + */ +export function getTextContent(element, sources) { + if (!sources || sources.length === 0) { + return element.textContent || ''; + } + return sources.map((source) => element[source] || '').join(' '); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function matchesSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + const elements = queryAllSelectors(selectors); + return elements.length > 0; +} + +/** + * @param {Element} element + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function matchesTextPatterns(element, patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + const text = getTextContent(element, sources); + return patterns.some((pattern) => { + const regex = new RegExp(pattern, 'i'); + return regex.test(text); + }); +} + +/** + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function checkTextPatterns(patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + return matchesTextPatterns(document.body, patterns, sources); +} + +/** + * @param {string[]} selectors + * @param {Element|Document} [root] + * @returns {Element[]} + */ +export function queryAllSelectors(selectors, root = document) { + if (!selectors || !Array.isArray(selectors) || selectors.length === 0) { + return []; + } + const elements = root.querySelectorAll(selectors.join(',')); + return Array.from(elements); +} diff --git a/injected/src/features.js b/injected/src/features.js index f704269a41..3d0995ad7d 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -24,6 +24,7 @@ const otherFeatures = /** @type {const} */ ([ 'duckAiDataClearing', 'harmfulApis', 'webCompat', + 'webInterferenceDetection', 'windowsPermissionUsage', 'brokerProtection', 'performanceMetrics', @@ -37,7 +38,7 @@ const otherFeatures = /** @type {const} */ ([ /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'duckAiDataClearing', 'pageContext'], + apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'webInterferenceDetection', 'duckAiDataClearing', 'pageContext'], 'apple-isolated': [ 'duckPlayer', 'duckPlayerNative', @@ -48,7 +49,7 @@ export const platformSupport = { 'messageBridge', 'favicon', ], - android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], + android: [...baseFeatures, 'webCompat', 'webInterferenceDetection', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-import': ['autofillImport'], 'android-adsjs': [ @@ -65,6 +66,7 @@ export const platformSupport = { windows: [ 'cookie', ...baseFeatures, + 'webInterferenceDetection', 'webTelemetry', 'windowsPermissionUsage', 'duckPlayer', @@ -76,7 +78,7 @@ export const platformSupport = { 'duckAiDataClearing', ], firefox: ['cookie', ...baseFeatures, 'clickToLoad'], - chrome: ['cookie', ...baseFeatures, 'clickToLoad'], - 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad'], + chrome: ['cookie', ...baseFeatures, 'clickToLoad', 'webInterferenceDetection', 'breakageReporting'], + 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad', 'webInterferenceDetection', 'breakageReporting'], integration: [...baseFeatures, ...otherFeatures], }; diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index b94172bfa7..dc00f0e9e7 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,16 +1,30 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { runBotDetection } from '../detectors/detections/bot-detection.js'; +import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; export default class BreakageReporting extends ContentFeature { init() { const isExpandedPerformanceMetricsEnabled = this.getFeatureSettingEnabled('expandedPerformanceMetrics', 'enabled'); + this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; + const result = { jsPerformance, referrer, }; + + // Only run detectors if explicitly configured + const detectorSettings = this.getFeatureSetting('interferenceTypes', 'webInterferenceDetection'); + if (detectorSettings) { + result.detectorData = { + botDetection: runBotDetection(detectorSettings.botDetection), + fraudDetection: runFraudDetection(detectorSettings.fraudDetection), + }; + } + if (isExpandedPerformanceMetricsEnabled) { const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); if (expandedPerformanceMetrics.success) { @@ -21,3 +35,4 @@ export default class BreakageReporting extends ContentFeature { }); } } + diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js new file mode 100644 index 0000000000..05b8b00ce7 --- /dev/null +++ b/injected/src/features/web-interference-detection.js @@ -0,0 +1,40 @@ +import ContentFeature from '../content-feature.js'; +import { runBotDetection } from '../detectors/detections/bot-detection.js'; +import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; + +/** + * @typedef {object} DetectInterferenceParams + * @property {string[]} [types] + */ + +export default class WebInterferenceDetection extends ContentFeature { + init() { + // Get settings with conditionalChanges already applied by framework + const settings = this.getFeatureSetting('interferenceTypes'); + const autoRunDelayMs = this.getFeatureSetting('autoRunDelayMs') ?? 100; + console.log('[web-interference] init', this.args?.site?.url, { settings, autoRunDelayMs }); + + // Auto-run placeholder. Enable this when adding detectors that need early caching (e.g., ad detection, buffering) + /* + setTimeout(() => { + if (settings?.botDetection) { + runBotDetection(settings.botDetection); + } + }, autoRunDelayMs); + */ + + // Register messaging handler for PIR/native requests + this.messaging.subscribe('detectInterference', (params) => { + const { types = [] } = /** @type {DetectInterferenceParams} */ (params ?? {}); + const results = {}; + + if (types.includes('botDetection')) { + results.botDetection = runBotDetection(settings?.botDetection); + } + if (types.includes('fraudDetection')) { + results.fraudDetection = runFraudDetection(settings?.fraudDetection); + } return results; + }); + } +} +