diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 0cea4c6e..674ed24a 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -554,4 +554,6 @@ The #134 remediation-ledger entry is source/test evidence only. For OBS-001A1 [# For OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139), keep the same evidence discipline: **source/tests** means application runtime source and public HTML/JavaScript entrypoints contain no Firebase Analytics import, initialization, or emission and synthetic mocked/static tests pass; **merge** means the resulting `main` SHA; **website publication** means the exact revision has a hosting publication record; **`runmprc.com` verification** separately proves the live host serves that revision; **Firebase deployment** is unchanged and not required for this source-only issue; **Analytics provider configuration** remains unchanged and unverified for collection, cookies/storage, prior provider data, access, retention, consent, and deletion; **live network behavior** remains unverified until an approved public-page check at the exact website revision observes no Analytics request or cookie without signing in or using member data. Removing source calls does not delete provider history or browser storage. Do not use a production-configured preview for this check. +For OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142), **source/tests** means application and public runtime source has no direct console path outside the closed five-outcome helper and synthetic console canaries pass; **merge** means the resulting `main` SHA; **website publication** and **`runmprc.com` verification** remain separate later evidence; **Firebase deployment** and **outside-provider configuration** are unchanged by this frontend-only source issue; **live behavior** and deletion of any earlier local browser-console history remain unverified. Never reproduce a member error or paste a browser console into an issue to prove this boundary. + Provider configuration, production secrets, Netlify publication, Firebase deployment, and live behavior always require separate dated evidence. Local tests and a green GitHub summary do not prove them. diff --git a/SECURITY.md b/SECURITY.md index 1d58cd43..7a51a330 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -102,7 +102,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | RISK-013 | PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) sets both current Checkout creators to promotion entry off and automatic tax off. Exact payload and signed event tests require an explicit all-zero breakdown; malformed or nonzero discount, tax, or shipping data creates durable review evidence while definitive failed/expired Sessions still cancel. | Privately inventory pre-change Sessions/provider promotion settings, keep the guard through protected staging/deploy, verify provider and live state separately, and implement an approved authoritative discount/tax/shipping contract before ever enabling it. | | Part of RISK-014 | SUPPLY-001A [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127) removes the unused direct `gpxparser` dependency and 50 lockfile entries without changing any retained package version, source URL, or integrity hash. A fresh Node 20 install lowers the root production audit from 33 findings (3 critical, 9 high) to 22 (1 critical, 8 high), and the optimized production asset hashes remain identical. | Remediate or time-bound the remaining root and Functions findings through the separately tested router, Firebase, Stripe, build-system, and scanning slices; do not use a forced audit upgrade. | | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | -| Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. Synthetic tests use mocks/canaries and scan CRA runtime source plus public HTML/JavaScript entrypoints; they make no provider call. | #110/#111 still own any future approved Analytics purpose, consent, event/parameter schema, provider settings, and residual hosted telemetry boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | +| Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) source routes the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibits direct console calls elsewhere in application/public runtime source. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | | Part of RISK-035 | The Jest environment is repaired, the stale frontend smoke test is replaced, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite as its own named blocking step. | Keep exact hosted evidence on #124/#126; make lint non-mutating/fail-closed, prove required branch checks, and add the broader TEST-001 suite. | | RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | | RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. | diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 3ff0f9ca..0caa50ec 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,5 +1,9 @@ import React from 'react'; import { captureException } from '../services/monitoring/sentry'; +import { + clientFailureEvents, + reportClientFailure, +} from '../services/monitoring/clientDiagnostics'; interface Props { children: React.ReactNode; @@ -19,10 +23,9 @@ class ErrorBoundary extends React.Component { return { error }; } - componentDidCatch(error: Error, info: React.ErrorInfo) { + componentDidCatch(error: Error) { captureException(error); - // eslint-disable-next-line no-console - console.error('ErrorBoundary caught:', error, info.componentStack); + reportClientFailure(clientFailureEvents.renderFailed); } handleReset = () => { diff --git a/src/components/MembersOnly.jsx b/src/components/MembersOnly.jsx index dfe82cb2..e183f81c 100644 --- a/src/components/MembersOnly.jsx +++ b/src/components/MembersOnly.jsx @@ -2,7 +2,14 @@ import React, { useEffect, useState } from 'react'; import { doc, getDoc } from 'firebase/firestore'; import parse from 'html-react-parser'; import { useServiceLocator } from '../services/ServiceLocatorContext'; +import { + clientFailureEvents, + reportClientFailure, +} from '../services/monitoring/clientDiagnostics'; +// This legacy JSX component predates typed props; adding a dependency or +// migrating the component is outside this privacy-only change. +// eslint-disable-next-line react/prop-types function MembersOnly({ dataKey, style }) { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -28,9 +35,9 @@ function MembersOnly({ dataKey, style }) { } else { setError('Document not found'); } - } catch (err) { + } catch { setError('Failed to fetch data'); - console.error('Error fetching members-only data:', err); + reportClientFailure(clientFailureEvents.membersOnlyFetchFailed); } finally { setLoading(false); } diff --git a/src/services/firebase/FirebaseResources.test.ts b/src/services/firebase/FirebaseResources.test.ts index 3142ef7b..0de7070b 100644 --- a/src/services/firebase/FirebaseResources.test.ts +++ b/src/services/firebase/FirebaseResources.test.ts @@ -186,6 +186,51 @@ describe('FirebaseResources environment isolation', () => { ); }); + test('production without an App Check key continues with one fixed diagnostic', () => { + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const resources = createResourcesFor('production'); + + expect(resources.auth).toBe(mockAuth); + expect(resources.firestore).toBe(mockFirestore); + expect(resources.functions).toBe(mockFunctions); + expect(mockInitializeAppCheck).not.toHaveBeenCalled(); + expect(consoleWarn).toHaveBeenCalledWith('[MPRC client] app_check_disabled'); + } finally { + consoleWarn.mockRestore(); + } + }); + + test('App Check failure continues without logging provider details', () => { + const canaries = [ + 'app-check-member@example.test', + 'app-check-provider-response-canary', + 'app-check-token-canary', + ]; + const providerError = Object.assign(new Error(canaries.join(' ')), { + response: canaries[1], + token: canaries[2], + }); + mockInitializeAppCheck.mockImplementationOnce(() => { throw providerError; }); + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const resources = createResourcesFor('production', 'configured-public-site-key'); + + expect(resources.auth).toBe(mockAuth); + expect(resources.firestore).toBe(mockFirestore); + expect(resources.functions).toBe(mockFunctions); + expect(consoleWarn).toHaveBeenCalledWith( + '[MPRC client] app_check_initialization_failed', + ); + const serializedConsole = JSON.stringify(consoleWarn.mock.calls); + canaries.forEach((canary) => expect(serializedConsole).not.toContain(canary)); + } finally { + consoleWarn.mockRestore(); + } + }); + test.each([ '/account/strava/callback?code=example&state=example-state', '/register/success?registration=r1&token=example-capability', diff --git a/src/services/firebase/FirebaseResources.ts b/src/services/firebase/FirebaseResources.ts index 59008b23..b493961e 100644 --- a/src/services/firebase/FirebaseResources.ts +++ b/src/services/firebase/FirebaseResources.ts @@ -15,6 +15,10 @@ import { } from 'firebase/functions'; import hasCapabilityCallbackState from '../monitoring/capabilityCallback'; +import { + clientFailureEvents, + reportClientFailure, +} from '../monitoring/clientDiagnostics'; const isLocalRuntime = process.env.NODE_ENV !== 'production'; const LOCAL_FIREBASE_PROJECT_ID = 'demo-mprc-local'; @@ -82,9 +86,7 @@ class FirebaseResources { const siteKey = process.env.REACT_APP_RECAPTCHA_SITE_KEY; if (!siteKey) { - console.warn( - 'App Check disabled: set REACT_APP_RECAPTCHA_SITE_KEY to enable', - ); + reportClientFailure(clientFailureEvents.appCheckDisabled); return; } try { @@ -92,8 +94,8 @@ class FirebaseResources { provider: new ReCaptchaV3Provider(siteKey), isTokenAutoRefreshEnabled: true, }); - } catch (error) { - console.warn('App Check init failed:', error); + } catch { + reportClientFailure(clientFailureEvents.appCheckInitializationFailed); } } diff --git a/src/services/identity/Identity.ts b/src/services/identity/Identity.ts index a7dee9a2..02355ce2 100644 --- a/src/services/identity/Identity.ts +++ b/src/services/identity/Identity.ts @@ -11,6 +11,10 @@ import { Unsubscribe, IdTokenResult, } from 'firebase/auth'; +import { + clientFailureEvents, + reportClientFailure, +} from '../monitoring/clientDiagnostics'; import FirebaseResources from '../firebase/FirebaseResources'; export type UserRole = 'admin' | 'member' | 'unverified' | null; @@ -132,8 +136,8 @@ class IdentityService { this.currentUserRole = 'unverified'; try { await sendEmailVerification(credential.user); - } catch (error) { - console.warn('Failed to send verification email:', error); + } catch { + reportClientFailure(clientFailureEvents.emailVerificationFailed); } return credential; } diff --git a/src/services/monitoring/clientDiagnostics.test.ts b/src/services/monitoring/clientDiagnostics.test.ts new file mode 100644 index 00000000..5b0435ee --- /dev/null +++ b/src/services/monitoring/clientDiagnostics.test.ts @@ -0,0 +1,323 @@ +export {}; + +const fs = require('node:fs'); +const path = require('node:path'); +const React = require('react'); +const ts = require('typescript'); +const { render, screen } = require('@testing-library/react'); + +const mockCaptureException = jest.fn(); +const mockCreateUserWithEmailAndPassword = jest.fn(); +const mockDoc = jest.fn(); +const mockGetDoc = jest.fn(); +const mockOnAuthStateChanged = jest.fn(() => jest.fn()); +const mockSendEmailVerification = jest.fn(); +const mockUseServiceLocator = jest.fn(); + +jest.mock('./sentry', () => ({ + captureException: mockCaptureException, +})); +jest.mock('firebase/auth', () => ({ + createUserWithEmailAndPassword: mockCreateUserWithEmailAndPassword, + onAuthStateChanged: mockOnAuthStateChanged, + sendEmailVerification: mockSendEmailVerification, + sendPasswordResetEmail: jest.fn(), + signInWithEmailAndPassword: jest.fn(), + signOut: jest.fn(), +})); +jest.mock('firebase/firestore', () => ({ + doc: mockDoc, + getDoc: mockGetDoc, +})); +jest.mock('../ServiceLocatorContext', () => ({ + useServiceLocator: mockUseServiceLocator, +})); + +const ErrorBoundary = require('../../components/ErrorBoundary').default; +const MembersOnly = require('../../components/MembersOnly').default; +const IdentityService = require('../identity/Identity').default; +const { + clientFailureEvents, + reportClientFailure, +} = require('./clientDiagnostics'); + +const SAFE_CONSOLE_OWNER = path.join( + 'src', + 'services', + 'monitoring', + 'clientDiagnostics.ts', +); + +function runtimeFiles(directory: string, includeHtml = false): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry: any) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return runtimeFiles(entryPath, includeHtml); + const runtimeExtension = includeHtml + ? /\.(?:html|js|mjs)$/ + : /\.(?:js|jsx|mjs|ts|tsx)$/; + if (!runtimeExtension.test(entry.name)) return []; + if (!includeHtml && /\.(?:test|spec)\.(?:js|jsx|mjs|ts|tsx)$/.test(entry.name)) return []; + return [entryPath]; + }); +} + +function applicationRuntimeFiles(projectRoot: string): string[] { + return [ + ...runtimeFiles(path.join(projectRoot, 'src')), + ...runtimeFiles(path.join(projectRoot, 'public'), true), + ]; +} + +function staticStringValue(node: any): string | undefined { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + if ( + ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const left = staticStringValue(node.left); + const right = staticStringValue(node.right); + return left === undefined || right === undefined ? undefined : left + right; + } + return undefined; +} + +function containsConsoleToken(value: string): boolean { + return /(?:\bconsole\b|con\\u(?:0073|\{73\})ole)/i.test(value); +} + +function hasConsoleReference(source: string, filename = 'fixture.ts'): boolean { + if (path.extname(filename).toLowerCase() === '.html') { + // Public HTML is not a TypeScript source file. Conservatively reject the + // console token even in text/comments so a script insertion cannot hide. + return containsConsoleToken(source); + } + + const extension = path.extname(filename).toLowerCase(); + let scriptKind = ts.ScriptKind.JS; + if (extension === '.tsx') scriptKind = ts.ScriptKind.TSX; + else if (extension === '.jsx') scriptKind = ts.ScriptKind.JSX; + else if (extension === '.ts') scriptKind = ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + scriptKind, + ); + let found = false; + const visit = (node: any) => { + if (found) return; + if ( + (ts.isIdentifier(node) && node.text === 'console') + || ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + && containsConsoleToken(node.text)) + || (ts.isBinaryExpression(node) + && containsConsoleToken(staticStringValue(node) || '')) + || (ts.isElementAccessExpression(node) + && staticStringValue(node.argumentExpression) === 'console') + || (ts.isComputedPropertyName(node) + && staticStringValue(node.expression) === 'console') + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +} + +describe('client diagnostic privacy', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + mockOnAuthStateChanged.mockReturnValue(jest.fn()); + mockDoc.mockReturnValue({ path: 'members_only/synthetic' }); + mockUseServiceLocator.mockReturnValue({ + isReady: true, + services: { firebaseResources: { firestore: { name: 'synthetic-firestore' } } }, + }); + }); + + afterAll(() => { + consoleError.mockRestore(); + consoleWarn.mockRestore(); + }); + + test.each([ + [clientFailureEvents.appCheckDisabled, 'warn'], + [clientFailureEvents.appCheckInitializationFailed, 'warn'], + [clientFailureEvents.emailVerificationFailed, 'warn'], + [clientFailureEvents.membersOnlyFetchFailed, 'warn'], + [clientFailureEvents.renderFailed, 'error'], + ])('emits the closed %s outcome without diagnostic data', (eventName, level) => { + reportClientFailure(eventName); + + const expected = `[MPRC client] ${eventName}`; + if (level === 'error') { + expect(consoleError).toHaveBeenCalledWith(expected); + expect(consoleWarn).not.toHaveBeenCalled(); + } else { + expect(consoleWarn).toHaveBeenCalledWith(expected); + expect(consoleError).not.toHaveBeenCalled(); + } + }); + + test('drops hostile and sensitive input without reading or coercing it', () => { + const canaries = [ + 'member-canary@example.test', + '+1-555-010-4242', + '123 Canary Street', + '1940-01-02', + 'Emergency Contact Canary', + 'oauth-token-canary', + 'provider-response-canary', + 'https://runmprc.com/account?token=capability-canary#private', + 'private-form-value-canary', + 'private-stack-canary', + ]; + const hostileInput = {}; + const valueGetter = jest.fn(() => { throw new Error(canaries[6]); }); + const toString = jest.fn(() => { throw new Error(canaries[5]); }); + Object.defineProperty(hostileInput, 'value', { get: valueGetter }); + Object.defineProperty(hostileInput, 'toString', { value: toString }); + + expect(() => reportClientFailure(canaries.join(' '))).not.toThrow(); + expect(() => reportClientFailure(hostileInput)).not.toThrow(); + expect(valueGetter).not.toHaveBeenCalled(); + expect(toString).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + test('does not let a broken console alter the underlying member flow', () => { + const consoleFailureCanary = 'private-console-failure-canary'; + consoleWarn.mockImplementationOnce(() => { + throw new Error(consoleFailureCanary); + }); + + expect(() => reportClientFailure( + clientFailureEvents.emailVerificationFailed, + )).not.toThrow(); + expect(consoleWarn).toHaveBeenCalledWith( + '[MPRC client] email_verification_failed', + ); + expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain(consoleFailureCanary); + }); + + test('preserves Sentry capture while keeping render details out of console', () => { + const emailCanary = 'render-member@example.test'; + const tokenCanary = 'render-token-canary'; + const error = new Error(`${emailCanary} ${tokenCanary}`); + const boundary = new ErrorBoundary({ children: null }); + + boundary.componentDidCatch(error, { + componentStack: `private component stack ${emailCanary}`, + }); + + expect(mockCaptureException).toHaveBeenCalledWith(error); + expect(consoleError).toHaveBeenCalledWith('[MPRC client] render_failed'); + const serializedConsole = JSON.stringify([ + ...consoleError.mock.calls, + ...consoleWarn.mock.calls, + ]); + expect(serializedConsole).not.toContain(emailCanary); + expect(serializedConsole).not.toContain(tokenCanary); + expect(serializedConsole).not.toContain('private component stack'); + }); + + test('verification-email failure returns the credential without logging provider data', async () => { + const canaries = [ + 'registration-member@example.test', + 'auth/provider-response-canary', + 'verification-token-canary', + ]; + const credential = { + user: { uid: 'synthetic-uid', email: canaries[0] }, + }; + const providerError = Object.assign(new Error(canaries.join(' ')), { + code: canaries[1], + token: canaries[2], + }); + mockCreateUserWithEmailAndPassword.mockResolvedValueOnce(credential); + mockSendEmailVerification.mockRejectedValueOnce(providerError); + const identity = new IdentityService({ auth: { currentUser: null } }); + + await expect(identity.register(canaries[0], 'synthetic-password')) + .resolves.toBe(credential); + expect(mockSendEmailVerification).toHaveBeenCalledWith(credential.user); + expect(consoleWarn).toHaveBeenCalledWith( + '[MPRC client] email_verification_failed', + ); + const serializedConsole = JSON.stringify(consoleWarn.mock.calls); + canaries.forEach((canary) => expect(serializedConsole).not.toContain(canary)); + }); + + test('members-only fetch failure completes loading without logging Firestore data', async () => { + const canaries = [ + 'members-only-member@example.test', + 'firestore-provider-response-canary', + 'https://runmprc.com/private?token=members-only-token#details', + ]; + mockGetDoc.mockRejectedValueOnce(Object.assign(new Error(canaries.join(' ')), { + response: canaries[1], + url: canaries[2], + })); + + render(React.createElement(MembersOnly, { + dataKey: 'discounts', + style: {}, + })); + + expect(await screen.findByText(/Failed to fetch data/)).toBeInTheDocument(); + expect(consoleWarn).toHaveBeenCalledWith( + '[MPRC client] members_only_fetch_failed', + ); + const serializedConsole = JSON.stringify(consoleWarn.mock.calls); + canaries.forEach((canary) => expect(serializedConsole).not.toContain(canary)); + }); + + test('allows console output only through the closed diagnostic helper', () => { + const projectRoot = path.resolve(__dirname, '../../..'); + const runtimeSources = applicationRuntimeFiles(projectRoot); + const relativeSources = runtimeSources.map((sourcePath) => ( + path.relative(projectRoot, sourcePath) + )); + const offenders = runtimeSources.flatMap((sourcePath) => { + const relativePath = path.relative(projectRoot, sourcePath); + if (relativePath === SAFE_CONSOLE_OWNER) return []; + return hasConsoleReference(fs.readFileSync(sourcePath, 'utf8'), sourcePath) + ? [relativePath] + : []; + }); + + expect(relativeSources).toEqual(expect.arrayContaining([ + path.join('public', 'index.html'), + path.join('src', 'components', 'ErrorBoundary.tsx'), + path.join('src', 'services', 'identity', 'Identity.ts'), + ])); + expect(offenders).toEqual([]); + }); + + test.each([ + ['dot access', 'console.error(privateValue);'], + ['window access', 'window.console.warn(privateValue);'], + ['computed access', "console['error'](privateValue);"], + ['optional chaining', 'console?.error(privateValue);'], + ['parenthesized access', '(console).error(privateValue);'], + ['comment-separated access', 'console /* private */ .error(privateValue);'], + ['alias', 'const c = console; c.error(privateValue);'], + ['destructure', 'const { error } = console; error(privateValue);'], + ['computed global', 'globalThis["console"].error(privateValue);'], + ['computed window', 'window["console"].warn(privateValue);'], + ['escaped identifier', 'con\\u0073ole.error(privateValue);'], + ['constructed property', "globalThis['con' + 'sole'].error(privateValue);"], + ['evaluated source', 'eval("console.error(privateValue)");'], + ])('recognizes the %s bypass', (_label, source) => { + expect(hasConsoleReference(source)).toBe(true); + }); +}); diff --git a/src/services/monitoring/clientDiagnostics.ts b/src/services/monitoring/clientDiagnostics.ts new file mode 100644 index 00000000..231aff12 --- /dev/null +++ b/src/services/monitoring/clientDiagnostics.ts @@ -0,0 +1,35 @@ +export const clientFailureEvents = { + appCheckDisabled: 'app_check_disabled', + appCheckInitializationFailed: 'app_check_initialization_failed', + emailVerificationFailed: 'email_verification_failed', + membersOnlyFetchFailed: 'members_only_fetch_failed', + renderFailed: 'render_failed', +} as const; + +type ClientFailureEvent = typeof clientFailureEvents[keyof typeof clientFailureEvents]; + +const SAFE_FAILURE_EVENTS: ReadonlySet = new Set( + Object.values(clientFailureEvents), +); + +/** + * Emit one closed, low-cardinality outcome without accepting diagnostic data. + * Unknown input is dropped without coercion so errors, provider responses, + * URLs, form values, and member data cannot reach the browser console. + */ +export function reportClientFailure(eventName: unknown): void { + if (typeof eventName !== 'string' || !SAFE_FAILURE_EVENTS.has(eventName)) return; + + const message = `[MPRC client] ${eventName as ClientFailureEvent}`; + try { + if (eventName === clientFailureEvents.renderFailed) { + // eslint-disable-next-line no-console + console.error(message); + return; + } + // eslint-disable-next-line no-console + console.warn(message); + } catch { + // Diagnostics must never alter the underlying member flow. + } +}