fix(logging): redact sensitive portal and Electron diagnostics (#1182)

* fix: redact sensitive log data

* fix(ci): keep logging preload self-contained

* fix(logging): preserve shared diagnostics

* fix(logging): close trace redaction gaps

* fix(logging): redact Xtream path credentials

* fix(logging): close credential redaction gaps

* fix(logging): suppress external player arguments

* fix(logging): harden URL and date redaction

* fix(logging): redact map keys and URL fragments

* fix(logging): redact sensitive map values

* fix(logging): redact credentials in diagnostic text

* fix(logging): close remaining credential leaks
This commit is contained in:
4gray authored and GitHub committed 2026-07-19 08:30:21 +02:00
1 parent 29e624b0dd
commit 5cae310430
29 files changed
+1203 -158

No files matched your search

+4
View File
@@ -73,6 +73,10 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `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
+5
View File
@@ -130,6 +130,10 @@ Useful narrower flags:
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `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
@@ -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');
});
});
@@ -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<object>(),
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<string, unknown>;
for (const [key, entry] of Object.entries(
value as unknown as Record<string, unknown>
)) {
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<string, unknown>).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),
};
}
@@ -0,0 +1,70 @@
const handlers = new Map<string, (...args: unknown[]) => 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');
});
it('does not print credentials embedded in external player arguments', () => {
const authorizationSecret = 'player-authorization-secret';
const cookieSecret = 'player-cookie-secret';
const handler = handlers.get('SETTINGS_UPDATE');
expect(handler).toBeDefined();
handler?.(
{},
{
language: 'de',
mpvPlayerArguments: `--http-header-fields=Authorization: Bearer ${authorizationSecret}`,
vlcPlayerArguments: `--http-referrer=https://example.com --http-cookie=Cookie: ${cookieSecret}`,
}
);
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
expect(output).not.toContain(authorizationSecret);
expect(output).not.toContain(cookieSecret);
expect(output).toContain('language');
expect(output).toContain('de');
});
});
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron';
import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import {
EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS,
@@ -17,7 +18,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(
@@ -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)) {
@@ -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
)
)
);
}
@@ -0,0 +1,47 @@
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');
});
it('redacts serialized credentials before truncating trace strings', () => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const secret = 'long-trace-json-password-secret';
const diagnostic = JSON.stringify({
password: secret,
operation: 'get_profile',
padding: 'x'.repeat(300),
});
trace('portal', 'request', { diagnostic });
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
expect(output).not.toContain(secret);
expect(output).toContain('[Redacted]');
expect(output).toContain('get_profile');
});
});
@@ -1,3 +1,5 @@
import { redactSensitiveData } from '@iptvnator/shared/logging';
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
const TRACE_PREFIX = '[IPTVnator Trace]';
const MAX_TRACE_ARRAY_ITEMS = 5;
@@ -146,10 +148,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 +168,7 @@ export function trace(scope: string, message: string, payload?: unknown): void {
console.log(
`${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace(
summarizeForTrace(payload)
summarizeForTrace(redactSensitiveData(payload))
)}`
);
}
@@ -16,6 +16,7 @@ describe('ElectronService', () => {
const session = { id: 'session-1' };
let electronBridge: {
fetchPlaylistByUrl: jest.Mock;
onPlayerError: jest.Mock;
openInMpv: jest.Mock;
openInVlc: jest.Mock;
};
@@ -24,9 +25,11 @@ describe('ElectronService', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
electronBridge = {
fetchPlaylistByUrl: jest.fn(),
onPlayerError: jest.fn(),
openInMpv: jest.fn().mockResolvedValue(session),
openInVlc: jest.fn().mockResolvedValue(session),
};
@@ -99,6 +102,23 @@ describe('ElectronService', () => {
expect(electronBridge.fetchPlaylistByUrl).not.toHaveBeenCalled();
});
it('redacts credentials from backend player errors before logging', () => {
const secret = 'player-error-token-secret';
const listener = electronBridge.onPlayerError.mock.calls[0][0];
listener({
player: 'MPV',
error: 'Playback failed',
originalError: `Request failed: token=${secret}&channel=news`,
});
const output = JSON.stringify(
(console.error as jest.Mock).mock.calls
);
expect(output).not.toContain(secret);
expect(output).toContain('channel=news');
});
it('shows the trust-host action for Electron-wrapped security errors', async () => {
const securityPayload = {
code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate,
+14 -12
View File
@@ -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<string>([
XtreamCodeActions.GetAccountInfo,
XtreamCodeActions.GetLiveCategories,
@@ -67,7 +67,6 @@ export class ElectronService extends DataService {
constructor() {
super();
this.debugLog('Electron service initialized...');
this.setupPlayerErrorListener();
this.setupPortalDebugListener();
}
@@ -81,7 +80,10 @@ export class ElectronService extends DataService {
error: string;
originalError: string;
}) => {
console.error(`${data.player} Error:`, data.originalError);
this.logger.error(
`${data.player} Error:`,
data.originalError
);
this.snackBar.open(
`${data.player} Error: ${data.error}`,
'Close',
@@ -182,7 +184,7 @@ export class ElectronService extends DataService {
duration: 5000,
}
);
console.error('MPV launch error:', error);
this.logger.error('MPV launch error:', error);
throw error;
}
}
@@ -211,7 +213,7 @@ export class ElectronService extends DataService {
duration: 5000,
}
);
console.error('VLC launch error:', error);
this.logger.error('VLC launch error:', error);
throw error;
}
}
@@ -237,7 +239,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 +267,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',
@@ -362,7 +364,7 @@ export class ElectronService extends DataService {
data.title
);
} else {
console.error(
this.logger.error(
'Either url or filePath must be provided, but not both.'
);
return;
@@ -387,7 +389,7 @@ export class ElectronService extends DataService {
{ duration: 2000 }
);
} catch (error: unknown) {
console.error('Playlist refresh error:', error);
this.logger.error('Playlist refresh error:', error);
if (
data.url &&
this.handlePlaylistSecurityError(error, () => {
@@ -590,12 +592,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
+6 -7
View File
@@ -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<string, Promise<string>>();
private readonly silentXtreamActions = new Set<string>([
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}`,
+20
View File
@@ -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.
+45 -2
View File
@@ -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');
});
});
+18 -5
View File
@@ -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));
}
}
@@ -24,7 +24,8 @@ type GetProfileWithIdentity = (
) => Promise<StalkerProfileResponse>;
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<string, unknown>;
serialNumber?: string;
@@ -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<string, string>();
@@ -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;
@@ -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<boolean> {
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;
}
}
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'shared-logging',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js'],
coverageDirectory: '../../../coverage/libs/shared/logging',
};
+20
View File
@@ -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"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export {
REDACTED_VALUE,
redactSensitiveData,
} from './lib/redact-sensitive-data';
export type { RedactionOptions } from './lib/redact-sensitive-data';
@@ -0,0 +1,357 @@
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 Stalker identity credentials while retaining request diagnostics', () => {
const identitySecrets = {
sn: 'stalker-sn-secret',
serialNumber: 'stalker-serial-number-secret',
device_id: 'stalker-device-id-secret',
deviceId1: 'stalker-device-id1-secret',
device_id2: 'stalker-device-id2-secret',
signature: 'stalker-signature-secret',
signature1: 'stalker-signature1-secret',
signature2: 'stalker-signature2-secret',
stalkerSerialNumber: 'playlist-stalker-serial-number-secret',
stalkerDeviceId1: 'playlist-stalker-device-id1-secret',
stalkerDeviceId2: 'playlist-stalker-device-id2-secret',
stalkerSignature1: 'playlist-stalker-signature1-secret',
stalkerSignature2: 'playlist-stalker-signature2-secret',
prehash: 'stalker-prehash-secret',
};
const result = redactSensitiveData({
action: 'get_profile',
requestId: 'stalker-request-id',
params: identitySecrets,
});
const output = serialized(result);
for (const secret of Object.values(identitySecrets)) {
expect(output).not.toContain(secret);
}
expect(output).toContain('get_profile');
expect(output).toContain('stalker-request-id');
});
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 in non-HTTP stream URL strings', () => {
const username = 'rtsp-user-secret';
const password = 'rtsp-password-secret';
const token = 'rtmp-token-secret';
const output = serialized(
redactSensitiveData([
`rtsp://${username}:${password}@stream.example/live?token=${token}&channel=news`,
`Playback failed: rtmp://stream.example/live?password=${password}&channel=sports`,
])
);
expect(output).not.toContain(username);
expect(output).not.toContain(password);
expect(output).not.toContain(token);
expect(output).toContain('channel=news');
expect(output).toContain('channel=sports');
});
it('redacts a credential from a single-parameter string', () => {
const password = 'single-param-password-secret';
const token = 'single-param-token-secret';
const output = serialized(
redactSensitiveData([
`password=${password}`,
`token=${token}`,
'operation=get_profile',
])
);
expect(output).not.toContain(password);
expect(output).not.toContain(token);
expect(output).toContain('get_profile');
});
it('redacts credentials embedded in diagnostic text', () => {
const token = 'diagnostic-token-secret';
const authorization = 'diagnostic-authorization-secret';
const authorizationAssignment =
'diagnostic-authorization-assignment-secret';
const stalkerDeviceId = 'diagnostic-stalker-device-id-secret';
const diagnostic = [
`Request failed: token=${token}&action=get_profile`,
`Upstream response Authorization: Bearer ${authorization}; status=401`,
`Retrying with authorization=Bearer ${authorizationAssignment}, attempt=2`,
`Identity rejected: stalkerDeviceId1: ${stalkerDeviceId}; action=handshake`,
];
const output = serialized(redactSensitiveData(diagnostic));
expect(output).not.toContain(token);
expect(output).not.toContain(authorization);
expect(output).not.toContain(authorizationAssignment);
expect(output).not.toContain(stalkerDeviceId);
expect(output).toContain('get_profile');
expect(output).toContain('status=401');
expect(output).toContain('attempt=2');
expect(output).toContain('action=handshake');
});
it('does not repeat a redacted Error message secret in its stack', () => {
const secret = 'error-stack-password-secret';
const error = new Error(`password=${secret}&operation=get_profile`);
const output = serialized(redactSensitiveData(error));
expect(output).not.toContain(secret);
expect(output).toContain(encodeURIComponent(REDACTED_VALUE));
expect(output).toContain('get_profile');
});
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('redacts credentials from URL fragments while retaining diagnostics', () => {
const token = 'fragment-token-secret';
const url = new URL(
`https://example.com/callback#access_token=${token}&state=diagnostic`
);
const output = serialized(redactSensitiveData(url));
expect(output).not.toContain(token);
expect(output).toContain('state=diagnostic');
});
it('redacts credentials from Map keys while retaining values', () => {
const username = 'map-key-user-secret';
const password = 'map-key-password-secret';
const token = 'map-key-token-secret';
const requests = new Map([
[
`https://example.com/live/${username}/${password}/101.ts?token=${token}`,
{ status: 200 },
],
]);
const output = serialized(redactSensitiveData(requests));
expect(output).not.toContain(username);
expect(output).not.toContain(password);
expect(output).not.toContain(token);
expect(output).toContain('101.ts');
expect(output).toContain('"status":200');
});
it('redacts Map values selected by sensitive keys', () => {
const authorization = 'map-authorization-secret';
const password = 'map-password-secret';
const headers = new Map([
['Authorization', `Bearer ${authorization}`],
['password', password],
['requestId', 'diagnostic-request-id'],
]);
const result = redactSensitiveData(headers);
const output = serialized(result);
expect(output).not.toContain(authorization);
expect(output).not.toContain(password);
expect(output).toContain('diagnostic-request-id');
expect(result).toEqual({
Authorization: REDACTED_VALUE,
password: REDACTED_VALUE,
requestId: 'diagnostic-request-id',
});
});
it('redacts Xtream credentials in playback URL paths', () => {
const username = 'xtream-path-user-secret';
const password = 'xtream-path-password-secret';
const urls = [
new URL(
`https://example.com/base/live/${username}/${password}/101.ts`
),
new URL(
`https://example.com/movie/${username}/${password}/202.mkv`
),
new URL(
`https://example.com/series/${username}/${password}/303.mp4`
),
new URL(
`https://example.com/timeshift/${username}/${password}/60/2026-07-18:12-00/404.ts`
),
];
const embedded = `Playback failed for https://example.com/live/${username}/${password}/505.m3u8`;
const ordinaryLiveUrl = 'https://example.com/live/channel.m3u8';
const output = serialized(
redactSensitiveData({ urls, embedded, ordinaryLiveUrl })
);
expect(output).not.toContain(username);
expect(output).not.toContain(password);
expect(output).toContain('101.ts');
expect(output).toContain('202.mkv');
expect(output).toContain('303.mp4');
expect(output).toContain('404.ts');
expect(output).toContain('505.m3u8');
expect(output).toContain(ordinaryLiveUrl);
});
it('redacts Xtream path credentials when no resource segment follows', () => {
const username = 'terminal-xtream-user-secret';
const password = 'terminal-xtream-password-secret';
const output = serialized(
redactSensitiveData(
new URL(`https://example.com/live/${username}/${password}`)
)
);
expect(output).not.toContain(username);
expect(output).not.toContain(password);
});
it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => {
const input: Record<string, unknown> = {
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');
});
it('serializes invalid dates without throwing', () => {
const invalidDate = new Date(Number.NaN);
expect(() => redactSensitiveData(invalidDate)).not.toThrow();
expect(redactSensitiveData([invalidDate])).toEqual(['[Invalid Date]']);
});
it('preserves repeated non-circular references while still redacting them', () => {
const shared = {
operation: 'get_profile',
password: TEST_SECRETS[2],
};
const result = redactSensitiveData({ first: shared, second: shared });
expect(result).toEqual({
first: {
operation: 'get_profile',
password: REDACTED_VALUE,
},
second: {
operation: 'get_profile',
password: REDACTED_VALUE,
},
});
});
});
@@ -0,0 +1,395 @@
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',
'deviceid',
'deviceid2',
'login',
'mac',
'macaddress',
'mpvplayerarguments',
'passwd',
'password',
'pwd',
'secret',
'setcookie',
'signature',
'signature2',
'sn',
'token',
'username',
'vlcplayerarguments',
]);
const SENSITIVE_KEY_SUFFIXES = [
'apikey', 'authorization', 'cookie',
'deviceid', 'deviceid1', 'deviceid2',
'macaddress', 'passwd', 'password', 'prehash',
'serialnumber', 'signature', 'signature1', 'signature2',
'secret', 'token', 'username',
];
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
'live',
'movie',
'series',
'timeshift',
]);
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 pathSegments = redacted.pathname.split('/');
for (let index = 0; index < pathSegments.length - 2; index += 1) {
if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(pathSegments[index])) {
pathSegments[index + 1] = REDACTED_VALUE;
pathSegments[index + 2] = REDACTED_VALUE;
}
}
redacted.pathname = pathSegments.join('/');
const search = redactSearchParams(
redacted.searchParams,
sanitizeValue
).toString();
redacted.search = search ? `?${search}` : '';
const fragment = redacted.hash.slice(1);
if (looksLikeSearchParams(fragment)) {
const hash = redactSearchParams(
new URLSearchParams(fragment),
sanitizeValue
).toString();
redacted.hash = hash ? `#${hash}` : '';
}
return redacted.toString();
}
function redactUrlStrings(
value: string,
sanitizeValue: (value: string) => string
): string {
return value.replace(
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
(candidate) => {
try {
return redactUrl(new URL(candidate), sanitizeValue);
} catch {
return candidate;
}
}
);
}
function redactEmbeddedSensitivePairs(value: string): string {
const redactedAssignments = value.replace(
/\b([a-z][a-z0-9_.-]*)(\s*=\s*)((?:Bearer\s+)?[^&\s,;]+)/giu,
(match, key: string, separator: string) =>
isSensitiveKey(key)
? `${key}${separator}${REDACTED_VALUE}`
: match
);
return redactedAssignments.replace(
/\b([a-z0-9_.-]*(?:api[-_.]?key|auth(?:orization)?|cookie|credentials|device[-_.]?id[12]?|login|mac(?:[-_.]?address)?|passwd|password|prehash|pwd|secret|serial[-_.]?number|set[-_.]?cookie|signature[12]?|sn|token|username))(\s*:\s*)(?:Bearer\s+)?[^;,\r\n]+/giu,
(_match, key: string, separator: string) =>
`${key}${separator}${REDACTED_VALUE}`
);
}
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<object>();
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 (/^[a-z][a-z0-9+.-]*:\/\//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
);
}
const redactedText = redactEmbeddedSensitivePairs(input);
return truncateString(
redactUrlStrings(redactedText, (entry) =>
visitString(entry, depth + 1)
),
resolved.maxStringLength
);
};
const visitObject = (
input: Record<string, unknown>,
depth: number
): Record<string, unknown> => {
const output: Record<string, unknown> = {};
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<string, unknown> => {
const output = visitObject(
error as Error & Record<string, unknown>,
depth
);
output['name'] = visitString(error.name, depth + 1);
output['message'] = visitString(error.message, depth + 1);
if (error.stack) {
const [, ...stackFrames] = error.stack.split('\n');
output['stack'] = visitString(
[`${output['name']}: ${output['message']}`, ...stackFrames].join(
'\n'
),
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);
try {
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 Number.isNaN(input.getTime())
? '[Invalid Date]'
: 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<string, unknown> = {};
const mapEntries = Array.from(input).slice(
0,
resolved.maxObjectKeys
);
for (const [key, entry] of mapEntries) {
const stringKey = String(key);
const redactedKey = visitString(stringKey, depth + 1);
entries[redactedKey] =
isSensitiveKey(stringKey) &&
!/[/:?=&]/u.test(stringKey)
? REDACTED_VALUE
: visit(entry, depth + 1);
}
if (input.size > resolved.maxObjectKeys) {
entries['__truncatedKeys'] =
input.size - resolved.maxObjectKeys;
}
return entries;
}
if (input instanceof Set) {
return visit(Array.from(input), depth);
}
return visitObject(input as Record<string, unknown>, depth);
} finally {
seen.delete(object);
}
};
return visit(value, 0);
}
+19
View File
@@ -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" }
]
}
+10
View File
@@ -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"]
}
+15
View File
@@ -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"
]
}
+7
View File
@@ -149,6 +149,13 @@
"validationCommand": "pnpm nx test shared-interfaces",
"e2eTags": ["@m3u", "@xtream", "@stalker"]
},
{
"name": "shared-logging",
"root": "libs/shared/logging",
"sourceRoot": "libs/shared/logging/src",
"validationCommand": "pnpm nx test shared-logging",
"e2eTags": ["@electron", "@xtream", "@stalker", "@settings"]
},
{
"name": "m3u-utils",
"root": "libs/shared/m3u-utils",
+1
View File
@@ -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"