diff --git a/lib/sentry-filter.ts b/lib/sentry-filter.ts index 90dc8a7..b50ec51 100644 --- a/lib/sentry-filter.ts +++ b/lib/sentry-filter.ts @@ -1,3 +1,5 @@ +import { shouldDropInjectedBrowserEvent } from "./sentry-injected-browser"; + // Surgical client-side Sentry noise filtering. // // This module holds the *decision* of whether a browser error event is one of @@ -34,6 +36,7 @@ interface FilterableFrame { filename?: string; abs_path?: string; module?: string; + function?: string; } interface FilterableException { @@ -94,6 +97,8 @@ function frameIsKaltura(frame: FilterableFrame): boolean { * otherwise. Pure and side-effect-free. */ export function shouldDropClientEvent(event: FilterableEvent): boolean { + if (shouldDropInjectedBrowserEvent(event)) return true; + const exceptions = event.exception?.values ?? []; // 1. Kaltura player bundle frames (TRANSCRIPTS-2K, 2A). If any exception in diff --git a/lib/sentry-injected-browser.test.ts b/lib/sentry-injected-browser.test.ts new file mode 100644 index 0000000..dc340d0 --- /dev/null +++ b/lib/sentry-injected-browser.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { shouldDropClientEvent } from "./sentry-filter"; + +const page = { filename: "app:///en/login", function: "global code" }; +const bridge = { + filename: "app://navigation_performance_logger_android", + function: "sendDataToNative", +}; +const app = { filename: "app:///lib/hooks/use-timezone.tsx", function: "read" }; +const signatures = [ + ["ReferenceError", "Can't find variable: __firefox__", page], + [ + "TypeError", + "undefined is not an object (evaluating 'window.__firefox__.reader')", + page, + ], + [ + "TypeError", + "undefined is not an object (evaluating 'window.__firefox__.refresh_youtube_quality_C85C5288CC2B4BDCBB20C5778007F667')", + page, + ], + [ + "Error", + "MetaMask extension not found", + { filename: "app:///scripts/inpage.js" }, + ], + [ + "i", + "Failed to connect to MetaMask", + { filename: "app:///scripts/inpage.js" }, + ], + ["Error", "Error invoking postMessage: Java object is gone", bridge], +] as const; + +function exception(type: string, value: string, frame = app) { + return { type, value, stacktrace: { frames: [frame] } }; +} + +describe("confirmed injected browser signatures", () => { + it.each(signatures)( + "drops %s: %s only at its external source", + (type, value, frame) => { + const error = { type, value, stacktrace: { frames: [frame] } }; + expect(shouldDropClientEvent({ exception: { values: [error] } })).toBe( + true, + ); + }, + ); + + it.each(signatures)("retains %s: %s from application code", (type, value) => { + expect( + shouldDropClientEvent({ + exception: { values: [exception(type, value)] }, + }), + ).toBe(false); + }); + + it("retains application callers in a native logger stack", () => { + const error = exception("Error", signatures[5][1], bridge); + error.stacktrace.frames.unshift(app); + expect(shouldDropClientEvent({ exception: { values: [error] } })).toBe( + false, + ); + }); + + it("drops the captured multi-frame native logger signature", () => { + const frames = ["sendINPMessage", "sendDataToNative"].map((name) => ({ + filename: bridge.filename, + function: name, + })); + const error = { + type: "Error", + value: signatures[5][1], + stacktrace: { frames }, + }; + expect(shouldDropClientEvent({ exception: { values: [error] } })).toBe( + true, + ); + }); + + it("retains a chain containing an application failure", () => { + const external = exception("Error", signatures[5][1], bridge); + const firstParty = exception( + "TypeError", + "Cannot read properties of undefined (reading 'name')", + ); + expect( + shouldDropClientEvent({ exception: { values: [external, firstParty] } }), + ).toBe(false); + }); + + it.each([ + "Cannot read properties of undefined (reading 'M_ID')", + "Failed to fetch", + "The operation is insecure.", + "Object captured as promise rejection with keys: category, code, data, severity", + "This operation was aborted", + "controller[kState].transformAlgorithm is not a function", + ])("retains unconfirmed or actionable failure: %s", (value) => { + expect( + shouldDropClientEvent({ + exception: { values: [exception("Error", value)] }, + }), + ).toBe(false); + }); + + it("does not classify stackless errors or unrelated bridge errors as injection", () => { + const value = signatures[5][1]; + expect( + shouldDropClientEvent({ + exception: { values: [{ type: "Error", value }] }, + }), + ).toBe(false); + expect( + shouldDropClientEvent({ + exception: { + values: [exception("Error", "Unexpected response", bridge)], + }, + }), + ).toBe(false); + }); +}); diff --git a/lib/sentry-injected-browser.ts b/lib/sentry-injected-browser.ts new file mode 100644 index 0000000..4e2dd28 --- /dev/null +++ b/lib/sentry-injected-browser.ts @@ -0,0 +1,77 @@ +// Only signatures attributed to injected browser scripts in the Sentry audit. +// Unknown executors (including M_ID), storage and media errors stay visible. +interface Frame { + filename?: string; + abs_path?: string; + function?: string; +} + +interface Exception { + type?: string; + value?: string; + stacktrace?: { frames?: Frame[] }; +} + +interface BrowserEvent { + exception?: { values?: Exception[] }; +} + +const FIREFOX_MESSAGES = new Set([ + "Can't find variable: __firefox__", + "undefined is not an object (evaluating 'window.__firefox__.reader')", + "undefined is not an object (evaluating 'window.__firefox__.refresh_youtube_quality_C85C5288CC2B4BDCBB20C5778007F667')", +]); + +function source(frame: Frame): string { + return frame.abs_path ?? frame.filename ?? ""; +} + +function isInjectedFirefox(error: Exception, frames: Frame[]): boolean { + if (!FIREFOX_MESSAGES.has(error.value ?? "")) return false; + return frames.every( + (frame) => + frame.function === "global code" && + /^app:\/\/\/(en|fr|es|ar|ru|zh)(\/|$)/.test(source(frame)), + ); +} + +function isInjectedMetaMask(error: Exception, frames: Frame[]): boolean { + const known = [ + "MetaMask extension not found", + "Failed to connect to MetaMask", + ]; + return ( + known.includes(error.value ?? "") && + frames.every((frame) => source(frame) === "app:///scripts/inpage.js") + ); +} + +function isInjectedPerformanceLogger( + error: Exception, + frames: Frame[], +): boolean { + return ( + error.value === "Error invoking postMessage: Java object is gone" && + frames.every( + (frame) => + source(frame) === "app://navigation_performance_logger_android", + ) && + frames[frames.length - 1].function === "sendDataToNative" + ); +} + +function isKnownInjection(error: Exception): boolean { + const frames = error.stacktrace?.frames ?? []; + if (frames.length === 0) return false; + return ( + isInjectedFirefox(error, frames) || + isInjectedMetaMask(error, frames) || + isInjectedPerformanceLogger(error, frames) + ); +} + +export function shouldDropInjectedBrowserEvent(event: BrowserEvent): boolean { + const values = event.exception?.values ?? []; + // Keep mixed exception chains: an injected cause does not excuse an app bug. + return values.length > 0 && values.every(isKnownInjection); +}