mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(database): keep SQL trace values private
This commit is contained in:
1 parent
1e027b8e40
commit
6bb7e764d8
11 files changed
+220
-25
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: database
|
||||
---
|
||||
|
||||
SQLite diagnostics now record only statement types, preventing playlist credentials and other private values from appearing in trace logs.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { trace } from './debug-trace';
|
||||
import { trace, traceSqlStatement } from './debug-trace';
|
||||
|
||||
describe('debug trace redaction', () => {
|
||||
afterEach(() => {
|
||||
@@ -44,4 +44,25 @@ describe('debug trace redaction', () => {
|
||||
expect(output).toContain('[Redacted]');
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
|
||||
it('records only the statement type for expanded worker SQL', () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const secrets = [
|
||||
'worker-user-secret',
|
||||
'worker-password-secret',
|
||||
'https://worker-user:worker-password@example.com/live?token=worker-token-secret',
|
||||
];
|
||||
|
||||
traceSqlStatement(
|
||||
'sql-worker',
|
||||
`INSERT INTO playlists (username, password, url) VALUES ('${secrets[0]}', '${secrets[1]}', '${secrets[2]}')`
|
||||
);
|
||||
|
||||
const output = (console.log as jest.Mock).mock.calls.flat().join('\n');
|
||||
expect(output).toContain('"statementType":"INSERT"');
|
||||
expect(output).not.toContain('INSERT INTO');
|
||||
for (const secret of secrets) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import {
|
||||
redactSensitiveData,
|
||||
summarizeSqlStatementForTrace,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const TRACE_PREFIX = '[IPTVnator Trace]';
|
||||
@@ -84,10 +87,6 @@ export function roundTraceDuration(durationMs: number): number {
|
||||
return Math.round(durationMs * 10) / 10;
|
||||
}
|
||||
|
||||
export function compactSqlForTrace(sql: string): string {
|
||||
return truncateString(sql.replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
export function summarizeForTrace(value: unknown, depth = 0): unknown {
|
||||
if (
|
||||
value == null ||
|
||||
@@ -176,3 +175,7 @@ export function trace(scope: string, message: string, payload?: unknown): void {
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function traceSqlStatement(scope: string, sql: unknown): void {
|
||||
trace(scope, 'query', summarizeSqlStatementForTrace(sql));
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
registerNativeModuleSearchPaths,
|
||||
} from './worker-runtime-paths';
|
||||
import {
|
||||
compactSqlForTrace,
|
||||
isSqlTraceEnabled,
|
||||
trace,
|
||||
traceSqlStatement,
|
||||
} from '../services/debug-trace';
|
||||
|
||||
let drizzleFactory:
|
||||
@@ -67,11 +67,7 @@ export async function getWorkerDatabase(): Promise<AppDatabase> {
|
||||
const filePath = getIptvnatorDatabasePath();
|
||||
sqlite = new Database(filePath, {
|
||||
verbose: isSqlTraceEnabled()
|
||||
? (sql: string) => {
|
||||
trace('sql-worker', 'query', {
|
||||
sql: compactSqlForTrace(sql),
|
||||
});
|
||||
}
|
||||
? (sql: string) => traceSqlStatement('sql-worker', sql)
|
||||
: undefined,
|
||||
});
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
@@ -18,6 +18,10 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
|
||||
}
|
||||
|
||||
describe('database schema statements', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const {
|
||||
createTableStatements,
|
||||
columnMigrationStatements,
|
||||
@@ -37,6 +41,26 @@ describe('database schema statements', () => {
|
||||
}
|
||||
).ensureDownloadsPauseResumeSchema;
|
||||
|
||||
it('records only the statement type for expanded main-process SQL', () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const secrets = [
|
||||
'main-user-secret',
|
||||
'main-password-secret',
|
||||
'https://main-user:main-password@example.com/live?token=main-token-secret',
|
||||
];
|
||||
|
||||
__databaseConnectionTestHooks.traceSqlStatement(
|
||||
`UPDATE playlists SET username = '${secrets[0]}', password = '${secrets[1]}', url = '${secrets[2]}'`
|
||||
);
|
||||
|
||||
const output = (console.log as jest.Mock).mock.calls.flat().join('\n');
|
||||
expect(output).toContain('"statementType":"UPDATE"');
|
||||
expect(output).not.toContain('UPDATE playlists');
|
||||
for (const secret of secrets) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
function createRebuildSqlite(legacyTableSql: string | undefined) {
|
||||
const statements: string[] = [];
|
||||
const transaction = jest.fn((callback: () => void) => callback);
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import {
|
||||
redactSensitiveData,
|
||||
summarizeSqlStatementForTrace,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import * as schema from './schema';
|
||||
import { getIptvnatorDatabasePath } from './path-utils';
|
||||
|
||||
@@ -49,13 +53,6 @@ function isSqlTraceEnabled(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function compactSqlForTrace(sql: string): string {
|
||||
const compactSql = sql.replace(/\s+/g, ' ').trim();
|
||||
return compactSql.length <= 180
|
||||
? compactSql
|
||||
: `${compactSql.slice(0, 177)}...`;
|
||||
}
|
||||
|
||||
function traceSql(scope: string, message: string, payload?: unknown): void {
|
||||
if (payload === undefined) {
|
||||
console.log(`[IPTVnator Trace][${scope}] ${message}`);
|
||||
@@ -63,10 +60,16 @@ function traceSql(scope: string, message: string, payload?: unknown): void {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(payload)}`
|
||||
`[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(
|
||||
redactSensitiveData(payload)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
function traceSqlStatement(sql: unknown): void {
|
||||
traceSql('sql-main', 'query', summarizeSqlStatementForTrace(sql));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database file path
|
||||
*/
|
||||
@@ -402,6 +405,7 @@ export const __databaseConnectionTestHooks = {
|
||||
backfillEpgProgramSourceUrls,
|
||||
cleanupLegacyTmdbSearchCache,
|
||||
runMigrations,
|
||||
traceSqlStatement,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -1119,11 +1123,7 @@ export async function initDatabase(
|
||||
sqlite = new Database(filePath, {
|
||||
readonly,
|
||||
verbose: isSqlTraceEnabled()
|
||||
? (message?: unknown) => {
|
||||
traceSql('sql-main', 'query', {
|
||||
sql: compactSqlForTrace(String(message ?? '')),
|
||||
});
|
||||
}
|
||||
? (message?: unknown) => traceSqlStatement(message)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@iptvnator/shared/logging",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "./src/index.js",
|
||||
"types": "./src/index.d.ts",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:shared-runtime", "type:util"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/shared/logging",
|
||||
"main": "libs/shared/logging/src/index.ts",
|
||||
"tsConfig": "libs/shared/logging/tsconfig.lib.json",
|
||||
"assets": []
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
|
||||
@@ -3,6 +3,14 @@ export {
|
||||
redactSensitiveData,
|
||||
} from './lib/redact-sensitive-data';
|
||||
export type { RedactionOptions } from './lib/redact-sensitive-data';
|
||||
export {
|
||||
SQL_TRACE_STATEMENT_TYPE,
|
||||
summarizeSqlStatementForTrace,
|
||||
} from './lib/sql-trace-summary';
|
||||
export type {
|
||||
SqlTraceStatementType,
|
||||
SqlTraceSummary,
|
||||
} from './lib/sql-trace-summary';
|
||||
export {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { summarizeSqlStatementForTrace } from './sql-trace-summary';
|
||||
|
||||
describe('summarizeSqlStatementForTrace', () => {
|
||||
it.each([
|
||||
[
|
||||
'SELECT',
|
||||
`SELECT * FROM playlists WHERE username = 'trace-user-secret' AND password = 'trace-password-secret' AND token = 'trace-token-secret'`,
|
||||
],
|
||||
[
|
||||
'INSERT',
|
||||
`INSERT INTO playlists (name) VALUES ('O''Brien-secret')`,
|
||||
],
|
||||
[
|
||||
'UPDATE',
|
||||
`UPDATE playlists SET url = 'https://url-user-secret:url-password-secret@example.com/live?token=url-token-secret'`,
|
||||
],
|
||||
[
|
||||
'DELETE',
|
||||
`DELETE FROM content WHERE id = 987654321 AND payload = X'7365637265742D626C6F62'`,
|
||||
],
|
||||
[
|
||||
'SELECT',
|
||||
` \n\tSeLeCt * FROM content WHERE rating = 12345.6789`,
|
||||
],
|
||||
[
|
||||
'WITH',
|
||||
`WITH credentials AS (SELECT 'with-secret') SELECT * FROM credentials`,
|
||||
],
|
||||
])('returns only the %s statement type', (statementType, sql) => {
|
||||
const summary = summarizeSqlStatementForTrace(sql);
|
||||
const serialized = JSON.stringify(summary);
|
||||
|
||||
expect(summary).toEqual({ statementType });
|
||||
expect(serialized).toBe(`{"statementType":"${statementType}"}`);
|
||||
expect(serialized).not.toContain('secret');
|
||||
expect(serialized).not.toContain(`O''Brien-secret`);
|
||||
expect(serialized).not.toContain('987654321');
|
||||
expect(serialized).not.toContain('7365637265742D626C6F62');
|
||||
expect(serialized).not.toContain('12345.6789');
|
||||
expect(serialized).not.toContain('length');
|
||||
});
|
||||
|
||||
it.each([
|
||||
` \n-- SELECT 'comment-secret'\nSELECT * FROM playlists`,
|
||||
`\t/* INSERT 'comment-secret' */ SELECT * FROM playlists`,
|
||||
`VACUUMINTO 'malicious-secret'`,
|
||||
`SELECTpassword FROM credentials`,
|
||||
`DO 'unrecognized-secret'`,
|
||||
`'; DROP TABLE playlists; -- malicious-secret`,
|
||||
'',
|
||||
undefined,
|
||||
null,
|
||||
])('maps comments and unrecognized input to OTHER', (sql) => {
|
||||
expect(summarizeSqlStatementForTrace(sql)).toEqual({
|
||||
statementType: 'OTHER',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export const SQL_TRACE_STATEMENT_TYPE = {
|
||||
SELECT: 'SELECT',
|
||||
INSERT: 'INSERT',
|
||||
UPDATE: 'UPDATE',
|
||||
DELETE: 'DELETE',
|
||||
REPLACE: 'REPLACE',
|
||||
CREATE: 'CREATE',
|
||||
ALTER: 'ALTER',
|
||||
DROP: 'DROP',
|
||||
PRAGMA: 'PRAGMA',
|
||||
WITH: 'WITH',
|
||||
BEGIN: 'BEGIN',
|
||||
COMMIT: 'COMMIT',
|
||||
ROLLBACK: 'ROLLBACK',
|
||||
SAVEPOINT: 'SAVEPOINT',
|
||||
RELEASE: 'RELEASE',
|
||||
VACUUM: 'VACUUM',
|
||||
ANALYZE: 'ANALYZE',
|
||||
REINDEX: 'REINDEX',
|
||||
ATTACH: 'ATTACH',
|
||||
DETACH: 'DETACH',
|
||||
EXPLAIN: 'EXPLAIN',
|
||||
OTHER: 'OTHER',
|
||||
} as const;
|
||||
|
||||
export type SqlTraceStatementType =
|
||||
(typeof SQL_TRACE_STATEMENT_TYPE)[keyof typeof SQL_TRACE_STATEMENT_TYPE];
|
||||
|
||||
export interface SqlTraceSummary {
|
||||
statementType: SqlTraceStatementType;
|
||||
}
|
||||
|
||||
const ALLOWED_STATEMENT_TYPES = new Set<SqlTraceStatementType>(
|
||||
Object.values(SQL_TRACE_STATEMENT_TYPE).filter(
|
||||
(statementType) =>
|
||||
statementType !== SQL_TRACE_STATEMENT_TYPE.OTHER
|
||||
) as SqlTraceStatementType[]
|
||||
);
|
||||
|
||||
const SQL_STATEMENT_PREFIX = /^\s*([A-Za-z]+)(?=[\s(;]|$)/;
|
||||
|
||||
export function summarizeSqlStatementForTrace(sql: unknown): SqlTraceSummary {
|
||||
if (typeof sql !== 'string') {
|
||||
return { statementType: SQL_TRACE_STATEMENT_TYPE.OTHER };
|
||||
}
|
||||
|
||||
const match = SQL_STATEMENT_PREFIX.exec(sql);
|
||||
const candidate = match?.[1]?.toUpperCase() as
|
||||
| SqlTraceStatementType
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
statementType:
|
||||
candidate && ALLOWED_STATEMENT_TYPES.has(candidate)
|
||||
? candidate
|
||||
: SQL_TRACE_STATEMENT_TYPE.OTHER,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user