diff --git a/src/Masked/Protect.php b/src/Masked/Protect.php index 42534ea..a6b5f56 100644 --- a/src/Masked/Protect.php +++ b/src/Masked/Protect.php @@ -15,13 +15,20 @@ use Fuko\Masked\ValueCollection; use const FILTER_DEFAULT; +use const PREG_OFFSET_CAPTURE; +use function count; use function filter_var; use function is_array; use function is_object; use function is_scalar; +use function preg_match; +use function preg_match_all; +use function preg_replace_callback; +use function strlen; use function strpos; use function str_replace; +use function substr; /** * Protect sensitive data and redacts it using {@link Fuko\Masked\Redact::redact()} @@ -192,7 +199,166 @@ static function protectScalar($var) } } - return $var; + return self::_redactCreditCards($var); + } + + /** + * Detects and redacts credit card numbers inside a string + * + * @param string $var + * @return string + */ + private static function _redactCreditCards($var) + { + $string = (string) $var; + $redacted = preg_replace_callback( + '~(? 19) + { + break; + } + + if ($digitsLength < 13) + { + continue; + } + + $end = $groups[$candidateIndex][1] + $groupLength; + $candidate = substr( + $value, + $start, + $end - $start + ); + + if (self::_isCreditCard($candidate)) + { + $match = array( + $start, + $end, + $candidateIndex + ); + } + } + + if (NULL === $match) + { + $groupIndex++; + continue; + } + + $redacted .= substr( + $value, + $offset, + $match[0] - $offset + ); + + $redacted .= Redact::redact( + substr( + $value, + $match[0], + $match[1] - $match[0] + ) + ); + + $offset = $match[1]; + $groupIndex = $match[2] + 1; + } + + return 0 === $offset + ? $value + : $redacted . substr($value, $offset); + } + + /** + * Checks whether a value is a valid credit card number + * using the Luhn checksum + * + * @param string $value + * @return boolean + */ + private static function _isCreditCard($value) + { + $number = str_replace(array(' ', '-'), '', $value); + $length = strlen($number); + + if ($length < 13 || $length > 19) + { + return false; + } + + if (preg_match('~^(\d)\1+$~', $number)) + { + return false; + } + + $sum = 0; + $parity = $length % 2; + + for ($index = 0; $index < $length; $index++) + { + $digit = (int) $number[$index]; + + if ($index % 2 === $parity) + { + $digit *= 2; + + if ($digit > 9) + { + $digit -= 9; + } + } + + $sum += $digit; + } + + return 0 === $sum % 10; } /** diff --git a/tests/Masked/ProtectCreditCardTest.php b/tests/Masked/ProtectCreditCardTest.php new file mode 100644 index 0000000..1355636 --- /dev/null +++ b/tests/Masked/ProtectCreditCardTest.php @@ -0,0 +1,207 @@ +assertEquals( + $expected, + Protect::protect($source) + ); + } + + function provider_protect_credit_card() + { + return array( + // Common card types + // + 'Visa 16-digit' => array( + 'Card: 4111111111111111', + 'Card: ████████████████' + ), + 'Mastercard 16-digit' => array( + 'Card: 5555555555554444', + 'Card: ████████████████' + ), + 'Amex 15-digit' => array( + 'Card: 378282246310005', + 'Card: ███████████████' + ), + + // PAN length boundaries + // + '13-digit PAN' => array( + 'Card: 4222222222222', + 'Card: █████████████' + ), + '19-digit PAN' => array( + 'Card: 4000000000000000006', + 'Card: ███████████████████' + ), + + // Formatted PANs + // + 'space-separated PAN' => array( + 'Card: 4111 1111 1111 1111', + 'Card: ███████████████████' + ), + 'hyphen-separated PAN' => array( + 'Card: 4111-1111-1111-1111', + 'Card: ███████████████████' + ), + 'Amex with spaces' => array( + 'Card: 3782 822463 10005', + 'Card: █████████████████' + ), + '19-digit PAN with spaces' => array( + 'Card: 4000 0000 0000 0000 006', + 'Card: ███████████████████████' + ), + + // PANs surrounded by other numeric data + // + 'PAN followed by expiry date' => array( + 'Card: 4111111111111111 12/30', + 'Card: ████████████████ 12/30' + ), + 'formatted PAN followed by CVV' => array( + 'Card: 4111 1111 1111 1111 123', + 'Card: ███████████████████ 123' + ), + 'PAN followed by labelled CVV' => array( + 'Card: 4111111111111111 CVV: 003', + 'Card: ████████████████ CVV: 003' + ), + 'number before formatted PAN' => array( + 'Order 123 4111 1111 1111 1111', + 'Order 123 ███████████████████' + ), + + // Multiple PANs + // + 'multiple PANs separated by text' => array( + 'Primary: 4111111111111111, backup: 5555555555554444', + 'Primary: ████████████████, backup: ████████████████' + ), + 'adjacent PANs' => array( + 'Cards: 4111111111111111 5555555555554444', + 'Cards: ████████████████ ████████████████' + ), + ); + } + + /** + * @dataProvider provider_ignore_non_credit_card + * @covers Fuko\Masked\Protect::protect + * @covers Fuko\Masked\Protect::protectScalar + */ + function test_ignore_non_credit_card($source) + { + $this->assertEquals( + $source, + Protect::protect($source) + ); + } + + function provider_ignore_non_credit_card() + { + return array( + 'invalid Luhn checksum' => array( + 'Card: 4111111111111112' + ), + 'arbitrary numeric reference' => array( + 'Reference: 1234567890123456' + ), + 'repeated digits' => array( + 'Reference: 0000000000000000' + ), + '20-digit numeric reference' => array( + 'Reference: 12345678901234567894' + ), + '20-digit reference containing valid PAN prefix' => array( + 'Reference: 41111111111111111234' + ), + ); + } + + /** + * @dataProvider provider_preserve_non_string_scalar + * @covers Fuko\Masked\Protect::protect + * @covers Fuko\Masked\Protect::protectScalar + */ + function test_preserve_non_string_scalar($value) + { + $this->assertSame( + $value, + Protect::protect($value) + ); + } + + function provider_preserve_non_string_scalar() + { + return array( + 'integer' => array(12345), + 'float' => array(12.34), + 'true' => array(true), + 'false' => array(false), + ); + } + + /** + * @covers Fuko\Masked\Protect::protect + * @covers Fuko\Masked\Protect::protectScalar + */ + function test_protect_credit_card_inside_nested_array() + { + $this->assertEquals( + array( + 'payment' => array( + 'card' => '████████████████', + ), + 'reference' => '1234567890123456', + ), + Protect::protect(array( + 'payment' => array( + 'card' => '4111111111111111', + ), + 'reference' => '1234567890123456', + )) + ); + } + + /** + * @covers Fuko\Masked\Protect::protect + * @covers Fuko\Masked\Protect::protectScalar + */ + function test_protect_credit_card_uses_redact_callback() + { + Redact::setRedactCallback( + array(Redact::class, 'disguise'), + array(4, '*') + ); + + $this->assertEquals( + 'Card: ************1111', + Protect::protect('Card: 4111111111111111') + ); + } +}