diff --git a/packages/instrumentation-mysql2/README.md b/packages/instrumentation-mysql2/README.md index 52d6d65d60..8ad5c22f67 100644 --- a/packages/instrumentation-mysql2/README.md +++ b/packages/instrumentation-mysql2/README.md @@ -48,6 +48,9 @@ You can set the following instrumentation options: | ------- | ---- | ----------- | | `responseHook` | `MySQL2InstrumentationExecutionResponseHook` (function) | Function for adding custom attributes from db response | | `addSqlCommenterCommentToQueries` | `boolean` | If true, adds [sqlcommenter](https://github.com/open-telemetry/opentelemetry-sqlcommenter) specification compliant comment to queries with tracing context (default false). _NOTE: A comment will not be added to queries that already contain `--` or `/* ... */` in them, even if these are not actually part of comments_ | +| `maskStatement` | `boolean` | If true, masks the `db.statement` attribute in spans (default false) with the `maskStatementHook` | +| `maskStatementHook` | `MySQL2InstrumentationMaskStatementHook` (function) | Function for masking the `db.statement` attribute in spans Default: `return query.replace(/\b\d+\b/g, '?').replac(/(["'])(?:(?=(\\?))\2.)*?\1/g, '?');`| + ## Semantic Conventions diff --git a/packages/instrumentation-mysql2/src/instrumentation.ts b/packages/instrumentation-mysql2/src/instrumentation.ts index d22db9a020..2cb7da960d 100644 --- a/packages/instrumentation-mysql2/src/instrumentation.ts +++ b/packages/instrumentation-mysql2/src/instrumentation.ts @@ -137,13 +137,20 @@ export class MySQL2Instrumentation extends InstrumentationBase { diff --git a/packages/instrumentation-mysql2/src/types.ts b/packages/instrumentation-mysql2/src/types.ts index 01e9f8a434..7ecc4a0a55 100644 --- a/packages/instrumentation-mysql2/src/types.ts +++ b/packages/instrumentation-mysql2/src/types.ts @@ -25,7 +25,26 @@ export interface MySQL2InstrumentationExecutionResponseHook { (span: Span, responseHookInfo: MySQL2ResponseHookInformation): void; } +export interface MySQL2InstrumentationQueryMaskingHook { + (query: string): string; +} + export interface MySQL2InstrumentationConfig extends InstrumentationConfig { + /** + * If true, the query will be masked before setting it as a span attribute, using the {@link maskStatementHook}. + * + * @default false + * @see maskStatementHook + */ + maskStatement?: boolean; + + /** + * Hook that allows masking the query string before setting it as span attribute. + * + * @default (query: string) => query.replace(/\b\d+\b/g, '?').replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, '?') + */ + maskStatementHook?: MySQL2InstrumentationQueryMaskingHook; + /** * Hook that allows adding custom span attributes based on the data * returned MySQL2 queries. diff --git a/packages/instrumentation-mysql2/src/utils.ts b/packages/instrumentation-mysql2/src/utils.ts index 9e495a7d2c..ff62d08ccc 100644 --- a/packages/instrumentation-mysql2/src/utils.ts +++ b/packages/instrumentation-mysql2/src/utils.ts @@ -23,6 +23,7 @@ import { SEMATTRS_NET_PEER_PORT, } from '@opentelemetry/semantic-conventions'; import type * as mysqlTypes from 'mysql2'; +import { MySQL2InstrumentationQueryMaskingHook } from './types'; type formatType = typeof mysqlTypes.format; @@ -107,22 +108,50 @@ function getJDBCString( export function getDbStatement( query: string | Query | QueryOptions, format?: formatType, - values?: any[] + values?: any[], + maskStatement = false, + maskStatementHook: MySQL2InstrumentationQueryMaskingHook = defaultMaskingHook ): string { - if (!format) { - return typeof query === 'string' ? query : query.sql; - } - if (typeof query === 'string') { - return values ? format(query, values) : query; - } else { - // According to https://github.com/mysqljs/mysql#performing-queries - // The values argument will override the values in the option object. - return values || (query as QueryOptions).values - ? format(query.sql, values || (query as QueryOptions).values) - : query.sql; + const [querySql, queryValues] = + typeof query === 'string' + ? [query, values] + : [query.sql, hasValues(query) ? values || query.values : values]; + try { + if (maskStatement) { + return maskStatementHook(querySql); + } else if (format && queryValues) { + return format(querySql, queryValues); + } else { + return querySql; + } + } catch (e) { + return 'Could not determine the query due to an error in masking or formatting'; } } +/** + * Replaces numeric values and quoted strings in the query with placeholders ('?'). + * + * - `\b\d+\b`: Matches whole numbers (integers) and replaces them with '?'. + * - `(["'])(?:(?=(\\?))\2.)*?\1`: + * - Matches quoted strings (both single `'` and double `"` quotes). + * - Uses a lookahead `(?=(\\?))` to detect an optional backslash without consuming it immediately. + * - Captures the optional backslash `\2` and ensures escaped quotes inside the string are handled correctly. + * - Ensures that only complete quoted strings are replaced with '?'. + * + * This prevents accidental replacement of escaped quotes within strings and ensures that the + * query structure remains intact while masking sensitive data. + */ +function defaultMaskingHook(query: string): string { + return query + .replace(/\b\d+\b/g, '?') + .replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, '?'); +} + +function hasValues(obj: Query | QueryOptions): obj is QueryOptions { + return 'values' in obj; +} + /** * The span name SHOULD be set to a low cardinality value * representing the statement executed on the database. diff --git a/packages/instrumentation-mysql2/test/mysql.test.ts b/packages/instrumentation-mysql2/test/mysql.test.ts index 1169e1e3d3..bfa1a112a6 100644 --- a/packages/instrumentation-mysql2/test/mysql.test.ts +++ b/packages/instrumentation-mysql2/test/mysql.test.ts @@ -1225,6 +1225,200 @@ describe('mysql2', () => { }); }); }); + describe('#maskStatementHook', () => { + beforeEach(done => { + //create table user and insert data + rootConnection.query( + 'CREATE TABLE user (id INT, name VARCHAR(255), age INT)', + () => { + rootConnection.query( + 'INSERT INTO user (id, name, age) VALUES (1, "test", 35)', + done + ); + } + ); + }); + + afterEach(done => { + rootConnection.query('DROP TABLE user', done); + }); + describe('default maskStatementHook', () => { + beforeEach(done => { + instrumentation.setConfig({ + maskStatement: true, + }); + memoryExporter.reset(); + done(); + }); + + it('should mask string and numbers in statements', done => { + const query = + "SELECT * FROM user WHERE name = 'test' AND age = 35 AND id = 1"; + const maskedQuery = + 'SELECT * FROM user WHERE name = ? AND age = ? AND id = ?'; + const span = provider.getTracer('default').startSpan('test span'); + context.with(trace.setSpan(context.active(), span), () => { + connection.query(query, (err, res: RowDataPacket[]) => { + assert.ifError(err); + assert.ok(res); + assert.strictEqual(res[0].name, 'test'); + assert.strictEqual(res[0].age, 35); + assert.strictEqual(res[0].id, 1); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assertSpan(spans[0], maskedQuery); + done(); + }); + }); + }); + }); + describe('custom maskStatementHook', () => { + beforeEach(done => { + instrumentation.setConfig({ + maskStatement: true, + maskStatementHook: query => { + return query + .replace(/\b\d+\b/g, '*') + .replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, '*'); + }, + }); + memoryExporter.reset(); + done(); + }); + + it('should mask string and numbers in statements', done => { + const query = + "SELECT * FROM user WHERE name = 'test' AND age = 35 AND id = 1"; + const maskedQuery = + 'SELECT * FROM user WHERE name = * AND age = * AND id = *'; + const span = provider.getTracer('default').startSpan('test span'); + context.with(trace.setSpan(context.active(), span), () => { + connection.query(query, (err, res: RowDataPacket[]) => { + assert.ifError(err); + assert.ok(res); + assert.strictEqual(res[0].name, 'test'); + assert.strictEqual(res[0].age, 35); + assert.strictEqual(res[0].id, 1); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assertSpan(spans[0], maskedQuery); + done(); + }); + }); + }); + }); + describe('maskStatementHook with error', () => { + beforeEach(done => { + instrumentation.setConfig({ + maskStatement: true, + maskStatementHook: () => { + throw new Error('random failure!'); + }, + }); + memoryExporter.reset(); + done(); + }); + it('should not affect the behavior of the query', done => { + const query = + "SELECT * FROM user WHERE name = 'test' AND age = 35 AND id = 1"; + const errorQuery = + 'Could not determine the query due to an error in masking or formatting'; + const span = provider.getTracer('default').startSpan('test span'); + context.with(trace.setSpan(context.active(), span), () => { + connection.query(query, (err, res: RowDataPacket[]) => { + assert.ifError(err); + assert.ok(res); + assert.strictEqual(res[0].name, 'test'); + assert.strictEqual(res[0].age, 35); + assert.strictEqual(res[0].id, 1); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assertSpan(spans[0], errorQuery); + done(); + }); + }); + }); + }); + }); + describe('#maskStatement', () => { + beforeEach(done => { + memoryExporter.reset(); + done(); + }); + + it('should mask query if maskStatement is true', done => { + instrumentation.setConfig({ + maskStatement: true, + }); + const query = 'SELECT 1+1 as solution'; + const maskedQuery = 'SELECT ?+? as solution'; + const span = provider.getTracer('default').startSpan('test span'); + context.with(trace.setSpan(context.active(), span), () => { + connection.query(query, (err, res: RowDataPacket[]) => { + assert.ifError(err); + assert.ok(res); + assert.strictEqual(res[0].solution, 2); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assertSpan(spans[0], maskedQuery); + done(); + }); + }); + }); + it('should return masked query, if values are present', done => { + instrumentation.setConfig({ + maskStatement: true, + }); + const query = 'SELECT ?+? as solution'; + const maskedQuery = 'SELECT ?+? as solution'; + const span = provider.getTracer('default').startSpan('test span'); + context.with(trace.setSpan(context.active(), span), () => { + connection.query(query, [1, 1], (err, res: RowDataPacket[]) => { + assert.ifError(err); + assert.ok(res); + assert.strictEqual(res[0].solution, 2); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assertSpan(spans[0], maskedQuery); + done(); + }); + }); + }); + it('should not mask query if maskStatement is false (default)', done => { + const query = 'SELECT 1+1 as solution'; + const span = provider.getTracer('default').startSpan('test span'); + context.with(trace.setSpan(context.active(), span), () => { + connection.query(query, (err, res: RowDataPacket[]) => { + assert.ifError(err); + assert.ok(res); + assert.strictEqual(res[0].solution, 2); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assertSpan(spans[0], query); + done(); + }); + }); + }); + it('should return query with values, if values are present and maskStatement is false', done => { + instrumentation.setConfig({ + maskStatement: false, + }); + const query = 'SELECT ?+? as solution'; + const queryWithValues = 'SELECT 1+1 as solution'; + const span = provider.getTracer('default').startSpan('test span'); + context.with(trace.setSpan(context.active(), span), () => { + connection.query(query, [1, 1], (err, res: RowDataPacket[]) => { + assert.ifError(err); + assert.ok(res); + assert.strictEqual(res[0].solution, 2); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assertSpan(spans[0], queryWithValues); + done(); + }); + }); + }); + }); }); describe('promise API', () => { let instrumentation: MySQL2Instrumentation;