From 325b55bae5a4aac9c797df509c1f4ee3fda6ce4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 Aug 2025 11:34:55 +0000 Subject: [PATCH 1/3] Initial plan From 1f3345e67f36ee30a6e7cb39956c8596233e913a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 Aug 2025 11:49:57 +0000 Subject: [PATCH 2/3] Complete sensitive data masking feature with custom patterns and documentation --- docs/secrets.md | 136 ++++++++++++++++-- lib/output.js | 18 ++- lib/utils/mask_data.js | 53 +++++++ .../sandbox/codecept.bdd.boolean-masking.js | 20 +++ test/data/sandbox/codecept.bdd.masking.js | 39 +++++ test/data/sandbox/features/masking.feature | 8 ++ .../features/step_definitions/my_steps.js | 23 +++ test/data/sandbox/support/bdd_helper.js | 40 ++++-- test/runner/custom_masking_test.js | 40 ++++++ test/unit/mask_data_test.js | 107 ++++++++++++++ 10 files changed, 449 insertions(+), 35 deletions(-) create mode 100644 lib/utils/mask_data.js create mode 100644 test/data/sandbox/codecept.bdd.boolean-masking.js create mode 100644 test/data/sandbox/codecept.bdd.masking.js create mode 100644 test/data/sandbox/features/masking.feature create mode 100644 test/runner/custom_masking_test.js create mode 100644 test/unit/mask_data_test.js diff --git a/docs/secrets.md b/docs/secrets.md index 4494575ba..95fc2fe9a 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -1,13 +1,15 @@ # Secrets -It is possible to **mask out sensitive data** when passing it to steps. This is important when filling password fields, or sending secure keys to API endpoint. +It is possible to **mask out sensitive data** when passing it to steps. This is important when filling password fields, or sending secure keys to API endpoint. CodeceptJS provides two approaches for masking sensitive data: + +## 1. Using the `secret()` Function Wrap data in `secret` function to mask sensitive values in output and logs. For basic string `secret` just wrap a value into a string: ```js -I.fillField('password', secret('123456')); +I.fillField('password', secret('123456')) ``` When executed it will be printed like this: @@ -15,22 +17,134 @@ When executed it will be printed like this: ``` I fill field "password" "*****" ``` + **Other Examples** + ```js -I.fillField('password', secret('123456')); -I.append('password', secret('123456')); -I.type('password', secret('123456')); +I.fillField('password', secret('123456')) +I.append('password', secret('123456')) +I.type('password', secret('123456')) ``` For an object, which can be a payload to POST request, specify which fields should be masked: ```js -I.sendPostRequest('/login', secret({ - name: 'davert', - password: '123456' -}, 'password')) +I.sendPostRequest( + '/login', + secret( + { + name: 'davert', + password: '123456', + }, + 'password', + ), +) +``` + +The object created from `secret` is as Proxy to the object passed in. When printed password will be replaced with \*\*\*\*. + +> ⚠️ Only direct properties of the object can be masked via `secret` + +## 2. Global Sensitive Data Masking + +CodeceptJS can automatically mask sensitive data in all output (logs, steps, debug messages, errors) using configurable patterns. This feature uses the `maskSensitiveData` configuration option. + +### Basic Usage (Boolean) + +Enable basic masking with predefined patterns: + +```js +// codecept.conf.js +exports.config = { + // ... other config + maskSensitiveData: true, +} +``` + +This will mask common sensitive data patterns like: + +- Authorization headers +- API keys +- Passwords +- Tokens +- Client secrets + +### Advanced Usage (Custom Patterns) + +Define your own masking patterns: + +```js +// codecept.conf.js +exports.config = { + // ... other config + maskSensitiveData: { + enabled: true, + patterns: [ + { + name: 'Email', + regex: /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/gi, + mask: '[MASKED_EMAIL]', + }, + { + name: 'Credit Card', + regex: /\b(?:\d{4}[- ]?){3}\d{4}\b/g, + mask: '[MASKED_CARD]', + }, + { + name: 'Phone Number', + regex: /(\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/g, + mask: '[MASKED_PHONE]', + }, + { + name: 'SSN', + regex: /\b\d{3}-\d{2}-\d{4}\b/g, + mask: '[MASKED_SSN]', + }, + ], + }, +} +``` + +### Pattern Configuration + +Each custom pattern object should have: + +- `name`: A descriptive name for the pattern +- `regex`: A JavaScript regular expression to match the sensitive data +- `mask`: The replacement string to show instead of the sensitive data + +### Examples + +With the above configuration: + +**Input:** + ``` +User email: john.doe@company.com +Credit card: 4111 1111 1111 1111 +Phone: +1-555-123-4567 +``` + +**Output:** + +``` +User email: [MASKED_EMAIL] +Credit card: [MASKED_CARD] +Phone: [MASKED_PHONE] +``` + +### Where Masking Applies + +Global sensitive data masking is applied to: + +- Step descriptions and output +- Debug messages (`--debug` mode) +- Log messages (`--verbose` mode) +- Error messages +- Success messages + +> ⚠️ Direct `console.log()` calls in helper functions are not masked. Use CodeceptJS output functions instead. -The object created from `secret` is as Proxy to the object passed in. When printed password will be replaced with ****. +### Combining Both Approaches -> ⚠️ Only direct properties of the object can be masked via `secret` \ No newline at end of file +You can use both `secret()` function and global masking together. The `secret()` function is applied first, then global patterns are applied to the remaining output. diff --git a/lib/output.js b/lib/output.js index a551174ae..b9e0e0d95 100644 --- a/lib/output.js +++ b/lib/output.js @@ -1,6 +1,6 @@ const colors = require('chalk') const figures = require('figures') -const { maskSensitiveData } = require('invisi-data') +const { maskData, shouldMaskData, getMaskConfig } = require('./utils/mask_data') const styles = { error: colors.bgRed.white.bold, @@ -59,7 +59,7 @@ module.exports = { * @param {string} msg */ debug(msg) { - const _msg = isMaskedData() ? maskSensitiveData(msg) : msg + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg if (outputLevel >= 2) { print(' '.repeat(this.stepShift), styles.debug(`${figures.pointerSmall} ${_msg}`)) } @@ -70,7 +70,7 @@ module.exports = { * @param {string} msg */ log(msg) { - const _msg = isMaskedData() ? maskSensitiveData(msg) : msg + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg if (outputLevel >= 3) { print(' '.repeat(this.stepShift), styles.log(truncate(` ${_msg}`, this.spaceShift))) } @@ -81,7 +81,8 @@ module.exports = { * @param {string} msg */ error(msg) { - print(styles.error(msg)) + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg + print(styles.error(_msg)) }, /** @@ -89,7 +90,8 @@ module.exports = { * @param {string} msg */ success(msg) { - print(styles.success(msg)) + const _msg = shouldMaskData() ? maskData(msg, getMaskConfig()) : msg + print(styles.success(_msg)) }, /** @@ -124,7 +126,7 @@ module.exports = { stepLine += colors.grey(step.comment.split('\n').join('\n' + ' '.repeat(4))) } - const _stepLine = isMaskedData() ? maskSensitiveData(stepLine) : stepLine + const _stepLine = shouldMaskData() ? maskData(stepLine, getMaskConfig()) : stepLine print(' '.repeat(this.stepShift), truncate(_stepLine, this.spaceShift)) }, @@ -278,7 +280,3 @@ function truncate(msg, gap = 0) { } return msg } - -function isMaskedData() { - return global.maskSensitiveData === true || false -} diff --git a/lib/utils/mask_data.js b/lib/utils/mask_data.js new file mode 100644 index 000000000..1a1dc4b4c --- /dev/null +++ b/lib/utils/mask_data.js @@ -0,0 +1,53 @@ +const { maskSensitiveData } = require('invisi-data') + +/** + * Mask sensitive data utility for CodeceptJS + * Supports both boolean and object configuration formats + * + * @param {string} input - The string to mask + * @param {boolean|object} config - Masking configuration + * @returns {string} - Masked string + */ +function maskData(input, config) { + if (!config) { + return input + } + + // Handle boolean config (backward compatibility) + if (typeof config === 'boolean' && config === true) { + return maskSensitiveData(input) + } + + // Handle object config with custom patterns + if (typeof config === 'object' && config.enabled === true) { + const customPatterns = config.patterns || [] + return maskSensitiveData(input, customPatterns) + } + + return input +} + +/** + * Check if masking is enabled based on global configuration + * + * @returns {boolean|object} - Current masking configuration + */ +function getMaskConfig() { + return global.maskSensitiveData || false +} + +/** + * Check if data should be masked + * + * @returns {boolean} - True if masking is enabled + */ +function shouldMaskData() { + const config = getMaskConfig() + return config === true || (typeof config === 'object' && config.enabled === true) +} + +module.exports = { + maskData, + getMaskConfig, + shouldMaskData, +} diff --git a/test/data/sandbox/codecept.bdd.boolean-masking.js b/test/data/sandbox/codecept.bdd.boolean-masking.js new file mode 100644 index 000000000..8b4d13d86 --- /dev/null +++ b/test/data/sandbox/codecept.bdd.boolean-masking.js @@ -0,0 +1,20 @@ +exports.config = { + tests: './*_no_test.js', + timeout: 10000, + output: './output', + helpers: { + BDD: { + require: './support/bdd_helper.js', + }, + }, + // Traditional boolean masking configuration + maskSensitiveData: true, + gherkin: { + features: './features/secret.feature', + steps: ['./features/step_definitions/my_steps.js', './features/step_definitions/my_other_steps.js'], + }, + include: {}, + bootstrap: false, + mocha: {}, + name: 'sandbox-boolean-masking', +} diff --git a/test/data/sandbox/codecept.bdd.masking.js b/test/data/sandbox/codecept.bdd.masking.js new file mode 100644 index 000000000..d66276ae0 --- /dev/null +++ b/test/data/sandbox/codecept.bdd.masking.js @@ -0,0 +1,39 @@ +exports.config = { + tests: './*_no_test.js', + timeout: 10000, + output: './output', + helpers: { + BDD: { + require: './support/bdd_helper.js', + }, + }, + // New masking configuration with custom patterns + maskSensitiveData: { + enabled: true, + patterns: [ + { + name: 'Email', + regex: /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/gi, + mask: '[MASKED_EMAIL]', + }, + { + name: 'Credit Card', + regex: /\b(?:\d{4}[- ]?){3}\d{4}\b/g, + mask: '[MASKED_CARD]', + }, + { + name: 'Phone', + regex: /(\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/g, + mask: '[MASKED_PHONE]', + }, + ], + }, + gherkin: { + features: './features/masking.feature', + steps: ['./features/step_definitions/my_steps.js', './features/step_definitions/my_other_steps.js'], + }, + include: {}, + bootstrap: false, + mocha: {}, + name: 'sandbox-masking', +} diff --git a/test/data/sandbox/features/masking.feature b/test/data/sandbox/features/masking.feature new file mode 100644 index 000000000..17dc0422e --- /dev/null +++ b/test/data/sandbox/features/masking.feature @@ -0,0 +1,8 @@ +Feature: Custom Data Masking + + Scenario: mask custom sensitive data in output + Given I have user email "john.doe@example.com" + And I have credit card "4111 1111 1111 1111" + And I have phone number "+1-555-123-4567" + When I process user data + Then I should see masked output \ No newline at end of file diff --git a/test/data/sandbox/features/step_definitions/my_steps.js b/test/data/sandbox/features/step_definitions/my_steps.js index 5c9cc2973..da49cb6d1 100644 --- a/test/data/sandbox/features/step_definitions/my_steps.js +++ b/test/data/sandbox/features/step_definitions/my_steps.js @@ -35,6 +35,29 @@ Given('I login', () => { I.login('user', secret('password')) }) +Given('I have user email {string}', email => { + I.debug(`User email is: ${email}`) + I.say(`Processing email: ${email}`) +}) + +Given('I have credit card {string}', card => { + I.debug(`Credit card is: ${card}`) + I.say(`Processing card: ${card}`) +}) + +Given('I have phone number {string}', phone => { + I.debug(`Phone number is: ${phone}`) + I.say(`Processing phone: ${phone}`) +}) + +When('I process user data', () => { + I.debug('Processing user data with sensitive information') +}) + +Then('I should see masked output', () => { + I.debug('All sensitive data should be masked in output') +}) + Given(/^I have this product in my cart$/, table => { let str = '' for (const id in table.rows) { diff --git a/test/data/sandbox/support/bdd_helper.js b/test/data/sandbox/support/bdd_helper.js index b051d30e0..c1846c365 100644 --- a/test/data/sandbox/support/bdd_helper.js +++ b/test/data/sandbox/support/bdd_helper.js @@ -1,45 +1,57 @@ -const assert = require('assert'); -const Helper = require('../../../../lib/helper'); +const assert = require('assert') +const Helper = require('../../../../lib/helper') class CheckoutHelper extends Helper { _before() { - this.num = 0; - this.sum = 0; - this.discountCalc = null; + this.num = 0 + this.sum = 0 + this.discountCalc = null } addItem(price) { - this.num++; - this.sum += price; + this.num++ + this.sum += price } seeNum(num) { - assert.equal(num, this.num); + assert.equal(num, this.num) } seeSum(sum) { - assert.equal(sum, this.sum); + assert.equal(sum, this.sum) } haveDiscountForPrice(price, discount) { this.discountCalc = () => { if (this.sum > price) { - this.sum -= this.sum * discount / 100; + this.sum -= (this.sum * discount) / 100 } - }; + } } addProduct(name, price) { - this.sum += price; + this.sum += price } checkout() { if (this.discountCalc) { - this.discountCalc(); + this.discountCalc() } } login() {} + + say(message) { + // Use CodeceptJS output system instead of direct console.log + const output = require('../../../../lib/output') + output.log(`[Helper] ${message}`) + } + + debug(message) { + // Use CodeceptJS output system instead of direct console.log + const output = require('../../../../lib/output') + output.debug(`[Helper] ${message}`) + } } -module.exports = CheckoutHelper; +module.exports = CheckoutHelper diff --git a/test/runner/custom_masking_test.js b/test/runner/custom_masking_test.js new file mode 100644 index 000000000..526563d4b --- /dev/null +++ b/test/runner/custom_masking_test.js @@ -0,0 +1,40 @@ +const { expect } = require('expect') +const exec = require('child_process').exec +const { assert } = require('chai') + +describe('Custom Masking Integration Tests', () => { + const config_run_config = config => `node ./bin/codecept.js run --config test/data/sandbox/${config}` + + it('should mask custom patterns in debug mode', done => { + exec(config_run_config('codecept.bdd.masking.js') + ' --debug --grep "Custom Data Masking"', (err, stdout, stderr) => { + console.log('STDOUT:', stdout) + console.log('STDERR:', stderr) + + // Check that the step descriptions are masked (these go through CodeceptJS output) + stdout.should.include('I have user email "[MASKED_EMAIL]"') + stdout.should.include('I have credit card "[MASKED_CARD]"') + stdout.should.include('I have phone number "[MASKED_PHONE]"') + + // Check that CodeceptJS debug output is masked + stdout.should.include('I debug "User email is: [MASKED_EMAIL]"') + stdout.should.include('I debug "Credit card is: [MASKED_CARD]"') + stdout.should.include('I debug "Phone number is: [MASKED_PHONE]"') + + assert(!err) + done() + }) + }) + + it('should mask custom patterns in regular run mode', done => { + exec(config_run_config('codecept.bdd.masking.js') + ' --grep "Custom Data Masking"', (err, stdout, stderr) => { + console.log('STDOUT:', stdout) + console.log('STDERR:', stderr) + + // In regular mode, we should still see the step names are present and test passes + stdout.should.include('✔ mask custom sensitive data in output') + + assert(!err) + done() + }) + }) +}) diff --git a/test/unit/mask_data_test.js b/test/unit/mask_data_test.js new file mode 100644 index 000000000..961f10138 --- /dev/null +++ b/test/unit/mask_data_test.js @@ -0,0 +1,107 @@ +const { expect } = require('expect') +const { maskData, getMaskConfig, shouldMaskData } = require('../../lib/utils/mask_data') + +describe('Mask Data Utility Tests', () => { + let originalMaskConfig + + beforeEach(() => { + originalMaskConfig = global.maskSensitiveData + global.maskSensitiveData = false // Reset to default + }) + + afterEach(() => { + global.maskSensitiveData = originalMaskConfig + }) + + describe('maskData', () => { + it('should return original string when config is false', () => { + const input = 'password=secret123' + expect(maskData(input, false)).toBe(input) + }) + + it('should return original string when config is null', () => { + const input = 'password=secret123' + expect(maskData(input, null)).toBe(input) + }) + + it('should mask data when config is true (boolean)', () => { + const input = '{"password": "secret123"}' + const result = maskData(input, true) + expect(result).toContain('****') + expect(result).not.toContain('secret123') + }) + + it('should mask data when config is object with enabled true', () => { + const input = '{"password": "secret123"}' + const config = { enabled: true } + const result = maskData(input, config) + expect(result).toContain('****') + expect(result).not.toContain('secret123') + }) + + it('should use custom patterns when provided', () => { + const input = 'email=user@example.com' + const config = { + enabled: true, + patterns: [ + { + name: 'Email', + regex: /email=([^\s]+)/gi, + mask: 'email=****', + }, + ], + } + const result = maskData(input, config) + expect(result).toBe('email=****') + }) + + it('should return original string when object config has enabled false', () => { + const input = 'password=secret123' + const config = { enabled: false } + expect(maskData(input, config)).toBe(input) + }) + }) + + describe('getMaskConfig', () => { + it('should return false when no global config set', () => { + expect(getMaskConfig()).toBe(false) + }) + + it('should return global config when set to boolean', () => { + global.maskSensitiveData = true + expect(getMaskConfig()).toBe(true) + }) + + it('should return global config when set to object', () => { + const config = { enabled: true, patterns: [] } + global.maskSensitiveData = config + expect(getMaskConfig()).toBe(config) + }) + }) + + describe('shouldMaskData', () => { + it('should return false when no global config set', () => { + expect(shouldMaskData()).toBe(false) + }) + + it('should return true when global config is true', () => { + global.maskSensitiveData = true + expect(shouldMaskData()).toBe(true) + }) + + it('should return true when global config object has enabled true', () => { + global.maskSensitiveData = { enabled: true } + expect(shouldMaskData()).toBe(true) + }) + + it('should return false when global config object has enabled false', () => { + global.maskSensitiveData = { enabled: false } + expect(shouldMaskData()).toBe(false) + }) + + it('should return false when global config object has no enabled property', () => { + global.maskSensitiveData = { patterns: [] } + expect(shouldMaskData()).toBe(false) + }) + }) +}) From 13e5a82bfeb8f1f03b3f27ec523b8ed808474cf9 Mon Sep 17 00:00:00 2001 From: kobenguyent Date: Tue, 26 Aug 2025 14:23:51 +0200 Subject: [PATCH 3/3] fix: runner tests --- test/runner/custom_masking_test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/runner/custom_masking_test.js b/test/runner/custom_masking_test.js index 526563d4b..c899ee460 100644 --- a/test/runner/custom_masking_test.js +++ b/test/runner/custom_masking_test.js @@ -1,10 +1,13 @@ -const { expect } = require('expect') -const exec = require('child_process').exec +const { exec } = require('child_process') const { assert } = require('chai') +const path = require('path') -describe('Custom Masking Integration Tests', () => { - const config_run_config = config => `node ./bin/codecept.js run --config test/data/sandbox/${config}` +const runner = path.join(__dirname, '/../../bin/codecept.js') +const codecept_dir = path.join(__dirname, '/../data/sandbox') +const codecept_run = `${runner} run` +const config_run_config = config => `${codecept_run} --config ${codecept_dir}/${config}` +describe('Custom Masking Integration Tests', () => { it('should mask custom patterns in debug mode', done => { exec(config_run_config('codecept.bdd.masking.js') + ' --debug --grep "Custom Data Masking"', (err, stdout, stderr) => { console.log('STDOUT:', stdout)