391 lines
18 KiB
PHP
391 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\FindingResource\Pages;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
|
use BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Infolists\Components\ViewEntry;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use UnitEnum;
|
|
|
|
class FindingResource extends Resource
|
|
{
|
|
protected static ?string $model = Finding::class;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
|
|
|
protected static ?string $navigationLabel = 'Findings';
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema;
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Finding')
|
|
->schema([
|
|
TextEntry::make('finding_type')->badge()->label('Type'),
|
|
TextEntry::make('status')->badge(),
|
|
TextEntry::make('severity')->badge(),
|
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
|
|
TextEntry::make('scope_key')->label('Scope')->copyable(),
|
|
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
|
TextEntry::make('subject_type')->label('Subject type'),
|
|
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
|
TextEntry::make('baseline_run_id')
|
|
->label('Baseline run')
|
|
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
|
: null)
|
|
->openUrlInNewTab(),
|
|
TextEntry::make('current_run_id')
|
|
->label('Current run')
|
|
->url(fn (Finding $record): ?string => $record->current_run_id
|
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
|
: null)
|
|
->openUrlInNewTab(),
|
|
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Diff')
|
|
->schema([
|
|
ViewEntry::make('settings_diff')
|
|
->label('')
|
|
->view('filament.infolists.entries.normalized-diff')
|
|
->state(function (Finding $record): array {
|
|
$tenant = Tenant::current();
|
|
if (! $tenant) {
|
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
}
|
|
|
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
|
|
$baselineVersion = is_numeric($baselineId)
|
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
: null;
|
|
|
|
$currentVersion = is_numeric($currentId)
|
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
: null;
|
|
|
|
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
|
|
|
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
|
|
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
|
|
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
|
|
|
|
if (($addedCount + $removedCount + $changedCount) === 0) {
|
|
Arr::set(
|
|
$diff,
|
|
'summary.message',
|
|
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
|
|
);
|
|
}
|
|
|
|
return $diff;
|
|
})
|
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
|
->columnSpanFull(),
|
|
|
|
ViewEntry::make('scope_tags_diff')
|
|
->label('')
|
|
->view('filament.infolists.entries.scope-tags-diff')
|
|
->state(function (Finding $record): array {
|
|
$tenant = Tenant::current();
|
|
if (! $tenant) {
|
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
}
|
|
|
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
|
|
$baselineVersion = is_numeric($baselineId)
|
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
: null;
|
|
|
|
$currentVersion = is_numeric($currentId)
|
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
: null;
|
|
|
|
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
|
})
|
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
|
->columnSpanFull(),
|
|
|
|
ViewEntry::make('assignments_diff')
|
|
->label('')
|
|
->view('filament.infolists.entries.assignments-diff')
|
|
->state(function (Finding $record): array {
|
|
$tenant = Tenant::current();
|
|
if (! $tenant) {
|
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
}
|
|
|
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
|
|
$baselineVersion = is_numeric($baselineId)
|
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
: null;
|
|
|
|
$currentVersion = is_numeric($currentId)
|
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
: null;
|
|
|
|
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
|
})
|
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
|
->columnSpanFull(),
|
|
])
|
|
->collapsed()
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Evidence (Sanitized)')
|
|
->schema([
|
|
ViewEntry::make('evidence_jsonb')
|
|
->label('')
|
|
->view('filament.infolists.entries.snapshot-json')
|
|
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
|
|
->columnSpanFull(),
|
|
])
|
|
->columnSpanFull(),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('created_at', 'desc')
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
|
Tables\Columns\TextColumn::make('status')->badge(),
|
|
Tables\Columns\TextColumn::make('severity')->badge(),
|
|
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
|
|
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
|
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
|
])
|
|
->filters([
|
|
Tables\Filters\SelectFilter::make('status')
|
|
->options([
|
|
Finding::STATUS_NEW => 'New',
|
|
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
|
|
])
|
|
->default(Finding::STATUS_NEW),
|
|
Tables\Filters\SelectFilter::make('finding_type')
|
|
->options([
|
|
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
|
])
|
|
->default(Finding::FINDING_TYPE_DRIFT),
|
|
Tables\Filters\Filter::make('scope_key')
|
|
->form([
|
|
TextInput::make('scope_key')
|
|
->label('Scope key')
|
|
->placeholder('Inventory selection hash')
|
|
->maxLength(255),
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$scopeKey = $data['scope_key'] ?? null;
|
|
|
|
if (! is_string($scopeKey) || $scopeKey === '') {
|
|
return $query;
|
|
}
|
|
|
|
return $query->where('scope_key', $scopeKey);
|
|
}),
|
|
Tables\Filters\Filter::make('run_ids')
|
|
->label('Run IDs')
|
|
->form([
|
|
TextInput::make('baseline_run_id')
|
|
->label('Baseline run id')
|
|
->numeric(),
|
|
TextInput::make('current_run_id')
|
|
->label('Current run id')
|
|
->numeric(),
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$baselineRunId = $data['baseline_run_id'] ?? null;
|
|
if (is_numeric($baselineRunId)) {
|
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
|
}
|
|
|
|
$currentRunId = $data['current_run_id'] ?? null;
|
|
if (is_numeric($currentRunId)) {
|
|
$query->where('current_run_id', (int) $currentRunId);
|
|
}
|
|
|
|
return $query;
|
|
}),
|
|
])
|
|
->actions([
|
|
Actions\Action::make('acknowledge')
|
|
->label('Acknowledge')
|
|
->icon('heroicon-o-check')
|
|
->color('gray')
|
|
->requiresConfirmation()
|
|
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
|
|
->authorize(function (Finding $record): bool {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can('update', $record);
|
|
})
|
|
->action(function (Finding $record): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant || ! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
Notification::make()
|
|
->title('Finding belongs to a different tenant')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$record->acknowledge($user);
|
|
|
|
Notification::make()
|
|
->title('Finding acknowledged')
|
|
->success()
|
|
->send();
|
|
}),
|
|
Actions\ViewAction::make(),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([
|
|
BulkAction::make('acknowledge_selected')
|
|
->label('Acknowledge selected')
|
|
->icon('heroicon-o-check')
|
|
->color('gray')
|
|
->authorize(function (): bool {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
|
|
|
return $user->can('update', $probe);
|
|
})
|
|
->authorizeIndividualRecords('update')
|
|
->requiresConfirmation()
|
|
->action(function (Collection $records): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant || ! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$firstRecord = $records->first();
|
|
if ($firstRecord instanceof Finding) {
|
|
Gate::authorize('update', $firstRecord);
|
|
}
|
|
|
|
$acknowledgedCount = 0;
|
|
$skippedCount = 0;
|
|
|
|
foreach ($records as $record) {
|
|
if (! $record instanceof Finding) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($record->status !== Finding::STATUS_NEW) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$record->acknowledge($user);
|
|
$acknowledgedCount++;
|
|
}
|
|
|
|
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
|
if ($skippedCount > 0) {
|
|
$body .= " Skipped {$skippedCount}.";
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Bulk acknowledge completed')
|
|
->body($body)
|
|
->success()
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion(),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$tenantId = Tenant::current()->getKey();
|
|
|
|
return parent::getEloquentQuery()
|
|
->addSelect([
|
|
'subject_display_name' => InventoryItem::query()
|
|
->select('display_name')
|
|
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
|
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
|
->limit(1),
|
|
])
|
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListFindings::route('/'),
|
|
'view' => Pages\ViewFinding::route('/{record}'),
|
|
];
|
|
}
|
|
}
|