Security Analytics - v1.0.0

Custom Detectors

DetectorInterface has three methods:

namespace ArtisanPackUI\SecurityAnalytics\Analytics\AnomalyDetection\Contracts;

use ArtisanPackUI\SecurityAnalytics\Models\Anomaly;
use ArtisanPackUI\SecurityAnalytics\Models\SecurityEvent;
use Illuminate\Support\Collection;

interface DetectorInterface
{
    /**
     * Inspect an event (or batch) and return any anomalies detected.
     *
     * @param Collection<int, SecurityEvent> $events
     * @return Collection<int, Anomaly>
     */
    public function detect( Collection $events ): Collection;

    public function getName(): string;

    public function getDescription(): string;
}

The AbstractDetector base class handles common boilerplate (config lookup, anomaly creation, severity calculation). Subclass it for most use cases.

Example: detecting weekend admin access

namespace App\SecurityAnalytics\Detectors;

use ArtisanPackUI\SecurityAnalytics\Analytics\AnomalyDetection\Detectors\AbstractDetector;
use ArtisanPackUI\SecurityAnalytics\Models\Anomaly;
use ArtisanPackUI\SecurityAnalytics\Models\SecurityEvent;
use Illuminate\Support\Collection;

class WeekendAdminAccessDetector extends AbstractDetector
{
    public function detect( Collection $events ): Collection
    {
        return $events
            ->filter( fn ( SecurityEvent $event ) => $event->event_type === 'access'
                && str_starts_with( $event->event_name, 'admin.' )
                && in_array( $event->created_at->dayOfWeek, [0, 6], true ) )
            ->map( fn ( SecurityEvent $event ) => $this->createAnomaly(
                event: $event,
                severity: 'medium',
                confidence: 70,
                metadata: [ 'reason' => 'admin access on weekend' ],
            ) );
    }

    public function getName(): string
    {
        return 'weekend_admin_access';
    }

    public function getDescription(): string
    {
        return 'Flags admin-area access during weekends';
    }
}

Registering your detector

In a service provider's boot():

use ArtisanPackUI\SecurityAnalytics\Analytics\AnomalyDetection\AnomalyDetectionService;
use App\SecurityAnalytics\Detectors\WeekendAdminAccessDetector;

$this->app->afterResolving( AnomalyDetectionService::class, function ( AnomalyDetectionService $service ): void {
    $service->extend( new WeekendAdminAccessDetector() );
} );

Your detector now runs alongside the 8 shipped ones every time security:detect-suspicious runs (or security_analytics()->detection()->analyze() is called inline).

Replacing a shipped detector

To swap your implementation in for a shipped one (e.g. a smarter geo-velocity check), use the same extend() call but match the existing name:

$service->extend( new SmarterGeoVelocityDetector() );
// Replaces the shipped GeoVelocityDetector since both call themselves 'geo_velocity'.

Disabling a shipped detector entirely

'anomaly_detection' => [
    'detectors' => [
        'geo_velocity' => ['enabled' => false],
    ],
],

The detection service skips disabled detectors without instantiating them.

Conventions

  • Fast is better than complete. The detector runs against potentially thousands of events per batch. Use database queries that exploit indexes; avoid N+1.
  • Confidence scoring. Use a 0..100 scale. Below 30 = noise, 30–70 = signal worth surfacing, 70+ = high confidence (gets surfaced more prominently).
  • Idempotent. Running the detector twice over the same events should produce the same anomalies — the framework deduplicates by event_id + detector_name when writing rows.