mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-02-04 15:53:17 +00:00
✨ Add Security Monitoring System with Command, Model, and Service
- 🛡️ Introduce `SecurityMonitor` service for tampering and malicious activity detection. - 🏷️ Add `SecurityAttempt` model and migration to log, categorize, and query security attempts. - 🖥️ Create `SecurityAttemptsCommand` for filtering, statistics, and top IP analysis. - ✅ Add extensive tests to ensure the reliability of security monitoring and logging. - 🔗 Integrate `SecurityMonitor` into the exception handling pipeline for real-time monitoring.
This commit is contained in:
166
app/Console/Commands/SecurityAttemptsCommand.php
Normal file
166
app/Console/Commands/SecurityAttemptsCommand.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\SecurityAttempt;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class SecurityAttemptsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'security:attempts
|
||||||
|
{--hours=24 : Show attempts from the last N hours}
|
||||||
|
{--ip= : Filter by IP address}
|
||||||
|
{--severity= : Filter by severity (low, medium, high)}
|
||||||
|
{--component= : Filter by component name}
|
||||||
|
{--stats : Show statistics only}
|
||||||
|
{--top-ips=10 : Show top N attacking IPs}';
|
||||||
|
|
||||||
|
protected $description = 'View and analyze security attack attempts';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$hours = (int) $this->option('hours');
|
||||||
|
|
||||||
|
if ($this->option('stats')) {
|
||||||
|
return $this->showStats($hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('top-ips')) {
|
||||||
|
return $this->showTopIps($hours, (int) $this->option('top-ips'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->showAttempts($hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function showAttempts(int $hours): int
|
||||||
|
{
|
||||||
|
$query = SecurityAttempt::query()->recent($hours);
|
||||||
|
|
||||||
|
if ($ip = $this->option('ip')) {
|
||||||
|
$query->fromIp($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($severity = $this->option('severity')) {
|
||||||
|
$query->severity($severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($component = $this->option('component')) {
|
||||||
|
$query->forComponent($component);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempts = $query->latest()->limit(100)->get();
|
||||||
|
|
||||||
|
if ($attempts->isEmpty()) {
|
||||||
|
$this->info("No security attempts found in the last {$hours} hours.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['ID', 'Time', 'IP', 'Severity', 'Component', 'Property', 'Exception'],
|
||||||
|
$attempts->map(fn (SecurityAttempt $a) => [
|
||||||
|
$a->id,
|
||||||
|
$a->created_at->format('Y-m-d H:i:s'),
|
||||||
|
$a->ip_address,
|
||||||
|
$this->formatSeverity($a->severity),
|
||||||
|
$a->component_name ?? '-',
|
||||||
|
$a->target_property ?? '-',
|
||||||
|
class_basename($a->exception_class),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Showing {$attempts->count()} attempts (max 100). Use filters to narrow down.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function showStats(int $hours): int
|
||||||
|
{
|
||||||
|
$total = SecurityAttempt::query()->recent($hours)->count();
|
||||||
|
$bySeverity = SecurityAttempt::query()
|
||||||
|
->recent($hours)
|
||||||
|
->selectRaw('severity, COUNT(*) as count')
|
||||||
|
->groupBy('severity')
|
||||||
|
->pluck('count', 'severity')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$uniqueIps = SecurityAttempt::query()
|
||||||
|
->recent($hours)
|
||||||
|
->distinct('ip_address')
|
||||||
|
->count('ip_address');
|
||||||
|
|
||||||
|
$topComponents = SecurityAttempt::query()
|
||||||
|
->recent($hours)
|
||||||
|
->whereNotNull('component_name')
|
||||||
|
->selectRaw('component_name, COUNT(*) as count')
|
||||||
|
->groupBy('component_name')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->limit(5)
|
||||||
|
->pluck('count', 'component_name')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$this->info("Security Attempts - Last {$hours} hours");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->table(['Metric', 'Value'], [
|
||||||
|
['Total Attempts', $total],
|
||||||
|
['Unique IPs', $uniqueIps],
|
||||||
|
['High Severity', $bySeverity['high'] ?? 0],
|
||||||
|
['Medium Severity', $bySeverity['medium'] ?? 0],
|
||||||
|
['Low Severity', $bySeverity['low'] ?? 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($topComponents)) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Top Targeted Components:');
|
||||||
|
$this->table(
|
||||||
|
['Component', 'Attempts'],
|
||||||
|
collect($topComponents)->map(fn ($count, $name) => [$name, $count])->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function showTopIps(int $hours, int $limit): int
|
||||||
|
{
|
||||||
|
$ips = SecurityAttempt::query()
|
||||||
|
->recent($hours)
|
||||||
|
->selectRaw('ip_address, COUNT(*) as count, MAX(severity) as max_severity')
|
||||||
|
->groupBy('ip_address')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($ips->isEmpty()) {
|
||||||
|
$this->info("No security attempts found in the last {$hours} hours.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Top {$limit} Attacking IPs - Last {$hours} hours");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['IP Address', 'Attempts', 'Max Severity'],
|
||||||
|
$ips->map(fn ($row) => [
|
||||||
|
$row->ip_address,
|
||||||
|
$row->count,
|
||||||
|
$this->formatSeverity($row->max_severity),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatSeverity(string $severity): string
|
||||||
|
{
|
||||||
|
return match ($severity) {
|
||||||
|
'high' => '<fg=red>HIGH</>',
|
||||||
|
'medium' => '<fg=yellow>MEDIUM</>',
|
||||||
|
'low' => '<fg=green>LOW</>',
|
||||||
|
default => $severity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Models/SecurityAttempt.php
Normal file
50
app/Models/SecurityAttempt.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class SecurityAttempt extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'method',
|
||||||
|
'url',
|
||||||
|
'route_name',
|
||||||
|
'exception_class',
|
||||||
|
'exception_message',
|
||||||
|
'component_name',
|
||||||
|
'target_property',
|
||||||
|
'payload',
|
||||||
|
'severity',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'payload' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeFromIp(Builder $query, string $ip): Builder
|
||||||
|
{
|
||||||
|
return $query->where('ip_address', $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSeverity(Builder $query, string $severity): Builder
|
||||||
|
{
|
||||||
|
return $query->where('severity', $severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeRecent(Builder $query, int $hours = 24): Builder
|
||||||
|
{
|
||||||
|
return $query->where('created_at', '>=', now()->subHours($hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForComponent(Builder $query, string $component): Builder
|
||||||
|
{
|
||||||
|
return $query->where('component_name', $component);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
app/Services/SecurityMonitor.php
Normal file
237
app/Services/SecurityMonitor.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\SecurityAttempt;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class SecurityMonitor
|
||||||
|
{
|
||||||
|
private const MAX_PAYLOAD_SIZE = 10000;
|
||||||
|
|
||||||
|
private const SEVERITY_HIGH = 'high';
|
||||||
|
|
||||||
|
private const SEVERITY_MEDIUM = 'medium';
|
||||||
|
|
||||||
|
private const SEVERITY_LOW = 'low';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livewire security exceptions that indicate tampering attempts.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
private const LIVEWIRE_SECURITY_EXCEPTIONS = [
|
||||||
|
'Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException',
|
||||||
|
'Livewire\Exceptions\ComponentNotFoundException',
|
||||||
|
'Livewire\Exceptions\MethodNotFoundException',
|
||||||
|
'Livewire\Exceptions\MissingFileUploadsTraitException',
|
||||||
|
'Livewire\Exceptions\PropertyNotFoundException',
|
||||||
|
'Livewire\Exceptions\PublicPropertyNotFoundException',
|
||||||
|
'Livewire\Exceptions\CannotBindToModelDataWithoutValidationRuleException',
|
||||||
|
'Livewire\Exceptions\CorruptComponentPayloadException',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns in payloads that indicate injection attacks.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
private const MALICIOUS_PATTERNS = [
|
||||||
|
'__toString',
|
||||||
|
'phpinfo',
|
||||||
|
'system(',
|
||||||
|
'exec(',
|
||||||
|
'shell_exec',
|
||||||
|
'passthru',
|
||||||
|
'eval(',
|
||||||
|
'base64_decode',
|
||||||
|
'SerializableClosure',
|
||||||
|
'BroadcastEvent',
|
||||||
|
'FnStream',
|
||||||
|
'PendingBroadcast',
|
||||||
|
'dispatchNextJobInChain',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function recordFromException(Throwable $exception, ?Request $request = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request ??= request();
|
||||||
|
|
||||||
|
if (! $this->shouldRecord($exception)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$componentName = $this->extractComponentName($request);
|
||||||
|
$targetProperty = $this->extractTargetProperty($exception);
|
||||||
|
$payload = $this->sanitizePayload($request);
|
||||||
|
$severity = $this->determineSeverity($exception, $payload);
|
||||||
|
|
||||||
|
SecurityAttempt::query()->create([
|
||||||
|
'ip_address' => $this->getIpAddress($request),
|
||||||
|
'user_agent' => $this->truncate($request->userAgent(), 500),
|
||||||
|
'method' => $request->method(),
|
||||||
|
'url' => $this->truncate($request->fullUrl(), 2000),
|
||||||
|
'route_name' => $request->route()?->getName(),
|
||||||
|
'exception_class' => get_class($exception),
|
||||||
|
'exception_message' => $this->truncate($exception->getMessage(), 1000),
|
||||||
|
'component_name' => $componentName,
|
||||||
|
'target_property' => $targetProperty,
|
||||||
|
'payload' => $payload,
|
||||||
|
'severity' => $severity,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Never let monitoring fail the application
|
||||||
|
Log::warning('SecurityMonitor failed to record attempt', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'original_exception' => get_class($exception),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldRecord(Throwable $exception): bool
|
||||||
|
{
|
||||||
|
$exceptionClass = get_class($exception);
|
||||||
|
|
||||||
|
foreach (self::LIVEWIRE_SECURITY_EXCEPTIONS as $securityException) {
|
||||||
|
if ($exceptionClass === $securityException || is_subclass_of($exception, $securityException)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAttemptsFromIp(string $ip, int $hours = 24): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return SecurityAttempt::query()
|
||||||
|
->fromIp($ip)
|
||||||
|
->recent($hours)
|
||||||
|
->count();
|
||||||
|
} catch (Throwable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isIpSuspicious(string $ip, int $threshold = 10, int $hours = 24): bool
|
||||||
|
{
|
||||||
|
return $this->getAttemptsFromIp($ip, $hours) >= $threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractComponentName(Request $request): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$components = $request->input('components', []);
|
||||||
|
if (empty($components)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = json_decode($components[0]['snapshot'] ?? '{}', true);
|
||||||
|
|
||||||
|
return $snapshot['memo']['name'] ?? null;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractTargetProperty(Throwable $exception): ?string
|
||||||
|
{
|
||||||
|
$message = $exception->getMessage();
|
||||||
|
|
||||||
|
if (preg_match('/\[([^\]]+)\]/', $message, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizePayload(Request $request): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $request->input('components', []);
|
||||||
|
$jsonPayload = json_encode($payload);
|
||||||
|
|
||||||
|
// Truncate if too large
|
||||||
|
if (strlen($jsonPayload) > self::MAX_PAYLOAD_SIZE) {
|
||||||
|
return [
|
||||||
|
'_truncated' => true,
|
||||||
|
'_original_size' => strlen($jsonPayload),
|
||||||
|
'summary' => $this->extractPayloadSummary($payload),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return ['_error' => 'Could not serialize payload'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractPayloadSummary(array $payload): array
|
||||||
|
{
|
||||||
|
$summary = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($payload as $index => $component) {
|
||||||
|
$summary[$index] = [
|
||||||
|
'has_snapshot' => isset($component['snapshot']),
|
||||||
|
'updates_count' => count($component['updates'] ?? []),
|
||||||
|
'update_keys' => array_keys($component['updates'] ?? []),
|
||||||
|
'calls_count' => count($component['calls'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function determineSeverity(Throwable $exception, ?array $payload): string
|
||||||
|
{
|
||||||
|
// Check for injection patterns in payload
|
||||||
|
$payloadJson = json_encode($payload) ?: '';
|
||||||
|
|
||||||
|
foreach (self::MALICIOUS_PATTERNS as $pattern) {
|
||||||
|
if (stripos($payloadJson, $pattern) !== false) {
|
||||||
|
return self::SEVERITY_HIGH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locked property tampering is medium severity
|
||||||
|
if (str_contains(get_class($exception), 'LockedProperty')) {
|
||||||
|
return self::SEVERITY_MEDIUM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SEVERITY_LOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getIpAddress(Request $request): string
|
||||||
|
{
|
||||||
|
// Handle proxied requests
|
||||||
|
$ip = $request->header('X-Forwarded-For');
|
||||||
|
|
||||||
|
if ($ip) {
|
||||||
|
// Take the first IP if there are multiple
|
||||||
|
$ips = explode(',', $ip);
|
||||||
|
|
||||||
|
return trim($ips[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->ip() ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function truncate(?string $value, int $length): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($value) <= $length) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($value, 0, $length - 3).'...';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Services\SecurityMonitor;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -18,4 +19,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
Integration::handles($exceptions);
|
Integration::handles($exceptions);
|
||||||
|
|
||||||
|
$exceptions->report(function (Throwable $e) {
|
||||||
|
app(SecurityMonitor::class)->recordFromException($e);
|
||||||
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('security_attempts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('ip_address', 45)->index();
|
||||||
|
$table->string('user_agent', 500)->nullable();
|
||||||
|
$table->string('method', 10);
|
||||||
|
$table->string('url', 2000);
|
||||||
|
$table->string('route_name')->nullable();
|
||||||
|
$table->string('exception_class');
|
||||||
|
$table->string('exception_message', 1000);
|
||||||
|
$table->string('component_name')->nullable()->index();
|
||||||
|
$table->string('target_property')->nullable();
|
||||||
|
$table->json('payload')->nullable();
|
||||||
|
$table->string('severity')->default('medium')->index();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('security_attempts');
|
||||||
|
}
|
||||||
|
};
|
||||||
166
tests/Feature/SecurityMonitorTest.php
Normal file
166
tests/Feature/SecurityMonitorTest.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\SecurityAttempt;
|
||||||
|
use App\Services\SecurityMonitor;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->monitor = app(SecurityMonitor::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records locked property exceptions', function () {
|
||||||
|
$exception = new CannotUpdateLockedPropertyException('isLoggedIn');
|
||||||
|
|
||||||
|
$request = Request::create('/livewire/update', 'POST', [
|
||||||
|
'components' => [
|
||||||
|
[
|
||||||
|
'snapshot' => json_encode([
|
||||||
|
'memo' => ['name' => 'auth-button'],
|
||||||
|
]),
|
||||||
|
'updates' => ['isLoggedIn' => true],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->setRouteResolver(fn () => null);
|
||||||
|
|
||||||
|
app()->instance('request', $request);
|
||||||
|
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
|
||||||
|
expect(SecurityAttempt::count())->toBe(1);
|
||||||
|
|
||||||
|
$attempt = SecurityAttempt::first();
|
||||||
|
expect($attempt->exception_class)->toBe(CannotUpdateLockedPropertyException::class)
|
||||||
|
->and($attempt->target_property)->toBe('isLoggedIn')
|
||||||
|
->and($attempt->component_name)->toBe('auth-button')
|
||||||
|
->and($attempt->severity)->toBe('medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects high severity injection attempts', function () {
|
||||||
|
$exception = new CannotUpdateLockedPropertyException('isLoggedIn');
|
||||||
|
|
||||||
|
$request = Request::create('/livewire/update', 'POST', [
|
||||||
|
'components' => [
|
||||||
|
[
|
||||||
|
'snapshot' => json_encode([
|
||||||
|
'memo' => ['name' => 'auth-button'],
|
||||||
|
]),
|
||||||
|
'updates' => [
|
||||||
|
'isLoggedIn' => [
|
||||||
|
'__toString' => 'phpinfo',
|
||||||
|
'SerializableClosure' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->setRouteResolver(fn () => null);
|
||||||
|
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
|
||||||
|
$attempt = SecurityAttempt::first();
|
||||||
|
expect($attempt->severity)->toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record non-security exceptions', function () {
|
||||||
|
$exception = new RuntimeException('Something went wrong');
|
||||||
|
|
||||||
|
$this->monitor->recordFromException($exception);
|
||||||
|
|
||||||
|
expect(SecurityAttempt::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws exceptions itself', function () {
|
||||||
|
$exception = new CannotUpdateLockedPropertyException('test');
|
||||||
|
|
||||||
|
// Create a request that might cause issues
|
||||||
|
$request = Request::create('/test', 'POST');
|
||||||
|
$request->setRouteResolver(fn () => null);
|
||||||
|
|
||||||
|
// This should not throw even if there are issues
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
|
||||||
|
// If we get here without exception, the test passes
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks attempts from same IP', function () {
|
||||||
|
$exception = new CannotUpdateLockedPropertyException('test');
|
||||||
|
|
||||||
|
$request = Request::create('/livewire/update', 'POST', [
|
||||||
|
'components' => [
|
||||||
|
['snapshot' => '{}', 'updates' => []],
|
||||||
|
],
|
||||||
|
], server: ['REMOTE_ADDR' => '192.168.1.100']);
|
||||||
|
|
||||||
|
$request->setRouteResolver(fn () => null);
|
||||||
|
|
||||||
|
// Record multiple attempts
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
|
||||||
|
expect($this->monitor->getAttemptsFromIp('192.168.1.100'))->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies suspicious IPs', function () {
|
||||||
|
$exception = new CannotUpdateLockedPropertyException('test');
|
||||||
|
|
||||||
|
$request = Request::create('/livewire/update', 'POST', [
|
||||||
|
'components' => [
|
||||||
|
['snapshot' => '{}', 'updates' => []],
|
||||||
|
],
|
||||||
|
], server: ['REMOTE_ADDR' => '10.0.0.1']);
|
||||||
|
|
||||||
|
$request->setRouteResolver(fn () => null);
|
||||||
|
|
||||||
|
// Record 10 attempts (threshold)
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($this->monitor->isIpSuspicious('10.0.0.1', threshold: 10))->toBeTrue()
|
||||||
|
->and($this->monitor->isIpSuspicious('10.0.0.2', threshold: 10))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long values', function () {
|
||||||
|
$exception = new CannotUpdateLockedPropertyException('test');
|
||||||
|
|
||||||
|
$longUserAgent = str_repeat('a', 1000);
|
||||||
|
|
||||||
|
$request = Request::create('/livewire/update', 'POST', [
|
||||||
|
'components' => [
|
||||||
|
['snapshot' => '{}', 'updates' => []],
|
||||||
|
],
|
||||||
|
], server: ['HTTP_USER_AGENT' => $longUserAgent]);
|
||||||
|
|
||||||
|
$request->setRouteResolver(fn () => null);
|
||||||
|
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
|
||||||
|
$attempt = SecurityAttempt::first();
|
||||||
|
expect(strlen($attempt->user_agent))->toBeLessThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles X-Forwarded-For header', function () {
|
||||||
|
$exception = new CannotUpdateLockedPropertyException('test');
|
||||||
|
|
||||||
|
$request = Request::create('/livewire/update', 'POST', [
|
||||||
|
'components' => [
|
||||||
|
['snapshot' => '{}', 'updates' => []],
|
||||||
|
],
|
||||||
|
], server: [
|
||||||
|
'REMOTE_ADDR' => '127.0.0.1',
|
||||||
|
'HTTP_X_FORWARDED_FOR' => '203.0.113.50, 70.41.3.18',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->setRouteResolver(fn () => null);
|
||||||
|
|
||||||
|
$this->monitor->recordFromException($exception, $request);
|
||||||
|
|
||||||
|
$attempt = SecurityAttempt::first();
|
||||||
|
expect($attempt->ip_address)->toBe('203.0.113.50');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user