Skip to content

Add AI_Data_Sanitizer helper #18

Description

@AnuragVasanwala

Context

The Dev Monitor's AI context provider (AI_Context_Provider interface) is built around one rule: only metadata leaves the developer's machine. No SQL, no error strings, no user input, no post content. Even a single accidental leak from one collector would break the privacy guarantee.

AI_Data_Sanitizer is a stateless defence-in-depth pass that runs over every payload before it is handed to any AI client. Each collector is also expected to filter at source, but the sanitizer is the safety net.

Expected Outcome

A single stateless helper class plus a focused unit test that proves it strips the forbidden patterns.

Files added:

src/Dev/AI/AI_Data_Sanitizer.php
tests/Dev/AI/AI_Data_SanitizerTest.php
CHANGELOG.md (Unreleased entry)

Class shape:

namespace RtCamp\WPToolkit\Dev\AI;

final class AI_Data_Sanitizer {

    private function __construct() {}      // stateless, do not instantiate

    /**
     * Sanitize a structured payload before it leaves the dev machine.
     * Strips SQL, error messages, user-identifiable data, and free-form strings
     * over a length threshold. Recursive on nested arrays.
     *
     * @param array $payload Raw collector context.
     * @return array Sanitized copy. Forbidden keys are removed. Suspicious string
     *               values are replaced with the token "[redacted:<reason>]".
     */
    public static function sanitize( array $payload ): array;

    /**
     * Returns true if the given string looks like SQL, an exception message,
     * a user email, an IP, a session token, or any other forbidden pattern.
     */
    public static function looks_sensitive( string $value ): bool;
}

Forbidden patterns (stripped or redacted):

