breznflow/breznflow/includes/Security/MaskingRules.php
Michael 5c4d5f6686 release: v1.0.4
Security

- Add looks_like_secret() entropy heuristic: vendor regex (AIza, sk-,
  ghp_, gho_, Slack xox, Bearer) + length/char-class fallback +
  path/whitespace denylist. Defensible hybrid: zero false-positives
  on known token formats, catches custom tokens without tripping on
  URLs or slugs.
- Gate generic 'key'-named fields and ?key= URL params with the
  entropy heuristic. Closes the n8n queryParameters Google-API-key
  bypass without false-positives on benign values.
- Entropy fallback in mask_name_value_pair for custom-header value
  patterns (X-App-Token etc.) whose names we cannot enumerate.
- Redact credentials[].name per node (id retained), clear
  meta.instanceId so exports no longer correlate to the source n8n
  instance.
- Opt-in tag clearing at publish time: wizard step 3 checkbox with
  the current tag list inline, only shown when tags exist.
- Wizard step 3 now renders a collapsible Reason / Key / Note table
  so publishers can verify exactly what was masked before publishing.

Mobile

- touch-action: none on .breznflow-svg to stop the
  browser-vs-plugin gesture tug-of-war.
- Rewrote pointer handling as a Map-based multi-pointer state
  machine with { passive: false } listeners: single-finger pan is
  now smooth on iOS and Android, pinch-to-zoom anchored at the
  finger midpoint, double-tap toggles 100/200 % zoom.
- Minimap ported to pointer events + setPointerCapture — tap and
  drag navigation work on touch.

Docs

- Expand Sensitive Data Masking section of both READMEs to describe
  the 1.0.4 passes and the opt-in tag removal.
- Version badge 1.0.3 -> 1.0.4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 18:58:51 +00:00

272 lines
7.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Masking rules for sensitive data in workflow parameters.
*
* @package BreznFlow
* @since 1.0.0
*/
namespace BreznFlow\Security;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Provides rules for masking sensitive values in workflow data.
*
* @since 1.0.0
*/
class MaskingRules {
/** URL query param pattern for sensitive keys. */
const URL_PARAM_PATTERN = '/([?&](?:api[_-]?key|apikey|token|secret|password|access_token|auth|private_key|client_secret)=)[^&\s#]+/i';
/**
* Known vendor secret formats.
*
* @since 1.0.4
*/
const HIGH_ENTROPY_PATTERNS = array(
'/^AIza[0-9A-Za-z_-]{35}$/', // Google API key.
'/^sk-[A-Za-z0-9_-]{20,}$/', // OpenAI / Anthropic family.
'/^ghp_[A-Za-z0-9]{36}$/', // GitHub personal access token.
'/^gho_[A-Za-z0-9]{36}$/', // GitHub OAuth token.
'/^xox[baprs]-[A-Za-z0-9-]{10,}$/', // Slack token.
'/^Bearer\s+[A-Za-z0-9._~+\/=-]{20,}$/', // Bearer auth header.
);
/** Safe-list values that should never be masked (condition rightValue). */
const SAFE_CONDITION_VALUES = array(
'true',
'false',
'null',
'undefined',
'0',
'1',
'yes',
'no',
'success',
'error',
'active',
'inactive',
'enabled',
'disabled',
'pending',
'complete',
'completed',
'failed',
);
/**
* Applies all masking rules to a string value.
*
* @param string $value Raw string to inspect.
* @param string $field_key The parameter key name (for context-aware masking).
* @param array $log Passed by reference — masked items appended here.
* @return string Possibly masked value.
*/
public static function apply( string $value, string $field_key, array &$log ): string {
$value = self::mask_url_params( $value, $log );
$value = self::mask_sensitive_field( $value, $field_key, $log );
return $value;
}
/**
* Masks sensitive URL query parameters.
*
* @since 1.0.0
* @param string $value Raw string to inspect.
* @param array $log Passed by reference — masked items appended here.
* @return string Possibly masked value.
*/
private static function mask_url_params( string $value, array &$log ): string {
if ( ! str_contains( $value, '=' ) && ! str_contains( $value, '?' ) ) {
return $value;
}
$masked = preg_replace_callback(
self::URL_PARAM_PATTERN,
function ( $matches ) use ( &$log ) {
$log[] = array(
'reason' => 'url_param',
'key' => $matches[0],
'note' => 'Sensitive URL parameter value replaced.',
);
return $matches[1] . '[REDACTED]';
},
$value
);
$value = null !== $masked ? $masked : $value;
// Conditional pass for generic `?key=…` / `&key=…` — only redact when
// the captured value itself looks like a secret.
$masked = preg_replace_callback(
'/([?&]key=)([^&\s#]+)/i',
function ( $matches ) use ( &$log ) {
if ( ! self::looks_like_secret( $matches[2] ) ) {
return $matches[0];
}
$log[] = array(
'reason' => 'url_param_generic_key',
'key' => $matches[0],
'note' => 'Generic key query-param holds secret-shaped value.',
);
return $matches[1] . '[REDACTED]';
},
$value
);
return null !== $masked ? $masked : $value;
}
/**
* Masks values of fields with inherently sensitive names.
*
* @since 1.0.0
* @param string $value Raw string to inspect.
* @param string $field_key The parameter key name.
* @param array $log Passed by reference — masked items appended here.
* @return string Possibly masked value.
*/
private static function mask_sensitive_field( string $value, string $field_key, array &$log ): string {
$sensitive_keys = array(
'api_key',
'apikey',
'token',
'secret',
'password',
'access_token',
'auth',
'private_key',
'client_secret',
'apiKey',
'accessToken',
'clientSecret',
'privateKey',
);
if ( in_array( $field_key, $sensitive_keys, true ) && '' !== $value && '[REDACTED]' !== $value ) {
$log[] = array(
'reason' => 'sensitive_field_name',
'key' => $field_key,
'note' => 'Field name indicates sensitive data.',
);
return '[REDACTED]';
}
// Generic `key` field names (common in n8n queryParameters) gated by
// entropy check to avoid false positives on harmless values like
// `{name:"key", value:"weather_berlin"}`.
$generic_key_names = array( 'key', 'x-key', 'access-key', 'x_key' );
if ( in_array( strtolower( $field_key ), $generic_key_names, true )
&& '' !== $value
&& '[REDACTED]' !== $value
&& self::looks_like_secret( $value ) ) {
$log[] = array(
'reason' => 'generic_key_with_entropy',
'key' => $field_key,
'note' => 'Generic key-named field holds secret-shaped value.',
);
return '[REDACTED]';
}
return $value;
}
/**
* Heuristically detects whether a string value looks like a secret.
*
* Defense-in-depth: complements the name-based filter in
* mask_sensitive_field() by catching generic fields (e.g. n8n's
* queryParameters `{name:"key"}`) and custom headers whose names we
* cannot enumerate.
*
* Matches:
* - Known vendor tokens: `AIzaSy…`, `sk-proj-…`, `ghp_…`, `Bearer eyJ…`
* - Opaque high-entropy strings: ≥30 chars, mixed classes,
* no whitespace, no path separators.
*
* Skips (false-positive control):
* - URLs / filesystem paths (contain `/`)
* - Strings with whitespace (not vendor-matched)
* - Simple slugs like `weather_berlin`, `my-plugin`
*
* @since 1.0.4
* @param string $value Candidate value.
* @return bool True if the value has secret-like characteristics.
*/
public static function looks_like_secret( string $value ): bool {
$len = strlen( $value );
if ( $len < 20 ) {
return false;
}
foreach ( self::HIGH_ENTROPY_PATTERNS as $pattern ) {
if ( 1 === preg_match( $pattern, $value ) ) {
return true;
}
}
if ( $len < 30 ) {
return false;
}
if ( 1 === preg_match( '/[\s\/]/', $value ) ) {
return false;
}
$classes = (int) (bool) preg_match( '/[a-z]/', $value );
$classes += (int) (bool) preg_match( '/[A-Z]/', $value );
$classes += (int) (bool) preg_match( '/[0-9]/', $value );
return $classes >= 2;
}
/**
* Applies condition rightValue heuristic masking.
* Used specifically for condition node parameter values.
*
* @since 1.0.0
* @param string $value Raw condition value.
* @param array $log Passed by reference — masked items appended here.
* @return string Possibly masked value.
*/
public static function apply_condition_heuristic( string $value, array &$log ): string {
$len = strlen( $value );
// Must be 8512 chars.
if ( $len < 8 || $len > 512 ) {
return $value;
}
// Safe-list check.
if ( in_array( strtolower( $value ), self::SAFE_CONDITION_VALUES, true ) ) {
return $value;
}
// n8n expression check.
if ( str_starts_with( $value, '={{' ) && str_ends_with( $value, '}}' ) ) {
return $value;
}
// ISO date check.
if ( preg_match( '/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/', $value ) ) {
return $value;
}
// Entropy check: UUID-shaped, or mixed case+digits, or long with no spaces.
$is_uuid = (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value );
$is_complex = (bool) preg_match( '/[A-Z]/', $value )
&& (bool) preg_match( '/[a-z]/', $value )
&& (bool) preg_match( '/[0-9]/', $value );
$is_long_no_space = $len > 20 && ! str_contains( $value, ' ' );
if ( $is_uuid || $is_complex || $is_long_no_space ) {
$log[] = array(
'reason' => 'condition_heuristic',
'key' => 'rightValue',
'note' => 'Value matches entropy heuristic for potential secret.',
);
return '[REDACTED]';
}
return $value;
}
}