From f2046e6d8489654ddb1d861c5d2d69ac9cb36d22 Mon Sep 17 00:00:00 2001 From: Abhilash Date: Mon, 23 Feb 2026 17:20:12 +0530 Subject: [PATCH 1/2] feat: instrumented pg bind variables --- .../currencies/databases/pg/app.js | 13 ++++++++ .../currencies/databases/pg/test_base.js | 30 +++++++++++++++++++ .../tracing/instrumentation/databases/pg.js | 17 +++++++++++ 3 files changed, 60 insertions(+) diff --git a/packages/collector/test/integration/currencies/databases/pg/app.js b/packages/collector/test/integration/currencies/databases/pg/app.js index 2843101905..f31b5ff666 100644 --- a/packages/collector/test/integration/currencies/databases/pg/app.js +++ b/packages/collector/test/integration/currencies/databases/pg/app.js @@ -105,6 +105,19 @@ app.get('/parameterized-query', async (req, res) => { res.json({}); }); +app.get('/bind-variables-test', async (req, res) => { + // Test with string query and array parameters + await client.query('SELECT * FROM users WHERE name = $1 AND email = $2', ['testuser', 'test@example.com']); + + // Test with config object containing values + await pool.query({ + text: 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *', + values: ['bindtest', 'bindtest@example.com'] + }); + + res.json({ success: true }); +}); + app.get('/pool-string-insert', (req, res) => { const insert = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *'; const values = ['beaker', 'beaker@muppets.com']; diff --git a/packages/collector/test/integration/currencies/databases/pg/test_base.js b/packages/collector/test/integration/currencies/databases/pg/test_base.js index b74fe336c5..17e142b255 100644 --- a/packages/collector/test/integration/currencies/databases/pg/test_base.js +++ b/packages/collector/test/integration/currencies/databases/pg/test_base.js @@ -65,6 +65,36 @@ module.exports = function (name, version, isLatest) { ) )); + it('must collect bind variables from parameterized queries', () => + controls + .sendRequest({ + method: 'GET', + path: '/bind-variables-test' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + verifyHttpEntry(spans, '/bind-variables-test'); + + // Verify first query with string and array parameters + const selectQuery = getSpansByName(spans, 'postgres').find( + span => span.data.pg.stmt === 'SELECT * FROM users WHERE name = $1 AND email = $2' + ); + expect(selectQuery).to.exist; + expect(selectQuery.data.pg.bindValues).to.exist; + expect(selectQuery.data.pg.bindValues).to.deep.equal(['testuser', 'test@example.com']); + + // Verify second query with config object containing values + const insertQuery = getSpansByName(spans, 'postgres').find( + span => span.data.pg.stmt === 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *' + ); + expect(insertQuery).to.exist; + expect(insertQuery.data.pg.bindValues).to.exist; + expect(insertQuery.data.pg.bindValues).to.deep.equal(['bindtest', 'bindtest@example.com']); + }) + ) + )); + it('must trace pooled select now', () => controls .sendRequest({ diff --git a/packages/core/src/tracing/instrumentation/databases/pg.js b/packages/core/src/tracing/instrumentation/databases/pg.js index 839724eb02..7dae1adc22 100644 --- a/packages/core/src/tracing/instrumentation/databases/pg.js +++ b/packages/core/src/tracing/instrumentation/databases/pg.js @@ -58,6 +58,19 @@ function instrumentedQuery(ctx, originalQuery, argsForOriginalQuery) { kind: constants.EXIT }); span.stack = tracingUtil.getStackTrace(instrumentedQuery); + + // Extract bind variables/parameters + let bindValues; + if (typeof config === 'string') { + // Query is a string, parameters might be in argsForOriginalQuery[1] + if (argsForOriginalQuery.length > 1 && Array.isArray(argsForOriginalQuery[1])) { + bindValues = argsForOriginalQuery[1]; + } + } else if (config && config.values) { + // Query config object with values property + bindValues = config.values; + } + span.data.pg = { stmt: tracingUtil.shortenDatabaseStatement(typeof config === 'string' ? config : config.text), host, @@ -66,6 +79,10 @@ function instrumentedQuery(ctx, originalQuery, argsForOriginalQuery) { db }; + if (bindValues && bindValues.length > 0) { + span.data.pg.bindValues = bindValues; + } + let originalCallback; let callbackIndex = -1; for (let i = 1; i < argsForOriginalQuery.length; i++) { From 4075f773d2c619154ff5ca93e740216242cf993f Mon Sep 17 00:00:00 2001 From: Abhilash Date: Wed, 4 Mar 2026 12:09:57 +0530 Subject: [PATCH 2/2] chore: update with partial hiding wip --- .../currencies/databases/pg/app.js | 92 +++++- .../currencies/databases/pg/test_base.js | 191 +++++++++++- .../tracing/instrumentation/databases/pg.js | 11 +- packages/core/src/tracing/tracingUtil.js | 146 +++++++++ .../tracingUtil_maskBindVariables_test.js | 285 ++++++++++++++++++ 5 files changed, 714 insertions(+), 11 deletions(-) create mode 100644 packages/core/test/tracing/tracingUtil_maskBindVariables_test.js diff --git a/packages/collector/test/integration/currencies/databases/pg/app.js b/packages/collector/test/integration/currencies/databases/pg/app.js index f31b5ff666..ab16dbb098 100644 --- a/packages/collector/test/integration/currencies/databases/pg/app.js +++ b/packages/collector/test/integration/currencies/databases/pg/app.js @@ -49,6 +49,22 @@ pool.query(createTableQuery, err => { } }); +// Create a stored procedure for testing +const createProcedureQuery = ` + CREATE OR REPLACE FUNCTION get_user_by_name(user_name VARCHAR) + RETURNS TABLE(id INT, name VARCHAR, email VARCHAR) AS $$ + BEGIN + RETURN QUERY SELECT users.id, users.name, users.email FROM users WHERE users.name = user_name; + END; + $$ LANGUAGE plpgsql; +`; + +pool.query(createProcedureQuery, err => { + if (err) { + log('Failed to create stored procedure', err); + } +}); + if (process.env.WITH_STDOUT) { app.use(morgan(`${logPrefix}:method :url :status`)); } @@ -107,7 +123,7 @@ app.get('/parameterized-query', async (req, res) => { app.get('/bind-variables-test', async (req, res) => { // Test with string query and array parameters - await client.query('SELECT * FROM users WHERE name = $1 AND email = $2', ['testuser', 'test@example.com']); + await client.query('SELECT * FROM users WHERE name = testuser AND email = test@example.com'); // Test with config object containing values await pool.query({ @@ -118,6 +134,80 @@ app.get('/bind-variables-test', async (req, res) => { res.json({ success: true }); }); +app.get('/stored-procedure-test', async (req, res) => { + // First insert a test user + await client.query('INSERT INTO users(name, email) VALUES($1, $2) ON CONFLICT DO NOTHING', [ + 'proceduretest', + 'procedure@example.com' + ]); + + // Call stored procedure with bind variable + const result = await client.query('SELECT * FROM get_user_by_name($1)', ['proceduretest']); + + res.json({ success: true, rows: result.rows }); +}); + +app.get('/all-data-types-test', async (req, res) => { + // Test with various data types to demonstrate masking + + // 1. String values + await client.query('SELECT $1::text as string_value', ['sensitive_password_123']); + + // 2. Number values (integer and float) + await client.query('SELECT $1::integer as int_value, $2::numeric as float_value', [42, 3.14159]); + + // 3. Boolean value + await client.query('SELECT $1::boolean as bool_value', [true]); + + // 4. null and undefined (null in SQL) + await client.query('SELECT $1 as null_value', [null]); + + // 5. Date object + await client.query('SELECT $1::timestamp as date_value', [new Date('2024-01-15T10:30:00Z')]); + + // 6. JSON object + await client.query('SELECT $1::jsonb as json_value', [ + JSON.stringify({ user: 'john', email: 'john@example.com', preferences: { theme: 'dark', notifications: true } }) + ]); + + // 7. Array (as JSON string for PostgreSQL) + await client.query('SELECT $1::jsonb as array_value', [JSON.stringify([1, 2, 3, 4, 5])]); + + // 8. Nested JSON with arrays + await client.query('SELECT $1::jsonb as nested_value', [ + JSON.stringify({ + users: [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' } + ], + metadata: { created: '2024-01-01', version: 1 } + }) + ]); + + // 9. Buffer/Binary data (bytea in PostgreSQL) + const imageBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]); // JPEG header + await client.query('SELECT $1::bytea as binary_value', [imageBuffer]); + + // 10. Large buffer (simulating file upload) + const largeBuffer = Buffer.alloc(1024); // 1KB buffer + await client.query('SELECT $1::bytea as large_binary', [largeBuffer]); + + // 11. Mixed types in single query + await client.query('SELECT $1::text, $2::integer, $3::boolean, $4::jsonb, $5::bytea', [ + 'user@example.com', + 12345, + false, + JSON.stringify({ key: 'value' }), + Buffer.from('secret') + ]); + + res.json({ + success: true, + message: 'All data types tested', + note: 'Check spans to see masked bind variables' + }); +}); + app.get('/pool-string-insert', (req, res) => { const insert = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *'; const values = ['beaker', 'beaker@muppets.com']; diff --git a/packages/collector/test/integration/currencies/databases/pg/test_base.js b/packages/collector/test/integration/currencies/databases/pg/test_base.js index 17e142b255..5dcfb8525d 100644 --- a/packages/collector/test/integration/currencies/databases/pg/test_base.js +++ b/packages/collector/test/integration/currencies/databases/pg/test_base.js @@ -77,20 +77,201 @@ module.exports = function (name, version, isLatest) { verifyHttpEntry(spans, '/bind-variables-test'); // Verify first query with string and array parameters - const selectQuery = getSpansByName(spans, 'postgres').find( + let selectQuery = getSpansByName(spans, 'postgres'); + + console.log('SPAN SELECT QUERY: ', selectQuery[0].data); + + selectQuery = selectQuery.find( span => span.data.pg.stmt === 'SELECT * FROM users WHERE name = $1 AND email = $2' ); expect(selectQuery).to.exist; - expect(selectQuery.data.pg.bindValues).to.exist; - expect(selectQuery.data.pg.bindValues).to.deep.equal(['testuser', 'test@example.com']); + expect(selectQuery.data.pg.params).to.exist; + expect(selectQuery.data.pg.params).to.be.an('array'); + expect(selectQuery.data.pg.params).to.have.lengthOf(2); + // Verify values are masked (first 2 and last 2 chars visible, exact length preserved) + // 'testuser' (8 chars) -> 'te****er' + expect(selectQuery.data.pg.params[0]).to.equal('te****er'); + expect(selectQuery.data.pg.params[0]).to.have.lengthOf(8); + // 'test@example.com' (16 chars) -> 'te************om' + expect(selectQuery.data.pg.params[1]).to.equal('te************om'); + expect(selectQuery.data.pg.params[1]).to.have.lengthOf(16); // Verify second query with config object containing values const insertQuery = getSpansByName(spans, 'postgres').find( span => span.data.pg.stmt === 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *' ); expect(insertQuery).to.exist; - expect(insertQuery.data.pg.bindValues).to.exist; - expect(insertQuery.data.pg.bindValues).to.deep.equal(['bindtest', 'bindtest@example.com']); + expect(insertQuery.data.pg.params).to.exist; + expect(insertQuery.data.pg.params).to.be.an('array'); + expect(insertQuery.data.pg.params).to.have.lengthOf(2); + // Verify values are masked with exact length preserved + // 'bindtest' (8 chars) -> 'bi****st' + expect(insertQuery.data.pg.params[0]).to.equal('bi****st'); + expect(insertQuery.data.pg.params[0]).to.have.lengthOf(8); + // 'bindtest@example.com' (20 chars) -> 'bi****************om' + expect(insertQuery.data.pg.params[1]).to.equal('bi****************om'); + expect(insertQuery.data.pg.params[1]).to.have.lengthOf(20); + }) + ) + )); + + it('must collect bind variables when calling stored procedures', () => + controls + .sendRequest({ + method: 'GET', + path: '/stored-procedure-test' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + verifyHttpEntry(spans, '/stored-procedure-test'); + + // Verify INSERT query with bind variables + const insertQuery = getSpansByName(spans, 'postgres').find( + span => span.data.pg.stmt && span.data.pg.stmt.includes('INSERT INTO users(name, email) VALUES($1, $2)') + ); + expect(insertQuery).to.exist; + expect(insertQuery.data.pg.params).to.exist; + expect(insertQuery.data.pg.params).to.be.an('array'); + expect(insertQuery.data.pg.params).to.have.lengthOf(2); + // Verify values are masked with exact length preserved + // 'proceduretest' (13 chars) -> 'pr*********st' + expect(insertQuery.data.pg.params[0]).to.equal('pr*********st'); + expect(insertQuery.data.pg.params[0]).to.have.lengthOf(13); + // 'procedure@example.com' (21 chars) -> 'pr*****************om' + expect(insertQuery.data.pg.params[1]).to.equal('pr*****************om'); + expect(insertQuery.data.pg.params[1]).to.have.lengthOf(21); + + // Verify stored procedure call with bind variable + const procedureCall = getSpansByName(spans, 'postgres').find( + span => span.data.pg.stmt === 'SELECT * FROM get_user_by_name($1)' + ); + expect(procedureCall).to.exist; + expect(procedureCall.data.pg.params).to.exist; + expect(procedureCall.data.pg.params).to.be.an('array'); + expect(procedureCall.data.pg.params).to.have.lengthOf(1); + // Verify value is masked with exact length preserved + // 'proceduretest' (13 chars) -> 'pr*********st' + expect(procedureCall.data.pg.params[0]).to.equal('pr*********st'); + expect(procedureCall.data.pg.params[0]).to.have.lengthOf(13); + }) + ) + )); + + it('must collect and mask all data types correctly', () => + controls + .sendRequest({ + method: 'GET', + path: '/all-data-types-test' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + verifyHttpEntry(spans, '/all-data-types-test'); + const pgSpans = getSpansByName(spans, 'postgres'); + + // 1. String value test + const stringQuery = pgSpans.find(span => span.data.pg.stmt.includes('string_value')); + expect(stringQuery).to.exist; + expect(stringQuery.data.pg.params).to.exist; + // 'sensitive_password_123' (23 chars) -> 'se*******************23' + expect(stringQuery.data.pg.params[0]).to.equal('se******************23'); + expect(stringQuery.data.pg.params[0]).to.have.lengthOf(22); + + // 2. Number values test + const numberQuery = pgSpans.find(span => span.data.pg.stmt.includes('int_value')); + expect(numberQuery).to.exist; + expect(numberQuery.data.pg.params).to.have.lengthOf(2); + // 42 -> '**' + expect(numberQuery.data.pg.params[0]).to.equal('**'); + // 3.14159 -> '3.***59' + expect(numberQuery.data.pg.params[1]).to.equal('3.***59'); + + // 3. Boolean value test + const boolQuery = pgSpans.find(span => span.data.pg.stmt.includes('bool_value')); + expect(boolQuery).to.exist; + // true -> 't**e' + expect(boolQuery.data.pg.params[0]).to.equal('t**e'); + + // 4. Null value test + const nullQuery = pgSpans.find(span => span.data.pg.stmt.includes('null_value')); + expect(nullQuery).to.exist; + expect(nullQuery.data.pg.params[0]).to.equal(''); + + // 5. Date value test + const dateQuery = pgSpans.find(span => span.data.pg.stmt.includes('date_value')); + expect(dateQuery).to.exist; + // Date ISO string is masked + expect(dateQuery.data.pg.params[0]).to.match(/^20\*+0Z$/); + expect(dateQuery.data.pg.params[0]).to.have.lengthOf(24); + + // 6. JSON object test + const jsonQuery = pgSpans.find(span => span.data.pg.stmt.includes('json_value')); + expect(jsonQuery).to.exist; + // JSON is now masked with structure preserved + const parsedJson = JSON.parse(jsonQuery.data.pg.params[0]); + expect(parsedJson).to.have.property('u**r', 'j**n'); + expect(parsedJson).to.have.property('em**l', 'jo**************om'); + expect(parsedJson).to.have.property('pr********s'); + expect(parsedJson['pr********s']).to.have.property('th**e', 'd**k'); + expect(parsedJson['pr********s']).to.have.property('no*********ns', 't**e'); + + // 7. Array test + const arrayQuery = pgSpans.find(span => span.data.pg.stmt.includes('array_value')); + expect(arrayQuery).to.exist; + // Array is now masked with structure preserved + const parsedArray = JSON.parse(arrayQuery.data.pg.params[0]); + expect(parsedArray).to.be.an('array'); + expect(parsedArray).to.have.lengthOf(5); + expect(parsedArray[0]).to.equal('1'); + expect(parsedArray[1]).to.equal('2'); + expect(parsedArray[2]).to.equal('3'); + expect(parsedArray[3]).to.equal('4'); + expect(parsedArray[4]).to.equal('5'); + + // 8. Nested JSON test + const nestedQuery = pgSpans.find(span => span.data.pg.stmt.includes('nested_value')); + expect(nestedQuery).to.exist; + // Complex nested JSON is now masked with structure preserved + const parsedNested = JSON.parse(nestedQuery.data.pg.params[0]); + expect(parsedNested).to.have.property('us**s'); + expect(parsedNested['us**s']).to.be.an('array'); + expect(parsedNested['us**s']).to.have.lengthOf(2); + expect(parsedNested['us**s'][0]).to.have.property('*d', '1'); + expect(parsedNested['us**s'][0]).to.have.property('n**e', 'Al**e'); + expect(parsedNested['us**s'][0]).to.have.property('em**l', 'al**************om'); + expect(parsedNested).to.have.property('me*****a'); + expect(parsedNested['me*****a']).to.have.property('cr****d', '20********01'); + expect(parsedNested['me*****a']).to.have.property('ve****n', '1'); + + // 9. Buffer/Binary data test + const binaryQuery = pgSpans.find(span => span.data.pg.stmt.includes('binary_value')); + expect(binaryQuery).to.exist; + // Buffer shown as size + expect(binaryQuery.data.pg.params[0]).to.equal(''); + + // 10. Large buffer test + const largeBinaryQuery = pgSpans.find(span => span.data.pg.stmt.includes('large_binary')); + expect(largeBinaryQuery).to.exist; + expect(largeBinaryQuery.data.pg.params[0]).to.equal(''); + + // 11. Mixed types in single query + const mixedQuery = pgSpans.find(span => + span.data.pg.stmt.includes('SELECT $1::text, $2::integer, $3::boolean, $4::jsonb, $5::bytea') + ); + expect(mixedQuery).to.exist; + expect(mixedQuery.data.pg.params).to.have.lengthOf(5); + // String: 'user@example.com' -> 'us************om' + expect(mixedQuery.data.pg.params[0]).to.equal('us************om'); + // Number: 12345 -> '1***5' + expect(mixedQuery.data.pg.params[1]).to.equal('1***5'); + // Boolean: false -> 'f***e' + expect(mixedQuery.data.pg.params[2]).to.equal('f***e'); + // JSON: '{"key":"value"}' is now masked with structure preserved + const parsedMixed = JSON.parse(mixedQuery.data.pg.params[3]); + expect(parsedMixed).to.have.property('k*y', 'va**e'); + // Buffer: '' + expect(mixedQuery.data.pg.params[4]).to.equal(''); }) ) )); diff --git a/packages/core/src/tracing/instrumentation/databases/pg.js b/packages/core/src/tracing/instrumentation/databases/pg.js index 7dae1adc22..e5b1bbb28a 100644 --- a/packages/core/src/tracing/instrumentation/databases/pg.js +++ b/packages/core/src/tracing/instrumentation/databases/pg.js @@ -60,15 +60,15 @@ function instrumentedQuery(ctx, originalQuery, argsForOriginalQuery) { span.stack = tracingUtil.getStackTrace(instrumentedQuery); // Extract bind variables/parameters - let bindValues; + let params; if (typeof config === 'string') { // Query is a string, parameters might be in argsForOriginalQuery[1] if (argsForOriginalQuery.length > 1 && Array.isArray(argsForOriginalQuery[1])) { - bindValues = argsForOriginalQuery[1]; + params = argsForOriginalQuery[1]; } } else if (config && config.values) { // Query config object with values property - bindValues = config.values; + params = config.values; } span.data.pg = { @@ -79,8 +79,9 @@ function instrumentedQuery(ctx, originalQuery, argsForOriginalQuery) { db }; - if (bindValues && bindValues.length > 0) { - span.data.pg.bindValues = bindValues; + // Add masked bind parameters to span data if present + if (params && params.length > 0) { + span.data.pg.params = tracingUtil.maskBindVariables(params); } let originalCallback; diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index e8d3efa461..a301b65517 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -429,3 +429,149 @@ exports.handleUnexpectedReturnValue = function handleUnexpectedReturnValue(retur return true; }; + +/** + * Masks a single bind variable value to protect sensitive data. + * Strategy: + * - Preserves exact length of original value for strings + * - For length <= 3: mask completely with asterisks + * - For length 4-5: show first and last character + * - For length > 5: show first 2 and last 2 characters + * - Special handling for objects, arrays, buffers, and other types + * - For JSON objects/arrays: masks individual values while preserving structure + * + * @param {*} value - The bind variable value to mask + * @returns {string} - The masked value + */ +function maskBindValue(value) { + // Handle null and undefined + if (value === null) { + return ''; + } + if (value === undefined) { + return ''; + } + + // Handle Buffer (binary data like images, files) + if (Buffer.isBuffer(value)) { + return ``; + } + + // Handle Date objects + if (value instanceof Date) { + const dateStr = value.toISOString(); + return maskString(dateStr); + } + + // Handle Arrays + if (Array.isArray(value)) { + try { + return maskJsonStructure(value); + } catch (e) { + return ''; + } + } + + // Handle Objects (including JSON) + if (typeof value === 'object') { + try { + return maskJsonStructure(value); + } catch (e) { + // Handle circular references or non-serializable objects + return ''; + } + } + + // Handle primitive types (string, number, boolean, bigint, symbol) + return maskString(String(value)); +} + +/** + * Masks JSON objects and arrays by masking individual values while preserving structure. + * Example: {"theme":"dark","notifications":true} -> {"t***e":"d**k","no*********ns":"t**e"} + * @param {*} obj - The object or array to mask + * @returns {string} - JSON string with masked values + */ +function maskJsonStructure(obj) { + // Check for circular references + const seen = new WeakSet(); + + /** + * @param {*} value + * @returns {*} + */ + function maskRecursive(value) { + // Handle primitives + if (value === null) return null; + if (value === undefined) return null; + if (typeof value === 'string') return maskString(value); + if (typeof value === 'number') return maskString(String(value)); + if (typeof value === 'boolean') return maskString(String(value)); + + // Handle objects and arrays + if (typeof value === 'object') { + // Check for circular reference + if (seen.has(value)) { + throw new Error('Circular reference detected'); + } + seen.add(value); + + if (Array.isArray(value)) { + return value.map(item => maskRecursive(item)); + } + + /** @type {Record} */ + const masked = {}; + Object.keys(value).forEach(key => { + // Mask both keys and values + const maskedKey = maskString(key); + masked[maskedKey] = maskRecursive(value[key]); + }); + return masked; + } + + return value; + } + + const masked = maskRecursive(obj); + return JSON.stringify(masked); +} + +/** + * Masks a string value preserving its exact length. + * @param {string} strValue - The string to mask + * @returns {string} - The masked string + */ +function maskString(strValue) { + const len = strValue.length; + + // For very short values (0-3 chars), mask completely + if (len <= 3) { + return '*'.repeat(len); + } + + // For length 4-5: show first and last character, mask the middle + if (len <= 5) { + const numAsterisks = len - 2; + return strValue[0] + '*'.repeat(numAsterisks) + strValue[len - 1]; + } + + // For length > 5: show first 2 and last 2 characters, mask the middle + const numAsterisks = len - 4; + return strValue.substring(0, 2) + '*'.repeat(numAsterisks) + strValue.substring(len - 2); +} + +/** + * Masks an array of bind variable values to protect sensitive data. + * Each value is masked individually with appropriate handling for different types. + * + * @param {Array<*>} params - Array of bind variable values + * @returns {Array} - Array of masked values + */ +exports.maskBindVariables = function maskBindVariables(params) { + if (!Array.isArray(params)) { + return params; + } + + return params.map(maskBindValue); +}; diff --git a/packages/core/test/tracing/tracingUtil_maskBindVariables_test.js b/packages/core/test/tracing/tracingUtil_maskBindVariables_test.js new file mode 100644 index 0000000000..b85ff6dd90 --- /dev/null +++ b/packages/core/test/tracing/tracingUtil_maskBindVariables_test.js @@ -0,0 +1,285 @@ +/* + * (c) Copyright IBM Corp. 2024 + */ + +'use strict'; + +const expect = require('chai').expect; +const tracingUtil = require('../../src/tracing/tracingUtil'); + +describe('tracing/tracingUtil', () => { + describe('maskBindVariables', () => { + it('should preserve exact length and show first 2 and last 2 chars for long values', () => { + const params = ['testuser', 'password123', 'email@example.com']; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(3); + // 'testuser' (8 chars) -> 'te****er' + expect(masked[0]).to.equal('te****er'); + expect(masked[0]).to.have.lengthOf(8); + // 'password123' (11 chars) -> 'pa*******23' + expect(masked[1]).to.equal('pa*******23'); + expect(masked[1]).to.have.lengthOf(11); + // 'email@example.com' (17 chars) -> 'em***********om' + expect(masked[2]).to.equal('em***********om'); + expect(masked[2]).to.have.lengthOf(17); + }); + + it('should show first and last char for length 4-5', () => { + const params = ['test', 'hello']; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(2); + // 'test' (4 chars) -> 't**t' + expect(masked[0]).to.equal('t**t'); + expect(masked[0]).to.have.lengthOf(4); + // 'hello' (5 chars) -> 'h***o' + expect(masked[1]).to.equal('h***o'); + expect(masked[1]).to.have.lengthOf(5); + }); + + it('should mask short strings completely preserving length', () => { + const params = ['ab', 'xyz', 'a']; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(3); + expect(masked[0]).to.equal('**'); + expect(masked[0]).to.have.lengthOf(2); + expect(masked[1]).to.equal('***'); + expect(masked[1]).to.have.lengthOf(3); + expect(masked[2]).to.equal('*'); + expect(masked[2]).to.have.lengthOf(1); + }); + + it('should handle null and undefined values', () => { + const params = [null, undefined, 'value']; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(3); + expect(masked[0]).to.equal(''); + expect(masked[1]).to.equal(''); + // 'value' (5 chars) -> 'v***e' + expect(masked[2]).to.equal('v***e'); + expect(masked[2]).to.have.lengthOf(5); + }); + + it('should convert numbers to strings and mask them', () => { + const params = [12345, 42, 999999999, 3.14159, -100]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(5); + // '12345' (5 chars) -> '1***5' + expect(masked[0]).to.equal('1***5'); + expect(masked[0]).to.have.lengthOf(5); + // '42' (2 chars) -> '**' + expect(masked[1]).to.equal('**'); + expect(masked[1]).to.have.lengthOf(2); + // '999999999' (9 chars) -> '99*****99' + expect(masked[2]).to.equal('99*****99'); + expect(masked[2]).to.have.lengthOf(9); + // '3.14159' (7 chars) -> '3.***59' + expect(masked[3]).to.equal('3.***59'); + expect(masked[3]).to.have.lengthOf(7); + // '-100' (4 chars) -> '-**0' + expect(masked[4]).to.equal('-**0'); + expect(masked[4]).to.have.lengthOf(4); + }); + + it('should handle boolean values', () => { + const params = [true, false]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(2); + // 'true' (4 chars) -> 't**e' + expect(masked[0]).to.equal('t**e'); + expect(masked[0]).to.have.lengthOf(4); + // 'false' (5 chars) -> 'f***e' + expect(masked[1]).to.equal('f***e'); + expect(masked[1]).to.have.lengthOf(5); + }); + + it('should handle Date objects', () => { + const date = new Date('2024-01-15T10:30:00.000Z'); + const params = [date]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(1); + // ISO string is masked: '2024-01-15T10:30:00.000Z' (24 chars) -> '20******************00Z' + expect(masked[0]).to.match(/^20\*+0Z$/); + expect(masked[0]).to.have.lengthOf(24); + }); + + it('should handle JSON objects', () => { + const params = [ + { name: 'John', age: 30 }, + { email: 'test@example.com', active: true } + ]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(2); + + // Parse to verify it's valid JSON and check structure + const parsed1 = JSON.parse(masked[0]); + expect(parsed1).to.have.property('n**e', 'J**n'); + expect(parsed1).to.have.property('a*e', '30'); + + const parsed2 = JSON.parse(masked[1]); + expect(parsed2).to.have.property('em**l', 'te**************om'); + expect(parsed2).to.have.property('ac***e', 't**e'); + }); + + it('should handle arrays', () => { + const params = [ + [1, 2, 3], + ['a', 'b', 'c'], + [{ id: 1 }, { id: 2 }] + ]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(3); + + // Parse to verify it's valid JSON and check structure + const parsed1 = JSON.parse(masked[0]); + expect(parsed1).to.be.an('array'); + expect(parsed1).to.have.lengthOf(3); + expect(parsed1[0]).to.equal('1'); + expect(parsed1[1]).to.equal('2'); + expect(parsed1[2]).to.equal('3'); + + const parsed2 = JSON.parse(masked[1]); + expect(parsed2).to.be.an('array'); + expect(parsed2).to.have.lengthOf(3); + expect(parsed2[0]).to.equal('*'); + expect(parsed2[1]).to.equal('*'); + expect(parsed2[2]).to.equal('*'); + + const parsed3 = JSON.parse(masked[2]); + expect(parsed3).to.be.an('array'); + expect(parsed3).to.have.lengthOf(2); + expect(parsed3[0]).to.have.property('*d', '1'); + expect(parsed3[1]).to.have.property('*d', '2'); + }); + + it('should handle Buffer (binary data)', () => { + const imageBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); // JPEG header + const params = [imageBuffer]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(1); + expect(masked[0]).to.equal(''); + }); + + it('should handle large Buffer', () => { + const largeBuffer = Buffer.alloc(1024 * 1024); // 1MB + const params = [largeBuffer]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(1); + expect(masked[0]).to.equal(''); + }); + + it('should handle circular references in objects', () => { + const obj = { name: 'test' }; + obj.self = obj; // Create circular reference + const params = [obj]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(1); + expect(masked[0]).to.equal(''); + }); + + it('should handle circular references in arrays', () => { + const arr = [1, 2, 3]; + arr.push(arr); // Create circular reference + const params = [arr]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(1); + expect(masked[0]).to.equal(''); + }); + + it('should handle BigInt values', () => { + // eslint-disable-next-line no-undef + const params = [BigInt(9007199254740991), BigInt(123)]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(2); + // '9007199254740991' (16 chars) -> '90************91' + expect(masked[0]).to.equal('90************91'); + expect(masked[0]).to.have.lengthOf(16); + // '123' (3 chars) -> '***' + expect(masked[1]).to.equal('***'); + expect(masked[1]).to.have.lengthOf(3); + }); + + it('should handle empty array', () => { + const params = []; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(0); + }); + + it('should return non-array input as-is', () => { + const notAnArray = 'not an array'; + const result = tracingUtil.maskBindVariables(notAnArray); + + expect(result).to.equal(notAnArray); + }); + + it('should preserve exact length for very long values', () => { + const longValue = 'a'.repeat(100); + const params = [longValue]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(1); + // First 2 chars + 96 asterisks + last 2 chars = 100 chars + expect(masked[0]).to.have.lengthOf(100); + expect(masked[0]).to.match(/^aa\*{96}aa$/); + }); + + it('should handle mixed data types', () => { + const params = [ + 'string', + 123, + true, + null, + undefined, + { key: 'value' }, + [1, 2, 3], + new Date('2024-01-01'), + Buffer.from('test') + ]; + const masked = tracingUtil.maskBindVariables(params); + + expect(masked).to.be.an('array'); + expect(masked).to.have.lengthOf(9); + expect(masked[0]).to.equal('st**ng'); // string + expect(masked[1]).to.equal('***'); // 123 + expect(masked[2]).to.equal('t**e'); // true + expect(masked[3]).to.equal(''); + expect(masked[4]).to.equal(''); + expect(masked[5]).to.match(/^\{"\*+e\}$/); // JSON object + expect(masked[6]).to.equal('[1***3]'); // array + expect(masked[7]).to.match(/^20\*+0Z$/); // Date + expect(masked[8]).to.equal(''); // Buffer + }); + }); +}); + +// Made with Bob