diff --git a/AGENTS.md b/AGENTS.md index 9937811d5..4a54e8fd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,10 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal +- Settings, portal request/response, and trace payloads must use + `@iptvnator/shared/logging` or the redacting portal logger before reaching + `console.*`; never log raw credentials while debugging. + - GPU/compositor debugging: ```bash diff --git a/CLAUDE.md b/CLAUDE.md index a68c0c124..db26b9372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,10 @@ Useful narrower flags: - `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal +Settings, portal request/response, and trace payloads must use +`@iptvnator/shared/logging` or the redacting portal logger before reaching +`console.*`; never log raw credentials while debugging. + For GPU/compositor debugging: ```bash @@ -238,6 +242,7 @@ This is an Nx monorepo with the following structure: - **portal/shared/{data-access,ui,util}** - Cross-portal shared code - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) + - **shared/logging** - Dependency-free structured redaction for diagnostic logs - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) - **shared/m3u-utils** - M3U playlist utilities - **shared/testing** - Shared test helpers diff --git a/apps/electron-backend/src/app/events/portal-debug.events.spec.ts b/apps/electron-backend/src/app/events/portal-debug.events.spec.ts index 9912c78f3..b64c4ead5 100644 --- a/apps/electron-backend/src/app/events/portal-debug.events.spec.ts +++ b/apps/electron-backend/src/app/events/portal-debug.events.spec.ts @@ -87,4 +87,37 @@ describe('sanitizePortalDebugEvent', () => { }, }); }); + + it('redacts credentials in request, response, errors, and URL query params', () => { + const secrets = { + username: 'main-user-secret', + password: 'main-password-secret', + token: 'main-token-secret', + authorization: 'main-authorization-secret', + mac: 'main-mac-secret', + }; + const event = sanitizePortalDebugEvent({ + requestId: 'req-redaction', + provider: 'stalker', + operation: 'get_profile', + transport: 'electron-main', + startedAt: new Date().toISOString(), + durationMs: 5, + status: 'error', + request: { + params: secrets, + url: `https://example.com/portal?token=${secrets.token}&action=get_profile`, + }, + error: new Error( + `Request failed: https://example.com/portal?authorization=${secrets.authorization}&action=get_profile` + ), + }); + + const output = JSON.stringify(event); + for (const secret of Object.values(secrets)) { + expect(output).not.toContain(secret); + } + expect(output).toContain('get_profile'); + expect(output).toContain('req-redaction'); + }); }); diff --git a/apps/electron-backend/src/app/events/portal-debug.events.ts b/apps/electron-backend/src/app/events/portal-debug.events.ts index 153efee15..c9fb68142 100644 --- a/apps/electron-backend/src/app/events/portal-debug.events.ts +++ b/apps/electron-backend/src/app/events/portal-debug.events.ts @@ -1,117 +1,19 @@ import { BrowserWindow } from 'electron'; -import { PORTAL_DEBUG_EVENT, PortalDebugEvent } from '@iptvnator/shared/interfaces'; +import { + PORTAL_DEBUG_EVENT, + PortalDebugEvent, +} from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { environment } from '../../environments/environment'; -const MAX_DEBUG_DEPTH = 6; - -function sanitizePortalDebugValue( - value: unknown, - seen = new WeakSet(), - depth = 0 -): unknown { - if ( - value == null || - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return value; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if ( - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - - if (depth >= MAX_DEBUG_DEPTH) { - return '[MaxDepth]'; - } - - if (value instanceof Error) { - const baseError = { - name: value.name, - message: value.message, - stack: value.stack, - } as Record; - - for (const [key, entry] of Object.entries( - value as unknown as Record - )) { - baseError[key] = sanitizePortalDebugValue( - entry, - seen, - depth + 1 - ); - } - - return baseError; - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (value instanceof URL) { - return value.toString(); - } - - if (Array.isArray(value)) { - return value.map((entry) => - sanitizePortalDebugValue(entry, seen, depth + 1) - ); - } - - if (value instanceof Map) { - return Object.fromEntries( - [...value.entries()].map(([key, entry]) => [ - String(key), - sanitizePortalDebugValue(entry, seen, depth + 1), - ]) - ); - } - - if (value instanceof Set) { - return [...value].map((entry) => - sanitizePortalDebugValue(entry, seen, depth + 1) - ); - } - - if (typeof value === 'object') { - if (seen.has(value)) { - return '[Circular]'; - } - - seen.add(value); - - const entries = Object.entries(value as Record).map( - ([key, entry]) => [ - key, - sanitizePortalDebugValue(entry, seen, depth + 1), - ] - ); - - seen.delete(value); - - return Object.fromEntries(entries); - } - - return String(value); -} - export function sanitizePortalDebugEvent( event: PortalDebugEvent ): PortalDebugEvent { return { ...event, - request: sanitizePortalDebugValue(event.request), - response: sanitizePortalDebugValue(event.response), - error: sanitizePortalDebugValue(event.error), + request: redactSensitiveData(event.request), + response: redactSensitiveData(event.response), + error: redactSensitiveData(event.error), }; } diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts new file mode 100644 index 000000000..179338e90 --- /dev/null +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -0,0 +1,48 @@ +const handlers = new Map unknown>(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn( + (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + } + ), + }, +})); + +jest.mock('../services/store.service', () => ({ + MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', + MPV_REUSE_INSTANCE: 'mpvReuseInstance', + VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', + VLC_REUSE_INSTANCE: 'vlcReuseInstance', + store: { get: jest.fn(), set: jest.fn() }, +})); + +jest.mock('../server/http-server', () => ({ + httpServer: { updateSettings: jest.fn() }, +})); + +describe('SETTINGS_UPDATE logging', () => { + beforeEach(async () => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + await import('./settings.events'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not print a TMDB apiKey while retaining useful fields', () => { + const apiKey = 'tmdb-settings-api-key-secret'; + const handler = handlers.get('SETTINGS_UPDATE'); + + expect(handler).toBeDefined(); + handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } }); + + const output = JSON.stringify((console.log as jest.Mock).mock.calls); + expect(output).not.toContain(apiKey); + expect(output).toContain('language'); + expect(output).toContain('de'); + expect(output).toContain('enabled'); + }); +}); diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index 5d3797880..1e5743b5e 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron'; import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { MPV_PLAYER_ARGUMENTS, MPV_REUSE_INSTANCE, @@ -16,7 +17,10 @@ export default class SettingsEvents { } ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { - console.log('Received SETTINGS_UPDATE with data:', arg); + console.log( + 'Received SETTINGS_UPDATE with data:', + redactSensitiveData(arg) + ); if (arg.mpvPlayerArguments !== undefined) { store.set( diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index d16fd8cba..a46a039f5 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -9,6 +9,7 @@ import { PortalDebugEvent, STALKER_REQUEST, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; import { buildStalkerIdentityRequestContext } from './stalker-identity'; @@ -186,7 +187,10 @@ ipcMain.handle( emitPortalDebugEvent(debugEvent); } - console.error('[StalkerEvents] Request error:', error); + console.error( + '[StalkerEvents] Request error:', + redactSensitiveData(error) + ); // Format error response if (axios.isAxiosError(error)) { diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index da96caa5e..0d63e0523 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -10,6 +10,7 @@ import { XTREAM_CANCEL_SESSION, normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; @@ -212,10 +213,12 @@ ipcMain.handle( if (!payload.suppressErrorLog) { console.error( '[XTREAM_REQUEST] Failed', - formatXtreamError( - error, - requestUrlForLog, - payload.params?.action + redactSensitiveData( + formatXtreamError( + error, + requestUrlForLog, + payload.params?.action + ) ) ); } diff --git a/apps/electron-backend/src/app/services/debug-trace.spec.ts b/apps/electron-backend/src/app/services/debug-trace.spec.ts new file mode 100644 index 000000000..def4a2af0 --- /dev/null +++ b/apps/electron-backend/src/app/services/debug-trace.spec.ts @@ -0,0 +1,30 @@ +import { trace } from './debug-trace'; + +describe('debug trace redaction', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not serialize credentials from nested payloads or URLs', () => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + const secrets = { + password: 'trace-password-secret', + token: 'trace-token-secret', + authorization: 'trace-authorization-secret', + mac: 'trace-mac-secret', + }; + + trace('portal', 'request', { + params: secrets, + url: `https://example.com/portal?token=${secrets.token}&action=get_profile`, + requestId: 'diagnostic-request-id', + }); + + const output = JSON.stringify((console.log as jest.Mock).mock.calls); + for (const secret of Object.values(secrets)) { + expect(output).not.toContain(secret); + } + expect(output).toContain('diagnostic-request-id'); + expect(output).toContain('get_profile'); + }); +}); diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index 19a727ea3..86fe7669c 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -146,10 +146,10 @@ export function summarizeForTrace(value: unknown, depth = 0): unknown { export function safeStringifyForTrace(payload: unknown): string { try { - return JSON.stringify(payload); + return JSON.stringify(redactSensitiveData(payload)); } catch (error) { return JSON.stringify({ - fallback: summarizeForTrace(payload), + fallback: summarizeForTrace(redactSensitiveData(payload)), stringifyError: error instanceof Error ? truncateString(error.message) @@ -166,7 +166,8 @@ export function trace(scope: string, message: string, payload?: unknown): void { console.log( `${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace( - summarizeForTrace(payload) + summarizeForTrace(redactSensitiveData(payload)) )}` ); } +import { redactSensitiveData } from '@iptvnator/shared/logging'; diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 00f3d96af..88e4ec625 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -7,7 +7,6 @@ import { DialogService } from '@iptvnator/ui/components'; import { DataService, SettingsStore } from '@iptvnator/services'; import { AUTO_UPDATE_PLAYLISTS, - createDevLogger, ELECTRON_BRIDGE_SECURITY_ERROR_CODES, ERROR, normalizeHost, @@ -22,6 +21,7 @@ import { } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../../environments/environment'; import { + createLogger, createPortalDebugRequestContext, logPortalDebugEvent, } from '@iptvnator/portal/shared/util'; @@ -54,7 +54,7 @@ export class ElectronService extends DataService { private readonly store = inject(Store); private readonly settingsStore = inject(SettingsStore); private readonly translateService = inject(TranslateService); - private readonly debugLog = createDevLogger('ElectronService'); + private readonly logger = createLogger('ElectronService'); private readonly silentXtreamActions = new Set([ XtreamCodeActions.GetAccountInfo, XtreamCodeActions.GetLiveCategories, @@ -67,7 +67,6 @@ export class ElectronService extends DataService { constructor() { super(); - this.debugLog('Electron service initialized...'); this.setupPlayerErrorListener(); this.setupPortalDebugListener(); } @@ -237,7 +236,7 @@ export class ElectronService extends DataService { return playlists as T; } - this.debugLog('Unknown IPC event type:', type); + this.logger.debug('Unknown IPC event type:', type); return undefined as T; } @@ -265,7 +264,7 @@ export class ElectronService extends DataService { return response; } catch (err: unknown) { const errorInfo = this.getErrorDetails(err); - console.error('Stalker request error:', err); + this.logger.error('Stalker request error:', err); this.snackBar.open( `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, 'Close', @@ -590,12 +589,12 @@ export class ElectronService extends DataService { // Log error to console if (isSilentAction) { - this.debugLog( + this.logger.debug( `Background Xtream action failed (${action ?? 'unknown'}):`, normalizedMessage ); } else { - console.error('Xtream request error:', normalizedMessage); + this.logger.error('Xtream request error:', normalizedMessage); } // Only show snackbar for user-triggered Xtream requests diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index e4f05f161..3204b3262 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -15,7 +15,6 @@ import { } from 'rxjs'; import { DataService } from '@iptvnator/services'; import { - createDevLogger, ERROR, Playlist, PLAYLIST_PARSE_BY_URL, @@ -32,6 +31,7 @@ import { createPortalDebugSuccessEvent, logPortalDebugEvent, logPortalDebugRequest, + createLogger, } from '@iptvnator/portal/shared/util'; import { getRuntimeBackendUrl } from './runtime-config'; @@ -71,7 +71,7 @@ export class PwaService extends DataService { private readonly store = inject(Store); private readonly swUpdate = inject(SwUpdate); private readonly translateService = inject(TranslateService); - private readonly debugLog = createDevLogger('PwaService'); + private readonly logger = createLogger('PwaService'); private readonly providerTargetIds = new Map>(); private readonly silentXtreamActions = new Set([ XtreamCodeActions.GetAccountInfo, @@ -88,7 +88,6 @@ export class PwaService extends DataService { constructor() { super(); - this.debugLog('PWA service initialized...'); } /** Uses service worker mechanism to check for available application updates */ @@ -358,7 +357,7 @@ export class PwaService extends DataService { ); if (isSilentAction) { - this.debugLog( + this.logger.debug( `Background Xtream action failed (${action ?? 'unknown'}):`, normalizedMessage ); @@ -398,7 +397,7 @@ export class PwaService extends DataService { // Log error to console if (isSilentAction) { - this.debugLog( + this.logger.debug( `Background Xtream action failed (${action ?? 'unknown'}):`, normalizedMessage ); @@ -409,7 +408,7 @@ export class PwaService extends DataService { }; } - console.error('Xtream request error:', normalizedMessage); + this.logger.error('Xtream request error:', normalizedMessage); this.snackBar.open( `Xtream request failed: ${normalizedMessage}`, 'Close', @@ -533,7 +532,7 @@ export class PwaService extends DataService { } catch (err: unknown) { const errorInfo = this.getErrorDetails(err); logPortalDebugEvent(createPortalDebugErrorEvent(context, err)); - console.error('Stalker request error:', err); + this.logger.error('Stalker request error:', err); this.snackBar.open( `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 47332c781..0f33d310f 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -188,6 +188,26 @@ playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch is only for explicitly trusted providers with invalid or self-signed certificates when the host-scoped UI path is not available. +## Sensitive Diagnostic Logging + +Settings, portal requests/responses, IPC trace payloads, and remote-request +errors can contain provider credentials. Code at those boundaries must pass +structured values through `redactSensitiveData` from +`@iptvnator/shared/logging`, or through the portal `createLogger`/portal-debug +helpers that apply it. Do not send a raw settings object, request params, +response, or `Error` directly to `console.*`. + +The redactor preserves non-sensitive diagnostic fields while replacing +credential fields case-insensitively, including usernames, passwords, tokens, +API keys, authorization/cookie headers, and MAC addresses. It also sanitizes +URL query parameters, serialized JSON, nested query values, errors, arrays, +and cyclic objects without mutating the original value. Depth, collection, +object-key, and string limits keep opt-in debug traces bounded. + +When adding a new logging boundary, extend the closest regression test with a +synthetic secret and assert that the exact value is absent from captured log +output. Never use a real provider credential to validate logging. + ## Filesystem Capabilities Renderer IPC payloads are not filesystem authorization. diff --git a/libs/portal/shared/util/src/lib/logger.spec.ts b/libs/portal/shared/util/src/lib/logger.spec.ts index 1691cae71..393f5f27e 100644 --- a/libs/portal/shared/util/src/lib/logger.spec.ts +++ b/libs/portal/shared/util/src/lib/logger.spec.ts @@ -14,7 +14,9 @@ describe('portal debug logger', () => { beforeEach(() => { globalWithNgDevMode.ngDevMode = true; - jest.spyOn(console, 'groupCollapsed').mockImplementation(() => undefined); + jest.spyOn(console, 'groupCollapsed').mockImplementation( + () => undefined + ); jest.spyOn(console, 'groupEnd').mockImplementation(() => undefined); jest.spyOn(console, 'log').mockImplementation(() => undefined); jest.spyOn(console, 'error').mockImplementation(() => undefined); @@ -36,7 +38,9 @@ describe('portal debug logger', () => { }); logPortalDebugRequest(context); - logPortalDebugEvent(createPortalDebugSuccessEvent(context, { ok: true })); + logPortalDebugEvent( + createPortalDebugSuccessEvent(context, { ok: true }) + ); expect(console.groupCollapsed).not.toHaveBeenCalled(); expect(console.log).not.toHaveBeenCalled(); @@ -85,4 +89,43 @@ describe('portal debug logger', () => { nowSpy.mockRestore(); }); + + it('redacts portal request and response credentials before logging', () => { + const secrets = { + username: 'portal-user-secret', + password: 'portal-password-secret', + token: 'portal-token-secret', + authorization: 'portal-authorization-secret', + mac: 'portal-mac-secret', + }; + const context = createPortalDebugRequestContext({ + provider: 'stalker', + operation: 'get_profile', + transport: 'pwa-http', + request: { + params: secrets, + url: `https://example.com/portal?token=${secrets.token}&action=get_profile`, + diagnosticId: 'request-42', + }, + }); + + logPortalDebugRequest(context); + logPortalDebugEvent( + createPortalDebugSuccessEvent(context, { + user_info: secrets, + status: 'ok', + }) + ); + + const output = JSON.stringify([ + ...(console.log as jest.Mock).mock.calls, + ...(console.error as jest.Mock).mock.calls, + ]); + for (const secret of Object.values(secrets)) { + expect(output).not.toContain(secret); + } + expect(output).toContain('request-42'); + expect(output).toContain('get_profile'); + expect(output).toContain('status'); + }); }); diff --git a/libs/portal/shared/util/src/lib/logger.ts b/libs/portal/shared/util/src/lib/logger.ts index 439b449e3..16578937a 100644 --- a/libs/portal/shared/util/src/lib/logger.ts +++ b/libs/portal/shared/util/src/lib/logger.ts @@ -3,6 +3,7 @@ import { PortalDebugProvider, PortalDebugTransport, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; export interface Logger { debug: (...args: unknown[]) => void; @@ -26,19 +27,31 @@ export function createLogger(scope: string): Logger { return { debug: (...args: unknown[]) => { if (debugEnabled) { - console.debug(prefix, ...args); + console.debug( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); } }, info: (...args: unknown[]) => { if (debugEnabled) { - console.info(prefix, ...args); + console.info( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); } }, warn: (...args: unknown[]) => { - console.warn(prefix, ...args); + console.warn( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); }, error: (...args: unknown[]) => { - console.error(prefix, ...args); + console.error( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); }, }; } @@ -78,7 +91,7 @@ function logPortalDebugSection( method: 'log' | 'error' = 'log' ): void { if (typeof console[method] === 'function') { - console[method](label, value); + console[method](label, redactSensitiveData(value)); } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 51810adb6..8ed39a0de 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -24,7 +24,8 @@ type GetProfileWithIdentity = ( ) => Promise; describe('StalkerSessionService identity payloads', () => { - const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php'; + const portalUrl = + 'https://portal.example.com/stalker_portal/server/load.php'; const macAddress = '00:1A:79:AA:BB:CC'; let service: StalkerSessionService; @@ -56,6 +57,10 @@ describe('StalkerSessionService identity payloads', () => { service = TestBed.inject(StalkerSessionService); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => { const getProfile = service.getProfile as unknown as GetProfileWithIdentity; @@ -180,6 +185,34 @@ describe('StalkerSessionService identity payloads', () => { expect(handshakePayload.serialNumber).toBe('CUSTOMSN123'); }); + it('does not log credentials from portal request errors', async () => { + const token = 'stalker-error-token-secret'; + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + dataService.sendIpcEvent.mockRejectedValue( + new Error( + `Request failed: https://portal.example/api?token=${token}&action=get_profile` + ) + ); + + await expect( + service.getProfile(portalUrl, macAddress, token, {}, 'random-1') + ).rejects.toThrow('Request failed'); + + const output = consoleError.mock.calls + .flatMap((call) => + call.map((value) => + value instanceof Error + ? `${value.message}\n${value.stack ?? ''}` + : JSON.stringify(value) + ) + ) + .join('\n'); + expect(output).not.toContain(token); + expect(output).toContain('get_profile'); + }); + function lastStalkerPayload(): { params: Record; serialNumber?: string; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index e32498aca..91f1ea741 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -1,10 +1,7 @@ import { Injectable, inject } from '@angular/core'; -import { - createDevLogger, - Playlist, - STALKER_REQUEST, -} from '@iptvnator/shared/interfaces'; +import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { DataService } from '@iptvnator/services'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { getStalkerPortalIdentityFromPlaylist, LEGACY_DEFAULT_STALKER_SERIAL, @@ -91,7 +88,7 @@ interface StalkerAuthConfirmationResponse { }) export class StalkerSessionService { private dataService = inject(DataService); - private readonly debugLog = createDevLogger('StalkerSession'); + private readonly logger = createLogger('StalkerSession'); // In-memory token cache for current session (keyed by playlist ID) private tokenCache = new Map(); @@ -234,7 +231,7 @@ export class StalkerSessionService { ); } catch (error) { // Keep failures non-fatal; next interval can recover after token refresh. - console.warn('[StalkerSession] Watchdog ping failed:', error); + this.logger.warn('Watchdog ping failed:', error); } finally { this.watchdogInFlight.delete(playlistId); } @@ -281,10 +278,10 @@ export class StalkerSessionService { }; } - console.error('[StalkerSession] No token in response'); + this.logger.error('No token in response'); throw new Error('Handshake failed: No token received'); } catch (error) { - console.error('[StalkerSession] Handshake error:', error); + this.logger.error('Handshake error:', error); throw error; } } @@ -364,7 +361,7 @@ export class StalkerSessionService { return response; } catch (error) { - console.error('[StalkerSession] Get profile error:', error); + this.logger.error('Get profile error:', error); throw error; } } @@ -404,7 +401,7 @@ export class StalkerSessionService { return false; } catch (error) { - console.error('[StalkerSession] do_auth error:', error); + this.logger.error('do_auth error:', error); throw error; } } @@ -447,7 +444,7 @@ export class StalkerSessionService { profileResponse.js.msg || profileResponse.js.block_msg || 'Unknown profile error'; - console.error('[StalkerSession] Profile error:', errorMsg); + this.logger.error('Profile error:', errorMsg); throw new Error(`Profile error: ${errorMsg}`); } @@ -457,7 +454,7 @@ export class StalkerSessionService { }; } catch (error) { // Profile fetch failed - this is a real error, propagate it - console.error('[StalkerSession] Profile fetch failed:', error); + this.logger.error('Profile fetch failed:', error); throw error; } } @@ -487,14 +484,14 @@ export class StalkerSessionService { // This prevents race conditions when multiple resources request a token simultaneously const pendingPromise = this.pendingAuth.get(playlist._id); if (pendingPromise) { - this.debugLog('Waiting for pending authentication...'); + this.logger.debug('Waiting for pending authentication...'); return pendingPromise; } // No cached token - need to do full authentication (handshake + get_profile) // Don't trust stored tokens as they may be from a different session if (!playlist.portalUrl || !playlist.macAddress) { - console.error('[StalkerSession] Missing portal URL or MAC address'); + this.logger.error('Missing portal URL or MAC address'); throw new Error('Portal URL and MAC address are required'); } const portalUrl = playlist.portalUrl; diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index ec5a7db01..d85aa843b 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { DataService } from '@iptvnator/services'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { EpgItem, XtreamCategory, @@ -78,6 +79,7 @@ interface EpgResponse { @Injectable({ providedIn: 'root' }) export class XtreamApiService { private readonly dataService = inject(DataService); + private readonly logger = createLogger('XtreamApiService'); async cancelSession(sessionId: string): Promise { if ( @@ -91,7 +93,7 @@ export class XtreamApiService { const result = await window.electron.xtreamCancelSession(sessionId); return result.success; } catch (error) { - console.error('Failed to cancel Xtream session:', error); + this.logger.error('Failed to cancel Xtream session:', error); return false; } } diff --git a/libs/shared/logging/jest.config.ts b/libs/shared/logging/jest.config.ts new file mode 100644 index 000000000..c2ec796ae --- /dev/null +++ b/libs/shared/logging/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'shared-logging', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js'], + coverageDirectory: '../../../coverage/libs/shared/logging', +}; diff --git a/libs/shared/logging/project.json b/libs/shared/logging/project.json new file mode 100644 index 000000000..031ec83b4 --- /dev/null +++ b/libs/shared/logging/project.json @@ -0,0 +1,20 @@ +{ + "name": "shared-logging", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/logging/src", + "projectType": "library", + "tags": ["scope:shared", "domain:shared-runtime", "type:util"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/shared/logging/jest.config.ts", + "tsConfig": "libs/shared/logging/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/shared/logging/src/index.ts b/libs/shared/logging/src/index.ts new file mode 100644 index 000000000..ecc4cc4cb --- /dev/null +++ b/libs/shared/logging/src/index.ts @@ -0,0 +1 @@ +export * from './lib/redact-sensitive-data'; diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts new file mode 100644 index 000000000..29d97f0b0 --- /dev/null +++ b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts @@ -0,0 +1,124 @@ +import { REDACTED_VALUE, redactSensitiveData } from './redact-sensitive-data'; + +const TEST_SECRETS = [ + 'settings-api-key-secret', + 'nested-user-secret', + 'nested-password-secret', + 'nested-token-secret', + 'nested-auth-secret', + 'nested-mac-secret', + 'query-password-secret', + 'query-token-secret', +]; + +function serialized(value: unknown): string { + return JSON.stringify(value); +} + +describe('redactSensitiveData', () => { + it('recursively redacts credentials while retaining diagnostic fields', () => { + const input = { + operation: 'get_profile', + settings: { tmdb: { apiKey: TEST_SECRETS[0] } }, + params: { + username: TEST_SECRETS[1], + PASSWORD: TEST_SECRETS[2], + access_token: TEST_SECRETS[3], + headers: { Authorization: `Bearer ${TEST_SECRETS[4]}` }, + macAddress: TEST_SECRETS[5], + }, + }; + + const result = redactSensitiveData(input); + const output = serialized(result); + + for (const secret of TEST_SECRETS) { + expect(output).not.toContain(secret); + } + expect(result).toEqual({ + operation: 'get_profile', + settings: { tmdb: { apiKey: REDACTED_VALUE } }, + params: { + username: REDACTED_VALUE, + PASSWORD: REDACTED_VALUE, + access_token: REDACTED_VALUE, + headers: { Authorization: REDACTED_VALUE }, + macAddress: REDACTED_VALUE, + }, + }); + }); + + it('redacts credentials embedded in URL, URLSearchParams, errors, and serialized strings', () => { + const url = new URL( + `https://user:pass@example.com/live?password=${TEST_SECRETS[6]}&token=${TEST_SECRETS[7]}&action=get_live_streams` + ); + const params = new URLSearchParams({ + authorization: TEST_SECRETS[4], + category: 'news', + }); + const error = new Error( + `Request failed: https://example.com/api?token=${TEST_SECRETS[3]}&action=profile` + ); + const json = JSON.stringify({ + refreshToken: TEST_SECRETS[3], + status: 401, + }); + + const output = serialized( + redactSensitiveData({ url, params, error, json }) + ); + + for (const secret of TEST_SECRETS) { + expect(output).not.toContain(secret); + } + expect(output).toContain('action=get_live_streams'); + expect(output).toContain('category=news'); + expect(output).toContain('status'); + expect(output).toContain('401'); + }); + + it('redacts credentials nested inside non-sensitive query values', () => { + const nestedUrl = `https://identity.example/callback?token=${TEST_SECRETS[3]}&step=authorize`; + const url = new URL('https://example.com/portal'); + url.searchParams.set('redirect', nestedUrl); + url.searchParams.set( + 'payload', + JSON.stringify({ password: TEST_SECRETS[2], action: 'profile' }) + ); + + const output = serialized(redactSensitiveData(url)); + + expect(output).not.toContain(TEST_SECRETS[2]); + expect(output).not.toContain(TEST_SECRETS[3]); + expect(output).toContain('authorize'); + expect(output).toContain('profile'); + }); + + it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => { + const input: Record = { + status: 'ok', + password: TEST_SECRETS[2], + items: [1, 2, 3, 4], + long: 'abcdefghij', + nested: { level: { value: 'too deep' } }, + extraA: 'a', + extraB: 'b', + }; + input['self'] = input; + const originalItems = input['items']; + + const result = redactSensitiveData(input, { + maxArrayItems: 2, + maxDepth: 2, + maxObjectKeys: 6, + maxStringLength: 8, + }); + + expect(input['password']).toBe(TEST_SECRETS[2]); + expect(input['items']).toBe(originalItems); + expect(result).not.toBe(input); + expect(() => serialized(result)).not.toThrow(); + expect(serialized(result)).not.toContain(TEST_SECRETS[2]); + expect(serialized(result)).toContain('[Truncated'); + }); +}); diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.ts b/libs/shared/logging/src/lib/redact-sensitive-data.ts new file mode 100644 index 000000000..758d969bf --- /dev/null +++ b/libs/shared/logging/src/lib/redact-sensitive-data.ts @@ -0,0 +1,320 @@ +export const REDACTED_VALUE = '[Redacted]'; + +const CIRCULAR_VALUE = '[Circular]'; +const MAX_DEPTH_VALUE = '[MaxDepth]'; +const DEFAULT_MAX_DEPTH = 6; +const DEFAULT_MAX_ARRAY_ITEMS = 50; +const DEFAULT_MAX_OBJECT_KEYS = 50; +const DEFAULT_MAX_STRING_LENGTH = 2_000; + +const SENSITIVE_KEY_NAMES = new Set([ + 'apikey', + 'auth', + 'authorization', + 'cookie', + 'credentials', + 'login', + 'mac', + 'macaddress', + 'passwd', + 'password', + 'pwd', + 'secret', + 'setcookie', + 'token', + 'username', +]); + +const SENSITIVE_KEY_SUFFIXES = [ + 'apikey', + 'authorization', + 'cookie', + 'macaddress', + 'passwd', + 'password', + 'secret', + 'token', + 'username', +]; + +export interface RedactionOptions { + maxDepth?: number; + maxArrayItems?: number; + maxObjectKeys?: number; + maxStringLength?: number; +} + +interface ResolvedRedactionOptions { + maxDepth: number; + maxArrayItems: number; + maxObjectKeys: number; + maxStringLength: number; +} + +function normalizeKey(key: string): string { + return key.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function isSensitiveKey(key: string): boolean { + const normalized = normalizeKey(key); + return ( + SENSITIVE_KEY_NAMES.has(normalized) || + SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix)) + ); +} + +function resolveOptions(options: RedactionOptions): ResolvedRedactionOptions { + return { + maxDepth: Math.max(0, options.maxDepth ?? DEFAULT_MAX_DEPTH), + maxArrayItems: Math.max( + 0, + options.maxArrayItems ?? DEFAULT_MAX_ARRAY_ITEMS + ), + maxObjectKeys: Math.max( + 0, + options.maxObjectKeys ?? DEFAULT_MAX_OBJECT_KEYS + ), + maxStringLength: Math.max( + 0, + options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH + ), + }; +} + +function truncateString(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + const omitted = value.length - maxLength; + return `${value.slice(0, maxLength)}[Truncated ${omitted} chars]`; +} + +function redactSearchParams( + params: URLSearchParams, + sanitizeValue: (value: string) => string +): URLSearchParams { + const redacted = new URLSearchParams(); + + params.forEach((value, key) => { + redacted.append( + key, + isSensitiveKey(key) ? REDACTED_VALUE : sanitizeValue(value) + ); + }); + + return redacted; +} + +function redactUrl( + value: URL, + sanitizeValue: (value: string) => string +): string { + const redacted = new URL(value.toString()); + + if (redacted.username) { + redacted.username = REDACTED_VALUE; + } + if (redacted.password) { + redacted.password = REDACTED_VALUE; + } + + const search = redactSearchParams( + redacted.searchParams, + sanitizeValue + ).toString(); + redacted.search = search ? `?${search}` : ''; + + return redacted.toString(); +} + +function redactUrlStrings( + value: string, + sanitizeValue: (value: string) => string +): string { + return value.replace(/https?:\/\/[^\s"'<>]+/giu, (candidate) => { + try { + return redactUrl(new URL(candidate), sanitizeValue); + } catch { + return candidate; + } + }); +} + +function looksLikeSearchParams(value: string): boolean { + return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)+$/u.test(value); +} + +export function redactSensitiveData( + value: unknown, + options: RedactionOptions = {} +): unknown { + const resolved = resolveOptions(options); + const seen = new WeakSet(); + + const visitString = (input: string, depth: number): string => { + const trimmed = input.trim(); + + if (depth >= resolved.maxDepth) { + return MAX_DEPTH_VALUE; + } + + if ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + try { + return truncateString( + JSON.stringify(visit(JSON.parse(trimmed), depth + 1)), + resolved.maxStringLength + ); + } catch { + // Keep processing malformed or non-JSON diagnostic strings. + } + } + + if (/^https?:\/\//iu.test(trimmed)) { + try { + return truncateString( + redactUrl(new URL(trimmed), (entry) => + visitString(entry, depth + 1) + ), + resolved.maxStringLength + ); + } catch { + // Continue with embedded URL handling for malformed URLs. + } + } + + if (looksLikeSearchParams(trimmed)) { + return truncateString( + redactSearchParams(new URLSearchParams(trimmed), (entry) => + visitString(entry, depth + 1) + ).toString(), + resolved.maxStringLength + ); + } + + return truncateString( + redactUrlStrings(input, (entry) => visitString(entry, depth + 1)), + resolved.maxStringLength + ); + }; + + const visitObject = ( + input: Record, + depth: number + ): Record => { + const output: Record = {}; + const keys = Object.keys(input); + + for (const key of keys.slice(0, resolved.maxObjectKeys)) { + if (isSensitiveKey(key)) { + output[key] = REDACTED_VALUE; + continue; + } + + try { + output[key] = visit(input[key], depth + 1); + } catch { + output[key] = '[Unserializable]'; + } + } + + if (keys.length > resolved.maxObjectKeys) { + output['__truncatedKeys'] = keys.length - resolved.maxObjectKeys; + } + + return output; + }; + + const visitError = ( + error: Error, + depth: number + ): Record => { + const output = visitObject( + error as Error & Record, + depth + ); + output['name'] = visitString(error.name, depth + 1); + output['message'] = visitString(error.message, depth + 1); + if (error.stack) { + output['stack'] = visitString(error.stack, depth + 1); + } + if ('cause' in error) { + output['cause'] = visit(error.cause, depth + 1); + } + return output; + }; + + const visit = (input: unknown, depth: number): unknown => { + if ( + input == null || + typeof input === 'boolean' || + typeof input === 'number' + ) { + return input; + } + if (typeof input === 'string') { + return visitString(input, depth); + } + if (typeof input === 'bigint') { + return input.toString(); + } + if (typeof input === 'symbol') { + return input.toString(); + } + if (typeof input === 'function') { + return undefined; + } + if (depth >= resolved.maxDepth) { + return MAX_DEPTH_VALUE; + } + + const object = input as object; + if (seen.has(object)) { + return CIRCULAR_VALUE; + } + seen.add(object); + + if (input instanceof URL) { + return redactUrl(input, (entry) => visitString(entry, depth + 1)); + } + if (input instanceof URLSearchParams) { + return redactSearchParams(input, (entry) => + visitString(entry, depth + 1) + ).toString(); + } + if (input instanceof Error) { + return visitError(input, depth); + } + if (input instanceof Date) { + return input.toISOString(); + } + if (Array.isArray(input)) { + const output = input + .slice(0, resolved.maxArrayItems) + .map((entry) => visit(entry, depth + 1)); + if (input.length > resolved.maxArrayItems) { + output.push( + `[Truncated ${input.length - resolved.maxArrayItems} items]` + ); + } + return output; + } + if (input instanceof Map) { + const entries: Record = {}; + for (const [key, entry] of input) { + entries[String(key)] = entry; + } + return visitObject(entries, depth); + } + if (input instanceof Set) { + return visit(Array.from(input), depth); + } + + return visitObject(input as Record, depth); + }; + + return visit(value, 0); +} diff --git a/libs/shared/logging/tsconfig.json b/libs/shared/logging/tsconfig.json new file mode 100644 index 000000000..ab8e5af25 --- /dev/null +++ b/libs/shared/logging/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/libs/shared/logging/tsconfig.lib.json b/libs/shared/logging/tsconfig.lib.json new file mode 100644 index 000000000..163d90724 --- /dev/null +++ b/libs/shared/logging/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/shared/logging/tsconfig.spec.json b/libs/shared/logging/tsconfig.spec.json new file mode 100644 index 000000000..4b0383fc4 --- /dev/null +++ b/libs/shared/logging/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2e8cc3c8c..05edde3ed 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -80,6 +80,7 @@ "libs/shared/m3u-utils/src/index.ts" ], "@iptvnator/shared/testing": ["libs/shared/testing/src/index.ts"], + "@iptvnator/shared/logging": ["libs/shared/logging/src/index.ts"], "@iptvnator/services": ["libs/services/src/index.ts"], "@iptvnator/shared/interfaces": [ "libs/shared/interfaces/src/index.ts"