100 lines
3.3 KiB
PHP
100 lines
3.3 KiB
PHP
<?php
|
|
|
|
namespace App\Logging;
|
|
|
|
use Monolog\LogRecord;
|
|
|
|
/**
|
|
* Keeps SaaS api keys out of the log while leaving the requests themselves in it.
|
|
*
|
|
* The SaaS takes its api key as a path segment rather than in a header, so the url of every
|
|
* call carries a credential. Symfony's http client logs a line per request, and an HTTP error
|
|
* repeats the url inside the exception message, which then travels into whatever channel
|
|
* catches it - so silencing one client would not have been enough.
|
|
*
|
|
* Registered without a channel, deliberately: a url can surface anywhere, and a processor
|
|
* that only guarded the saas channel would miss the exception that matters most.
|
|
*
|
|
* Two passes, because the two failure modes are different. The configured key is redacted
|
|
* wherever it appears, whole or url-encoded, which is exact. The path shapes are redacted by
|
|
* pattern, which also covers the per-employee keys the roster hands out and any future
|
|
* endpoint that follows the same convention.
|
|
*/
|
|
class RedactSaasCredentialsProcessor
|
|
{
|
|
private const PLACEHOLDER = '[redacted]';
|
|
|
|
/**
|
|
* Anything sitting where an api key sits: /api/v2/<something>/<key>[/...].
|
|
*/
|
|
private const PATH_PATTERNS = [
|
|
'#(/api/v[0-9]+/time/account/)[^/\s"\']+#i',
|
|
'#(/api/v[0-9]+/employees/)[^/\s"\']+#i',
|
|
'#(/api/v[0-9]+/employee/departments/)[^/\s"\']+#i',
|
|
];
|
|
|
|
/**
|
|
* @var string[]
|
|
*/
|
|
private readonly array $secrets;
|
|
|
|
public function __construct(string $apiKey)
|
|
{
|
|
$secrets = [];
|
|
|
|
// Short values would match far too much; a real key never is.
|
|
if (8 <= strlen($apiKey)) {
|
|
$secrets[$apiKey] = $apiKey;
|
|
$secrets[rawurlencode($apiKey)] = rawurlencode($apiKey);
|
|
}
|
|
|
|
$this->secrets = array_values($secrets);
|
|
}
|
|
|
|
public function __invoke(LogRecord $record): LogRecord
|
|
{
|
|
$message = $this->redact($record->message);
|
|
$context = $this->redactDeep($record->context);
|
|
$extra = $this->redactDeep($record->extra);
|
|
|
|
if ($message === $record->message && $context === $record->context && $extra === $record->extra) {
|
|
return $record;
|
|
}
|
|
|
|
// LogRecord only allows some of its fields to be assigned through array access;
|
|
// message is not one of them, so a redacted record is a new one.
|
|
return $record->with(message: $message, context: $context, extra: $extra);
|
|
}
|
|
|
|
private function redactDeep(array $values, int $depth = 0): array
|
|
{
|
|
// Contexts hold arbitrary payloads; stop rather than walk something pathological.
|
|
if (8 < $depth) {
|
|
return $values;
|
|
}
|
|
|
|
foreach ($values as $key => $value) {
|
|
if (is_string($value)) {
|
|
$values[$key] = $this->redact($value);
|
|
} elseif (is_array($value)) {
|
|
$values[$key] = $this->redactDeep($value, $depth + 1);
|
|
}
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
private function redact(string $value): string
|
|
{
|
|
foreach ($this->secrets as $secret) {
|
|
$value = str_replace($secret, self::PLACEHOLDER, $value);
|
|
}
|
|
|
|
foreach (self::PATH_PATTERNS as $pattern) {
|
|
$value = (string) preg_replace($pattern, '$1'.self::PLACEHOLDER, $value);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|