Repository: codeceptjs/CodeceptJS (2025-08-24T11:34:55Z)

Issue:
feat: hidden sensitive data in logs, steps, etc.
Codeceptjs may expose sensitive data like passwords after test runs. Providing a config to obfuscate all sensitive data that codeceptjs may print out in cli, logs, steps, etc. user could provide patterns to obfuscate sensitive information like he wants to obfuscate email, name, etc.


Discussion:

Patch:
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,
+}


Test patch:
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..c899ee460
--- /dev/null
+++ b/test/runner/custom_masking_test.js
@@ -0,0 +1,43 @@
+const { exec } = require('child_process')
+const { assert } = require('chai')
+const path = require('path')
+
+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)
+      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)
+    })
+  })
+})