Pattern Detection rule Replacement
SQL matches `/\b(SELECT INSERT
Exception trace contains #0 followed by file path [redacted:trace]
Email matches /[\w.+-]+@[\w-]+\.[\w.-]+/ [redacted:email]
IP address matches /\b\d{1,3}(\.\d{1,3}){3}\b/ [redacted:ip]
Long free text string length > 500 characters [redacted:long_text]
Forbidden keys any key in [ 'password', 'secret', 'token', 'api_key', 'cookie', 'auth' ] (case-insensitive) key removed entirely

Verification commands (all must exit 0):

composer install
composer phpcs src/Dev/AI/ tests/Dev/AI/
composer phpstan
composer test -- tests/Dev/AI/AI_Data_SanitizerTest.php

Implementation guidance

Self-contained brief. This is a pure-function helper. No state, no Singleton, no instances.

Step-by-step

  1. Create src/Dev/AI/AI_Data_Sanitizer.php. Mark the class final, give it private function __construct() {} so it cannot be instantiated.
  2. Implement looks_sensitive() first with the six pattern checks. Use preg_match() for SQL, exception trace, email, IP. Use strlen() for the long-text check.
  3. Implement sanitize() recursively. Walk the array; for each value:
    • If the key matches a forbidden key (case-insensitive), drop the key entirely.
    • If the value is an array, recurse.
    • If the value is a string, run looks_sensitive() and replace with the redaction token.
    • Otherwise pass through unchanged (int / bool / float / null are safe).
  4. Use a private helper for the redaction token so the format is consistent: redact( string $reason ): string returns "[redacted:{$reason}]".
  5. Write the 12 tests: one per forbidden pattern (SQL, trace, email, IP, long text, each forbidden key) + nested-array test + clean-pass-through test.
  6. PHPCS + PHPStan + PHPUnit all green.

Reference patterns

final class AI_Data_Sanitizer {

    private const FORBIDDEN_KEYS = [ 'password', 'secret', 'token', 'api_key', 'cookie', 'auth' ];
    private const LONG_TEXT_THRESHOLD = 500;

    private function __construct() {}

    public static function sanitize( array $payload ): array {
        $clean = [];
        foreach ( $payload as $key => $value ) {
            if ( self::is_forbidden_key( (string) $key ) ) {
                continue;
            }
            $clean[ $key ] = is_array( $value )
                ? self::sanitize( $value )
                : self::sanitize_value( $value );
        }
        return $clean;
    }

    private static function sanitize_value( mixed $value ): mixed {
        if ( ! is_string( $value ) ) {
            return $value;
        }
        // Apply each pattern. Order matters: check trace before email
        // because traces often contain emails too.
        // ...
    }

    private static function redact( string $reason ): string {
        return '[redacted:' . $reason . ']';
    }
}

Complete sample: full sanitize_value() with all six patterns

<?php
declare(strict_types=1);

namespace RtCamp\WPToolkit\Dev\AI;

final class AI_Data_Sanitizer {

    private const FORBIDDEN_KEYS = [ 'password', 'secret', 'token', 'api_key', 'cookie', 'auth' ];
    private const LONG_TEXT_THRESHOLD = 500;

    private function __construct() {}

    /**
     * Sanitize a structured payload before it leaves the dev machine.
     *
     * @param array<string, mixed> $payload Raw collector context.
     * @return array<string, mixed> Sanitized copy.
     */
    public static function sanitize( array $payload ): array {
        $clean = [];
        foreach ( $payload as $key => $value ) {
            if ( self::is_forbidden_key( (string) $key ) ) {
                continue;
            }
            $clean[ $key ] = is_array( $value )
                ? self::sanitize( $value )
                : self::sanitize_value( $value );
        }
        return $clean;
    }

    /**
     * Returns true if the given string looks like one of the forbidden patterns.
     */
    public static function looks_sensitive( string $value ): bool {
        return self::detect_pattern( $value ) !== null;
    }

    private static function sanitize_value( mixed $value ): mixed {
        if ( ! is_string( $value ) ) {
            return $value;       // int, bool, float, null pass through
        }
        $reason = self::detect_pattern( $value );
        return $reason === null ? $value : self::redact( $reason );
    }

    /**
     * Order matters: check exception traces first (they often contain emails / SQL too).
     * Returns the redaction reason, or null if the value is safe.
     */
    private static function detect_pattern( string $value ): ?string {
        // 1. Exception trace
        if ( preg_match( '/#0\s+\/[\w\/\.-]+/', $value ) === 1 ) {
            return 'trace';
        }
        // 2. SQL keywords
        if ( preg_match( '/\b(SELECT|INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER)\b/i', $value ) === 1 ) {
            return 'sql';
        }
        // 3. Email
        if ( preg_match( '/[\w.+-]+@[\w-]+\.[\w.-]+/', $value ) === 1 ) {
            return 'email';
        }
        // 4. IPv4
        if ( preg_match( '/\b\d{1,3}(\.\d{1,3}){3}\b/', $value ) === 1 ) {
            return 'ip';
        }
        // 5. Long free text
        if ( strlen( $value ) > self::LONG_TEXT_THRESHOLD ) {
            return 'long_text';
        }
        return null;
    }

    private static function is_forbidden_key( string $key ): bool {
        $lower = strtolower( $key );
        foreach ( self::FORBIDDEN_KEYS as $forbidden ) {
            if ( str_contains( $lower, $forbidden ) ) {
                return true;
            }
        }
        return false;
    }

    private static function redact( string $reason ): string {
        return '[redacted:' . $reason . ']';
    }
}

Sample input → sanitized output

$input = [
    'collector'      => 'queries',
    'query_count'    => 42,
    'duration_ms'    => 123.4,
    'user_email'     => 'jane@example.com',          // → [redacted:email]
    'last_query'     => "SELECT * FROM wp_users",    // → [redacted:sql]
    'session_token'  => 'abc123',                    // KEY DROPPED
    'api_key'        => 'sk_live_xxx',               // KEY DROPPED
    'remote_ip'      => '203.0.113.42',              // → [redacted:ip]
    'last_error'     => "#0 /var/www/wp/foo.php(42)\n#1 ...",  // → [redacted:trace]
    'meta'           => [
        'cache_group'  => 'transient',                // safe
        'long_payload' => str_repeat( 'a', 600 ),     // → [redacted:long_text]
    ],
];

$output = AI_Data_Sanitizer::sanitize( $input );
// [
//     'collector'   => 'queries',
//     'query_count' => 42,
//     'duration_ms' => 123.4,
//     'user_email'  => '[redacted:email]',
//     'last_query'  => '[redacted:sql]',
//     'remote_ip'   => '[redacted:ip]',
//     'last_error'  => '[redacted:trace]',
//     'meta'        => [
//         'cache_group'  => 'transient',
//         'long_payload' => '[redacted:long_text]',
//     ],
// ]

Edge cases

  • Order of pattern checks matters. Check exception traces first (they often contain everything else).
  • is_array() does not catch ArrayObject. Document in the @param array $payload that only plain arrays are supported. Throw InvalidArgumentException if an ArrayObject is passed at the top level (test this).
  • Do not log the sanitised vs original. Logging defeats the purpose.
  • Do not call any WordPress functions. This class must be testable without WP loaded.

Pre-PR self-check

  • Class is final with private __construct()
  • No instance state — all methods static
  • Recursion works on nested arrays (verified by test)
  • All 6 forbidden patterns enforced (verified by 6 tests)
  • Forbidden keys are dropped (case-insensitive, verified by test)
  • No WordPress dependency (verified by running test in isolation)
  • PHPCS, PHPStan, PHPUnit all green
  • CHANGELOG entry under ## Unreleased

Acceptance Criteria

  • AI_Data_Sanitizer is final with private __construct() (stateless, cannot be instantiated)
  • Both methods are public static with full type declarations
  • sanitize() recurses into nested arrays
  • All 6 forbidden-pattern rules from the table are enforced
  • At least 12 unit tests: one per forbidden pattern, plus nested-array, plus pass-through-of-clean-metadata
  • PHPCS passes
  • PHPStan level 5 passes
  • CHANGELOG.md entry added under ## Unreleased

Notes

  • Depends on the PHPCS baseline (Add shared PHPCS baseline (phpcs.xml.dist) #15) and PHPStan baseline (Add shared PHPStan baseline (phpstan.neon.dist) #16) merged.
  • This is a stateless helper, not a Singleton or multi-instance class — pure functions only.
  • The sanitizer is defence in depth, not the primary filter. Each collector's ai_context() is expected to return only metadata in the first place.
  • A ai_context() REST endpoint in the skeleton is gated separately (manage_options + nonce + DEV_MODE + rate limit). Sanitizer applies regardless of caller.
  • Console-log collector data is memory-only and never reaches the server, so it never reaches this sanitizer either.
  • PR target: release/v1.0.0. Branch: v1.0.0/task/ai-data-sanitizer. Commit subject: feat(dev): add AI_Data_Sanitizer helper.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: P1High — sprint commitmentScope: AI PrivacyAI_Data_Sanitizer + ai_context() contractType: TaskSelf-contained unit of work for a milestone

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions