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>
272 lines
7.6 KiB
PHP
272 lines
7.6 KiB
PHP
<?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 8–512 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;
|
||
}
|
||
}
|