← Recipes

阅读中文版本

Test Data Masking Before It Ships

Assert that emails, card numbers, and tokens are masked in logs, exports, and fixtures, and that raw values never reach shared storage.

~30 mintestingprivacysecurity

Task

Verify masking functions and every output path that handles personal data, with tests that fail on the raw value.

When to use this

A support export includes customer emails and card digits. The masking helper exists, but one report path bypasses it and the file lands in a shared bucket.

Prerequisites

  • Node.js 22 with a masking utility in the codebase
  • fast-check for the property assertions
  • Sample fixtures that contain clearly fake personal data

Steps

1. Define small masking functions with an explicit, documented output format.

export const maskEmail = (email) =>
  email.replace(/^([^@]).*?(@.*)$/, '$1***$2');

export const maskCard = (pan) => `**** **** **** ${pan.slice(-4)}`;

Expected output: [email protected] becomes a***@example.com and 4242424242424242 becomes **** **** **** 4242.

2. Property-test the invariants: the domain survives, the full value never survives.

fc.assert(fc.property(fc.emailAddress(), (email) => {
  const masked = maskEmail(email);
  const domain = email.slice(email.indexOf('@'));
  return masked.endsWith(domain)
    && masked !== email
    && !masked.includes(email);
}));

Expected output: Hundreds of generated addresses keep their domain and never contain the original address.

3. Test each real output path, not just the helper, using a known sensitive value.

const row = buildExportRow({ id: 'c1', email: '[email protected]', pan: '4242424242424242' });
assert.deepEqual(row, {
  id: 'c1',
  email: 'a***@example.com',
  pan: '**** **** **** 4242',
});
assert.ok(!JSON.stringify(row).includes('[email protected]'));

Expected output: The export row contains only masked values, and the raw email is absent from the serialized output.

4. Make the export allowlist-based so new sensitive fields cannot leak by default.

const ALLOWED = new Set(['id', 'email', 'pan']);
export function toExportRow(customer) {
  const row = {};
  for (const key of ALLOWED) {
    if (key in customer) row[key] = maskField(key, customer[key]);
  }
  return row;
}

Expected output: A newly added customer field does not appear in exports until it is explicitly allowed and masked.

5. Scan fixtures and golden files in CI for card-like sequences.

rg -n --pcre2 '\b\d{13,16}\b' test/fixtures test/golden

Expected output: Only the documented test PAN 4242424242424242 appears; any additional match fails the review job.

Troubleshooting

Problem

Support cannot find a customer from the masked export

Solution

Add a keyed hash column for correlation instead of weakening the mask, and keep the key outside the export.

Problem

Tests pass but production still leaks

Solution

Masking is applied at the serializer while another path writes raw rows. Test the actual export and log paths end to end.

Problem

The email regex fails on unusual but valid addresses

Solution

Prefer a keep-the-domain rule over a perfect email regex, and cover edge cases with property-based inputs.

Limitations

  • Masking reduces exposure but is not anonymization; aggregate data can still re-identify people.
  • Format-preserving masks are reversible if the scheme or key is weak.
  • Tests only cover paths that are exercised; a new unlisted export can still bypass the helper.

A masking helper is not a masking guarantee

Most teams have a maskEmail function. The leak almost never comes from that function being wrong; it comes from a new export path that never calls it, a debug log that prints the whole row, or a fixture committed with real customer data. Testing the helper alone gives false confidence. Test the outputs: every file, log line, and API response that leaves the trusted boundary.

Assert absence, not just presence

A test that checks row.email === 'a***@example.com' passes even if the row also contains rawEmail: '[email protected]'. Assert both the masked value and the absence of the raw value in the serialized output. Absence assertions catch fields nobody remembered to classify, which is exactly how leaks happen.

assert.ok(!JSON.stringify(row).includes('[email protected]'));

Prefer allowlists

A denylist of fields to mask fails open: every new field is exported until someone remembers to add it. An allowlist fails closed: only known-safe fields are exported, and adding a sensitive field requires an explicit, reviewable change. The allowlist also documents the export contract, which is useful for support and compliance reviews.

Watch the boundaries

Data leaves through more paths than the API: CSV exports, analytics events, error reports, database dumps, and test fixtures. At minimum, cover the export endpoint, the request logger, and the error serializer. For fixtures, use obviously fake values and scan the repository for card-like digit sequences; a scanner will not catch a cleverly formatted email, but it catches the most damaging pattern.

Do not weaken the mask for convenience

When support asks for the full email to find a customer, the tempting fix is to unmask the export. The safer pattern is a keyed hash: support pastes the email, the tool computes the hash, and the export row is found without exposing the value. Unmasking should require a separate, audited workflow, not a flag in the export job.