Files
jabali-panel/app/Filament/Admin/Pages/Waf.php

512 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\Setting;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Str;
class Waf extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?int $navigationSort = 20;
protected static ?string $slug = 'waf';
protected string $view = 'filament.admin.pages.waf';
public bool $wafInstalled = false;
public array $wafFormData = [];
public array $auditEntries = [];
public bool $auditLoaded = false;
public function getTitle(): string|Htmlable
{
return __('ModSecurity / WAF');
}
public static function getNavigationLabel(): string
{
return __('ModSecurity / WAF');
}
public function mount(): void
{
$this->wafInstalled = $this->detectWaf();
$whitelistRaw = Setting::get('waf_whitelist_rules', '[]');
$whitelistRules = json_decode($whitelistRaw, true);
if (! is_array($whitelistRules)) {
$whitelistRules = [];
}
$this->wafFormData = [
'enabled' => Setting::get('waf_enabled', '0') === '1',
'paranoia' => Setting::get('waf_paranoia', '1'),
'audit_log' => Setting::get('waf_audit_log', '1') === '1',
'whitelist_rules' => $whitelistRules,
];
}
protected function detectWaf(): bool
{
$paths = [
'/etc/nginx/modsec/main.conf',
'/etc/nginx/modsecurity.conf',
'/etc/modsecurity/modsecurity.conf',
'/etc/modsecurity/modsecurity.conf-recommended',
];
foreach ($paths as $path) {
if (file_exists($path)) {
return true;
}
}
return false;
}
protected function getForms(): array
{
return ['wafForm'];
}
public function wafForm(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
{
return $schema
->statePath('wafFormData')
->schema([
Section::make(__('WAF Settings'))
->schema([
Toggle::make('enabled')
->label(__('Enable ModSecurity'))
->disabled(fn () => ! $this->wafInstalled)
->helperText(fn () => $this->wafInstalled ? null : __('ModSecurity is not installed. Install it to enable WAF.')),
Select::make('paranoia')
->label(__('Paranoia Level'))
->options([
'1' => '1 - Basic',
'2' => '2 - Moderate',
'3' => '3 - Strict',
'4' => '4 - Very Strict',
])
->default('1'),
Toggle::make('audit_log')
->label(__('Enable Audit Log')),
])
->columns(2),
Section::make(__('Whitelist Rules'))
->description(__('Exclude trusted traffic from specific ModSecurity rule IDs.'))
->schema([
Repeater::make('whitelist_rules')
->label(__('Whitelist Entries'))
->schema([
TextInput::make('label')
->label(__('Name'))
->placeholder(__('Admin API allowlist'))
->maxLength(80),
Select::make('match_type')
->label(__('Match Type'))
->options([
'ip' => __('IP Address or CIDR'),
'uri_exact' => __('Exact URI'),
'uri_prefix' => __('URI Prefix'),
'host' => __('Host Header'),
])
->required(),
TextInput::make('match_value')
->label(__('Match Value'))
->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php'))
->required(),
TextInput::make('rule_ids')
->label(__('Rule IDs'))
->placeholder(__('Example: 942100,949110'))
->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.'))
->required(),
Toggle::make('enabled')
->label(__('Enabled'))
->default(true),
])
->itemLabel(fn (array $state): ?string => $state['label'] ?? $state['match_value'] ?? null)
->addActionLabel(__('Add Whitelist Rule'))
->collapsible()
->columns(['default' => 2, 'md' => 2]),
])
->columns(1),
]);
}
public function saveWafSettings(): void
{
$data = $this->wafForm->getState();
$requestedEnabled = ! empty($data['enabled']);
if ($requestedEnabled && ! $this->wafInstalled) {
$requestedEnabled = false;
}
Setting::set('waf_enabled', $requestedEnabled ? '1' : '0');
Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1'));
Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0');
Setting::set('waf_whitelist_rules', json_encode(array_values($data['whitelist_rules'] ?? []), JSON_UNESCAPED_SLASHES));
try {
$agent = new AgentClient;
$agent->wafApplySettings(
$requestedEnabled,
(string) ($data['paranoia'] ?? '1'),
! empty($data['audit_log']),
$data['whitelist_rules'] ?? []
);
if (! $this->wafInstalled && ! empty($data['enabled'])) {
Notification::make()
->title(__('ModSecurity is not installed'))
->body(__('WAF was disabled automatically. Install ModSecurity to enable it.'))
->warning()
->send();
return;
}
Notification::make()
->title(__('WAF settings applied'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('WAF settings saved, but apply failed'))
->body($e->getMessage())
->warning()
->send();
}
}
public function loadAuditLogs(bool $notify = true): void
{
try {
$agent = new AgentClient;
$response = $agent->wafAuditLogList();
$entries = $response['entries'] ?? [];
if (! is_array($entries)) {
$entries = [];
}
$this->auditEntries = $this->markWhitelisted($entries);
$this->auditLoaded = true;
if ($notify) {
Notification::make()
->title(__('WAF logs refreshed'))
->success()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to load WAF logs'))
->body($e->getMessage())
->warning()
->send();
}
}
protected function getWhitelistRules(): array
{
$raw = Setting::get('waf_whitelist_rules', '[]');
$rules = json_decode($raw, true);
return is_array($rules) ? $rules : [];
}
protected function markWhitelisted(array $entries): array
{
$rules = $this->getWhitelistRules();
foreach ($entries as &$entry) {
$entry['whitelisted'] = $this->matchesWhitelist($entry, $rules);
}
return $entries;
}
protected function matchesWhitelist(array $entry, array $rules): bool
{
$ruleId = (string) ($entry['rule_id'] ?? '');
$uri = (string) ($entry['uri'] ?? '');
$host = (string) ($entry['host'] ?? '');
$ip = (string) ($entry['remote_ip'] ?? '');
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
if (isset($rule['enabled']) && !$rule['enabled']) {
continue;
}
$idsRaw = (string) ($rule['rule_ids'] ?? '');
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$ids = array_map('trim', $ids);
if ($ruleId !== '' && !empty($ids) && !in_array($ruleId, $ids, true)) {
continue;
}
$matchType = (string) ($rule['match_type'] ?? '');
$matchValue = (string) ($rule['match_value'] ?? '');
if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) {
return true;
}
if ($matchType === 'uri_exact' && $matchValue !== '' && $uri === $matchValue) {
return true;
}
if ($matchType === 'uri_prefix' && $matchValue !== '' && str_starts_with($uri, $matchValue)) {
return true;
}
if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) {
return true;
}
}
return false;
}
protected function ipMatches(string $ip, string $rule): bool
{
if ($ip === '' || $rule === '') {
return false;
}
if (! str_contains($rule, '/')) {
return $ip === $rule;
}
[$subnet, $bits] = array_pad(explode('/', $rule, 2), 2, null);
$bits = is_numeric($bits) ? (int) $bits : null;
if ($bits === null || $bits < 0 || $bits > 32) {
return $ip === $rule;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return $ip === $rule;
}
$mask = -1 << (32 - $bits);
return ($ipLong & $mask) === ($subnetLong & $mask);
}
public function whitelistEntry(array $record): void
{
$rules = $this->getWhitelistRules();
$matchType = 'uri_exact';
$matchValue = (string) ($record['uri'] ?? '');
if ($matchValue === '') {
$matchType = 'ip';
$matchValue = (string) ($record['remote_ip'] ?? '');
}
if ($matchValue === '' || empty($record['rule_id'])) {
Notification::make()
->title(__('Unable to whitelist entry'))
->body(__('Missing URI/IP or rule ID for this entry.'))
->warning()
->send();
return;
}
$rules[] = [
'label' => __('Whitelist rule {rule}', ['rule' => $record['rule_id'] ?? '']),
'match_type' => $matchType,
'match_value' => $matchValue,
'rule_ids' => $record['rule_id'] ?? '',
'enabled' => true,
];
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
$this->wafFormData['whitelist_rules'] = $rules;
try {
$agent = new AgentClient;
$agent->wafApplySettings(
Setting::get('waf_enabled', '0') === '1',
(string) Setting::get('waf_paranoia', '1'),
Setting::get('waf_audit_log', '1') === '1',
$rules
);
} catch (Exception $e) {
Notification::make()
->title(__('Whitelist saved, but apply failed'))
->body($e->getMessage())
->warning()
->send();
}
$this->auditEntries = $this->markWhitelisted($this->auditEntries);
Notification::make()
->title(__('Rule whitelisted'))
->success()
->send();
}
public function table(Table $table): Table
{
return $table
->paginated([25, 50, 100])
->defaultPaginationPageOption(25)
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
if (! $this->auditLoaded) {
$this->loadAuditLogs(false);
}
$records = $this->auditEntries;
$records = $this->filterRecords($records, $search);
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
return $this->paginateRecords($records, $page, $recordsPerPage);
})
->columns([
TextColumn::make('timestamp')
->label(__('Time'))
->formatStateUsing(function (array $record): string {
$timestamp = (int) ($record['timestamp'] ?? 0);
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
})
->sortable(),
TextColumn::make('rule_id')
->label(__('Rule ID'))
->fontFamily('mono')
->sortable()
->searchable(),
TextColumn::make('message')
->label(__('Message'))
->wrap()
->limit(80)
->searchable(),
TextColumn::make('uri')
->label(__('URI'))
->wrap()
->searchable(),
TextColumn::make('remote_ip')
->label(__('IP'))
->fontFamily('mono')
->toggleable(),
TextColumn::make('host')
->label(__('Host'))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('whitelisted')
->label(__('Whitelisted'))
->badge()
->formatStateUsing(fn (array $record): string => !empty($record['whitelisted']) ? __('Yes') : __('No'))
->color(fn (array $record): string => !empty($record['whitelisted']) ? 'success' : 'gray'),
])
->recordActions([
\Filament\Actions\Action::make('whitelist')
->label(__('Whitelist'))
->icon('heroicon-o-check-badge')
->color('primary')
->visible(fn (array $record): bool => empty($record['whitelisted']))
->action(fn (array $record) => $this->whitelistEntry($record)),
])
->emptyStateHeading(__('No blocked rules found'))
->emptyStateDescription(__('No ModSecurity denials found in the audit log.'))
->headerActions([
\Filament\Actions\Action::make('refresh')
->label(__('Refresh Logs'))
->icon('heroicon-o-arrow-path')
->action(fn () => $this->loadAuditLogs()),
]);
}
protected function filterRecords(array $records, ?string $search): array
{
if (! $search) {
return $records;
}
return array_values(array_filter($records, function (array $record) use ($search) {
$haystack = implode(' ', array_filter([
(string) ($record['rule_id'] ?? ''),
(string) ($record['message'] ?? ''),
(string) ($record['uri'] ?? ''),
(string) ($record['remote_ip'] ?? ''),
(string) ($record['host'] ?? ''),
]));
return str_contains(Str::lower($haystack), Str::lower($search));
}));
}
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
{
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
if (! $sortColumn) {
return $records;
}
usort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
$aValue = $a[$sortColumn] ?? null;
$bValue = $b[$sortColumn] ?? null;
if (is_numeric($aValue) && is_numeric($bValue)) {
$result = (float) $aValue <=> (float) $bValue;
} else {
$result = strcmp((string) $aValue, (string) $bValue);
}
return $direction === 'asc' ? $result : -$result;
});
return $records;
}
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
{
$page = max(1, (int) $page);
$perPage = max(1, (int) $recordsPerPage);
$total = count($records);
$items = array_slice($records, ($page - 1) * $perPage, $perPage);
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
[
'path' => request()->url(),
'pageName' => $this->getTablePaginationPageName(),
],
);
}
}