Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 167 additions & 1 deletion src/Masked/Protect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down Expand Up @@ -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(
'~(?<!\d)\d+(?:[ -]\d+)*(?!\d)~',
static function ($matches)
{
return self::_redactCreditCardSequence($matches[0]);
},
$string
);
return $redacted === $string
? $var
: $redacted;
}

/**
* Detects and redacts credit card numbers inside a numeric sequence
*
* @param string $value
* @return string
*/
private static function _redactCreditCardSequence($value)
{
preg_match_all(
'~\d+~',
$value,
$matches,
PREG_OFFSET_CAPTURE
);

$groups = $matches[0];
$count = count($groups);
$redacted = '';
$offset = 0;
$groupIndex = 0;

while ($groupIndex < $count)
{
$start = $groups[$groupIndex][1];
$digitsLength = 0;
$match = NULL;

for (
$candidateIndex = $groupIndex;
$candidateIndex < $count;
$candidateIndex++
)
{
$groupLength = strlen($groups[$candidateIndex][0]);
$digitsLength += $groupLength;

if ($digitsLength > 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;
}

/**
Expand Down
207 changes: 207 additions & 0 deletions tests/Masked/ProtectCreditCardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php

namespace Fuko\Masked\Tests;

use Fuko\Masked\Protect;
use Fuko\Masked\Redact;
use PHPUnit\Framework\TestCase;

class ProtectCreditCardTest extends TestCase
{
function tearDown(): void
{
Redact::setRedactCallback(
array(Redact::class, 'disguise'),
array(0, '█')
);
}

/**
* @dataProvider provider_protect_credit_card
* @covers Fuko\Masked\Protect::protect
* @covers Fuko\Masked\Protect::protectScalar
*/
function test_protect_credit_card($source, $expected)
{
$this->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')
);
}
}