Files
jabali-panel/app/Filament/Admin/Pages/Security.php
2026-02-06 17:00:13 +00:00

2695 lines
114 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\Security\BannedIpsTable;
use App\Filament\Admin\Widgets\Security\Fail2banLogsTable;
use App\Filament\Admin\Widgets\Security\JailsTable;
use App\Filament\Admin\Widgets\Security\LynisResultsTable;
use App\Filament\Admin\Widgets\Security\NiktoResultsTable;
use App\Filament\Admin\Widgets\Security\QuarantinedFilesTable;
use App\Filament\Admin\Widgets\Security\ThreatsTable;
use App\Filament\Admin\Widgets\Security\WpscanResultsTable;
use App\Models\AuditLog;
use App\Models\Setting;
use App\Models\User;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Action as FormAction;
use Filament\Actions\Action as TableAction;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
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\Actions as FormActions;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Alignment;
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 Livewire\Attributes\On;
use Livewire\Attributes\Url;
class Security extends Page implements HasActions, HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?int $navigationSort = 5;
protected static ?string $slug = 'security';
protected string $view = 'filament.admin.pages.security';
protected ?AgentClient $agent = null;
// Firewall
public bool $firewallInstalled = false;
public bool $firewallEnabled = false;
public array $firewallRules = [];
public string $defaultIncoming = 'deny';
public string $defaultOutgoing = 'allow';
public string $firewallStatusText = '';
public ?int $ruleToDelete = null;
// WAF
public bool $wafInstalled = false;
public bool $wafEnabled = false;
// Fail2ban
public bool $fail2banInstalled = false;
public bool $fail2banRunning = false;
public string $fail2banVersion = '';
public array $jails = [];
public array $availableJails = [];
public ?int $totalBanned = null;
public array $fail2banLogs = [];
public int $maxRetry = 5;
public int $banTime = 600;
public int $findTime = 600;
// ClamAV
public bool $clamavInstalled = false;
public bool $clamavRunning = false;
public string $clamavVersion = '';
public int $signatureCount = 0;
public string $lastUpdate = '';
public bool $clamavLightMode = false;
public array $signatureDatabases = [];
public array $recentThreats = [];
public array $quarantinedFiles = [];
public bool $realtimeEnabled = false;
public bool $realtimeRunning = false;
// SSH Settings
public bool $sshPasswordAuth = false;
public bool $sshPubkeyAuth = true;
public int $sshPort = 22;
public bool $sshShellAccessEnabled = true;
// Scanner properties
public bool $lynisInstalled = false;
public bool $wpscanInstalled = false;
public bool $niktoInstalled = false;
public string $lynisVersion = '';
public string $wpscanVersion = '';
public string $niktoVersion = '';
public array $lynisResults = [];
public array $wpscanResults = [];
public array $niktoResults = [];
public bool $isScanning = false;
public string $currentScan = '';
public string $scanOutput = '';
public ?string $selectedWpSiteId = null;
public ?array $data = [];
public ?string $lastLynisScan = null;
public ?string $lastWpscanScan = null;
public ?string $lastNiktoScan = null;
// ClamAV on-demand scan properties
public ?string $selectedClamUser = null;
public array $clamScanResults = [];
public ?string $lastClamScan = null;
#[Url(as: 'tab')]
public ?string $activeTab = 'overview';
public static function getNavigationLabel(): string
{
return __('Security');
}
public function getTitle(): string|Htmlable
{
return __('Security Center');
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
protected function normalizeTabName(?string $tab): string
{
return match ($tab) {
'overview', 'firewall', 'waf', 'fail2ban', 'antivirus', 'ssh', 'scanner' => $tab,
default => 'overview',
};
}
public function setTab(string $tab): void
{
$this->activeTab = $this->normalizeTabName($tab);
if ($this->activeTab === 'fail2ban') {
$this->loadFail2banStatus();
}
if ($this->activeTab === 'antivirus') {
$this->loadClamavStatus();
$this->loadClamScanResults();
}
if ($this->activeTab === 'scanner') {
$this->checkScannerToolStatus();
$this->loadLastScans();
}
}
public function updatedActiveTab(): void
{
$this->setTab($this->activeTab ?? 'overview');
}
public function mount(): void
{
$this->activeTab = $this->normalizeTabName($this->activeTab);
$this->loadFirewallStatus();
$this->loadWafStatus();
$this->loadFail2banStatusLight();
$this->loadClamavStatusLight();
$this->loadSshSettings();
$this->data = [
'selectedWpSiteId' => null,
'selectedClamUser' => null,
];
if ($this->activeTab === 'fail2ban') {
$this->loadFail2banStatus();
} elseif ($this->activeTab === 'antivirus') {
$this->loadClamavStatus();
$this->loadClamScanResults();
} elseif ($this->activeTab === 'scanner') {
$this->checkScannerToolStatus();
$this->loadLastScans();
}
}
#[On('refresh-security-data')]
public function refreshSecurityData(): void
{
$this->loadWafStatus();
$this->loadFail2banStatus();
$this->loadClamavStatus();
}
protected function getForms(): array
{
return [
'securityForm',
];
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->firewallRules)
->columns([
TextColumn::make('number')
->label(__('#'))
->sortable()
->width('60px'),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => match (strtoupper($state)) {
'ALLOW' => 'success',
'DENY' => 'danger',
'LIMIT' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => strtoupper($state)),
TextColumn::make('to')
->label(__('To'))
->default('-'),
TextColumn::make('from')
->label(__('From'))
->default(__('Anywhere'))
->formatStateUsing(fn (?string $state): string => $state ?: __('Anywhere')),
TextColumn::make('direction')
->label(__('Direction'))
->badge()
->color('gray'),
])
->recordAction(null)
->recordUrl(null)
->striped()
->emptyStateHeading(__('No firewall rules'))
->emptyStateDescription(__('Use the buttons above to add rules.'))
->emptyStateIcon('heroicon-o-shield-exclamation')
->actions([
TableAction::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(fn (array $record) => $this->deleteRule($record['number'] ?? null)),
]);
}
public function securityForm(Schema $schema): Schema
{
return $schema
->schema([
Tabs::make(__('Security Sections'))
->contained()
->livewireProperty('activeTab')
->tabs([
'overview' => Tab::make(__('Overview'))
->icon('heroicon-o-home')
->schema($this->overviewTabContent()),
'firewall' => Tab::make(__('Firewall'))
->icon('heroicon-o-shield-check')
->schema($this->firewallTabContent()),
'waf' => Tab::make(__('ModSecurity / WAF'))
->icon('heroicon-o-shield-exclamation')
->schema($this->wafTabContent()),
'fail2ban' => Tab::make(__('Fail2ban'))
->icon('heroicon-o-lock-closed')
->schema($this->fail2banTabContent()),
'antivirus' => Tab::make(__('Antivirus'))
->icon('heroicon-o-bug-ant')
->schema($this->antivirusTabContent()),
'ssh' => Tab::make(__('SSH'))
->icon('heroicon-o-command-line')
->schema($this->sshTabContent()),
'scanner' => Tab::make(__('Vulnerability Scanner'))
->icon('heroicon-o-magnifying-glass-circle')
->schema($this->scannerTabContent()),
]),
]);
}
protected function wafTabContent(): array
{
return [
View::make('filament.admin.components.security-waf'),
];
}
protected function overviewTabContent(): array
{
return [
Grid::make(['default' => 1, 'sm' => 3])
->schema([
Section::make($this->firewallEnabled ? __('Active') : __('Inactive'))
->description(__('Firewall'))
->icon('heroicon-o-shield-check')
->iconColor($this->firewallEnabled ? 'success' : 'danger'),
Section::make($this->wafInstalled
? ($this->wafEnabled ? __('Enabled') : __('Disabled'))
: __('Not Installed'))
->description(__('WAF'))
->icon('heroicon-o-shield-exclamation')
->iconColor($this->wafInstalled
? ($this->wafEnabled ? 'success' : 'danger')
: 'gray'),
Section::make($this->fail2banRunning ? __('Running') : __('Stopped'))
->description(__('Fail2ban'))
->icon('heroicon-o-lock-closed')
->iconColor($this->fail2banRunning ? 'success' : 'danger'),
Section::make($this->totalBanned !== null ? (string) $this->totalBanned : __('N/A'))
->description(__('IPs Banned'))
->icon('heroicon-o-lock-closed')
->iconColor($this->fail2banRunning ? 'success' : 'danger'),
Section::make((string) count($this->recentThreats))
->description(__('Threats Detected'))
->icon('heroicon-o-bug-ant')
->iconColor($this->clamavInstalled ? 'success' : 'gray'),
Section::make($this->clamavRunning ? __('Running') : __('Stopped'))
->description(__('Antivirus'))
->icon('heroicon-o-shield-check')
->iconColor($this->clamavRunning ? 'success' : 'danger'),
]),
Section::make(__('Security Recommendations'))
->icon('heroicon-o-light-bulb')
->schema(fn () => [
View::make('filament.admin.components.security-recommendations'),
]),
Section::make(__('Quick Actions'))
->icon('heroicon-o-bolt')
->schema([
FormActions::make([
FormAction::make('installFirewall')
->label(__('Install Firewall'))
->action('installFirewall')
->visible(fn () => ! $this->firewallInstalled),
FormAction::make('installFail2ban')
->label(__('Install Fail2ban'))
->action('installFail2ban')
->visible(fn () => ! $this->fail2banInstalled),
FormAction::make('installClamav')
->label(__('Install Antivirus'))
->action('installClamav')
->visible(fn () => ! $this->clamavInstalled),
])->visible(fn () => ! $this->firewallInstalled || ! $this->fail2banInstalled || ! $this->clamavInstalled),
Text::make(__('All essential security tools are installed.'))
->visible(fn () => $this->firewallInstalled && $this->fail2banInstalled),
]),
Section::make(__('Recent Audit Logs'))
->icon('heroicon-o-clipboard-document-list')
->schema([
EmbeddedTable::make(\App\Filament\Admin\Widgets\Security\AuditLogsTable::class),
]),
];
}
protected function firewallTabContent(): array
{
return [
// Not installed state
Section::make()
->schema([
Text::make(__('UFW Firewall is not installed.')),
FormActions::make([
FormAction::make('installFirewall')
->label(__('Install Firewall'))
->action('installFirewall'),
])->alignment(Alignment::Center),
])
->visible(fn () => ! $this->firewallInstalled),
// Installed state
Group::make([
Section::make(__('Firewall Status'))
->icon('heroicon-o-shield-check')
->iconColor($this->firewallEnabled ? 'success' : 'danger')
->schema([
Grid::make(['default' => 1, 'sm' => 2, 'lg' => 4])
->schema([
Section::make($this->firewallEnabled ? __('ACTIVE') : __('INACTIVE'))
->description(__('Status'))
->icon('heroicon-o-signal')
->iconColor($this->firewallEnabled ? 'success' : 'danger'),
Section::make(strtoupper($this->defaultIncoming))
->description(__('Default Incoming'))
->icon('heroicon-o-arrow-down-circle')
->iconColor($this->defaultIncoming === 'deny' ? 'success' : 'warning'),
Section::make(strtoupper($this->defaultOutgoing))
->description(__('Default Outgoing'))
->icon('heroicon-o-arrow-up-circle')
->iconColor($this->defaultOutgoing === 'allow' ? 'success' : 'warning'),
Section::make((string) count($this->firewallRules))
->description(__('Active Rules'))
->icon('heroicon-o-queue-list')
->iconColor('gray'),
]),
FormActions::make([
FormAction::make('toggleFirewall')
->label(fn () => $this->firewallEnabled ? __('Disable Firewall') : __('Enable Firewall'))
->icon(fn () => $this->firewallEnabled ? 'heroicon-o-x-circle' : 'heroicon-o-shield-check')
->color(fn () => $this->firewallEnabled ? 'danger' : 'success')
->action('toggleFirewall'),
FormAction::make('reloadFirewall')
->label(__('Reload'))
->icon('heroicon-o-arrow-path')
->color('gray')
->outlined()
->action('reloadFirewall'),
FormAction::make('resetFirewall')
->label(__('Reset All'))
->icon('heroicon-o-trash')
->color('danger')
->outlined()
->action('resetFirewall'),
]),
]),
Section::make(__('Add Rule'))
->icon('heroicon-o-plus-circle')
->schema([
FormActions::make([
FormAction::make('allowPort')
->label(__('Allow Port'))
->icon('heroicon-o-plus-circle')
->color('success')
->action('openAllowPort'),
FormAction::make('denyPort')
->label(__('Block Port'))
->icon('heroicon-o-x-circle')
->color('danger')
->action('openDenyPort'),
FormAction::make('allowIp')
->label(__('Allow IP'))
->icon('heroicon-o-check-circle')
->color('success')
->action('openAllowIp'),
FormAction::make('denyIp')
->label(__('Block IP'))
->icon('heroicon-o-no-symbol')
->color('danger')
->action('openDenyIp'),
FormAction::make('allowService')
->label(__('Allow Service'))
->icon('heroicon-o-server')
->color('info')
->action('openAllowService'),
FormAction::make('limitPort')
->label(__('Rate Limit'))
->icon('heroicon-o-clock')
->color('warning')
->action('openLimitPort'),
]),
]),
Section::make(__('Firewall Rules'))
->icon('heroicon-o-queue-list')
->schema([
EmbeddedTable::make(),
]),
Section::make(__('Quick Tips'))
->icon('heroicon-o-light-bulb')
->collapsible()
->collapsed()
->schema([
Text::make(__('Allow Port: Opens a port for all incoming connections (e.g., 80 for web, 443 for HTTPS)')),
Text::make(__('Block IP: Denies all traffic from a specific IP address or subnet')),
Text::make(__('Rate Limit: Limits connections to prevent brute-force attacks (6 connections in 30 seconds)')),
Text::make(__('Default Policy: Controls what happens to traffic that doesn\'t match any rule')),
Text::make(__('Important: Always ensure SSH (port 22) is allowed before enabling the firewall!')),
]),
])->visible(fn () => $this->firewallInstalled),
];
}
protected function fail2banTabContent(): array
{
return [
// Not installed state
Section::make()
->schema([
Text::make(__('Fail2ban is not installed.')),
FormActions::make([
FormAction::make('installFail2ban')
->label(__('Install Fail2ban'))
->action('installFail2ban'),
])->alignment(Alignment::Center),
])
->visible(fn () => ! $this->fail2banInstalled),
// Installed state
Group::make([
Section::make(__('Fail2ban Status'))
->icon('heroicon-o-lock-closed')
->headerActions([
FormAction::make('fail2banStatus')
->label(fn () => $this->fail2banRunning ? __('Running') : __('Stopped'))
->color(fn () => $this->fail2banRunning ? 'success' : 'danger')
->badge(),
FormAction::make('startFail2ban')
->label(__('Start'))
->color('success')
->size('sm')
->visible(fn () => ! $this->fail2banRunning)
->action('startFail2ban'),
FormAction::make('disableFail2ban')
->label(__('Disable'))
->color('warning')
->size('sm')
->visible(fn () => $this->fail2banRunning)
->requiresConfirmation()
->modalHeading(__('Disable Fail2ban'))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(__('Warning: Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
->action('disableFail2ban'),
])
->schema([
Grid::make(['default' => 1, 'md' => 3])
->schema([
TextInput::make('maxRetry')
->label(__('Max Retry'))
->numeric()
->minValue(1)
->maxValue(20),
TextInput::make('banTime')
->label(__('Ban Time (seconds)'))
->numeric()
->minValue(60),
TextInput::make('findTime')
->label(__('Find Time (seconds)'))
->numeric()
->minValue(60),
]),
FormActions::make([
FormAction::make('saveFail2banSettings')
->label(__('Save Settings'))
->action('saveFail2banSettings'),
]),
]),
Section::make(__('Protection Modules'))
->icon('heroicon-o-shield-exclamation')
->description(__('Enable or disable protection modules for different services.'))
->schema([
EmbeddedTable::make(JailsTable::class, ['jails' => $this->availableJails])
->key('fail2ban-jails-table'),
]),
Section::make(__('Banned IPs').' ('.($this->totalBanned ?? __('N/A')).')')
->icon('heroicon-o-no-symbol')
->schema([
EmbeddedTable::make(BannedIpsTable::class, ['jails' => $this->jails]),
])
->visible(fn () => ($this->totalBanned ?? 0) > 0),
Section::make(__('Fail2ban Logs'))
->icon('heroicon-o-document-text')
->schema([
EmbeddedTable::make(Fail2banLogsTable::class, ['logs' => $this->fail2banLogs])
->key('fail2ban-logs-table'),
])
->collapsible(),
])->visible(fn () => $this->fail2banInstalled),
];
}
protected function antivirusTabContent(): array
{
return [
// Warning for non-installed
Section::make(__('Memory Requirements'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning')
->description(__('ClamAV requires significant memory (~500MB+). Only install if your server has at least 2GB RAM available.'))
->visible(fn () => ! $this->clamavInstalled),
// Not installed state
Section::make()
->schema([
Text::make(__('ClamAV Antivirus is not installed.')),
FormActions::make([
FormAction::make('installClamav')
->label(__('Install ClamAV'))
->action('installClamav')
->requiresConfirmation()
->modalDescription(__('ClamAV uses significant memory. Are you sure you want to install it?')),
])->alignment(Alignment::Center),
])
->visible(fn () => ! $this->clamavInstalled),
// Installed state
Group::make([
Section::make(__('Resource Warning'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning')
->description(__('ClamAV uses significant memory (~500MB+) and CPU resources. Running the daemon or real-time protection on low-resource servers may impact performance. Consider using on-demand scanning instead.')),
Section::make(__('ClamAV Status'))
->icon('heroicon-o-shield-check')
->headerActions([
FormAction::make('daemonStatus')
->label(fn () => __('Daemon').' '.($this->clamavRunning ? __('Running') : __('Stopped')))
->color(fn () => $this->clamavRunning ? 'success' : 'gray')
->badge(),
FormAction::make('toggleClamav')
->label(fn () => $this->clamavRunning ? __('Disable') : __('Enable'))
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
->size('sm')
->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav())
->requiresConfirmation()
->modalHeading(fn () => $this->clamavRunning ? __('Disable ClamAV') : __('Enable ClamAV'))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(fn () => $this->clamavRunning
? __('Warning: This will stop and disable ClamAV. You can re-enable it later.')
: __('Starting ClamAV daemon uses ~500MB RAM. Continue?')),
FormAction::make('updateSignatures')
->label(__('Update Signatures'))
->color('gray')
->size('sm')
->action('updateSignatures'),
])
->schema([
Grid::make(['default' => 1, 'md' => 3])
->schema([
Section::make($this->clamavVersion ?: __('Unknown'))
->description(__('Version'))
->icon('heroicon-o-code-bracket')
->iconColor('gray'),
Section::make(number_format($this->signatureCount))
->description(__('Signatures'))
->icon('heroicon-o-document-text')
->iconColor('gray'),
Section::make($this->lastUpdate ?: __('Unknown'))
->description(__('Last Update'))
->icon('heroicon-o-clock')
->iconColor('gray'),
]),
Grid::make(['default' => 1, 'md' => 2])
->schema([
Section::make(__('Real-time Protection'))
->description(__('Monitors /home for new PHP, HTML, JS files'))
->icon($this->realtimeRunning ? 'heroicon-o-shield-check' : 'heroicon-o-shield-exclamation')
->iconColor($this->realtimeRunning ? 'success' : 'gray')
->schema([
FormActions::make([
FormAction::make('toggleRealtime')
->label(fn () => $this->realtimeRunning ? __('Disable') : __('Enable'))
->color(fn () => $this->realtimeRunning ? 'danger' : 'success')
->size('sm')
->action('toggleRealtime'),
]),
]),
Section::make(__('Database Mode'))
->description(fn () => $this->clamavLightMode
? __('Web hosting only: PHP, email, scripts (~50K sigs)')
: __('Full database: all malware signatures'))
->icon($this->clamavLightMode ? 'heroicon-o-bolt' : 'heroicon-o-circle-stack')
->iconColor($this->clamavLightMode ? 'warning' : 'gray')
->schema([
FormActions::make([
FormAction::make('toggleLightMode')
->label(fn () => $this->clamavLightMode ? __('Full') : __('Light'))
->color(fn () => $this->clamavLightMode ? 'warning' : 'gray')
->size('sm')
->action('toggleLightMode')
->requiresConfirmation()
->modalDescription(fn () => $this->clamavLightMode
? __('Switch to full database? This will download ~400MB of signatures.')
: __('Switch to lightweight mode? This reduces signatures to web hosting essentials only.')),
]),
]),
]),
]),
Section::make(__('On-Demand Scanner'))
->icon('heroicon-o-magnifying-glass')
->description(__('Manually scan user directories or the entire server for malware and threats.'))
->schema([
Grid::make(['default' => 1, 'md' => 2])
->schema([
Section::make(__('Scan User Directory'))
->description(__('Scan a specific user\'s home directory for malware.'))
->icon('heroicon-o-user')
->iconColor('gray')
->schema([
Select::make('selectedClamUser')
->label(__('Select User'))
->options(fn () => $this->getClamScanUsers())
->placeholder(__('Select a user to scan'))
->searchable()
->live(),
FormActions::make([
FormAction::make('runClamScanUser')
->label(fn () => $this->isScanning && $this->currentScan === 'clamav' ? __('Scanning...') : __('Scan User'))
->icon('heroicon-o-play')
->action('runClamScanUser')
->disabled(fn () => $this->isScanning || ! $this->selectedClamUser),
]),
]),
Section::make(__('Server-Wide Scan'))
->description(__('Scan all user directories (/home). This may take a long time.'))
->icon('heroicon-o-server')
->iconColor('warning')
->schema([
Text::make(__('Server-wide scans can take 30+ minutes depending on data size.')),
FormActions::make([
FormAction::make('runClamScanServer')
->label(fn () => $this->isScanning && $this->currentScan === 'clamav' ? __('Scanning...') : __('Scan All Users'))
->icon('heroicon-o-server')
->color('warning')
->action('runClamScanServer')
->disabled(fn () => $this->isScanning)
->requiresConfirmation()
->modalDescription(__('This will scan all user directories and may take a long time. Continue?')),
]),
]),
]),
Text::make($this->lastClamScan ? __('Last scan:').' '.$this->lastClamScan : '')
->visible(fn () => (bool) $this->lastClamScan),
]),
Section::make(__('Scan Output'))
->icon('heroicon-o-command-line')
->collapsible()
->schema(fn () => $this->buildClamScanOutputSchema())
->visible(fn () => $this->isScanning && $this->currentScan === 'clamav' || ! empty($this->clamScanResults['raw_output'] ?? '')),
Section::make(__('Scan Results'))
->icon('heroicon-o-document-chart-bar')
->collapsible()
->schema(fn () => $this->buildClamavScanResultsSchema())
->visible(fn () => ! empty($this->clamScanResults)),
Section::make(__('Quarantined Files').' ('.count($this->quarantinedFiles).')')
->icon('heroicon-o-archive-box')
->schema([
EmbeddedTable::make(QuarantinedFilesTable::class, ['files' => $this->quarantinedFiles]),
])
->visible(fn () => count($this->quarantinedFiles) > 0),
Section::make(__('Recent Threats'))
->icon('heroicon-o-exclamation-triangle')
->schema([
EmbeddedTable::make(ThreatsTable::class, ['threats' => $this->recentThreats]),
])
->visible(fn () => count($this->recentThreats) > 0),
])->visible(fn () => $this->clamavInstalled),
];
}
protected function sshTabContent(): array
{
return [
// Current Configuration - 3 widgets on top
Grid::make(['default' => 1, 'sm' => 3])
->schema([
Section::make($this->sshPasswordAuth ? __('Enabled') : __('Disabled'))
->description(__('Password Auth'))
->icon('heroicon-o-key')
->iconColor($this->sshPasswordAuth ? 'warning' : 'success'),
Section::make($this->sshPubkeyAuth ? __('Enabled') : __('Disabled'))
->description(__('Public Key Auth'))
->icon('heroicon-o-finger-print')
->iconColor($this->sshPubkeyAuth ? 'success' : 'danger'),
Section::make((string) $this->sshPort)
->description(__('SSH Port'))
->icon('heroicon-o-server')
->iconColor($this->sshPort != 22 ? 'success' : 'gray'),
]),
Section::make(__('Important Notice'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning')
->description(__('Changing SSH settings can lock you out of the server. Ensure you have console access or an active SSH session before disabling password authentication.')),
Section::make(__('SSH Authentication Settings'))
->icon('heroicon-o-command-line')
->schema([
Toggle::make('sshPasswordAuth')
->label(__('Password Authentication'))
->helperText(__('Allow users to log in using passwords. Disable for key-only access.')),
Toggle::make('sshPubkeyAuth')
->label(__('Public Key Authentication'))
->helperText(__('Allow users to log in using SSH keys. Recommended for security.')),
Toggle::make('sshShellAccessEnabled')
->label(__('Terminal Access (Shell)'))
->helperText(__('Allow users to enable SSH shell access from the user panel. wp-cli is available in the jailed shell.')),
TextInput::make('sshPort')
->label(__('SSH Port'))
->numeric()
->minValue(1)
->maxValue(65535)
->helperText(__('Default is 22. Changing port can help reduce automated attacks.'))
->columnSpan(1),
FormActions::make([
FormAction::make('saveSshSettings')
->label(__('Save SSH Settings'))
->action('saveSshSettings')
->requiresConfirmation()
->modalDescription(__('Are you sure? This will restart the SSH service immediately.')),
]),
]),
];
}
protected function scannerTabContent(): array
{
return [
Grid::make(['default' => 1, 'md' => 3])
->schema([
// Lynis
Section::make(__('Lynis'))
->icon('heroicon-o-shield-check')
->description(__('System security auditing tool. Checks configurations, permissions, and hardening settings.'))
->headerActions([
FormAction::make('lynisStatus')
->label(fn () => $this->lynisInstalled ? __('Installed') : __('Not Installed'))
->color(fn () => $this->lynisInstalled ? 'success' : 'danger')
->badge(),
])
->schema([
Group::make([
Text::make($this->lynisVersion)
->visible(fn () => $this->lynisInstalled && $this->lynisVersion),
Text::make($this->lastLynisScan ? __('Last:').' '.$this->lastLynisScan : '')
->visible(fn () => $this->lynisInstalled && $this->lastLynisScan),
])->visible(fn () => $this->lynisInstalled),
FormActions::make([
FormAction::make('runLynisScan')
->label(fn () => $this->isScanning && $this->currentScan === 'lynis' ? __('Scanning...') : __('Run System Audit'))
->icon('heroicon-o-play')
->color('success')
->action('runLynisScan')
->disabled(fn () => $this->isScanning),
])->visible(fn () => $this->lynisInstalled),
FormActions::make([
FormAction::make('installLynis')
->label(__('Install Lynis'))
->icon('heroicon-o-arrow-down-tray')
->action('installLynis'),
])->visible(fn () => ! $this->lynisInstalled),
]),
// WPScan
Section::make(__('WPScan'))
->icon('heroicon-o-globe-alt')
->description(__('WordPress vulnerability scanner. Checks for vulnerable plugins, themes, and core issues.'))
->headerActions([
FormAction::make('wpscanStatus')
->label(fn () => $this->wpscanInstalled ? __('Installed') : __('Not Installed'))
->color(fn () => $this->wpscanInstalled ? 'success' : 'danger')
->badge(),
])
->schema([
Group::make([
Text::make($this->wpscanVersion)
->visible(fn () => $this->wpscanInstalled && $this->wpscanVersion),
Text::make($this->lastWpscanScan ? __('Last:').' '.$this->lastWpscanScan : '')
->visible(fn () => $this->wpscanInstalled && $this->lastWpscanScan),
Select::make('selectedWpSiteId')
->label(__('WordPress Site'))
->options(fn () => $this->getLocalWordPressSites())
->placeholder(__('Select a WordPress site'))
->searchable()
->live()
->visible(fn () => count($this->getLocalWordPressSites()) > 0),
Text::make(__('No WordPress sites found'))
->visible(fn () => count($this->getLocalWordPressSites()) === 0),
])->visible(fn () => $this->wpscanInstalled),
FormActions::make([
FormAction::make('runWpscanOnSite')
->label(fn () => $this->isScanning && $this->currentScan === 'wpscan' ? __('Scanning...') : __('Scan WordPress Site'))
->icon('heroicon-o-play')
->color('info')
->action('runWpscanOnSite')
->disabled(fn () => $this->isScanning || ! $this->selectedWpSiteId),
])->visible(fn () => $this->wpscanInstalled && count($this->getLocalWordPressSites()) > 0),
FormActions::make([
FormAction::make('installWpscan')
->label(__('Install WPScan'))
->icon('heroicon-o-arrow-down-tray')
->action('installWpscan'),
])->visible(fn () => ! $this->wpscanInstalled),
]),
// Nikto
Section::make(__('Nikto'))
->icon('heroicon-o-server')
->description(__('Web server scanner. Finds server misconfigurations and known vulnerabilities on localhost.'))
->headerActions([
FormAction::make('niktoStatus')
->label(fn () => $this->niktoInstalled ? __('Installed') : __('Not Installed'))
->color(fn () => $this->niktoInstalled ? 'success' : 'danger')
->badge(),
])
->schema([
Group::make([
Text::make($this->niktoVersion)
->visible(fn () => $this->niktoInstalled && $this->niktoVersion),
Text::make($this->lastNiktoScan ? __('Last:').' '.$this->lastNiktoScan : '')
->visible(fn () => $this->niktoInstalled && $this->lastNiktoScan),
])->visible(fn () => $this->niktoInstalled),
FormActions::make([
FormAction::make('runNiktoScan')
->label(fn () => $this->isScanning && $this->currentScan === 'nikto' ? __('Scanning...') : __('Scan Local Server'))
->icon('heroicon-o-play')
->color('warning')
->action('runNiktoScan')
->disabled(fn () => $this->isScanning),
])->visible(fn () => $this->niktoInstalled),
FormActions::make([
FormAction::make('installNikto')
->label(__('Install Nikto'))
->icon('heroicon-o-arrow-down-tray')
->action('installNikto'),
])->visible(fn () => ! $this->niktoInstalled),
]),
]),
// Lynis Results
Section::make(__('Lynis System Audit Results'))
->icon('heroicon-o-document-chart-bar')
->schema([
Grid::make(['default' => 1, 'md' => 3])
->schema([
Section::make((string) ($this->lynisResults['hardening_index'] ?? 0))
->description(__('Hardening Index'))
->icon('heroicon-o-shield-check')
->iconColor(fn () => ($this->lynisResults['hardening_index'] ?? 0) >= 70 ? 'success' : (($this->lynisResults['hardening_index'] ?? 0) >= 50 ? 'warning' : 'danger')),
Section::make((string) count($this->lynisResults['warnings'] ?? []))
->description(__('Warnings'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning'),
Section::make((string) count($this->lynisResults['suggestions'] ?? []))
->description(__('Suggestions'))
->icon('heroicon-o-light-bulb')
->iconColor('primary'),
]),
Section::make(__('Warnings'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning')
->collapsible()
->schema([
EmbeddedTable::make(LynisResultsTable::class, ['results' => $this->lynisResults, 'type' => 'warnings']),
])
->visible(fn () => ! empty($this->lynisResults['warnings'] ?? [])),
Section::make(__('Suggestions'))
->icon('heroicon-o-light-bulb')
->iconColor('info')
->collapsible()
->schema([
EmbeddedTable::make(LynisResultsTable::class, ['results' => $this->lynisResults, 'type' => 'suggestions']),
])
->visible(fn () => ! empty($this->lynisResults['suggestions'] ?? [])),
])
->visible(fn () => ! empty($this->lynisResults)),
// WPScan Results
Section::make(__('WPScan Results'))
->icon('heroicon-o-globe-alt')
->schema([
Section::make('WordPress '.($this->wpscanResults['version']['number'] ?? __('Unknown')))
->icon('heroicon-o-code-bracket')
->iconColor('info')
->visible(fn () => isset($this->wpscanResults['version']['number'])),
EmbeddedTable::make(WpscanResultsTable::class, ['results' => $this->wpscanResults]),
])
->visible(fn () => ! empty($this->wpscanResults) && ! isset($this->wpscanResults['error'])),
// Nikto Results
Section::make(__('Nikto Scan Results'))
->icon('heroicon-o-server')
->schema([
Section::make(__('Vulnerabilities'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('danger')
->collapsible()
->schema([
EmbeddedTable::make(NiktoResultsTable::class, ['results' => $this->niktoResults, 'type' => 'vulnerabilities']),
])
->visible(fn () => ! empty($this->niktoResults['vulnerabilities'] ?? [])),
Section::make(__('Information'))
->icon('heroicon-o-information-circle')
->iconColor('info')
->collapsible()
->schema([
EmbeddedTable::make(NiktoResultsTable::class, ['results' => $this->niktoResults, 'type' => 'info']),
])
->visible(fn () => ! empty($this->niktoResults['info'] ?? [])),
])
->visible(fn () => ! empty($this->niktoResults)),
// Scan Output
Section::make(__('Scan Output'))
->icon('heroicon-o-command-line')
->schema(fn () => $this->buildScanOutputSchema())
->visible(fn () => (bool) $this->scanOutput),
];
}
protected function getHeaderActions(): array
{
return [
];
}
// Load methods
protected function loadFirewallStatus(): void
{
try {
$result = $this->getAgent()->send('ufw.status');
$this->firewallInstalled = true;
$this->firewallEnabled = $result['active'] ?? false;
$this->defaultIncoming = $result['default_incoming'] ?? 'deny';
$this->defaultOutgoing = $result['default_outgoing'] ?? 'allow';
$this->firewallStatusText = $result['status_text'] ?? '';
$rulesResult = $this->getAgent()->send('ufw.list_rules');
$this->firewallRules = $rulesResult['rules'] ?? [];
} catch (Exception $e) {
$this->firewallInstalled = false;
}
}
protected function loadWafStatus(): void
{
$this->wafInstalled = $this->detectWaf();
$this->wafEnabled = $this->wafInstalled && Setting::get('waf_enabled', '0') === '1';
}
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 loadFail2banStatusLight(): void
{
try {
$result = $this->getAgent()->send('fail2ban.status_light');
$this->fail2banInstalled = $result['installed'] ?? false;
$this->fail2banRunning = $result['running'] ?? false;
$this->fail2banVersion = $result['version'] ?? 'Unknown';
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
} catch (Exception $e) {
$this->fail2banInstalled = false;
$this->fail2banRunning = false;
$this->fail2banVersion = '';
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
}
}
protected function loadFail2banStatus(): void
{
try {
$result = $this->getAgent()->send('fail2ban.status');
$this->fail2banInstalled = $result['installed'] ?? false;
if ($this->fail2banInstalled) {
$this->fail2banRunning = $result['running'] ?? false;
$this->fail2banVersion = $result['version'] ?? 'Unknown';
$this->jails = $result['jails'] ?? [];
$this->totalBanned = $result['total_banned'] ?? 0;
$this->maxRetry = $result['max_retry'] ?? 5;
$this->banTime = $result['ban_time'] ?? 600;
$this->findTime = $result['find_time'] ?? 600;
$jailsResult = $this->getAgent()->send('fail2ban.list_jails');
$this->availableJails = $jailsResult['jails'] ?? [];
$logsResult = $this->getAgent()->send('fail2ban.logs');
$this->fail2banLogs = $logsResult['logs'] ?? [];
} else {
$this->fail2banRunning = false;
$this->fail2banVersion = '';
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
}
} catch (Exception $e) {
$this->fail2banInstalled = false;
$this->fail2banRunning = false;
$this->fail2banVersion = '';
$this->jails = [];
$this->availableJails = [];
$this->totalBanned = null;
$this->fail2banLogs = [];
}
}
protected function loadClamavStatusLight(): void
{
try {
$result = $this->getAgent()->send('clamav.status_light');
$this->clamavInstalled = $result['installed'] ?? false;
$this->clamavRunning = $result['running'] ?? false;
$this->clamavVersion = $result['version'] ?? 'Unknown';
$this->realtimeEnabled = $result['realtime_enabled'] ?? false;
$this->realtimeRunning = $result['realtime_running'] ?? false;
$this->clamavLightMode = $result['light_mode'] ?? false;
$this->signatureCount = 0;
$this->lastUpdate = '';
$this->recentThreats = [];
$this->quarantinedFiles = [];
$this->signatureDatabases = [];
} catch (Exception $e) {
$this->clamavInstalled = false;
$this->clamavRunning = false;
$this->clamavVersion = '';
$this->realtimeEnabled = false;
$this->realtimeRunning = false;
$this->clamavLightMode = false;
$this->signatureCount = 0;
$this->lastUpdate = '';
$this->recentThreats = [];
$this->quarantinedFiles = [];
$this->signatureDatabases = [];
}
}
protected function loadClamavStatus(): void
{
try {
$result = $this->getAgent()->send('clamav.status');
$this->clamavInstalled = $result['installed'] ?? false;
if ($this->clamavInstalled) {
$this->clamavRunning = $result['running'] ?? false;
$this->clamavVersion = $result['version'] ?? 'Unknown';
$this->signatureCount = $result['signature_count'] ?? 0;
$this->lastUpdate = $result['last_update'] ?? '';
$this->recentThreats = $result['recent_threats'] ?? [];
$this->quarantinedFiles = $result['quarantined_files'] ?? [];
$this->realtimeEnabled = $result['realtime_enabled'] ?? false;
$this->realtimeRunning = $result['realtime_running'] ?? false;
$this->clamavLightMode = $result['light_mode'] ?? false;
$this->signatureDatabases = $result['signature_databases'] ?? [];
} else {
$this->clamavRunning = false;
$this->clamavVersion = '';
$this->signatureCount = 0;
$this->lastUpdate = '';
$this->recentThreats = [];
$this->quarantinedFiles = [];
$this->realtimeEnabled = false;
$this->realtimeRunning = false;
$this->clamavLightMode = false;
$this->signatureDatabases = [];
}
} catch (Exception $e) {
$this->clamavInstalled = false;
$this->clamavRunning = false;
$this->clamavVersion = '';
$this->signatureCount = 0;
$this->lastUpdate = '';
$this->recentThreats = [];
$this->quarantinedFiles = [];
$this->realtimeEnabled = false;
$this->realtimeRunning = false;
$this->clamavLightMode = false;
$this->signatureDatabases = [];
}
}
protected function loadSshSettings(): void
{
try {
$result = $this->getAgent()->send('ssh.get_settings');
if ($result['success'] ?? false) {
$this->sshPasswordAuth = $result['password_auth'] ?? false;
$this->sshPubkeyAuth = $result['pubkey_auth'] ?? true;
$this->sshPort = $result['port'] ?? 22;
}
$this->sshShellAccessEnabled = Setting::get('ssh_shell_access_enabled', '1') === '1';
} catch (Exception $e) {
// Use defaults
$this->sshShellAccessEnabled = Setting::get('ssh_shell_access_enabled', '1') === '1';
}
}
public function saveSshSettings(): void
{
try {
if (! $this->sshPasswordAuth && ! $this->sshPubkeyAuth) {
Notification::make()
->title(__('Invalid Configuration'))
->body(__('At least one authentication method must be enabled.'))
->danger()
->send();
return;
}
$result = $this->getAgent()->send('ssh.save_settings', [
'password_auth' => $this->sshPasswordAuth,
'pubkey_auth' => $this->sshPubkeyAuth,
'port' => $this->sshPort,
]);
if ($result['success'] ?? false) {
$previousShellAccessEnabled = Setting::get('ssh_shell_access_enabled', '1') === '1';
Setting::set('ssh_shell_access_enabled', $this->sshShellAccessEnabled ? '1' : '0');
if ($previousShellAccessEnabled && ! $this->sshShellAccessEnabled) {
$this->disableShellAccessForUsers();
}
Notification::make()
->title(__('SSH settings saved'))
->body(__('Changes will take effect immediately. Make sure you have key access if disabling passwords.'))
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Failed to save settings'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to save SSH settings'))
->body($e->getMessage())
->danger()
->send();
}
$this->loadSshSettings();
}
private function disableShellAccessForUsers(): void
{
$failures = 0;
User::query()
->where('is_admin', false)
->select(['id', 'username', 'system_username', 'email'])
->chunk(100, function ($users) use (&$failures): void {
foreach ($users as $user) {
$username = $this->resolveSystemUsername($user);
if ($username === '') {
$failures++;
continue;
}
try {
$result = $this->getAgent()->send('ssh.disable_shell', ['username' => $username]);
if (! ($result['success'] ?? false)) {
$failures++;
}
} catch (Exception) {
$failures++;
}
}
});
if ($failures > 0) {
Notification::make()
->title(__('Some users could not be updated'))
->body(__('Shell access was disabled globally, but some users could not be switched to SFTP-only.'))
->warning()
->send();
}
}
private function resolveSystemUsername(User $user): string
{
return (string) ($user->system_username ?? $user->username ?? '');
}
// Firewall actions
public function toggleFirewall(): void
{
try {
$action = $this->firewallEnabled ? 'ufw.disable' : 'ufw.enable';
$result = $this->getAgent()->send($action);
if ($result['success'] ?? false) {
$this->firewallEnabled = ! $this->firewallEnabled;
$auditAction = $this->firewallEnabled ? 'enabled' : 'disabled';
AuditLog::logFirewallAction($auditAction);
Notification::make()
->title($this->firewallEnabled ? __('Firewall enabled') : __('Firewall disabled'))
->success()
->send();
} else {
throw new Exception($result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
$this->loadFirewallStatus();
}
public function installFirewall(): void
{
Notification::make()->title(__('Installing firewall...'))->info()->send();
try {
$this->getAgent()->send('ufw.enable');
$this->getAgent()->send('ufw.allow_service', ['service' => 'ssh']);
$this->getAgent()->send('ufw.allow_service', ['service' => 'http']);
$this->getAgent()->send('ufw.allow_service', ['service' => 'https']);
AuditLog::logFirewallAction('installed', 'default rules configured');
Notification::make()->title(__('Firewall configured with default rules'))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Failed to configure firewall'))->body($e->getMessage())->danger()->send();
}
$this->loadFirewallStatus();
}
public function deleteRule(int $ruleNumber): void
{
$this->ruleToDelete = $ruleNumber;
$this->mountAction('deleteRuleAction');
}
public function deleteRuleAction(): Action
{
return Action::make('deleteRuleAction')
->requiresConfirmation()
->modalHeading(__('Delete Firewall Rule'))
->modalDescription(fn () => __('Are you sure you want to delete rule #:number?', ['number' => $this->ruleToDelete]))
->modalSubmitActionLabel(__('Delete'))
->color('danger')
->action(function (): void {
try {
$result = $this->getAgent()->send('ufw.delete_rule', ['rule_number' => $this->ruleToDelete]);
if ($result['success'] ?? false) {
AuditLog::logFirewallAction('deleted', "rule #{$this->ruleToDelete}");
Notification::make()->title(__('Rule deleted'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
public function setDefaultPolicy(string $direction): void
{
$this->mountAction('setDefaultPolicyAction', ['direction' => $direction]);
}
public function setDefaultPolicyAction(): Action
{
return Action::make('setDefaultPolicyAction')
->modalHeading(__('Set Default Policy'))
->form([
Select::make('policy')
->label(__('Policy'))
->options([
'allow' => __('Allow'),
'deny' => __('Deny'),
'reject' => __('Reject'),
])
->required(),
])
->action(function (array $data, array $arguments): void {
try {
$result = $this->getAgent()->send('ufw.set_default', [
'direction' => $arguments['direction'] ?? 'incoming',
'policy' => $data['policy'],
]);
if ($result['success'] ?? false) {
Notification::make()->title(__('Default policy updated'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
public function reloadFirewall(): void
{
try {
$result = $this->getAgent()->send('ufw.reload');
if ($result['success'] ?? false) {
Notification::make()->title(__('Firewall reloaded'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
}
public function resetFirewall(): void
{
$this->mountAction('resetFirewallAction');
}
public function resetFirewallAction(): Action
{
return Action::make('resetFirewallAction')
->requiresConfirmation()
->modalHeading(__('Reset Firewall'))
->modalDescription(__('This will delete ALL firewall rules and disable the firewall. Are you sure?'))
->modalSubmitActionLabel(__('Reset Everything'))
->color('danger')
->action(function (): void {
try {
$result = $this->getAgent()->send('ufw.reset');
if ($result['success'] ?? false) {
AuditLog::logFirewallAction('reset', 'all rules deleted');
Notification::make()->title(__('Firewall reset'))->body(__('All rules have been deleted.'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
protected function allowPortAction(): Action
{
return Action::make('allowPort')
->label(__('Allow Port'))
->icon('heroicon-o-plus-circle')
->color('success')
->form([
TextInput::make('port')
->label(__('Port'))
->placeholder(__('e.g., 80, 443, 8000:8100'))
->required()
->helperText(__('Single port or range (e.g., 8000:8100)')),
Select::make('protocol')
->label(__('Protocol'))
->options([
'' => __('Both (TCP & UDP)'),
'tcp' => __('TCP only'),
'udp' => __('UDP only'),
])
->default(''),
TextInput::make('comment')
->label(__('Comment (optional)'))
->placeholder(__('e.g., Web server')),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->send('ufw.allow_port', $data);
if ($result['success'] ?? false) {
$rule = "allow port {$data['port']}".($data['protocol'] ? "/{$data['protocol']}" : '');
AuditLog::logFirewallAction('added', $rule, $data);
Notification::make()->title(__('Port allowed'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
protected function denyPortAction(): Action
{
return Action::make('denyPort')
->label(__('Block Port'))
->icon('heroicon-o-x-circle')
->color('danger')
->form([
TextInput::make('port')
->label(__('Port'))
->placeholder(__('e.g., 3306'))
->required(),
Select::make('protocol')
->label(__('Protocol'))
->options([
'' => __('Both (TCP & UDP)'),
'tcp' => __('TCP only'),
'udp' => __('UDP only'),
])
->default(''),
TextInput::make('comment')
->label(__('Comment (optional)')),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->send('ufw.deny_port', $data);
if ($result['success'] ?? false) {
$rule = "deny port {$data['port']}".($data['protocol'] ? "/{$data['protocol']}" : '');
AuditLog::logFirewallAction('added', $rule, $data);
Notification::make()->title(__('Port blocked'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
protected function allowIpAction(): Action
{
return Action::make('allowIp')
->label(__('Allow IP'))
->icon('heroicon-o-check-circle')
->color('success')
->form([
TextInput::make('ip')
->label(__('IP Address'))
->placeholder(__('e.g., 192.168.1.100 or 10.0.0.0/8'))
->required()
->helperText(__('Single IP or CIDR notation')),
TextInput::make('port')
->label(__('Port (optional)'))
->placeholder(__('Leave empty to allow all ports')),
Select::make('protocol')
->label(__('Protocol'))
->options([
'' => __('Any'),
'tcp' => __('TCP'),
'udp' => __('UDP'),
])
->default(''),
TextInput::make('comment')
->label(__('Comment (optional)')),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->send('ufw.allow_ip', $data);
if ($result['success'] ?? false) {
$rule = "allow from {$data['ip']}".($data['port'] ? " to port {$data['port']}" : '');
AuditLog::logFirewallAction('added', $rule, $data);
Notification::make()->title(__('IP allowed'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
protected function denyIpAction(): Action
{
return Action::make('denyIp')
->label(__('Block IP'))
->icon('heroicon-o-no-symbol')
->color('danger')
->form([
TextInput::make('ip')
->label(__('IP Address'))
->placeholder(__('e.g., 192.168.1.100 or 10.0.0.0/8'))
->required(),
TextInput::make('port')
->label(__('Port (optional)'))
->placeholder(__('Leave empty to block all ports')),
Select::make('protocol')
->label(__('Protocol'))
->options([
'' => __('Any'),
'tcp' => __('TCP'),
'udp' => __('UDP'),
])
->default(''),
TextInput::make('comment')
->label(__('Comment (optional)')),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->send('ufw.deny_ip', $data);
if ($result['success'] ?? false) {
$rule = "deny from {$data['ip']}".($data['port'] ? " to port {$data['port']}" : '');
AuditLog::logFirewallAction('added', $rule, $data);
Notification::make()->title(__('IP blocked'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
protected function allowServiceAction(): Action
{
return Action::make('allowService')
->label(__('Allow Service'))
->icon('heroicon-o-server')
->color('info')
->form([
Select::make('service')
->label(__('Service'))
->options([
'ssh' => __('SSH (22)'),
'http' => __('HTTP (80)'),
'https' => __('HTTPS (443)'),
'ftp' => __('FTP (21)'),
'smtp' => __('SMTP (25)'),
'pop3' => __('POP3 (110)'),
'imap' => __('IMAP (143)'),
'dns' => __('DNS (53)'),
'mysql' => __('MySQL (3306)'),
'postgresql' => __('PostgreSQL (5432)'),
])
->required()
->searchable(),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->send('ufw.allow_service', $data);
if ($result['success'] ?? false) {
AuditLog::logFirewallAction('added', "allow service {$data['service']}", $data);
Notification::make()->title(__('Service allowed'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
protected function limitPortAction(): Action
{
return Action::make('limitPort')
->label(__('Rate Limit'))
->icon('heroicon-o-clock')
->color('warning')
->form([
TextInput::make('port')
->label(__('Port'))
->placeholder(__('e.g., 22'))
->required()
->helperText(__('Limit connections (6 in 30 seconds)')),
Select::make('protocol')
->label(__('Protocol'))
->options([
'tcp' => __('TCP'),
'udp' => __('UDP'),
])
->default('tcp'),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->send('ufw.limit_port', $data);
if ($result['success'] ?? false) {
AuditLog::logFirewallAction('added', "limit port {$data['port']}/{$data['protocol']}", $data);
Notification::make()->title(__('Rate limit applied'))->success()->send();
$this->loadFirewallStatus();
} else {
throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
}
});
}
public function getActionColor(string $action): string
{
return match (strtoupper($action)) {
'ALLOW' => 'success',
'DENY' => 'danger',
'REJECT' => 'warning',
'LIMIT' => 'warning',
default => 'gray',
};
}
// Firewall action mount helpers
public function openAllowPort(): void
{
$this->mountAction('allowPort');
}
public function openDenyPort(): void
{
$this->mountAction('denyPort');
}
public function openAllowIp(): void
{
$this->mountAction('allowIp');
}
public function openDenyIp(): void
{
$this->mountAction('denyIp');
}
public function openAllowService(): void
{
$this->mountAction('allowService');
}
public function openLimitPort(): void
{
$this->mountAction('limitPort');
}
// Fail2ban actions
public function installFail2ban(): void
{
Notification::make()->title(__('Installing Fail2ban...'))->info()->send();
try {
$result = $this->getAgent()->send('fail2ban.install');
if ($result['success'] ?? false) {
Notification::make()->title(__('Fail2ban installed'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Installation failed'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to install Fail2ban'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
public function startFail2ban(): void
{
try {
$result = $this->getAgent()->send('service.enable', ['service' => 'fail2ban']);
if ($result['success'] ?? false) {
$result = $this->getAgent()->send('service.start', ['service' => 'fail2ban']);
}
if ($result['success'] ?? false) {
Notification::make()->title(__('Fail2ban started'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to start'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to start Fail2ban'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
public function stopFail2ban(): void
{
try {
$result = $this->getAgent()->send('service.stop', ['service' => 'fail2ban']);
if ($result['success'] ?? false) {
Notification::make()->title(__('Fail2ban stopped'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to stop'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to stop Fail2ban'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
public function disableFail2ban(): void
{
try {
$result = $this->getAgent()->send('service.stop', ['service' => 'fail2ban']);
if ($result['success'] ?? false) {
$result = $this->getAgent()->send('service.disable', ['service' => 'fail2ban']);
}
if ($result['success'] ?? false) {
Notification::make()->title(__('Fail2ban disabled'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to disable'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to disable Fail2ban'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
public function saveFail2banSettings(): void
{
try {
$result = $this->getAgent()->send('fail2ban.save_settings', [
'max_retry' => $this->maxRetry,
'ban_time' => $this->banTime,
'find_time' => $this->findTime,
]);
if ($result['success'] ?? false) {
Notification::make()->title(__('Settings saved'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to save'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to save settings'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
public function unbanIp(string $jail, string $ip): void
{
try {
$result = $this->getAgent()->send('fail2ban.unban_ip', ['jail' => $jail, 'ip' => $ip]);
if ($result['success'] ?? false) {
Notification::make()->title(__('Unbanned :ip', ['ip' => $ip]))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to unban'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to unban IP'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
public function enableJail(string $jail): void
{
try {
$result = $this->getAgent()->send('fail2ban.enable_jail', ['jail' => $jail]);
if ($result['success'] ?? false) {
Notification::make()->title(__('Jail :jail enabled', ['jail' => $jail]))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to enable jail'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to enable jail'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
public function disableJail(string $jail): void
{
try {
$result = $this->getAgent()->send('fail2ban.disable_jail', ['jail' => $jail]);
if ($result['success'] ?? false) {
Notification::make()->title(__('Jail :jail disabled', ['jail' => $jail]))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to disable jail'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to disable jail'))->body($e->getMessage())->danger()->send();
}
$this->loadFail2banStatus();
}
// ClamAV actions
public function installClamav(): void
{
Notification::make()->title(__('Installing ClamAV...'))->body(__('This may take a few minutes.'))->info()->send();
try {
$result = $this->getAgent()->send('clamav.install');
if ($result['success'] ?? false) {
Notification::make()->title(__('ClamAV installed'))->body(__('Daemon disabled by default to save memory.'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Installation failed'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to install ClamAV'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
public function updateSignatures(): void
{
try {
$result = $this->getAgent()->send('clamav.update_signatures');
if ($result['success'] ?? false) {
Notification::make()->title(__('Signatures updated'))->success()->send();
} else {
Notification::make()->title(__('Update may have issues'))->body($result['output'] ?? '')->warning()->send();
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to update signatures'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
public function startClamav(): void
{
try {
$result = $this->getAgent()->send('clamav.start');
if ($result['success'] ?? false) {
Notification::make()->title(__('ClamAV started'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to start'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to start ClamAV'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
public function enableClamav(): void
{
try {
$result = $this->getAgent()->send('service.enable', ['service' => 'clamav-daemon']);
if ($result['success'] ?? false) {
$result = $this->getAgent()->send('service.start', ['service' => 'clamav-daemon']);
}
if ($result['success'] ?? false) {
Notification::make()->title(__('ClamAV enabled'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to enable'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to enable ClamAV'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
public function stopClamav(): void
{
try {
$result = $this->getAgent()->send('clamav.stop');
if ($result['success'] ?? false) {
Notification::make()->title(__('ClamAV stopped'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to stop'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to stop ClamAV'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
public function disableClamav(): void
{
try {
$result = $this->getAgent()->send('service.stop', ['service' => 'clamav-daemon']);
if ($result['success'] ?? false) {
$result = $this->getAgent()->send('service.disable', ['service' => 'clamav-daemon']);
}
if ($result['success'] ?? false) {
Notification::make()->title(__('ClamAV disabled'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to disable'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to disable ClamAV'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
public function toggleRealtime(): void
{
try {
if ($this->realtimeRunning) {
$result = $this->getAgent()->send('clamav.realtime_disable');
$message = __('Real-time protection disabled');
} else {
$result = $this->getAgent()->send('clamav.realtime_enable');
$message = __('Real-time protection enabled');
}
if ($result['success'] ?? false) {
Notification::make()->title($message)->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to toggle real-time protection'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
public function toggleLightMode(): void
{
try {
$action = $this->clamavLightMode ? 'clamav.set_full_mode' : 'clamav.set_light_mode';
$result = $this->getAgent()->send($action);
if ($result['success'] ?? false) {
$this->clamavLightMode = ! $this->clamavLightMode;
$message = $this->clamavLightMode
? __('Switched to lightweight mode - web hosting signatures only')
: __('Switched to full mode - all ClamAV signatures');
Notification::make()
->title($message)
->body(__('Signature count: :count', ['count' => number_format($result['signature_count'] ?? 0)]))
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Failed to switch mode'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to switch ClamAV mode'))
->body($e->getMessage())
->danger()
->send();
}
$this->loadClamavStatus();
}
public function deleteQuarantined(string $filename): void
{
try {
$result = $this->getAgent()->send('clamav.delete_quarantined', ['filename' => $filename]);
if ($result['success'] ?? false) {
Notification::make()->title(__('File deleted'))->success()->send();
} else {
throw new Exception($result['error'] ?? __('Failed to delete'));
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to delete file'))->body($e->getMessage())->danger()->send();
}
$this->loadClamavStatus();
}
// Scanner methods
protected function checkScannerToolStatus(): void
{
exec('which lynis 2>/dev/null', $output, $code);
$this->lynisInstalled = $code === 0;
if ($this->lynisInstalled) {
exec('lynis --version 2>/dev/null | head -1', $versionOutput);
$this->lynisVersion = trim($versionOutput[0] ?? 'Unknown');
}
exec('which wpscan 2>/dev/null', $output2, $code2);
$this->wpscanInstalled = $code2 === 0;
if ($this->wpscanInstalled) {
exec("wpscan --version 2>/dev/null | grep -i 'version' | tail -1", $versionOutput2);
$this->wpscanVersion = trim($versionOutput2[0] ?? 'Unknown');
}
exec('which nikto 2>/dev/null', $output3, $code3);
$this->niktoInstalled = $code3 === 0;
if ($this->niktoInstalled) {
exec('nikto -Version 2>/dev/null | grep -i version | head -1', $versionOutput3);
$this->niktoVersion = trim($versionOutput3[0] ?? 'Unknown');
}
}
protected function loadLastScans(): void
{
$scanDir = storage_path('app/security-scans');
if (file_exists("$scanDir/lynis-latest.json")) {
$this->lastLynisScan = date('Y-m-d H:i:s', filemtime("$scanDir/lynis-latest.json"));
$this->lynisResults = json_decode(file_get_contents("$scanDir/lynis-latest.json"), true) ?? [];
}
if (file_exists("$scanDir/wpscan-latest.json")) {
$this->lastWpscanScan = date('Y-m-d H:i:s', filemtime("$scanDir/wpscan-latest.json"));
$this->wpscanResults = json_decode(file_get_contents("$scanDir/wpscan-latest.json"), true) ?? [];
}
if (file_exists("$scanDir/nikto-latest.json")) {
$this->lastNiktoScan = date('Y-m-d H:i:s', filemtime("$scanDir/nikto-latest.json"));
$this->niktoResults = json_decode(file_get_contents("$scanDir/nikto-latest.json"), true) ?? [];
}
}
public function installLynis(): void
{
Notification::make()->title(__('Installing Lynis...'))->info()->send();
exec('apt-get update && apt-get install -y lynis 2>&1', $output, $code);
if ($code === 0) {
Notification::make()->title(__('Lynis installed successfully'))->success()->send();
} else {
Notification::make()->title(__('Installation failed'))->body(implode("\n", array_slice($output, -5)))->danger()->send();
}
$this->checkScannerToolStatus();
}
public function installWpscan(): void
{
Notification::make()->title(__('Installing WPScan...'))->body(__('This may take a few minutes.'))->info()->send();
exec('which ruby 2>/dev/null', $rubyCheck, $rubyCode);
if ($rubyCode !== 0) {
exec('apt-get update && apt-get install -y ruby ruby-dev build-essential libcurl4-openssl-dev libxml2 libxml2-dev libxslt1-dev 2>&1', $output, $code);
}
exec('gem install wpscan 2>&1', $output, $code);
if ($code === 0) {
exec('wpscan --update 2>&1');
Notification::make()->title(__('WPScan installed successfully'))->success()->send();
} else {
Notification::make()->title(__('Installation failed'))->body(implode("\n", array_slice($output, -5)))->danger()->send();
}
$this->checkScannerToolStatus();
}
public function installNikto(): void
{
Notification::make()->title(__('Installing Nikto...'))->info()->send();
exec('apt-get update && apt-get install -y nikto 2>&1', $output, $code);
if ($code === 0) {
Notification::make()->title(__('Nikto installed successfully'))->success()->send();
} else {
Notification::make()->title(__('Installation failed'))->body(implode("\n", array_slice($output, -5)))->danger()->send();
}
$this->checkScannerToolStatus();
}
public function runLynisScan(): void
{
if (! $this->lynisInstalled) {
Notification::make()->title(__('Lynis not installed'))->danger()->send();
return;
}
$this->isScanning = true;
$this->currentScan = 'lynis';
$this->scanOutput = __('Running Lynis system audit...')."\n";
$scanDir = storage_path('app/security-scans');
if (! is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
exec('lynis audit system --no-colors --quick 2>&1', $output, $code);
$this->scanOutput = implode("\n", $output);
$results = $this->parseLynisOutput($output);
$results['scan_time'] = date('Y-m-d H:i:s');
$results['raw_output'] = $this->scanOutput;
file_put_contents("$scanDir/lynis-latest.json", json_encode($results, JSON_PRETTY_PRINT));
$this->lynisResults = $results;
$this->lastLynisScan = $results['scan_time'];
$this->isScanning = false;
$this->currentScan = '';
$warningCount = count($results['warnings'] ?? []);
$suggestionCount = count($results['suggestions'] ?? []);
Notification::make()
->title(__('Lynis scan completed'))
->body(__('Found :warnings warnings and :suggestions suggestions', ['warnings' => $warningCount, 'suggestions' => $suggestionCount]))
->success()
->send();
}
protected function parseLynisOutput(array $output): array
{
$results = [
'hardening_index' => 0,
'warnings' => [],
'suggestions' => [],
'tests_performed' => 0,
];
$fullOutput = implode("\n", $output);
if (preg_match('/Hardening index\s*:\s*(\d+)/i', $fullOutput, $matches)) {
$results['hardening_index'] = (int) $matches[1];
}
if (preg_match('/Tests performed\s*:\s*(\d+)/i', $fullOutput, $matches)) {
$results['tests_performed'] = (int) $matches[1];
}
preg_match_all('/\[WARNING\]\s*(.+)$/m', $fullOutput, $warningMatches);
$results['warnings'] = $warningMatches[1] ?? [];
preg_match_all('/\[SUGGESTION\]\s*(.+)$/m', $fullOutput, $suggestionMatches);
$results['suggestions'] = $suggestionMatches[1] ?? [];
return $results;
}
public function getLocalWordPressSites(): array
{
$sites = [];
try {
$users = \App\Models\User::where('is_admin', false)->get();
foreach ($users as $user) {
$result = $this->getAgent()->wpList($user->username);
$userSites = $result['sites'] ?? [];
foreach ($userSites as $site) {
$siteId = $site['domain'].($site['path'] ?? '');
$sites[$siteId] = $site['domain'].($site['path'] !== '/' ? ($site['path'] ?? '') : '');
}
}
} catch (Exception $e) {
// Return empty array on error
}
return $sites;
}
public function runWpscanOnSite(): void
{
if (! $this->wpscanInstalled) {
Notification::make()->title(__('WPScan not installed'))->danger()->send();
return;
}
if (! $this->selectedWpSiteId) {
Notification::make()->title(__('Please select a WordPress site'))->danger()->send();
return;
}
$url = 'https://'.$this->selectedWpSiteId;
$this->isScanning = true;
$this->currentScan = 'wpscan';
$this->scanOutput = __('Scanning WordPress site: :url', ['url' => $url])."\n";
$scanDir = storage_path('app/security-scans');
if (! is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
// Set HOME to writable directory for wpscan cache
$wpscanCmd = 'HOME=/var/www wpscan --url '.escapeshellarg($url).' --format json --no-banner 2>&1';
exec($wpscanCmd, $output, $code);
$jsonOutput = implode("\n", $output);
$this->scanOutput = $jsonOutput;
$results = json_decode($jsonOutput, true);
if (! $results) {
$results = [
'error' => __('Failed to parse scan results'),
'raw_output' => $jsonOutput,
];
}
$results['scan_time'] = date('Y-m-d H:i:s');
$results['target_url'] = $url;
file_put_contents("$scanDir/wpscan-latest.json", json_encode($results, JSON_PRETTY_PRINT));
$this->wpscanResults = $results;
$this->lastWpscanScan = $results['scan_time'];
$this->isScanning = false;
$this->currentScan = '';
Notification::make()
->title(__('WPScan completed'))
->body(__('Scan finished for :url', ['url' => $url]))
->success()
->send();
}
public function runNiktoScan(): void
{
if (! $this->niktoInstalled) {
Notification::make()->title(__('Nikto not installed'))->danger()->send();
return;
}
$target = 'localhost';
$this->isScanning = true;
$this->currentScan = 'nikto';
$this->scanOutput = __('Scanning local web server...')."\n";
$scanDir = storage_path('app/security-scans');
if (! is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
$jsonFile = "$scanDir/nikto-".date('Y-m-d-His').'.json';
// Use full path for nikto since timeout command has restricted PATH
$niktoPath = file_exists('/usr/bin/nikto') ? '/usr/bin/nikto' : '/usr/local/bin/nikto';
exec("timeout 300 {$niktoPath} -h localhost -Format json -output {$jsonFile} 2>&1", $output, $code);
$this->scanOutput = implode("\n", $output);
$results = [];
if (file_exists($jsonFile)) {
$results = json_decode(file_get_contents($jsonFile), true) ?? [];
}
if (empty($results)) {
$results = $this->parseNiktoTextOutput($output);
}
$results['scan_time'] = date('Y-m-d H:i:s');
$results['target'] = $target;
$results['raw_output'] = $this->scanOutput;
file_put_contents("$scanDir/nikto-latest.json", json_encode($results, JSON_PRETTY_PRINT));
$this->niktoResults = $results;
$this->lastNiktoScan = $results['scan_time'];
$this->isScanning = false;
$this->currentScan = '';
Notification::make()
->title(__('Nikto scan completed'))
->body(__('Local web server scan finished'))
->success()
->send();
}
protected function parseNiktoTextOutput(array $output): array
{
$results = [
'vulnerabilities' => [],
'info' => [],
];
foreach ($output as $line) {
if (preg_match('/^\+\s*OSVDB-\d+:\s*(.+)/', $line, $matches)) {
$results['vulnerabilities'][] = trim($matches[1]);
} elseif (preg_match('/^\+\s*(.+)/', $line, $matches)) {
$results['info'][] = trim($matches[1]);
}
}
return $results;
}
// ClamAV on-demand scan methods
public function getClamScanUsers(): array
{
$users = [];
try {
$systemUsers = \App\Models\User::where('is_admin', false)->get();
foreach ($systemUsers as $user) {
$users[$user->username] = $user->username.' ('.$user->email.')';
}
} catch (Exception $e) {
// Return empty array on error
}
return $users;
}
public function runClamScanUser(): void
{
if (! $this->clamavInstalled) {
Notification::make()->title(__('ClamAV not installed'))->danger()->send();
return;
}
if (! $this->selectedClamUser) {
Notification::make()->title(__('Please select a user'))->danger()->send();
return;
}
$this->isScanning = true;
$this->currentScan = 'clamav';
$this->scanOutput = __('Scanning user directory: /home/:user', ['user' => $this->selectedClamUser])."\n";
$scanDir = storage_path('app/security-scans');
if (! is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
$userDir = "/home/{$this->selectedClamUser}";
$logFile = "$scanDir/clamscan-{$this->selectedClamUser}-".date('Y-m-d-His').'.log';
$cmd = "clamscan -r --infected --log={$logFile} ".
"--exclude-dir='^/home/{$this->selectedClamUser}/\\.cache' ".
"--exclude-dir='^/home/{$this->selectedClamUser}/\\.local' ".
escapeshellarg($userDir).' 2>&1';
exec($cmd, $output, $code);
$this->scanOutput = implode("\n", $output);
$results = $this->parseClamScanOutput($output);
$results['scan_time'] = date('Y-m-d H:i:s');
$results['scan_type'] = 'user';
$results['target'] = $userDir;
$results['username'] = $this->selectedClamUser;
$results['raw_output'] = $this->scanOutput;
file_put_contents("$scanDir/clamscan-latest.json", json_encode($results, JSON_PRETTY_PRINT));
$this->clamScanResults = $results;
$this->lastClamScan = $results['scan_time'];
$this->isScanning = false;
$this->currentScan = '';
$infected = $results['infected_files'] ?? 0;
$message = $infected > 0
? __('Found :count infected file(s)', ['count' => $infected])
: __('No threats detected');
Notification::make()
->title(__('ClamAV scan completed'))
->body($message)
->color($infected > 0 ? 'danger' : 'success')
->send();
}
public function runClamScanServer(): void
{
if (! $this->clamavInstalled) {
Notification::make()->title(__('ClamAV not installed'))->danger()->send();
return;
}
$this->isScanning = true;
$this->currentScan = 'clamav';
$this->scanOutput = __('Scanning server-wide: /home')."\n".
__('This may take a while...')."\n";
$scanDir = storage_path('app/security-scans');
if (! is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
$logFile = "$scanDir/clamscan-server-".date('Y-m-d-His').'.log';
$cmd = "clamscan -r --infected --log={$logFile} ".
"--exclude-dir='^\\.cache' ".
"--exclude-dir='^\\.local' ".
'/home 2>&1';
exec($cmd, $output, $code);
$this->scanOutput = implode("\n", $output);
$results = $this->parseClamScanOutput($output);
$results['scan_time'] = date('Y-m-d H:i:s');
$results['scan_type'] = 'server';
$results['target'] = '/home';
$results['raw_output'] = $this->scanOutput;
file_put_contents("$scanDir/clamscan-latest.json", json_encode($results, JSON_PRETTY_PRINT));
$this->clamScanResults = $results;
$this->lastClamScan = $results['scan_time'];
$this->isScanning = false;
$this->currentScan = '';
$infected = $results['infected_files'] ?? 0;
$message = $infected > 0
? __('Found :count infected file(s)', ['count' => $infected])
: __('No threats detected');
Notification::make()
->title(__('Server-wide scan completed'))
->body($message)
->color($infected > 0 ? 'danger' : 'success')
->send();
}
protected function parseClamScanOutput(array $output): array
{
$results = [
'scanned_files' => 0,
'infected_files' => 0,
'threats' => [],
];
$fullOutput = implode("\n", $output);
if (preg_match('/Scanned files:\s*(\d+)/i', $fullOutput, $matches)) {
$results['scanned_files'] = (int) $matches[1];
}
if (preg_match('/Infected files:\s*(\d+)/i', $fullOutput, $matches)) {
$results['infected_files'] = (int) $matches[1];
}
foreach ($output as $line) {
if (preg_match('/^(.+?):\s*(.+?)\s*FOUND$/i', $line, $matches)) {
$results['threats'][] = [
'file' => trim($matches[1]),
'threat' => trim($matches[2]),
];
}
}
return $results;
}
protected function loadClamScanResults(): void
{
$scanDir = storage_path('app/security-scans');
if (file_exists("$scanDir/clamscan-latest.json")) {
$this->lastClamScan = date('Y-m-d H:i:s', filemtime("$scanDir/clamscan-latest.json"));
$this->clamScanResults = json_decode(file_get_contents("$scanDir/clamscan-latest.json"), true) ?? [];
}
}
// Dynamic component builders for pure Filament UI
protected function buildClamavScanResultsSchema(): array
{
if (empty($this->clamScanResults)) {
return [Text::make(__('No scan results available'))];
}
$scannedFiles = $this->clamScanResults['scanned_files'] ?? 0;
$infectedFiles = $this->clamScanResults['infected_files'] ?? 0;
$scanType = $this->clamScanResults['scan_type'] ?? 'unknown';
$target = $scanType === 'user' ? ($this->clamScanResults['username'] ?? '-') : __('Server');
$threats = $this->clamScanResults['threats'] ?? [];
$components = [
Grid::make(['default' => 1, 'md' => 3])
->schema([
Section::make((string) $scannedFiles)
->description(__('Files Scanned'))
->icon('heroicon-o-document-magnifying-glass')
->iconColor('primary'),
Section::make((string) $infectedFiles)
->description(__('Infected Files'))
->icon('heroicon-o-exclamation-triangle')
->iconColor($infectedFiles > 0 ? 'danger' : 'success'),
Section::make($target)
->description(__('Scan Target'))
->icon('heroicon-o-folder')
->iconColor('gray'),
]),
];
if (! empty($threats)) {
$threatComponents = [];
foreach ($threats as $threat) {
$threatComponents[] = Text::make($threat['threat'].': '.basename($threat['file']));
}
$components[] = Section::make(__('Threats Detected').' ('.count($threats).')')
->icon('heroicon-o-exclamation-triangle')
->iconColor('danger')
->schema($threatComponents);
} else {
$components[] = Section::make(__('No threats detected'))
->icon('heroicon-o-check-circle')
->iconColor('success')
->description(__('The scanned directory appears to be clean.'));
}
return $components;
}
protected function buildSshCurrentConfigSchema(): array
{
return [
Section::make($this->sshPasswordAuth ? __('Enabled') : __('Disabled'))
->description(__('Password Authentication'))
->icon('heroicon-o-key')
->iconColor($this->sshPasswordAuth ? 'warning' : 'success')
->aside(),
Section::make($this->sshPubkeyAuth ? __('Enabled') : __('Disabled'))
->description(__('Public Key Authentication'))
->icon('heroicon-o-finger-print')
->iconColor($this->sshPubkeyAuth ? 'success' : 'danger')
->aside(),
Section::make((string) $this->sshPort)
->description(__('SSH Port'))
->icon('heroicon-o-server')
->iconColor('gray')
->aside(),
];
}
public function getSecurityRecommendations(): array
{
$keysOnly = ! $this->sshPasswordAuth && $this->sshPubkeyAuth;
$nonDefaultPort = $this->sshPort !== 22;
$firewallActive = $this->firewallEnabled;
$wafActive = $this->wafInstalled && $this->wafEnabled;
$antivirusActive = $this->clamavRunning;
return [
[
'title' => __('Disable password authentication'),
'description' => __('Use SSH keys only for better security'),
'ok' => $keysOnly,
],
[
'title' => __('Use a non-default SSH port'),
'description' => __('Reduce automated attacks by avoiding port 22'),
'ok' => $nonDefaultPort,
],
[
'title' => __('Enable Fail2ban protection'),
'description' => __('Automatically block brute-force attempts'),
'ok' => $this->fail2banRunning,
],
[
'title' => __('Enable the firewall'),
'description' => __('Restrict inbound access to required services'),
'ok' => $firewallActive,
],
[
'title' => __('Enable ModSecurity (WAF)'),
'description' => __('Add web attack protection for hosted sites'),
'ok' => $wafActive,
],
[
'title' => __('Keep antivirus protection active'),
'description' => __('Scan and block malware threats'),
'ok' => $antivirusActive,
],
];
}
protected function buildClamScanOutputSchema(): array
{
$output = $this->clamScanResults['raw_output'] ?? '';
if ($this->isScanning && $this->currentScan === 'clamav') {
$output = $this->scanOutput;
}
if (! $output) {
return [];
}
return [
Text::make($output),
];
}
protected function buildScanOutputSchema(): array
{
if (! $this->scanOutput) {
return [];
}
return [
Text::make($this->scanOutput),
];
}
}