self::SANITY_CEILING; } public static function acted_on(array $stats) { return (int) apply_filters('adghost_ivt_acted_on', (int) $stats['obvious'], $stats); } public static function stats() { $stats = get_option('adghost_ivt_stats', []); $families = []; foreach ((array) ($stats['families'] ?? []) as $family => $count) { if (isset(AdGhost_Signal_Registry::FAMILIES[(int) $family]) && (int) $count > 0) { $families[(int) $family] = (int) $count; } } return [ 'sessions' => (int) ($stats['sessions'] ?? 0), 'invalid' => (int) ($stats['invalid'] ?? 0), 'obvious' => (int) ($stats['obvious'] ?? 0), 'families' => $families, 'reverted' => (bool) ($stats['reverted'] ?? false), 'since' => (string) ($stats['since'] ?? gmdate('Y-m-d')), ]; } public static function record(bool $invalid, array $signals = []) { $stats = self::stats(); if ($stats['since'] !== gmdate('Y-m-d')) { $stats = ['sessions' => 0, 'invalid' => 0, 'obvious' => 0, 'families' => [], 'reverted' => false, 'since' => gmdate('Y-m-d')]; } $stats['sessions']++; if (self::is_obvious($signals)) { $stats['obvious']++; } if ($invalid) { $stats['invalid']++; foreach (self::families_of($signals) as $family) { $stats['families'][$family] = (int) ($stats['families'][$family] ?? 0) + 1; } } if (!$stats['reverted'] && $stats['sessions'] >= self::SANITY_MIN_SAMPLE && (self::acted_on($stats) / $stats['sessions']) > self::SANITY_CEILING && get_option('adghost_ivt_mode', 'monitor') !== 'monitor') { $stats['reverted'] = true; self::notify_revert($stats); } update_option('adghost_ivt_stats', $stats, false); do_action('adghost_ivt_recorded', $invalid, $signals); } private static function families_of(array $ids) { $families = []; foreach ($ids as $id) { if (!is_string($id)) { continue; } $signal = AdGhost_Signal_Registry::get($id); if ($signal !== null && !empty($signal['family'])) { $families[(int) $signal['family']] = true; } } return array_keys($families); } public static function invalid_share() { $stats = self::stats(); if ($stats['sessions'] < self::SHARE_MIN_SAMPLE) { return null; } return $stats['invalid'] / $stats['sessions']; } public static function top_family() { $families = self::stats()['families']; if (!$families) { return null; } arsort($families); $id = (int) array_key_first($families); return [ 'id' => $id, 'name' => AdGhost_Signal_Registry::FAMILIES[$id], 'sessions' => (int) $families[$id], ]; } public static function should_alert() { $stats = self::stats(); if (!self::enabled() || $stats['sessions'] < self::SANITY_MIN_SAMPLE) { return false; } return ($stats['invalid'] / $stats['sessions']) >= self::ALERT_SHARE; } public static function reconcile_mode() { $stored = (string) get_option('adghost_ivt_mode', 'monitor'); if ($stored === '' || in_array($stored, self::available_modes(), true)) { return; } update_option('adghost_ivt_mode', 'monitor'); update_option('adghost_ivt_notice', [ 'message' => sprintf( /* translators: %s: the enforcement mode the site had set, e.g. "block". */ __('The "%s" mode is no longer available on this site, because the add-on that provided it is not active. Detection is now running in Monitor: it reports what it sees and leaves ad serving alone. Nothing else about your setup changed.', 'adghost-lite'), $stored ), 'time' => time(), ], false); } private static function count_server_session(bool $invalid, array $signals = []) { if (self::mode() === 'monitor') { return; } $key = 'adghost_ivt_seen_' . substr(AdGhost_IVT_Session::hash_ip(self::client_ip()), 0, 32); if (get_transient($key)) { return; } set_transient($key, 1, HOUR_IN_SECONDS); self::record($invalid, $signals); } private static function notify_revert(array $stats) { $share = round((self::acted_on($stats) / max(1, $stats['sessions'])) * 100, 1); $message = sprintf( 'AdGhost dropped IVT enforcement back to monitor: %s%% of %d sessions were flagged invalid, above the %s%% sanity ceiling. This nearly always means a detector is misfiring rather than that the traffic is bad.', $share, $stats['sessions'], self::SANITY_CEILING * 100 ); update_option('adghost_ivt_notice', [ 'message' => $message, 'time' => time(), ], false); do_action('adghost_ivt_auto_reverted', $stats, $message); } public static function decide() { if (self::$decision !== null) { return self::$decision; } $allow = function ($reason, $rail = null) { return self::$decision = [ 'action' => 'serve', 'reason' => $reason, 'verdict' => null, 'rail' => $rail, ]; }; if (!self::enabled()) { return $allow('IVT is switched off', 'kill-switch'); } if (self::is_static_request()) { return $allow('Static asset', 'static-assets'); } if (is_user_logged_in()) { return $allow('Logged-in user', 'logged-in'); } if (AdGhost_Crawler_Allowlist::is_verified_crawler(self::client_ip())) { return $allow('Verified search crawler', 'crawler-allowlist'); } $server = AdGhost_Server_Signals::score(); $client = AdGhost_IVT_Session::verdict(); $signals = $server['matched']; $score = $server['score']; $hard = $server['hard']; $correlated = AdGhost_Correlation::evaluate(is_array($client) ? $client : null); if (!empty($correlated)) { $corr = AdGhost_Signal_Registry::score($correlated); $score = min(100, $score + $corr['score']); $hard = $hard || $corr['hard']; $signals = array_merge($signals, $corr['matched']); } if (is_array($client)) { $score = min(100, $score + (int) ($client['score'] ?? 0)); $hard = $hard || !empty($client['hard']); foreach ((array) ($client['signals'] ?? []) as $id) { $row = AdGhost_Signal_Registry::get($id); if ($row) { $signals[] = $row; } } } $families = count(array_unique(array_column($signals, 'family'))); $strongest = $signals ? max(array_column($signals, 'weight')) : 0; $corroborated = $families >= 2 || $strongest >= self::SINGLE_SIGNAL_FLOOR; $invalid = $hard || ($score >= AdGhost_Signal_Registry::INVALID_THRESHOLD && $corroborated); $verdict = [ 'score' => $score, 'hard' => $hard, 'families' => $families, 'verdict' => $invalid ? 'invalid' : 'clean', 'signals' => array_values(array_unique(array_column($signals, 'id'))), 'phase' => is_array($client) ? 'client+server' : 'server-only', ]; do_action('adghost_ivt_verdict', $verdict); $mode = self::mode(); if ($mode === 'monitor') { return self::$decision = [ 'action' => 'serve', 'reason' => 'Monitor mode: nothing is enforced', 'verdict' => $verdict, 'rail' => null, ]; } $acted = self::actionable($invalid, $verdict['signals']); self::count_server_session($invalid, $verdict['signals']); if (!$acted) { return self::$decision = [ 'action' => 'serve', 'reason' => 'Session looks clean', 'verdict' => $verdict, 'rail' => null, ]; } $decision = [ 'action' => 'no-ads', 'reason' => sprintf('Scored %d on %s', $score, implode(', ', $verdict['signals']) ?: 'no named signal'), 'verdict' => $verdict, 'rail' => null, ]; $filtered = apply_filters('adghost_ivt_decision', $decision, $mode); if (is_array($filtered) && sanitize_key((string) ($filtered['action'] ?? '')) !== '') { $decision = array_merge($decision, ['action' => sanitize_key((string) $filtered['action']), 'reason' => (string) ($filtered['reason'] ?? $decision['reason'])]); } return self::$decision = $decision; } public static function ads_allowed() { $decision = self::decide(); if ($decision['action'] === 'no-ads') { do_action('adghost_ivt_enforced', 'no-ads', $decision); } return $decision['action'] === 'serve'; } public function enforce() { $decision = self::decide(); if ($decision['action'] === 'serve') { return; } self::prevent_caching(); do_action('adghost_ivt_enforce', $decision); } private static function set_flag_cookie(bool $invalid) { do_action('adghost_ivt_flag_cookie', $invalid); if (!$invalid && !isset($_COOKIE[self::FLAG_COOKIE])) { return; } if (headers_sent()) { return; } setcookie(self::FLAG_COOKIE, $invalid ? '1' : '', [ 'expires' => $invalid ? time() + AdGhost_IVT_Session::TTL : time() - HOUR_IN_SECONDS, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => false, 'samesite' => 'Lax', ]); } public static function cached_page_parts(array $formats = []) { if (!self::enabled() || self::mode() === 'monitor' || (function_exists('is_user_logged_in') && is_user_logged_in())) { return ['css' => '', 'js' => '']; } return [ 'css' => AdGhost_Targeting::suppress_css('adghost-ivt-off'), 'js' => '(function(w,d){' . 'if(!/(?:^|;\\s*)' . self::FLAG_COOKIE . '=1(?:;|$)/.test(d.cookie||"")){return;}' . 'w.AdGhostIVTSuppressed=true;' . AdGhost_Targeting::suppress_js($formats, 'adghost-ivt-off') . '})(window,document);', ]; } private static function prevent_caching() { if (!defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound } do_action('litespeed_control_set_nocache', 'AdGhost: response depends on this session'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound if (!headers_sent()) { nocache_headers(); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, private'); } } public static function client_ip() { $remote = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $ip = $remote; $cf = isset($_SERVER['HTTP_CF_CONNECTING_IP']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'])) : ''; if ($cf !== '' && filter_var($cf, FILTER_VALIDATE_IP) && self::ip_in_ranges($remote, self::CLOUDFLARE_RANGES)) { $ip = $cf; } if ($ip === $remote) { $proxies = self::trusted_proxies(); $xff = isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])) : ''; if ($proxies && $xff !== '' && self::ip_in_ranges($remote, $proxies)) { $first = trim(explode(',', $xff)[0]); if (filter_var($first, FILTER_VALIDATE_IP)) { $ip = $first; } } } return (string) apply_filters('adghost_ivt_client_ip', $ip, $remote); } public static function trusted_proxies() { $ranges = []; if (defined('ADGHOST_TRUSTED_PROXIES')) { $ranges = is_array(ADGHOST_TRUSTED_PROXIES) ? ADGHOST_TRUSTED_PROXIES : array_map('trim', explode(',', (string) ADGHOST_TRUSTED_PROXIES)); } return array_values(array_filter((array) apply_filters('adghost_ivt_trusted_proxies', $ranges))); } public static function ip_in_ranges($ip, array $ranges) { $packed = @inet_pton($ip); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid input is a normal "no" if ($packed === false) { return false; } foreach ($ranges as $range) { [$subnet, $bits] = explode('/', $range) + [1 => null]; $net = @inet_pton($subnet); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ($net === false || strlen($net) !== strlen($packed)) { continue; } $bits = $bits === null ? strlen($net) * 8 : (int) $bits; $bytes = intdiv($bits, 8); if (substr($packed, 0, $bytes) !== substr($net, 0, $bytes)) { continue; } $rest = $bits % 8; if ($rest === 0) { return true; } $mask = chr((0xff << (8 - $rest)) & 0xff); if ((($packed[$bytes] & $mask) === ($net[$bytes] & $mask))) { return true; } } return false; } private static function within_rate(string $client) { $key = 'adghost_ivt_rate_' . substr($client, 0, 32); $now = time(); $state = get_transient($key); if (!is_array($state) || ($now - (int) ($state['start'] ?? 0)) >= self::VERDICT_WINDOW) { $state = ['start' => $now, 'count' => 0]; } if ($state['count'] >= self::VERDICT_RATE) { return false; } $state['count']++; set_transient($key, $state, max(1, self::VERDICT_WINDOW - ($now - $state['start']))); return true; } private static function take_session_quota(string $client) { $key = 'adghost_ivt_quota_' . substr($client, 0, 32); $used = (int) get_transient($key); if ($used >= self::SESSIONS_PER_CLIENT) { return false; } set_transient($key, $used + 1, DAY_IN_SECONDS); return true; } private static function is_static_request() { $path = (string) wp_parse_url(isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '', PHP_URL_PATH); return (bool) preg_match( '/\.(?:css|js|mjs|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|eot|mp4|webm|mp3|pdf|zip|map|txt|xml)$/i', $path ); } public function register_routes() { register_rest_route('adghost/v1', '/ivt/verdict', [ 'methods' => 'POST', 'callback' => [$this, 'receive_verdict'], 'permission_callback' => '__return_true', 'args' => [ 'score' => ['type' => 'integer', 'required' => false], 'hard' => ['type' => 'boolean', 'required' => false], 'signals' => ['type' => 'array', 'required' => false], ], ]); register_rest_route('adghost/v1', '/ivt/report', [ 'methods' => 'GET', 'callback' => [$this, 'report'], 'permission_callback' => function () { return current_user_can('manage_options'); }, ]); } public function receive_verdict($request) { if (!self::enabled()) { return rest_ensure_response(['ok' => true, 'ignored' => 'ivt disabled']); } $client = AdGhost_IVT_Session::hash_ip(self::client_ip()); if (!self::within_rate($client)) { $response = rest_ensure_response(['ok' => false, 'ignored' => 'rate limited']); $response->set_status(429); return $response; } $first_report = AdGhost_IVT_Session::verdict() === null; $reported = (array) $request->get_param('signals'); $ids = []; foreach ($reported as $id) { if (is_string($id) && AdGhost_Signal_Registry::get($id) !== null) { $ids[] = $id; } } $scored = AdGhost_Signal_Registry::score($ids); do_action('adghost_ivt_signals', $ids, $scored); AdGhost_IVT_Session::store([ 'score' => $scored['score'], 'hard' => $scored['hard'], 'verdict' => $scored['verdict'], 'signals' => $ids, 'time' => time(), ]); if ($first_report && self::take_session_quota($client)) { self::record($scored['verdict'] === 'invalid', $ids); } self::set_flag_cookie( self::actionable($scored['verdict'] === 'invalid', $ids) && self::mode() !== 'monitor' && !AdGhost_Crawler_Allowlist::is_verified_crawler(self::client_ip()) ); return rest_ensure_response([ 'ok' => true, 'verdict' => $scored['verdict'], 'score' => $scored['score'], 'counted' => count($ids), 'ignored' => count($reported) - count($ids), ]); } public function report() { $stats = self::stats(); return rest_ensure_response([ 'enabled' => self::enabled(), 'mode' => self::mode(), 'configured_mode' => get_option('adghost_ivt_mode', 'monitor'), 'available_modes' => self::available_modes(), 'auto_reverted' => $stats['reverted'], 'notice' => get_option('adghost_ivt_notice', null), 'signal_count' => AdGhost_Signal_Registry::count(), 'correlation' => AdGhost_Correlation::correlation_enabled(), 'fingerprinting' => [ 'enabled' => (bool) get_option('adghost_ivt_fingerprinting', false), 'permitted' => AdGhost_Correlation::fingerprinting_permitted(), 'signals' => count(AdGhost_Signal_Registry::consent_required()), ], 'families' => AdGhost_Signal_Registry::family_counts(), 'families_fired' => $stats['families'], 'top_family' => self::top_family(), 'family_names' => AdGhost_Signal_Registry::FAMILIES, 'tiers' => [ 'fast' => count(AdGhost_Signal_Registry::by_tier('fast')), 'full' => count(AdGhost_Signal_Registry::by_tier('full')), 'server' => count(AdGhost_Signal_Registry::by_tier('server')), ], 'sessions' => $stats['sessions'], 'invalid' => $stats['invalid'], 'obvious' => $stats['obvious'], 'invalid_share' => $stats['sessions'] ? round($stats['invalid'] / $stats['sessions'], 4) : 0, 'since' => $stats['since'], ]); } }