6 min read just now

Protect Sensitive Data Before It Reaches the AI

Some visitors will type things they shouldn’t into a chatbot: a card number, a Social Security number, a bank account. This snippet catches those on your server, before anything is sent to the AI, and either stops the message or hides the sensitive parts.

What it catches

  • Card numbers, 13 to 19 digits, spaces or dashes allowed. Each one must pass the Luhn check, so order numbers and dates are left alone.
  • Social Security numbers written like 123-45-6789 or 123 45 6789 (ranges that are never issued are skipped).
  • Bank routing numbers (US ABA), checked with their prefix and checksum.
  • Account and member numbers written after a keyword, like account 12345678, acct #: 5566-7788 or my SSN is 123456789.
  • Emails and phone numbers, off by default, each with its own switch.

Block or mask

Set MWAI_PII_MODE at the top of the snippet:

  • block: the message is not sent, and the visitor sees a friendly notice (you can change its text with MWAI_PII_BLOCK_MESSAGE).
  • mask: the message is sent, with every sensitive value replaced by [REDACTED]. The AI never sees the real number.

In both modes, earlier messages in the conversation are masked too. They are never blocked: a blocked message stays in the visitor’s chat history, and blocking on it would lock the chat for good.

Why it runs so early

The snippet hooks mwai_context_search at priority 1, and mwai_ai_query as well. If your chatbot uses a Knowledge Base (embeddings), AI Engine searches it before mwai_ai_query runs, and that search sends the visitor’s message to your embeddings provider. Hooking only mwai_ai_query would be too late for those chatbots. Running twice is harmless: once masked, there is nothing left to mask.

Install

Paste it into Code Engine ↗ as a PHP snippet that runs everywhere, or into your theme’s functions.php. It works with chatbots and AI Forms, and needs AI Engine 3.6 or newer.

<?php
/**
 * AI Engine: Sensitive Data Guard
 *
 * Checks what a visitor types into a chatbot or an AI Form before anything leaves
 * your server, and either blocks the message or replaces the sensitive parts
 * with [REDACTED]. Requires AI Engine 3.6 or newer.
 */

// 'block': refuse the message and show MWAI_PII_BLOCK_MESSAGE to the visitor.
// 'mask':  send it with the sensitive parts replaced by [REDACTED].
define( 'MWAI_PII_MODE', 'block' );
define( 'MWAI_PII_CHECK_EMAILS', false );
define( 'MWAI_PII_CHECK_PHONES', false );
define( 'MWAI_PII_BLOCK_MESSAGE', "For your security, please don't share card numbers, Social Security numbers or account numbers here. Your message was not sent." );

