Skip to content

Instantly share code, notes, and snippets.

@maks-rafalko
Created October 8, 2019 05:39
Show Gist options
  • Save maks-rafalko/ac9ccb36a68008be1e61b18e4d2ff9cc to your computer and use it in GitHub Desktop.
Save maks-rafalko/ac9ccb36a68008be1e61b18e4d2ff9cc to your computer and use it in GitHub Desktop.
Symfony: do not log secrets (custom Monolog Formatter) to avoid secrets disclosure
<?php
declare(strict_types=1);
namespace Core\Monolog\Formatter;
use Doctrine\DBAL\Connection;
use Monolog\Formatter\LineFormatter;
/**
* This Monolog Formatter replaces secrets with '*****' characters to avoid secrets disclosure
*/
final class MaskSecretsLineFormatter extends LineFormatter
{
// ENV variables you want to hide in the logs
// DB username & pass will be added automatically below
public const SECRETS_TO_MASK = [
'APP_SECRET',
'JWT_PASSPHRASE',
'JWT_API_PASS_HASH',
'AWS_S3_SECRET',
'AWS_S3_KEY',
];
/**
* @var Connection
*/
private $connection;
/**
* {@inheritdoc}
*/
public function format(array $record)
{
$logLine = parent::format($record);
$secretValues = [];
foreach (self::SECRETS_TO_MASK as $secretName) {
/** @var string|bool $secretValue */
$secretValue = getenv($secretName);
if ($secretValue !== false) {
$secretValues[] = $secretValue;
}
}
$secretValues[] = $this->connection->getUsername();
$secretValues[] = $this->connection->getPassword();
$logLine = str_replace($secretValues, '*****', $logLine);
return $logLine;
}
public function setConnection(Connection $connection): void
{
$this->connection = $connection;
}
}
<?php
declare(strict_types=1);
namespace Core\Tests\Unit\Monolog\Formatter;
use Core\Monolog\Formatter\MaskSecretsLineFormatter;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class MaskSecretsLineFormatterTest extends TestCase
{
/**
* @var array
*/
private static $env;
/**
* Saves the current environment
*/
public static function setUpBeforeClass(): void
{
self::$env = [];
foreach (MaskSecretsLineFormatter::SECRETS_TO_MASK as $name) {
self::$env[$name] = getenv($name);
}
}
public static function tearDownAfterClass(): void
{
self::restorePathEnvironment();
}
protected function setUp(): void
{
self::restorePathEnvironment();
}
public function test_it_replaces_secrets_with_stars(): void
{
/** @var Connection|MockObject $connectionMock */
$connectionMock = $this->createMock(Connection::class);
$connectionMock->expects(self::once())->method('getUsername')->willReturn('db_username');
$connectionMock->expects(self::once())->method('getPassword')->willReturn('db_pass');
putenv(sprintf('%s=%s', 'AWS_S3_KEY', 'aws_s3_key_secret'));
$formatter = new MaskSecretsLineFormatter();
$formatter->setConnection($connectionMock);
$record = [
'level_name' => 'ERROR',
'channel' => 'custom_channel',
'message' => 'DB secrets: db_username, db_pass. AWS S3 secret: aws_s3_key_secret',
'datetime' => '2019-10-07 12:56:19',
'extra' => [],
'context' => [
'foo' => 'bar',
],
];
$formattedString = $formatter->format($record);
self::assertContains('[2019-10-07 12:56:19] custom_channel.ERROR: DB secrets: *****, *****. AWS S3 secret: ***** {"foo":"bar"} []', $formattedString);
}
private static function restorePathEnvironment(): void
{
foreach (self::$env as $name => $value) {
if (false !== $value) {
putenv($name . '=' . $value);
} else {
putenv($name);
}
}
}
}
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_404s:
# regex: exclude all 404 errors from the logs
- ^/
nested:
type: stream
path: "php://stderr"
include_stacktraces: true
level: error
+ formatter: 'Core\Monolog\Formatter\MaskSecretsLineFormatter'
+ Core\Monolog\Formatter\MaskSecretsLineFormatter:
+ calls:
+ - [setConnection, ['@Doctrine\DBAL\Connection']]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment