Implements Spec 115 (Baseline Operability & Alert Integration). Key changes - Baseline compare: safe auto-close of stale baseline findings (gated on successful/complete compares) - Baseline alerts: `baseline_high_drift` + `baseline_compare_failed` with dedupe/cooldown semantics - Workspace settings: baseline severity mapping + minimum severity threshold + auto-close toggle - Baseline Compare UX: shared stats layer + landing/widget consistency Notes - Livewire v4 / Filament v5 compatible. - Destructive-like actions require confirmation (no new destructive actions added here). Tests - `vendor/bin/sail artisan test --compact tests/Feature/Baselines/ tests/Feature/Alerts/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #140
404 lines
13 KiB
PHP
404 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Settings;
|
|
|
|
use App\Models\Finding;
|
|
|
|
final class SettingsRegistry
|
|
{
|
|
/**
|
|
* @var array<string, SettingDefinition>
|
|
*/
|
|
private array $definitions;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->definitions = [];
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'backup',
|
|
key: 'retention_keep_last_default',
|
|
type: 'int',
|
|
systemDefault: 30,
|
|
rules: ['required', 'integer', 'min:1', 'max:365'],
|
|
normalizer: static fn (mixed $value): int => (int) $value,
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'backup',
|
|
key: 'retention_min_floor',
|
|
type: 'int',
|
|
systemDefault: 1,
|
|
rules: ['required', 'integer', 'min:1', 'max:365'],
|
|
normalizer: static fn (mixed $value): int => (int) $value,
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'drift',
|
|
key: 'severity_mapping',
|
|
type: 'json',
|
|
systemDefault: [],
|
|
rules: [
|
|
'required',
|
|
'array',
|
|
static function (string $attribute, mixed $value, \Closure $fail): void {
|
|
if (! is_array($value)) {
|
|
$fail('The severity mapping must be a JSON object.');
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($value as $findingType => $severity) {
|
|
if (! is_string($findingType) || trim($findingType) === '') {
|
|
$fail('Each severity mapping key must be a non-empty string.');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! is_string($severity)) {
|
|
$fail(sprintf('Severity for "%s" must be a string.', $findingType));
|
|
|
|
return;
|
|
}
|
|
|
|
$normalizedSeverity = strtolower($severity);
|
|
|
|
if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) {
|
|
$fail(sprintf(
|
|
'Severity for "%s" must be one of: %s.',
|
|
$findingType,
|
|
implode(', ', self::supportedFindingSeverities()),
|
|
));
|
|
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
],
|
|
normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value),
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'baseline',
|
|
key: 'severity_mapping',
|
|
type: 'json',
|
|
systemDefault: self::defaultBaselineSeverityMapping(),
|
|
rules: [
|
|
'required',
|
|
'array',
|
|
static function (string $attribute, mixed $value, \Closure $fail): void {
|
|
if (! is_array($value)) {
|
|
$fail('The baseline severity mapping must be a JSON object.');
|
|
|
|
return;
|
|
}
|
|
|
|
$supportedKeys = array_fill_keys(self::supportedBaselineChangeTypes(), true);
|
|
|
|
foreach ($value as $changeType => $severity) {
|
|
if (! is_string($changeType) || ! isset($supportedKeys[$changeType])) {
|
|
$fail(sprintf(
|
|
'Baseline severity mapping keys must be one of: %s.',
|
|
implode(', ', self::supportedBaselineChangeTypes()),
|
|
));
|
|
|
|
return;
|
|
}
|
|
|
|
if (! is_string($severity)) {
|
|
$fail(sprintf('Severity for "%s" must be a string.', $changeType));
|
|
|
|
return;
|
|
}
|
|
|
|
$normalizedSeverity = strtolower($severity);
|
|
|
|
if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) {
|
|
$fail(sprintf(
|
|
'Severity for "%s" must be one of: %s.',
|
|
$changeType,
|
|
implode(', ', self::supportedFindingSeverities()),
|
|
));
|
|
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
],
|
|
normalizer: static fn (mixed $value): array => self::normalizeBaselineSeverityMapping($value),
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'baseline',
|
|
key: 'alert_min_severity',
|
|
type: 'string',
|
|
systemDefault: Finding::SEVERITY_HIGH,
|
|
rules: ['required', 'string', 'in:low,medium,high,critical'],
|
|
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'baseline',
|
|
key: 'auto_close_enabled',
|
|
type: 'bool',
|
|
systemDefault: true,
|
|
rules: ['required', 'boolean'],
|
|
normalizer: static fn (mixed $value): bool => filter_var($value, FILTER_VALIDATE_BOOL),
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'findings',
|
|
key: 'sla_days',
|
|
type: 'json',
|
|
systemDefault: self::defaultFindingsSlaDays(),
|
|
rules: [
|
|
'required',
|
|
'array',
|
|
static function (string $attribute, mixed $value, \Closure $fail): void {
|
|
if (! is_array($value)) {
|
|
$fail('The findings SLA days setting must be a JSON object.');
|
|
|
|
return;
|
|
}
|
|
|
|
$supportedSeverities = self::supportedFindingSeverities();
|
|
$supportedMap = array_fill_keys($supportedSeverities, true);
|
|
|
|
foreach ($value as $severity => $days) {
|
|
if (! is_string($severity)) {
|
|
$fail('Each findings SLA key must be a severity string.');
|
|
|
|
return;
|
|
}
|
|
|
|
$normalizedSeverity = strtolower($severity);
|
|
|
|
if (! isset($supportedMap[$normalizedSeverity])) {
|
|
$fail(sprintf(
|
|
'Unsupported findings SLA severity "%s". Expected only: %s.',
|
|
$severity,
|
|
implode(', ', $supportedSeverities),
|
|
));
|
|
|
|
return;
|
|
}
|
|
|
|
$normalizedDays = filter_var($days, FILTER_VALIDATE_INT);
|
|
|
|
if ($normalizedDays === false || $normalizedDays < 1 || $normalizedDays > 3650) {
|
|
$fail(sprintf(
|
|
'Findings SLA days for "%s" must be an integer between 1 and 3650.',
|
|
$normalizedSeverity,
|
|
));
|
|
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
],
|
|
normalizer: static fn (mixed $value): array => self::normalizeFindingsSlaDays($value),
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'operations',
|
|
key: 'operation_run_retention_days',
|
|
type: 'int',
|
|
systemDefault: 90,
|
|
rules: ['required', 'integer', 'min:7', 'max:3650'],
|
|
normalizer: static fn (mixed $value): int => (int) $value,
|
|
));
|
|
|
|
$this->register(new SettingDefinition(
|
|
domain: 'operations',
|
|
key: 'stuck_run_threshold_minutes',
|
|
type: 'int',
|
|
systemDefault: 0,
|
|
rules: ['required', 'integer', 'min:0', 'max:10080'],
|
|
normalizer: static fn (mixed $value): int => (int) $value,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, SettingDefinition>
|
|
*/
|
|
public function all(): array
|
|
{
|
|
return $this->definitions;
|
|
}
|
|
|
|
public function find(string $domain, string $key): ?SettingDefinition
|
|
{
|
|
return $this->definitions[$this->cacheKey($domain, $key)] ?? null;
|
|
}
|
|
|
|
public function require(string $domain, string $key): SettingDefinition
|
|
{
|
|
$definition = $this->find($domain, $key);
|
|
|
|
if ($definition instanceof SettingDefinition) {
|
|
return $definition;
|
|
}
|
|
|
|
throw new \InvalidArgumentException(sprintf('Unknown setting key: %s.%s', $domain, $key));
|
|
}
|
|
|
|
private function register(SettingDefinition $definition): void
|
|
{
|
|
$this->definitions[$this->cacheKey($definition->domain, $definition->key)] = $definition;
|
|
}
|
|
|
|
private function cacheKey(string $domain, string $key): string
|
|
{
|
|
return $domain.'.'.$key;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private static function supportedFindingSeverities(): array
|
|
{
|
|
return [
|
|
Finding::SEVERITY_LOW,
|
|
Finding::SEVERITY_MEDIUM,
|
|
Finding::SEVERITY_HIGH,
|
|
Finding::SEVERITY_CRITICAL,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function normalizeSeverityMapping(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($value as $findingType => $severity) {
|
|
if (! is_string($findingType) || trim($findingType) === '' || ! is_string($severity)) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[$findingType] = strtolower($severity);
|
|
}
|
|
|
|
ksort($normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private static function supportedBaselineChangeTypes(): array
|
|
{
|
|
return [
|
|
'different_version',
|
|
'missing_policy',
|
|
'unexpected_policy',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function defaultBaselineSeverityMapping(): array
|
|
{
|
|
return [
|
|
'different_version' => Finding::SEVERITY_MEDIUM,
|
|
'missing_policy' => Finding::SEVERITY_HIGH,
|
|
'unexpected_policy' => Finding::SEVERITY_LOW,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function normalizeBaselineSeverityMapping(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
$supportedKeys = array_fill_keys(self::supportedBaselineChangeTypes(), true);
|
|
|
|
foreach ($value as $changeType => $severity) {
|
|
if (! is_string($changeType) || ! isset($supportedKeys[$changeType]) || ! is_string($severity)) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[$changeType] = strtolower($severity);
|
|
}
|
|
|
|
$ordered = [];
|
|
|
|
foreach (self::defaultBaselineSeverityMapping() as $changeType => $_severity) {
|
|
if (array_key_exists($changeType, $normalized)) {
|
|
$ordered[$changeType] = $normalized[$changeType];
|
|
}
|
|
}
|
|
|
|
return $ordered;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private static function defaultFindingsSlaDays(): array
|
|
{
|
|
return [
|
|
Finding::SEVERITY_CRITICAL => 3,
|
|
Finding::SEVERITY_HIGH => 7,
|
|
Finding::SEVERITY_MEDIUM => 14,
|
|
Finding::SEVERITY_LOW => 30,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private static function normalizeFindingsSlaDays(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return self::defaultFindingsSlaDays();
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($value as $severity => $days) {
|
|
if (! is_string($severity)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedSeverity = strtolower($severity);
|
|
|
|
if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedDays = filter_var($days, FILTER_VALIDATE_INT);
|
|
|
|
if ($normalizedDays === false) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[$normalizedSeverity] = (int) $normalizedDays;
|
|
}
|
|
|
|
$ordered = [];
|
|
|
|
foreach (self::defaultFindingsSlaDays() as $severity => $_default) {
|
|
if (array_key_exists($severity, $normalized)) {
|
|
$ordered[$severity] = $normalized[$severity];
|
|
}
|
|
}
|
|
|
|
return $ordered;
|
|
}
|
|
}
|