// Returns the text with every sensitive value replaced by [REDACTED].
function mwai_pii_mask( $text ) {
  if ( !is_string( $text ) || $text === '' ) {
    return $text;
  }

  // Card numbers: 13 to 19 digits, spaces or dashes allowed, must pass the Luhn check
  // (this is what keeps order numbers and dates from being flagged).
  $text = preg_replace_callback( '/(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])/', function ( $m ) {
    $digits = preg_replace( '/\D/', '', $m[0] );
    return mwai_pii_luhn( $digits ) ? '[REDACTED]' : $m[0];
  }, $text );

  // SSNs written 123-45-6789 or 123 45 6789, skipping ranges that are never issued.
  $text = preg_replace( '/\b(?!000|666|9\d\d)\d{3}([- ])(?!00)\d{2}\1(?!0000)\d{4}\b/', '[REDACTED]', $text );

  // Any bare 9-digit number that is a valid ABA routing number.
  $text = preg_replace_callback( '/(?<!\d)\d{9}(?!\d)/', function ( $m ) {
    return mwai_pii_aba( $m[0] ) ? '[REDACTED]' : $m[0];
  }, $text );

  // Numbers following a keyword: "account 12345678", "acct #: 0012-3456", "my SSN is 123456789".
  $keywords = 'account|acct|a\/c|member(?:ship)?|routing|aba|ssn|social(?: security)?|tax id|tin|card|pin';
  $text = preg_replace(
    '/\b(' . $keywords . ')\b(\s*(?:number|no\.?|num|#)?\s*(?:is|:|=)?\s*#?\s*)\d[\d -]{2,20}\d/i',
    '$1$2[REDACTED]',
    $text
  );

  if ( MWAI_PII_CHECK_EMAILS ) {
    $text = preg_replace( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[REDACTED]', $text );
  }
  if ( MWAI_PII_CHECK_PHONES ) {
    $text = preg_replace( '/(?<![\w])(?:\+?1[ .-]?)?\(?[2-9]\d{2}\)?[ .-]?\d{3}[ .-]?\d{4}(?![\w])/', '[REDACTED]', $text );
  }

  return $text;
}

function mwai_pii_luhn( $digits ) {
  $sum = 0;
  $double = false;
  for ( $i = strlen( $digits ) - 1; $i >= 0; $i-- ) {
    $d = (int) $digits[$i];
    if ( $double ) {
      $d *= 2;
      if ( $d > 9 ) {
        $d -= 9;
      }
    }
    $sum += $d;
    $double = !$double;
  }
  return $sum % 10 === 0;
}

// Valid ABA routing numbers start with 00-12, 21-32, 61-72 or 80, and pass a checksum.
function mwai_pii_aba( $digits ) {
  $prefix = (int) substr( $digits, 0, 2 );
  $validPrefix = $prefix <= 12 || ( $prefix >= 21 && $prefix <= 32 ) || ( $prefix >= 61 && $prefix <= 72 ) || $prefix === 80;
  if ( !$validPrefix ) {
    return false;
  }
  $w = [ 3, 7, 1, 3, 7, 1, 3, 7, 1 ];
  $sum = 0;
  for ( $i = 0; $i < 9; $i++ ) {
    $sum += (int) $digits[$i] * $w[$i];
  }
  return $sum % 10 === 0 && $digits !== '000000000';
}

// Masks a history message whatever its shape (plain string, or a list of parts).
function mwai_pii_mask_content( $content ) {
  if ( is_string( $content ) ) {
    return mwai_pii_mask( $content );
  }
  if ( is_array( $content ) ) {
    foreach ( $content as $key => $value ) {
      $content[$key] = ( $key === 'text' || is_array( $value ) ) ? mwai_pii_mask_content( $value ) : $value;
    }
  }
  return $content;
}

function mwai_pii_guard( $query ) {
  if ( !is_object( $query ) || !isset( $query->message ) || $query instanceof Meow_MWAI_Query_Embed ) {
    return;
  }

  // The new message: blocked or masked, depending on the mode.
  $masked = mwai_pii_mask( $query->message );
  if ( $masked !== $query->message ) {
    if ( MWAI_PII_MODE === 'block' ) {
      // A refusal is shown to the visitor as is (a plain Exception would become a generic error).
      throw new Meow_MWAI_RefusedException( MWAI_PII_BLOCK_MESSAGE, 'pii' );
    }
    $query->set_message( $masked );
  }

  // The conversation history is always masked, never blocked: the visitor's browser
  // keeps a blocked message in its history, and blocking on it would lock the chat.
  foreach ( $query->messages as $i => $message ) {
    if ( isset( $message['content'] ) ) {
      $query->messages[$i]['content'] = mwai_pii_mask_content( $message['content'] );
    }
  }
}

// Runs before the Knowledge Base (embeddings) search, which sends the message to the
// embeddings provider. Priority 1 so it runs before AI Engine's own search.
add_filter( 'mwai_context_search', function ( $context, $query ) {
  mwai_pii_guard( $query );
  return $context;
}, 1, 2 );

// Runs right before the query is sent to the AI (chatbots, forms, and bots without a
// Knowledge Base). Running twice is harmless: masked text has nothing left to mask.
add_filter( 'mwai_ai_query', function ( $query ) {
  mwai_pii_guard( $query );
  return $query;
}, 1 );

Good to know

  • Pattern matching lowers the risk, it doesn’t remove it. It can miss numbers written in words or split across messages, and it can sometimes flag a harmless 9-digit number that happens to look like a routing number.
  • It only checks what visitors type. It doesn’t scan your Knowledge Base, uploaded files or images, or anything sent before it was installed.
  • Masking changes what goes to the AI, not what stays on your site: the visitor’s chat history and the Discussions log still hold what they typed. Privacy First and the Discussions settings are covered on the data privacy page.
  • If Moderation is on for a chatbot, the raw message goes to OpenAI’s moderation service before this snippet runs. If the data must stay with your own provider (Azure, for example), keep Moderation off.