Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
314
src/Framework/Waf/Analysis/AnalysisResult.php
Normal file
314
src/Framework/Waf/Analysis/AnalysisResult.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Result of a single analysis operation
|
||||
*/
|
||||
final readonly class AnalysisResult
|
||||
{
|
||||
public function __construct(
|
||||
public AnalysisType $type,
|
||||
public bool $passed,
|
||||
public DetectionSeverity $severity,
|
||||
public string $message,
|
||||
public array $findings = [],
|
||||
public array $metadata = [],
|
||||
public ?Duration $processingTime = null,
|
||||
public ?Timestamp $timestamp = null,
|
||||
public ?Percentage $confidence = null,
|
||||
public ?string $recommendation = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create successful analysis result
|
||||
*/
|
||||
public static function success(
|
||||
AnalysisType $type,
|
||||
string $message = 'Analysis passed',
|
||||
array $metadata = []
|
||||
): self {
|
||||
return new self(
|
||||
type: $type,
|
||||
passed: true,
|
||||
severity: DetectionSeverity::INFO,
|
||||
message: $message,
|
||||
findings: [],
|
||||
metadata: $metadata,
|
||||
timestamp: Timestamp::now(),
|
||||
confidence: Percentage::from(100.0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failed analysis result
|
||||
*/
|
||||
public static function failure(
|
||||
AnalysisType $type,
|
||||
DetectionSeverity $severity,
|
||||
string $message,
|
||||
array $findings = [],
|
||||
array $metadata = [],
|
||||
?string $recommendation = null
|
||||
): self {
|
||||
return new self(
|
||||
type: $type,
|
||||
passed: false,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata,
|
||||
timestamp: Timestamp::now(),
|
||||
confidence: Percentage::from(85.0),
|
||||
recommendation: $recommendation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create warning result (passed but with concerns)
|
||||
*/
|
||||
public static function warning(
|
||||
AnalysisType $type,
|
||||
string $message,
|
||||
array $findings = [],
|
||||
array $metadata = [],
|
||||
?string $recommendation = null
|
||||
): self {
|
||||
return new self(
|
||||
type: $type,
|
||||
passed: true,
|
||||
severity: DetectionSeverity::LOW,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata,
|
||||
timestamp: Timestamp::now(),
|
||||
confidence: Percentage::from(70.0),
|
||||
recommendation: $recommendation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create analysis result with custom confidence
|
||||
*/
|
||||
public static function withConfidence(
|
||||
AnalysisType $type,
|
||||
bool $passed,
|
||||
DetectionSeverity $severity,
|
||||
string $message,
|
||||
Percentage $confidence,
|
||||
array $findings = [],
|
||||
array $metadata = []
|
||||
): self {
|
||||
return new self(
|
||||
type: $type,
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata,
|
||||
timestamp: Timestamp::now(),
|
||||
confidence: $confidence
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add processing time
|
||||
*/
|
||||
public function withProcessingTime(Duration $processingTime): self
|
||||
{
|
||||
return new self(
|
||||
type: $this->type,
|
||||
passed: $this->passed,
|
||||
severity: $this->severity,
|
||||
message: $this->message,
|
||||
findings: $this->findings,
|
||||
metadata: $this->metadata,
|
||||
processingTime: $processingTime,
|
||||
timestamp: $this->timestamp,
|
||||
confidence: $this->confidence,
|
||||
recommendation: $this->recommendation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add metadata
|
||||
*/
|
||||
public function withMetadata(array $metadata): self
|
||||
{
|
||||
return new self(
|
||||
type: $this->type,
|
||||
passed: $this->passed,
|
||||
severity: $this->severity,
|
||||
message: $this->message,
|
||||
findings: $this->findings,
|
||||
metadata: array_merge($this->metadata, $metadata),
|
||||
processingTime: $this->processingTime,
|
||||
timestamp: $this->timestamp,
|
||||
confidence: $this->confidence,
|
||||
recommendation: $this->recommendation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result indicates a security threat
|
||||
*/
|
||||
public function isThreat(): bool
|
||||
{
|
||||
return ! $this->passed && ($this->severity === DetectionSeverity::HIGH ||
|
||||
$this->severity === DetectionSeverity::CRITICAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result should block request
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return $this->isThreat() &&
|
||||
($this->confidence === null || $this->confidence->getValue() >= 80.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result should trigger alert
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return ! $this->passed &&
|
||||
$this->severity !== DetectionSeverity::INFO &&
|
||||
($this->confidence === null || $this->confidence->getValue() >= 60.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk score (0-100)
|
||||
*/
|
||||
public function getRiskScore(): float
|
||||
{
|
||||
if ($this->passed) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$severityScore = match ($this->severity) {
|
||||
DetectionSeverity::CRITICAL => 100.0,
|
||||
DetectionSeverity::HIGH => 80.0,
|
||||
DetectionSeverity::MEDIUM => 60.0,
|
||||
DetectionSeverity::LOW => 30.0,
|
||||
DetectionSeverity::INFO => 10.0
|
||||
};
|
||||
|
||||
// Apply confidence modifier
|
||||
if ($this->confidence !== null) {
|
||||
$severityScore *= ($this->confidence->getValue() / 100.0);
|
||||
}
|
||||
|
||||
return $severityScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get finding count by type
|
||||
*/
|
||||
public function getFindingCount(?string $type = null): int
|
||||
{
|
||||
if ($type === null) {
|
||||
return count($this->findings);
|
||||
}
|
||||
|
||||
return count(array_filter(
|
||||
$this->findings,
|
||||
fn (array $finding) => ($finding['type'] ?? '') === $type
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get findings by type
|
||||
*/
|
||||
public function getFindingsByType(string $type): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->findings,
|
||||
fn (array $finding) => ($finding['type'] ?? '') === $type
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis exceeded expected processing time
|
||||
*/
|
||||
public function exceededExpectedTime(): bool
|
||||
{
|
||||
if ($this->processingTime === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxTime = $this->type->getMaxProcessingTime();
|
||||
|
||||
return $this->processingTime->toMilliseconds() > $maxTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance assessment
|
||||
*/
|
||||
public function getPerformanceAssessment(): string
|
||||
{
|
||||
if ($this->processingTime === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$maxTime = $this->type->getMaxProcessingTime();
|
||||
$actualTime = $this->processingTime->toMilliseconds();
|
||||
|
||||
$ratio = $actualTime / $maxTime;
|
||||
|
||||
return match (true) {
|
||||
$ratio <= 0.5 => 'excellent',
|
||||
$ratio <= 1.0 => 'good',
|
||||
$ratio <= 2.0 => 'acceptable',
|
||||
$ratio <= 5.0 => 'slow',
|
||||
default => 'critical'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary text
|
||||
*/
|
||||
public function getSummary(): string
|
||||
{
|
||||
$status = $this->passed ? 'PASS' : 'FAIL';
|
||||
$typeDesc = $this->type->getDescription();
|
||||
|
||||
return "{$status}: {$typeDesc} - {$this->message}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'type_description' => $this->type->getDescription(),
|
||||
'passed' => $this->passed,
|
||||
'severity' => $this->severity->value,
|
||||
'message' => $this->message,
|
||||
'findings' => $this->findings,
|
||||
'finding_count' => $this->getFindingCount(),
|
||||
'metadata' => $this->metadata,
|
||||
'processing_time_ms' => $this->processingTime?->toMilliseconds(),
|
||||
'timestamp' => $this->timestamp?->toIsoString(),
|
||||
'confidence' => $this->confidence?->getValue(),
|
||||
'recommendation' => $this->recommendation,
|
||||
'is_threat' => $this->isThreat(),
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
'risk_score' => $this->getRiskScore(),
|
||||
'exceeded_expected_time' => $this->exceededExpectedTime(),
|
||||
'performance_assessment' => $this->getPerformanceAssessment(),
|
||||
'summary' => $this->getSummary(),
|
||||
];
|
||||
}
|
||||
}
|
||||
198
src/Framework/Waf/Analysis/AnalysisType.php
Normal file
198
src/Framework/Waf/Analysis/AnalysisType.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis;
|
||||
|
||||
/**
|
||||
* Types of request analysis that can be performed
|
||||
*/
|
||||
enum AnalysisType: string
|
||||
{
|
||||
case HEADERS = 'headers';
|
||||
case BODY = 'body';
|
||||
case PARAMETERS = 'parameters';
|
||||
case COOKIES = 'cookies';
|
||||
case URL = 'url';
|
||||
case METHOD = 'method';
|
||||
case PROTOCOL = 'protocol';
|
||||
case SIZE = 'size';
|
||||
case ENCODING = 'encoding';
|
||||
case CONTENT_TYPE = 'content_type';
|
||||
case USER_AGENT = 'user_agent';
|
||||
case REFERRER = 'referrer';
|
||||
case FILES = 'files';
|
||||
case JSON = 'json';
|
||||
case XML = 'xml';
|
||||
|
||||
/**
|
||||
* Get analysis description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEADERS => 'HTTP headers analysis and validation',
|
||||
self::BODY => 'Request body content analysis',
|
||||
self::PARAMETERS => 'Query and form parameters analysis',
|
||||
self::COOKIES => 'Cookie values and structure analysis',
|
||||
self::URL => 'URL path and structure analysis',
|
||||
self::METHOD => 'HTTP method validation',
|
||||
self::PROTOCOL => 'HTTP protocol compliance analysis',
|
||||
self::SIZE => 'Request size and limits analysis',
|
||||
self::ENCODING => 'Content encoding and character set analysis',
|
||||
self::CONTENT_TYPE => 'Content-Type header validation',
|
||||
self::USER_AGENT => 'User-Agent string analysis',
|
||||
self::REFERRER => 'Referrer header analysis',
|
||||
self::FILES => 'File upload analysis',
|
||||
self::JSON => 'JSON payload structure analysis',
|
||||
self::XML => 'XML payload structure analysis'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing priority (higher = earlier processing)
|
||||
*/
|
||||
public function getPriority(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::METHOD => 100, // Validate method first
|
||||
self::PROTOCOL => 95, // Protocol compliance
|
||||
self::SIZE => 90, // Size limits before content parsing
|
||||
self::HEADERS => 85, // Header validation
|
||||
self::CONTENT_TYPE => 80, // Content type before body parsing
|
||||
self::URL => 75, // URL structure
|
||||
self::USER_AGENT => 70, // User agent validation
|
||||
self::REFERRER => 65, // Referrer validation
|
||||
self::ENCODING => 60, // Encoding validation
|
||||
self::COOKIES => 55, // Cookie analysis
|
||||
self::PARAMETERS => 50, // Parameter analysis
|
||||
self::FILES => 45, // File upload analysis
|
||||
self::BODY => 40, // Body content analysis
|
||||
self::JSON => 35, // JSON structure (after body)
|
||||
self::XML => 30 // XML structure (after body)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis requires request body
|
||||
*/
|
||||
public function requiresBody(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::BODY, self::JSON, self::XML, self::FILES => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis is computationally expensive
|
||||
*/
|
||||
public function isExpensive(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::BODY, self::JSON, self::XML, self::FILES => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expected processing time category
|
||||
*/
|
||||
public function getProcessingTimeCategory(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::METHOD, self::PROTOCOL, self::SIZE => 'fast',
|
||||
self::HEADERS, self::URL, self::COOKIES, self::PARAMETERS => 'medium',
|
||||
self::BODY, self::JSON, self::XML, self::FILES => 'slow',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis can run in parallel
|
||||
*/
|
||||
public function canRunInParallel(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
// These can run independently
|
||||
self::HEADERS, self::USER_AGENT, self::REFERRER, self::COOKIES,
|
||||
self::URL, self::PARAMETERS => true,
|
||||
// These need sequential processing
|
||||
self::CONTENT_TYPE, self::BODY, self::JSON, self::XML, self::FILES => false,
|
||||
default => true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analysis dependencies (must run before this analysis)
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::BODY => [self::CONTENT_TYPE, self::SIZE],
|
||||
self::JSON => [self::BODY, self::CONTENT_TYPE],
|
||||
self::XML => [self::BODY, self::CONTENT_TYPE],
|
||||
self::FILES => [self::BODY, self::CONTENT_TYPE, self::SIZE],
|
||||
self::PARAMETERS => [self::BODY], // Form parameters need body
|
||||
default => []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis applies to specific content type
|
||||
*/
|
||||
public function appliesToContentType(?string $contentType): bool
|
||||
{
|
||||
if ($contentType === null) {
|
||||
return ! $this->requiresBody();
|
||||
}
|
||||
|
||||
$contentType = strtolower($contentType);
|
||||
|
||||
return match ($this) {
|
||||
self::JSON => str_contains($contentType, 'application/json'),
|
||||
self::XML => str_contains($contentType, 'application/xml') ||
|
||||
str_contains($contentType, 'text/xml'),
|
||||
self::FILES => str_contains($contentType, 'multipart/form-data'),
|
||||
self::PARAMETERS => str_contains($contentType, 'application/x-www-form-urlencoded') ||
|
||||
str_contains($contentType, 'multipart/form-data'),
|
||||
// These apply to all content types
|
||||
self::HEADERS, self::URL, self::METHOD, self::PROTOCOL,
|
||||
self::SIZE, self::COOKIES, self::USER_AGENT, self::REFERRER,
|
||||
self::CONTENT_TYPE, self::ENCODING => true,
|
||||
// Body analysis applies to all content types
|
||||
self::BODY => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum safe processing time in milliseconds
|
||||
*/
|
||||
public function getMaxProcessingTime(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::METHOD, self::PROTOCOL => 10,
|
||||
self::SIZE, self::HEADERS, self::URL => 50,
|
||||
self::COOKIES, self::PARAMETERS, self::USER_AGENT,
|
||||
self::REFERRER, self::CONTENT_TYPE, self::ENCODING => 100,
|
||||
self::BODY => 500,
|
||||
self::JSON, self::XML => 1000,
|
||||
self::FILES => 2000
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis should be cached
|
||||
*/
|
||||
public function shouldCache(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
// Cache expensive analyses
|
||||
self::BODY, self::JSON, self::XML, self::FILES => true,
|
||||
// Cache user agent analysis (frequently repeated)
|
||||
self::USER_AGENT => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
}
|
||||
60
src/Framework/Waf/Analysis/Analyzers/AnalyzerInterface.php
Normal file
60
src/Framework/Waf/Analysis/Analyzers/AnalyzerInterface.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
|
||||
/**
|
||||
* Interface for all request analyzers
|
||||
*/
|
||||
interface AnalyzerInterface
|
||||
{
|
||||
/**
|
||||
* Get analyzer type
|
||||
*/
|
||||
public function getType(): AnalysisType;
|
||||
|
||||
/**
|
||||
* Check if analyzer can process the given request data
|
||||
*/
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool;
|
||||
|
||||
/**
|
||||
* Perform analysis on request data
|
||||
*/
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult;
|
||||
|
||||
/**
|
||||
* Get analyzer configuration
|
||||
*/
|
||||
public function getConfiguration(): array;
|
||||
|
||||
/**
|
||||
* Check if analyzer is enabled
|
||||
*/
|
||||
public function isEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Get analyzer priority (higher = runs earlier)
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
|
||||
/**
|
||||
* Get expected processing time in milliseconds
|
||||
*/
|
||||
public function getExpectedProcessingTime(): int;
|
||||
|
||||
/**
|
||||
* Check if analyzer supports parallel execution
|
||||
*/
|
||||
public function supportsParallelExecution(): bool;
|
||||
|
||||
/**
|
||||
* Get analyzer dependencies (analyzers that must run before this one)
|
||||
*/
|
||||
public function getDependencies(): array;
|
||||
}
|
||||
442
src/Framework/Waf/Analysis/Analyzers/BodyAnalyzer.php
Normal file
442
src/Framework/Waf/Analysis/Analyzers/BodyAnalyzer.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Request Body Analyzer
|
||||
* Analyzes request body content for security threats
|
||||
*/
|
||||
final class BodyAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxBodySize = 10485760, // 10MB
|
||||
private readonly int $maxScanSize = 1048576, // 1MB for deep analysis
|
||||
private readonly bool $enableDeepScanning = true,
|
||||
private readonly array $suspiciousPatterns = [
|
||||
// SQL Injection patterns
|
||||
'sql_injection' => [
|
||||
'/(?i:union[\s\/\*]+(?:all[\s\/\*]+)?select)/',
|
||||
'/(?i:[\'\"`][\s]*(?:or|and)[\s]*[\'\"`]*[\s]*(?:[\'\"`]*[\w]+[\'\"`]*[\s]*=[\s]*[\'\"`]*[\w]+|[\d]+[\s]*=[\s]*[\d]+))/',
|
||||
'/(?i:(?:--|#|\/\*|\*\/))/',
|
||||
],
|
||||
// XSS patterns
|
||||
'xss' => [
|
||||
'/(?i:<script[^>]*>.*?<\/script>)/',
|
||||
'/(?i:on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\s]*=)/',
|
||||
'/(?i:javascript[\s]*:)/',
|
||||
],
|
||||
// Command injection
|
||||
'command_injection' => [
|
||||
'/(?i:(?:;|\||\|\||&&|&|`|\$\(|\${)[\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown|find|grep|awk|sed|sort|head|tail|wc|netstat|ifconfig|ping|wget|curl|nc|telnet|ssh|su|sudo|passwd|shadow|etc\/passwd|etc\/shadow|proc\/))/',
|
||||
'/(?i:(?:cmd|command)\.exe|powershell|bash|sh|zsh|csh|tcsh|fish)[\s]*(?:\/c|\/k|-c|-e)/',
|
||||
],
|
||||
// Path traversal
|
||||
'path_traversal' => [
|
||||
'/(?i:(?:\.\.[\\/])|(?:[\\/]\.\.)|(?:\.\.\\\\)|(?:\\\\\.\.)|(?:%2e%2e%2f)|(?:%2e%2e\\\\)|(?:\.\.%2f)|(?:\.\.%5c)|(?:%2e%2e%5c)|(?:%c0%ae%c0%ae%c0%af)|(?:%c1%9c%c1%9c%c1%af))/',
|
||||
'/(?i:(?:\/etc\/passwd|\/etc\/shadow|\/etc\/hosts|\/proc\/|\/sys\/|c:[\\\\\\/]|\\\\\\\\))/',
|
||||
],
|
||||
// PHP code injection
|
||||
'php_injection' => [
|
||||
'/(?i:(?:<\?(?:php)?|<\?=|\?>)|(?:eval|assert|create_function|call_user_func|call_user_func_array|preg_replace|system|exec|shell_exec|passthru|file_get_contents|file_put_contents|fopen|fwrite|include|require|include_once|require_once)[\s]*\(|(?:\$_(?:GET|POST|REQUEST|COOKIE|SESSION|SERVER|ENV)\[))/',
|
||||
],
|
||||
// LDAP injection
|
||||
'ldap_injection' => [
|
||||
'/(?i:(?:\()(?:&|\|)(?:\(|\)))/',
|
||||
],
|
||||
// XML/XXE attacks
|
||||
'xml_attacks' => [
|
||||
'/(?i:<!ENTITY[\s]+[\w]+[\s]+SYSTEM[\s]+["\'][^"\']*["\'])/',
|
||||
'/(?i:<!DOCTYPE[\s]+[\w]+[\s]*\[[\s]*<!ENTITY)/',
|
||||
],
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::BODY;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return $requestData->hasBody();
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'Body analysis completed';
|
||||
|
||||
$body = $requestData->body;
|
||||
$bodySize = strlen($body);
|
||||
|
||||
$metadata['body_size'] = $bodySize;
|
||||
$metadata['content_type'] = $requestData->contentType;
|
||||
|
||||
// Check body size limits
|
||||
if ($bodySize > $this->maxBodySize) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_body',
|
||||
'message' => "Request body too large: " . Byte::fromBytes($bodySize)->toHumanReadable(),
|
||||
'severity' => 'high',
|
||||
'size' => $bodySize,
|
||||
'limit' => $this->maxBodySize,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'Request body exceeds size limits';
|
||||
}
|
||||
|
||||
// Skip deep analysis if body is too large
|
||||
if ($bodySize > $this->maxScanSize) {
|
||||
$metadata['deep_scan_skipped'] = true;
|
||||
$metadata['scan_reason'] = 'Body too large for deep analysis';
|
||||
} elseif ($this->enableDeepScanning) {
|
||||
// Perform deep content analysis
|
||||
$deepScanResults = $this->performDeepScan($body);
|
||||
$findings = array_merge($findings, $deepScanResults['findings']);
|
||||
$metadata = array_merge($metadata, $deepScanResults['metadata']);
|
||||
|
||||
if (! empty($deepScanResults['findings'])) {
|
||||
$maxSeverity = $this->getMaxSeverityFromFindings($deepScanResults['findings']);
|
||||
if ($maxSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $maxSeverity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious encoding
|
||||
$encodingResults = $this->analyzeEncoding($body);
|
||||
if (! empty($encodingResults['findings'])) {
|
||||
$findings = array_merge($findings, $encodingResults['findings']);
|
||||
$metadata = array_merge($metadata, $encodingResults['metadata']);
|
||||
}
|
||||
|
||||
// Check for binary content in text requests
|
||||
if ($this->isBinaryContent($body) && $this->isTextContentType($requestData->contentType)) {
|
||||
$findings[] = [
|
||||
'type' => 'binary_in_text',
|
||||
'message' => 'Binary content detected in text-based request',
|
||||
'severity' => 'medium',
|
||||
'content_type' => $requestData->contentType,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (strpos($body, "\x00") !== false) {
|
||||
$findings[] = [
|
||||
'type' => 'null_byte_injection',
|
||||
'message' => 'Null byte detected in request body',
|
||||
'severity' => 'high',
|
||||
'count' => substr_count($body, "\x00"),
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Check for extremely long lines (potential buffer overflow attempts)
|
||||
$lines = explode("\n", $body);
|
||||
$maxLineLength = 0;
|
||||
foreach ($lines as $line) {
|
||||
$lineLength = strlen($line);
|
||||
if ($lineLength > $maxLineLength) {
|
||||
$maxLineLength = $lineLength;
|
||||
}
|
||||
}
|
||||
|
||||
if ($maxLineLength > 8192) {
|
||||
$findings[] = [
|
||||
'type' => 'long_line',
|
||||
'message' => "Extremely long line detected: {$maxLineLength} characters",
|
||||
'severity' => 'medium',
|
||||
'length' => $maxLineLength,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$criticalFindings = array_filter($findings, fn ($f) => $f['severity'] === 'critical');
|
||||
$highFindings = array_filter($findings, fn ($f) => $f['severity'] === 'high');
|
||||
|
||||
if (! empty($criticalFindings)) {
|
||||
$message = count($criticalFindings) === 1
|
||||
? $criticalFindings[0]['message']
|
||||
: "Critical security threats detected in request body (" . count($criticalFindings) . " threats)";
|
||||
} elseif (! empty($highFindings)) {
|
||||
$message = count($highFindings) === 1
|
||||
? $highFindings[0]['message']
|
||||
: "High-severity security issues detected in request body (" . count($highFindings) . " issues)";
|
||||
} else {
|
||||
$message = "Security issues detected in request body (" . count($findings) . " findings)";
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'BodyAnalyzer';
|
||||
$metadata['line_count'] = count($lines);
|
||||
$metadata['max_line_length'] = $maxLineLength;
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform deep content scanning for attack patterns
|
||||
*/
|
||||
private function performDeepScan(string $body): array
|
||||
{
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$patternsMatched = 0;
|
||||
|
||||
foreach ($this->suspiciousPatterns as $category => $patterns) {
|
||||
$categoryMatches = 0;
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $body, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$categoryMatches++;
|
||||
$patternsMatched++;
|
||||
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForCategory($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category,
|
||||
'message' => "Suspicious {$category} pattern detected",
|
||||
'severity' => $severity,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 100), // Truncate for safety
|
||||
'offset' => $matchOffset,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($categoryMatches > 0) {
|
||||
$metadata["matches_{$category}"] = $categoryMatches;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['deep_scan_performed'] = true;
|
||||
$metadata['patterns_checked'] = array_sum(array_map('count', $this->suspiciousPatterns));
|
||||
$metadata['patterns_matched'] = $patternsMatched;
|
||||
|
||||
return [
|
||||
'findings' => $findings,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze content encoding and detect evasion attempts
|
||||
*/
|
||||
private function analyzeEncoding(string $body): array
|
||||
{
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
|
||||
// Check for URL encoding evasion
|
||||
$urlEncodedChars = preg_match_all('/%[0-9a-fA-F]{2}/', $body);
|
||||
if ($urlEncodedChars > 50) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_url_encoding',
|
||||
'message' => "Excessive URL encoding detected: {$urlEncodedChars} encoded characters",
|
||||
'severity' => 'medium',
|
||||
'count' => $urlEncodedChars,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for Unicode evasion
|
||||
$unicodeChars = preg_match_all('/(?:%u[0-9a-fA-F]{4}|\\\\u[0-9a-fA-F]{4})/', $body);
|
||||
if ($unicodeChars > 0) {
|
||||
$findings[] = [
|
||||
'type' => 'unicode_evasion',
|
||||
'message' => "Unicode evasion attempt detected: {$unicodeChars} Unicode sequences",
|
||||
'severity' => 'medium',
|
||||
'count' => $unicodeChars,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for double encoding
|
||||
if (preg_match('/%25[0-9a-fA-F]{2}/', $body)) {
|
||||
$findings[] = [
|
||||
'type' => 'double_encoding',
|
||||
'message' => 'Double URL encoding detected (potential evasion)',
|
||||
'severity' => 'high',
|
||||
];
|
||||
}
|
||||
|
||||
$metadata['url_encoded_chars'] = $urlEncodedChars;
|
||||
$metadata['unicode_chars'] = $unicodeChars;
|
||||
|
||||
return [
|
||||
'findings' => $findings,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is binary
|
||||
*/
|
||||
private function isBinaryContent(string $content): bool
|
||||
{
|
||||
// Check for common binary indicators
|
||||
$binaryChars = ["\x00", "\x01", "\x02", "\x03", "\x04", "\x05", "\x06", "\x07", "\x08"];
|
||||
|
||||
foreach ($binaryChars as $char) {
|
||||
if (strpos($content, $char) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for high ratio of non-printable characters
|
||||
$printableChars = 0;
|
||||
$totalChars = strlen($content);
|
||||
|
||||
if ($totalChars === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < min($totalChars, 1000); $i++) {
|
||||
$char = ord($content[$i]);
|
||||
if (($char >= 32 && $char <= 126) || $char === 9 || $char === 10 || $char === 13) {
|
||||
$printableChars++;
|
||||
}
|
||||
}
|
||||
|
||||
$printableRatio = $printableChars / min($totalChars, 1000);
|
||||
|
||||
return $printableRatio < 0.85; // Less than 85% printable = likely binary
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content type is text-based
|
||||
*/
|
||||
private function isTextContentType(?string $contentType): bool
|
||||
{
|
||||
if ($contentType === null) {
|
||||
return true; // Assume text if no content type
|
||||
}
|
||||
|
||||
$textTypes = [
|
||||
'text/',
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/x-www-form-urlencoded',
|
||||
];
|
||||
|
||||
$contentType = strtolower($contentType);
|
||||
|
||||
foreach ($textTypes as $type) {
|
||||
if (str_starts_with($contentType, $type)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for attack category
|
||||
*/
|
||||
private function getSeverityForCategory(string $category): string
|
||||
{
|
||||
return match ($category) {
|
||||
'sql_injection', 'command_injection', 'php_injection' => 'critical',
|
||||
'xss', 'path_traversal', 'xml_attacks' => 'high',
|
||||
'ldap_injection' => 'medium',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum severity from findings
|
||||
*/
|
||||
private function getMaxSeverityFromFindings(array $findings): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = match ($finding['severity']) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
'low' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
|
||||
if ($severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_body_size' => $this->maxBodySize,
|
||||
'max_scan_size' => $this->maxScanSize,
|
||||
'enable_deep_scanning' => $this->enableDeepScanning,
|
||||
'pattern_categories' => array_keys($this->suspiciousPatterns),
|
||||
'total_patterns' => array_sum(array_map('count', $this->suspiciousPatterns)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
684
src/Framework/Waf/Analysis/Analyzers/CookieAnalyzer.php
Normal file
684
src/Framework/Waf/Analysis/Analyzers/CookieAnalyzer.php
Normal file
@@ -0,0 +1,684 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Cookie Security Analyzer
|
||||
* Analyzes HTTP cookies for security threats and compliance
|
||||
*/
|
||||
final class CookieAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxCookieCount = 100,
|
||||
private readonly int $maxCookieSize = 4096,
|
||||
private readonly int $maxCookieNameLength = 256,
|
||||
private readonly int $maxTotalCookieSize = 32768, // 32KB
|
||||
private readonly array $suspiciousCookieNames = [
|
||||
'admin', 'root', 'user', 'password', 'pass', 'auth', 'token',
|
||||
'session', 'sess', 'login', 'logged', 'authenticated',
|
||||
'debug', 'test', 'dev', 'development', 'config', 'settings',
|
||||
'cmd', 'command', 'exec', 'system', 'shell', 'eval',
|
||||
'sql', 'query', 'database', 'db',
|
||||
],
|
||||
private readonly array $requiredSecurityAttributes = [
|
||||
'HttpOnly', 'Secure', 'SameSite',
|
||||
],
|
||||
private readonly array $injectionPatterns = [
|
||||
// XSS patterns in cookies
|
||||
'xss' => [
|
||||
'/(?i:<script[^>]*>.*?<\\/script>)/',
|
||||
'/(?i:on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\\s]*=)/',
|
||||
'/(?i:javascript[\\s]*:)/',
|
||||
'/(?i:vbscript[\\s]*:)/',
|
||||
'/(?i:data[\\s]*:[^;]*;base64)/',
|
||||
],
|
||||
// SQL injection patterns
|
||||
'sql_injection' => [
|
||||
'/(?i:union[\\s\\/\\*]+(?:all[\\s\\/\\*]+)?select)/',
|
||||
'/(?i:[\\\'\\"`][\\s]*(?:or|and)[\\s]*[\\\'\\"`]*[\\s]*(?:[\\\'\\"`]*[\\w]+[\\\'\\"`]*[\\s]*=[\\s]*[\\\'\\"`]*[\\w]+|[\\d]+[\\s]*=[\\s]*[\\d]+))/',
|
||||
'/(?i:(?:--|#|\\/\\*|\\*\\/))/',
|
||||
],
|
||||
// Command injection
|
||||
'command_injection' => [
|
||||
'/(?i:(?:;|\\||\\|\\||&&|&|`|\\$\\(|\\${)[\\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown))/',
|
||||
'/(?i:(?:cmd|command)\\.exe|powershell|bash|sh)[\\s]*(?:\\/c|\\/k|-c|-e)/',
|
||||
],
|
||||
// Path traversal
|
||||
'path_traversal' => [
|
||||
'/(?i:(?:\\.\\.[\\\\/])|(?:[\\\\/]\\.\\.)|(?:\\.\\.\\\\\\\\)|(?:\\\\\\\\\\.\\.))/',
|
||||
'/(?i:(?:\\/etc\\/passwd|\\/etc\\/shadow|\\/proc\\/|\\/sys\\/|c:[\\\\\\\\\\\\/]))/',
|
||||
],
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::COOKIES;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return ! empty($requestData->cookies);
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'Cookie analysis completed';
|
||||
|
||||
$cookieCount = count($requestData->cookies);
|
||||
$metadata['cookie_count'] = $cookieCount;
|
||||
|
||||
// Check cookie count limits
|
||||
if ($cookieCount > $this->maxCookieCount) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_cookies',
|
||||
'message' => "Too many cookies: {$cookieCount} (max: {$this->maxCookieCount})",
|
||||
'severity' => 'medium',
|
||||
'count' => $cookieCount,
|
||||
'limit' => $this->maxCookieCount,
|
||||
];
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
$message = 'Excessive number of cookies detected';
|
||||
}
|
||||
|
||||
$totalCookieSize = 0;
|
||||
$suspiciousCookieCount = 0;
|
||||
$encodedCookieCount = 0;
|
||||
$insecureCookieCount = 0;
|
||||
$duplicateCookieCount = 0;
|
||||
|
||||
// Track cookie names for duplicate detection
|
||||
$cookieNames = [];
|
||||
|
||||
foreach ($requestData->cookies as $name => $value) {
|
||||
$cookieSize = strlen($name) + strlen($value);
|
||||
$totalCookieSize += $cookieSize;
|
||||
|
||||
// Track cookie names (case-insensitive)
|
||||
$normalizedName = strtolower($name);
|
||||
if (isset($cookieNames[$normalizedName])) {
|
||||
$cookieNames[$normalizedName]++;
|
||||
$duplicateCookieCount++;
|
||||
} else {
|
||||
$cookieNames[$normalizedName] = 1;
|
||||
}
|
||||
|
||||
// Check cookie name length
|
||||
if (strlen($name) > $this->maxCookieNameLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_cookie_name',
|
||||
'message' => "Cookie name too long: '{$name}' ({strlen($name)} chars)",
|
||||
'severity' => 'medium',
|
||||
'cookie' => $name,
|
||||
'length' => strlen($name),
|
||||
'limit' => $this->maxCookieNameLength,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check individual cookie size
|
||||
if ($cookieSize > $this->maxCookieSize) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_cookie',
|
||||
'message' => "Cookie '{$name}' is too large: {$cookieSize} bytes",
|
||||
'severity' => 'medium',
|
||||
'cookie' => $name,
|
||||
'size' => $cookieSize,
|
||||
'limit' => $this->maxCookieSize,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious cookie names
|
||||
if ($this->isSuspiciousCookieName($name)) {
|
||||
$suspiciousCookieCount++;
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_cookie_name',
|
||||
'message' => "Suspicious cookie name: '{$name}'",
|
||||
'severity' => 'low',
|
||||
'cookie' => $name,
|
||||
'value' => substr($value, 0, 100), // Truncate for safety
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO) {
|
||||
$severity = DetectionSeverity::LOW;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for injection attacks in cookie values
|
||||
$injectionResults = $this->checkForInjections($name, $value);
|
||||
if (! empty($injectionResults)) {
|
||||
$findings = array_merge($findings, $injectionResults);
|
||||
$maxInjectionSeverity = $this->getMaxSeverityFromFindings($injectionResults);
|
||||
if ($maxInjectionSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $maxInjectionSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for encoded content
|
||||
if ($this->hasEncodedContent($value)) {
|
||||
$encodedCookieCount++;
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (str_contains($value, "\x00")) {
|
||||
$findings[] = [
|
||||
'type' => 'null_byte_cookie',
|
||||
'message' => "Null byte in cookie '{$name}'",
|
||||
'severity' => 'high',
|
||||
'cookie' => $name,
|
||||
'count' => substr_count($value, "\x00"),
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value)) {
|
||||
$findings[] = [
|
||||
'type' => 'control_characters_cookie',
|
||||
'message' => "Control characters in cookie '{$name}'",
|
||||
'severity' => 'medium',
|
||||
'cookie' => $name,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CRLF injection in cookie values
|
||||
if (preg_match('/[\\r\\n]/', $value)) {
|
||||
$findings[] = [
|
||||
'type' => 'crlf_injection_cookie',
|
||||
'message' => "CRLF injection in cookie '{$name}'",
|
||||
'severity' => 'high',
|
||||
'cookie' => $name,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Check for session hijacking indicators
|
||||
if ($this->isSessionCookie($name) && $this->isSuspiciousSessionValue($value)) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_session_cookie',
|
||||
'message' => "Suspicious session cookie value: '{$name}'",
|
||||
'severity' => 'medium',
|
||||
'cookie' => $name,
|
||||
'reason' => 'Potentially manipulated session identifier',
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for authentication bypass attempts
|
||||
if ($this->isAuthenticationCookie($name) && $this->isPrivilegeEscalationAttempt($value)) {
|
||||
$findings[] = [
|
||||
'type' => 'privilege_escalation_cookie',
|
||||
'message' => "Privilege escalation attempt in cookie '{$name}'",
|
||||
'severity' => 'high',
|
||||
'cookie' => $name,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
}
|
||||
|
||||
// Check total cookie size
|
||||
if ($totalCookieSize > $this->maxTotalCookieSize) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_total_cookie_size',
|
||||
'message' => "Total cookie size too large: {$totalCookieSize} bytes (max: {$this->maxTotalCookieSize})",
|
||||
'severity' => 'medium',
|
||||
'total_size' => $totalCookieSize,
|
||||
'limit' => $this->maxTotalCookieSize,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cookie security compliance
|
||||
$securityResults = $this->analyzeSecurityCompliance($requestData);
|
||||
if (! empty($securityResults)) {
|
||||
$findings = array_merge($findings, $securityResults);
|
||||
$insecureCookieCount = count($securityResults);
|
||||
}
|
||||
|
||||
// Check for cookie tampering patterns
|
||||
$tamperingResults = $this->analyzeCookieTampering($requestData->cookies);
|
||||
if (! empty($tamperingResults)) {
|
||||
$findings = array_merge($findings, $tamperingResults);
|
||||
}
|
||||
|
||||
// Report duplicate cookies
|
||||
if ($duplicateCookieCount > 0) {
|
||||
$findings[] = [
|
||||
'type' => 'duplicate_cookies',
|
||||
'message' => "Duplicate cookie names detected: {$duplicateCookieCount} duplicates",
|
||||
'severity' => 'low',
|
||||
'count' => $duplicateCookieCount,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO) {
|
||||
$severity = DetectionSeverity::LOW;
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$criticalFindings = array_filter($findings, fn ($f) => $f['severity'] === 'critical');
|
||||
$highFindings = array_filter($findings, fn ($f) => $f['severity'] === 'high');
|
||||
|
||||
if (! empty($criticalFindings)) {
|
||||
$message = count($criticalFindings) === 1
|
||||
? $criticalFindings[0]['message']
|
||||
: "Critical security threats detected in cookies (" . count($criticalFindings) . " threats)";
|
||||
} elseif (! empty($highFindings)) {
|
||||
$message = count($highFindings) === 1
|
||||
? $highFindings[0]['message']
|
||||
: "High-severity security issues detected in cookies (" . count($highFindings) . " issues)";
|
||||
} else {
|
||||
$message = "Security issues detected in cookies (" . count($findings) . " findings)";
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['total_cookie_size'] = $totalCookieSize;
|
||||
$metadata['suspicious_cookie_count'] = $suspiciousCookieCount;
|
||||
$metadata['encoded_cookie_count'] = $encodedCookieCount;
|
||||
$metadata['insecure_cookie_count'] = $insecureCookieCount;
|
||||
$metadata['duplicate_cookie_count'] = $duplicateCookieCount;
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'CookieAnalyzer';
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cookie name is suspicious
|
||||
*/
|
||||
private function isSuspiciousCookieName(string $name): bool
|
||||
{
|
||||
$normalizedName = strtolower($name);
|
||||
|
||||
foreach ($this->suspiciousCookieNames as $suspicious) {
|
||||
if (str_contains($normalizedName, $suspicious)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for encoded cookie names
|
||||
if (preg_match('/%[0-9a-fA-F]{2}/', $name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for unusual characters in cookie names
|
||||
if (preg_match('/[^a-zA-Z0-9_-]/', $name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for injection attacks in cookie value
|
||||
*/
|
||||
private function checkForInjections(string $cookieName, string $value): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
foreach ($this->injectionPatterns as $category => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $value, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForInjectionType($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category . '_cookie',
|
||||
'message' => "Potential {$category} in cookie '{$cookieName}'",
|
||||
'severity' => $severity,
|
||||
'cookie' => $cookieName,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 100), // Truncate for safety
|
||||
'offset' => $matchOffset,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value contains encoded content
|
||||
*/
|
||||
private function hasEncodedContent(string $value): bool
|
||||
{
|
||||
// Check for URL encoding
|
||||
$urlEncodedMatches = preg_match_all('/%[0-9a-fA-F]{2}/', $value);
|
||||
if ($urlEncodedMatches > 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for Base64 encoding
|
||||
if (preg_match('/^[A-Za-z0-9+\/]{20,}={0,2}$/', trim($value))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for HTML entity encoding
|
||||
$htmlEntityMatches = preg_match_all('/&(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/', $value);
|
||||
if ($htmlEntityMatches > 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cookie is a session cookie
|
||||
*/
|
||||
private function isSessionCookie(string $name): bool
|
||||
{
|
||||
$sessionPatterns = [
|
||||
'session', 'sess', 'sid', 'sessionid', 'session_id',
|
||||
'phpsessid', 'jsessionid', 'aspsessionid',
|
||||
];
|
||||
|
||||
$normalizedName = strtolower($name);
|
||||
|
||||
foreach ($sessionPatterns as $pattern) {
|
||||
if (str_contains($normalizedName, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session value is suspicious
|
||||
*/
|
||||
private function isSuspiciousSessionValue(string $value): bool
|
||||
{
|
||||
// Check for predictable session IDs
|
||||
if (preg_match('/^(0+|1+|admin|test|guest)$/', strtolower($value))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for short session IDs (potential brute force)
|
||||
if (strlen($value) < 16) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for sequential patterns
|
||||
if (preg_match('/^(123|abc|000|111|aaa)/i', $value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cookie is authentication-related
|
||||
*/
|
||||
private function isAuthenticationCookie(string $name): bool
|
||||
{
|
||||
$authPatterns = [
|
||||
'auth', 'token', 'login', 'user', 'admin', 'role', 'permission',
|
||||
];
|
||||
|
||||
$normalizedName = strtolower($name);
|
||||
|
||||
foreach ($authPatterns as $pattern) {
|
||||
if (str_contains($normalizedName, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for privilege escalation attempts
|
||||
*/
|
||||
private function isPrivilegeEscalationAttempt(string $value): bool
|
||||
{
|
||||
$privilegePatterns = [
|
||||
'admin', 'root', 'administrator', 'superuser', 'su',
|
||||
'role=admin', 'user=admin', 'level=admin', 'type=admin',
|
||||
];
|
||||
|
||||
$normalizedValue = strtolower($value);
|
||||
|
||||
foreach ($privilegePatterns as $pattern) {
|
||||
if (str_contains($normalizedValue, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze cookie security compliance
|
||||
*/
|
||||
private function analyzeSecurityCompliance(RequestAnalysisData $requestData): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Note: This analysis is limited since we only have cookie values,
|
||||
// not the full Set-Cookie headers with security attributes.
|
||||
// In a real implementation, this would need access to response headers.
|
||||
|
||||
foreach ($requestData->cookies as $name => $value) {
|
||||
// Check for secure cookies being sent over non-HTTPS
|
||||
if ($this->isSecuritySensitiveCookie($name) && ! $this->isHttpsRequest($requestData)) {
|
||||
$findings[] = [
|
||||
'type' => 'insecure_transport',
|
||||
'message' => "Security-sensitive cookie '{$name}' sent over non-HTTPS",
|
||||
'severity' => 'medium',
|
||||
'cookie' => $name,
|
||||
'recommendation' => 'Use HTTPS for all security-sensitive cookies',
|
||||
];
|
||||
}
|
||||
|
||||
// Check for potential session fixation
|
||||
if ($this->isSessionCookie($name) && strlen($value) > 0) {
|
||||
// Simple heuristic: check if session ID looks manually crafted
|
||||
if (! ctype_alnum($value) && ! preg_match('/^[a-zA-Z0-9+\/]+=*$/', $value)) {
|
||||
$findings[] = [
|
||||
'type' => 'potential_session_fixation',
|
||||
'message' => "Potentially fixed session ID in cookie '{$name}'",
|
||||
'severity' => 'medium',
|
||||
'cookie' => $name,
|
||||
'value' => substr($value, 0, 20) . '...',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze cookie tampering patterns
|
||||
*/
|
||||
private function analyzeCookieTampering(array $cookies): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
foreach ($cookies as $name => $value) {
|
||||
// Check for timestamp manipulation
|
||||
if (preg_match('/(?:time|timestamp|expires?|date)[^=]*=(\d+)/', $value, $matches)) {
|
||||
$timestamp = (int) $matches[1];
|
||||
$currentTime = time();
|
||||
|
||||
// Check for future timestamps (possible tampering)
|
||||
if ($timestamp > $currentTime + 86400) { // More than 1 day in future
|
||||
$findings[] = [
|
||||
'type' => 'future_timestamp',
|
||||
'message' => "Future timestamp in cookie '{$name}' (possible tampering)",
|
||||
'severity' => 'medium',
|
||||
'cookie' => $name,
|
||||
'timestamp' => $timestamp,
|
||||
'current_time' => $currentTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for signature/hash manipulation indicators
|
||||
if (preg_match('/(?:sig|signature|hash|hmac|checksum)[^=]*=([^&;]+)/', $value, $matches)) {
|
||||
$signature = $matches[1];
|
||||
|
||||
// Simple checks for obviously tampered signatures
|
||||
if (strlen($signature) < 16 || $signature === 'null' || $signature === 'undefined') {
|
||||
$findings[] = [
|
||||
'type' => 'tampered_signature',
|
||||
'message' => "Potentially tampered signature in cookie '{$name}'",
|
||||
'severity' => 'high',
|
||||
'cookie' => $name,
|
||||
'signature' => $signature,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cookie is security-sensitive
|
||||
*/
|
||||
private function isSecuritySensitiveCookie(string $name): bool
|
||||
{
|
||||
return $this->isSessionCookie($name) || $this->isAuthenticationCookie($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is over HTTPS
|
||||
*/
|
||||
private function isHttpsRequest(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
// Check for HTTPS indicators in headers
|
||||
$httpsIndicators = [
|
||||
'X-Forwarded-Proto' => 'https',
|
||||
'X-Forwarded-SSL' => 'on',
|
||||
'X-Forwarded-Scheme' => 'https',
|
||||
];
|
||||
|
||||
foreach ($httpsIndicators as $header => $value) {
|
||||
$headerValue = $requestData->getHeader($header);
|
||||
if ($headerValue !== null && strtolower($headerValue) === $value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check protocol
|
||||
return $requestData->protocol !== null && str_starts_with(strtolower($requestData->protocol), 'https');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity for injection type
|
||||
*/
|
||||
private function getSeverityForInjectionType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'sql_injection', 'command_injection' => 'critical',
|
||||
'xss', 'path_traversal' => 'high',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum severity from findings
|
||||
*/
|
||||
private function getMaxSeverityFromFindings(array $findings): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = match ($finding['severity']) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
'low' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
|
||||
if ($severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_cookie_count' => $this->maxCookieCount,
|
||||
'max_cookie_size' => $this->maxCookieSize,
|
||||
'max_cookie_name_length' => $this->maxCookieNameLength,
|
||||
'max_total_cookie_size' => $this->maxTotalCookieSize,
|
||||
'suspicious_cookie_names' => $this->suspiciousCookieNames,
|
||||
'required_security_attributes' => $this->requiredSecurityAttributes,
|
||||
'injection_categories' => array_keys($this->injectionPatterns),
|
||||
'total_patterns' => array_sum(array_map('count', $this->injectionPatterns)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
639
src/Framework/Waf/Analysis/Analyzers/FileAnalyzer.php
Normal file
639
src/Framework/Waf/Analysis/Analyzers/FileAnalyzer.php
Normal file
@@ -0,0 +1,639 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* File Upload Security Analyzer
|
||||
* Analyzes file uploads for security threats including malicious files, path traversal, and content validation
|
||||
*/
|
||||
final class FileAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxFileSize = 10485760, // 10MB
|
||||
private readonly int $maxTotalFileSize = 52428800, // 50MB
|
||||
private readonly int $maxFileCount = 20,
|
||||
private readonly int $maxFilenameLength = 255,
|
||||
private readonly array $allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
'text/plain', 'text/csv', 'text/html', 'text/css', 'text/javascript',
|
||||
'application/pdf', 'application/json', 'application/xml',
|
||||
'application/zip', 'application/gzip',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
],
|
||||
private readonly array $dangerousExtensions = [
|
||||
'php', 'php3', 'php4', 'php5', 'phtml', 'pht', 'phps',
|
||||
'asp', 'aspx', 'ascx', 'ashx', 'asmx', 'cer', 'asa',
|
||||
'jsp', 'jspx', 'jsw', 'jsv', 'jspf',
|
||||
'py', 'pyc', 'pyo', 'pyw', 'pyz',
|
||||
'pl', 'pm', 'cgi',
|
||||
'rb', 'rbw',
|
||||
'sh', 'bash', 'zsh', 'csh', 'ksh', 'fish',
|
||||
'bat', 'cmd', 'com', 'pif', 'scr',
|
||||
'exe', 'msi', 'dll', 'scr', 'vbs', 'vbe', 'js', 'jse', 'ws', 'wsf', 'wsc', 'wsh',
|
||||
'ps1', 'ps1xml', 'ps2', 'ps2xml', 'psc1', 'psc2',
|
||||
'jar', 'class',
|
||||
'htaccess', 'htpasswd', 'ini', 'conf', 'config',
|
||||
],
|
||||
private readonly array $suspiciousPatterns = [
|
||||
// Web shell patterns
|
||||
'web_shell' => [
|
||||
'/(?i:eval[\\s]*\\([\\s]*\\$_(?:GET|POST|REQUEST|COOKIE)\\[)/,',
|
||||
'/(?i:system[\\s]*\\([\\s]*\\$_(?:GET|POST|REQUEST|COOKIE)\\[)/,',
|
||||
'/(?i:exec[\\s]*\\([\\s]*\\$_(?:GET|POST|REQUEST|COOKIE)\\[)/,',
|
||||
'/(?i:shell_exec[\\s]*\\([\\s]*\\$_(?:GET|POST|REQUEST|COOKIE)\\[)/,',
|
||||
'/(?i:passthru[\\s]*\\([\\s]*\\$_(?:GET|POST|REQUEST|COOKIE)\\[)/,',
|
||||
'/(?i:file_get_contents[\\s]*\\([\\s]*\\$_(?:GET|POST|REQUEST|COOKIE)\\[)/,',
|
||||
],
|
||||
// Malware signatures
|
||||
'malware' => [
|
||||
'/(?i:<%\\s*eval[\\s]*\\()/,',
|
||||
'/(?i:<script[^>]*>\\s*eval[\\s]*\\()/,',
|
||||
'/(?i:\\$[a-zA-Z_][a-zA-Z0-9_]*\\s*=\\s*["\'][^"\']*base64_decode)/,',
|
||||
'/(?i:gzinflate[\\s]*\\([\\s]*base64_decode)/,',
|
||||
'/(?i:str_rot13[\\s]*\\([\\s]*base64_decode)/,',
|
||||
],
|
||||
// Path traversal
|
||||
'path_traversal' => [
|
||||
'/(?i:(?:\\.\\.[\\\\/])|(?:[\\\\/]\\.\\.)|(?:\\.\\.\\\\\\\\)|(?:\\\\\\\\\\.\\.))/',
|
||||
'/(?i:(?:%2e%2e%2f)|(?:%2e%2e\\\\\\\\)|(?:\\.\\.%2f)|(?:\\.\\.%5c)|(?:%2e%2e%5c))/',
|
||||
],
|
||||
// Executable content
|
||||
'executable' => [
|
||||
'/^\\x7fELF/', // ELF header
|
||||
'/^MZ/', // PE header
|
||||
'/^\\xca\\xfe\\xba\\xbe/', // Java class file
|
||||
'/^\\xfe\\xed\\xfa/', // Mach-O header
|
||||
],
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::FILES;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return $requestData->hasFiles();
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'File analysis completed';
|
||||
|
||||
$files = $requestData->files;
|
||||
$fileCount = count($files);
|
||||
$totalFileSize = 0;
|
||||
|
||||
$metadata['file_count'] = $fileCount;
|
||||
|
||||
// Check file count limits
|
||||
if ($fileCount > $this->maxFileCount) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_file_count',
|
||||
'message' => "Too many files uploaded: {$fileCount} (max: {$this->maxFileCount})",
|
||||
'severity' => 'medium',
|
||||
'count' => $fileCount,
|
||||
'limit' => $this->maxFileCount,
|
||||
];
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
$message = 'Excessive number of files uploaded';
|
||||
}
|
||||
|
||||
$dangerousFileCount = 0;
|
||||
$suspiciousFileCount = 0;
|
||||
$oversizedFileCount = 0;
|
||||
|
||||
foreach ($files as $fieldName => $fileInfo) {
|
||||
if (! is_array($fileInfo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $fileInfo['name'] ?? '';
|
||||
$fileSize = $fileInfo['size'] ?? 0;
|
||||
$fileTmpName = $fileInfo['tmp_name'] ?? '';
|
||||
$fileType = $fileInfo['type'] ?? '';
|
||||
$fileError = $fileInfo['error'] ?? UPLOAD_ERR_OK;
|
||||
|
||||
$totalFileSize += $fileSize;
|
||||
|
||||
// Analyze individual file
|
||||
$fileAnalysis = $this->analyzeIndividualFile(
|
||||
$fieldName,
|
||||
$fileName,
|
||||
$fileSize,
|
||||
$fileTmpName,
|
||||
$fileType,
|
||||
$fileError
|
||||
);
|
||||
|
||||
if (! empty($fileAnalysis['findings'])) {
|
||||
$findings = array_merge($findings, $fileAnalysis['findings']);
|
||||
$fileSeverity = $this->getMaxSeverityFromFindings($fileAnalysis['findings']);
|
||||
if ($fileSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $fileSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Count file types
|
||||
if ($fileAnalysis['is_dangerous']) {
|
||||
$dangerousFileCount++;
|
||||
}
|
||||
if ($fileAnalysis['is_suspicious']) {
|
||||
$suspiciousFileCount++;
|
||||
}
|
||||
if ($fileAnalysis['is_oversized']) {
|
||||
$oversizedFileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check total file size
|
||||
if ($totalFileSize > $this->maxTotalFileSize) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_total_file_size',
|
||||
'message' => "Total file size too large: " . Byte::fromBytes($totalFileSize)->toHumanReadable() . " (max: " . Byte::fromBytes($this->maxTotalFileSize)->toHumanReadable() . ")",
|
||||
'severity' => 'high',
|
||||
'total_size' => $totalFileSize,
|
||||
'limit' => $this->maxTotalFileSize,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'Total file size exceeds limits';
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$criticalFindings = array_filter($findings, fn ($f) => $f['severity'] === 'critical');
|
||||
$highFindings = array_filter($findings, fn ($f) => $f['severity'] === 'high');
|
||||
|
||||
if (! empty($criticalFindings)) {
|
||||
$message = count($criticalFindings) === 1
|
||||
? $criticalFindings[0]['message']
|
||||
: "Critical security threats detected in file uploads (" . count($criticalFindings) . " threats)";
|
||||
} elseif (! empty($highFindings)) {
|
||||
$message = count($highFindings) === 1
|
||||
? $highFindings[0]['message']
|
||||
: "High-severity security issues detected in file uploads (" . count($highFindings) . " issues)";
|
||||
} else {
|
||||
$message = "Security issues detected in file uploads (" . count($findings) . " findings)";
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['total_file_size'] = $totalFileSize;
|
||||
$metadata['dangerous_file_count'] = $dangerousFileCount;
|
||||
$metadata['suspicious_file_count'] = $suspiciousFileCount;
|
||||
$metadata['oversized_file_count'] = $oversizedFileCount;
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'FileAnalyzer';
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze individual file upload
|
||||
*/
|
||||
private function analyzeIndividualFile(
|
||||
string $fieldName,
|
||||
string $fileName,
|
||||
int $fileSize,
|
||||
string $fileTmpName,
|
||||
string $fileType,
|
||||
int $fileError
|
||||
): array {
|
||||
$findings = [];
|
||||
$isDangerous = false;
|
||||
$isSuspicious = false;
|
||||
$isOversized = false;
|
||||
|
||||
// Check upload errors
|
||||
if ($fileError !== UPLOAD_ERR_OK) {
|
||||
$errorMessage = $this->getUploadErrorMessage($fileError);
|
||||
$findings[] = [
|
||||
'type' => 'file_upload_error',
|
||||
'message' => "File upload error for '{$fileName}': {$errorMessage}",
|
||||
'severity' => 'medium',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'error_code' => $fileError,
|
||||
'error_message' => $errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if ($fileSize > $this->maxFileSize) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_file',
|
||||
'message' => "File '{$fileName}' is too large: " . Byte::fromBytes($fileSize)->toHumanReadable(),
|
||||
'severity' => 'medium',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'size' => $fileSize,
|
||||
'limit' => $this->maxFileSize,
|
||||
];
|
||||
$isOversized = true;
|
||||
}
|
||||
|
||||
// Check filename length
|
||||
if (strlen($fileName) > $this->maxFilenameLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_filename',
|
||||
'message' => "Filename too long: '{$fileName}' (" . strlen($fileName) . " chars)",
|
||||
'severity' => 'medium',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'length' => strlen($fileName),
|
||||
'limit' => $this->maxFilenameLength,
|
||||
];
|
||||
}
|
||||
|
||||
// Check filename for path traversal
|
||||
if (preg_match('/(?:\\.\\.[\\\\/])|(?:[\\\\/]\\.\\.)|(?:\\.\\.\\\\\\\\)|(?:\\\\\\\\\\.\\.)|(?:%2e%2e%2f)|(?:%2e%2e\\\\\\\\)|(?:\\.\\.%2f)|(?:\\.\\.%5c)|(?:%2e%2e%5c)/', $fileName)) {
|
||||
$findings[] = [
|
||||
'type' => 'path_traversal_filename',
|
||||
'message' => "Path traversal attempt in filename: '{$fileName}'",
|
||||
'severity' => 'high',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
];
|
||||
$isDangerous = true;
|
||||
}
|
||||
|
||||
// Check for null bytes in filename
|
||||
if (str_contains($fileName, "\x00")) {
|
||||
$findings[] = [
|
||||
'type' => 'null_byte_filename',
|
||||
'message' => "Null byte in filename: '{$fileName}'",
|
||||
'severity' => 'high',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
];
|
||||
$isDangerous = true;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (! empty($extension) && in_array($extension, $this->dangerousExtensions, true)) {
|
||||
$severity = match ($extension) {
|
||||
'php', 'php3', 'php4', 'php5', 'phtml', 'asp', 'aspx', 'jsp' => 'critical',
|
||||
'exe', 'bat', 'cmd', 'sh', 'py', 'pl', 'rb' => 'high',
|
||||
default => 'medium'
|
||||
};
|
||||
|
||||
$findings[] = [
|
||||
'type' => 'dangerous_file_extension',
|
||||
'message' => "Dangerous file extension: .{$extension} in '{$fileName}'",
|
||||
'severity' => $severity,
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'extension' => $extension,
|
||||
];
|
||||
$isDangerous = true;
|
||||
}
|
||||
|
||||
// Check MIME type
|
||||
if (! empty($fileType) && ! in_array($fileType, $this->allowedMimeTypes, true)) {
|
||||
$findings[] = [
|
||||
'type' => 'disallowed_mime_type',
|
||||
'message' => "Disallowed MIME type: {$fileType} for '{$fileName}'",
|
||||
'severity' => 'medium',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'mime_type' => $fileType,
|
||||
];
|
||||
$isSuspicious = true;
|
||||
}
|
||||
|
||||
// Analyze file content if accessible
|
||||
if (! empty($fileTmpName) && is_file($fileTmpName) && is_readable($fileTmpName)) {
|
||||
$contentAnalysis = $this->analyzeFileContent($fileTmpName, $fileName, $fieldName);
|
||||
$findings = array_merge($findings, $contentAnalysis['findings']);
|
||||
|
||||
if ($contentAnalysis['is_dangerous']) {
|
||||
$isDangerous = true;
|
||||
}
|
||||
if ($contentAnalysis['is_suspicious']) {
|
||||
$isSuspicious = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for double extensions (e.g., file.jpg.php)
|
||||
if (preg_match('/\\.([a-zA-Z0-9]+)\\.([a-zA-Z0-9]+)$/', $fileName, $matches)) {
|
||||
$firstExt = strtolower($matches[1]);
|
||||
$secondExt = strtolower($matches[2]);
|
||||
|
||||
if (in_array($secondExt, $this->dangerousExtensions, true)) {
|
||||
$findings[] = [
|
||||
'type' => 'double_extension',
|
||||
'message' => "Double extension detected: .{$firstExt}.{$secondExt} in '{$fileName}'",
|
||||
'severity' => 'high',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'first_extension' => $firstExt,
|
||||
'second_extension' => $secondExt,
|
||||
];
|
||||
$isDangerous = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious filename patterns
|
||||
$suspiciousPatterns = [
|
||||
'/(?i:shell|backdoor|webshell|c99|r57|b374k|wso|idx_file)/',
|
||||
'/(?i:eval|exec|system|cmd|command)/',
|
||||
'/(?i:config|passwd|shadow|htaccess|htpasswd)/',
|
||||
];
|
||||
|
||||
foreach ($suspiciousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $fileName)) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_filename_pattern',
|
||||
'message' => "Suspicious filename pattern: '{$fileName}'",
|
||||
'severity' => 'medium',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'pattern' => $pattern,
|
||||
];
|
||||
$isSuspicious = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'findings' => $findings,
|
||||
'is_dangerous' => $isDangerous,
|
||||
'is_suspicious' => $isSuspicious,
|
||||
'is_oversized' => $isOversized,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze file content for threats
|
||||
*/
|
||||
private function analyzeFileContent(string $filePath, string $fileName, string $fieldName): array
|
||||
{
|
||||
$findings = [];
|
||||
$isDangerous = false;
|
||||
$isSuspicious = false;
|
||||
|
||||
try {
|
||||
// Read first few KB for analysis
|
||||
$handle = fopen($filePath, 'rb');
|
||||
if ($handle === false) {
|
||||
return ['findings' => [], 'is_dangerous' => false, 'is_suspicious' => false];
|
||||
}
|
||||
|
||||
$content = fread($handle, 8192); // Read first 8KB
|
||||
fclose($handle);
|
||||
|
||||
if ($content === false) {
|
||||
return ['findings' => [], 'is_dangerous' => false, 'is_suspicious' => false];
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
foreach ($this->suspiciousPatterns as $category => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForContentType($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category . '_content',
|
||||
'message' => "Suspicious {$category} content in '{$fileName}'",
|
||||
'severity' => $severity,
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 50), // Truncate for safety
|
||||
'offset' => $matchOffset,
|
||||
'category' => $category,
|
||||
];
|
||||
|
||||
if ($severity === 'critical' || $severity === 'high') {
|
||||
$isDangerous = true;
|
||||
} else {
|
||||
$isSuspicious = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for embedded files (polyglot attacks)
|
||||
if ($this->detectEmbeddedFiles($content)) {
|
||||
$findings[] = [
|
||||
'type' => 'embedded_file',
|
||||
'message' => "Embedded file detected in '{$fileName}' (polyglot attack)",
|
||||
'severity' => 'high',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
];
|
||||
$isDangerous = true;
|
||||
}
|
||||
|
||||
// Check MIME type vs actual content
|
||||
$detectedMimeType = $this->detectMimeType($content);
|
||||
$declaredMimeType = mime_content_type($filePath);
|
||||
|
||||
if ($detectedMimeType && $declaredMimeType && $detectedMimeType !== $declaredMimeType) {
|
||||
$findings[] = [
|
||||
'type' => 'mime_type_mismatch',
|
||||
'message' => "MIME type mismatch in '{$fileName}': detected {$detectedMimeType}, declared {$declaredMimeType}",
|
||||
'severity' => 'medium',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'detected_mime' => $detectedMimeType,
|
||||
'declared_mime' => $declaredMimeType,
|
||||
];
|
||||
$isSuspicious = true;
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$findings[] = [
|
||||
'type' => 'content_analysis_error',
|
||||
'message' => "Error analyzing file content for '{$fileName}': " . $e->getMessage(),
|
||||
'severity' => 'low',
|
||||
'field' => $fieldName,
|
||||
'filename' => $fileName,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'findings' => $findings,
|
||||
'is_dangerous' => $isDangerous,
|
||||
'is_suspicious' => $isSuspicious,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect embedded files (polyglot attacks)
|
||||
*/
|
||||
private function detectEmbeddedFiles(string $content): bool
|
||||
{
|
||||
$signatures = [
|
||||
'zip' => 'PK',
|
||||
'rar' => 'Rar!',
|
||||
'pdf' => '%PDF',
|
||||
'exe' => 'MZ',
|
||||
'elf' => "\x7fELF",
|
||||
'java' => "\xca\xfe\xba\xbe",
|
||||
];
|
||||
|
||||
$detectedCount = 0;
|
||||
foreach ($signatures as $type => $signature) {
|
||||
if (str_contains($content, $signature)) {
|
||||
$detectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If more than one file signature is detected, it's likely a polyglot
|
||||
return $detectedCount > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from content
|
||||
*/
|
||||
private function detectMimeType(string $content): ?string
|
||||
{
|
||||
$signatures = [
|
||||
'image/jpeg' => ["\xff\xd8\xff"],
|
||||
'image/png' => ["\x89PNG\x0d\x0a\x1a\x0a"],
|
||||
'image/gif' => ["GIF87a", "GIF89a"],
|
||||
'application/pdf' => ["%PDF"],
|
||||
'application/zip' => ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"],
|
||||
'text/html' => ["<!DOCTYPE", "<html", "<HTML"],
|
||||
'text/xml' => ["<?xml"],
|
||||
];
|
||||
|
||||
foreach ($signatures as $mimeType => $sigs) {
|
||||
foreach ($sigs as $signature) {
|
||||
if (str_starts_with($content, $signature)) {
|
||||
return $mimeType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upload error message
|
||||
*/
|
||||
private function getUploadErrorMessage(int $errorCode): string
|
||||
{
|
||||
return match ($errorCode) {
|
||||
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
|
||||
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
|
||||
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
|
||||
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
|
||||
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension',
|
||||
default => "Unknown upload error (code: {$errorCode})"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity for content type
|
||||
*/
|
||||
private function getSeverityForContentType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'web_shell', 'malware', 'executable' => 'critical',
|
||||
'path_traversal' => 'high',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum severity from findings
|
||||
*/
|
||||
private function getMaxSeverityFromFindings(array $findings): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = match ($finding['severity']) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
'low' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
|
||||
if ($severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_file_size' => $this->maxFileSize,
|
||||
'max_total_file_size' => $this->maxTotalFileSize,
|
||||
'max_file_count' => $this->maxFileCount,
|
||||
'max_filename_length' => $this->maxFilenameLength,
|
||||
'allowed_mime_types' => $this->allowedMimeTypes,
|
||||
'dangerous_extensions' => $this->dangerousExtensions,
|
||||
'pattern_categories' => array_keys($this->suspiciousPatterns),
|
||||
'total_patterns' => array_sum(array_map('count', $this->suspiciousPatterns)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
299
src/Framework/Waf/Analysis/Analyzers/HeaderAnalyzer.php
Normal file
299
src/Framework/Waf/Analysis/Analyzers/HeaderAnalyzer.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* HTTP Headers Analyzer
|
||||
* Analyzes HTTP headers for security threats and compliance
|
||||
*/
|
||||
final class HeaderAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxHeaderSize = 8192,
|
||||
private readonly int $maxHeaderCount = 100,
|
||||
private readonly array $requiredHeaders = ['Host'],
|
||||
private readonly array $suspiciousHeaders = [
|
||||
'X-Forwarded-Host', 'X-Original-URL', 'X-Rewrite-URL',
|
||||
'X-Real-IP', 'X-Cluster-Client-IP',
|
||||
],
|
||||
private readonly array $forbiddenHeaders = [
|
||||
'X-Debug', 'X-Dev-Mode', 'X-Test-Mode',
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::HEADERS;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return true; // All requests have headers
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'Header analysis completed';
|
||||
|
||||
// Check header count
|
||||
$headerCount = count($requestData->headers);
|
||||
$metadata['header_count'] = $headerCount;
|
||||
|
||||
if ($headerCount > $this->maxHeaderCount) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_headers',
|
||||
'message' => "Too many headers: {$headerCount} (max: {$this->maxHeaderCount})",
|
||||
'severity' => 'high',
|
||||
'value' => $headerCount,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'Excessive number of headers detected';
|
||||
}
|
||||
|
||||
// Check individual header sizes and content
|
||||
foreach ($requestData->headers as $name => $value) {
|
||||
$headerSize = strlen($name) + strlen($value);
|
||||
|
||||
if ($headerSize > $this->maxHeaderSize) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_header',
|
||||
'message' => "Header '{$name}' is too large: {$headerSize} bytes",
|
||||
'severity' => 'medium',
|
||||
'header' => $name,
|
||||
'size' => $headerSize,
|
||||
];
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
|
||||
// Check for forbidden headers
|
||||
if (in_array($name, $this->forbiddenHeaders, true)) {
|
||||
$findings[] = [
|
||||
'type' => 'forbidden_header',
|
||||
'message' => "Forbidden header detected: {$name}",
|
||||
'severity' => 'high',
|
||||
'header' => $name,
|
||||
'value' => $value,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'Forbidden headers detected';
|
||||
}
|
||||
|
||||
// Check for suspicious headers
|
||||
if (in_array($name, $this->suspiciousHeaders, true)) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_header',
|
||||
'message' => "Suspicious header detected: {$name}",
|
||||
'severity' => 'low',
|
||||
'header' => $name,
|
||||
'value' => $value,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO) {
|
||||
$severity = DetectionSeverity::LOW;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for injection attempts in headers
|
||||
$injectionPatterns = [
|
||||
'/[\r\n]/' => 'Header injection (CRLF)',
|
||||
'/\x00/' => 'Null byte injection',
|
||||
'/<script/i' => 'XSS attempt in header',
|
||||
'/javascript:/i' => 'JavaScript pseudo-protocol',
|
||||
'/data:/i' => 'Data URL scheme',
|
||||
];
|
||||
|
||||
foreach ($injectionPatterns as $pattern => $description) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
$findings[] = [
|
||||
'type' => 'header_injection',
|
||||
'message' => "{$description} in header '{$name}'",
|
||||
'severity' => 'critical',
|
||||
'header' => $name,
|
||||
'pattern' => $pattern,
|
||||
'value' => substr($value, 0, 100), // Truncate for safety
|
||||
];
|
||||
$severity = DetectionSeverity::CRITICAL;
|
||||
$message = 'Header injection attack detected';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required headers
|
||||
foreach ($this->requiredHeaders as $requiredHeader) {
|
||||
if (! $requestData->hasHeader($requiredHeader)) {
|
||||
$findings[] = [
|
||||
'type' => 'missing_required_header',
|
||||
'message' => "Required header missing: {$requiredHeader}",
|
||||
'severity' => 'medium',
|
||||
'header' => $requiredHeader,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Host header specifically
|
||||
$hostHeader = $requestData->getHeader('Host');
|
||||
if ($hostHeader !== null) {
|
||||
$metadata['host'] = $hostHeader;
|
||||
|
||||
// Check for host header injection
|
||||
if (preg_match('/[\r\n\x00]/', $hostHeader)) {
|
||||
$findings[] = [
|
||||
'type' => 'host_injection',
|
||||
'message' => 'Host header injection detected',
|
||||
'severity' => 'critical',
|
||||
'value' => $hostHeader,
|
||||
];
|
||||
$severity = DetectionSeverity::CRITICAL;
|
||||
$message = 'Host header injection detected';
|
||||
}
|
||||
|
||||
// Check for suspicious host values
|
||||
$suspiciousHostPatterns = [
|
||||
'/^\d+\.\d+\.\d+\.\d+$/' => 'IP address as host',
|
||||
'/localhost|127\.0\.0\.1/' => 'Localhost as host',
|
||||
'/[<>"\']/' => 'Special characters in host',
|
||||
];
|
||||
|
||||
foreach ($suspiciousHostPatterns as $pattern => $description) {
|
||||
if (preg_match($pattern, $hostHeader)) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_host',
|
||||
'message' => "{$description}: {$hostHeader}",
|
||||
'severity' => 'low',
|
||||
'value' => $hostHeader,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO) {
|
||||
$severity = DetectionSeverity::LOW;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check User-Agent header
|
||||
$userAgent = $requestData->getHeader('User-Agent');
|
||||
if ($userAgent !== null) {
|
||||
$metadata['user_agent_length'] = strlen($userAgent);
|
||||
|
||||
// Check for empty or suspicious user agents
|
||||
if (empty(trim($userAgent))) {
|
||||
$findings[] = [
|
||||
'type' => 'empty_user_agent',
|
||||
'message' => 'Empty User-Agent header',
|
||||
'severity' => 'low',
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO) {
|
||||
$severity = DetectionSeverity::LOW;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extremely long user agent
|
||||
if (strlen($userAgent) > 1000) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_user_agent',
|
||||
'message' => 'Extremely long User-Agent header',
|
||||
'severity' => 'medium',
|
||||
'length' => strlen($userAgent),
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Content-Length vs actual body size
|
||||
if ($requestData->hasBody() && $requestData->contentLength !== null) {
|
||||
$actualSize = strlen($requestData->body);
|
||||
$declaredSize = $requestData->contentLength->toBytes();
|
||||
|
||||
if ($actualSize !== $declaredSize) {
|
||||
$findings[] = [
|
||||
'type' => 'content_length_mismatch',
|
||||
'message' => "Content-Length mismatch: declared {$declaredSize}, actual {$actualSize}",
|
||||
'severity' => 'medium',
|
||||
'declared_size' => $declaredSize,
|
||||
'actual_size' => $actualSize,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$message = count($findings) === 1
|
||||
? $findings[0]['message']
|
||||
: "Multiple header security issues detected (" . count($findings) . " findings)";
|
||||
}
|
||||
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'HeaderAnalyzer';
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_header_size' => $this->maxHeaderSize,
|
||||
'max_header_count' => $this->maxHeaderCount,
|
||||
'required_headers' => $this->requiredHeaders,
|
||||
'suspicious_headers' => $this->suspiciousHeaders,
|
||||
'forbidden_headers' => $this->forbiddenHeaders,
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
637
src/Framework/Waf/Analysis/Analyzers/JsonAnalyzer.php
Normal file
637
src/Framework/Waf/Analysis/Analyzers/JsonAnalyzer.php
Normal file
@@ -0,0 +1,637 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* JSON Payload Structure Analyzer
|
||||
* Analyzes JSON payloads for security threats and structural anomalies
|
||||
*/
|
||||
final class JsonAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxJsonDepth = 32,
|
||||
private readonly int $maxJsonSize = 1048576, // 1MB
|
||||
private readonly int $maxArrayElements = 10000,
|
||||
private readonly int $maxObjectProperties = 1000,
|
||||
private readonly int $maxStringLength = 65536, // 64KB
|
||||
private readonly array $suspiciousPropertyNames = [
|
||||
'eval', 'exec', 'system', 'shell', 'command', 'cmd',
|
||||
'constructor', 'prototype', '__proto__', '__defineGetter__', '__defineSetter__',
|
||||
'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf',
|
||||
'script', 'javascript', 'vbscript', 'onload', 'onerror',
|
||||
'admin', 'root', 'password', 'pass', 'auth', 'token',
|
||||
'config', 'settings', 'debug', 'test', 'dev',
|
||||
],
|
||||
private readonly array $injectionPatterns = [
|
||||
// JavaScript injection patterns
|
||||
'javascript_injection' => [
|
||||
'/(?i:function[\\s]*\\()/,',
|
||||
'/(?i:(?:eval|exec|setTimeout|setInterval)[\\s]*\\()/,',
|
||||
'/(?i:(?:alert|confirm|prompt)[\\s]*\\()/,',
|
||||
'/(?i:(?:document|window|navigator)[\\s]*\\.)/,',
|
||||
],
|
||||
// NoSQL injection patterns
|
||||
'nosql_injection' => [
|
||||
'/(?i:\\$(?:where|ne|eq|gt|gte|lt|lte|in|nin|regex|exists|type|size|all|elemMatch))/',
|
||||
'/(?i:\\{\\s*\\$(?:where|ne|eq|gt|gte|lt|lte|in|nin|regex|exists|type|size|all|elemMatch))/',
|
||||
'/(?i:(?:this|obj|db)\\s*\\.)/',
|
||||
],
|
||||
// SQL injection in JSON
|
||||
'sql_injection' => [
|
||||
'/(?i:union[\\s\\/\\*]+(?:all[\\s\\/\\*]+)?select)/',
|
||||
'/(?i:[\\\'\\"`][\\s]*(?:or|and)[\\s]*[\\\'\\"`]*[\\s]*(?:[\\\'\\"`]*[\\w]+[\\\'\\"`]*[\\s]*=[\\s]*[\\\'\\"`]*[\\w]+|[\\d]+[\\s]*=[\\s]*[\\d]+))/',
|
||||
'/(?i:(?:--|#|\\/\\*|\\*\\/))/',
|
||||
'/(?i:(?:information_schema|sys\\.tables|mysql\\.user|pg_tables))/',
|
||||
],
|
||||
// XSS patterns
|
||||
'xss' => [
|
||||
'/(?i:<script[^>]*>.*?<\\/script>)/',
|
||||
'/(?i:on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\\s]*=)/',
|
||||
'/(?i:javascript[\\s]*:)/',
|
||||
'/(?i:vbscript[\\s]*:)/',
|
||||
'/(?i:data[\\s]*:[^;]*;base64)/',
|
||||
],
|
||||
// Command injection
|
||||
'command_injection' => [
|
||||
'/(?i:(?:;|\\||\\|\\||&&|&|`|\\$\\(|\\${)[\\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown|find|grep|awk|sed|sort|head|tail|wc|netstat|ifconfig|ping|wget|curl|nc|telnet|ssh|su|sudo))/',
|
||||
'/(?i:(?:cmd|command)\\.exe|powershell|bash|sh|zsh|csh|tcsh|fish)[\\s]*(?:\\/c|\\/k|-c|-e)/',
|
||||
],
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::JSON;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return $requestData->isJson() && ! empty($requestData->body);
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'JSON analysis completed';
|
||||
|
||||
$jsonBody = $requestData->body;
|
||||
$jsonSize = strlen($jsonBody);
|
||||
|
||||
$metadata['json_size'] = $jsonSize;
|
||||
$metadata['content_type'] = $requestData->contentType;
|
||||
|
||||
// Check JSON size limits
|
||||
if ($jsonSize > $this->maxJsonSize) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_json',
|
||||
'message' => "JSON payload too large: {$jsonSize} bytes (max: {$this->maxJsonSize})",
|
||||
'severity' => 'high',
|
||||
'size' => $jsonSize,
|
||||
'limit' => $this->maxJsonSize,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'JSON payload exceeds size limits';
|
||||
}
|
||||
|
||||
// Attempt to parse JSON
|
||||
$jsonData = null;
|
||||
$parseError = null;
|
||||
|
||||
try {
|
||||
$jsonData = json_decode($jsonBody, true, $this->maxJsonDepth, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$parseError = $e->getMessage();
|
||||
$findings[] = [
|
||||
'type' => 'json_parse_error',
|
||||
'message' => "JSON parsing failed: {$parseError}",
|
||||
'severity' => 'medium',
|
||||
'error' => $parseError,
|
||||
];
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
|
||||
if ($jsonData !== null) {
|
||||
// Analyze JSON structure
|
||||
$structureAnalysis = $this->analyzeJsonStructure($jsonData);
|
||||
$metadata = array_merge($metadata, $structureAnalysis['metadata']);
|
||||
if (! empty($structureAnalysis['findings'])) {
|
||||
$findings = array_merge($findings, $structureAnalysis['findings']);
|
||||
$structureSeverity = $this->getMaxSeverityFromFindings($structureAnalysis['findings']);
|
||||
if ($structureSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $structureSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for injection patterns in JSON content
|
||||
$injectionResults = $this->checkForInjections($jsonData);
|
||||
if (! empty($injectionResults)) {
|
||||
$findings = array_merge($findings, $injectionResults);
|
||||
$injectionSeverity = $this->getMaxSeverityFromFindings($injectionResults);
|
||||
if ($injectionSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $injectionSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious property names
|
||||
$propertyResults = $this->analyzeSuspiciousProperties($jsonData);
|
||||
if (! empty($propertyResults)) {
|
||||
$findings = array_merge($findings, $propertyResults);
|
||||
}
|
||||
|
||||
// Check for prototype pollution attempts
|
||||
$pollutionResults = $this->checkPrototypePollution($jsonData);
|
||||
if (! empty($pollutionResults)) {
|
||||
$findings = array_merge($findings, $pollutionResults);
|
||||
$pollutionSeverity = $this->getMaxSeverityFromFindings($pollutionResults);
|
||||
if ($pollutionSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $pollutionSeverity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for JSON bombing patterns in raw string
|
||||
$bombingResults = $this->checkJsonBombing($jsonBody);
|
||||
if (! empty($bombingResults)) {
|
||||
$findings = array_merge($findings, $bombingResults);
|
||||
}
|
||||
|
||||
// Check for encoding attacks
|
||||
$encodingResults = $this->analyzeJsonEncoding($jsonBody);
|
||||
if (! empty($encodingResults)) {
|
||||
$findings = array_merge($findings, $encodingResults);
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (str_contains($jsonBody, "\x00")) {
|
||||
$findings[] = [
|
||||
'type' => 'null_byte_json',
|
||||
'message' => "Null byte in JSON payload",
|
||||
'severity' => 'high',
|
||||
'count' => substr_count($jsonBody, "\x00"),
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$criticalFindings = array_filter($findings, fn ($f) => $f['severity'] === 'critical');
|
||||
$highFindings = array_filter($findings, fn ($f) => $f['severity'] === 'high');
|
||||
|
||||
if (! empty($criticalFindings)) {
|
||||
$message = count($criticalFindings) === 1
|
||||
? $criticalFindings[0]['message']
|
||||
: "Critical security threats detected in JSON (" . count($criticalFindings) . " threats)";
|
||||
} elseif (! empty($highFindings)) {
|
||||
$message = count($highFindings) === 1
|
||||
? $highFindings[0]['message']
|
||||
: "High-severity security issues detected in JSON (" . count($highFindings) . " issues)";
|
||||
} else {
|
||||
$message = "Security issues detected in JSON (" . count($findings) . " findings)";
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'JsonAnalyzer';
|
||||
$metadata['parse_successful'] = $jsonData !== null;
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze JSON structure for anomalies
|
||||
*/
|
||||
private function analyzeJsonStructure($jsonData): array
|
||||
{
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
|
||||
$analysis = $this->analyzeJsonElement($jsonData, '', 0);
|
||||
|
||||
$metadata['max_depth'] = $analysis['max_depth'];
|
||||
$metadata['total_elements'] = $analysis['total_elements'];
|
||||
$metadata['total_arrays'] = $analysis['total_arrays'];
|
||||
$metadata['total_objects'] = $analysis['total_objects'];
|
||||
$metadata['total_strings'] = $analysis['total_strings'];
|
||||
$metadata['max_array_size'] = $analysis['max_array_size'];
|
||||
$metadata['max_object_size'] = $analysis['max_object_size'];
|
||||
$metadata['max_string_length'] = $analysis['max_string_length'];
|
||||
|
||||
// Check depth limits
|
||||
if ($analysis['max_depth'] > $this->maxJsonDepth) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_json_depth',
|
||||
'message' => "JSON nesting too deep: {$analysis['max_depth']} levels (max: {$this->maxJsonDepth})",
|
||||
'severity' => 'high',
|
||||
'depth' => $analysis['max_depth'],
|
||||
'limit' => $this->maxJsonDepth,
|
||||
];
|
||||
}
|
||||
|
||||
// Check array size limits
|
||||
if ($analysis['max_array_size'] > $this->maxArrayElements) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_json_array',
|
||||
'message' => "JSON array too large: {$analysis['max_array_size']} elements (max: {$this->maxArrayElements})",
|
||||
'severity' => 'medium',
|
||||
'size' => $analysis['max_array_size'],
|
||||
'limit' => $this->maxArrayElements,
|
||||
];
|
||||
}
|
||||
|
||||
// Check object property limits
|
||||
if ($analysis['max_object_size'] > $this->maxObjectProperties) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_json_object',
|
||||
'message' => "JSON object too large: {$analysis['max_object_size']} properties (max: {$this->maxObjectProperties})",
|
||||
'severity' => 'medium',
|
||||
'size' => $analysis['max_object_size'],
|
||||
'limit' => $this->maxObjectProperties,
|
||||
];
|
||||
}
|
||||
|
||||
// Check string length limits
|
||||
if ($analysis['max_string_length'] > $this->maxStringLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_json_string',
|
||||
'message' => "JSON string too long: {$analysis['max_string_length']} characters (max: {$this->maxStringLength})",
|
||||
'severity' => 'medium',
|
||||
'length' => $analysis['max_string_length'],
|
||||
'limit' => $this->maxStringLength,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'findings' => $findings,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively analyze JSON elements
|
||||
*/
|
||||
private function analyzeJsonElement($element, string $path, int $depth): array
|
||||
{
|
||||
$analysis = [
|
||||
'max_depth' => $depth,
|
||||
'total_elements' => 1,
|
||||
'total_arrays' => 0,
|
||||
'total_objects' => 0,
|
||||
'total_strings' => 0,
|
||||
'max_array_size' => 0,
|
||||
'max_object_size' => 0,
|
||||
'max_string_length' => 0,
|
||||
];
|
||||
|
||||
if (is_array($element)) {
|
||||
$analysis['total_arrays'] = 1;
|
||||
$analysis['max_array_size'] = count($element);
|
||||
|
||||
foreach ($element as $index => $value) {
|
||||
$childPath = $path . '[' . $index . ']';
|
||||
$childAnalysis = $this->analyzeJsonElement($value, $childPath, $depth + 1);
|
||||
|
||||
$analysis['max_depth'] = max($analysis['max_depth'], $childAnalysis['max_depth']);
|
||||
$analysis['total_elements'] += $childAnalysis['total_elements'];
|
||||
$analysis['total_arrays'] += $childAnalysis['total_arrays'];
|
||||
$analysis['total_objects'] += $childAnalysis['total_objects'];
|
||||
$analysis['total_strings'] += $childAnalysis['total_strings'];
|
||||
$analysis['max_array_size'] = max($analysis['max_array_size'], $childAnalysis['max_array_size']);
|
||||
$analysis['max_object_size'] = max($analysis['max_object_size'], $childAnalysis['max_object_size']);
|
||||
$analysis['max_string_length'] = max($analysis['max_string_length'], $childAnalysis['max_string_length']);
|
||||
}
|
||||
} elseif (is_object($element) || (is_array($element) && $this->isAssociativeArray($element))) {
|
||||
$analysis['total_objects'] = 1;
|
||||
$properties = is_object($element) ? get_object_vars($element) : $element;
|
||||
$analysis['max_object_size'] = count($properties);
|
||||
|
||||
foreach ($properties as $key => $value) {
|
||||
$childPath = $path . '.' . $key;
|
||||
$childAnalysis = $this->analyzeJsonElement($value, $childPath, $depth + 1);
|
||||
|
||||
$analysis['max_depth'] = max($analysis['max_depth'], $childAnalysis['max_depth']);
|
||||
$analysis['total_elements'] += $childAnalysis['total_elements'];
|
||||
$analysis['total_arrays'] += $childAnalysis['total_arrays'];
|
||||
$analysis['total_objects'] += $childAnalysis['total_objects'];
|
||||
$analysis['total_strings'] += $childAnalysis['total_strings'];
|
||||
$analysis['max_array_size'] = max($analysis['max_array_size'], $childAnalysis['max_array_size']);
|
||||
$analysis['max_object_size'] = max($analysis['max_object_size'], $childAnalysis['max_object_size']);
|
||||
$analysis['max_string_length'] = max($analysis['max_string_length'], $childAnalysis['max_string_length']);
|
||||
}
|
||||
} elseif (is_string($element)) {
|
||||
$analysis['total_strings'] = 1;
|
||||
$analysis['max_string_length'] = strlen($element);
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if array is associative (object-like)
|
||||
*/
|
||||
private function isAssociativeArray(array $array): bool
|
||||
{
|
||||
if (empty($array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_keys($array) !== range(0, count($array) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for injection patterns in JSON data
|
||||
*/
|
||||
private function checkForInjections($jsonData): array
|
||||
{
|
||||
$findings = [];
|
||||
$jsonString = json_encode($jsonData);
|
||||
|
||||
foreach ($this->injectionPatterns as $category => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $jsonString, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForInjectionType($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category,
|
||||
'message' => "Potential {$category} in JSON payload",
|
||||
'severity' => $severity,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 100), // Truncate for safety
|
||||
'offset' => $matchOffset,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze suspicious property names
|
||||
*/
|
||||
private function analyzeSuspiciousProperties($jsonData): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
$this->traverseJsonForProperties($jsonData, '', function ($path, $key, $value) use (&$findings) {
|
||||
if (in_array(strtolower($key), $this->suspiciousPropertyNames, true)) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_json_property',
|
||||
'message' => "Suspicious property name: '{$key}' at {$path}",
|
||||
'severity' => 'medium',
|
||||
'property' => $key,
|
||||
'path' => $path,
|
||||
'value' => is_string($value) ? substr($value, 0, 100) : gettype($value),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for prototype pollution attempts
|
||||
*/
|
||||
private function checkPrototypePollution($jsonData): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
$pollutionPatterns = [
|
||||
'__proto__', 'constructor', 'prototype',
|
||||
'__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
|
||||
];
|
||||
|
||||
$this->traverseJsonForProperties($jsonData, '', function ($path, $key, $value) use (&$findings, $pollutionPatterns) {
|
||||
if (in_array($key, $pollutionPatterns, true)) {
|
||||
$findings[] = [
|
||||
'type' => 'prototype_pollution',
|
||||
'message' => "Prototype pollution attempt: '{$key}' at {$path}",
|
||||
'severity' => 'critical',
|
||||
'property' => $key,
|
||||
'path' => $path,
|
||||
'attack_type' => 'JavaScript prototype pollution',
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse JSON structure and call callback for each property
|
||||
*/
|
||||
private function traverseJsonForProperties($data, string $path, callable $callback): void
|
||||
{
|
||||
if (is_array($data)) {
|
||||
if ($this->isAssociativeArray($data)) {
|
||||
// Treat as object
|
||||
foreach ($data as $key => $value) {
|
||||
$currentPath = $path ? "{$path}.{$key}" : $key;
|
||||
$callback($currentPath, $key, $value);
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$this->traverseJsonForProperties($value, $currentPath, $callback);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Treat as array
|
||||
foreach ($data as $index => $value) {
|
||||
$currentPath = $path ? "{$path}[{$index}]" : "[{$index}]";
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$this->traverseJsonForProperties($value, $currentPath, $callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif (is_object($data)) {
|
||||
foreach (get_object_vars($data) as $key => $value) {
|
||||
$currentPath = $path ? "{$path}.{$key}" : $key;
|
||||
$callback($currentPath, $key, $value);
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$this->traverseJsonForProperties($value, $currentPath, $callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for JSON bombing patterns
|
||||
*/
|
||||
private function checkJsonBombing(string $jsonBody): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Check for excessive repetition
|
||||
$repetitionPattern = '/(.{10,50})(?:\1){10,}/';
|
||||
if (preg_match($repetitionPattern, $jsonBody, $matches)) {
|
||||
$findings[] = [
|
||||
'type' => 'json_bombing_repetition',
|
||||
'message' => 'Excessive repetition detected (potential JSON bomb)',
|
||||
'severity' => 'high',
|
||||
'pattern' => substr($matches[1], 0, 20) . '...',
|
||||
'repetitions' => substr_count($jsonBody, $matches[1]),
|
||||
];
|
||||
}
|
||||
|
||||
// Check for extremely nested structures in raw JSON
|
||||
$nestingLevel = 0;
|
||||
$maxNesting = 0;
|
||||
$length = strlen($jsonBody);
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$char = $jsonBody[$i];
|
||||
if ($char === '{' || $char === '[') {
|
||||
$nestingLevel++;
|
||||
$maxNesting = max($maxNesting, $nestingLevel);
|
||||
} elseif ($char === '}' || $char === ']') {
|
||||
$nestingLevel--;
|
||||
}
|
||||
}
|
||||
|
||||
if ($maxNesting > 100) {
|
||||
$findings[] = [
|
||||
'type' => 'json_bombing_nesting',
|
||||
'message' => "Extremely deep nesting detected: {$maxNesting} levels",
|
||||
'severity' => 'high',
|
||||
'nesting_level' => $maxNesting,
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze JSON encoding for attacks
|
||||
*/
|
||||
private function analyzeJsonEncoding(string $jsonBody): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Check for Unicode escapes
|
||||
$unicodeEscapes = preg_match_all('/\\\\u[0-9a-fA-F]{4}/', $jsonBody);
|
||||
if ($unicodeEscapes > 50) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_unicode_escapes',
|
||||
'message' => "Excessive Unicode escapes: {$unicodeEscapes} sequences",
|
||||
'severity' => 'medium',
|
||||
'count' => $unicodeEscapes,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for potential bypass attempts using Unicode
|
||||
if (preg_match('/\\\\u00[0-9a-fA-F]{2}/', $jsonBody)) {
|
||||
$findings[] = [
|
||||
'type' => 'unicode_bypass_attempt',
|
||||
'message' => 'Potential Unicode-based bypass attempt',
|
||||
'severity' => 'medium',
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity for injection type
|
||||
*/
|
||||
private function getSeverityForInjectionType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'javascript_injection', 'nosql_injection', 'sql_injection' => 'critical',
|
||||
'xss', 'command_injection' => 'high',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum severity from findings
|
||||
*/
|
||||
private function getMaxSeverityFromFindings(array $findings): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = match ($finding['severity']) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
'low' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
|
||||
if ($severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_json_depth' => $this->maxJsonDepth,
|
||||
'max_json_size' => $this->maxJsonSize,
|
||||
'max_array_elements' => $this->maxArrayElements,
|
||||
'max_object_properties' => $this->maxObjectProperties,
|
||||
'max_string_length' => $this->maxStringLength,
|
||||
'suspicious_property_names' => $this->suspiciousPropertyNames,
|
||||
'injection_categories' => array_keys($this->injectionPatterns),
|
||||
'total_patterns' => array_sum(array_map('count', $this->injectionPatterns)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
523
src/Framework/Waf/Analysis/Analyzers/ParameterAnalyzer.php
Normal file
523
src/Framework/Waf/Analysis/Analyzers/ParameterAnalyzer.php
Normal file
@@ -0,0 +1,523 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Request Parameter Analyzer
|
||||
* Analyzes query parameters and form data for security threats
|
||||
*/
|
||||
final class ParameterAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxParameterCount = 1000,
|
||||
private readonly int $maxParameterSize = 8192,
|
||||
private readonly int $maxParameterNameLength = 256,
|
||||
private readonly int $maxTotalParameterSize = 1048576, // 1MB
|
||||
private readonly array $suspiciousParameterNames = [
|
||||
'cmd', 'command', 'exec', 'system', 'shell', 'eval', 'query',
|
||||
'sql', 'union', 'select', 'insert', 'update', 'delete', 'drop',
|
||||
'script', 'javascript', 'onload', 'onerror', 'onclick',
|
||||
'file', 'path', 'dir', 'directory', 'upload', 'download',
|
||||
'admin', 'root', 'user', 'password', 'pass', 'auth', 'login',
|
||||
'debug', 'test', 'dev', 'development', 'config', 'settings',
|
||||
],
|
||||
private readonly array $injectionPatterns = [
|
||||
// SQL Injection patterns
|
||||
'sql_injection' => [
|
||||
'/(?i:union[\\s\\/\\*]+(?:all[\\s\\/\\*]+)?select)/',
|
||||
'/(?i:[\\\'\\"`][\\s]*(?:or|and)[\\s]*[\\\'\\"`]*[\\s]*(?:[\\\'\\"`]*[\\w]+[\\\'\\"`]*[\\s]*=[\\s]*[\\\'\\"`]*[\\w]+|[\\d]+[\\s]*=[\\s]*[\\d]+))/',
|
||||
'/(?i:(?:--|#|\\/\\*|\\*\\/))/',
|
||||
'/(?i:(?:information_schema|sys\\.tables|mysql\\.user|pg_tables))/',
|
||||
],
|
||||
// XSS patterns
|
||||
'xss' => [
|
||||
'/(?i:<script[^>]*>.*?<\\/script>)/',
|
||||
'/(?i:on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\\s]*=)/',
|
||||
'/(?i:javascript[\\s]*:)/',
|
||||
'/(?i:vbscript[\\s]*:)/',
|
||||
'/(?i:data[\\s]*:[^;]*;base64)/',
|
||||
],
|
||||
// Command injection
|
||||
'command_injection' => [
|
||||
'/(?i:(?:;|\\||\\|\\||&&|&|`|\\$\\(|\\${)[\\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown|find|grep|awk|sed|sort|head|tail|wc|netstat|ifconfig|ping|wget|curl|nc|telnet|ssh|su|sudo))/',
|
||||
'/(?i:(?:cmd|command)\\.exe|powershell|bash|sh|zsh|csh|tcsh|fish)[\\s]*(?:\\/c|\\/k|-c|-e)/',
|
||||
],
|
||||
// Path traversal
|
||||
'path_traversal' => [
|
||||
'/(?i:(?:\\.\\.[\\\\/])|(?:[\\\\/]\\.\\.)|(?:\\.\\.\\\\\\\\)|(?:\\\\\\\\\\.\\.)|(?:%2e%2e%2f)|(?:%2e%2e\\\\\\\\)|(?:\\.\\.%2f)|(?:\\.\\.%5c)|(?:%2e%2e%5c))/',
|
||||
'/(?i:(?:\\/etc\\/passwd|\\/etc\\/shadow|\\/etc\\/hosts|\\/proc\\/|\\/sys\\/|c:[\\\\\\\\\\\\/]|\\\\\\\\\\\\\\\\))/',
|
||||
],
|
||||
// LDAP injection
|
||||
'ldap_injection' => [
|
||||
'/(?i:(?:\\()(?:&|\\|)(?:\\(|\\)))/',
|
||||
'/(?i:(?:\\*\\)|\\(cn=|\\(uid=|\\(objectClass=))/',
|
||||
],
|
||||
// NoSQL injection
|
||||
'nosql_injection' => [
|
||||
'/(?i:\\$(?:where|ne|eq|gt|gte|lt|lte|in|nin|regex|exists|type|size|all|elemMatch))/',
|
||||
'/(?i:\\{\\s*\\$(?:where|ne|eq|gt|gte|lt|lte|in|nin|regex|exists|type|size|all|elemMatch))/',
|
||||
],
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::PARAMETERS;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return ! empty($requestData->queryParameters) || ! empty($requestData->postParameters);
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'Parameter analysis completed';
|
||||
|
||||
$allParameters = $requestData->getAllParameters();
|
||||
$parameterCount = count($allParameters);
|
||||
|
||||
$metadata['parameter_count'] = $parameterCount;
|
||||
$metadata['query_parameter_count'] = count($requestData->queryParameters);
|
||||
$metadata['post_parameter_count'] = count($requestData->postParameters);
|
||||
|
||||
// Check parameter count limits
|
||||
if ($parameterCount > $this->maxParameterCount) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_parameters',
|
||||
'message' => "Too many parameters: {$parameterCount} (max: {$this->maxParameterCount})",
|
||||
'severity' => 'high',
|
||||
'count' => $parameterCount,
|
||||
'limit' => $this->maxParameterCount,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'Excessive number of parameters detected';
|
||||
}
|
||||
|
||||
// Calculate total parameter size
|
||||
$totalSize = 0;
|
||||
$suspiciousParameterCount = 0;
|
||||
$encodedParameterCount = 0;
|
||||
$duplicateParameterCount = 0;
|
||||
|
||||
// Track parameter names for duplicate detection
|
||||
$parameterNames = [];
|
||||
|
||||
foreach ($allParameters as $name => $value) {
|
||||
$parameterSize = strlen($name) + strlen($value);
|
||||
$totalSize += $parameterSize;
|
||||
|
||||
// Track parameter names (case-insensitive)
|
||||
$normalizedName = strtolower($name);
|
||||
if (isset($parameterNames[$normalizedName])) {
|
||||
$parameterNames[$normalizedName]++;
|
||||
$duplicateParameterCount++;
|
||||
} else {
|
||||
$parameterNames[$normalizedName] = 1;
|
||||
}
|
||||
|
||||
// Check parameter name length
|
||||
if (strlen($name) > $this->maxParameterNameLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_parameter_name',
|
||||
'message' => "Parameter name too long: '{$name}' ({strlen($name)} chars)",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $name,
|
||||
'length' => strlen($name),
|
||||
'limit' => $this->maxParameterNameLength,
|
||||
];
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
|
||||
// Check individual parameter size
|
||||
if ($parameterSize > $this->maxParameterSize) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_parameter',
|
||||
'message' => "Parameter '{$name}' is too large: {$parameterSize} bytes",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $name,
|
||||
'size' => $parameterSize,
|
||||
'limit' => $this->maxParameterSize,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious parameter names
|
||||
if ($this->isSuspiciousParameterName($name)) {
|
||||
$suspiciousParameterCount++;
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_parameter_name',
|
||||
'message' => "Suspicious parameter name: '{$name}'",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $name,
|
||||
'value' => substr($value, 0, 100), // Truncate for safety
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for injection attacks in parameter values
|
||||
$injectionResults = $this->checkForInjections($name, $value);
|
||||
if (! empty($injectionResults)) {
|
||||
$findings = array_merge($findings, $injectionResults);
|
||||
$maxInjectionSeverity = $this->getMaxSeverityFromFindings($injectionResults);
|
||||
if ($maxInjectionSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $maxInjectionSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for encoded content
|
||||
if ($this->hasEncodedContent($value)) {
|
||||
$encodedParameterCount++;
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (str_contains($value, "\x00")) {
|
||||
$findings[] = [
|
||||
'type' => 'null_byte_parameter',
|
||||
'message' => "Null byte in parameter '{$name}'",
|
||||
'severity' => 'high',
|
||||
'parameter' => $name,
|
||||
'count' => substr_count($value, "\x00"),
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value)) {
|
||||
$findings[] = [
|
||||
'type' => 'control_characters',
|
||||
'message' => "Control characters in parameter '{$name}'",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $name,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extremely long values
|
||||
if (strlen($value) > 10000) {
|
||||
$findings[] = [
|
||||
'type' => 'extremely_long_parameter',
|
||||
'message' => "Extremely long parameter value: '{$name}' ({strlen($value)} chars)",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $name,
|
||||
'length' => strlen($value),
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check total parameter size
|
||||
if ($totalSize > $this->maxTotalParameterSize) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_total_parameter_size',
|
||||
'message' => "Total parameter size too large: {$totalSize} bytes (max: {$this->maxTotalParameterSize})",
|
||||
'severity' => 'high',
|
||||
'total_size' => $totalSize,
|
||||
'limit' => $this->maxTotalParameterSize,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'Total parameter size exceeds limits';
|
||||
}
|
||||
|
||||
// Check for suspicious patterns in parameter combinations
|
||||
$parameterPatternResults = $this->analyzeParameterPatterns($allParameters);
|
||||
if (! empty($parameterPatternResults)) {
|
||||
$findings = array_merge($findings, $parameterPatternResults);
|
||||
}
|
||||
|
||||
// Report duplicate parameters
|
||||
if ($duplicateParameterCount > 0) {
|
||||
$findings[] = [
|
||||
'type' => 'duplicate_parameters',
|
||||
'message' => "Duplicate parameter names detected: {$duplicateParameterCount} duplicates",
|
||||
'severity' => 'low',
|
||||
'count' => $duplicateParameterCount,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO) {
|
||||
$severity = DetectionSeverity::LOW;
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$criticalFindings = array_filter($findings, fn ($f) => $f['severity'] === 'critical');
|
||||
$highFindings = array_filter($findings, fn ($f) => $f['severity'] === 'high');
|
||||
|
||||
if (! empty($criticalFindings)) {
|
||||
$message = count($criticalFindings) === 1
|
||||
? $criticalFindings[0]['message']
|
||||
: "Critical security threats detected in parameters (" . count($criticalFindings) . " threats)";
|
||||
} elseif (! empty($highFindings)) {
|
||||
$message = count($highFindings) === 1
|
||||
? $highFindings[0]['message']
|
||||
: "High-severity security issues detected in parameters (" . count($highFindings) . " issues)";
|
||||
} else {
|
||||
$message = "Security issues detected in parameters (" . count($findings) . " findings)";
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['total_parameter_size'] = $totalSize;
|
||||
$metadata['suspicious_parameter_count'] = $suspiciousParameterCount;
|
||||
$metadata['encoded_parameter_count'] = $encodedParameterCount;
|
||||
$metadata['duplicate_parameter_count'] = $duplicateParameterCount;
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'ParameterAnalyzer';
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parameter name is suspicious
|
||||
*/
|
||||
private function isSuspiciousParameterName(string $name): bool
|
||||
{
|
||||
$normalizedName = strtolower($name);
|
||||
|
||||
foreach ($this->suspiciousParameterNames as $suspicious) {
|
||||
if (str_contains($normalizedName, $suspicious)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common attack parameter patterns
|
||||
$suspiciousPatterns = [
|
||||
'/^(?:cmd|exec|system|eval|query)$/',
|
||||
'/(?:sql|union|select|insert|update|delete|drop)/',
|
||||
'/(?:script|javascript|onload|onerror)/',
|
||||
'/(?:file|path|dir|\.\.\/|\.\.\\\\)/',
|
||||
'/(?:admin|root|password|auth|login|debug)/',
|
||||
];
|
||||
|
||||
foreach ($suspiciousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $normalizedName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for injection attacks in parameter value
|
||||
*/
|
||||
private function checkForInjections(string $parameterName, string $value): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
foreach ($this->injectionPatterns as $category => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $value, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForInjectionType($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category,
|
||||
'message' => "Potential {$category} in parameter '{$parameterName}'",
|
||||
'severity' => $severity,
|
||||
'parameter' => $parameterName,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 100), // Truncate for safety
|
||||
'offset' => $matchOffset,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value contains encoded content
|
||||
*/
|
||||
private function hasEncodedContent(string $value): bool
|
||||
{
|
||||
// Check for URL encoding
|
||||
$urlEncodedMatches = preg_match_all('/%[0-9a-fA-F]{2}/', $value);
|
||||
if ($urlEncodedMatches > 5) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for HTML entity encoding
|
||||
$htmlEntityMatches = preg_match_all('/&(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/', $value);
|
||||
if ($htmlEntityMatches > 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for Base64 encoding (rough heuristic)
|
||||
if (preg_match('/^[A-Za-z0-9+\/]{20,}={0,2}$/', trim($value))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze parameter patterns for advanced attacks
|
||||
*/
|
||||
private function analyzeParameterPatterns(array $parameters): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Check for SQL injection through parameter names
|
||||
$sqlParameterPatterns = ['union', 'select', 'insert', 'update', 'delete', 'drop', 'create', 'alter'];
|
||||
foreach ($parameters as $name => $value) {
|
||||
foreach ($sqlParameterPatterns as $sqlPattern) {
|
||||
if (stripos($name, $sqlPattern) !== false) {
|
||||
$findings[] = [
|
||||
'type' => 'sql_parameter_name',
|
||||
'message' => "SQL keyword in parameter name: '{$name}'",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $name,
|
||||
'keyword' => $sqlPattern,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for parameter pollution attack patterns
|
||||
$parameterCounts = array_count_values(array_keys($parameters));
|
||||
foreach ($parameterCounts as $paramName => $count) {
|
||||
if ($count > 1) {
|
||||
$findings[] = [
|
||||
'type' => 'parameter_pollution',
|
||||
'message' => "Parameter pollution detected: '{$paramName}' appears {$count} times",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $paramName,
|
||||
'count' => $count,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for file upload indicators in non-multipart requests
|
||||
$fileIndicators = ['filename', 'file', 'upload', 'attachment'];
|
||||
foreach ($parameters as $name => $value) {
|
||||
$normalizedName = strtolower($name);
|
||||
foreach ($fileIndicators as $indicator) {
|
||||
if (str_contains($normalizedName, $indicator) && strlen($value) > 1000) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_file_parameter',
|
||||
'message' => "Large file-related parameter: '{$name}' ({strlen($value)} bytes)",
|
||||
'severity' => 'medium',
|
||||
'parameter' => $name,
|
||||
'size' => strlen($value),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity for injection type
|
||||
*/
|
||||
private function getSeverityForInjectionType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'sql_injection', 'command_injection', 'nosql_injection' => 'critical',
|
||||
'xss', 'path_traversal' => 'high',
|
||||
'ldap_injection' => 'medium',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum severity from findings
|
||||
*/
|
||||
private function getMaxSeverityFromFindings(array $findings): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = match ($finding['severity']) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
'low' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
|
||||
if ($severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_parameter_count' => $this->maxParameterCount,
|
||||
'max_parameter_size' => $this->maxParameterSize,
|
||||
'max_parameter_name_length' => $this->maxParameterNameLength,
|
||||
'max_total_parameter_size' => $this->maxTotalParameterSize,
|
||||
'suspicious_parameter_names' => $this->suspiciousParameterNames,
|
||||
'injection_categories' => array_keys($this->injectionPatterns),
|
||||
'total_patterns' => array_sum(array_map('count', $this->injectionPatterns)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
618
src/Framework/Waf/Analysis/Analyzers/UrlAnalyzer.php
Normal file
618
src/Framework/Waf/Analysis/Analyzers/UrlAnalyzer.php
Normal file
@@ -0,0 +1,618 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use Normalizer;
|
||||
|
||||
/**
|
||||
* URL Path and Structure Analyzer
|
||||
* Analyzes URL paths for security threats and suspicious patterns
|
||||
*/
|
||||
final class UrlAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxUrlLength = 2048,
|
||||
private readonly int $maxPathLength = 1024,
|
||||
private readonly int $maxPathDepth = 20,
|
||||
private readonly int $maxQueryStringLength = 1024,
|
||||
private readonly array $suspiciousPathPatterns = [
|
||||
// Path traversal patterns
|
||||
'path_traversal' => [
|
||||
'/(?i:(?:\\.\\.[\\\\/])|(?:[\\\\/]\\.\\.)|(?:\\.\\.\\\\\\\\)|(?:\\\\\\\\\\.\\.))/',
|
||||
'/(?i:(?:%2e%2e%2f)|(?:%2e%2e\\\\\\\\)|(?:\\.\\.%2f)|(?:\\.\\.%5c)|(?:%2e%2e%5c))/',
|
||||
'/(?i:(?:%c0%ae%c0%ae%c0%af)|(?:%c1%9c%c1%9c%c1%af))/', // Unicode bypasses
|
||||
],
|
||||
// File access patterns
|
||||
'file_access' => [
|
||||
'/(?i:(?:\\/etc\\/passwd|\\/etc\\/shadow|\\/etc\\/hosts|\\/proc\\/|\\/sys\\/))/',
|
||||
'/(?i:(?:c:[\\\\\\\\\\\\/]|\\\\\\\\\\\\\\\\|windows[\\\\\\\\\\\\/]system32))/',
|
||||
'/(?i:(?:\\.htaccess|\\.htpasswd|web\\.config|php\\.ini|httpd\\.conf))/',
|
||||
],
|
||||
// Script execution patterns
|
||||
'script_execution' => [
|
||||
'/(?i:\\.(?:php|asp|aspx|jsp|py|pl|cgi|sh|bat|cmd|exe)(?:[\\?\\/]|$))/',
|
||||
'/(?i:(?:eval|exec|system|shell_exec|passthru|file_get_contents)\\s*\\()/',
|
||||
],
|
||||
// SQL injection in URLs
|
||||
'sql_injection' => [
|
||||
'/(?i:union[\\s\\/\\*]+(?:all[\\s\\/\\*]+)?select)/',
|
||||
'/(?i:[\\\'\\"`][\\s]*(?:or|and)[\\s]*[\\\'\\"`]*[\\s]*(?:[\\\'\\"`]*[\\w]+[\\\'\\"`]*[\\s]*=[\\s]*[\\\'\\"`]*[\\w]+|[\\d]+[\\s]*=[\\s]*[\\d]+))/',
|
||||
'/(?i:(?:--|#|\\/\\*|\\*\\/))/',
|
||||
'/(?i:(?:information_schema|sys\\.tables|mysql\\.user|pg_tables))/',
|
||||
],
|
||||
// XSS patterns in URLs
|
||||
'xss' => [
|
||||
'/(?i:<script[^>]*>.*?<\\/script>)/',
|
||||
'/(?i:on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\\s]*=)/',
|
||||
'/(?i:javascript[\\s]*:)/',
|
||||
'/(?i:vbscript[\\s]*:)/',
|
||||
'/(?i:data[\\s]*:[^;]*;base64)/',
|
||||
],
|
||||
// Command injection
|
||||
'command_injection' => [
|
||||
'/(?i:(?:;|\\||\\|\\||&&|&|`|\\$\\(|\\${)[\\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown|find|grep|awk|sed|sort|head|tail|wc|netstat|ifconfig|ping|wget|curl|nc|telnet|ssh|su|sudo))/',
|
||||
'/(?i:(?:cmd|command)\\.exe|powershell|bash|sh|zsh|csh|tcsh|fish)[\\s]*(?:\\/c|\\/k|-c|-e)/',
|
||||
],
|
||||
],
|
||||
private readonly array $suspiciousFileExtensions = [
|
||||
'php', 'asp', 'aspx', 'jsp', 'py', 'pl', 'cgi', 'sh', 'bat', 'cmd', 'exe',
|
||||
'config', 'ini', 'conf', 'log', 'backup', 'bak', 'old', 'tmp', 'temp',
|
||||
],
|
||||
private readonly array $adminPathIndicators = [
|
||||
'admin', 'administrator', 'management', 'manager', 'control', 'panel',
|
||||
'dashboard', 'backend', 'console', 'config', 'settings', 'setup',
|
||||
],
|
||||
private readonly array $debugPathIndicators = [
|
||||
'debug', 'test', 'dev', 'development', 'staging', 'phpinfo',
|
||||
'info', 'status', 'health', 'metrics', 'stats',
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::URL;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return ! empty($requestData->url) || ! empty($requestData->path);
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'URL analysis completed';
|
||||
|
||||
$url = $requestData->url;
|
||||
$path = $requestData->path;
|
||||
$queryString = $requestData->queryString;
|
||||
|
||||
$metadata['url_length'] = strlen($url);
|
||||
$metadata['path_length'] = strlen($path);
|
||||
$metadata['query_string_length'] = strlen($queryString);
|
||||
|
||||
// Check URL length limits
|
||||
if (strlen($url) > $this->maxUrlLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_url',
|
||||
'message' => "URL too long: " . strlen($url) . " chars (max: {$this->maxUrlLength})",
|
||||
'severity' => 'medium',
|
||||
'length' => strlen($url),
|
||||
'limit' => $this->maxUrlLength,
|
||||
];
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
$message = 'URL exceeds length limits';
|
||||
}
|
||||
|
||||
// Check path length limits
|
||||
if (strlen($path) > $this->maxPathLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_path',
|
||||
'message' => "Path too long: " . strlen($path) . " chars (max: {$this->maxPathLength})",
|
||||
'severity' => 'medium',
|
||||
'length' => strlen($path),
|
||||
'limit' => $this->maxPathLength,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check query string length
|
||||
if (strlen($queryString) > $this->maxQueryStringLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_query_string',
|
||||
'message' => "Query string too long: " . strlen($queryString) . " chars (max: {$this->maxQueryStringLength})",
|
||||
'severity' => 'medium',
|
||||
'length' => strlen($queryString),
|
||||
'limit' => $this->maxQueryStringLength,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze path structure
|
||||
$pathAnalysis = $this->analyzePathStructure($path);
|
||||
$metadata = array_merge($metadata, $pathAnalysis['metadata']);
|
||||
if (! empty($pathAnalysis['findings'])) {
|
||||
$findings = array_merge($findings, $pathAnalysis['findings']);
|
||||
$pathSeverity = $this->getMaxSeverityFromFindings($pathAnalysis['findings']);
|
||||
if ($pathSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $pathSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious patterns in entire URL
|
||||
$patternResults = $this->checkSuspiciousPatterns($url);
|
||||
if (! empty($patternResults)) {
|
||||
$findings = array_merge($findings, $patternResults);
|
||||
$patternSeverity = $this->getMaxSeverityFromFindings($patternResults);
|
||||
if ($patternSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $patternSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for encoding evasion attempts
|
||||
$encodingResults = $this->analyzeEncoding($url);
|
||||
if (! empty($encodingResults)) {
|
||||
$findings = array_merge($findings, $encodingResults);
|
||||
}
|
||||
|
||||
// Check for administrative path access
|
||||
if ($this->isAdminPath($path)) {
|
||||
$findings[] = [
|
||||
'type' => 'admin_path_access',
|
||||
'message' => "Access to administrative path: {$path}",
|
||||
'severity' => 'medium',
|
||||
'path' => $path,
|
||||
'reason' => 'Administrative interface access attempt',
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for debug/development path access
|
||||
if ($this->isDebugPath($path)) {
|
||||
$findings[] = [
|
||||
'type' => 'debug_path_access',
|
||||
'message' => "Access to debug/development path: {$path}",
|
||||
'severity' => 'low',
|
||||
'path' => $path,
|
||||
'reason' => 'Debug interface access attempt',
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO) {
|
||||
$severity = DetectionSeverity::LOW;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious file extensions
|
||||
$fileExtensionResults = $this->analyzeFileExtension($path);
|
||||
if (! empty($fileExtensionResults)) {
|
||||
$findings = array_merge($findings, $fileExtensionResults);
|
||||
}
|
||||
|
||||
// Check for null bytes in URL
|
||||
if (str_contains($url, "\x00")) {
|
||||
$findings[] = [
|
||||
'type' => 'null_byte_url',
|
||||
'message' => "Null byte in URL",
|
||||
'severity' => 'high',
|
||||
'count' => substr_count($url, "\x00"),
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $url)) {
|
||||
$findings[] = [
|
||||
'type' => 'control_characters_url',
|
||||
'message' => "Control characters in URL",
|
||||
'severity' => 'medium',
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Unicode normalization attacks
|
||||
$unicodeResults = $this->analyzeUnicodeThreats($url);
|
||||
if (! empty($unicodeResults)) {
|
||||
$findings = array_merge($findings, $unicodeResults);
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$criticalFindings = array_filter($findings, fn ($f) => $f['severity'] === 'critical');
|
||||
$highFindings = array_filter($findings, fn ($f) => $f['severity'] === 'high');
|
||||
|
||||
if (! empty($criticalFindings)) {
|
||||
$message = count($criticalFindings) === 1
|
||||
? $criticalFindings[0]['message']
|
||||
: "Critical security threats detected in URL (" . count($criticalFindings) . " threats)";
|
||||
} elseif (! empty($highFindings)) {
|
||||
$message = count($highFindings) === 1
|
||||
? $highFindings[0]['message']
|
||||
: "High-severity security issues detected in URL (" . count($highFindings) . " issues)";
|
||||
} else {
|
||||
$message = "Security issues detected in URL (" . count($findings) . " findings)";
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'UrlAnalyzer';
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze path structure and depth
|
||||
*/
|
||||
private function analyzePathStructure(string $path): array
|
||||
{
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
|
||||
// Split path into segments
|
||||
$segments = array_filter(explode('/', trim($path, '/')));
|
||||
$depth = count($segments);
|
||||
|
||||
$metadata['path_depth'] = $depth;
|
||||
$metadata['path_segments'] = count($segments);
|
||||
|
||||
// Check path depth
|
||||
if ($depth > $this->maxPathDepth) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_path_depth',
|
||||
'message' => "Path depth too deep: {$depth} levels (max: {$this->maxPathDepth})",
|
||||
'severity' => 'medium',
|
||||
'depth' => $depth,
|
||||
'limit' => $this->maxPathDepth,
|
||||
];
|
||||
}
|
||||
|
||||
// Analyze individual segments
|
||||
foreach ($segments as $index => $segment) {
|
||||
// Check for empty segments (double slashes)
|
||||
if (empty($segment)) {
|
||||
$findings[] = [
|
||||
'type' => 'empty_path_segment',
|
||||
'message' => "Empty path segment at position {$index}",
|
||||
'severity' => 'low',
|
||||
'position' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for extremely long segments
|
||||
if (strlen($segment) > 255) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_path_segment',
|
||||
'message' => "Path segment too long: " . strlen($segment) . " chars",
|
||||
'severity' => 'medium',
|
||||
'segment' => substr($segment, 0, 50) . '...',
|
||||
'length' => strlen($segment),
|
||||
'position' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for encoded segments
|
||||
if (preg_match('/%[0-9a-fA-F]{2}/', $segment)) {
|
||||
$metadata['encoded_segments'] = ($metadata['encoded_segments'] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Check for suspicious characters in segments
|
||||
if (preg_match('/[<>"\']/', $segment)) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_path_characters',
|
||||
'message' => "Suspicious characters in path segment: {$segment}",
|
||||
'severity' => 'low',
|
||||
'segment' => $segment,
|
||||
'position' => $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'findings' => $findings,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for suspicious patterns in URL
|
||||
*/
|
||||
private function checkSuspiciousPatterns(string $url): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
foreach ($this->suspiciousPathPatterns as $category => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $url, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForCategory($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category,
|
||||
'message' => "Suspicious {$category} pattern in URL",
|
||||
'severity' => $severity,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 100), // Truncate for safety
|
||||
'offset' => $matchOffset,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze URL encoding and detect evasion attempts
|
||||
*/
|
||||
private function analyzeEncoding(string $url): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Check for excessive URL encoding
|
||||
$urlEncodedChars = preg_match_all('/%[0-9a-fA-F]{2}/', $url);
|
||||
if ($urlEncodedChars > 20) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_url_encoding',
|
||||
'message' => "Excessive URL encoding: {$urlEncodedChars} encoded characters",
|
||||
'severity' => 'medium',
|
||||
'count' => $urlEncodedChars,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for double encoding
|
||||
if (preg_match('/%25[0-9a-fA-F]{2}/', $url)) {
|
||||
$findings[] = [
|
||||
'type' => 'double_encoding',
|
||||
'message' => 'Double URL encoding detected (potential evasion)',
|
||||
'severity' => 'high',
|
||||
];
|
||||
}
|
||||
|
||||
// Check for Unicode evasion
|
||||
$unicodeChars = preg_match_all('/(?:%u[0-9a-fA-F]{4}|\\\\\\\\u[0-9a-fA-F]{4})/', $url);
|
||||
if ($unicodeChars > 0) {
|
||||
$findings[] = [
|
||||
'type' => 'unicode_evasion',
|
||||
'message' => "Unicode evasion attempt: {$unicodeChars} Unicode sequences",
|
||||
'severity' => 'medium',
|
||||
'count' => $unicodeChars,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for alternative encoding schemes
|
||||
if (preg_match('/(?:%c0%ae|%c1%9c|%c0%2f|%c1%8s)/', $url)) {
|
||||
$findings[] = [
|
||||
'type' => 'alternative_encoding',
|
||||
'message' => 'Alternative encoding scheme detected (potential bypass)',
|
||||
'severity' => 'high',
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path indicates administrative access
|
||||
*/
|
||||
private function isAdminPath(string $path): bool
|
||||
{
|
||||
$normalizedPath = strtolower($path);
|
||||
|
||||
foreach ($this->adminPathIndicators as $indicator) {
|
||||
if (str_contains($normalizedPath, $indicator)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path indicates debug/development access
|
||||
*/
|
||||
private function isDebugPath(string $path): bool
|
||||
{
|
||||
$normalizedPath = strtolower($path);
|
||||
|
||||
foreach ($this->debugPathIndicators as $indicator) {
|
||||
if (str_contains($normalizedPath, $indicator)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze file extension in path
|
||||
*/
|
||||
private function analyzeFileExtension(string $path): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Extract file extension
|
||||
$pathInfo = pathinfo($path);
|
||||
$extension = strtolower($pathInfo['extension'] ?? '');
|
||||
|
||||
if (! empty($extension)) {
|
||||
if (in_array($extension, $this->suspiciousFileExtensions, true)) {
|
||||
$severity = match ($extension) {
|
||||
'php', 'asp', 'aspx', 'jsp', 'py', 'pl', 'cgi' => 'medium',
|
||||
'sh', 'bat', 'cmd', 'exe' => 'high',
|
||||
'config', 'ini', 'conf' => 'low',
|
||||
default => 'low'
|
||||
};
|
||||
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_file_extension',
|
||||
'message' => "Access to file with suspicious extension: .{$extension}",
|
||||
'severity' => $severity,
|
||||
'extension' => $extension,
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for backup file indicators
|
||||
if (preg_match('/\.(backup|bak|old|tmp|temp|~)$/i', $path)) {
|
||||
$findings[] = [
|
||||
'type' => 'backup_file_access',
|
||||
'message' => "Access to potential backup file: {$path}",
|
||||
'severity' => 'medium',
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Unicode-based threats
|
||||
*/
|
||||
private function analyzeUnicodeThreats(string $url): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Check for homograph attacks (similar-looking Unicode characters)
|
||||
if (preg_match('/[^\x00-\x7F]/', $url)) {
|
||||
$findings[] = [
|
||||
'type' => 'unicode_characters',
|
||||
'message' => 'Non-ASCII Unicode characters in URL (potential homograph attack)',
|
||||
'severity' => 'low',
|
||||
];
|
||||
}
|
||||
|
||||
// Check for Unicode normalization attacks (if Normalizer extension is available)
|
||||
if (class_exists('Normalizer')) {
|
||||
$normalized = Normalizer::normalize($url, Normalizer::FORM_C);
|
||||
if ($normalized !== false && strlen($url) !== strlen($normalized)) {
|
||||
$findings[] = [
|
||||
'type' => 'unicode_normalization',
|
||||
'message' => 'Unicode normalization discrepancy detected',
|
||||
'severity' => 'medium',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlong UTF-8 sequences
|
||||
if (preg_match('/(?:%[cCdD][0-9a-fA-F])/', $url)) {
|
||||
$findings[] = [
|
||||
'type' => 'overlong_utf8',
|
||||
'message' => 'Potentially overlong UTF-8 sequence detected',
|
||||
'severity' => 'medium',
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for attack category
|
||||
*/
|
||||
private function getSeverityForCategory(string $category): string
|
||||
{
|
||||
return match ($category) {
|
||||
'sql_injection', 'command_injection', 'script_execution' => 'critical',
|
||||
'path_traversal', 'file_access', 'xss' => 'high',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum severity from findings
|
||||
*/
|
||||
private function getMaxSeverityFromFindings(array $findings): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = match ($finding['severity']) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
'low' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
|
||||
if ($severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_url_length' => $this->maxUrlLength,
|
||||
'max_path_length' => $this->maxPathLength,
|
||||
'max_path_depth' => $this->maxPathDepth,
|
||||
'max_query_string_length' => $this->maxQueryStringLength,
|
||||
'suspicious_file_extensions' => $this->suspiciousFileExtensions,
|
||||
'admin_path_indicators' => $this->adminPathIndicators,
|
||||
'debug_path_indicators' => $this->debugPathIndicators,
|
||||
'pattern_categories' => array_keys($this->suspiciousPathPatterns),
|
||||
'total_patterns' => array_sum(array_map('count', $this->suspiciousPathPatterns)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
675
src/Framework/Waf/Analysis/Analyzers/XmlAnalyzer.php
Normal file
675
src/Framework/Waf/Analysis/Analyzers/XmlAnalyzer.php
Normal file
@@ -0,0 +1,675 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\Analyzers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\AnalysisResult;
|
||||
use App\Framework\Waf\Analysis\AnalysisType;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use LibXMLError;
|
||||
|
||||
/**
|
||||
* XML Payload Structure Analyzer
|
||||
* Analyzes XML payloads for security threats including XXE, XML bombs, and injection attacks
|
||||
*/
|
||||
final class XmlAnalyzer implements AnalyzerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxXmlSize = 1048576, // 1MB
|
||||
private readonly int $maxXmlDepth = 32,
|
||||
private readonly int $maxEntityExpansions = 1000,
|
||||
private readonly int $maxElementCount = 10000,
|
||||
private readonly int $maxAttributeCount = 1000,
|
||||
private readonly int $maxTextLength = 65536, // 64KB
|
||||
private readonly array $suspiciousElementNames = [
|
||||
'script', 'eval', 'exec', 'system', 'command', 'cmd',
|
||||
'include', 'require', 'import', 'load', 'fetch',
|
||||
'admin', 'root', 'password', 'pass', 'auth', 'token',
|
||||
'config', 'settings', 'debug', 'test', 'dev',
|
||||
],
|
||||
private readonly array $dangerousEntityPatterns = [
|
||||
// XXE patterns
|
||||
'xxe_file' => [
|
||||
'/<!ENTITY[\\s]+[\\w]+[\\s]+SYSTEM[\\s]+["\'][^"\']*["\']/',
|
||||
'/<!ENTITY[\\s]+[\\w]+[\\s]+PUBLIC[\\s]+["\'][^"\']*["\'][\\s]+["\'][^"\']*["\']/',
|
||||
],
|
||||
// Entity bombing patterns
|
||||
'entity_bomb' => [
|
||||
'/<!ENTITY[\\s]+[\\w]+[\\s]+["\'][^"\']*&[\\w]+;[^"\']*["\']/',
|
||||
'/<!ENTITY[\\s]+%[\\w]+[\\s]+["\'][^"\']*%[\\w]+;[^"\']*["\']/',
|
||||
],
|
||||
// External DTD references
|
||||
'external_dtd' => [
|
||||
'/<!DOCTYPE[\\s]+[\\w]+[\\s]+SYSTEM[\\s]+["\'][^"\']*["\']/',
|
||||
'/<!DOCTYPE[\\s]+[\\w]+[\\s]+PUBLIC[\\s]+["\'][^"\']*["\'][\\s]+["\'][^"\']*["\']/',
|
||||
],
|
||||
],
|
||||
private readonly array $injectionPatterns = [
|
||||
// XPath injection patterns
|
||||
'xpath_injection' => [
|
||||
'/(?i:[\\\'\\"`][\\s]*(?:or|and)[\\s]*[\\\'\\"`]*[\\s]*(?:[\\\'\\"`]*[\\w]+[\\\'\\"`]*[\\s]*=[\\s]*[\\\'\\"`]*[\\w]+|[\\d]+[\\s]*=[\\s]*[\\d]+))/',
|
||||
'/(?i:(?:starts-with|contains|substring|string-length|normalize-space)\\s*\\()/',
|
||||
'/(?i:\\/\\*|\\*\\/|--\\s)/',
|
||||
],
|
||||
// SQL injection in XML
|
||||
'sql_injection' => [
|
||||
'/(?i:union[\\s\\/\\*]+(?:all[\\s\\/\\*]+)?select)/',
|
||||
'/(?i:[\\\'\\"`][\\s]*(?:or|and)[\\s]*[\\\'\\"`]*[\\s]*(?:[\\\'\\"`]*[\\w]+[\\\'\\"`]*[\\s]*=[\\s]*[\\\'\\"`]*[\\w]+|[\\d]+[\\s]*=[\\s]*[\\d]+))/',
|
||||
'/(?i:(?:--|#|\\/\\*|\\*\\/))/',
|
||||
],
|
||||
// XSS patterns
|
||||
'xss' => [
|
||||
'/(?i:<script[^>]*>.*?<\\/script>)/',
|
||||
'/(?i:on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\\s]*=)/',
|
||||
'/(?i:javascript[\\s]*:)/',
|
||||
'/(?i:vbscript[\\s]*:)/',
|
||||
],
|
||||
// Command injection
|
||||
'command_injection' => [
|
||||
'/(?i:(?:;|\\||\\|\\||&&|&|`|\\$\\(|\\${)[\\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown|find|grep))/',
|
||||
'/(?i:(?:cmd|command)\\.exe|powershell|bash|sh)[\\s]*(?:\\/c|\\/k|-c|-e)/',
|
||||
],
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getType(): AnalysisType
|
||||
{
|
||||
return AnalysisType::XML;
|
||||
}
|
||||
|
||||
public function canAnalyze(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return $requestData->isXml() && ! empty($requestData->body);
|
||||
}
|
||||
|
||||
public function analyze(RequestAnalysisData $requestData): AnalysisResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
$severity = DetectionSeverity::INFO;
|
||||
$message = 'XML analysis completed';
|
||||
|
||||
$xmlBody = $requestData->body;
|
||||
$xmlSize = strlen($xmlBody);
|
||||
|
||||
$metadata['xml_size'] = $xmlSize;
|
||||
$metadata['content_type'] = $requestData->contentType;
|
||||
|
||||
// Check XML size limits
|
||||
if ($xmlSize > $this->maxXmlSize) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_xml',
|
||||
'message' => "XML payload too large: {$xmlSize} bytes (max: {$this->maxXmlSize})",
|
||||
'severity' => 'high',
|
||||
'size' => $xmlSize,
|
||||
'limit' => $this->maxXmlSize,
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
$message = 'XML payload exceeds size limits';
|
||||
}
|
||||
|
||||
// Check for dangerous entity patterns in raw XML before parsing
|
||||
$entityResults = $this->checkDangerousEntities($xmlBody);
|
||||
if (! empty($entityResults)) {
|
||||
$findings = array_merge($findings, $entityResults);
|
||||
$entitySeverity = $this->getMaxSeverityFromFindings($entityResults);
|
||||
if ($entitySeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $entitySeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to parse XML with security measures
|
||||
$xmlDoc = null;
|
||||
$parseError = null;
|
||||
|
||||
try {
|
||||
$xmlDoc = $this->parseXmlSecurely($xmlBody);
|
||||
} catch (\Exception $e) {
|
||||
$parseError = $e->getMessage();
|
||||
$findings[] = [
|
||||
'type' => 'xml_parse_error',
|
||||
'message' => "XML parsing failed: {$parseError}",
|
||||
'severity' => 'medium',
|
||||
'error' => $parseError,
|
||||
];
|
||||
if ($severity === DetectionSeverity::INFO || $severity === DetectionSeverity::LOW) {
|
||||
$severity = DetectionSeverity::MEDIUM;
|
||||
}
|
||||
}
|
||||
|
||||
if ($xmlDoc !== null) {
|
||||
// Analyze XML structure
|
||||
$structureAnalysis = $this->analyzeXmlStructure($xmlDoc);
|
||||
$metadata = array_merge($metadata, $structureAnalysis['metadata']);
|
||||
if (! empty($structureAnalysis['findings'])) {
|
||||
$findings = array_merge($findings, $structureAnalysis['findings']);
|
||||
$structureSeverity = $this->getMaxSeverityFromFindings($structureAnalysis['findings']);
|
||||
if ($structureSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $structureSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for injection patterns in XML content
|
||||
$injectionResults = $this->checkForInjections($xmlDoc);
|
||||
if (! empty($injectionResults)) {
|
||||
$findings = array_merge($findings, $injectionResults);
|
||||
$injectionSeverity = $this->getMaxSeverityFromFindings($injectionResults);
|
||||
if ($injectionSeverity->getCvssScore() > $severity->getCvssScore()) {
|
||||
$severity = $injectionSeverity;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious elements
|
||||
$elementResults = $this->analyzeSuspiciousElements($xmlDoc);
|
||||
if (! empty($elementResults)) {
|
||||
$findings = array_merge($findings, $elementResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for XML bombing patterns
|
||||
$bombingResults = $this->checkXmlBombing($xmlBody);
|
||||
if (! empty($bombingResults)) {
|
||||
$findings = array_merge($findings, $bombingResults);
|
||||
}
|
||||
|
||||
// Check for encoding attacks
|
||||
$encodingResults = $this->analyzeXmlEncoding($xmlBody);
|
||||
if (! empty($encodingResults)) {
|
||||
$findings = array_merge($findings, $encodingResults);
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (str_contains($xmlBody, "\x00")) {
|
||||
$findings[] = [
|
||||
'type' => 'null_byte_xml',
|
||||
'message' => "Null byte in XML payload",
|
||||
'severity' => 'high',
|
||||
'count' => substr_count($xmlBody, "\x00"),
|
||||
];
|
||||
$severity = DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$processingTime = $startTime->diff($endTime);
|
||||
|
||||
$passed = empty($findings) ||
|
||||
! in_array($severity, [DetectionSeverity::HIGH, DetectionSeverity::CRITICAL]);
|
||||
|
||||
if (! $passed) {
|
||||
$criticalFindings = array_filter($findings, fn ($f) => $f['severity'] === 'critical');
|
||||
$highFindings = array_filter($findings, fn ($f) => $f['severity'] === 'high');
|
||||
|
||||
if (! empty($criticalFindings)) {
|
||||
$message = count($criticalFindings) === 1
|
||||
? $criticalFindings[0]['message']
|
||||
: "Critical security threats detected in XML (" . count($criticalFindings) . " threats)";
|
||||
} elseif (! empty($highFindings)) {
|
||||
$message = count($highFindings) === 1
|
||||
? $highFindings[0]['message']
|
||||
: "High-severity security issues detected in XML (" . count($highFindings) . " issues)";
|
||||
} else {
|
||||
$message = "Security issues detected in XML (" . count($findings) . " findings)";
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['analysis_duration_ms'] = $processingTime->toMilliseconds();
|
||||
$metadata['analyzer'] = 'XmlAnalyzer';
|
||||
$metadata['parse_successful'] = $xmlDoc !== null;
|
||||
|
||||
return (new AnalysisResult(
|
||||
type: $this->getType(),
|
||||
passed: $passed,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
findings: $findings,
|
||||
metadata: $metadata
|
||||
))->withProcessingTime($processingTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XML with security measures
|
||||
*/
|
||||
private function parseXmlSecurely(string $xmlBody): ?DOMDocument
|
||||
{
|
||||
// Disable external entity loading to prevent XXE
|
||||
$previousEntityLoader = libxml_disable_entity_loader(true);
|
||||
$previousUseInternalErrors = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$doc = new DOMDocument();
|
||||
|
||||
// Configure security settings
|
||||
$doc->resolveExternals = false;
|
||||
$doc->substituteEntities = false;
|
||||
|
||||
// Parse with security flags
|
||||
$loaded = $doc->loadXML(
|
||||
$xmlBody,
|
||||
LIBXML_NOENT | LIBXML_DTDLOAD | LIBXML_DTDATTR | LIBXML_NOCDATA | LIBXML_NOERROR | LIBXML_NOWARNING
|
||||
);
|
||||
|
||||
if (! $loaded) {
|
||||
$errors = libxml_get_errors();
|
||||
$errorMessages = array_map(fn (LibXMLError $error) => trim($error->message), $errors);
|
||||
|
||||
throw new \Exception('XML parsing failed: ' . implode(', ', $errorMessages));
|
||||
}
|
||||
|
||||
return $doc;
|
||||
} finally {
|
||||
// Restore previous settings
|
||||
libxml_disable_entity_loader($previousEntityLoader);
|
||||
libxml_use_internal_errors($previousUseInternalErrors);
|
||||
libxml_clear_errors();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for dangerous entity patterns
|
||||
*/
|
||||
private function checkDangerousEntities(string $xmlBody): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
foreach ($this->dangerousEntityPatterns as $category => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $xmlBody, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForEntityType($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category,
|
||||
'message' => "Dangerous {$category} detected in XML",
|
||||
'severity' => $severity,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 200), // Show more context for entities
|
||||
'offset' => $matchOffset,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze XML structure
|
||||
*/
|
||||
private function analyzeXmlStructure(DOMDocument $xmlDoc): array
|
||||
{
|
||||
$findings = [];
|
||||
$metadata = [];
|
||||
|
||||
$analysis = $this->analyzeXmlNode($xmlDoc->documentElement, 0);
|
||||
|
||||
$metadata['max_depth'] = $analysis['max_depth'];
|
||||
$metadata['total_elements'] = $analysis['total_elements'];
|
||||
$metadata['total_attributes'] = $analysis['total_attributes'];
|
||||
$metadata['total_text_nodes'] = $analysis['total_text_nodes'];
|
||||
$metadata['max_text_length'] = $analysis['max_text_length'];
|
||||
|
||||
// Check depth limits
|
||||
if ($analysis['max_depth'] > $this->maxXmlDepth) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_xml_depth',
|
||||
'message' => "XML nesting too deep: {$analysis['max_depth']} levels (max: {$this->maxXmlDepth})",
|
||||
'severity' => 'high',
|
||||
'depth' => $analysis['max_depth'],
|
||||
'limit' => $this->maxXmlDepth,
|
||||
];
|
||||
}
|
||||
|
||||
// Check element count limits
|
||||
if ($analysis['total_elements'] > $this->maxElementCount) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_xml_elements',
|
||||
'message' => "Too many XML elements: {$analysis['total_elements']} (max: {$this->maxElementCount})",
|
||||
'severity' => 'medium',
|
||||
'count' => $analysis['total_elements'],
|
||||
'limit' => $this->maxElementCount,
|
||||
];
|
||||
}
|
||||
|
||||
// Check attribute count limits
|
||||
if ($analysis['total_attributes'] > $this->maxAttributeCount) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_xml_attributes',
|
||||
'message' => "Too many XML attributes: {$analysis['total_attributes']} (max: {$this->maxAttributeCount})",
|
||||
'severity' => 'medium',
|
||||
'count' => $analysis['total_attributes'],
|
||||
'limit' => $this->maxAttributeCount,
|
||||
];
|
||||
}
|
||||
|
||||
// Check text length limits
|
||||
if ($analysis['max_text_length'] > $this->maxTextLength) {
|
||||
$findings[] = [
|
||||
'type' => 'oversized_xml_text',
|
||||
'message' => "XML text content too long: {$analysis['max_text_length']} characters (max: {$this->maxTextLength})",
|
||||
'severity' => 'medium',
|
||||
'length' => $analysis['max_text_length'],
|
||||
'limit' => $this->maxTextLength,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'findings' => $findings,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively analyze XML nodes
|
||||
*/
|
||||
private function analyzeXmlNode($node, int $depth): array
|
||||
{
|
||||
if ($node === null) {
|
||||
return [
|
||||
'max_depth' => $depth,
|
||||
'total_elements' => 0,
|
||||
'total_attributes' => 0,
|
||||
'total_text_nodes' => 0,
|
||||
'max_text_length' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$analysis = [
|
||||
'max_depth' => $depth,
|
||||
'total_elements' => 0,
|
||||
'total_attributes' => 0,
|
||||
'total_text_nodes' => 0,
|
||||
'max_text_length' => 0,
|
||||
];
|
||||
|
||||
if ($node->nodeType === XML_ELEMENT_NODE) {
|
||||
$analysis['total_elements'] = 1;
|
||||
$analysis['total_attributes'] = $node->attributes ? $node->attributes->length : 0;
|
||||
|
||||
// Analyze child nodes
|
||||
if ($node->childNodes) {
|
||||
foreach ($node->childNodes as $childNode) {
|
||||
if ($childNode->nodeType === XML_TEXT_NODE) {
|
||||
$textLength = strlen(trim($childNode->nodeValue));
|
||||
if ($textLength > 0) {
|
||||
$analysis['total_text_nodes']++;
|
||||
$analysis['max_text_length'] = max($analysis['max_text_length'], $textLength);
|
||||
}
|
||||
} elseif ($childNode->nodeType === XML_ELEMENT_NODE) {
|
||||
$childAnalysis = $this->analyzeXmlNode($childNode, $depth + 1);
|
||||
|
||||
$analysis['max_depth'] = max($analysis['max_depth'], $childAnalysis['max_depth']);
|
||||
$analysis['total_elements'] += $childAnalysis['total_elements'];
|
||||
$analysis['total_attributes'] += $childAnalysis['total_attributes'];
|
||||
$analysis['total_text_nodes'] += $childAnalysis['total_text_nodes'];
|
||||
$analysis['max_text_length'] = max($analysis['max_text_length'], $childAnalysis['max_text_length']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for injection patterns in XML content
|
||||
*/
|
||||
private function checkForInjections(DOMDocument $xmlDoc): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Get all text content and attribute values
|
||||
$xpath = new DOMXPath($xmlDoc);
|
||||
$textNodes = $xpath->query('//text()');
|
||||
$attributeNodes = $xpath->query('//@*');
|
||||
|
||||
// Check text content
|
||||
foreach ($textNodes as $textNode) {
|
||||
$textContent = $textNode->nodeValue;
|
||||
$nodeFindings = $this->checkContentForInjections($textContent, 'text', $textNode->parentNode->nodeName);
|
||||
$findings = array_merge($findings, $nodeFindings);
|
||||
}
|
||||
|
||||
// Check attribute values
|
||||
foreach ($attributeNodes as $attributeNode) {
|
||||
$attributeValue = $attributeNode->nodeValue;
|
||||
$nodeFindings = $this->checkContentForInjections($attributeValue, 'attribute', $attributeNode->name);
|
||||
$findings = array_merge($findings, $nodeFindings);
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check content for injection patterns
|
||||
*/
|
||||
private function checkContentForInjections(string $content, string $nodeType, string $nodeName): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
foreach ($this->injectionPatterns as $category => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$matchText = $matches[0][0];
|
||||
$matchOffset = $matches[0][1];
|
||||
|
||||
$severity = $this->getSeverityForInjectionType($category);
|
||||
|
||||
$findings[] = [
|
||||
'type' => $category,
|
||||
'message' => "Potential {$category} in XML {$nodeType}: {$nodeName}",
|
||||
'severity' => $severity,
|
||||
'pattern' => $pattern,
|
||||
'match' => substr($matchText, 0, 100),
|
||||
'offset' => $matchOffset,
|
||||
'node_type' => $nodeType,
|
||||
'node_name' => $nodeName,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze suspicious elements
|
||||
*/
|
||||
private function analyzeSuspiciousElements(DOMDocument $xmlDoc): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
$xpath = new DOMXPath($xmlDoc);
|
||||
|
||||
foreach ($this->suspiciousElementNames as $suspiciousName) {
|
||||
$elements = $xpath->query("//{$suspiciousName}");
|
||||
|
||||
foreach ($elements as $element) {
|
||||
$findings[] = [
|
||||
'type' => 'suspicious_xml_element',
|
||||
'message' => "Suspicious XML element: {$suspiciousName}",
|
||||
'severity' => 'medium',
|
||||
'element' => $suspiciousName,
|
||||
'content' => substr($element->textContent, 0, 100),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for XML bombing patterns
|
||||
*/
|
||||
private function checkXmlBombing(string $xmlBody): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Check for excessive entity references
|
||||
$entityRefs = preg_match_all('/&[\\w]+;/', $xmlBody);
|
||||
if ($entityRefs > $this->maxEntityExpansions) {
|
||||
$findings[] = [
|
||||
'type' => 'xml_bombing_entities',
|
||||
'message' => "Excessive entity references: {$entityRefs} (max: {$this->maxEntityExpansions})",
|
||||
'severity' => 'high',
|
||||
'count' => $entityRefs,
|
||||
'limit' => $this->maxEntityExpansions,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for recursive entity definitions (billion laughs attack)
|
||||
if (preg_match('/<!ENTITY[^>]*&[^>]*>/', $xmlBody)) {
|
||||
$findings[] = [
|
||||
'type' => 'xml_bombing_recursive',
|
||||
'message' => 'Recursive entity definition detected (billion laughs attack)',
|
||||
'severity' => 'critical',
|
||||
];
|
||||
}
|
||||
|
||||
// Check for extremely repetitive content
|
||||
$repetitionPattern = '/(.{20,100})(?:\1){20,}/';
|
||||
if (preg_match($repetitionPattern, $xmlBody, $matches)) {
|
||||
$findings[] = [
|
||||
'type' => 'xml_bombing_repetition',
|
||||
'message' => 'Excessive repetition detected (potential XML bomb)',
|
||||
'severity' => 'high',
|
||||
'pattern' => substr($matches[1], 0, 30) . '...',
|
||||
'repetitions' => substr_count($xmlBody, $matches[1]),
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze XML encoding for attacks
|
||||
*/
|
||||
private function analyzeXmlEncoding(string $xmlBody): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
// Check for character entity references
|
||||
$charRefs = preg_match_all('/&#(?:[0-9]+|x[0-9a-fA-F]+);/', $xmlBody);
|
||||
if ($charRefs > 100) {
|
||||
$findings[] = [
|
||||
'type' => 'excessive_char_entities',
|
||||
'message' => "Excessive character entity references: {$charRefs}",
|
||||
'severity' => 'medium',
|
||||
'count' => $charRefs,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for potentially dangerous character references
|
||||
if (preg_match('/&#(?:0|[1-8]|1[0-9]|2[0-9]|3[0-1]);/', $xmlBody)) {
|
||||
$findings[] = [
|
||||
'type' => 'dangerous_char_entities',
|
||||
'message' => 'Dangerous character entity references detected',
|
||||
'severity' => 'medium',
|
||||
];
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity for entity type
|
||||
*/
|
||||
private function getSeverityForEntityType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'xxe_file', 'external_dtd' => 'critical',
|
||||
'entity_bomb' => 'high',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity for injection type
|
||||
*/
|
||||
private function getSeverityForInjectionType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'xpath_injection', 'sql_injection' => 'critical',
|
||||
'xss', 'command_injection' => 'high',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum severity from findings
|
||||
*/
|
||||
private function getMaxSeverityFromFindings(array $findings): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = match ($finding['severity']) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
'low' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
|
||||
if ($severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_xml_size' => $this->maxXmlSize,
|
||||
'max_xml_depth' => $this->maxXmlDepth,
|
||||
'max_entity_expansions' => $this->maxEntityExpansions,
|
||||
'max_element_count' => $this->maxElementCount,
|
||||
'max_attribute_count' => $this->maxAttributeCount,
|
||||
'max_text_length' => $this->maxTextLength,
|
||||
'suspicious_element_names' => $this->suspiciousElementNames,
|
||||
'entity_pattern_categories' => array_keys($this->dangerousEntityPatterns),
|
||||
'injection_categories' => array_keys($this->injectionPatterns),
|
||||
'total_entity_patterns' => array_sum(array_map('count', $this->dangerousEntityPatterns)),
|
||||
'total_injection_patterns' => array_sum(array_map('count', $this->injectionPatterns)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->getType()->getPriority();
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return $this->getType()->getMaxProcessingTime();
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return $this->getType()->canRunInParallel();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->getType()->getDependencies();
|
||||
}
|
||||
}
|
||||
363
src/Framework/Waf/Analysis/ValueObjects/RequestAnalysisData.php
Normal file
363
src/Framework/Waf/Analysis/ValueObjects/RequestAnalysisData.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Analysis\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\IpAddress;
|
||||
use App\Framework\UserAgent\UserAgent;
|
||||
|
||||
/**
|
||||
* Structured representation of request data for analysis
|
||||
*/
|
||||
final readonly class RequestAnalysisData
|
||||
{
|
||||
public function __construct(
|
||||
public string $method,
|
||||
public string $url,
|
||||
public string $path,
|
||||
public string $queryString,
|
||||
public array $headers,
|
||||
public array $queryParameters,
|
||||
public array $postParameters,
|
||||
public array $cookies,
|
||||
public string $body,
|
||||
public array $files,
|
||||
public ?IpAddress $clientIp = null,
|
||||
public ?UserAgent $userAgent = null,
|
||||
public ?string $contentType = null,
|
||||
public ?Byte $contentLength = null,
|
||||
public ?string $protocol = null,
|
||||
public ?Timestamp $timestamp = null,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from HTTP request array
|
||||
*/
|
||||
public static function fromArray(array $requestData): self
|
||||
{
|
||||
$headers = $requestData['headers'] ?? [];
|
||||
$clientIp = isset($headers['X-Forwarded-For']) || isset($headers['X-Real-IP'])
|
||||
? IpAddress::fromString($headers['X-Forwarded-For'] ?? $headers['X-Real-IP'])
|
||||
: null;
|
||||
|
||||
$userAgent = isset($headers['User-Agent'])
|
||||
? UserAgent::fromString($headers['User-Agent'])
|
||||
: null;
|
||||
|
||||
$contentLength = isset($headers['Content-Length'])
|
||||
? Byte::fromBytes((int) $headers['Content-Length'])
|
||||
: null;
|
||||
|
||||
// Parse URL components
|
||||
$url = $requestData['url'] ?? '/';
|
||||
$parsed = parse_url($url);
|
||||
$path = $parsed['path'] ?? '/';
|
||||
$queryString = $parsed['query'] ?? '';
|
||||
|
||||
return new self(
|
||||
method: strtoupper($requestData['method'] ?? 'GET'),
|
||||
url: $url,
|
||||
path: $path,
|
||||
queryString: $queryString,
|
||||
headers: $headers,
|
||||
queryParameters: $requestData['query'] ?? [],
|
||||
postParameters: $requestData['post'] ?? [],
|
||||
cookies: $requestData['cookies'] ?? [],
|
||||
body: $requestData['body'] ?? '',
|
||||
files: $requestData['files'] ?? [],
|
||||
clientIp: $clientIp,
|
||||
userAgent: $userAgent,
|
||||
contentType: $headers['Content-Type'] ?? null,
|
||||
contentLength: $contentLength,
|
||||
protocol: $requestData['protocol'] ?? 'HTTP/1.1',
|
||||
timestamp: Timestamp::fromFloat(microtime(true)),
|
||||
metadata: $requestData['metadata'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create minimal data for testing
|
||||
*/
|
||||
public static function minimal(
|
||||
string $method = 'GET',
|
||||
string $path = '/',
|
||||
array $headers = []
|
||||
): self {
|
||||
return new self(
|
||||
method: $method,
|
||||
url: $path,
|
||||
path: $path,
|
||||
queryString: '',
|
||||
headers: $headers,
|
||||
queryParameters: [],
|
||||
postParameters: [],
|
||||
cookies: [],
|
||||
body: '',
|
||||
files: [],
|
||||
timestamp: Timestamp::fromFloat(microtime(true))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request has body content
|
||||
*/
|
||||
public function hasBody(): bool
|
||||
{
|
||||
return ! empty($this->body) || in_array($this->method, ['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request contains file uploads
|
||||
*/
|
||||
public function hasFiles(): bool
|
||||
{
|
||||
return ! empty($this->files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is JSON
|
||||
*/
|
||||
public function isJson(): bool
|
||||
{
|
||||
return $this->contentType !== null &&
|
||||
str_contains(strtolower($this->contentType), 'application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is XML
|
||||
*/
|
||||
public function isXml(): bool
|
||||
{
|
||||
return $this->contentType !== null &&
|
||||
(str_contains(strtolower($this->contentType), 'application/xml') ||
|
||||
str_contains(strtolower($this->contentType), 'text/xml'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is form data
|
||||
*/
|
||||
public function isFormData(): bool
|
||||
{
|
||||
return $this->contentType !== null &&
|
||||
str_contains(strtolower($this->contentType), 'application/x-www-form-urlencoded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is multipart
|
||||
*/
|
||||
public function isMultipart(): bool
|
||||
{
|
||||
return $this->contentType !== null &&
|
||||
str_contains(strtolower($this->contentType), 'multipart/form-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request size in bytes
|
||||
*/
|
||||
public function getSize(): Byte
|
||||
{
|
||||
if ($this->contentLength !== null) {
|
||||
return $this->contentLength;
|
||||
}
|
||||
|
||||
// Calculate approximate size
|
||||
$headerSize = array_sum(array_map(
|
||||
fn ($name, $value) => strlen($name) + strlen($value) + 4, // +4 for ": \r\n"
|
||||
array_keys($this->headers),
|
||||
array_values($this->headers)
|
||||
));
|
||||
|
||||
$bodySize = strlen($this->body);
|
||||
$urlSize = strlen($this->url);
|
||||
|
||||
return Byte::fromBytes($headerSize + $bodySize + $urlSize + 50); // +50 for method, protocol, etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get header value (case-insensitive)
|
||||
*/
|
||||
public function getHeader(string $name): ?string
|
||||
{
|
||||
foreach ($this->headers as $headerName => $value) {
|
||||
if (strcasecmp($headerName, $name) === 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if header exists (case-insensitive)
|
||||
*/
|
||||
public function hasHeader(string $name): bool
|
||||
{
|
||||
return $this->getHeader($name) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all parameters (query + post)
|
||||
*/
|
||||
public function getAllParameters(): array
|
||||
{
|
||||
return array_merge($this->queryParameters, $this->postParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameter value from query or post
|
||||
*/
|
||||
public function getParameter(string $name): ?string
|
||||
{
|
||||
return $this->queryParameters[$name] ?? $this->postParameters[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parameter exists
|
||||
*/
|
||||
public function hasParameter(string $name): bool
|
||||
{
|
||||
return isset($this->queryParameters[$name]) || isset($this->postParameters[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookie value
|
||||
*/
|
||||
public function getCookie(string $name): ?string
|
||||
{
|
||||
return $this->cookies[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cookie exists
|
||||
*/
|
||||
public function hasCookie(string $name): bool
|
||||
{
|
||||
return isset($this->cookies[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file upload info
|
||||
*/
|
||||
public function getFile(string $name): ?array
|
||||
{
|
||||
return $this->files[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all uploaded file names
|
||||
*/
|
||||
public function getFileNames(): array
|
||||
{
|
||||
return array_keys($this->files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of uploaded files
|
||||
*/
|
||||
public function getFileCount(): int
|
||||
{
|
||||
return count($this->files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is from localhost
|
||||
*/
|
||||
public function isLocalhost(): bool
|
||||
{
|
||||
return $this->clientIp !== null && $this->clientIp->isLoopback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is from private network
|
||||
*/
|
||||
public function isPrivateNetwork(): bool
|
||||
{
|
||||
return $this->clientIp !== null && $this->clientIp->isPrivate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request summary for logging
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'method' => $this->method,
|
||||
'path' => $this->path,
|
||||
'client_ip' => $this->clientIp?->toString(),
|
||||
'user_agent' => $this->userAgent?->toString(),
|
||||
'content_type' => $this->contentType,
|
||||
'size_bytes' => $this->getSize()->toBytes(),
|
||||
'has_body' => $this->hasBody(),
|
||||
'has_files' => $this->hasFiles(),
|
||||
'parameter_count' => count($this->getAllParameters()),
|
||||
'cookie_count' => count($this->cookies),
|
||||
'header_count' => count($this->headers),
|
||||
'timestamp' => $this->timestamp?->format('c'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to analysis-friendly array
|
||||
*/
|
||||
public function toAnalysisArray(): array
|
||||
{
|
||||
return [
|
||||
'method' => $this->method,
|
||||
'url' => $this->url,
|
||||
'path' => $this->path,
|
||||
'query_string' => $this->queryString,
|
||||
'headers' => $this->headers,
|
||||
'query' => $this->queryParameters,
|
||||
'post' => $this->postParameters,
|
||||
'cookies' => $this->cookies,
|
||||
'body' => $this->body,
|
||||
'files' => $this->files,
|
||||
'client_ip' => $this->clientIp?->toString(),
|
||||
'user_agent' => $this->userAgent?->toString(),
|
||||
'content_type' => $this->contentType,
|
||||
'content_length' => $this->contentLength?->toBytes(),
|
||||
'protocol' => $this->protocol,
|
||||
'is_json' => $this->isJson(),
|
||||
'is_xml' => $this->isXml(),
|
||||
'is_form_data' => $this->isFormData(),
|
||||
'is_multipart' => $this->isMultipart(),
|
||||
'has_body' => $this->hasBody(),
|
||||
'has_files' => $this->hasFiles(),
|
||||
'size_bytes' => $this->getSize()->toBytes(),
|
||||
'timestamp' => $this->timestamp?->format('c'),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'method' => $this->method,
|
||||
'url' => $this->url,
|
||||
'path' => $this->path,
|
||||
'query_string' => $this->queryString,
|
||||
'headers' => $this->headers,
|
||||
'query_parameters' => $this->queryParameters,
|
||||
'post_parameters' => $this->postParameters,
|
||||
'cookies' => $this->cookies,
|
||||
'body' => strlen($this->body) > 1000 ? substr($this->body, 0, 1000) . '...' : $this->body,
|
||||
'files' => $this->files,
|
||||
'client_ip' => $this->clientIp?->toString(),
|
||||
'user_agent' => $this->userAgent?->toString(),
|
||||
'content_type' => $this->contentType,
|
||||
'content_length' => $this->contentLength?->toBytes(),
|
||||
'protocol' => $this->protocol,
|
||||
'timestamp' => $this->timestamp?->format('c'),
|
||||
'summary' => $this->getSummary(),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
}
|
||||
117
src/Framework/Waf/BotProtection/BotDetectionType.php
Normal file
117
src/Framework/Waf/BotProtection/BotDetectionType.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection;
|
||||
|
||||
/**
|
||||
* Bot detection types for classification
|
||||
*/
|
||||
enum BotDetectionType: string
|
||||
{
|
||||
case FINGERPRINT_ANOMALY = 'fingerprint_anomaly';
|
||||
case BEHAVIORAL_ANOMALY = 'behavioral_anomaly';
|
||||
case DEVICE_ANOMALY = 'device_anomaly';
|
||||
case CAPTCHA_FAILURE = 'captcha_failure';
|
||||
case RATE_LIMITING = 'rate_limiting';
|
||||
case USER_AGENT_ANALYSIS = 'user_agent_analysis';
|
||||
case IP_REPUTATION = 'ip_reputation';
|
||||
case MOUSE_MOVEMENT = 'mouse_movement';
|
||||
case KEYSTROKE_PATTERN = 'keystroke_pattern';
|
||||
case TIMING_ANALYSIS = 'timing_analysis';
|
||||
case HEADLESS_DETECTION = 'headless_detection';
|
||||
case WEBDRIVER_DETECTION = 'webdriver_detection';
|
||||
case AUTOMATION_DETECTION = 'automation_detection';
|
||||
|
||||
/**
|
||||
* Get detection description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::FINGERPRINT_ANOMALY => 'Browser fingerprint shows anomalous characteristics',
|
||||
self::BEHAVIORAL_ANOMALY => 'User behavior patterns indicate automation',
|
||||
self::DEVICE_ANOMALY => 'Device characteristics are suspicious',
|
||||
self::CAPTCHA_FAILURE => 'CAPTCHA validation failed',
|
||||
self::RATE_LIMITING => 'Request rate exceeds normal patterns',
|
||||
self::USER_AGENT_ANALYSIS => 'User agent matches known bot patterns',
|
||||
self::IP_REPUTATION => 'IP address has poor reputation',
|
||||
self::MOUSE_MOVEMENT => 'Mouse movement patterns are unnatural',
|
||||
self::KEYSTROKE_PATTERN => 'Keystroke timing indicates automation',
|
||||
self::TIMING_ANALYSIS => 'Request timing patterns are suspicious',
|
||||
self::HEADLESS_DETECTION => 'Headless browser detected',
|
||||
self::WEBDRIVER_DETECTION => 'WebDriver automation detected',
|
||||
self::AUTOMATION_DETECTION => 'General automation indicators found'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for this detection type
|
||||
*/
|
||||
public function getDefaultSeverity(): DetectionSeverity
|
||||
{
|
||||
return match($this) {
|
||||
self::CAPTCHA_FAILURE,
|
||||
self::HEADLESS_DETECTION,
|
||||
self::WEBDRIVER_DETECTION => DetectionSeverity::HIGH,
|
||||
|
||||
self::FINGERPRINT_ANOMALY,
|
||||
self::BEHAVIORAL_ANOMALY,
|
||||
self::DEVICE_ANOMALY,
|
||||
self::AUTOMATION_DETECTION => DetectionSeverity::MEDIUM,
|
||||
|
||||
self::RATE_LIMITING,
|
||||
self::USER_AGENT_ANALYSIS,
|
||||
self::IP_REPUTATION,
|
||||
self::MOUSE_MOVEMENT,
|
||||
self::KEYSTROKE_PATTERN,
|
||||
self::TIMING_ANALYSIS => DetectionSeverity::LOW
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this detection type should block requests
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return match($this) {
|
||||
self::CAPTCHA_FAILURE,
|
||||
self::HEADLESS_DETECTION,
|
||||
self::WEBDRIVER_DETECTION => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all detection types
|
||||
*/
|
||||
public static function getAll(): array
|
||||
{
|
||||
return [
|
||||
self::FINGERPRINT_ANOMALY,
|
||||
self::BEHAVIORAL_ANOMALY,
|
||||
self::DEVICE_ANOMALY,
|
||||
self::CAPTCHA_FAILURE,
|
||||
self::RATE_LIMITING,
|
||||
self::USER_AGENT_ANALYSIS,
|
||||
self::IP_REPUTATION,
|
||||
self::MOUSE_MOVEMENT,
|
||||
self::KEYSTROKE_PATTERN,
|
||||
self::TIMING_ANALYSIS,
|
||||
self::HEADLESS_DETECTION,
|
||||
self::WEBDRIVER_DETECTION,
|
||||
self::AUTOMATION_DETECTION,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detection types by severity
|
||||
*/
|
||||
public static function getBySeverity(DetectionSeverity $severity): array
|
||||
{
|
||||
return array_filter(
|
||||
self::getAll(),
|
||||
fn ($type) => $type->getDefaultSeverity() === $severity
|
||||
);
|
||||
}
|
||||
}
|
||||
492
src/Framework/Waf/BotProtection/BotProtectionEngine.php
Normal file
492
src/Framework/Waf/BotProtection/BotProtectionEngine.php
Normal file
@@ -0,0 +1,492 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\BotProtection\Detectors\BehavioralDetector;
|
||||
use App\Framework\Waf\BotProtection\Detectors\DeviceIntelligenceDetector;
|
||||
use App\Framework\Waf\BotProtection\Detectors\FingerprintDetector;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\BotDetectionResult;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\BotRiskScore;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\BrowserFingerprint;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\DeviceProfile;
|
||||
|
||||
/**
|
||||
* Advanced Bot Protection Engine
|
||||
* Combines multiple detection techniques for comprehensive bot detection
|
||||
*/
|
||||
final class BotProtectionEngine
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FingerprintDetector $fingerprintDetector,
|
||||
private readonly BehavioralDetector $behavioralDetector,
|
||||
private readonly DeviceIntelligenceDetector $deviceDetector,
|
||||
private readonly CaptchaValidator $captchaValidator,
|
||||
private readonly Clock $clock,
|
||||
private readonly Logger $logger,
|
||||
private readonly bool $enabled = true,
|
||||
private readonly ?Percentage $botThreshold = null,
|
||||
private readonly ?Duration $analysisTimeout = null,
|
||||
private readonly bool $enableFingerprinting = true,
|
||||
private readonly bool $enableBehavioralAnalysis = true,
|
||||
private readonly bool $enableDeviceIntelligence = true,
|
||||
private readonly bool $enableCaptcha = true,
|
||||
private readonly int $maxFingerprintAge = 86400, // 24 hours
|
||||
private array $detectionHistory = [],
|
||||
private array $performanceMetrics = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bot threshold with default fallback
|
||||
*/
|
||||
private function getBotThreshold(): Percentage
|
||||
{
|
||||
return $this->botThreshold ?? Percentage::from(75.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analysis timeout with default fallback
|
||||
*/
|
||||
private function getAnalysisTimeout(): Duration
|
||||
{
|
||||
return $this->analysisTimeout ?? Duration::fromSeconds(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request for bot indicators
|
||||
*/
|
||||
public function analyzeRequest(RequestAnalysisData $requestData, array $context = []): BotDetectionResult
|
||||
{
|
||||
$startTime = $this->clock->time();
|
||||
|
||||
if (! $this->enabled) {
|
||||
return BotDetectionResult::disabled();
|
||||
}
|
||||
|
||||
try {
|
||||
$detections = [];
|
||||
$fingerprint = null;
|
||||
$deviceProfile = null;
|
||||
$riskFactors = [];
|
||||
|
||||
// Browser fingerprinting detection
|
||||
if ($this->enableFingerprinting) {
|
||||
$fingerprint = $this->detectFingerprint($requestData, $context);
|
||||
if ($fingerprint->isAnomalous()) {
|
||||
$detections[] = $this->createFingerprintDetection($fingerprint);
|
||||
$riskFactors['fingerprint_anomaly'] = $fingerprint->getAnomalyScore();
|
||||
}
|
||||
}
|
||||
|
||||
// Behavioral analysis
|
||||
if ($this->enableBehavioralAnalysis) {
|
||||
$behavioralResult = $this->analyzeBehavior($requestData, $context);
|
||||
if ($behavioralResult->isSuspicious()) {
|
||||
$detections = array_merge($detections, $behavioralResult->getDetections());
|
||||
$riskFactors['behavioral_anomaly'] = $behavioralResult->getRiskScore();
|
||||
}
|
||||
}
|
||||
|
||||
// Device intelligence
|
||||
if ($this->enableDeviceIntelligence) {
|
||||
$deviceProfile = $this->analyzeDevice($requestData, $context);
|
||||
if ($deviceProfile->isSuspicious()) {
|
||||
$detections[] = $this->createDeviceDetection($deviceProfile);
|
||||
$riskFactors['device_anomaly'] = $deviceProfile->getSuspicionScore();
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall bot risk score
|
||||
$botRiskScore = $this->calculateBotRiskScore($riskFactors, $detections);
|
||||
|
||||
// CAPTCHA challenge if risk is elevated
|
||||
$captchaRequired = $this->shouldRequireCaptcha($botRiskScore, $context);
|
||||
$captchaValid = false;
|
||||
|
||||
if ($captchaRequired && $this->enableCaptcha) {
|
||||
$captchaValid = $this->validateCaptcha($requestData, $context);
|
||||
if (! $captchaValid) {
|
||||
$detections[] = $this->createCaptchaFailureDetection();
|
||||
}
|
||||
}
|
||||
|
||||
$processingTime = $startTime->diff($this->clock->time());
|
||||
|
||||
// Record detection history
|
||||
$this->recordDetection($requestData, $botRiskScore, $detections);
|
||||
|
||||
// Update performance metrics
|
||||
$this->updatePerformanceMetrics($processingTime, count($detections));
|
||||
|
||||
return new BotDetectionResult(
|
||||
isBot: $botRiskScore->getValue() >= $this->getBotThreshold()->getValue(),
|
||||
riskScore: $botRiskScore,
|
||||
detections: $detections,
|
||||
fingerprint: $fingerprint,
|
||||
deviceProfile: $deviceProfile,
|
||||
captchaRequired: $captchaRequired,
|
||||
captchaValid: $captchaValid,
|
||||
processingTime: $processingTime,
|
||||
riskFactors: $riskFactors,
|
||||
confidence: $this->calculateConfidence($detections, $riskFactors)
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = $startTime->diff($this->clock->time());
|
||||
|
||||
$this->logger->error('Bot protection analysis failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'request_id' => $context['request_id'] ?? null,
|
||||
'processing_time' => $processingTime->toMilliseconds(),
|
||||
]);
|
||||
|
||||
return BotDetectionResult::error($e->getMessage(), $processingTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect browser fingerprint anomalies
|
||||
*/
|
||||
private function detectFingerprint(RequestAnalysisData $requestData, array $context): BrowserFingerprint
|
||||
{
|
||||
return $this->fingerprintDetector->analyzeFingerprint($requestData, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze behavioral patterns
|
||||
*/
|
||||
private function analyzeBehavior(RequestAnalysisData $requestData, array $context): BehavioralAnalysisResult
|
||||
{
|
||||
return $this->behavioralDetector->analyzeBehavior($requestData, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze device characteristics
|
||||
*/
|
||||
private function analyzeDevice(RequestAnalysisData $requestData, array $context): DeviceProfile
|
||||
{
|
||||
return $this->deviceDetector->analyzeDevice($requestData, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall bot risk score
|
||||
*/
|
||||
private function calculateBotRiskScore(array $riskFactors, array $detections): BotRiskScore
|
||||
{
|
||||
$totalRisk = 0.0;
|
||||
$maxWeight = 0.0;
|
||||
|
||||
// Weight risk factors
|
||||
$weights = [
|
||||
'fingerprint_anomaly' => 0.3,
|
||||
'behavioral_anomaly' => 0.4,
|
||||
'device_anomaly' => 0.2,
|
||||
'captcha_failure' => 0.6,
|
||||
];
|
||||
|
||||
foreach ($riskFactors as $factor => $score) {
|
||||
$weight = $weights[$factor] ?? 0.1;
|
||||
$totalRisk += $score * $weight;
|
||||
$maxWeight += $weight;
|
||||
}
|
||||
|
||||
// Apply detection count multiplier
|
||||
$detectionMultiplier = 1.0 + (count($detections) * 0.1);
|
||||
$finalScore = ($maxWeight > 0) ? ($totalRisk / $maxWeight) * $detectionMultiplier : 0.0;
|
||||
|
||||
return new BotRiskScore(
|
||||
score: Percentage::from(min(100.0, $finalScore * 100)),
|
||||
factors: $riskFactors,
|
||||
detectionCount: count($detections),
|
||||
confidence: $this->calculateRiskConfidence($riskFactors)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if CAPTCHA challenge is required
|
||||
*/
|
||||
private function shouldRequireCaptcha(BotRiskScore $riskScore, array $context): bool
|
||||
{
|
||||
// Require CAPTCHA for medium-high risk scores
|
||||
if ($riskScore->getValue() >= 50.0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Require CAPTCHA for repeated suspicious activity
|
||||
$clientIdentifier = $this->getClientIdentifier($context);
|
||||
$recentDetections = $this->getRecentDetections($clientIdentifier);
|
||||
|
||||
if (count($recentDetections) >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CAPTCHA response
|
||||
*/
|
||||
private function validateCaptcha(RequestAnalysisData $requestData, array $context): bool
|
||||
{
|
||||
return $this->captchaValidator->validateResponse($requestData, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fingerprint-based detection
|
||||
*/
|
||||
private function createFingerprintDetection(BrowserFingerprint $fingerprint): BotDetection
|
||||
{
|
||||
return new BotDetection(
|
||||
type: BotDetectionType::FINGERPRINT_ANOMALY,
|
||||
severity: $fingerprint->getAnomalyScore() > 80 ? DetectionSeverity::HIGH : DetectionSeverity::MEDIUM,
|
||||
message: 'Suspicious browser fingerprint detected',
|
||||
evidence: $fingerprint->getAnomalousFeatures(),
|
||||
confidence: Percentage::from($fingerprint->getAnomalyScore()),
|
||||
source: 'fingerprint_detector'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create device-based detection
|
||||
*/
|
||||
private function createDeviceDetection(DeviceProfile $deviceProfile): BotDetection
|
||||
{
|
||||
return new BotDetection(
|
||||
type: BotDetectionType::DEVICE_ANOMALY,
|
||||
severity: $deviceProfile->getSuspicionScore() > 80 ? DetectionSeverity::HIGH : DetectionSeverity::MEDIUM,
|
||||
message: 'Suspicious device characteristics detected',
|
||||
evidence: $deviceProfile->getSuspiciousFeatures(),
|
||||
confidence: Percentage::from($deviceProfile->getSuspicionScore()),
|
||||
source: 'device_detector'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CAPTCHA failure detection
|
||||
*/
|
||||
private function createCaptchaFailureDetection(): BotDetection
|
||||
{
|
||||
return new BotDetection(
|
||||
type: BotDetectionType::CAPTCHA_FAILURE,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
message: 'CAPTCHA validation failed',
|
||||
evidence: ['captcha_validation' => 'failed'],
|
||||
confidence: Percentage::from(95.0),
|
||||
source: 'captcha_validator'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate detection confidence
|
||||
*/
|
||||
private function calculateConfidence(array $detections, array $riskFactors): Percentage
|
||||
{
|
||||
if (empty($detections) && empty($riskFactors)) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$totalConfidence = 0.0;
|
||||
$count = 0;
|
||||
|
||||
// Average detection confidences
|
||||
foreach ($detections as $detection) {
|
||||
if ($detection instanceof BotDetection) {
|
||||
$totalConfidence += $detection->confidence->getValue();
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Factor in risk score consistency
|
||||
$riskScoreVariance = $this->calculateRiskScoreVariance($riskFactors);
|
||||
$consistencyBonus = max(0, 20 - $riskScoreVariance); // Higher consistency = higher confidence
|
||||
|
||||
$averageConfidence = $count > 0 ? $totalConfidence / $count : 50.0;
|
||||
$finalConfidence = min(100.0, $averageConfidence + $consistencyBonus);
|
||||
|
||||
return Percentage::from($finalConfidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk confidence
|
||||
*/
|
||||
private function calculateRiskConfidence(array $riskFactors): Percentage
|
||||
{
|
||||
$factorCount = count($riskFactors);
|
||||
|
||||
if ($factorCount === 0) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
// More consistent risk factors = higher confidence
|
||||
$variance = $this->calculateRiskScoreVariance($riskFactors);
|
||||
$baseConfidence = min(100.0, $factorCount * 25); // Up to 100% with 4+ factors
|
||||
$variancePenalty = min(30.0, $variance); // Up to 30% penalty for high variance
|
||||
|
||||
return Percentage::from(max(0.0, $baseConfidence - $variancePenalty));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate variance in risk factor scores
|
||||
*/
|
||||
private function calculateRiskScoreVariance(array $riskFactors): float
|
||||
{
|
||||
if (count($riskFactors) < 2) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$mean = array_sum($riskFactors) / count($riskFactors);
|
||||
$squaredDiffs = array_map(fn ($score) => pow($score - $mean, 2), $riskFactors);
|
||||
|
||||
return sqrt(array_sum($squaredDiffs) / count($squaredDiffs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier for tracking
|
||||
*/
|
||||
private function getClientIdentifier(array $context): string
|
||||
{
|
||||
return md5(implode('|', [
|
||||
$context['client_ip'] ?? '',
|
||||
$context['user_agent'] ?? '',
|
||||
$context['accept_language'] ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent detections for client
|
||||
*/
|
||||
private function getRecentDetections(string $clientIdentifier): array
|
||||
{
|
||||
$cutoff = $this->clock->time()->toUnixTimestamp() - 3600; // Last hour
|
||||
|
||||
return array_filter(
|
||||
$this->detectionHistory[$clientIdentifier] ?? [],
|
||||
fn ($detection) => $detection['timestamp'] > $cutoff
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record detection in history
|
||||
*/
|
||||
private function recordDetection(RequestAnalysisData $requestData, BotRiskScore $riskScore, array $detections): void
|
||||
{
|
||||
$clientIdentifier = $this->getClientIdentifier([
|
||||
'client_ip' => $requestData->clientIp?->toString(),
|
||||
'user_agent' => $requestData->userAgent?->toString(),
|
||||
'accept_language' => $requestData->headers['accept-language'] ?? '',
|
||||
]);
|
||||
|
||||
if (! isset($this->detectionHistory[$clientIdentifier])) {
|
||||
$this->detectionHistory[$clientIdentifier] = [];
|
||||
}
|
||||
|
||||
$this->detectionHistory[$clientIdentifier][] = [
|
||||
'timestamp' => $this->clock->time()->toUnixTimestamp(),
|
||||
'risk_score' => $riskScore->getValue(),
|
||||
'detection_count' => count($detections),
|
||||
'is_bot' => $riskScore->getValue() >= $this->getBotThreshold()->getValue(),
|
||||
];
|
||||
|
||||
// Limit history size per client
|
||||
if (count($this->detectionHistory[$clientIdentifier]) > 100) {
|
||||
array_shift($this->detectionHistory[$clientIdentifier]);
|
||||
}
|
||||
|
||||
// Limit total clients tracked
|
||||
if (count($this->detectionHistory) > 10000) {
|
||||
$oldestClient = array_key_first($this->detectionHistory);
|
||||
unset($this->detectionHistory[$oldestClient]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update performance metrics
|
||||
*/
|
||||
private function updatePerformanceMetrics(Duration $processingTime, int $detectionCount): void
|
||||
{
|
||||
$this->performanceMetrics[] = [
|
||||
'timestamp' => $this->clock->time()->toUnixTimestamp(),
|
||||
'processing_time_ms' => $processingTime->toMilliseconds(),
|
||||
'detection_count' => $detectionCount,
|
||||
];
|
||||
|
||||
// Limit metrics history
|
||||
if (count($this->performanceMetrics) > 1000) {
|
||||
array_shift($this->performanceMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
*/
|
||||
public function getPerformanceStats(): array
|
||||
{
|
||||
if (empty($this->performanceMetrics)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$processingTimes = array_column($this->performanceMetrics, 'processing_time_ms');
|
||||
$detectionCounts = array_column($this->performanceMetrics, 'detection_count');
|
||||
|
||||
return [
|
||||
'total_requests' => count($this->performanceMetrics),
|
||||
'avg_processing_time_ms' => array_sum($processingTimes) / count($processingTimes),
|
||||
'max_processing_time_ms' => max($processingTimes),
|
||||
'avg_detection_count' => array_sum($detectionCounts) / count($detectionCounts),
|
||||
'bot_detection_rate' => $this->calculateBotDetectionRate(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bot detection rate
|
||||
*/
|
||||
private function calculateBotDetectionRate(): float
|
||||
{
|
||||
$totalDetections = 0;
|
||||
$botDetections = 0;
|
||||
|
||||
foreach ($this->detectionHistory as $clientHistory) {
|
||||
foreach ($clientHistory as $detection) {
|
||||
$totalDetections++;
|
||||
if ($detection['is_bot']) {
|
||||
$botDetections++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $totalDetections > 0 ? ($botDetections / $totalDetections) * 100 : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if engine is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration
|
||||
*/
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'bot_threshold' => $this->getBotThreshold()->getValue(),
|
||||
'analysis_timeout_ms' => $this->getAnalysisTimeout()->toMilliseconds(),
|
||||
'fingerprinting_enabled' => $this->enableFingerprinting,
|
||||
'behavioral_analysis_enabled' => $this->enableBehavioralAnalysis,
|
||||
'device_intelligence_enabled' => $this->enableDeviceIntelligence,
|
||||
'captcha_enabled' => $this->enableCaptcha,
|
||||
'max_fingerprint_age_seconds' => $this->maxFingerprintAge,
|
||||
'tracked_clients' => count($this->detectionHistory),
|
||||
];
|
||||
}
|
||||
}
|
||||
360
src/Framework/Waf/BotProtection/CaptchaValidator.php
Normal file
360
src/Framework/Waf/BotProtection/CaptchaValidator.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\CaptchaChallenge;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\CaptchaResult;
|
||||
|
||||
/**
|
||||
* CAPTCHA validation service supporting multiple providers
|
||||
*/
|
||||
final class CaptchaValidator
|
||||
{
|
||||
private const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
private const HCAPTCHA_VERIFY_URL = 'https://hcaptcha.com/siteverify';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClient $httpClient,
|
||||
private readonly Clock $clock,
|
||||
private readonly Logger $logger,
|
||||
private readonly Cache $cache,
|
||||
private readonly bool $enabled = true,
|
||||
private readonly string $provider = 'recaptcha', // 'recaptcha' or 'hcaptcha'
|
||||
private readonly ?string $secretKey = null,
|
||||
private readonly ?string $siteKey = null,
|
||||
private readonly float $minimumScore = 0.5,
|
||||
private readonly int $challengeTimeout = 300, // 5 minutes
|
||||
private array $activeChallenges = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CAPTCHA challenge for suspicious requests
|
||||
*/
|
||||
public function generateChallenge(string $sessionId, float $riskScore, array $context = []): CaptchaChallenge
|
||||
{
|
||||
if (! $this->enabled || ! $this->secretKey || ! $this->siteKey) {
|
||||
return CaptchaChallenge::disabled();
|
||||
}
|
||||
|
||||
try {
|
||||
$challengeId = $this->generateChallengeId();
|
||||
$expiresAt = $this->clock->time()->addSeconds($this->challengeTimeout);
|
||||
|
||||
$challenge = new CaptchaChallenge(
|
||||
challengeId: $challengeId,
|
||||
sessionId: $sessionId,
|
||||
provider: $this->provider,
|
||||
siteKey: $this->siteKey,
|
||||
riskScore: $riskScore,
|
||||
createdAt: $this->clock->time(),
|
||||
expiresAt: $expiresAt,
|
||||
isRequired: $riskScore >= 70.0,
|
||||
context: $context
|
||||
);
|
||||
|
||||
// Store active challenge
|
||||
$this->activeChallenges[$challengeId] = [
|
||||
'challenge' => $challenge,
|
||||
'created_at' => $this->clock->time()->toUnixTimestamp(),
|
||||
];
|
||||
|
||||
// Cache challenge for persistence
|
||||
$this->cache->set(
|
||||
CacheKey::fromString("captcha_challenge:{$challengeId}"),
|
||||
$challenge->toArray(),
|
||||
Duration::fromSeconds($this->challengeTimeout)
|
||||
);
|
||||
|
||||
$this->logger->info('CAPTCHA challenge generated', [
|
||||
'challenge_id' => $challengeId,
|
||||
'session_id' => $sessionId,
|
||||
'provider' => $this->provider,
|
||||
'risk_score' => $riskScore,
|
||||
'required' => $challenge->isRequired,
|
||||
]);
|
||||
|
||||
return $challenge;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to generate CAPTCHA challenge', [
|
||||
'error' => $e->getMessage(),
|
||||
'session_id' => $sessionId,
|
||||
'risk_score' => $riskScore,
|
||||
]);
|
||||
|
||||
return CaptchaChallenge::disabled();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CAPTCHA response
|
||||
*/
|
||||
public function validateResponse(string $challengeId, string $response, string $clientIp): CaptchaResult
|
||||
{
|
||||
if (! $this->enabled || ! $this->secretKey) {
|
||||
return CaptchaResult::disabled();
|
||||
}
|
||||
|
||||
try {
|
||||
// Get challenge from cache or memory
|
||||
$challenge = $this->getChallenge($challengeId);
|
||||
if (! $challenge) {
|
||||
return CaptchaResult::invalid('Challenge not found or expired');
|
||||
}
|
||||
|
||||
// Check if challenge has expired
|
||||
if ($challenge->expiresAt->isBefore($this->clock->time())) {
|
||||
$this->removeChallenge($challengeId);
|
||||
|
||||
return CaptchaResult::invalid('Challenge expired');
|
||||
}
|
||||
|
||||
// Validate with provider
|
||||
$providerResult = $this->validateWithProvider($response, $clientIp);
|
||||
|
||||
// Create result
|
||||
$result = new CaptchaResult(
|
||||
isValid: $providerResult['success'],
|
||||
score: $providerResult['score'] ?? null,
|
||||
action: $providerResult['action'] ?? null,
|
||||
hostname: $providerResult['hostname'] ?? null,
|
||||
challengeId: $challengeId,
|
||||
provider: $this->provider,
|
||||
validatedAt: $this->clock->time(),
|
||||
errorCodes: $providerResult['error_codes'] ?? [],
|
||||
metadata: [
|
||||
'client_ip' => $clientIp,
|
||||
'response_time' => $providerResult['response_time'] ?? null,
|
||||
'challenge_context' => $challenge->context,
|
||||
]
|
||||
);
|
||||
|
||||
// Remove challenge after validation
|
||||
$this->removeChallenge($challengeId);
|
||||
|
||||
$this->logger->info('CAPTCHA validation completed', [
|
||||
'challenge_id' => $challengeId,
|
||||
'provider' => $this->provider,
|
||||
'valid' => $result->isValid,
|
||||
'score' => $result->score,
|
||||
'client_ip' => $clientIp,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('CAPTCHA validation failed', [
|
||||
'challenge_id' => $challengeId,
|
||||
'error' => $e->getMessage(),
|
||||
'client_ip' => $clientIp,
|
||||
]);
|
||||
|
||||
return CaptchaResult::invalid('Validation error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate response with external provider
|
||||
*/
|
||||
private function validateWithProvider(string $response, string $clientIp): array
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
$url = match($this->provider) {
|
||||
'hcaptcha' => self::HCAPTCHA_VERIFY_URL,
|
||||
default => self::RECAPTCHA_VERIFY_URL
|
||||
};
|
||||
|
||||
$postData = [
|
||||
'secret' => $this->secretKey,
|
||||
'response' => $response,
|
||||
'remoteip' => $clientIp,
|
||||
];
|
||||
|
||||
try {
|
||||
$httpResponse = $this->httpClient->post($url, [
|
||||
'form_params' => $postData,
|
||||
'timeout' => 10,
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
]);
|
||||
|
||||
$responseTime = microtime(true) - $startTime;
|
||||
$data = json_decode($httpResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from CAPTCHA provider');
|
||||
}
|
||||
|
||||
// Handle provider-specific response format
|
||||
return $this->normalizeProviderResponse($data, $responseTime);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('CAPTCHA provider request failed', [
|
||||
'provider' => $this->provider,
|
||||
'error' => $e->getMessage(),
|
||||
'response_time' => microtime(true) - $startTime,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize provider response to common format
|
||||
*/
|
||||
private function normalizeProviderResponse(array $data, float $responseTime): array
|
||||
{
|
||||
$normalized = [
|
||||
'success' => $data['success'] ?? false,
|
||||
'response_time' => $responseTime,
|
||||
'error_codes' => $data['error-codes'] ?? [],
|
||||
];
|
||||
|
||||
// Provider-specific handling
|
||||
switch ($this->provider) {
|
||||
case 'recaptcha':
|
||||
$normalized['score'] = $data['score'] ?? null;
|
||||
$normalized['action'] = $data['action'] ?? null;
|
||||
$normalized['hostname'] = $data['hostname'] ?? null;
|
||||
|
||||
// For reCAPTCHA v3, check minimum score
|
||||
if ($normalized['score'] !== null && $normalized['score'] < $this->minimumScore) {
|
||||
$normalized['success'] = false;
|
||||
$normalized['error_codes'][] = 'score-threshold-not-met';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'hcaptcha':
|
||||
// hCaptcha doesn't provide score, so we treat success as binary
|
||||
$normalized['score'] = $normalized['success'] ? 1.0 : 0.0;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get challenge from cache or memory
|
||||
*/
|
||||
private function getChallenge(string $challengeId): ?CaptchaChallenge
|
||||
{
|
||||
// Try memory first
|
||||
if (isset($this->activeChallenges[$challengeId])) {
|
||||
return $this->activeChallenges[$challengeId]['challenge'];
|
||||
}
|
||||
|
||||
// Try cache
|
||||
$cached = $this->cache->get(CacheKey::fromString("captcha_challenge:{$challengeId}"));
|
||||
if ($cached) {
|
||||
return CaptchaChallenge::fromArray($cached);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove challenge from memory and cache
|
||||
*/
|
||||
private function removeChallenge(string $challengeId): void
|
||||
{
|
||||
unset($this->activeChallenges[$challengeId]);
|
||||
$this->cache->delete(CacheKey::fromString("captcha_challenge:{$challengeId}"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique challenge ID
|
||||
*/
|
||||
private function generateChallengeId(): string
|
||||
{
|
||||
return hash('sha256', uniqid('captcha_', true) . microtime(true) . random_bytes(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CAPTCHA is required for given risk score
|
||||
*/
|
||||
public function isRequired(float $riskScore): bool
|
||||
{
|
||||
return $this->enabled && $riskScore >= 50.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CAPTCHA should be mandatory (no bypass)
|
||||
*/
|
||||
public function isMandatory(float $riskScore): bool
|
||||
{
|
||||
return $this->enabled && $riskScore >= 80.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired challenges
|
||||
*/
|
||||
public function cleanupExpiredChallenges(): int
|
||||
{
|
||||
$currentTime = $this->clock->time()->toUnixTimestamp();
|
||||
$removed = 0;
|
||||
|
||||
foreach ($this->activeChallenges as $challengeId => $data) {
|
||||
if ($currentTime - $data['created_at'] > $this->challengeTimeout) {
|
||||
$this->removeChallenge($challengeId);
|
||||
$removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validator statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'provider' => $this->provider,
|
||||
'minimum_score' => $this->minimumScore,
|
||||
'challenge_timeout_seconds' => $this->challengeTimeout,
|
||||
'active_challenges' => count($this->activeChallenges),
|
||||
'has_credentials' => ! empty($this->secretKey) && ! empty($this->siteKey),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if validator is properly configured
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->enabled && ! empty($this->secretKey) && ! empty($this->siteKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported providers
|
||||
*/
|
||||
public static function getSupportedProviders(): array
|
||||
{
|
||||
return [
|
||||
'recaptcha' => [
|
||||
'name' => 'Google reCAPTCHA',
|
||||
'versions' => ['v2', 'v3'],
|
||||
'features' => ['score_based', 'action_based', 'hostname_validation'],
|
||||
],
|
||||
'hcaptcha' => [
|
||||
'name' => 'hCaptcha',
|
||||
'versions' => ['v1'],
|
||||
'features' => ['privacy_focused', 'gdpr_compliant'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
655
src/Framework/Waf/BotProtection/Detectors/BehavioralDetector.php
Normal file
655
src/Framework/Waf/BotProtection/Detectors/BehavioralDetector.php
Normal file
@@ -0,0 +1,655 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\Detectors;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\BotProtection\BotDetectionType;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\BehavioralAnalysisResult;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\BotDetection;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Behavioral analysis detector for automated patterns
|
||||
*/
|
||||
final class BehavioralDetector
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Clock $clock,
|
||||
private readonly Logger $logger,
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $sessionTimeout = 1800, // 30 minutes
|
||||
private array $sessionData = [],
|
||||
private array $behaviorPatterns = [],
|
||||
private array $timingThresholds = []
|
||||
) {
|
||||
$this->initializeBehaviorPatterns();
|
||||
$this->initializeTimingThresholds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze behavioral patterns for bot indicators
|
||||
*/
|
||||
public function analyzeBehavior(RequestAnalysisData $requestData, array $context): BehavioralAnalysisResult
|
||||
{
|
||||
$startTime = $this->clock->time();
|
||||
|
||||
if (! $this->enabled) {
|
||||
return BehavioralAnalysisResult::empty();
|
||||
}
|
||||
|
||||
try {
|
||||
$sessionId = $this->getSessionId($requestData, $context);
|
||||
$detections = [];
|
||||
$behaviorMetrics = [];
|
||||
|
||||
// Update session data
|
||||
$this->updateSessionData($sessionId, $requestData, $context);
|
||||
|
||||
// Analyze request timing patterns
|
||||
$timingAnalysis = $this->analyzeRequestTiming($sessionId, $requestData, $context);
|
||||
if ($timingAnalysis['suspicious']) {
|
||||
$detections[] = new BotDetection(
|
||||
type: BotDetectionType::TIMING_ANALYSIS,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
message: $timingAnalysis['message'],
|
||||
evidence: $timingAnalysis['evidence'],
|
||||
confidence: Percentage::from($timingAnalysis['confidence']),
|
||||
source: 'behavioral_detector'
|
||||
);
|
||||
}
|
||||
$behaviorMetrics['timing'] = $timingAnalysis['metrics'];
|
||||
|
||||
// Analyze mouse movement patterns (if available)
|
||||
if (isset($context['mouse_movements'])) {
|
||||
$mouseAnalysis = $this->analyzeMouseMovement($context['mouse_movements']);
|
||||
if ($mouseAnalysis['suspicious']) {
|
||||
$detections[] = new BotDetection(
|
||||
type: BotDetectionType::MOUSE_MOVEMENT,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
message: $mouseAnalysis['message'],
|
||||
evidence: $mouseAnalysis['evidence'],
|
||||
confidence: Percentage::from($mouseAnalysis['confidence']),
|
||||
source: 'behavioral_detector'
|
||||
);
|
||||
}
|
||||
$behaviorMetrics['mouse'] = $mouseAnalysis['metrics'];
|
||||
}
|
||||
|
||||
// Analyze keystroke patterns (if available)
|
||||
if (isset($context['keystrokes'])) {
|
||||
$keystrokeAnalysis = $this->analyzeKeystrokePatterns($context['keystrokes']);
|
||||
if ($keystrokeAnalysis['suspicious']) {
|
||||
$detections[] = new BotDetection(
|
||||
type: BotDetectionType::KEYSTROKE_PATTERN,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
message: $keystrokeAnalysis['message'],
|
||||
evidence: $keystrokeAnalysis['evidence'],
|
||||
confidence: Percentage::from($keystrokeAnalysis['confidence']),
|
||||
source: 'behavioral_detector'
|
||||
);
|
||||
}
|
||||
$behaviorMetrics['keystrokes'] = $keystrokeAnalysis['metrics'];
|
||||
}
|
||||
|
||||
// Analyze navigation patterns
|
||||
$navigationAnalysis = $this->analyzeNavigationPatterns($sessionId);
|
||||
if ($navigationAnalysis['suspicious']) {
|
||||
$detections[] = new BotDetection(
|
||||
type: BotDetectionType::BEHAVIORAL_ANOMALY,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
message: $navigationAnalysis['message'],
|
||||
evidence: $navigationAnalysis['evidence'],
|
||||
confidence: Percentage::from($navigationAnalysis['confidence']),
|
||||
source: 'behavioral_detector'
|
||||
);
|
||||
}
|
||||
$behaviorMetrics['navigation'] = $navigationAnalysis['metrics'];
|
||||
|
||||
// Calculate overall risk score
|
||||
$riskScore = $this->calculateBehavioralRiskScore($detections, $behaviorMetrics);
|
||||
|
||||
// Calculate confidence
|
||||
$confidence = $this->calculateAnalysisConfidence($detections, $behaviorMetrics);
|
||||
|
||||
$analysisDuration = $startTime->diff($this->clock->time());
|
||||
|
||||
return new BehavioralAnalysisResult(
|
||||
isSuspicious: $riskScore > 50.0 || ! empty($detections),
|
||||
riskScore: $riskScore,
|
||||
detections: $detections,
|
||||
behaviorMetrics: $behaviorMetrics,
|
||||
analysisDuration: $analysisDuration,
|
||||
confidence: $confidence
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Behavioral analysis failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'request_id' => $context['request_id'] ?? null,
|
||||
]);
|
||||
|
||||
$analysisDuration = $startTime->diff($this->clock->time());
|
||||
|
||||
return BehavioralAnalysisResult::empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session identifier
|
||||
*/
|
||||
private function getSessionId(RequestAnalysisData $requestData, array $context): string
|
||||
{
|
||||
return hash('sha256', implode('|', [
|
||||
$requestData->clientIp?->toString() ?? '',
|
||||
$requestData->userAgent?->toString() ?? '',
|
||||
$context['session_id'] ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session behavioral data
|
||||
*/
|
||||
private function updateSessionData(string $sessionId, RequestAnalysisData $requestData, array $context): void
|
||||
{
|
||||
$currentTime = $this->clock->time()->toUnixTimestamp();
|
||||
|
||||
if (! isset($this->sessionData[$sessionId])) {
|
||||
$this->sessionData[$sessionId] = [
|
||||
'created_at' => $currentTime,
|
||||
'requests' => [],
|
||||
'mouse_movements' => [],
|
||||
'keystrokes' => [],
|
||||
'navigation_patterns' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Add current request
|
||||
$this->sessionData[$sessionId]['requests'][] = [
|
||||
'timestamp' => $currentTime,
|
||||
'path' => $requestData->path,
|
||||
'method' => $requestData->method,
|
||||
'user_agent' => $requestData->userAgent?->toString(),
|
||||
'referrer' => $requestData->headers['referer'] ?? null,
|
||||
];
|
||||
|
||||
// Add mouse movements if available
|
||||
if (isset($context['mouse_movements'])) {
|
||||
$this->sessionData[$sessionId]['mouse_movements'] = array_merge(
|
||||
$this->sessionData[$sessionId]['mouse_movements'],
|
||||
$context['mouse_movements']
|
||||
);
|
||||
}
|
||||
|
||||
// Add keystrokes if available
|
||||
if (isset($context['keystrokes'])) {
|
||||
$this->sessionData[$sessionId]['keystrokes'] = array_merge(
|
||||
$this->sessionData[$sessionId]['keystrokes'],
|
||||
$context['keystrokes']
|
||||
);
|
||||
}
|
||||
|
||||
// Limit session data size
|
||||
$this->limitSessionDataSize($sessionId);
|
||||
|
||||
// Clean up old sessions
|
||||
$this->cleanupOldSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request timing patterns
|
||||
*/
|
||||
private function analyzeRequestTiming(string $sessionId, RequestAnalysisData $requestData, array $context): array
|
||||
{
|
||||
$sessionRequests = $this->sessionData[$sessionId]['requests'] ?? [];
|
||||
|
||||
if (count($sessionRequests) < 2) {
|
||||
return [
|
||||
'suspicious' => false,
|
||||
'message' => '',
|
||||
'evidence' => [],
|
||||
'confidence' => 0.0,
|
||||
'metrics' => ['request_count' => count($sessionRequests)],
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate request intervals
|
||||
$intervals = [];
|
||||
for ($i = 1; $i < count($sessionRequests); $i++) {
|
||||
$intervals[] = $sessionRequests[$i]['timestamp'] - $sessionRequests[$i - 1]['timestamp'];
|
||||
}
|
||||
|
||||
// Analyze interval patterns
|
||||
$avgInterval = array_sum($intervals) / count($intervals);
|
||||
$intervalVariance = $this->calculateVariance($intervals);
|
||||
|
||||
$suspicious = false;
|
||||
$evidence = [];
|
||||
$confidence = 0.0;
|
||||
$message = '';
|
||||
|
||||
// Check for suspiciously regular intervals (bot-like)
|
||||
if ($intervalVariance < 0.1 && count($intervals) >= 5) {
|
||||
$suspicious = true;
|
||||
$confidence = 85.0;
|
||||
$message = 'Highly regular request timing pattern detected';
|
||||
$evidence['interval_variance'] = $intervalVariance;
|
||||
$evidence['average_interval'] = $avgInterval;
|
||||
}
|
||||
|
||||
// Check for suspiciously fast requests
|
||||
if ($avgInterval < $this->timingThresholds['min_human_interval']) {
|
||||
$suspicious = true;
|
||||
$confidence = max($confidence, 75.0);
|
||||
$message = 'Requests too fast for human interaction';
|
||||
$evidence['average_interval'] = $avgInterval;
|
||||
$evidence['threshold'] = $this->timingThresholds['min_human_interval'];
|
||||
}
|
||||
|
||||
return [
|
||||
'suspicious' => $suspicious,
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'confidence' => $confidence,
|
||||
'metrics' => [
|
||||
'request_count' => count($sessionRequests),
|
||||
'average_interval' => $avgInterval,
|
||||
'interval_variance' => $intervalVariance,
|
||||
'intervals' => $intervals,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze mouse movement patterns
|
||||
*/
|
||||
private function analyzeMouseMovement(array $movements): array
|
||||
{
|
||||
if (empty($movements)) {
|
||||
return [
|
||||
'suspicious' => false,
|
||||
'message' => '',
|
||||
'evidence' => [],
|
||||
'confidence' => 0.0,
|
||||
'metrics' => ['movement_count' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
$suspicious = false;
|
||||
$evidence = [];
|
||||
$confidence = 0.0;
|
||||
$message = '';
|
||||
|
||||
// Calculate movement metrics
|
||||
$totalDistance = 0;
|
||||
$totalTime = 0;
|
||||
$perfectLines = 0;
|
||||
|
||||
for ($i = 1; $i < count($movements); $i++) {
|
||||
$prev = $movements[$i - 1];
|
||||
$curr = $movements[$i];
|
||||
|
||||
$distance = sqrt(pow($curr['x'] - $prev['x'], 2) + pow($curr['y'] - $prev['y'], 2));
|
||||
$timeDiff = $curr['timestamp'] - $prev['timestamp'];
|
||||
|
||||
$totalDistance += $distance;
|
||||
$totalTime += $timeDiff;
|
||||
|
||||
// Check for perfectly straight lines (bot indicator)
|
||||
if ($distance > 10 && $this->isPerfectLine($prev, $curr)) {
|
||||
$perfectLines++;
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze patterns
|
||||
$avgSpeed = $totalTime > 0 ? $totalDistance / $totalTime : 0;
|
||||
$perfectLineRatio = count($movements) > 1 ? $perfectLines / (count($movements) - 1) : 0;
|
||||
|
||||
// Check for bot-like patterns
|
||||
if ($perfectLineRatio > 0.8) {
|
||||
$suspicious = true;
|
||||
$confidence = 90.0;
|
||||
$message = 'Mouse movements are too perfect (likely automated)';
|
||||
$evidence['perfect_line_ratio'] = $perfectLineRatio;
|
||||
}
|
||||
|
||||
if ($avgSpeed > 5000) { // Extremely fast movement
|
||||
$suspicious = true;
|
||||
$confidence = max($confidence, 80.0);
|
||||
$message = 'Mouse movement speed is inhuman';
|
||||
$evidence['average_speed'] = $avgSpeed;
|
||||
}
|
||||
|
||||
return [
|
||||
'suspicious' => $suspicious,
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'confidence' => $confidence,
|
||||
'metrics' => [
|
||||
'movement_count' => count($movements),
|
||||
'total_distance' => $totalDistance,
|
||||
'average_speed' => $avgSpeed,
|
||||
'perfect_line_ratio' => $perfectLineRatio,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze keystroke patterns
|
||||
*/
|
||||
private function analyzeKeystrokePatterns(array $keystrokes): array
|
||||
{
|
||||
if (count($keystrokes) < 5) {
|
||||
return [
|
||||
'suspicious' => false,
|
||||
'message' => '',
|
||||
'evidence' => [],
|
||||
'confidence' => 0.0,
|
||||
'metrics' => ['keystroke_count' => count($keystrokes)],
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate keystroke intervals
|
||||
$intervals = [];
|
||||
for ($i = 1; $i < count($keystrokes); $i++) {
|
||||
$intervals[] = $keystrokes[$i]['timestamp'] - $keystrokes[$i - 1]['timestamp'];
|
||||
}
|
||||
|
||||
$avgInterval = array_sum($intervals) / count($intervals);
|
||||
$intervalVariance = $this->calculateVariance($intervals);
|
||||
|
||||
$suspicious = false;
|
||||
$evidence = [];
|
||||
$confidence = 0.0;
|
||||
$message = '';
|
||||
|
||||
// Check for suspiciously regular keystroke timing
|
||||
if ($intervalVariance < 5 && count($intervals) >= 10) {
|
||||
$suspicious = true;
|
||||
$confidence = 85.0;
|
||||
$message = 'Keystroke timing is too regular (likely automated)';
|
||||
$evidence['interval_variance'] = $intervalVariance;
|
||||
}
|
||||
|
||||
// Check for inhuman typing speed
|
||||
if ($avgInterval < 50) { // Less than 50ms between keystrokes
|
||||
$suspicious = true;
|
||||
$confidence = max($confidence, 90.0);
|
||||
$message = 'Typing speed is inhuman';
|
||||
$evidence['average_interval'] = $avgInterval;
|
||||
}
|
||||
|
||||
return [
|
||||
'suspicious' => $suspicious,
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'confidence' => $confidence,
|
||||
'metrics' => [
|
||||
'keystroke_count' => count($keystrokes),
|
||||
'average_interval' => $avgInterval,
|
||||
'interval_variance' => $intervalVariance,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze navigation patterns
|
||||
*/
|
||||
private function analyzeNavigationPatterns(string $sessionId): array
|
||||
{
|
||||
$requests = $this->sessionData[$sessionId]['requests'] ?? [];
|
||||
|
||||
if (count($requests) < 3) {
|
||||
return [
|
||||
'suspicious' => false,
|
||||
'message' => '',
|
||||
'evidence' => [],
|
||||
'confidence' => 0.0,
|
||||
'metrics' => ['page_count' => count($requests)],
|
||||
];
|
||||
}
|
||||
|
||||
$suspicious = false;
|
||||
$evidence = [];
|
||||
$confidence = 0.0;
|
||||
$message = '';
|
||||
|
||||
// Analyze path patterns
|
||||
$paths = array_column($requests, 'path');
|
||||
$uniquePaths = array_unique($paths);
|
||||
$pathRepetition = count($paths) > 0 ? (count($paths) - count($uniquePaths)) / count($paths) : 0;
|
||||
|
||||
// Check for excessive path repetition
|
||||
if ($pathRepetition > 0.7) {
|
||||
$suspicious = true;
|
||||
$confidence = 70.0;
|
||||
$message = 'Excessive repetition in navigation patterns';
|
||||
$evidence['path_repetition_ratio'] = $pathRepetition;
|
||||
}
|
||||
|
||||
// Check for missing referrers (common bot behavior)
|
||||
$missingReferrers = 0;
|
||||
foreach ($requests as $request) {
|
||||
if (empty($request['referrer'])) {
|
||||
$missingReferrers++;
|
||||
}
|
||||
}
|
||||
|
||||
$missingReferrerRatio = count($requests) > 0 ? $missingReferrers / count($requests) : 0;
|
||||
if ($missingReferrerRatio > 0.8 && count($requests) >= 5) {
|
||||
$suspicious = true;
|
||||
$confidence = max($confidence, 60.0);
|
||||
$message = 'Unusual referrer pattern detected';
|
||||
$evidence['missing_referrer_ratio'] = $missingReferrerRatio;
|
||||
}
|
||||
|
||||
return [
|
||||
'suspicious' => $suspicious,
|
||||
'message' => $message,
|
||||
'evidence' => $evidence,
|
||||
'confidence' => $confidence,
|
||||
'metrics' => [
|
||||
'page_count' => count($requests),
|
||||
'unique_pages' => count($uniquePaths),
|
||||
'path_repetition_ratio' => $pathRepetition,
|
||||
'missing_referrer_ratio' => $missingReferrerRatio,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate behavioral risk score
|
||||
*/
|
||||
private function calculateBehavioralRiskScore(array $detections, array $behaviorMetrics): float
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
// Base score from detections
|
||||
foreach ($detections as $detection) {
|
||||
if ($detection instanceof BotDetection) {
|
||||
$score += $detection->getRiskContribution();
|
||||
}
|
||||
}
|
||||
|
||||
// Additional scoring from metrics
|
||||
if (isset($behaviorMetrics['timing']['interval_variance'])) {
|
||||
$variance = $behaviorMetrics['timing']['interval_variance'];
|
||||
if ($variance < 0.1) {
|
||||
$score += 30; // Very regular timing
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($behaviorMetrics['mouse']['perfect_line_ratio'])) {
|
||||
$ratio = $behaviorMetrics['mouse']['perfect_line_ratio'];
|
||||
$score += $ratio * 40; // Perfect lines indicate automation
|
||||
}
|
||||
|
||||
return min(100.0, $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate analysis confidence
|
||||
*/
|
||||
private function calculateAnalysisConfidence(array $detections, array $behaviorMetrics): Percentage
|
||||
{
|
||||
if (empty($detections)) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$totalConfidence = 0.0;
|
||||
foreach ($detections as $detection) {
|
||||
if ($detection instanceof BotDetection) {
|
||||
$totalConfidence += $detection->confidence->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
$avgConfidence = $totalConfidence / count($detections);
|
||||
|
||||
// Boost confidence if multiple detection types agree
|
||||
if (count($detections) > 1) {
|
||||
$avgConfidence = min(100.0, $avgConfidence * 1.2);
|
||||
}
|
||||
|
||||
return Percentage::from($avgConfidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if line is perfectly straight
|
||||
*/
|
||||
private function isPerfectLine(array $point1, array $point2): bool
|
||||
{
|
||||
// A perfectly straight line would have the same angle throughout
|
||||
// This is a simplified check - in reality, you'd need more sophisticated analysis
|
||||
return abs($point1['x'] - $point2['x']) < 2 || abs($point1['y'] - $point2['y']) < 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate variance of an array
|
||||
*/
|
||||
private function calculateVariance(array $values): float
|
||||
{
|
||||
if (count($values) < 2) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$mean = array_sum($values) / count($values);
|
||||
$squaredDiffs = array_map(fn ($value) => pow($value - $mean, 2), $values);
|
||||
|
||||
return array_sum($squaredDiffs) / count($squaredDiffs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit session data size to prevent memory issues
|
||||
*/
|
||||
private function limitSessionDataSize(string $sessionId): void
|
||||
{
|
||||
$maxRequests = 100;
|
||||
$maxMovements = 1000;
|
||||
$maxKeystrokes = 500;
|
||||
|
||||
if (isset($this->sessionData[$sessionId]['requests']) &&
|
||||
count($this->sessionData[$sessionId]['requests']) > $maxRequests) {
|
||||
$this->sessionData[$sessionId]['requests'] = array_slice(
|
||||
$this->sessionData[$sessionId]['requests'],
|
||||
-$maxRequests
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->sessionData[$sessionId]['mouse_movements']) &&
|
||||
count($this->sessionData[$sessionId]['mouse_movements']) > $maxMovements) {
|
||||
$this->sessionData[$sessionId]['mouse_movements'] = array_slice(
|
||||
$this->sessionData[$sessionId]['mouse_movements'],
|
||||
-$maxMovements
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->sessionData[$sessionId]['keystrokes']) &&
|
||||
count($this->sessionData[$sessionId]['keystrokes']) > $maxKeystrokes) {
|
||||
$this->sessionData[$sessionId]['keystrokes'] = array_slice(
|
||||
$this->sessionData[$sessionId]['keystrokes'],
|
||||
-$maxKeystrokes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old session data
|
||||
*/
|
||||
private function cleanupOldSessions(): void
|
||||
{
|
||||
$currentTime = $this->clock->time()->toUnixTimestamp();
|
||||
|
||||
foreach ($this->sessionData as $sessionId => $data) {
|
||||
if ($currentTime - $data['created_at'] > $this->sessionTimeout) {
|
||||
unset($this->sessionData[$sessionId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Also limit total sessions to prevent memory issues
|
||||
if (count($this->sessionData) > 1000) {
|
||||
$sortedSessions = $this->sessionData;
|
||||
uasort($sortedSessions, fn ($a, $b) => $a['created_at'] <=> $b['created_at']);
|
||||
|
||||
$toRemove = array_slice(array_keys($sortedSessions), 0, 500, true);
|
||||
foreach ($toRemove as $sessionId) {
|
||||
unset($this->sessionData[$sessionId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize behavior patterns
|
||||
*/
|
||||
private function initializeBehaviorPatterns(): void
|
||||
{
|
||||
$this->behaviorPatterns = [
|
||||
'rapid_requests' => [
|
||||
'min_requests' => 10,
|
||||
'max_interval' => 1.0, // seconds
|
||||
'confidence' => 80.0,
|
||||
],
|
||||
'regular_timing' => [
|
||||
'min_requests' => 5,
|
||||
'max_variance' => 0.1,
|
||||
'confidence' => 85.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize timing thresholds
|
||||
*/
|
||||
private function initializeTimingThresholds(): void
|
||||
{
|
||||
$this->timingThresholds = [
|
||||
'min_human_interval' => 0.5, // Minimum seconds between human requests
|
||||
'max_human_speed' => 2000, // Maximum mouse movement speed (pixels/second)
|
||||
'min_keystroke_interval' => 50, // Minimum milliseconds between keystrokes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detector statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'active_sessions' => count($this->sessionData),
|
||||
'session_timeout_seconds' => $this->sessionTimeout,
|
||||
'behavior_patterns_loaded' => count($this->behaviorPatterns),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detector is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\Detectors;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\DeviceProfile;
|
||||
|
||||
/**
|
||||
* Device intelligence detector for automated systems
|
||||
*/
|
||||
final class DeviceIntelligenceDetector
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Clock $clock,
|
||||
private readonly Logger $logger,
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $profileCacheTimeout = 1800, // 30 minutes
|
||||
private array $deviceProfileCache = [],
|
||||
private array $suspiciousDevices = [],
|
||||
private array $devicePatterns = []
|
||||
) {
|
||||
$this->initializeDevicePatterns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze device characteristics for bot indicators
|
||||
*/
|
||||
public function analyzeDevice(RequestAnalysisData $requestData, array $context): DeviceProfile
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return $this->createEmptyProfile();
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract device data from request and context
|
||||
$deviceData = $this->extractDeviceData($requestData, $context);
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = $this->generateProfileCacheKey($deviceData);
|
||||
if (isset($this->deviceProfileCache[$cacheKey])) {
|
||||
$cached = $this->deviceProfileCache[$cacheKey];
|
||||
if ($this->clock->time()->toUnixTimestamp() - $cached['timestamp'] < $this->profileCacheTimeout) {
|
||||
return $cached['profile'];
|
||||
}
|
||||
}
|
||||
|
||||
// Create device profile
|
||||
$profile = DeviceProfile::fromData($deviceData, $this->clock->time());
|
||||
|
||||
// Additional analysis
|
||||
$this->analyzeUserAgentConsistency($profile, $deviceData);
|
||||
$this->analyzeHardwareConsistency($profile, $deviceData);
|
||||
$this->checkAgainstKnownPatterns($profile, $deviceData);
|
||||
|
||||
// Cache the result
|
||||
$this->deviceProfileCache[$cacheKey] = [
|
||||
'profile' => $profile,
|
||||
'timestamp' => $this->clock->time()->toUnixTimestamp(),
|
||||
];
|
||||
|
||||
// Track suspicious devices
|
||||
if ($profile->isSuspicious()) {
|
||||
$this->trackSuspiciousDevice($profile, $context);
|
||||
}
|
||||
|
||||
// Limit cache size
|
||||
if (count($this->deviceProfileCache) > 5000) {
|
||||
$this->cleanupProfileCache();
|
||||
}
|
||||
|
||||
return $profile;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Device intelligence analysis failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'request_id' => $context['request_id'] ?? null,
|
||||
]);
|
||||
|
||||
return $this->createEmptyProfile();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract device data from request and context
|
||||
*/
|
||||
private function extractDeviceData(RequestAnalysisData $requestData, array $context): array
|
||||
{
|
||||
$userAgent = $requestData->userAgent?->toString() ?? '';
|
||||
|
||||
$data = [
|
||||
'user_agent' => $userAgent,
|
||||
'os' => $this->extractOperatingSystem($userAgent),
|
||||
'browser' => $this->extractBrowser($userAgent),
|
||||
'device_type' => $this->extractDeviceType($userAgent),
|
||||
'language' => $requestData->headers['accept-language'] ?? null,
|
||||
];
|
||||
|
||||
// Extract client-provided device information
|
||||
if (isset($context['device_info'])) {
|
||||
$deviceInfo = $context['device_info'];
|
||||
|
||||
// Hardware specifications
|
||||
if (isset($deviceInfo['hardware'])) {
|
||||
$data['hardware'] = [
|
||||
'screen_width' => $deviceInfo['hardware']['screen_width'] ?? null,
|
||||
'screen_height' => $deviceInfo['hardware']['screen_height'] ?? null,
|
||||
'device_pixel_ratio' => $deviceInfo['hardware']['device_pixel_ratio'] ?? null,
|
||||
'color_depth' => $deviceInfo['hardware']['color_depth'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Memory information
|
||||
$data['memory_size'] = $deviceInfo['memory'] ?? null;
|
||||
|
||||
// CPU information
|
||||
$data['cpu_cores'] = $deviceInfo['cpu_cores'] ?? null;
|
||||
|
||||
// GPU information
|
||||
$data['gpu_renderer'] = $deviceInfo['gpu_renderer'] ?? null;
|
||||
|
||||
// Network information
|
||||
if (isset($deviceInfo['network'])) {
|
||||
$data['network'] = [
|
||||
'connection_type' => $deviceInfo['network']['type'] ?? null,
|
||||
'downlink' => $deviceInfo['network']['downlink'] ?? null,
|
||||
'rtt' => $deviceInfo['network']['rtt'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Location/timezone
|
||||
if (isset($deviceInfo['location'])) {
|
||||
$data['location'] = [
|
||||
'timezone_offset' => $deviceInfo['location']['timezone_offset'] ?? null,
|
||||
'timezone_name' => $deviceInfo['location']['timezone_name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Battery status
|
||||
$data['battery_level'] = $deviceInfo['battery_level'] ?? null;
|
||||
|
||||
// Connection type
|
||||
if (isset($deviceInfo['connection'])) {
|
||||
$data['connection_type'] = [
|
||||
'type' => $deviceInfo['connection']['type'] ?? null,
|
||||
'speed' => $deviceInfo['connection']['speed'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Automation indicators
|
||||
$data['is_vm'] = $this->detectVirtualMachine($deviceInfo, $userAgent);
|
||||
$data['is_headless'] = $this->detectHeadlessBrowser($deviceInfo, $userAgent);
|
||||
$data['has_webdriver'] = $this->detectWebDriver($deviceInfo, $context);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract operating system from user agent
|
||||
*/
|
||||
private function extractOperatingSystem(string $userAgent): ?string
|
||||
{
|
||||
$patterns = [
|
||||
'Windows NT 10.0' => 'Windows 10',
|
||||
'Windows NT 6.3' => 'Windows 8.1',
|
||||
'Windows NT 6.2' => 'Windows 8',
|
||||
'Windows NT 6.1' => 'Windows 7',
|
||||
'Mac OS X' => 'macOS',
|
||||
'iPhone OS' => 'iOS',
|
||||
'Android' => 'Android',
|
||||
'Linux' => 'Linux',
|
||||
'Ubuntu' => 'Ubuntu',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern => $os) {
|
||||
if (stripos($userAgent, $pattern) !== false) {
|
||||
return $os;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract browser from user agent
|
||||
*/
|
||||
private function extractBrowser(string $userAgent): ?string
|
||||
{
|
||||
$patterns = [
|
||||
'Chrome' => 'Chrome',
|
||||
'Firefox' => 'Firefox',
|
||||
'Safari' => 'Safari',
|
||||
'Edge' => 'Edge',
|
||||
'Opera' => 'Opera',
|
||||
'Internet Explorer' => 'Internet Explorer',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern => $browser) {
|
||||
if (stripos($userAgent, $pattern) !== false) {
|
||||
return $browser;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract device type from user agent
|
||||
*/
|
||||
private function extractDeviceType(string $userAgent): string
|
||||
{
|
||||
if (preg_match('/(tablet|ipad)/i', $userAgent)) {
|
||||
return 'tablet';
|
||||
}
|
||||
|
||||
if (preg_match('/(mobile|phone|android|ios)/i', $userAgent)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect virtual machine environment
|
||||
*/
|
||||
private function detectVirtualMachine(array $deviceInfo, string $userAgent): bool
|
||||
{
|
||||
// Check for VM indicators in user agent
|
||||
$vmIndicators = ['VirtualBox', 'VMware', 'QEMU', 'Parallels', 'Xen'];
|
||||
foreach ($vmIndicators as $indicator) {
|
||||
if (stripos($userAgent, $indicator) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for VM indicators in device info
|
||||
if (isset($deviceInfo['gpu_renderer'])) {
|
||||
$vmGpuPatterns = ['VirtualBox', 'VMware', 'Microsoft Basic', 'VirtIO'];
|
||||
foreach ($vmGpuPatterns as $pattern) {
|
||||
if (stripos($deviceInfo['gpu_renderer'], $pattern) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect headless browser
|
||||
*/
|
||||
private function detectHeadlessBrowser(array $deviceInfo, string $userAgent): bool
|
||||
{
|
||||
// Check for headless indicators in user agent
|
||||
$headlessIndicators = ['HeadlessChrome', 'PhantomJS', 'SlimerJS'];
|
||||
foreach ($headlessIndicators as $indicator) {
|
||||
if (stripos($userAgent, $indicator) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing expected features
|
||||
if (isset($deviceInfo['hardware'])) {
|
||||
$hardware = $deviceInfo['hardware'];
|
||||
// Headless browsers often report 0x0 screen dimensions
|
||||
if (($hardware['screen_width'] ?? 0) === 0 || ($hardware['screen_height'] ?? 0) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect WebDriver automation
|
||||
*/
|
||||
private function detectWebDriver(array $deviceInfo, array $context): bool
|
||||
{
|
||||
// Check for WebDriver properties in JavaScript context
|
||||
if (isset($context['webdriver_detected'])) {
|
||||
return $context['webdriver_detected'];
|
||||
}
|
||||
|
||||
// Check for WebDriver indicators in request headers
|
||||
$headers = $context['headers'] ?? [];
|
||||
foreach ($headers as $name => $value) {
|
||||
if (stripos($name, 'webdriver') !== false || stripos($value, 'webdriver') !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze user agent consistency with device characteristics
|
||||
*/
|
||||
private function analyzeUserAgentConsistency(DeviceProfile $profile, array $deviceData): void
|
||||
{
|
||||
// This would analyze if the user agent matches the reported device characteristics
|
||||
// For example, mobile user agent with desktop screen resolution
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze hardware consistency
|
||||
*/
|
||||
private function analyzeHardwareConsistency(DeviceProfile $profile, array $deviceData): void
|
||||
{
|
||||
// This would analyze if hardware specifications are consistent
|
||||
// For example, high-end CPU with low memory, or missing expected mobile features
|
||||
}
|
||||
|
||||
/**
|
||||
* Check device against known suspicious patterns
|
||||
*/
|
||||
private function checkAgainstKnownPatterns(DeviceProfile $profile, array $deviceData): void
|
||||
{
|
||||
foreach ($this->devicePatterns as $pattern) {
|
||||
if ($this->matchesPattern($deviceData, $pattern)) {
|
||||
// Add to suspicious features or increase suspicion score
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device data matches a suspicious pattern
|
||||
*/
|
||||
private function matchesPattern(array $deviceData, array $pattern): bool
|
||||
{
|
||||
foreach ($pattern as $key => $expectedValue) {
|
||||
if (! isset($deviceData[$key]) || $deviceData[$key] !== $expectedValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track suspicious device for analysis
|
||||
*/
|
||||
private function trackSuspiciousDevice(DeviceProfile $profile, array $context): void
|
||||
{
|
||||
$deviceId = $profile->deviceId;
|
||||
|
||||
if (! isset($this->suspiciousDevices[$deviceId])) {
|
||||
$this->suspiciousDevices[$deviceId] = [
|
||||
'first_seen' => $this->clock->time()->toUnixTimestamp(),
|
||||
'detection_count' => 0,
|
||||
'highest_suspicion_score' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$this->suspiciousDevices[$deviceId]['detection_count']++;
|
||||
$this->suspiciousDevices[$deviceId]['last_seen'] = $this->clock->time()->toUnixTimestamp();
|
||||
$this->suspiciousDevices[$deviceId]['highest_suspicion_score'] = max(
|
||||
$this->suspiciousDevices[$deviceId]['highest_suspicion_score'],
|
||||
$profile->getSuspicionScore()
|
||||
);
|
||||
|
||||
// Limit tracking to most recent suspicious devices
|
||||
if (count($this->suspiciousDevices) > 1000) {
|
||||
$oldest = array_keys($this->suspiciousDevices)[0];
|
||||
unset($this->suspiciousDevices[$oldest]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize known suspicious device patterns
|
||||
*/
|
||||
private function initializeDevicePatterns(): void
|
||||
{
|
||||
$this->devicePatterns = [
|
||||
// Common automation frameworks
|
||||
[
|
||||
'browser' => 'Chrome',
|
||||
'is_headless' => true,
|
||||
'has_webdriver' => true,
|
||||
],
|
||||
// Virtual machine with automation
|
||||
[
|
||||
'is_vm' => true,
|
||||
'has_webdriver' => true,
|
||||
],
|
||||
// Suspicious hardware combinations
|
||||
[
|
||||
'memory_size' => 256, // Very low memory
|
||||
'cpu_cores' => 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty device profile for disabled/error cases
|
||||
*/
|
||||
private function createEmptyProfile(): DeviceProfile
|
||||
{
|
||||
return DeviceProfile::fromData([], $this->clock->time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for device profile
|
||||
*/
|
||||
private function generateProfileCacheKey(array $deviceData): string
|
||||
{
|
||||
return hash('sha256', json_encode($deviceData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old profile cache entries
|
||||
*/
|
||||
private function cleanupProfileCache(): void
|
||||
{
|
||||
$currentTime = $this->clock->time()->toUnixTimestamp();
|
||||
|
||||
foreach ($this->deviceProfileCache as $key => $entry) {
|
||||
if ($currentTime - $entry['timestamp'] > $this->profileCacheTimeout) {
|
||||
unset($this->deviceProfileCache[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// If still too large, remove oldest entries
|
||||
if (count($this->deviceProfileCache) > 2500) {
|
||||
$sorted = $this->deviceProfileCache;
|
||||
uasort($sorted, fn ($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||
|
||||
$toRemove = array_slice(array_keys($sorted), 0, 1250, true);
|
||||
foreach ($toRemove as $key) {
|
||||
unset($this->deviceProfileCache[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detector statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'profile_cache_size' => count($this->deviceProfileCache),
|
||||
'cache_timeout_seconds' => $this->profileCacheTimeout,
|
||||
'suspicious_devices_tracked' => count($this->suspiciousDevices),
|
||||
'device_patterns_loaded' => count($this->devicePatterns),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suspicious devices
|
||||
*/
|
||||
public function getSuspiciousDevices(): array
|
||||
{
|
||||
return $this->suspiciousDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detector is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\Detectors;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\BotProtection\ValueObjects\BrowserFingerprint;
|
||||
|
||||
/**
|
||||
* Browser fingerprinting detector for bot identification
|
||||
*/
|
||||
final class FingerprintDetector
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Clock $clock,
|
||||
private readonly Logger $logger,
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $cacheTimeout = 3600, // 1 hour
|
||||
private array $fingerprintCache = [],
|
||||
private array $knownBotFingerprints = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze browser fingerprint for bot indicators
|
||||
*/
|
||||
public function analyzeFingerprint(RequestAnalysisData $requestData, array $context): BrowserFingerprint
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return $this->createEmptyFingerprint();
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract fingerprint data from request
|
||||
$fingerprintData = $this->extractFingerprintData($requestData, $context);
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = $this->generateCacheKey($fingerprintData);
|
||||
if (isset($this->fingerprintCache[$cacheKey])) {
|
||||
$cached = $this->fingerprintCache[$cacheKey];
|
||||
if ($this->clock->time()->toUnixTimestamp() - $cached['timestamp'] < $this->cacheTimeout) {
|
||||
return $cached['fingerprint'];
|
||||
}
|
||||
}
|
||||
|
||||
// Create fingerprint
|
||||
$fingerprint = BrowserFingerprint::fromData($fingerprintData, $this->clock->time());
|
||||
|
||||
// Cache the result
|
||||
$this->fingerprintCache[$cacheKey] = [
|
||||
'fingerprint' => $fingerprint,
|
||||
'timestamp' => $this->clock->time()->toUnixTimestamp(),
|
||||
];
|
||||
|
||||
// Limit cache size
|
||||
if (count($this->fingerprintCache) > 10000) {
|
||||
$this->cleanupCache();
|
||||
}
|
||||
|
||||
return $fingerprint;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Fingerprint analysis failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'request_id' => $context['request_id'] ?? null,
|
||||
]);
|
||||
|
||||
return $this->createEmptyFingerprint();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract fingerprint data from request
|
||||
*/
|
||||
private function extractFingerprintData(RequestAnalysisData $requestData, array $context): array
|
||||
{
|
||||
$data = [
|
||||
'user_agent' => $requestData->userAgent?->toString(),
|
||||
'accept_language' => $requestData->headers['accept-language'] ?? null,
|
||||
'accept_encoding' => $requestData->headers['accept-encoding'] ?? null,
|
||||
];
|
||||
|
||||
// Extract JavaScript-provided fingerprint data if available
|
||||
if (isset($context['fingerprint_data'])) {
|
||||
$jsData = $context['fingerprint_data'];
|
||||
|
||||
// Canvas fingerprint
|
||||
if (isset($jsData['canvas'])) {
|
||||
$data['canvas'] = [
|
||||
'data' => $jsData['canvas']['data'] ?? null,
|
||||
'width' => $jsData['canvas']['width'] ?? null,
|
||||
'height' => $jsData['canvas']['height'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// WebGL fingerprint
|
||||
if (isset($jsData['webgl'])) {
|
||||
$data['webgl'] = [
|
||||
'vendor' => $jsData['webgl']['vendor'] ?? null,
|
||||
'renderer' => $jsData['webgl']['renderer'] ?? null,
|
||||
'version' => $jsData['webgl']['version'] ?? null,
|
||||
'extensions' => $jsData['webgl']['extensions'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
// Audio fingerprint
|
||||
if (isset($jsData['audio'])) {
|
||||
$data['audio'] = [
|
||||
'context_hash' => $jsData['audio']['context_hash'] ?? null,
|
||||
'oscillator_hash' => $jsData['audio']['oscillator_hash'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Font detection
|
||||
if (isset($jsData['fonts'])) {
|
||||
$data['fonts'] = $jsData['fonts'];
|
||||
}
|
||||
|
||||
// Screen information
|
||||
if (isset($jsData['screen'])) {
|
||||
$data['screen'] = [
|
||||
$jsData['screen']['width'] ?? 0,
|
||||
$jsData['screen']['height'] ?? 0,
|
||||
$jsData['screen']['color_depth'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Timezone information
|
||||
if (isset($jsData['timezone'])) {
|
||||
$data['timezone'] = [
|
||||
'offset' => $jsData['timezone']['offset'] ?? null,
|
||||
'name' => $jsData['timezone']['name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Plugin information
|
||||
if (isset($jsData['plugins'])) {
|
||||
$data['plugins'] = $jsData['plugins'];
|
||||
}
|
||||
|
||||
// Touch support
|
||||
$data['touch_support'] = $jsData['touch_support'] ?? false;
|
||||
|
||||
// Color depth
|
||||
$data['color_depth'] = $jsData['color_depth'] ?? null;
|
||||
|
||||
// Pixel ratio
|
||||
$data['pixel_ratio'] = $jsData['pixel_ratio'] ?? null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty fingerprint for disabled/error cases
|
||||
*/
|
||||
private function createEmptyFingerprint(): BrowserFingerprint
|
||||
{
|
||||
return BrowserFingerprint::fromData([], $this->clock->time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for fingerprint data
|
||||
*/
|
||||
private function generateCacheKey(array $data): string
|
||||
{
|
||||
return hash('sha256', json_encode($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache entries
|
||||
*/
|
||||
private function cleanupCache(): void
|
||||
{
|
||||
$currentTime = $this->clock->time()->toUnixTimestamp();
|
||||
|
||||
foreach ($this->fingerprintCache as $key => $entry) {
|
||||
if ($currentTime - $entry['timestamp'] > $this->cacheTimeout) {
|
||||
unset($this->fingerprintCache[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// If still too large, remove oldest entries
|
||||
if (count($this->fingerprintCache) > 5000) {
|
||||
$sorted = $this->fingerprintCache;
|
||||
uasort($sorted, fn ($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||
|
||||
$toRemove = array_slice(array_keys($sorted), 0, 2500, true);
|
||||
foreach ($toRemove as $key) {
|
||||
unset($this->fingerprintCache[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add known bot fingerprint
|
||||
*/
|
||||
public function addKnownBotFingerprint(string $fingerprintHash, array $metadata = []): void
|
||||
{
|
||||
$this->knownBotFingerprints[$fingerprintHash] = [
|
||||
'added_at' => $this->clock->time()->toUnixTimestamp(),
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fingerprint is known bot
|
||||
*/
|
||||
public function isKnownBot(string $fingerprintHash): bool
|
||||
{
|
||||
return isset($this->knownBotFingerprints[$fingerprintHash]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detector statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'cache_size' => count($this->fingerprintCache),
|
||||
'cache_timeout_seconds' => $this->cacheTimeout,
|
||||
'known_bot_fingerprints' => count($this->knownBotFingerprints),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detector is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Result of behavioral analysis for bot detection
|
||||
*/
|
||||
final readonly class BehavioralAnalysisResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $isSuspicious,
|
||||
public float $riskScore,
|
||||
public array $detections,
|
||||
public array $behaviorMetrics,
|
||||
public Duration $analysisDuration,
|
||||
public Percentage $confidence
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty result
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
isSuspicious: false,
|
||||
riskScore: 0.0,
|
||||
detections: [],
|
||||
behaviorMetrics: [],
|
||||
analysisDuration: Duration::zero(),
|
||||
confidence: Percentage::from(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk score
|
||||
*/
|
||||
public function getRiskScore(): float
|
||||
{
|
||||
return $this->riskScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detections
|
||||
*/
|
||||
public function getDetections(): array
|
||||
{
|
||||
return $this->detections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if highly suspicious
|
||||
*/
|
||||
public function isHighlySuspicious(): bool
|
||||
{
|
||||
return $this->riskScore > 80.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'is_suspicious' => $this->isSuspicious,
|
||||
'is_highly_suspicious' => $this->isHighlySuspicious(),
|
||||
'risk_score' => $this->riskScore,
|
||||
'detection_count' => count($this->detections),
|
||||
'behavior_metrics' => $this->behaviorMetrics,
|
||||
'analysis_duration_ms' => $this->analysisDuration->toMilliseconds(),
|
||||
'confidence' => $this->confidence->getValue(),
|
||||
'detections' => array_map(
|
||||
fn ($detection) => $detection instanceof BotDetection ? $detection->toArray() : $detection,
|
||||
$this->detections
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\BotProtection\BotDetectionType;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Individual bot detection result
|
||||
*/
|
||||
final readonly class BotDetection
|
||||
{
|
||||
public function __construct(
|
||||
public BotDetectionType $type,
|
||||
public DetectionSeverity $severity,
|
||||
public string $message,
|
||||
public array $evidence,
|
||||
public Percentage $confidence,
|
||||
public string $source,
|
||||
public ?Timestamp $timestamp = null
|
||||
) {
|
||||
$this->timestamp = $timestamp ?? Timestamp::fromFloat(microtime(true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this detection should block the request
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return $this->type->shouldBlock() ||
|
||||
($this->severity === DetectionSeverity::HIGH && $this->confidence->getValue() >= 90.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this detection should trigger an alert
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return $this->severity->shouldAlert() || $this->confidence->getValue() >= 80.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this detection is high confidence
|
||||
*/
|
||||
public function isHighConfidence(): bool
|
||||
{
|
||||
return $this->confidence->getValue() >= 80.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk contribution score
|
||||
*/
|
||||
public function getRiskContribution(): float
|
||||
{
|
||||
$severityMultiplier = match($this->severity) {
|
||||
DetectionSeverity::CRITICAL => 1.0,
|
||||
DetectionSeverity::HIGH => 0.8,
|
||||
DetectionSeverity::MEDIUM => 0.6,
|
||||
DetectionSeverity::LOW => 0.4,
|
||||
DetectionSeverity::INFO => 0.2
|
||||
};
|
||||
|
||||
return ($this->confidence->getValue() / 100) * $severityMultiplier * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'type_description' => $this->type->getDescription(),
|
||||
'severity' => $this->severity->value,
|
||||
'message' => $this->message,
|
||||
'evidence' => $this->evidence,
|
||||
'confidence' => $this->confidence->getValue(),
|
||||
'source' => $this->source,
|
||||
'timestamp' => $this->timestamp->toIsoString(),
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
'is_high_confidence' => $this->isHighConfidence(),
|
||||
'risk_contribution' => $this->getRiskContribution(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Result of bot protection analysis
|
||||
*/
|
||||
final readonly class BotDetectionResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $isBot,
|
||||
public BotRiskScore $riskScore,
|
||||
public array $detections,
|
||||
public Duration $processingTime,
|
||||
public ?Percentage $confidence = null,
|
||||
public ?BrowserFingerprint $fingerprint = null,
|
||||
public ?DeviceProfile $deviceProfile = null,
|
||||
public bool $captchaRequired = false,
|
||||
public bool $captchaValid = false,
|
||||
public array $riskFactors = [],
|
||||
public ?string $error = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confidence with default fallback
|
||||
*/
|
||||
public function getConfidence(): Percentage
|
||||
{
|
||||
return $this->confidence ?? Percentage::from(0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create disabled result
|
||||
*/
|
||||
public static function disabled(): self
|
||||
{
|
||||
return new self(
|
||||
isBot: false,
|
||||
riskScore: BotRiskScore::zero(),
|
||||
detections: [],
|
||||
processingTime: Duration::zero(),
|
||||
confidence: Percentage::from(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error result
|
||||
*/
|
||||
public static function error(string $error, Duration $processingTime): self
|
||||
{
|
||||
return new self(
|
||||
isBot: false,
|
||||
riskScore: BotRiskScore::zero(),
|
||||
detections: [],
|
||||
processingTime: $processingTime,
|
||||
confidence: Percentage::from(0.0),
|
||||
error: $error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be blocked
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return $this->isBot && $this->riskScore->getValue() >= 90.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be flagged
|
||||
*/
|
||||
public function shouldFlag(): bool
|
||||
{
|
||||
return $this->isBot || $this->riskScore->getValue() >= 50.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request requires additional verification
|
||||
*/
|
||||
public function requiresVerification(): bool
|
||||
{
|
||||
return $this->captchaRequired && ! $this->captchaValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level description
|
||||
*/
|
||||
public function getRiskLevel(): string
|
||||
{
|
||||
$score = $this->riskScore->getValue();
|
||||
|
||||
return match (true) {
|
||||
$score >= 90.0 => 'CRITICAL',
|
||||
$score >= 75.0 => 'HIGH',
|
||||
$score >= 50.0 => 'MEDIUM',
|
||||
$score >= 25.0 => 'LOW',
|
||||
default => 'MINIMAL'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detection types present
|
||||
*/
|
||||
public function getDetectionTypes(): array
|
||||
{
|
||||
$types = [];
|
||||
|
||||
foreach ($this->detections as $detection) {
|
||||
if ($detection instanceof BotDetection) {
|
||||
$types[] = $detection->type->value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get high-confidence detections
|
||||
*/
|
||||
public function getHighConfidenceDetections(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->detections,
|
||||
fn ($detection) => $detection instanceof BotDetection && $detection->confidence->getValue() >= 80.0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis had errors
|
||||
*/
|
||||
public function hasError(): bool
|
||||
{
|
||||
return $this->error !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'is_bot' => $this->isBot,
|
||||
'risk_score' => $this->riskScore->getValue(),
|
||||
'risk_level' => $this->getRiskLevel(),
|
||||
'detection_count' => count($this->detections),
|
||||
'detection_types' => $this->getDetectionTypes(),
|
||||
'captcha_required' => $this->captchaRequired,
|
||||
'captcha_valid' => $this->captchaValid,
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_flag' => $this->shouldFlag(),
|
||||
'requires_verification' => $this->requiresVerification(),
|
||||
'confidence' => $this->getConfidence()->getValue(),
|
||||
'processing_time_ms' => $this->processingTime->toMilliseconds(),
|
||||
'risk_factors' => $this->riskFactors,
|
||||
'fingerprint' => $this->fingerprint?->toArray(),
|
||||
'device_profile' => $this->deviceProfile?->toArray(),
|
||||
'detections' => array_map(
|
||||
fn ($detection) => $detection instanceof BotDetection ? $detection->toArray() : $detection,
|
||||
$this->detections
|
||||
),
|
||||
'error' => $this->error,
|
||||
];
|
||||
}
|
||||
}
|
||||
110
src/Framework/Waf/BotProtection/ValueObjects/BotRiskScore.php
Normal file
110
src/Framework/Waf/BotProtection/ValueObjects/BotRiskScore.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Bot risk score with factors and confidence
|
||||
*/
|
||||
final readonly class BotRiskScore
|
||||
{
|
||||
public function __construct(
|
||||
public Percentage $score,
|
||||
public array $factors,
|
||||
public int $detectionCount,
|
||||
public Percentage $confidence
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create zero risk score
|
||||
*/
|
||||
public static function zero(): self
|
||||
{
|
||||
return new self(
|
||||
score: Percentage::from(0.0),
|
||||
factors: [],
|
||||
detectionCount: 0,
|
||||
confidence: Percentage::from(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk score value
|
||||
*/
|
||||
public function getValue(): float
|
||||
{
|
||||
return $this->score->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if risk is critical
|
||||
*/
|
||||
public function isCritical(): bool
|
||||
{
|
||||
return $this->getValue() >= 90.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if risk is high
|
||||
*/
|
||||
public function isHigh(): bool
|
||||
{
|
||||
return $this->getValue() >= 75.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if risk is elevated
|
||||
*/
|
||||
public function isElevated(): bool
|
||||
{
|
||||
return $this->getValue() >= 50.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dominant risk factor
|
||||
*/
|
||||
public function getDominantFactor(): ?string
|
||||
{
|
||||
if (empty($this->factors)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_keys($this->factors, max($this->factors))[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level
|
||||
*/
|
||||
public function getRiskLevel(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->isCritical() => 'CRITICAL',
|
||||
$this->isHigh() => 'HIGH',
|
||||
$this->isElevated() => 'MEDIUM',
|
||||
$this->getValue() >= 25.0 => 'LOW',
|
||||
default => 'MINIMAL'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'score' => $this->getValue(),
|
||||
'level' => $this->getRiskLevel(),
|
||||
'factors' => $this->factors,
|
||||
'detection_count' => $this->detectionCount,
|
||||
'confidence' => $this->confidence->getValue(),
|
||||
'dominant_factor' => $this->getDominantFactor(),
|
||||
'is_critical' => $this->isCritical(),
|
||||
'is_high' => $this->isHigh(),
|
||||
'is_elevated' => $this->isElevated(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Browser fingerprint with anomaly detection
|
||||
*/
|
||||
final readonly class BrowserFingerprint
|
||||
{
|
||||
public function __construct(
|
||||
public string $fingerprintHash,
|
||||
public array $features,
|
||||
public array $anomalousFeatures,
|
||||
public float $anomalyScore,
|
||||
public Timestamp $createdAt,
|
||||
public ?string $userAgent = null,
|
||||
public ?string $acceptLanguage = null,
|
||||
public ?string $acceptEncoding = null,
|
||||
public ?array $canvasFingerprint = null,
|
||||
public ?array $webglFingerprint = null,
|
||||
public ?array $audioFingerprint = null,
|
||||
public ?array $fontList = null,
|
||||
public ?array $screenResolution = null,
|
||||
public ?array $timezoneInfo = null,
|
||||
public ?array $pluginList = null,
|
||||
public bool $touchSupport = false,
|
||||
public ?int $colorDepth = null,
|
||||
public ?int $pixelRatio = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from raw fingerprint data
|
||||
*/
|
||||
public static function fromData(array $data, Timestamp $timestamp): self
|
||||
{
|
||||
$features = self::extractFeatures($data);
|
||||
$anomalousFeatures = self::detectAnomalies($features);
|
||||
$anomalyScore = self::calculateAnomalyScore($anomalousFeatures, $features);
|
||||
|
||||
return new self(
|
||||
fingerprintHash: self::generateHash($features),
|
||||
features: $features,
|
||||
anomalousFeatures: $anomalousFeatures,
|
||||
anomalyScore: $anomalyScore,
|
||||
createdAt: $timestamp,
|
||||
userAgent: $data['user_agent'] ?? null,
|
||||
acceptLanguage: $data['accept_language'] ?? null,
|
||||
acceptEncoding: $data['accept_encoding'] ?? null,
|
||||
canvasFingerprint: $data['canvas'] ?? null,
|
||||
webglFingerprint: $data['webgl'] ?? null,
|
||||
audioFingerprint: $data['audio'] ?? null,
|
||||
fontList: $data['fonts'] ?? null,
|
||||
screenResolution: $data['screen'] ?? null,
|
||||
timezoneInfo: $data['timezone'] ?? null,
|
||||
pluginList: $data['plugins'] ?? null,
|
||||
touchSupport: $data['touch_support'] ?? false,
|
||||
colorDepth: $data['color_depth'] ?? null,
|
||||
pixelRatio: $data['pixel_ratio'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fingerprint is anomalous
|
||||
*/
|
||||
public function isAnomalous(): bool
|
||||
{
|
||||
return $this->anomalyScore > 70.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fingerprint is highly suspicious
|
||||
*/
|
||||
public function isHighlySuspicious(): bool
|
||||
{
|
||||
return $this->anomalyScore > 90.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anomaly score
|
||||
*/
|
||||
public function getAnomalyScore(): float
|
||||
{
|
||||
return $this->anomalyScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anomalous features
|
||||
*/
|
||||
public function getAnomalousFeatures(): array
|
||||
{
|
||||
return $this->anomalousFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fingerprint matches another
|
||||
*/
|
||||
public function matches(self $other): bool
|
||||
{
|
||||
return $this->fingerprintHash === $other->fingerprintHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity with another fingerprint
|
||||
*/
|
||||
public function similarity(self $other): float
|
||||
{
|
||||
$commonFeatures = array_intersect_key($this->features, $other->features);
|
||||
$totalFeatures = array_merge($this->features, $other->features);
|
||||
|
||||
if (empty($totalFeatures)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$matchingFeatures = 0;
|
||||
foreach ($commonFeatures as $key => $value) {
|
||||
if ($this->features[$key] === $other->features[$key]) {
|
||||
$matchingFeatures++;
|
||||
}
|
||||
}
|
||||
|
||||
return ($matchingFeatures / count($totalFeatures)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract features from raw data
|
||||
*/
|
||||
private static function extractFeatures(array $data): array
|
||||
{
|
||||
return [
|
||||
'user_agent_hash' => hash('sha256', $data['user_agent'] ?? ''),
|
||||
'language' => $data['accept_language'] ?? null,
|
||||
'encoding' => $data['accept_encoding'] ?? null,
|
||||
'canvas_hash' => isset($data['canvas']) ? hash('sha256', json_encode($data['canvas'])) : null,
|
||||
'webgl_hash' => isset($data['webgl']) ? hash('sha256', json_encode($data['webgl'])) : null,
|
||||
'audio_hash' => isset($data['audio']) ? hash('sha256', json_encode($data['audio'])) : null,
|
||||
'font_count' => isset($data['fonts']) ? count($data['fonts']) : null,
|
||||
'screen_signature' => isset($data['screen']) ? implode('x', $data['screen']) : null,
|
||||
'timezone_offset' => $data['timezone']['offset'] ?? null,
|
||||
'plugin_count' => isset($data['plugins']) ? count($data['plugins']) : null,
|
||||
'touch_support' => $data['touch_support'] ?? false,
|
||||
'color_depth' => $data['color_depth'] ?? null,
|
||||
'pixel_ratio' => $data['pixel_ratio'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect anomalies in features
|
||||
*/
|
||||
private static function detectAnomalies(array $features): array
|
||||
{
|
||||
$anomalies = [];
|
||||
|
||||
// Check for common bot indicators
|
||||
if (isset($features['user_agent_hash']) && self::isSuspiciousUserAgent($features['user_agent_hash'])) {
|
||||
$anomalies['suspicious_user_agent'] = 'User agent matches known bot patterns';
|
||||
}
|
||||
|
||||
if (isset($features['font_count']) && ($features['font_count'] < 10 || $features['font_count'] > 500)) {
|
||||
$anomalies['unusual_font_count'] = "Unusual font count: {$features['font_count']}";
|
||||
}
|
||||
|
||||
if (isset($features['plugin_count']) && $features['plugin_count'] === 0) {
|
||||
$anomalies['no_plugins'] = 'No browser plugins detected';
|
||||
}
|
||||
|
||||
if (! isset($features['canvas_hash']) || ! isset($features['webgl_hash'])) {
|
||||
$anomalies['missing_fingerprinting'] = 'Canvas or WebGL fingerprinting blocked';
|
||||
}
|
||||
|
||||
if (isset($features['screen_signature'])) {
|
||||
$commonResolutions = ['1920x1080', '1366x768', '1440x900', '1280x720'];
|
||||
if (! in_array($features['screen_signature'], $commonResolutions)) {
|
||||
$anomalies['unusual_resolution'] = "Unusual screen resolution: {$features['screen_signature']}";
|
||||
}
|
||||
}
|
||||
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate anomaly score
|
||||
*/
|
||||
private static function calculateAnomalyScore(array $anomalies, array $features): float
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
// Base score from anomaly count
|
||||
$score += count($anomalies) * 20;
|
||||
|
||||
// Weight specific anomalies
|
||||
$weights = [
|
||||
'suspicious_user_agent' => 30,
|
||||
'unusual_font_count' => 15,
|
||||
'no_plugins' => 25,
|
||||
'missing_fingerprinting' => 35,
|
||||
'unusual_resolution' => 10,
|
||||
];
|
||||
|
||||
foreach ($anomalies as $type => $description) {
|
||||
$score += $weights[$type] ?? 10;
|
||||
}
|
||||
|
||||
// Cap at 100
|
||||
return min(100.0, $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user agent is suspicious
|
||||
*/
|
||||
private static function isSuspiciousUserAgent(string $userAgentHash): bool
|
||||
{
|
||||
// In a real implementation, this would check against a database of known bot user agents
|
||||
$knownBotHashes = [
|
||||
// Common bot user agent hashes would be stored here
|
||||
];
|
||||
|
||||
return in_array($userAgentHash, $knownBotHashes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fingerprint hash
|
||||
*/
|
||||
private static function generateHash(array $features): string
|
||||
{
|
||||
ksort($features);
|
||||
|
||||
return hash('sha256', json_encode($features));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'fingerprint_hash' => $this->fingerprintHash,
|
||||
'anomaly_score' => $this->anomalyScore,
|
||||
'is_anomalous' => $this->isAnomalous(),
|
||||
'is_highly_suspicious' => $this->isHighlySuspicious(),
|
||||
'anomalous_features' => $this->anomalousFeatures,
|
||||
'created_at' => $this->createdAt->toIsoString(),
|
||||
'user_agent' => $this->userAgent,
|
||||
'accept_language' => $this->acceptLanguage,
|
||||
'canvas_available' => $this->canvasFingerprint !== null,
|
||||
'webgl_available' => $this->webglFingerprint !== null,
|
||||
'audio_available' => $this->audioFingerprint !== null,
|
||||
'font_count' => $this->fontList ? count($this->fontList) : null,
|
||||
'screen_resolution' => $this->screenResolution,
|
||||
'timezone_info' => $this->timezoneInfo,
|
||||
'plugin_count' => $this->pluginList ? count($this->pluginList) : null,
|
||||
'touch_support' => $this->touchSupport,
|
||||
'color_depth' => $this->colorDepth,
|
||||
'pixel_ratio' => $this->pixelRatio,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* CAPTCHA challenge data
|
||||
*/
|
||||
final readonly class CaptchaChallenge
|
||||
{
|
||||
public function __construct(
|
||||
public string $challengeId,
|
||||
public string $sessionId,
|
||||
public string $provider,
|
||||
public string $siteKey,
|
||||
public float $riskScore,
|
||||
public Timestamp $createdAt,
|
||||
public Timestamp $expiresAt,
|
||||
public bool $isRequired,
|
||||
public array $context = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create disabled challenge
|
||||
*/
|
||||
public static function disabled(): self
|
||||
{
|
||||
return new self(
|
||||
challengeId: '',
|
||||
sessionId: '',
|
||||
provider: 'disabled',
|
||||
siteKey: '',
|
||||
riskScore: 0.0,
|
||||
createdAt: Timestamp::fromFloat(microtime(true)),
|
||||
expiresAt: Timestamp::fromFloat(microtime(true)),
|
||||
isRequired: false,
|
||||
context: []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
challengeId: $data['challenge_id'] ?? '',
|
||||
sessionId: $data['session_id'] ?? '',
|
||||
provider: $data['provider'] ?? 'disabled',
|
||||
siteKey: $data['site_key'] ?? '',
|
||||
riskScore: $data['risk_score'] ?? 0.0,
|
||||
createdAt: isset($data['created_at']) ? Timestamp::fromFloat($data['created_at']) : Timestamp::fromFloat(microtime(true)),
|
||||
expiresAt: isset($data['expires_at']) ? Timestamp::fromFloat($data['expires_at']) : Timestamp::fromFloat(microtime(true)),
|
||||
isRequired: $data['is_required'] ?? false,
|
||||
context: $data['context'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if challenge is active
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return ! empty($this->challengeId) &&
|
||||
$this->provider !== 'disabled' &&
|
||||
! $this->isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if challenge has expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expiresAt->isBefore(Timestamp::fromFloat(microtime(true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until expiration in seconds
|
||||
*/
|
||||
public function getTimeUntilExpiration(): int
|
||||
{
|
||||
if ($this->isExpired()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ($this->expiresAt->toUnixTimestamp() - microtime(true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get challenge HTML for frontend integration
|
||||
*/
|
||||
public function getHtml(): string
|
||||
{
|
||||
if (! $this->isActive()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match($this->provider) {
|
||||
'recaptcha' => $this->getRecaptchaHtml(),
|
||||
'hcaptcha' => $this->getHcaptchaHtml(),
|
||||
default => ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reCAPTCHA HTML
|
||||
*/
|
||||
private function getRecaptchaHtml(): string
|
||||
{
|
||||
return sprintf(
|
||||
'<div class="g-recaptcha" data-sitekey="%s" data-challenge-id="%s"></div>',
|
||||
htmlspecialchars($this->siteKey),
|
||||
htmlspecialchars($this->challengeId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hCaptcha HTML
|
||||
*/
|
||||
private function getHcaptchaHtml(): string
|
||||
{
|
||||
return sprintf(
|
||||
'<div class="h-captcha" data-sitekey="%s" data-challenge-id="%s"></div>',
|
||||
htmlspecialchars($this->siteKey),
|
||||
htmlspecialchars($this->challengeId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JavaScript code for frontend integration
|
||||
*/
|
||||
public function getJavaScript(): string
|
||||
{
|
||||
if (! $this->isActive()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match($this->provider) {
|
||||
'recaptcha' => $this->getRecaptchaJavaScript(),
|
||||
'hcaptcha' => $this->getHcaptchaJavaScript(),
|
||||
default => ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reCAPTCHA JavaScript
|
||||
*/
|
||||
private function getRecaptchaJavaScript(): string
|
||||
{
|
||||
$requiredValue = $this->isRequired ? 'true' : 'false';
|
||||
$expiresAt = $this->expiresAt->toUnixTimestamp();
|
||||
|
||||
return <<<JS
|
||||
// Load reCAPTCHA API
|
||||
if (!window.grecaptcha) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.google.com/recaptcha/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Challenge data
|
||||
window.captchaChallenge = {
|
||||
id: '{$this->challengeId}',
|
||||
provider: 'recaptcha',
|
||||
siteKey: '{$this->siteKey}',
|
||||
required: {$requiredValue},
|
||||
expiresAt: {$expiresAt}
|
||||
};
|
||||
JS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hCaptcha JavaScript
|
||||
*/
|
||||
private function getHcaptchaJavaScript(): string
|
||||
{
|
||||
$requiredValue = $this->isRequired ? 'true' : 'false';
|
||||
$expiresAt = $this->expiresAt->toUnixTimestamp();
|
||||
|
||||
return <<<JS
|
||||
// Load hCaptcha API
|
||||
if (!window.hcaptcha) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://js.hcaptcha.com/1/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Challenge data
|
||||
window.captchaChallenge = {
|
||||
id: '{$this->challengeId}',
|
||||
provider: 'hcaptcha',
|
||||
siteKey: '{$this->siteKey}',
|
||||
required: {$requiredValue},
|
||||
expiresAt: {$expiresAt}
|
||||
};
|
||||
JS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'challenge_id' => $this->challengeId,
|
||||
'session_id' => $this->sessionId,
|
||||
'provider' => $this->provider,
|
||||
'site_key' => $this->siteKey,
|
||||
'risk_score' => $this->riskScore,
|
||||
'created_at' => $this->createdAt->toUnixTimestamp(),
|
||||
'expires_at' => $this->expiresAt->toUnixTimestamp(),
|
||||
'is_required' => $this->isRequired,
|
||||
'is_active' => $this->isActive(),
|
||||
'is_expired' => $this->isExpired(),
|
||||
'time_until_expiration' => $this->getTimeUntilExpiration(),
|
||||
'context' => $this->context,
|
||||
];
|
||||
}
|
||||
}
|
||||
232
src/Framework/Waf/BotProtection/ValueObjects/CaptchaResult.php
Normal file
232
src/Framework/Waf/BotProtection/ValueObjects/CaptchaResult.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* CAPTCHA validation result
|
||||
*/
|
||||
final readonly class CaptchaResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $isValid,
|
||||
public ?float $score,
|
||||
public ?string $action,
|
||||
public ?string $hostname,
|
||||
public string $challengeId,
|
||||
public string $provider,
|
||||
public Timestamp $validatedAt,
|
||||
public array $errorCodes = [],
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create disabled result
|
||||
*/
|
||||
public static function disabled(): self
|
||||
{
|
||||
return new self(
|
||||
isValid: true, // Allow through when disabled
|
||||
score: null,
|
||||
action: null,
|
||||
hostname: null,
|
||||
challengeId: '',
|
||||
provider: 'disabled',
|
||||
validatedAt: Timestamp::fromFloat(microtime(true)),
|
||||
errorCodes: [],
|
||||
metadata: ['disabled' => true]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create invalid result
|
||||
*/
|
||||
public static function invalid(string $reason = 'Invalid response'): self
|
||||
{
|
||||
return new self(
|
||||
isValid: false,
|
||||
score: 0.0,
|
||||
action: null,
|
||||
hostname: null,
|
||||
challengeId: '',
|
||||
provider: 'unknown',
|
||||
validatedAt: Timestamp::fromFloat(microtime(true)),
|
||||
errorCodes: ['invalid-response'],
|
||||
metadata: ['reason' => $reason]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result indicates human user (high confidence)
|
||||
*/
|
||||
public function isHuman(): bool
|
||||
{
|
||||
if (! $this->isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If score is available, use it
|
||||
if ($this->score !== null) {
|
||||
return $this->score >= 0.7;
|
||||
}
|
||||
|
||||
// For binary CAPTCHA (like hCaptcha), valid = human
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result indicates likely bot
|
||||
*/
|
||||
public function isBot(): bool
|
||||
{
|
||||
if (! $this->isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If score is available, use it
|
||||
if ($this->score !== null) {
|
||||
return $this->score < 0.3;
|
||||
}
|
||||
|
||||
// For binary CAPTCHA, invalid = bot
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result is inconclusive
|
||||
*/
|
||||
public function isInconclusive(): bool
|
||||
{
|
||||
if (! $this->isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If score is available, check middle range
|
||||
if ($this->score !== null) {
|
||||
return $this->score >= 0.3 && $this->score < 0.7;
|
||||
}
|
||||
|
||||
// Binary CAPTCHA is never inconclusive
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confidence level
|
||||
*/
|
||||
public function getConfidenceLevel(): string
|
||||
{
|
||||
if (! $this->isValid) {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
if ($this->score === null) {
|
||||
return 'binary'; // Binary CAPTCHA
|
||||
}
|
||||
|
||||
return match(true) {
|
||||
$this->score >= 0.9 => 'very_high',
|
||||
$this->score >= 0.7 => 'high',
|
||||
$this->score >= 0.5 => 'medium',
|
||||
$this->score >= 0.3 => 'low',
|
||||
default => 'very_low'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if has errors
|
||||
*/
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return ! empty($this->errorCodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message
|
||||
*/
|
||||
public function getErrorMessage(): ?string
|
||||
{
|
||||
if (empty($this->errorCodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map common error codes to user-friendly messages
|
||||
$errorMessages = [
|
||||
'missing-input-secret' => 'CAPTCHA configuration error',
|
||||
'invalid-input-secret' => 'CAPTCHA configuration error',
|
||||
'missing-input-response' => 'CAPTCHA response missing',
|
||||
'invalid-input-response' => 'Invalid CAPTCHA response',
|
||||
'bad-request' => 'CAPTCHA request malformed',
|
||||
'timeout-or-duplicate' => 'CAPTCHA expired or already used',
|
||||
'score-threshold-not-met' => 'CAPTCHA score too low',
|
||||
'hostname-mismatch' => 'CAPTCHA hostname mismatch',
|
||||
];
|
||||
|
||||
$firstError = $this->errorCodes[0];
|
||||
|
||||
return $errorMessages[$firstError] ?? "CAPTCHA error: {$firstError}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Should request be blocked based on result
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return ! $this->isValid || $this->isBot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Should request be allowed through
|
||||
*/
|
||||
public function shouldAllow(): bool
|
||||
{
|
||||
return $this->isValid && ($this->isHuman() || $this->provider === 'disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Should request require additional verification
|
||||
*/
|
||||
public function requiresAdditionalVerification(): bool
|
||||
{
|
||||
return $this->isValid && $this->isInconclusive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get response time if available
|
||||
*/
|
||||
public function getResponseTime(): ?float
|
||||
{
|
||||
return $this->metadata['response_time'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'is_valid' => $this->isValid,
|
||||
'score' => $this->score,
|
||||
'action' => $this->action,
|
||||
'hostname' => $this->hostname,
|
||||
'challenge_id' => $this->challengeId,
|
||||
'provider' => $this->provider,
|
||||
'validated_at' => $this->validatedAt->toIsoString(),
|
||||
'error_codes' => $this->errorCodes,
|
||||
'metadata' => $this->metadata,
|
||||
'is_human' => $this->isHuman(),
|
||||
'is_bot' => $this->isBot(),
|
||||
'is_inconclusive' => $this->isInconclusive(),
|
||||
'confidence_level' => $this->getConfidenceLevel(),
|
||||
'has_errors' => $this->hasErrors(),
|
||||
'error_message' => $this->getErrorMessage(),
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_allow' => $this->shouldAllow(),
|
||||
'requires_additional_verification' => $this->requiresAdditionalVerification(),
|
||||
'response_time' => $this->getResponseTime(),
|
||||
];
|
||||
}
|
||||
}
|
||||
284
src/Framework/Waf/BotProtection/ValueObjects/DeviceProfile.php
Normal file
284
src/Framework/Waf/BotProtection/ValueObjects/DeviceProfile.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\BotProtection\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Device profile with hardware and software characteristics
|
||||
*/
|
||||
final readonly class DeviceProfile
|
||||
{
|
||||
public function __construct(
|
||||
public string $deviceId,
|
||||
public array $characteristics,
|
||||
public array $suspiciousFeatures,
|
||||
public float $suspicionScore,
|
||||
public Timestamp $createdAt,
|
||||
public ?string $operatingSystem = null,
|
||||
public ?string $browser = null,
|
||||
public ?string $deviceType = null,
|
||||
public ?array $hardwareSpecs = null,
|
||||
public ?array $networkInfo = null,
|
||||
public ?array $locationData = null,
|
||||
public ?float $batteryLevel = null,
|
||||
public ?int $memorySize = null,
|
||||
public ?int $cpuCores = null,
|
||||
public ?string $gpuRenderer = null,
|
||||
public ?array $connectionType = null,
|
||||
public bool $isVirtualMachine = false,
|
||||
public bool $isHeadless = false,
|
||||
public bool $hasWebDriver = false
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from device data
|
||||
*/
|
||||
public static function fromData(array $data, Timestamp $timestamp): self
|
||||
{
|
||||
$characteristics = self::extractCharacteristics($data);
|
||||
$suspiciousFeatures = self::detectSuspiciousFeatures($characteristics);
|
||||
$suspicionScore = self::calculateSuspicionScore($suspiciousFeatures, $characteristics);
|
||||
|
||||
return new self(
|
||||
deviceId: self::generateDeviceId($characteristics),
|
||||
characteristics: $characteristics,
|
||||
suspiciousFeatures: $suspiciousFeatures,
|
||||
suspicionScore: $suspicionScore,
|
||||
createdAt: $timestamp,
|
||||
operatingSystem: $data['os'] ?? null,
|
||||
browser: $data['browser'] ?? null,
|
||||
deviceType: $data['device_type'] ?? null,
|
||||
hardwareSpecs: $data['hardware'] ?? null,
|
||||
networkInfo: $data['network'] ?? null,
|
||||
locationData: $data['location'] ?? null,
|
||||
batteryLevel: $data['battery_level'] ?? null,
|
||||
memorySize: $data['memory_size'] ?? null,
|
||||
cpuCores: $data['cpu_cores'] ?? null,
|
||||
gpuRenderer: $data['gpu_renderer'] ?? null,
|
||||
connectionType: $data['connection_type'] ?? null,
|
||||
isVirtualMachine: $data['is_vm'] ?? false,
|
||||
isHeadless: $data['is_headless'] ?? false,
|
||||
hasWebDriver: $data['has_webdriver'] ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is suspicious
|
||||
*/
|
||||
public function isSuspicious(): bool
|
||||
{
|
||||
return $this->suspicionScore > 60.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is highly suspicious
|
||||
*/
|
||||
public function isHighlySuspicious(): bool
|
||||
{
|
||||
return $this->suspicionScore > 85.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suspicion score
|
||||
*/
|
||||
public function getSuspicionScore(): float
|
||||
{
|
||||
return $this->suspicionScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suspicious features
|
||||
*/
|
||||
public function getSuspiciousFeatures(): array
|
||||
{
|
||||
return $this->suspiciousFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is likely a bot
|
||||
*/
|
||||
public function isLikelyBot(): bool
|
||||
{
|
||||
return $this->isVirtualMachine || $this->isHeadless || $this->hasWebDriver || $this->suspicionScore > 90.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device matches another profile
|
||||
*/
|
||||
public function matches(self $other): bool
|
||||
{
|
||||
return $this->deviceId === $other->deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity with another device profile
|
||||
*/
|
||||
public function similarity(self $other): float
|
||||
{
|
||||
$commonCharacteristics = array_intersect_key($this->characteristics, $other->characteristics);
|
||||
$totalCharacteristics = array_merge($this->characteristics, $other->characteristics);
|
||||
|
||||
if (empty($totalCharacteristics)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$matchingCharacteristics = 0;
|
||||
foreach ($commonCharacteristics as $key => $value) {
|
||||
if ($this->characteristics[$key] === $other->characteristics[$key]) {
|
||||
$matchingCharacteristics++;
|
||||
}
|
||||
}
|
||||
|
||||
return ($matchingCharacteristics / count($totalCharacteristics)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract device characteristics
|
||||
*/
|
||||
private static function extractCharacteristics(array $data): array
|
||||
{
|
||||
return [
|
||||
'os_hash' => hash('sha256', $data['os'] ?? ''),
|
||||
'browser_hash' => hash('sha256', $data['browser'] ?? ''),
|
||||
'device_type' => $data['device_type'] ?? 'unknown',
|
||||
'memory_size' => $data['memory_size'] ?? null,
|
||||
'cpu_cores' => $data['cpu_cores'] ?? null,
|
||||
'gpu_hash' => isset($data['gpu_renderer']) ? hash('sha256', $data['gpu_renderer']) : null,
|
||||
'screen_width' => $data['hardware']['screen_width'] ?? null,
|
||||
'screen_height' => $data['hardware']['screen_height'] ?? null,
|
||||
'timezone_offset' => $data['location']['timezone_offset'] ?? null,
|
||||
'language' => $data['language'] ?? null,
|
||||
'connection_type' => $data['connection_type']['type'] ?? null,
|
||||
'is_vm' => $data['is_vm'] ?? false,
|
||||
'is_headless' => $data['is_headless'] ?? false,
|
||||
'has_webdriver' => $data['has_webdriver'] ?? false,
|
||||
'battery_available' => isset($data['battery_level']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect suspicious device features
|
||||
*/
|
||||
private static function detectSuspiciousFeatures(array $characteristics): array
|
||||
{
|
||||
$suspicious = [];
|
||||
|
||||
// Check for virtualization indicators
|
||||
if ($characteristics['is_vm']) {
|
||||
$suspicious['virtual_machine'] = 'Device is running in a virtual machine';
|
||||
}
|
||||
|
||||
if ($characteristics['is_headless']) {
|
||||
$suspicious['headless_browser'] = 'Headless browser detected';
|
||||
}
|
||||
|
||||
if ($characteristics['has_webdriver']) {
|
||||
$suspicious['webdriver_detected'] = 'WebDriver automation detected';
|
||||
}
|
||||
|
||||
// Check for unusual hardware specifications
|
||||
if (isset($characteristics['memory_size']) && $characteristics['memory_size'] < 1024) {
|
||||
$suspicious['low_memory'] = "Unusually low memory: {$characteristics['memory_size']}MB";
|
||||
}
|
||||
|
||||
if (isset($characteristics['cpu_cores']) && $characteristics['cpu_cores'] > 64) {
|
||||
$suspicious['high_cpu_cores'] = "Unusually high CPU cores: {$characteristics['cpu_cores']}";
|
||||
}
|
||||
|
||||
// Check for missing expected features
|
||||
if (! $characteristics['battery_available'] && $characteristics['device_type'] === 'mobile') {
|
||||
$suspicious['missing_battery'] = 'Mobile device without battery information';
|
||||
}
|
||||
|
||||
// Check for inconsistent characteristics
|
||||
if ($characteristics['device_type'] === 'mobile' &&
|
||||
isset($characteristics['screen_width']) &&
|
||||
$characteristics['screen_width'] > 1920) {
|
||||
$suspicious['inconsistent_mobile'] = 'Mobile device with desktop-like screen resolution';
|
||||
}
|
||||
|
||||
return $suspicious;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate suspicion score
|
||||
*/
|
||||
private static function calculateSuspicionScore(array $suspiciousFeatures, array $characteristics): float
|
||||
{
|
||||
$score = 0.0;
|
||||
|
||||
// Weight suspicious features
|
||||
$weights = [
|
||||
'virtual_machine' => 40,
|
||||
'headless_browser' => 50,
|
||||
'webdriver_detected' => 60,
|
||||
'low_memory' => 20,
|
||||
'high_cpu_cores' => 25,
|
||||
'missing_battery' => 15,
|
||||
'inconsistent_mobile' => 30,
|
||||
];
|
||||
|
||||
foreach ($suspiciousFeatures as $feature => $description) {
|
||||
$score += $weights[$feature] ?? 10;
|
||||
}
|
||||
|
||||
// Additional scoring based on characteristic patterns
|
||||
if ($characteristics['is_vm'] && $characteristics['is_headless'] && $characteristics['has_webdriver']) {
|
||||
$score += 30; // Triple threat bonus
|
||||
}
|
||||
|
||||
return min(100.0, $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique device ID
|
||||
*/
|
||||
private static function generateDeviceId(array $characteristics): string
|
||||
{
|
||||
$identifiers = [
|
||||
$characteristics['os_hash'] ?? '',
|
||||
$characteristics['browser_hash'] ?? '',
|
||||
$characteristics['gpu_hash'] ?? '',
|
||||
$characteristics['screen_width'] ?? '',
|
||||
$characteristics['screen_height'] ?? '',
|
||||
$characteristics['memory_size'] ?? '',
|
||||
$characteristics['cpu_cores'] ?? '',
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $identifiers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'device_id' => $this->deviceId,
|
||||
'suspicion_score' => $this->suspicionScore,
|
||||
'is_suspicious' => $this->isSuspicious(),
|
||||
'is_highly_suspicious' => $this->isHighlySuspicious(),
|
||||
'is_likely_bot' => $this->isLikelyBot(),
|
||||
'suspicious_features' => $this->suspiciousFeatures,
|
||||
'created_at' => $this->createdAt->toIsoString(),
|
||||
'operating_system' => $this->operatingSystem,
|
||||
'browser' => $this->browser,
|
||||
'device_type' => $this->deviceType,
|
||||
'hardware_specs' => $this->hardwareSpecs,
|
||||
'network_info' => $this->networkInfo,
|
||||
'location_data' => $this->locationData,
|
||||
'battery_level' => $this->batteryLevel,
|
||||
'memory_size' => $this->memorySize,
|
||||
'cpu_cores' => $this->cpuCores,
|
||||
'gpu_renderer' => $this->gpuRenderer,
|
||||
'connection_type' => $this->connectionType,
|
||||
'is_virtual_machine' => $this->isVirtualMachine,
|
||||
'is_headless' => $this->isHeadless,
|
||||
'has_webdriver' => $this->hasWebDriver,
|
||||
];
|
||||
}
|
||||
}
|
||||
209
src/Framework/Waf/DetectionCategory.php
Normal file
209
src/Framework/Waf/DetectionCategory.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
/**
|
||||
* Categories of security threats detected by WAF layers
|
||||
* Based on OWASP Top 10 and common attack vectors
|
||||
*/
|
||||
enum DetectionCategory: string
|
||||
{
|
||||
// OWASP Top 10 categories
|
||||
case BROKEN_ACCESS_CONTROL = 'broken_access_control';
|
||||
case CRYPTOGRAPHIC_FAILURES = 'cryptographic_failures';
|
||||
case INJECTION = 'injection';
|
||||
case INSECURE_DESIGN = 'insecure_design';
|
||||
case SECURITY_MISCONFIGURATION = 'security_misconfiguration';
|
||||
case VULNERABLE_COMPONENTS = 'vulnerable_components';
|
||||
case IDENTIFICATION_FAILURES = 'identification_failures';
|
||||
case SOFTWARE_INTEGRITY_FAILURES = 'software_integrity_failures';
|
||||
case LOGGING_MONITORING_FAILURES = 'logging_monitoring_failures';
|
||||
case SSRF = 'server_side_request_forgery';
|
||||
|
||||
// Specific attack types
|
||||
case SQL_INJECTION = 'sql_injection';
|
||||
case XSS = 'cross_site_scripting';
|
||||
case CSRF = 'cross_site_request_forgery';
|
||||
case XXE = 'xml_external_entity';
|
||||
case PATH_TRAVERSAL = 'path_traversal';
|
||||
case COMMAND_INJECTION = 'command_injection';
|
||||
case LDAP_INJECTION = 'ldap_injection';
|
||||
case XPATH_INJECTION = 'xpath_injection';
|
||||
case NOSQL_INJECTION = 'nosql_injection';
|
||||
|
||||
// Protocol and transport attacks
|
||||
case HTTP_PROTOCOL_VIOLATION = 'http_protocol_violation';
|
||||
case HTTP_REQUEST_SMUGGLING = 'http_request_smuggling';
|
||||
case HTTP_RESPONSE_SPLITTING = 'http_response_splitting';
|
||||
case DESERIALIZATION = 'unsafe_deserialization';
|
||||
|
||||
// Application layer attacks
|
||||
case AUTHENTICATION_BYPASS = 'authentication_bypass';
|
||||
case SESSION_FIXATION = 'session_fixation';
|
||||
case SESSION_HIJACKING = 'session_hijacking';
|
||||
case PRIVILEGE_ESCALATION = 'privilege_escalation';
|
||||
case BUSINESS_LOGIC_BYPASS = 'business_logic_bypass';
|
||||
|
||||
// Infrastructure attacks
|
||||
case DOS_ATTACK = 'denial_of_service';
|
||||
case DDOS_ATTACK = 'distributed_denial_of_service';
|
||||
case BRUTE_FORCE = 'brute_force_attack';
|
||||
case CREDENTIAL_STUFFING = 'credential_stuffing';
|
||||
case RATE_LIMIT_VIOLATION = 'rate_limit_violation';
|
||||
|
||||
// Bot and automation
|
||||
case MALICIOUS_BOT = 'malicious_bot';
|
||||
case SCRAPING_BOT = 'scraping_bot';
|
||||
case SPAM_BOT = 'spam_bot';
|
||||
case AUTOMATED_ATTACK = 'automated_attack';
|
||||
|
||||
// Content and data
|
||||
case MALICIOUS_FILE_UPLOAD = 'malicious_file_upload';
|
||||
case SENSITIVE_DATA_EXPOSURE = 'sensitive_data_exposure';
|
||||
case DATA_EXFILTRATION = 'data_exfiltration';
|
||||
case INFORMATION_DISCLOSURE = 'information_disclosure';
|
||||
|
||||
// Network and IP-based
|
||||
case SUSPICIOUS_IP = 'suspicious_ip_address';
|
||||
case GEO_BLOCKING_VIOLATION = 'geo_blocking_violation';
|
||||
case TOR_EXIT_NODE = 'tor_exit_node';
|
||||
case PROXY_DETECTION = 'proxy_detection';
|
||||
|
||||
// Behavioral anomalies
|
||||
case ANOMALOUS_BEHAVIOR = 'anomalous_behavior';
|
||||
case SUSPICIOUS_USER_AGENT = 'suspicious_user_agent';
|
||||
case FINGERPRINTING_ATTEMPT = 'fingerprinting_attempt';
|
||||
case RECONNAISSANCE = 'reconnaissance';
|
||||
|
||||
// Generic categories
|
||||
case POLICY_VIOLATION = 'policy_violation';
|
||||
case UNKNOWN_THREAT = 'unknown_threat';
|
||||
case FALSE_POSITIVE = 'false_positive';
|
||||
|
||||
/**
|
||||
* Get OWASP Top 10 rank (if applicable)
|
||||
*/
|
||||
public function getOwaspRank(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::BROKEN_ACCESS_CONTROL => 1,
|
||||
self::CRYPTOGRAPHIC_FAILURES => 2,
|
||||
self::INJECTION => 3,
|
||||
self::INSECURE_DESIGN => 4,
|
||||
self::SECURITY_MISCONFIGURATION => 5,
|
||||
self::VULNERABLE_COMPONENTS => 6,
|
||||
self::IDENTIFICATION_FAILURES => 7,
|
||||
self::SOFTWARE_INTEGRITY_FAILURES => 8,
|
||||
self::LOGGING_MONITORING_FAILURES => 9,
|
||||
self::SSRF => 10,
|
||||
default => null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an OWASP Top 10 category
|
||||
*/
|
||||
public function isOwaspTop10(): bool
|
||||
{
|
||||
return $this->getOwaspRank() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default severity for this category
|
||||
*/
|
||||
public function getDefaultSeverity(): DetectionSeverity
|
||||
{
|
||||
return match ($this) {
|
||||
// Critical threats
|
||||
self::SQL_INJECTION,
|
||||
self::COMMAND_INJECTION,
|
||||
self::AUTHENTICATION_BYPASS,
|
||||
self::PRIVILEGE_ESCALATION,
|
||||
self::DATA_EXFILTRATION => DetectionSeverity::CRITICAL,
|
||||
|
||||
// High severity threats
|
||||
self::XSS,
|
||||
self::XXE,
|
||||
self::PATH_TRAVERSAL,
|
||||
self::DESERIALIZATION,
|
||||
self::SESSION_HIJACKING,
|
||||
self::MALICIOUS_FILE_UPLOAD => DetectionSeverity::HIGH,
|
||||
|
||||
// Medium severity threats
|
||||
self::CSRF,
|
||||
self::SESSION_FIXATION,
|
||||
self::BRUTE_FORCE,
|
||||
self::DOS_ATTACK,
|
||||
self::MALICIOUS_BOT,
|
||||
self::INFORMATION_DISCLOSURE => DetectionSeverity::MEDIUM,
|
||||
|
||||
// Low severity threats
|
||||
self::SUSPICIOUS_IP,
|
||||
self::SUSPICIOUS_USER_AGENT,
|
||||
self::RATE_LIMIT_VIOLATION,
|
||||
self::POLICY_VIOLATION => DetectionSeverity::LOW,
|
||||
|
||||
// Info level
|
||||
self::FALSE_POSITIVE,
|
||||
self::RECONNAISSANCE,
|
||||
self::FINGERPRINTING_ATTEMPT => DetectionSeverity::INFO,
|
||||
|
||||
// Default to medium for unknown categories
|
||||
default => DetectionSeverity::MEDIUM
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this category should trigger automatic blocking
|
||||
*/
|
||||
public function shouldAutoBlock(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SQL_INJECTION,
|
||||
self::COMMAND_INJECTION,
|
||||
self::XXE,
|
||||
self::PATH_TRAVERSAL,
|
||||
self::AUTHENTICATION_BYPASS,
|
||||
self::DDOS_ATTACK => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SQL_INJECTION => 'SQL Injection attempt detected',
|
||||
self::XSS => 'Cross-Site Scripting (XSS) attempt detected',
|
||||
self::CSRF => 'Cross-Site Request Forgery (CSRF) attempt detected',
|
||||
self::COMMAND_INJECTION => 'Command injection attempt detected',
|
||||
self::PATH_TRAVERSAL => 'Path traversal/directory traversal attempt detected',
|
||||
self::BRUTE_FORCE => 'Brute force attack detected',
|
||||
self::MALICIOUS_BOT => 'Malicious bot activity detected',
|
||||
self::SUSPICIOUS_IP => 'Request from suspicious IP address',
|
||||
self::DOS_ATTACK => 'Denial of Service attack detected',
|
||||
self::RATE_LIMIT_VIOLATION => 'Rate limit exceeded',
|
||||
default => 'Security threat detected: ' . str_replace('_', ' ', $this->value)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related categories for correlation analysis
|
||||
*/
|
||||
public function getRelatedCategories(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::SQL_INJECTION => [self::INJECTION, self::BROKEN_ACCESS_CONTROL],
|
||||
self::XSS => [self::INJECTION, self::BROKEN_ACCESS_CONTROL],
|
||||
self::COMMAND_INJECTION => [self::INJECTION, self::BROKEN_ACCESS_CONTROL],
|
||||
self::BRUTE_FORCE => [self::IDENTIFICATION_FAILURES, self::CREDENTIAL_STUFFING],
|
||||
self::SESSION_FIXATION => [self::SESSION_HIJACKING, self::IDENTIFICATION_FAILURES],
|
||||
self::DOS_ATTACK => [self::DDOS_ATTACK, self::RATE_LIMIT_VIOLATION],
|
||||
default => []
|
||||
};
|
||||
}
|
||||
}
|
||||
151
src/Framework/Waf/DetectionSeverity.php
Normal file
151
src/Framework/Waf/DetectionSeverity.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
/**
|
||||
* Severity levels for WAF detections
|
||||
* Based on OWASP and CVSS severity classifications
|
||||
*/
|
||||
enum DetectionSeverity: string
|
||||
{
|
||||
case INFO = 'info';
|
||||
case LOW = 'low';
|
||||
case MEDIUM = 'medium';
|
||||
case HIGH = 'high';
|
||||
case CRITICAL = 'critical';
|
||||
|
||||
/**
|
||||
* Get numeric severity score (0-100)
|
||||
*/
|
||||
public function getScore(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::INFO => 10,
|
||||
self::LOW => 25,
|
||||
self::MEDIUM => 50,
|
||||
self::HIGH => 75,
|
||||
self::CRITICAL => 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CVSS-like severity score (0.0-10.0)
|
||||
*/
|
||||
public function getCvssScore(): float
|
||||
{
|
||||
return match ($this) {
|
||||
self::INFO => 0.1,
|
||||
self::LOW => 2.5,
|
||||
self::MEDIUM => 5.0,
|
||||
self::HIGH => 7.5,
|
||||
self::CRITICAL => 10.0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this severity is higher than another
|
||||
*/
|
||||
public function isHigherThan(self $other): bool
|
||||
{
|
||||
return $this->getScore() > $other->getScore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this severity is lower than another
|
||||
*/
|
||||
public function isLowerThan(self $other): bool
|
||||
{
|
||||
return $this->getScore() < $other->getScore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this severity requires immediate action
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::HIGH, self::CRITICAL => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this severity should trigger blocking
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::MEDIUM, self::HIGH, self::CRITICAL => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this severity should trigger alerting
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::HIGH, self::CRITICAL => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::INFO => 'Informational - No immediate risk',
|
||||
self::LOW => 'Low severity - Minimal risk',
|
||||
self::MEDIUM => 'Medium severity - Moderate risk',
|
||||
self::HIGH => 'High severity - Significant risk',
|
||||
self::CRITICAL => 'Critical severity - Immediate risk'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended response action
|
||||
*/
|
||||
public function getRecommendedAction(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::INFO => 'Log for monitoring',
|
||||
self::LOW => 'Log and monitor',
|
||||
self::MEDIUM => 'Block and log',
|
||||
self::HIGH => 'Block, log, and alert',
|
||||
self::CRITICAL => 'Block, log, alert, and ban IP'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from numeric score
|
||||
*/
|
||||
public static function fromScore(int $score): self
|
||||
{
|
||||
return match (true) {
|
||||
$score >= 90 => self::CRITICAL,
|
||||
$score >= 70 => self::HIGH,
|
||||
$score >= 40 => self::MEDIUM,
|
||||
$score >= 15 => self::LOW,
|
||||
default => self::INFO
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from CVSS score
|
||||
*/
|
||||
public static function fromCvss(float $score): self
|
||||
{
|
||||
return match (true) {
|
||||
$score >= 9.0 => self::CRITICAL,
|
||||
$score >= 7.0 => self::HIGH,
|
||||
$score >= 4.0 => self::MEDIUM,
|
||||
$score >= 1.0 => self::LOW,
|
||||
default => self::INFO
|
||||
};
|
||||
}
|
||||
}
|
||||
597
src/Framework/Waf/Feedback/Analytics/FeedbackReportGenerator.php
Normal file
597
src/Framework/Waf/Feedback/Analytics/FeedbackReportGenerator.php
Normal file
@@ -0,0 +1,597 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback\Analytics;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\Feedback\FeedbackRepositoryInterface;
|
||||
use App\Framework\Waf\Feedback\FeedbackType;
|
||||
|
||||
/**
|
||||
* Generates detailed reports and analytics from WAF feedback data
|
||||
*/
|
||||
final readonly class FeedbackReportGenerator
|
||||
{
|
||||
/**
|
||||
* @param FeedbackRepositoryInterface $repository Repository for accessing feedback data
|
||||
*/
|
||||
public function __construct(
|
||||
private FeedbackRepositoryInterface $repository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comprehensive report on WAF feedback
|
||||
*
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return array<string, mixed> Comprehensive report data
|
||||
*/
|
||||
public function generateComprehensiveReport(?Timestamp $since = null): array
|
||||
{
|
||||
// Get basic statistics
|
||||
$stats = $this->repository->getFeedbackStats();
|
||||
|
||||
// Get feedback by type
|
||||
$falsePositives = $this->repository->getFeedbackByFeedbackType(
|
||||
FeedbackType::FALSE_POSITIVE,
|
||||
$since
|
||||
);
|
||||
|
||||
$falseNegatives = $this->repository->getFeedbackByFeedbackType(
|
||||
FeedbackType::FALSE_NEGATIVE,
|
||||
$since
|
||||
);
|
||||
|
||||
$correctDetections = $this->repository->getFeedbackByFeedbackType(
|
||||
FeedbackType::CORRECT_DETECTION,
|
||||
$since
|
||||
);
|
||||
|
||||
$severityAdjustments = $this->repository->getFeedbackByFeedbackType(
|
||||
FeedbackType::SEVERITY_ADJUSTMENT,
|
||||
$since
|
||||
);
|
||||
|
||||
// Calculate overall metrics
|
||||
$totalFeedback = count($falsePositives) + count($falseNegatives) + count($correctDetections);
|
||||
|
||||
$accuracy = $totalFeedback > 0
|
||||
? (count($correctDetections) / $totalFeedback) * 100
|
||||
: 0;
|
||||
|
||||
$falsePositiveRate = $totalFeedback > 0
|
||||
? (count($falsePositives) / $totalFeedback) * 100
|
||||
: 0;
|
||||
|
||||
$falseNegativeRate = $totalFeedback > 0
|
||||
? (count($falseNegatives) / $totalFeedback) * 100
|
||||
: 0;
|
||||
|
||||
// Generate category reports
|
||||
$categoryReports = $this->generateCategoryReports($since);
|
||||
|
||||
// Generate severity reports
|
||||
$severityReports = $this->generateSeverityReports($since);
|
||||
|
||||
// Generate trend analysis
|
||||
$trendAnalysis = $this->generateTrendAnalysis($since);
|
||||
|
||||
// Generate common false positive patterns
|
||||
$falsePositivePatterns = $this->analyzefalsePositivePatterns($falsePositives);
|
||||
|
||||
// Generate common false negative patterns
|
||||
$falseNegativePatterns = $this->analyzeFalseNegativePatterns($falseNegatives);
|
||||
|
||||
// Generate severity adjustment patterns
|
||||
$severityAdjustmentPatterns = $this->analyzeSeverityAdjustmentPatterns($severityAdjustments);
|
||||
|
||||
return [
|
||||
'generated_at' => Timestamp::now()->toIso8601String(),
|
||||
'period' => $since ? 'since_' . $since->toIso8601String() : 'all_time',
|
||||
'total_feedback' => $totalFeedback,
|
||||
'metrics' => [
|
||||
'accuracy' => round($accuracy, 1),
|
||||
'false_positive_rate' => round($falsePositiveRate, 1),
|
||||
'false_negative_rate' => round($falseNegativeRate, 1),
|
||||
'false_positives_count' => count($falsePositives),
|
||||
'false_negatives_count' => count($falseNegatives),
|
||||
'correct_detections_count' => count($correctDetections),
|
||||
'severity_adjustments_count' => count($severityAdjustments),
|
||||
],
|
||||
'category_reports' => $categoryReports,
|
||||
'severity_reports' => $severityReports,
|
||||
'trend_analysis' => $trendAnalysis,
|
||||
'patterns' => [
|
||||
'false_positives' => $falsePositivePatterns,
|
||||
'false_negatives' => $falseNegativePatterns,
|
||||
'severity_adjustments' => $severityAdjustmentPatterns,
|
||||
],
|
||||
'raw_stats' => $stats,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate reports for each detection category
|
||||
*
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return array<string, array<string, mixed>> Reports by category
|
||||
*/
|
||||
public function generateCategoryReports(?Timestamp $since = null): array
|
||||
{
|
||||
$reports = [];
|
||||
|
||||
// Get all categories
|
||||
$categories = DetectionCategory::cases();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
// Get feedback for this category
|
||||
$feedback = $this->repository->getFeedbackByCategory($category, $since);
|
||||
|
||||
if (empty($feedback)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Group feedback by type
|
||||
$feedbackByType = [];
|
||||
foreach ($feedback as $item) {
|
||||
$type = $item->feedbackType->value;
|
||||
if (! isset($feedbackByType[$type])) {
|
||||
$feedbackByType[$type] = [];
|
||||
}
|
||||
$feedbackByType[$type][] = $item;
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
$falsePositives = $feedbackByType[FeedbackType::FALSE_POSITIVE->value] ?? [];
|
||||
$falseNegatives = $feedbackByType[FeedbackType::FALSE_NEGATIVE->value] ?? [];
|
||||
$correctDetections = $feedbackByType[FeedbackType::CORRECT_DETECTION->value] ?? [];
|
||||
$severityAdjustments = $feedbackByType[FeedbackType::SEVERITY_ADJUSTMENT->value] ?? [];
|
||||
|
||||
$totalFeedback = count($falsePositives) + count($falseNegatives) + count($correctDetections);
|
||||
|
||||
if ($totalFeedback === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$accuracy = (count($correctDetections) / $totalFeedback) * 100;
|
||||
$falsePositiveRate = (count($falsePositives) / $totalFeedback) * 100;
|
||||
$falseNegativeRate = (count($falseNegatives) / $totalFeedback) * 100;
|
||||
|
||||
// Analyze severity adjustments
|
||||
$severityChanges = [];
|
||||
foreach ($severityAdjustments as $adjustment) {
|
||||
$suggestedSeverity = $adjustment->getSuggestedSeverity();
|
||||
if ($suggestedSeverity === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $adjustment->severity->value . '_to_' . $suggestedSeverity->value;
|
||||
$severityChanges[$key] = ($severityChanges[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$reports[$category->value] = [
|
||||
'category' => $category,
|
||||
'total_feedback' => $totalFeedback,
|
||||
'metrics' => [
|
||||
'accuracy' => round($accuracy, 1),
|
||||
'false_positive_rate' => round($falsePositiveRate, 1),
|
||||
'false_negative_rate' => round($falseNegativeRate, 1),
|
||||
'false_positives_count' => count($falsePositives),
|
||||
'false_negatives_count' => count($falseNegatives),
|
||||
'correct_detections_count' => count($correctDetections),
|
||||
'severity_adjustments_count' => count($severityAdjustments),
|
||||
],
|
||||
'severity_changes' => $severityChanges,
|
||||
'is_owasp_top10' => $category->isOwaspTop10(),
|
||||
'owasp_rank' => $category->getOwaspRank(),
|
||||
'default_severity' => $category->getDefaultSeverity()->value,
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by total feedback count (descending)
|
||||
uasort($reports, fn ($a, $b) => $b['total_feedback'] <=> $a['total_feedback']);
|
||||
|
||||
return $reports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate reports for each severity level
|
||||
*
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return array<string, array<string, mixed>> Reports by severity
|
||||
*/
|
||||
public function generateSeverityReports(?Timestamp $since = null): array
|
||||
{
|
||||
$reports = [];
|
||||
|
||||
// Get all severities
|
||||
$severities = DetectionSeverity::cases();
|
||||
|
||||
foreach ($severities as $severity) {
|
||||
$reports[$severity->value] = [
|
||||
'severity' => $severity,
|
||||
'feedback_by_type' => [
|
||||
'false_positive' => 0,
|
||||
'false_negative' => 0,
|
||||
'correct_detection' => 0,
|
||||
'severity_adjustment' => 0,
|
||||
],
|
||||
'total_feedback' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Get all feedback
|
||||
$allFeedback = [];
|
||||
foreach (FeedbackType::cases() as $type) {
|
||||
$feedback = $this->repository->getFeedbackByFeedbackType($type, $since);
|
||||
$allFeedback = array_merge($allFeedback, $feedback);
|
||||
}
|
||||
|
||||
// Group feedback by severity and type
|
||||
foreach ($allFeedback as $item) {
|
||||
$severityKey = $item->severity->value;
|
||||
$typeKey = $item->feedbackType->value;
|
||||
|
||||
if (! isset($reports[$severityKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reports[$severityKey]['feedback_by_type'][$typeKey] =
|
||||
($reports[$severityKey]['feedback_by_type'][$typeKey] ?? 0) + 1;
|
||||
|
||||
$reports[$severityKey]['total_feedback']++;
|
||||
}
|
||||
|
||||
// Calculate metrics for each severity
|
||||
foreach ($reports as $severity => &$report) {
|
||||
if ($report['total_feedback'] === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$falsePositives = $report['feedback_by_type']['false_positive'];
|
||||
$falseNegatives = $report['feedback_by_type']['false_negative'];
|
||||
$correctDetections = $report['feedback_by_type']['correct_detection'];
|
||||
|
||||
$totalClassifiable = $falsePositives + $falseNegatives + $correctDetections;
|
||||
|
||||
if ($totalClassifiable > 0) {
|
||||
$accuracy = ($correctDetections / $totalClassifiable) * 100;
|
||||
$falsePositiveRate = ($falsePositives / $totalClassifiable) * 100;
|
||||
$falseNegativeRate = ($falseNegatives / $totalClassifiable) * 100;
|
||||
|
||||
$report['metrics'] = [
|
||||
'accuracy' => round($accuracy, 1),
|
||||
'false_positive_rate' => round($falsePositiveRate, 1),
|
||||
'false_negative_rate' => round($falseNegativeRate, 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by total feedback count (descending)
|
||||
uasort($reports, fn ($a, $b) => $b['total_feedback'] <=> $a['total_feedback']);
|
||||
|
||||
return $reports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trend analysis for feedback over time
|
||||
*
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return array<string, mixed> Trend analysis data
|
||||
*/
|
||||
public function generateTrendAnalysis(?Timestamp $since = null): array
|
||||
{
|
||||
// Get basic statistics
|
||||
$stats = $this->repository->getFeedbackStats();
|
||||
|
||||
// Extract trend data
|
||||
$trendData = $stats['trend_data'] ?? [];
|
||||
|
||||
// Calculate moving averages
|
||||
$movingAverages = $this->calculateMovingAverages($trendData, 7);
|
||||
|
||||
// Calculate accuracy trend
|
||||
$accuracyTrend = $this->calculateAccuracyTrend($trendData);
|
||||
|
||||
// Calculate detection rate trend
|
||||
$detectionRateTrend = $this->calculateDetectionRateTrend($trendData);
|
||||
|
||||
return [
|
||||
'raw_trend_data' => $trendData,
|
||||
'moving_averages' => $movingAverages,
|
||||
'accuracy_trend' => $accuracyTrend,
|
||||
'detection_rate_trend' => $detectionRateTrend,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze patterns in false positive feedback
|
||||
*
|
||||
* @param array $falsePositives Array of false positive feedback
|
||||
* @return array<string, mixed> Analysis of false positive patterns
|
||||
*/
|
||||
private function analyzefalsePositivePatterns(array $falsePositives): array
|
||||
{
|
||||
if (empty($falsePositives)) {
|
||||
return [
|
||||
'count' => 0,
|
||||
'message' => 'No false positive feedback available for analysis',
|
||||
];
|
||||
}
|
||||
|
||||
// Group by category
|
||||
$byCategory = [];
|
||||
foreach ($falsePositives as $feedback) {
|
||||
$category = $feedback->category->value;
|
||||
if (! isset($byCategory[$category])) {
|
||||
$byCategory[$category] = [];
|
||||
}
|
||||
$byCategory[$category][] = $feedback;
|
||||
}
|
||||
|
||||
// Find top categories
|
||||
$categoryCounts = array_map('count', $byCategory);
|
||||
arsort($categoryCounts);
|
||||
|
||||
// Analyze comments for patterns
|
||||
$commentPatterns = [];
|
||||
foreach ($falsePositives as $feedback) {
|
||||
if (empty($feedback->comment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract keywords from comment
|
||||
$keywords = $this->extractKeywords($feedback->comment);
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$commentPatterns[$keyword] = ($commentPatterns[$keyword] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency
|
||||
arsort($commentPatterns);
|
||||
|
||||
// Limit to top 10
|
||||
$commentPatterns = array_slice($commentPatterns, 0, 10, true);
|
||||
|
||||
return [
|
||||
'count' => count($falsePositives),
|
||||
'top_categories' => $categoryCounts,
|
||||
'comment_patterns' => $commentPatterns,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze patterns in false negative feedback
|
||||
*
|
||||
* @param array $falseNegatives Array of false negative feedback
|
||||
* @return array<string, mixed> Analysis of false negative patterns
|
||||
*/
|
||||
private function analyzeFalseNegativePatterns(array $falseNegatives): array
|
||||
{
|
||||
if (empty($falseNegatives)) {
|
||||
return [
|
||||
'count' => 0,
|
||||
'message' => 'No false negative feedback available for analysis',
|
||||
];
|
||||
}
|
||||
|
||||
// Group by category
|
||||
$byCategory = [];
|
||||
foreach ($falseNegatives as $feedback) {
|
||||
$category = $feedback->category->value;
|
||||
if (! isset($byCategory[$category])) {
|
||||
$byCategory[$category] = [];
|
||||
}
|
||||
$byCategory[$category][] = $feedback;
|
||||
}
|
||||
|
||||
// Find top categories
|
||||
$categoryCounts = array_map('count', $byCategory);
|
||||
arsort($categoryCounts);
|
||||
|
||||
// Analyze comments for patterns
|
||||
$commentPatterns = [];
|
||||
foreach ($falseNegatives as $feedback) {
|
||||
if (empty($feedback->comment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract keywords from comment
|
||||
$keywords = $this->extractKeywords($feedback->comment);
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$commentPatterns[$keyword] = ($commentPatterns[$keyword] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency
|
||||
arsort($commentPatterns);
|
||||
|
||||
// Limit to top 10
|
||||
$commentPatterns = array_slice($commentPatterns, 0, 10, true);
|
||||
|
||||
return [
|
||||
'count' => count($falseNegatives),
|
||||
'top_categories' => $categoryCounts,
|
||||
'comment_patterns' => $commentPatterns,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze patterns in severity adjustment feedback
|
||||
*
|
||||
* @param array $severityAdjustments Array of severity adjustment feedback
|
||||
* @return array<string, mixed> Analysis of severity adjustment patterns
|
||||
*/
|
||||
private function analyzeSeverityAdjustmentPatterns(array $severityAdjustments): array
|
||||
{
|
||||
if (empty($severityAdjustments)) {
|
||||
return [
|
||||
'count' => 0,
|
||||
'message' => 'No severity adjustment feedback available for analysis',
|
||||
];
|
||||
}
|
||||
|
||||
// Group by category
|
||||
$byCategory = [];
|
||||
foreach ($severityAdjustments as $feedback) {
|
||||
$category = $feedback->category->value;
|
||||
if (! isset($byCategory[$category])) {
|
||||
$byCategory[$category] = [];
|
||||
}
|
||||
$byCategory[$category][] = $feedback;
|
||||
}
|
||||
|
||||
// Find top categories
|
||||
$categoryCounts = array_map('count', $byCategory);
|
||||
arsort($categoryCounts);
|
||||
|
||||
// Analyze severity changes
|
||||
$severityChanges = [];
|
||||
foreach ($severityAdjustments as $feedback) {
|
||||
$suggestedSeverity = $feedback->getSuggestedSeverity();
|
||||
if ($suggestedSeverity === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $feedback->severity->value . '_to_' . $suggestedSeverity->value;
|
||||
$severityChanges[$key] = ($severityChanges[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Sort by frequency
|
||||
arsort($severityChanges);
|
||||
|
||||
return [
|
||||
'count' => count($severityAdjustments),
|
||||
'top_categories' => $categoryCounts,
|
||||
'severity_changes' => $severityChanges,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate moving averages for trend data
|
||||
*
|
||||
* @param array $trendData Raw trend data
|
||||
* @param int $window Window size for moving average
|
||||
* @return array<string, array<string, float>> Moving averages
|
||||
*/
|
||||
private function calculateMovingAverages(array $trendData, int $window): array
|
||||
{
|
||||
if (empty($trendData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$dates = array_keys($trendData);
|
||||
sort($dates);
|
||||
|
||||
foreach ($dates as $i => $date) {
|
||||
if ($i < $window - 1) {
|
||||
continue; // Not enough data for window
|
||||
}
|
||||
|
||||
$windowData = [];
|
||||
for ($j = $i - $window + 1; $j <= $i; $j++) {
|
||||
$windowDate = $dates[$j];
|
||||
foreach ($trendData[$windowDate] as $type => $count) {
|
||||
$windowData[$type] = ($windowData[$type] ?? 0) + $count;
|
||||
}
|
||||
}
|
||||
|
||||
$result[$date] = [];
|
||||
foreach ($windowData as $type => $count) {
|
||||
$result[$date][$type] = $count / $window;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate accuracy trend from trend data
|
||||
*
|
||||
* @param array $trendData Raw trend data
|
||||
* @return array<string, float> Accuracy trend
|
||||
*/
|
||||
private function calculateAccuracyTrend(array $trendData): array
|
||||
{
|
||||
if (empty($trendData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($trendData as $date => $data) {
|
||||
$falsePositives = $data[FeedbackType::FALSE_POSITIVE->value] ?? 0;
|
||||
$falseNegatives = $data[FeedbackType::FALSE_NEGATIVE->value] ?? 0;
|
||||
$correctDetections = $data[FeedbackType::CORRECT_DETECTION->value] ?? 0;
|
||||
|
||||
$total = $falsePositives + $falseNegatives + $correctDetections;
|
||||
|
||||
if ($total > 0) {
|
||||
$result[$date] = ($correctDetections / $total) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate detection rate trend from trend data
|
||||
*
|
||||
* @param array $trendData Raw trend data
|
||||
* @return array<string, array<string, float>> Detection rate trend
|
||||
*/
|
||||
private function calculateDetectionRateTrend(array $trendData): array
|
||||
{
|
||||
if (empty($trendData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($trendData as $date => $data) {
|
||||
$falsePositives = $data[FeedbackType::FALSE_POSITIVE->value] ?? 0;
|
||||
$falseNegatives = $data[FeedbackType::FALSE_NEGATIVE->value] ?? 0;
|
||||
$correctDetections = $data[FeedbackType::CORRECT_DETECTION->value] ?? 0;
|
||||
|
||||
$total = $falsePositives + $falseNegatives + $correctDetections;
|
||||
|
||||
if ($total > 0) {
|
||||
$result[$date] = [
|
||||
'false_positive_rate' => ($falsePositives / $total) * 100,
|
||||
'false_negative_rate' => ($falseNegatives / $total) * 100,
|
||||
'correct_detection_rate' => ($correctDetections / $total) * 100,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract keywords from a comment
|
||||
*
|
||||
* @param string $comment The comment to extract keywords from
|
||||
* @return array<string> Array of keywords
|
||||
*/
|
||||
private function extractKeywords(string $comment): array
|
||||
{
|
||||
// Simple keyword extraction - in a real implementation, this would be more sophisticated
|
||||
$comment = strtolower($comment);
|
||||
$comment = preg_replace('/[^\p{L}\p{N}\s]/u', '', $comment);
|
||||
$words = preg_split('/\s+/', $comment);
|
||||
|
||||
// Filter out common words and short words
|
||||
$stopWords = ['the', 'and', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'is', 'are', 'was', 'were'];
|
||||
$words = array_filter($words, function ($word) use ($stopWords) {
|
||||
return strlen($word) > 3 && ! in_array($word, $stopWords);
|
||||
});
|
||||
|
||||
return array_values($words);
|
||||
}
|
||||
}
|
||||
220
src/Framework/Waf/Feedback/Commands/LearnFromFeedbackCommand.php
Normal file
220
src/Framework/Waf/Feedback/Commands/LearnFromFeedbackCommand.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback\Commands;
|
||||
|
||||
use App\Framework\Console\Command;
|
||||
use App\Framework\Console\Input;
|
||||
use App\Framework\Console\Output;
|
||||
use App\Framework\Waf\Feedback\FeedbackLearningService;
|
||||
|
||||
/**
|
||||
* Command to run the WAF feedback learning process
|
||||
*/
|
||||
final class LearnFromFeedbackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @param FeedbackLearningService $learningService Service for learning from feedback
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly FeedbackLearningService $learningService
|
||||
) {
|
||||
parent::__construct('waf:learn-from-feedback');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the command
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Learn from WAF feedback and adjust detection models');
|
||||
$this->addOption('threshold', 't', Input::OPTION_VALUE_REQUIRED, 'Minimum feedback threshold', '5');
|
||||
$this->addOption('learning-rate', 'r', Input::OPTION_VALUE_REQUIRED, 'Learning rate (0.0-1.0)', '0.3');
|
||||
$this->addOption('verbose', 'v', Input::OPTION_VALUE_NONE, 'Show detailed output');
|
||||
$this->addOption('dry-run', null, Input::OPTION_VALUE_NONE, 'Run without applying changes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command
|
||||
*/
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$output->writeln('<info>Starting WAF feedback learning process...</info>');
|
||||
|
||||
// Get options
|
||||
$threshold = (int) $input->getOption('threshold');
|
||||
$learningRate = (float) $input->getOption('learning-rate');
|
||||
$verbose = (bool) $input->getOption('verbose');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
// Validate options
|
||||
if ($threshold < 1) {
|
||||
$output->writeln('<error>Threshold must be at least 1</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($learningRate < 0.0 || $learningRate > 1.0) {
|
||||
$output->writeln('<error>Learning rate must be between 0.0 and 1.0</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Configure learning service
|
||||
$learningService = $this->learningService
|
||||
->withMinimumFeedbackThreshold($threshold)
|
||||
->withLearningRate($learningRate);
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<comment>Running in dry-run mode - no changes will be applied</comment>');
|
||||
// In a real implementation, we would pass the dry-run flag to the learning service
|
||||
}
|
||||
|
||||
// Run learning process
|
||||
$result = $learningService->learnFromFeedback();
|
||||
|
||||
// Output results
|
||||
if (! $result['success']) {
|
||||
$output->writeln('<error>' . ($result['message'] ?? 'Learning process failed') . '</error>');
|
||||
|
||||
if (isset($result['feedback_count'])) {
|
||||
$output->writeln(sprintf(
|
||||
'<comment>Feedback count: %d (minimum required: %d)</comment>',
|
||||
$result['feedback_count'],
|
||||
$result['minimum_threshold'] ?? $threshold
|
||||
));
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Learning process completed successfully</info>');
|
||||
$output->writeln(sprintf(
|
||||
'<info>Processed: %d false positives, %d false negatives, %d severity adjustments</info>',
|
||||
$result['false_positives_processed'],
|
||||
$result['false_negatives_processed'],
|
||||
$result['severity_adjustments_processed']
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
'<info>Applied %d model adjustments in %d seconds</info>',
|
||||
$result['total_adjustments_applied'],
|
||||
$result['duration_seconds']
|
||||
));
|
||||
|
||||
// Show detailed output if verbose
|
||||
if ($verbose && isset($result['adjustment_details'])) {
|
||||
$this->outputDetailedResults($output, $result['adjustment_details']);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output detailed results
|
||||
*
|
||||
* @param Output $output Output interface
|
||||
* @param array<string, mixed> $details Detailed results
|
||||
*/
|
||||
private function outputDetailedResults(Output $output, array $details): void
|
||||
{
|
||||
$output->writeln('');
|
||||
$output->writeln('<info>Detailed adjustment information:</info>');
|
||||
|
||||
// False positives
|
||||
if (! empty($details['false_positives'])) {
|
||||
$output->writeln('');
|
||||
$output->writeln('<info>False positive adjustments:</info>');
|
||||
|
||||
foreach ($details['false_positives'] as $id => $adjustment) {
|
||||
$output->writeln(sprintf(
|
||||
' - <comment>%s</comment>: %s',
|
||||
$id,
|
||||
$adjustment->description
|
||||
));
|
||||
|
||||
if (isset($adjustment->thresholdAdjustment)) {
|
||||
$output->writeln(sprintf(
|
||||
' Threshold adjustment: %+.2f%%',
|
||||
$adjustment->thresholdAdjustment->getValue()
|
||||
));
|
||||
}
|
||||
|
||||
if (isset($adjustment->confidenceAdjustment)) {
|
||||
$output->writeln(sprintf(
|
||||
' Confidence adjustment: %+.2f%%',
|
||||
$adjustment->confidenceAdjustment->getValue()
|
||||
));
|
||||
}
|
||||
|
||||
if (! empty($adjustment->featureWeightAdjustments)) {
|
||||
$output->writeln(' Feature weight adjustments:');
|
||||
|
||||
foreach ($adjustment->featureWeightAdjustments as $feature => $value) {
|
||||
$output->writeln(sprintf(
|
||||
' - %s: %+.2f',
|
||||
$feature,
|
||||
$value
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// False negatives
|
||||
if (! empty($details['false_negatives'])) {
|
||||
$output->writeln('');
|
||||
$output->writeln('<info>False negative adjustments:</info>');
|
||||
|
||||
foreach ($details['false_negatives'] as $id => $adjustment) {
|
||||
$output->writeln(sprintf(
|
||||
' - <comment>%s</comment>: %s',
|
||||
$id,
|
||||
$adjustment->description
|
||||
));
|
||||
|
||||
if (isset($adjustment->thresholdAdjustment)) {
|
||||
$output->writeln(sprintf(
|
||||
' Threshold adjustment: %+.2f%%',
|
||||
$adjustment->thresholdAdjustment->getValue()
|
||||
));
|
||||
}
|
||||
|
||||
if (isset($adjustment->confidenceAdjustment)) {
|
||||
$output->writeln(sprintf(
|
||||
' Confidence adjustment: %+.2f%%',
|
||||
$adjustment->confidenceAdjustment->getValue()
|
||||
));
|
||||
}
|
||||
|
||||
if (! empty($adjustment->featureWeightAdjustments)) {
|
||||
$output->writeln(' Feature weight adjustments:');
|
||||
|
||||
foreach ($adjustment->featureWeightAdjustments as $feature => $value) {
|
||||
$output->writeln(sprintf(
|
||||
' - %s: %+.2f',
|
||||
$feature,
|
||||
$value
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Severity adjustments
|
||||
if (! empty($details['severity_adjustments']) && ! empty($details['severity_adjustments']['severity_changes'])) {
|
||||
$output->writeln('');
|
||||
$output->writeln('<info>Severity adjustments:</info>');
|
||||
|
||||
foreach ($details['severity_adjustments']['severity_changes'] as $category => $change) {
|
||||
$output->writeln(sprintf(
|
||||
' - <comment>%s</comment>: %s → %s (%.1f%% consensus)',
|
||||
$category,
|
||||
$change['from'],
|
||||
$change['to'],
|
||||
$change['consensus_percentage']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
249
src/Framework/Waf/Feedback/DatabaseFeedbackRepository.php
Normal file
249
src/Framework/Waf/Feedback/DatabaseFeedbackRepository.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Database implementation of the feedback repository
|
||||
*/
|
||||
final readonly class DatabaseFeedbackRepository implements FeedbackRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param PDO $pdo Database connection
|
||||
*/
|
||||
public function __construct(
|
||||
private PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function saveFeedback(DetectionFeedback $feedback): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('
|
||||
INSERT INTO waf_feedback (
|
||||
detection_id, feedback_type, user_id, comment,
|
||||
timestamp, category, severity, context
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$feedback->detectionId,
|
||||
$feedback->feedbackType->value,
|
||||
$feedback->userId,
|
||||
$feedback->comment,
|
||||
$feedback->timestamp->toSqlString(),
|
||||
$feedback->category->value,
|
||||
$feedback->severity->value,
|
||||
json_encode($feedback->context),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFeedbackForDetection(string $detectionId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('
|
||||
SELECT
|
||||
detection_id, feedback_type, user_id, comment,
|
||||
timestamp, category, severity, context
|
||||
FROM waf_feedback
|
||||
WHERE detection_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
');
|
||||
|
||||
$stmt->execute([$detectionId]);
|
||||
|
||||
return $this->hydrateMultipleResults($stmt);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFeedbackByCategory(DetectionCategory $category, ?Timestamp $since = null): array
|
||||
{
|
||||
$sql = '
|
||||
SELECT
|
||||
detection_id, feedback_type, user_id, comment,
|
||||
timestamp, category, severity, context
|
||||
FROM waf_feedback
|
||||
WHERE category = ?
|
||||
';
|
||||
|
||||
$params = [$category->value];
|
||||
|
||||
if ($since !== null) {
|
||||
$sql .= ' AND timestamp >= ?';
|
||||
$params[] = $since->toSqlString();
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY timestamp DESC';
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydrateMultipleResults($stmt);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFeedbackByFeedbackType(FeedbackType $feedbackType, ?Timestamp $since = null): array
|
||||
{
|
||||
$sql = '
|
||||
SELECT
|
||||
detection_id, feedback_type, user_id, comment,
|
||||
timestamp, category, severity, context
|
||||
FROM waf_feedback
|
||||
WHERE feedback_type = ?
|
||||
';
|
||||
|
||||
$params = [$feedbackType->value];
|
||||
|
||||
if ($since !== null) {
|
||||
$sql .= ' AND timestamp >= ?';
|
||||
$params[] = $since->toSqlString();
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY timestamp DESC';
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydrateMultipleResults($stmt);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFeedbackStats(): array
|
||||
{
|
||||
// Get total count
|
||||
$totalStmt = $this->pdo->query('SELECT COUNT(*) FROM waf_feedback');
|
||||
$totalCount = (int)$totalStmt->fetchColumn();
|
||||
|
||||
// Get counts by feedback type
|
||||
$typeStmt = $this->pdo->query('
|
||||
SELECT feedback_type, COUNT(*) as count
|
||||
FROM waf_feedback
|
||||
GROUP BY feedback_type
|
||||
');
|
||||
$typeStats = [];
|
||||
while ($row = $typeStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$typeStats[$row['feedback_type']] = (int)$row['count'];
|
||||
}
|
||||
|
||||
// Get counts by category
|
||||
$categoryStmt = $this->pdo->query('
|
||||
SELECT category, COUNT(*) as count
|
||||
FROM waf_feedback
|
||||
GROUP BY category
|
||||
');
|
||||
$categoryStats = [];
|
||||
while ($row = $categoryStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$categoryStats[$row['category']] = (int)$row['count'];
|
||||
}
|
||||
|
||||
// Get counts by severity
|
||||
$severityStmt = $this->pdo->query('
|
||||
SELECT severity, COUNT(*) as count
|
||||
FROM waf_feedback
|
||||
GROUP BY severity
|
||||
');
|
||||
$severityStats = [];
|
||||
while ($row = $severityStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$severityStats[$row['severity']] = (int)$row['count'];
|
||||
}
|
||||
|
||||
// Get trend data (last 30 days)
|
||||
$trendStmt = $this->pdo->query('
|
||||
SELECT
|
||||
DATE(timestamp) as date,
|
||||
feedback_type,
|
||||
COUNT(*) as count
|
||||
FROM waf_feedback
|
||||
WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(timestamp), feedback_type
|
||||
ORDER BY date
|
||||
');
|
||||
$trendData = [];
|
||||
while ($row = $trendStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
if (! isset($trendData[$row['date']])) {
|
||||
$trendData[$row['date']] = [];
|
||||
}
|
||||
$trendData[$row['date']][$row['feedback_type']] = (int)$row['count'];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_count' => $totalCount,
|
||||
'by_feedback_type' => $typeStats,
|
||||
'by_category' => $categoryStats,
|
||||
'by_severity' => $severityStats,
|
||||
'trend_data' => $trendData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getRecentFeedback(int $limit = 10): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('
|
||||
SELECT
|
||||
detection_id, feedback_type, user_id, comment,
|
||||
timestamp, category, severity, context
|
||||
FROM waf_feedback
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
');
|
||||
|
||||
$stmt->execute([$limit]);
|
||||
|
||||
return $this->hydrateMultipleResults($stmt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates multiple DetectionFeedback objects from a PDO statement
|
||||
*
|
||||
* @param \PDOStatement $stmt The executed PDO statement
|
||||
* @return DetectionFeedback[] Array of DetectionFeedback objects
|
||||
*/
|
||||
private function hydrateMultipleResults(\PDOStatement $stmt): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$results[] = $this->hydrateFromRow($row);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates a DetectionFeedback object from a database row
|
||||
*
|
||||
* @param array $row Database row
|
||||
* @return DetectionFeedback
|
||||
*/
|
||||
private function hydrateFromRow(array $row): DetectionFeedback
|
||||
{
|
||||
return new DetectionFeedback(
|
||||
detectionId: $row['detection_id'],
|
||||
feedbackType: FeedbackType::from($row['feedback_type']),
|
||||
userId: $row['user_id'],
|
||||
comment: $row['comment'],
|
||||
timestamp: Timestamp::fromString($row['timestamp']),
|
||||
category: DetectionCategory::from($row['category']),
|
||||
severity: DetectionSeverity::from($row['severity']),
|
||||
context: json_decode($row['context'] ?? '{}', true) ?: []
|
||||
);
|
||||
}
|
||||
}
|
||||
152
src/Framework/Waf/Feedback/DetectionFeedback.php
Normal file
152
src/Framework/Waf/Feedback/DetectionFeedback.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Represents feedback for a WAF detection
|
||||
*/
|
||||
final readonly class DetectionFeedback
|
||||
{
|
||||
/**
|
||||
* @param string $detectionId ID of the detection this feedback is for
|
||||
* @param FeedbackType $feedbackType Type of feedback (false positive, false negative, etc.)
|
||||
* @param string $userId ID of the user who submitted the feedback
|
||||
* @param string|null $comment Optional comment provided with the feedback
|
||||
* @param Timestamp $timestamp When the feedback was submitted
|
||||
* @param DetectionCategory $category Category of the detection
|
||||
* @param DetectionSeverity $severity Severity of the detection
|
||||
* @param array<string, mixed> $context Additional context information
|
||||
*/
|
||||
public function __construct(
|
||||
public string $detectionId,
|
||||
public FeedbackType $feedbackType,
|
||||
public string $userId,
|
||||
public ?string $comment,
|
||||
public Timestamp $timestamp,
|
||||
public DetectionCategory $category,
|
||||
public DetectionSeverity $severity,
|
||||
public array $context = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates feedback for a false positive detection
|
||||
*/
|
||||
public static function falsePositive(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
array $context = []
|
||||
): self {
|
||||
return new self(
|
||||
$detectionId,
|
||||
FeedbackType::FALSE_POSITIVE,
|
||||
$userId,
|
||||
$comment,
|
||||
Timestamp::now(),
|
||||
$category,
|
||||
$severity,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates feedback for a false negative detection
|
||||
*/
|
||||
public static function falseNegative(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
array $context = []
|
||||
): self {
|
||||
return new self(
|
||||
$detectionId,
|
||||
FeedbackType::FALSE_NEGATIVE,
|
||||
$userId,
|
||||
$comment,
|
||||
Timestamp::now(),
|
||||
$category,
|
||||
$severity,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates feedback for a correct detection
|
||||
*/
|
||||
public static function correctDetection(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
array $context = []
|
||||
): self {
|
||||
return new self(
|
||||
$detectionId,
|
||||
FeedbackType::CORRECT_DETECTION,
|
||||
$userId,
|
||||
$comment,
|
||||
Timestamp::now(),
|
||||
$category,
|
||||
$severity,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates feedback for a severity adjustment
|
||||
*/
|
||||
public static function severityAdjustment(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $currentSeverity,
|
||||
DetectionSeverity $suggestedSeverity,
|
||||
array $context = []
|
||||
): self {
|
||||
$contextWithSuggestion = array_merge($context, [
|
||||
'suggested_severity' => $suggestedSeverity->value,
|
||||
]);
|
||||
|
||||
return new self(
|
||||
$detectionId,
|
||||
FeedbackType::SEVERITY_ADJUSTMENT,
|
||||
$userId,
|
||||
$comment,
|
||||
Timestamp::now(),
|
||||
$category,
|
||||
$currentSeverity,
|
||||
$contextWithSuggestion
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the suggested severity for a severity adjustment feedback
|
||||
*/
|
||||
public function getSuggestedSeverity(): ?DetectionSeverity
|
||||
{
|
||||
if ($this->feedbackType !== FeedbackType::SEVERITY_ADJUSTMENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$suggestedSeverityValue = $this->context['suggested_severity'] ?? null;
|
||||
|
||||
if ($suggestedSeverityValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DetectionSeverity::from($suggestedSeverityValue);
|
||||
}
|
||||
}
|
||||
510
src/Framework/Waf/Feedback/FeedbackLearningService.php
Normal file
510
src/Framework/Waf/Feedback/FeedbackLearningService.php
Normal file
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\MachineLearning\MachineLearningEngine;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\ModelAdjustment;
|
||||
|
||||
/**
|
||||
* Service for analyzing feedback data and learning from it to improve WAF detection
|
||||
*/
|
||||
final readonly class FeedbackLearningService
|
||||
{
|
||||
/**
|
||||
* @param FeedbackRepositoryInterface $repository Repository for accessing feedback data
|
||||
* @param MachineLearningEngine $mlEngine Machine learning engine to adjust
|
||||
* @param Clock $clock Clock for getting current time
|
||||
* @param Logger $logger Logger for logging learning events
|
||||
* @param int $minimumFeedbackThreshold Minimum number of feedback items required for learning
|
||||
* @param float $learningRate Rate at which to apply adjustments (0.0-1.0)
|
||||
*/
|
||||
public function __construct(
|
||||
private FeedbackRepositoryInterface $repository,
|
||||
private MachineLearningEngine $mlEngine,
|
||||
private Clock $clock,
|
||||
private Logger $logger,
|
||||
private int $minimumFeedbackThreshold = 5,
|
||||
private float $learningRate = 0.3
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn from feedback and adjust detection models
|
||||
*
|
||||
* @return array<string, mixed> Results of the learning process
|
||||
*/
|
||||
public function learnFromFeedback(): array
|
||||
{
|
||||
$startTime = $this->clock->now();
|
||||
$this->logger->info('Starting WAF feedback learning process');
|
||||
|
||||
// Get feedback statistics
|
||||
$stats = $this->repository->getFeedbackStats();
|
||||
$totalFeedbackCount = $stats['total_count'] ?? 0;
|
||||
|
||||
if ($totalFeedbackCount < $this->minimumFeedbackThreshold) {
|
||||
$this->logger->info('Not enough feedback for learning', [
|
||||
'feedback_count' => $totalFeedbackCount,
|
||||
'minimum_threshold' => $this->minimumFeedbackThreshold,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Not enough feedback for learning',
|
||||
'feedback_count' => $totalFeedbackCount,
|
||||
'minimum_threshold' => $this->minimumFeedbackThreshold,
|
||||
];
|
||||
}
|
||||
|
||||
// Process false positives
|
||||
$falsePositives = $this->repository->getFeedbackByFeedbackType(
|
||||
FeedbackType::FALSE_POSITIVE,
|
||||
Timestamp::fromString('-30 days')
|
||||
);
|
||||
|
||||
$falsePositiveAdjustments = $this->processfalsePositives($falsePositives);
|
||||
|
||||
// Process false negatives
|
||||
$falseNegatives = $this->repository->getFeedbackByFeedbackType(
|
||||
FeedbackType::FALSE_NEGATIVE,
|
||||
Timestamp::fromString('-30 days')
|
||||
);
|
||||
|
||||
$falseNegativeAdjustments = $this->processFalseNegatives($falseNegatives);
|
||||
|
||||
// Process severity adjustments
|
||||
$severityAdjustments = $this->repository->getFeedbackByFeedbackType(
|
||||
FeedbackType::SEVERITY_ADJUSTMENT,
|
||||
Timestamp::fromString('-30 days')
|
||||
);
|
||||
|
||||
$severityAdjustmentResults = $this->processSeverityAdjustments($severityAdjustments);
|
||||
|
||||
// Combine all adjustments
|
||||
$allAdjustments = array_merge(
|
||||
$falsePositiveAdjustments,
|
||||
$falseNegativeAdjustments,
|
||||
$severityAdjustmentResults['adjustments'] ?? []
|
||||
);
|
||||
|
||||
// Apply adjustments to the ML engine
|
||||
if (! empty($allAdjustments)) {
|
||||
$this->applyModelAdjustments($allAdjustments);
|
||||
}
|
||||
|
||||
$endTime = $this->clock->now();
|
||||
$duration = $endTime->getTimestamp() - $startTime->getTimestamp();
|
||||
|
||||
$this->logger->info('Completed WAF feedback learning process', [
|
||||
'duration_seconds' => $duration,
|
||||
'false_positives_processed' => count($falsePositives),
|
||||
'false_negatives_processed' => count($falseNegatives),
|
||||
'severity_adjustments_processed' => count($severityAdjustments),
|
||||
'total_adjustments_applied' => count($allAdjustments),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'duration_seconds' => $duration,
|
||||
'false_positives_processed' => count($falsePositives),
|
||||
'false_negatives_processed' => count($falseNegatives),
|
||||
'severity_adjustments_processed' => count($severityAdjustments),
|
||||
'total_adjustments_applied' => count($allAdjustments),
|
||||
'adjustment_details' => [
|
||||
'false_positives' => $falsePositiveAdjustments,
|
||||
'false_negatives' => $falseNegativeAdjustments,
|
||||
'severity_adjustments' => $severityAdjustmentResults,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process false positive feedback to generate model adjustments
|
||||
*
|
||||
* @param DetectionFeedback[] $falsePositives Array of false positive feedback
|
||||
* @return array<string, ModelAdjustment> Model adjustments keyed by adjustment ID
|
||||
*/
|
||||
private function processfalsePositives(array $falsePositives): array
|
||||
{
|
||||
if (empty($falsePositives)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->logger->info('Processing false positive feedback', [
|
||||
'count' => count($falsePositives),
|
||||
]);
|
||||
|
||||
$adjustments = [];
|
||||
$categoryFrequency = [];
|
||||
|
||||
// Count frequency by category
|
||||
foreach ($falsePositives as $feedback) {
|
||||
$categoryKey = $feedback->category->value;
|
||||
$categoryFrequency[$categoryKey] = ($categoryFrequency[$categoryKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Generate adjustments for categories with multiple false positives
|
||||
foreach ($categoryFrequency as $category => $count) {
|
||||
if ($count >= 3) { // Require at least 3 false positives in a category
|
||||
$detectionCategory = DetectionCategory::from($category);
|
||||
$adjustmentFactor = min(0.8, $count / 20); // Cap at 0.8
|
||||
|
||||
// Apply learning rate
|
||||
$adjustmentFactor *= $this->learningRate;
|
||||
|
||||
$adjustmentId = 'fp_' . $category . '_' . $this->clock->now()->getTimestamp();
|
||||
|
||||
$adjustments[$adjustmentId] = new ModelAdjustment(
|
||||
id: $adjustmentId,
|
||||
category: $detectionCategory,
|
||||
thresholdAdjustment: Percentage::from($adjustmentFactor * 100),
|
||||
confidenceAdjustment: Percentage::from(-$adjustmentFactor * 100),
|
||||
featureWeightAdjustments: $this->generateFeatureWeightAdjustments($detectionCategory, true),
|
||||
description: "Adjustment based on {$count} false positive reports for {$category}",
|
||||
timestamp: Timestamp::fromClock($this->clock)
|
||||
);
|
||||
|
||||
$this->logger->info('Created false positive adjustment', [
|
||||
'category' => $category,
|
||||
'count' => $count,
|
||||
'adjustment_factor' => $adjustmentFactor,
|
||||
'adjustment_id' => $adjustmentId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $adjustments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process false negative feedback to generate model adjustments
|
||||
*
|
||||
* @param DetectionFeedback[] $falseNegatives Array of false negative feedback
|
||||
* @return array<string, ModelAdjustment> Model adjustments keyed by adjustment ID
|
||||
*/
|
||||
private function processFalseNegatives(array $falseNegatives): array
|
||||
{
|
||||
if (empty($falseNegatives)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->logger->info('Processing false negative feedback', [
|
||||
'count' => count($falseNegatives),
|
||||
]);
|
||||
|
||||
$adjustments = [];
|
||||
$categoryFrequency = [];
|
||||
|
||||
// Count frequency by category
|
||||
foreach ($falseNegatives as $feedback) {
|
||||
$categoryKey = $feedback->category->value;
|
||||
$categoryFrequency[$categoryKey] = ($categoryFrequency[$categoryKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Generate adjustments for categories with multiple false negatives
|
||||
foreach ($categoryFrequency as $category => $count) {
|
||||
if ($count >= 2) { // Require at least 2 false negatives in a category
|
||||
$detectionCategory = DetectionCategory::from($category);
|
||||
$adjustmentFactor = min(0.7, $count / 15); // Cap at 0.7
|
||||
|
||||
// Apply learning rate
|
||||
$adjustmentFactor *= $this->learningRate;
|
||||
|
||||
$adjustmentId = 'fn_' . $category . '_' . $this->clock->now()->getTimestamp();
|
||||
|
||||
$adjustments[$adjustmentId] = new ModelAdjustment(
|
||||
id: $adjustmentId,
|
||||
category: $detectionCategory,
|
||||
thresholdAdjustment: Percentage::from(-$adjustmentFactor * 100),
|
||||
confidenceAdjustment: Percentage::from($adjustmentFactor * 100),
|
||||
featureWeightAdjustments: $this->generateFeatureWeightAdjustments($detectionCategory, false),
|
||||
description: "Adjustment based on {$count} false negative reports for {$category}",
|
||||
timestamp: Timestamp::fromClock($this->clock)
|
||||
);
|
||||
|
||||
$this->logger->info('Created false negative adjustment', [
|
||||
'category' => $category,
|
||||
'count' => $count,
|
||||
'adjustment_factor' => $adjustmentFactor,
|
||||
'adjustment_id' => $adjustmentId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $adjustments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process severity adjustment feedback
|
||||
*
|
||||
* @param DetectionFeedback[] $severityAdjustments Array of severity adjustment feedback
|
||||
* @return array<string, mixed> Results of processing severity adjustments
|
||||
*/
|
||||
private function processSeverityAdjustments(array $severityAdjustments): array
|
||||
{
|
||||
if (empty($severityAdjustments)) {
|
||||
return ['adjustments' => [], 'severity_changes' => []];
|
||||
}
|
||||
|
||||
$this->logger->info('Processing severity adjustment feedback', [
|
||||
'count' => count($severityAdjustments),
|
||||
]);
|
||||
|
||||
$adjustments = [];
|
||||
$severityChanges = [];
|
||||
$categoryAdjustments = [];
|
||||
|
||||
// Group by category and analyze suggested severities
|
||||
foreach ($severityAdjustments as $feedback) {
|
||||
$categoryKey = $feedback->category->value;
|
||||
$suggestedSeverity = $feedback->getSuggestedSeverity();
|
||||
|
||||
if ($suggestedSeverity === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($categoryAdjustments[$categoryKey])) {
|
||||
$categoryAdjustments[$categoryKey] = [
|
||||
'count' => 0,
|
||||
'severities' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$categoryAdjustments[$categoryKey]['count']++;
|
||||
$categoryAdjustments[$categoryKey]['severities'][$suggestedSeverity->value] =
|
||||
($categoryAdjustments[$categoryKey]['severities'][$suggestedSeverity->value] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Find consensus for each category
|
||||
foreach ($categoryAdjustments as $category => $data) {
|
||||
if ($data['count'] < 3) {
|
||||
continue; // Not enough data
|
||||
}
|
||||
|
||||
// Find most suggested severity
|
||||
$mostSuggestedSeverity = null;
|
||||
$mostSuggestedCount = 0;
|
||||
|
||||
foreach ($data['severities'] as $severity => $count) {
|
||||
if ($count > $mostSuggestedCount) {
|
||||
$mostSuggestedSeverity = $severity;
|
||||
$mostSuggestedCount = $count;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's a clear consensus (more than 50% of suggestions)
|
||||
if ($mostSuggestedCount > ($data['count'] / 2)) {
|
||||
$detectionCategory = DetectionCategory::from($category);
|
||||
$suggestedSeverity = DetectionSeverity::from($mostSuggestedSeverity);
|
||||
$defaultSeverity = $detectionCategory->getDefaultSeverity();
|
||||
|
||||
// Only adjust if different from default
|
||||
if ($suggestedSeverity !== $defaultSeverity) {
|
||||
$severityChanges[$category] = [
|
||||
'from' => $defaultSeverity->value,
|
||||
'to' => $suggestedSeverity->value,
|
||||
'consensus_percentage' => round(($mostSuggestedCount / $data['count']) * 100, 1),
|
||||
];
|
||||
|
||||
// Create adjustment based on severity change
|
||||
$adjustmentId = 'sev_' . $category . '_' . $this->clock->now()->getTimestamp();
|
||||
|
||||
// Determine confidence adjustment based on severity change
|
||||
$confidenceAdjustment = match(true) {
|
||||
$suggestedSeverity->value > $defaultSeverity->value => Percentage::from(15.0 * $this->learningRate),
|
||||
$suggestedSeverity->value < $defaultSeverity->value => Percentage::from(-15.0 * $this->learningRate),
|
||||
default => Percentage::from(0.0)
|
||||
};
|
||||
|
||||
$adjustments[$adjustmentId] = new ModelAdjustment(
|
||||
id: $adjustmentId,
|
||||
category: $detectionCategory,
|
||||
thresholdAdjustment: Percentage::from(0.0),
|
||||
confidenceAdjustment: $confidenceAdjustment,
|
||||
featureWeightAdjustments: [],
|
||||
description: "Severity adjustment from {$defaultSeverity->value} to {$suggestedSeverity->value} for {$category}",
|
||||
timestamp: Timestamp::fromClock($this->clock)
|
||||
);
|
||||
|
||||
$this->logger->info('Created severity adjustment', [
|
||||
'category' => $category,
|
||||
'from_severity' => $defaultSeverity->value,
|
||||
'to_severity' => $suggestedSeverity->value,
|
||||
'consensus_percentage' => round(($mostSuggestedCount / $data['count']) * 100, 1),
|
||||
'adjustment_id' => $adjustmentId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'adjustments' => $adjustments,
|
||||
'severity_changes' => $severityChanges,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate feature weight adjustments for a specific category
|
||||
*
|
||||
* @param DetectionCategory $category The detection category
|
||||
* @param bool $isfalsePositive Whether this is for false positive (true) or false negative (false)
|
||||
* @return array<string, float> Feature weight adjustments
|
||||
*/
|
||||
private function generateFeatureWeightAdjustments(DetectionCategory $category, bool $isfalsePositive): array
|
||||
{
|
||||
// This is a simplified implementation - in a real system, this would analyze
|
||||
// the specific features that contributed to false positives/negatives
|
||||
|
||||
$adjustments = [];
|
||||
|
||||
// Get category-specific features based on detection type
|
||||
$categoryFeatures = match($category) {
|
||||
DetectionCategory::SQL_INJECTION => [
|
||||
'sql_keywords_count' => $isfalsePositive ? -0.2 : 0.15,
|
||||
'special_chars_ratio' => $isfalsePositive ? -0.25 : 0.2,
|
||||
'parameter_entropy' => $isfalsePositive ? -0.15 : 0.1,
|
||||
],
|
||||
DetectionCategory::XSS => [
|
||||
'script_tag_count' => $isfalsePositive ? -0.3 : 0.25,
|
||||
'event_handler_count' => $isfalsePositive ? -0.25 : 0.2,
|
||||
'url_encoding_depth' => $isfalsePositive ? -0.2 : 0.15,
|
||||
],
|
||||
DetectionCategory::PATH_TRAVERSAL => [
|
||||
'directory_traversal_sequences' => $isfalsePositive ? -0.35 : 0.3,
|
||||
'path_separator_count' => $isfalsePositive ? -0.2 : 0.15,
|
||||
],
|
||||
DetectionCategory::COMMAND_INJECTION => [
|
||||
'command_delimiter_count' => $isfalsePositive ? -0.3 : 0.25,
|
||||
'shell_command_count' => $isfalsePositive ? -0.35 : 0.3,
|
||||
],
|
||||
default => [
|
||||
'anomaly_score' => $isfalsePositive ? -0.15 : 0.1,
|
||||
'request_complexity' => $isfalsePositive ? -0.1 : 0.05,
|
||||
]
|
||||
};
|
||||
|
||||
// Apply learning rate to all adjustments
|
||||
foreach ($categoryFeatures as $feature => $adjustment) {
|
||||
$adjustments[$feature] = $adjustment * $this->learningRate;
|
||||
}
|
||||
|
||||
return $adjustments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply model adjustments to the machine learning engine
|
||||
*
|
||||
* @param array<string, ModelAdjustment> $adjustments The adjustments to apply
|
||||
* @return array<string, mixed> Results of applying the adjustments
|
||||
*/
|
||||
private function applyModelAdjustments(array $adjustments): array
|
||||
{
|
||||
if (empty($adjustments)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'applied_count' => 0,
|
||||
'message' => 'No adjustments to apply',
|
||||
];
|
||||
}
|
||||
|
||||
$this->logger->info('Applying model adjustments', [
|
||||
'count' => count($adjustments),
|
||||
]);
|
||||
|
||||
// Apply adjustments to the machine learning engine
|
||||
$result = $this->mlEngine->applyFeedbackAdjustments($adjustments);
|
||||
|
||||
// Log the results
|
||||
if ($result['success']) {
|
||||
$this->logger->info('Successfully applied model adjustments', [
|
||||
'applied_count' => $result['applied_count'],
|
||||
'failed_count' => $result['failed_count'] ?? 0,
|
||||
]);
|
||||
} else {
|
||||
$this->logger->warning('Failed to apply some model adjustments', [
|
||||
'applied_count' => $result['applied_count'],
|
||||
'failed_count' => $result['failed_count'] ?? 0,
|
||||
]);
|
||||
|
||||
// Log details of failed adjustments
|
||||
if (isset($result['results'])) {
|
||||
foreach ($result['results'] as $id => $adjustmentResult) {
|
||||
if (! $adjustmentResult['success']) {
|
||||
$this->logger->warning('Failed to apply adjustment', [
|
||||
'id' => $id,
|
||||
'message' => $adjustmentResult['message'] ?? 'Unknown error',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum feedback threshold
|
||||
*
|
||||
* @return int The minimum feedback threshold
|
||||
*/
|
||||
public function getMinimumFeedbackThreshold(): int
|
||||
{
|
||||
return $this->minimumFeedbackThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the learning rate
|
||||
*
|
||||
* @return float The learning rate
|
||||
*/
|
||||
public function getLearningRate(): float
|
||||
{
|
||||
return $this->learningRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with a different minimum feedback threshold
|
||||
*
|
||||
* @param int $threshold The new threshold
|
||||
* @return self
|
||||
*/
|
||||
public function withMinimumFeedbackThreshold(int $threshold): self
|
||||
{
|
||||
return new self(
|
||||
$this->repository,
|
||||
$this->mlEngine,
|
||||
$this->clock,
|
||||
$this->logger,
|
||||
$threshold,
|
||||
$this->learningRate
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with a different learning rate
|
||||
*
|
||||
* @param float $rate The new learning rate (0.0-1.0)
|
||||
* @return self
|
||||
*/
|
||||
public function withLearningRate(float $rate): self
|
||||
{
|
||||
$clampedRate = max(0.0, min(1.0, $rate));
|
||||
|
||||
return new self(
|
||||
$this->repository,
|
||||
$this->mlEngine,
|
||||
$this->clock,
|
||||
$this->logger,
|
||||
$this->minimumFeedbackThreshold,
|
||||
$clampedRate
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/Framework/Waf/Feedback/FeedbackRepositoryInterface.php
Normal file
59
src/Framework/Waf/Feedback/FeedbackRepositoryInterface.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
|
||||
/**
|
||||
* Repository interface for storing and retrieving WAF feedback
|
||||
*/
|
||||
interface FeedbackRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Saves feedback for a WAF detection
|
||||
*/
|
||||
public function saveFeedback(DetectionFeedback $feedback): void;
|
||||
|
||||
/**
|
||||
* Retrieves feedback for a specific detection
|
||||
*
|
||||
* @return DetectionFeedback[]
|
||||
*/
|
||||
public function getFeedbackForDetection(string $detectionId): array;
|
||||
|
||||
/**
|
||||
* Retrieves all feedback entries for a specific category
|
||||
*
|
||||
* @param DetectionCategory $category The category to filter by
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return DetectionFeedback[]
|
||||
*/
|
||||
public function getFeedbackByCategory(DetectionCategory $category, ?Timestamp $since = null): array;
|
||||
|
||||
/**
|
||||
* Retrieves feedback entries by feedback type
|
||||
*
|
||||
* @param FeedbackType $feedbackType The feedback type to filter by
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return DetectionFeedback[]
|
||||
*/
|
||||
public function getFeedbackByFeedbackType(FeedbackType $feedbackType, ?Timestamp $since = null): array;
|
||||
|
||||
/**
|
||||
* Gets feedback statistics
|
||||
*
|
||||
* @return array<string, mixed> Statistics about the feedback
|
||||
*/
|
||||
public function getFeedbackStats(): array;
|
||||
|
||||
/**
|
||||
* Gets the most recent feedback entries
|
||||
*
|
||||
* @param int $limit Maximum number of entries to return
|
||||
* @return DetectionFeedback[]
|
||||
*/
|
||||
public function getRecentFeedback(int $limit = 10): array;
|
||||
}
|
||||
311
src/Framework/Waf/Feedback/FeedbackService.php
Normal file
311
src/Framework/Waf/Feedback/FeedbackService.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
|
||||
/**
|
||||
* Service for handling WAF feedback submission and retrieval
|
||||
*/
|
||||
final readonly class FeedbackService
|
||||
{
|
||||
/**
|
||||
* @param FeedbackRepositoryInterface $repository Repository for storing and retrieving feedback
|
||||
* @param Clock $clock Clock for getting current time
|
||||
* @param Logger $logger Logger for logging feedback events
|
||||
*/
|
||||
public function __construct(
|
||||
private FeedbackRepositoryInterface $repository,
|
||||
private Clock $clock,
|
||||
private Logger $logger
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a false positive detection
|
||||
*
|
||||
* @param string $detectionId ID of the detection
|
||||
* @param string $userId ID of the user submitting the feedback
|
||||
* @param string|null $comment Optional comment
|
||||
* @param DetectionCategory $category Category of the detection
|
||||
* @param DetectionSeverity $severity Severity of the detection
|
||||
* @param array<string, mixed> $context Additional context information
|
||||
* @return DetectionFeedback The created feedback
|
||||
*/
|
||||
public function submitFalsePositive(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
array $context = []
|
||||
): DetectionFeedback {
|
||||
$feedback = DetectionFeedback::falsePositive(
|
||||
$detectionId,
|
||||
$userId,
|
||||
$comment,
|
||||
$category,
|
||||
$severity,
|
||||
$context
|
||||
);
|
||||
|
||||
$this->repository->saveFeedback($feedback);
|
||||
|
||||
$this->logger->info('WAF false positive feedback submitted', [
|
||||
'detection_id' => $detectionId,
|
||||
'user_id' => $userId,
|
||||
'category' => $category->value,
|
||||
'severity' => $severity->value,
|
||||
]);
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a false negative detection
|
||||
*
|
||||
* @param string $detectionId ID of the detection
|
||||
* @param string $userId ID of the user submitting the feedback
|
||||
* @param string|null $comment Optional comment
|
||||
* @param DetectionCategory $category Category of the detection
|
||||
* @param DetectionSeverity $severity Severity of the detection
|
||||
* @param array<string, mixed> $context Additional context information
|
||||
* @return DetectionFeedback The created feedback
|
||||
*/
|
||||
public function submitFalseNegative(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
array $context = []
|
||||
): DetectionFeedback {
|
||||
$feedback = DetectionFeedback::falseNegative(
|
||||
$detectionId,
|
||||
$userId,
|
||||
$comment,
|
||||
$category,
|
||||
$severity,
|
||||
$context
|
||||
);
|
||||
|
||||
$this->repository->saveFeedback($feedback);
|
||||
|
||||
$this->logger->info('WAF false negative feedback submitted', [
|
||||
'detection_id' => $detectionId,
|
||||
'user_id' => $userId,
|
||||
'category' => $category->value,
|
||||
'severity' => $severity->value,
|
||||
]);
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a correct detection
|
||||
*
|
||||
* @param string $detectionId ID of the detection
|
||||
* @param string $userId ID of the user submitting the feedback
|
||||
* @param string|null $comment Optional comment
|
||||
* @param DetectionCategory $category Category of the detection
|
||||
* @param DetectionSeverity $severity Severity of the detection
|
||||
* @param array<string, mixed> $context Additional context information
|
||||
* @return DetectionFeedback The created feedback
|
||||
*/
|
||||
public function submitCorrectDetection(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
array $context = []
|
||||
): DetectionFeedback {
|
||||
$feedback = DetectionFeedback::correctDetection(
|
||||
$detectionId,
|
||||
$userId,
|
||||
$comment,
|
||||
$category,
|
||||
$severity,
|
||||
$context
|
||||
);
|
||||
|
||||
$this->repository->saveFeedback($feedback);
|
||||
|
||||
$this->logger->info('WAF correct detection feedback submitted', [
|
||||
'detection_id' => $detectionId,
|
||||
'user_id' => $userId,
|
||||
'category' => $category->value,
|
||||
'severity' => $severity->value,
|
||||
]);
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a severity adjustment
|
||||
*
|
||||
* @param string $detectionId ID of the detection
|
||||
* @param string $userId ID of the user submitting the feedback
|
||||
* @param string|null $comment Optional comment
|
||||
* @param DetectionCategory $category Category of the detection
|
||||
* @param DetectionSeverity $currentSeverity Current severity of the detection
|
||||
* @param DetectionSeverity $suggestedSeverity Suggested severity of the detection
|
||||
* @param array<string, mixed> $context Additional context information
|
||||
* @return DetectionFeedback The created feedback
|
||||
*/
|
||||
public function submitSeverityAdjustment(
|
||||
string $detectionId,
|
||||
string $userId,
|
||||
?string $comment,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $currentSeverity,
|
||||
DetectionSeverity $suggestedSeverity,
|
||||
array $context = []
|
||||
): DetectionFeedback {
|
||||
$feedback = DetectionFeedback::severityAdjustment(
|
||||
$detectionId,
|
||||
$userId,
|
||||
$comment,
|
||||
$category,
|
||||
$currentSeverity,
|
||||
$suggestedSeverity,
|
||||
$context
|
||||
);
|
||||
|
||||
$this->repository->saveFeedback($feedback);
|
||||
|
||||
$this->logger->info('WAF severity adjustment feedback submitted', [
|
||||
'detection_id' => $detectionId,
|
||||
'user_id' => $userId,
|
||||
'category' => $category->value,
|
||||
'current_severity' => $currentSeverity->value,
|
||||
'suggested_severity' => $suggestedSeverity->value,
|
||||
]);
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a detection
|
||||
*
|
||||
* @param Detection $detection The detection to submit feedback for
|
||||
* @param FeedbackType $feedbackType Type of feedback
|
||||
* @param string $userId ID of the user submitting the feedback
|
||||
* @param string|null $comment Optional comment
|
||||
* @param array<string, mixed> $context Additional context information
|
||||
* @return DetectionFeedback The created feedback
|
||||
*/
|
||||
public function submitFeedbackForDetection(
|
||||
Detection $detection,
|
||||
FeedbackType $feedbackType,
|
||||
string $userId,
|
||||
?string $comment = null,
|
||||
array $context = []
|
||||
): DetectionFeedback {
|
||||
$mergedContext = array_merge($context, [
|
||||
'detection_context' => $detection->context,
|
||||
'detection_timestamp' => $detection->timestamp?->toFloat(),
|
||||
'detection_source' => $detection->source,
|
||||
]);
|
||||
|
||||
return match ($feedbackType) {
|
||||
FeedbackType::FALSE_POSITIVE => $this->submitFalsePositive(
|
||||
$detection->id,
|
||||
$userId,
|
||||
$comment,
|
||||
$detection->category,
|
||||
$detection->severity,
|
||||
$mergedContext
|
||||
),
|
||||
FeedbackType::FALSE_NEGATIVE => $this->submitFalseNegative(
|
||||
$detection->id,
|
||||
$userId,
|
||||
$comment,
|
||||
$detection->category,
|
||||
$detection->severity,
|
||||
$mergedContext
|
||||
),
|
||||
FeedbackType::CORRECT_DETECTION => $this->submitCorrectDetection(
|
||||
$detection->id,
|
||||
$userId,
|
||||
$comment,
|
||||
$detection->category,
|
||||
$detection->severity,
|
||||
$mergedContext
|
||||
),
|
||||
FeedbackType::SEVERITY_ADJUSTMENT => $this->submitSeverityAdjustment(
|
||||
$detection->id,
|
||||
$userId,
|
||||
$comment,
|
||||
$detection->category,
|
||||
$detection->severity,
|
||||
// Default to MEDIUM if not specified in context
|
||||
DetectionSeverity::from($context['suggested_severity'] ?? DetectionSeverity::MEDIUM->value),
|
||||
$mergedContext
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback for a specific detection
|
||||
*
|
||||
* @param string $detectionId ID of the detection
|
||||
* @return DetectionFeedback[] Array of feedback for the detection
|
||||
*/
|
||||
public function getFeedbackForDetection(string $detectionId): array
|
||||
{
|
||||
return $this->repository->getFeedbackForDetection($detectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback by category
|
||||
*
|
||||
* @param DetectionCategory $category Category to filter by
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return DetectionFeedback[] Array of feedback for the category
|
||||
*/
|
||||
public function getFeedbackByCategory(DetectionCategory $category, ?Timestamp $since = null): array
|
||||
{
|
||||
return $this->repository->getFeedbackByCategory($category, $since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback by feedback type
|
||||
*
|
||||
* @param FeedbackType $feedbackType Feedback type to filter by
|
||||
* @param Timestamp|null $since Optional timestamp to filter feedback after a certain date
|
||||
* @return DetectionFeedback[] Array of feedback for the feedback type
|
||||
*/
|
||||
public function getFeedbackByFeedbackType(FeedbackType $feedbackType, ?Timestamp $since = null): array
|
||||
{
|
||||
return $this->repository->getFeedbackByFeedbackType($feedbackType, $since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback statistics
|
||||
*
|
||||
* @return array<string, mixed> Statistics about the feedback
|
||||
*/
|
||||
public function getFeedbackStats(): array
|
||||
{
|
||||
return $this->repository->getFeedbackStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent feedback
|
||||
*
|
||||
* @param int $limit Maximum number of entries to return
|
||||
* @return DetectionFeedback[] Array of recent feedback
|
||||
*/
|
||||
public function getRecentFeedback(int $limit = 10): array
|
||||
{
|
||||
return $this->repository->getRecentFeedback($limit);
|
||||
}
|
||||
}
|
||||
16
src/Framework/Waf/Feedback/FeedbackType.php
Normal file
16
src/Framework/Waf/Feedback/FeedbackType.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback;
|
||||
|
||||
/**
|
||||
* Types of feedback for WAF detections
|
||||
*/
|
||||
enum FeedbackType: string
|
||||
{
|
||||
case FALSE_POSITIVE = 'false_positive';
|
||||
case FALSE_NEGATIVE = 'false_negative';
|
||||
case CORRECT_DETECTION = 'correct_detection';
|
||||
case SEVERITY_ADJUSTMENT = 'severity_adjustment';
|
||||
}
|
||||
390
src/Framework/Waf/Feedback/WafFeedbackIntegrator.php
Normal file
390
src/Framework/Waf/Feedback/WafFeedbackIntegrator.php
Normal file
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Feedback;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\LayerStatus;
|
||||
use App\Framework\Waf\ThreatAssessment;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\DetectionCollection;
|
||||
use App\Framework\Waf\WafDecision;
|
||||
use App\Framework\Waf\WafEngine;
|
||||
|
||||
/**
|
||||
* Integrates WAF feedback system with the WAF engine using composition
|
||||
*/
|
||||
final readonly class WafFeedbackIntegrator
|
||||
{
|
||||
/**
|
||||
* @param WafEngine $wafEngine The WAF engine to integrate with
|
||||
* @param FeedbackService $feedbackService The feedback service to use
|
||||
* @param Clock $clock Clock for getting current time
|
||||
* @param Logger $logger Logger for logging events
|
||||
* @param float $falsePositiveThreshold Threshold for considering feedback when making decisions (0.0-1.0)
|
||||
*/
|
||||
public function __construct(
|
||||
private WafEngine $wafEngine,
|
||||
private FeedbackService $feedbackService,
|
||||
private Clock $clock,
|
||||
private Logger $logger,
|
||||
private float $falsePositiveThreshold = 0.7
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request through WAF engine, considering feedback data
|
||||
*
|
||||
* @param Request $request The request to analyze
|
||||
* @return LayerResult The analysis result
|
||||
*/
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
// First, get the standard WAF analysis
|
||||
$result = $this->wafEngine->analyze($request);
|
||||
|
||||
// If there are no detections, return the result as is
|
||||
if (! $result->hasDetections()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Get the detections from the result
|
||||
$detections = $result->getDetections();
|
||||
|
||||
// Process each detection and check for false positive feedback
|
||||
$processedDetections = [];
|
||||
$removedDetections = [];
|
||||
|
||||
foreach ($detections->detections as $detection) {
|
||||
// Check if this detection has false positive feedback
|
||||
$feedback = $this->feedbackService->getFeedbackForDetection($detection->id);
|
||||
|
||||
// Count false positive feedback
|
||||
$falsePositiveCount = 0;
|
||||
$totalFeedbackCount = count($feedback);
|
||||
|
||||
foreach ($feedback as $item) {
|
||||
if ($item->feedbackType === FeedbackType::FALSE_POSITIVE) {
|
||||
$falsePositiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate false positive ratio
|
||||
$falsePositiveRatio = $totalFeedbackCount > 0
|
||||
? $falsePositiveCount / $totalFeedbackCount
|
||||
: 0.0;
|
||||
|
||||
// If false positive ratio is above threshold, remove the detection
|
||||
if ($totalFeedbackCount >= 3 && $falsePositiveRatio >= $this->falsePositiveThreshold) {
|
||||
$this->logger->info('Removing detection based on feedback', [
|
||||
'detection_id' => $detection->id,
|
||||
'false_positive_ratio' => $falsePositiveRatio,
|
||||
'false_positive_count' => $falsePositiveCount,
|
||||
'total_feedback_count' => $totalFeedbackCount,
|
||||
]);
|
||||
|
||||
$removedDetections[] = $detection;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for severity adjustment feedback
|
||||
$suggestedSeverity = $this->getSuggestedSeverity($feedback);
|
||||
if ($suggestedSeverity !== null && $suggestedSeverity !== $detection->severity) {
|
||||
// Create a new detection with adjusted severity
|
||||
$adjustedDetection = new Detection(
|
||||
$detection->category,
|
||||
$suggestedSeverity,
|
||||
$detection->message,
|
||||
null, // ruleId
|
||||
null, // confidence
|
||||
null, // payload
|
||||
null, // location
|
||||
$detection->timestamp
|
||||
);
|
||||
|
||||
$this->logger->info('Adjusted detection severity based on feedback', [
|
||||
'detection_id' => $detection->id,
|
||||
'original_severity' => $detection->severity->value,
|
||||
'adjusted_severity' => $suggestedSeverity->value,
|
||||
]);
|
||||
|
||||
$processedDetections[] = $adjustedDetection;
|
||||
} else {
|
||||
// Keep the original detection
|
||||
$processedDetections[] = $detection;
|
||||
}
|
||||
}
|
||||
|
||||
// If we removed or adjusted any detections, create a new result
|
||||
if (! empty($removedDetections) || count($processedDetections) !== count($detections->detections)) {
|
||||
// If all detections were removed, return a clean result
|
||||
if (empty($processedDetections)) {
|
||||
return LayerResult::clean(
|
||||
'waf_feedback_integrator',
|
||||
'All detections were removed based on feedback'
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new detection collection with the processed detections
|
||||
$newDetections = new DetectionCollection($processedDetections);
|
||||
|
||||
// Determine the new status based on the remaining detections
|
||||
$highestSeverity = DetectionSeverity::INFO;
|
||||
foreach ($processedDetections as $detection) {
|
||||
if ($detection->severity->value > $highestSeverity->value) {
|
||||
$highestSeverity = $detection->severity;
|
||||
}
|
||||
}
|
||||
|
||||
$newStatus = match (true) {
|
||||
$highestSeverity === DetectionSeverity::CRITICAL || $highestSeverity === DetectionSeverity::HIGH
|
||||
=> LayerStatus::THREAT_DETECTED,
|
||||
$highestSeverity === DetectionSeverity::MEDIUM || $highestSeverity === DetectionSeverity::LOW
|
||||
=> LayerStatus::NEUTRAL,
|
||||
default => LayerStatus::CLEAN
|
||||
};
|
||||
|
||||
// Calculate threat score based on detections
|
||||
$maxRiskScore = 0.0;
|
||||
foreach ($processedDetections as $detection) {
|
||||
$threatScore = $detection->getThreatScore()->getValue();
|
||||
if ($threatScore > $maxRiskScore) {
|
||||
$maxRiskScore = $threatScore;
|
||||
}
|
||||
}
|
||||
|
||||
if ($newStatus === LayerStatus::THREAT_DETECTED) {
|
||||
return LayerResult::threat(
|
||||
'waf_feedback_integrator',
|
||||
'Detections adjusted based on feedback',
|
||||
LayerStatus::THREAT_DETECTED,
|
||||
$processedDetections,
|
||||
$result->executionDuration
|
||||
);
|
||||
} elseif ($newStatus === LayerStatus::NEUTRAL) {
|
||||
return LayerResult::neutral(
|
||||
'waf_feedback_integrator',
|
||||
'Detections adjusted based on feedback'
|
||||
)->withExecutionDuration($result->executionDuration);
|
||||
} else {
|
||||
return LayerResult::clean(
|
||||
'waf_feedback_integrator',
|
||||
'Detections adjusted based on feedback',
|
||||
$result->executionDuration
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no changes were made, return the original result
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request and return a WAF decision, considering feedback data
|
||||
*
|
||||
* @param Request $request The request to analyze
|
||||
* @return WafDecision The WAF decision
|
||||
*/
|
||||
public function analyzeRequest(Request $request): WafDecision
|
||||
{
|
||||
// First, get the standard WAF decision
|
||||
$decision = $this->wafEngine->analyzeRequest($request);
|
||||
|
||||
// If the decision is to allow, return it as is
|
||||
if ($decision->action === \App\Framework\Waf\WafAction::ALLOW) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
// Get the threat assessment from the decision
|
||||
$assessment = $decision->assessment;
|
||||
|
||||
// Check for false positive feedback for each detection
|
||||
$detections = $assessment->detections;
|
||||
$removedDetectionCount = 0;
|
||||
|
||||
foreach ($detections->detections as $detection) {
|
||||
// Check if this detection has false positive feedback
|
||||
$feedback = $this->feedbackService->getFeedbackForDetection($detection->id);
|
||||
|
||||
// Count false positive feedback
|
||||
$falsePositiveCount = 0;
|
||||
$totalFeedbackCount = count($feedback);
|
||||
|
||||
foreach ($feedback as $item) {
|
||||
if ($item->feedbackType === FeedbackType::FALSE_POSITIVE) {
|
||||
$falsePositiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate false positive ratio
|
||||
$falsePositiveRatio = $totalFeedbackCount > 0
|
||||
? $falsePositiveCount / $totalFeedbackCount
|
||||
: 0.0;
|
||||
|
||||
// If false positive ratio is above threshold, consider this a false positive
|
||||
if ($totalFeedbackCount >= 3 && $falsePositiveRatio >= $this->falsePositiveThreshold) {
|
||||
$removedDetectionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If all detections are considered false positives, allow the request
|
||||
if ($removedDetectionCount === count($detections->detections)) {
|
||||
$this->logger->info('Allowing request based on feedback', [
|
||||
'removed_detection_count' => $removedDetectionCount,
|
||||
'total_detection_count' => count($detections->detections),
|
||||
]);
|
||||
|
||||
return WafDecision::allow(
|
||||
ThreatAssessment::createEmpty(),
|
||||
Duration::zero()
|
||||
);
|
||||
}
|
||||
|
||||
// If some detections are considered false positives, but not all,
|
||||
// adjust the threat score proportionally
|
||||
if ($removedDetectionCount > 0) {
|
||||
$adjustmentFactor = 1.0 - ($removedDetectionCount / count($detections->detections));
|
||||
$adjustedThreatScore = $assessment->threatScore->getValue() * $adjustmentFactor;
|
||||
|
||||
$this->logger->info('Adjusted threat score based on feedback', [
|
||||
'original_threat_score' => $assessment->threatScore->getValue(),
|
||||
'adjusted_threat_score' => $adjustedThreatScore,
|
||||
'adjustment_factor' => $adjustmentFactor,
|
||||
'removed_detection_count' => $removedDetectionCount,
|
||||
'total_detection_count' => count($detections->detections),
|
||||
]);
|
||||
|
||||
// If the adjusted threat score is below the block threshold, allow the request
|
||||
if ($adjustedThreatScore < 0.7) { // Assuming 0.7 is the block threshold
|
||||
return WafDecision::allow(
|
||||
$assessment,
|
||||
$decision->processingTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return the original decision
|
||||
return $decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a detection
|
||||
*
|
||||
* @param Detection $detection The detection to submit feedback for
|
||||
* @param FeedbackType $feedbackType The type of feedback
|
||||
* @param string $userId The ID of the user submitting the feedback
|
||||
* @param string|null $comment Optional comment
|
||||
* @param array<string, mixed> $context Additional context
|
||||
* @return DetectionFeedback The submitted feedback
|
||||
*/
|
||||
public function submitFeedback(
|
||||
Detection $detection,
|
||||
FeedbackType $feedbackType,
|
||||
string $userId,
|
||||
?string $comment = null,
|
||||
array $context = []
|
||||
): DetectionFeedback {
|
||||
return $this->feedbackService->submitFeedbackForDetection(
|
||||
$detection,
|
||||
$feedbackType,
|
||||
$userId,
|
||||
$comment,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the suggested severity from feedback
|
||||
*
|
||||
* @param DetectionFeedback[] $feedback The feedback to analyze
|
||||
* @return DetectionSeverity|null The suggested severity, or null if no consensus
|
||||
*/
|
||||
private function getSuggestedSeverity(array $feedback): ?DetectionSeverity
|
||||
{
|
||||
// Count suggested severities
|
||||
$severityCounts = [];
|
||||
$totalSeverityAdjustments = 0;
|
||||
|
||||
foreach ($feedback as $item) {
|
||||
if ($item->feedbackType === FeedbackType::SEVERITY_ADJUSTMENT) {
|
||||
$suggestedSeverity = $item->getSuggestedSeverity();
|
||||
if ($suggestedSeverity !== null) {
|
||||
$severityCounts[$suggestedSeverity->value] = ($severityCounts[$suggestedSeverity->value] ?? 0) + 1;
|
||||
$totalSeverityAdjustments++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no severity adjustments, return null
|
||||
if ($totalSeverityAdjustments === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the most suggested severity
|
||||
$mostSuggestedSeverity = null;
|
||||
$mostSuggestedCount = 0;
|
||||
|
||||
foreach ($severityCounts as $severity => $count) {
|
||||
if ($count > $mostSuggestedCount) {
|
||||
$mostSuggestedSeverity = $severity;
|
||||
$mostSuggestedCount = $count;
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a clear consensus (more than 50% of suggestions), return it
|
||||
if ($mostSuggestedCount > ($totalSeverityAdjustments / 2)) {
|
||||
return DetectionSeverity::from($mostSuggestedSeverity);
|
||||
}
|
||||
|
||||
// Otherwise, return null (no consensus)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WAF engine
|
||||
*/
|
||||
public function getWafEngine(): WafEngine
|
||||
{
|
||||
return $this->wafEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the feedback service
|
||||
*/
|
||||
public function getFeedbackService(): FeedbackService
|
||||
{
|
||||
return $this->feedbackService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the false positive threshold
|
||||
*/
|
||||
public function getFalsePositiveThreshold(): float
|
||||
{
|
||||
return $this->falsePositiveThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the false positive threshold
|
||||
*
|
||||
* @param float $threshold The new threshold (0.0-1.0)
|
||||
* @return self
|
||||
*/
|
||||
public function withFalsePositiveThreshold(float $threshold): self
|
||||
{
|
||||
return new self(
|
||||
$this->wafEngine,
|
||||
$this->feedbackService,
|
||||
$this->clock,
|
||||
$this->logger,
|
||||
max(0.0, min(1.0, $threshold))
|
||||
);
|
||||
}
|
||||
}
|
||||
251
src/Framework/Waf/LayerResult.php
Normal file
251
src/Framework/Waf/LayerResult.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\ValueObjects\DetectionCollection;
|
||||
use App\Framework\Waf\ValueObjects\ResultMetadata;
|
||||
|
||||
/**
|
||||
* Result from a WAF security layer analysis
|
||||
*/
|
||||
final readonly class LayerResult
|
||||
{
|
||||
// Action constants for middleware compatibility
|
||||
public const string ACTION_PASS = 'pass';
|
||||
public const string ACTION_BLOCK = 'block';
|
||||
public const string ACTION_SUSPICIOUS = 'suspicious';
|
||||
|
||||
public function __construct(
|
||||
public string $layerName,
|
||||
public Percentage $threatScore,
|
||||
public LayerStatus $status,
|
||||
public DetectionCollection $detections,
|
||||
public string $reason,
|
||||
public ?Duration $executionDuration = null,
|
||||
public ?Timestamp $timestamp = null,
|
||||
public ?ResultMetadata $metadata = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a result indicating a threat was detected
|
||||
*/
|
||||
public static function threat(
|
||||
string $layerName,
|
||||
string $reason,
|
||||
LayerStatus $status = LayerStatus::THREAT_DETECTED,
|
||||
array $detections = [],
|
||||
?Duration $executionDuration = null,
|
||||
?ResultMetadata $metadata = null
|
||||
): self {
|
||||
// Calculate threat score based on detections
|
||||
$maxRiskScore = 0.0;
|
||||
foreach ($detections as $detection) {
|
||||
$threatScore = $detection->getThreatScore()->getValue();
|
||||
if ($threatScore > $maxRiskScore) {
|
||||
$maxRiskScore = $threatScore;
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
layerName: $layerName,
|
||||
threatScore: Percentage::from($maxRiskScore),
|
||||
status: $status,
|
||||
detections: DetectionCollection::fromArray($detections),
|
||||
reason: $reason,
|
||||
executionDuration: $executionDuration,
|
||||
metadata: $metadata ?? ResultMetadata::empty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a result indicating the request is clean
|
||||
*/
|
||||
public static function clean(string $layerName, string $reason = '', ?Duration $executionDuration = null, ?ResultMetadata $metadata = null): self
|
||||
{
|
||||
return new self(
|
||||
layerName: $layerName,
|
||||
threatScore: Percentage::from(0.0),
|
||||
status: LayerStatus::CLEAN,
|
||||
detections: DetectionCollection::empty(),
|
||||
reason: $reason,
|
||||
executionDuration: $executionDuration,
|
||||
metadata: $metadata ?? ResultMetadata::empty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a neutral result (layer couldn't determine threat level)
|
||||
*/
|
||||
public static function neutral(string $layerName, string $reason = '', ?ResultMetadata $metadata = null): self
|
||||
{
|
||||
return new self(
|
||||
layerName: $layerName,
|
||||
threatScore: Percentage::from(50.0), // Neutral score
|
||||
status: LayerStatus::NEUTRAL,
|
||||
detections: DetectionCollection::empty(),
|
||||
reason: $reason,
|
||||
metadata: $metadata ?? ResultMetadata::empty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a result indicating suspicious activity (lower confidence threat)
|
||||
*/
|
||||
public static function suspicious(string $layerName, string $reason, array $detections = [], ?ResultMetadata $metadata = null): self
|
||||
{
|
||||
return new self(
|
||||
layerName: $layerName,
|
||||
threatScore: Percentage::from(60.0), // Moderate threat score
|
||||
status: LayerStatus::SUSPICIOUS,
|
||||
detections: DetectionCollection::fromArray($detections),
|
||||
reason: $reason,
|
||||
metadata: $metadata ?? ResultMetadata::empty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a result indicating layer error/failure
|
||||
*/
|
||||
public static function error(string $layerName, string $reason, ?Duration $executionDuration = null, ?ResultMetadata $metadata = null): self
|
||||
{
|
||||
return new self(
|
||||
layerName: $layerName,
|
||||
threatScore: Percentage::from(0.0), // Don't penalize on error
|
||||
status: LayerStatus::ERROR,
|
||||
detections: DetectionCollection::empty(),
|
||||
reason: $reason,
|
||||
executionDuration: $executionDuration,
|
||||
metadata: $metadata ?? ResultMetadata::empty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this result indicates a threat
|
||||
*/
|
||||
public function isThreat(): bool
|
||||
{
|
||||
return $this->status === LayerStatus::THREAT_DETECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this result is clean
|
||||
*/
|
||||
public function isClean(): bool
|
||||
{
|
||||
return $this->status === LayerStatus::CLEAN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if layer had an error
|
||||
*/
|
||||
public function hasError(): bool
|
||||
{
|
||||
return $this->status === LayerStatus::ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended action based on layer result
|
||||
*/
|
||||
public function getAction(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
LayerStatus::THREAT_DETECTED => self::ACTION_BLOCK,
|
||||
LayerStatus::CLEAN => self::ACTION_PASS,
|
||||
LayerStatus::NEUTRAL => self::ACTION_SUSPICIOUS,
|
||||
LayerStatus::ERROR => self::ACTION_PASS, // Fail open
|
||||
LayerStatus::SKIPPED => self::ACTION_PASS, // Continue processing
|
||||
LayerStatus::TIMEOUT => self::ACTION_PASS, // Fail open on timeout
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reason/message for this result
|
||||
*/
|
||||
public function getMessage(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the layer name
|
||||
*/
|
||||
public function getLayerName(): string
|
||||
{
|
||||
return $this->layerName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result has detections
|
||||
*/
|
||||
public function hasDetections(): bool
|
||||
{
|
||||
return ! $this->detections->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detections collection
|
||||
*/
|
||||
public function getDetections(): DetectionCollection
|
||||
{
|
||||
return $this->detections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new result with execution duration
|
||||
*/
|
||||
public function withExecutionDuration(Duration $duration): self
|
||||
{
|
||||
return new self(
|
||||
layerName: $this->layerName,
|
||||
threatScore: $this->threatScore,
|
||||
status: $this->status,
|
||||
detections: $this->detections,
|
||||
reason: $this->reason,
|
||||
executionDuration: $duration,
|
||||
timestamp: $this->timestamp,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new result with timestamp
|
||||
*/
|
||||
public function withTimestamp(Timestamp $timestamp): self
|
||||
{
|
||||
return new self(
|
||||
layerName: $this->layerName,
|
||||
threatScore: $this->threatScore,
|
||||
status: $this->status,
|
||||
detections: $this->detections,
|
||||
reason: $this->reason,
|
||||
executionDuration: $this->executionDuration,
|
||||
timestamp: $timestamp,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add metadata to result
|
||||
*/
|
||||
public function withMetadata(ResultMetadata $additionalMetadata): self
|
||||
{
|
||||
$combinedMetadata = $this->metadata?->merge($additionalMetadata) ?? $additionalMetadata;
|
||||
|
||||
return new self(
|
||||
layerName: $this->layerName,
|
||||
threatScore: $this->threatScore,
|
||||
status: $this->status,
|
||||
detections: $this->detections,
|
||||
reason: $this->reason,
|
||||
executionDuration: $this->executionDuration,
|
||||
timestamp: $this->timestamp,
|
||||
metadata: $combinedMetadata
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/Framework/Waf/LayerStatus.php
Normal file
89
src/Framework/Waf/LayerStatus.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
/**
|
||||
* Status of a WAF layer analysis result
|
||||
*/
|
||||
enum LayerStatus: string
|
||||
{
|
||||
case CLEAN = 'clean';
|
||||
case THREAT_DETECTED = 'threat_detected';
|
||||
case NEUTRAL = 'neutral';
|
||||
case ERROR = 'error';
|
||||
case SKIPPED = 'skipped';
|
||||
case TIMEOUT = 'timeout';
|
||||
|
||||
/**
|
||||
* Check if status indicates a successful analysis
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::CLEAN,
|
||||
self::THREAT_DETECTED,
|
||||
self::NEUTRAL => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status indicates an error condition
|
||||
*/
|
||||
public function isError(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ERROR,
|
||||
self::TIMEOUT => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status indicates a threat was found
|
||||
*/
|
||||
public function isThreat(): bool
|
||||
{
|
||||
return $this === self::THREAT_DETECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status indicates clean request
|
||||
*/
|
||||
public function isClean(): bool
|
||||
{
|
||||
return $this === self::CLEAN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CLEAN => 'Request passed all security checks',
|
||||
self::THREAT_DETECTED => 'Security threat detected',
|
||||
self::NEUTRAL => 'Unable to determine threat level',
|
||||
self::ERROR => 'Layer analysis failed',
|
||||
self::SKIPPED => 'Layer analysis was skipped',
|
||||
self::TIMEOUT => 'Layer analysis timed out'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for logging/alerting
|
||||
*/
|
||||
public function getSeverityLevel(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::CLEAN => 1, // Info
|
||||
self::NEUTRAL => 2, // Notice
|
||||
self::SKIPPED => 3, // Warning
|
||||
self::TIMEOUT => 4, // Error
|
||||
self::ERROR => 5, // Critical
|
||||
self::THREAT_DETECTED => 6 // Alert
|
||||
};
|
||||
}
|
||||
}
|
||||
454
src/Framework/Waf/Layers/CommandInjectionLayer.php
Normal file
454
src/Framework/Waf/Layers/CommandInjectionLayer.php
Normal file
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Layers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\LayerStatus;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\LayerConfig;
|
||||
use App\Framework\Waf\ValueObjects\LayerMetrics;
|
||||
|
||||
/**
|
||||
* Command Injection Detection Layer
|
||||
*
|
||||
* Detects operating system command injection attempts.
|
||||
* Protects against arbitrary command execution vulnerabilities.
|
||||
*/
|
||||
final class CommandInjectionLayer implements LayerInterface
|
||||
{
|
||||
private LayerConfig $config;
|
||||
|
||||
private LayerMetrics $metrics;
|
||||
|
||||
private bool $enabled = true;
|
||||
|
||||
/** Command injection patterns */
|
||||
private const COMMAND_INJECTION_PATTERNS = [
|
||||
// Command separators
|
||||
'/[;&|`]+/',
|
||||
'/\|\|/',
|
||||
'/&&/',
|
||||
|
||||
// Common Unix/Linux commands
|
||||
'/\b(ls|cat|pwd|whoami|id|uname|ps|netstat|ifconfig|mount)\b/i',
|
||||
'/\b(grep|find|locate|which|whereis|file|head|tail|more|less)\b/i',
|
||||
'/\b(chmod|chown|mkdir|rmdir|rm|cp|mv|ln|touch)\b/i',
|
||||
'/\b(wget|curl|nc|netcat|telnet|ssh|ftp|tftp)\b/i',
|
||||
'/\b(su|sudo|passwd|useradd|userdel|usermod)\b/i',
|
||||
|
||||
// Common Windows commands
|
||||
'/\b(dir|type|copy|del|move|md|rd|cd|cls|ver)\b/i',
|
||||
'/\b(net|ipconfig|ping|tracert|nslookup|arp|route)\b/i',
|
||||
'/\b(tasklist|taskkill|sc|reg|wmic|powershell|cmd)\b/i',
|
||||
'/\b(systeminfo|whoami|echo|set|path)\b/i',
|
||||
|
||||
// System information and process commands
|
||||
'/\b(ps|top|htop|kill|killall|pkill|nohup|jobs|bg|fg)\b/i',
|
||||
'/\b(crontab|at|service|systemctl|chkconfig|update-rc\.d)\b/i',
|
||||
'/\b(iptables|netfilter|firewall|selinux|apparmor)\b/i',
|
||||
|
||||
// File operations and text processing
|
||||
'/\b(awk|sed|sort|uniq|wc|diff|patch|tar|gzip|gunzip)\b/i',
|
||||
'/\b(zip|unzip|compress|uncompress|ar|strings|od|hexdump)\b/i',
|
||||
|
||||
// Network and system utilities
|
||||
'/\b(dig|nslookup|host|whois|traceroute|mtr|tcpdump|wireshark)\b/i',
|
||||
'/\b(lsof|strace|ltrace|gdb|objdump|readelf|nm)\b/i',
|
||||
|
||||
// Dangerous system calls
|
||||
'/\b(exec|system|shell_exec|passthru|eval|popen|proc_open)\b/i',
|
||||
'/\b(Runtime\.getRuntime|ProcessBuilder|cmd\.exe|sh)\b/i',
|
||||
|
||||
// Command substitution
|
||||
'/\$\(.*\)/',
|
||||
'/`[^`]*`/',
|
||||
'/\${.*}/',
|
||||
|
||||
// Input/Output redirection
|
||||
'/[<>]+/',
|
||||
'/\b(tee|xargs)\b/i',
|
||||
|
||||
// Environment variable manipulation
|
||||
'/\$\w+/',
|
||||
'/\bexport\s+\w+=/i',
|
||||
'/\bset\s+\w+=/i',
|
||||
|
||||
// Script interpreters
|
||||
'/\b(bash|sh|csh|tcsh|zsh|fish|ksh|dash)\b/i',
|
||||
'/\b(python|perl|ruby|php|node|java|lua)\b/i',
|
||||
|
||||
// Encoded command attempts
|
||||
'/%5C/', // Backslash
|
||||
'/%7C/', // Pipe
|
||||
'/%26/', // Ampersand
|
||||
'/%3B/', // Semicolon
|
||||
'/%60/', // Backtick
|
||||
|
||||
// Null byte injection for command termination
|
||||
'/\x00/',
|
||||
'/%00/',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new LayerConfig(
|
||||
enabled: true,
|
||||
timeout: Duration::fromMilliseconds(40),
|
||||
confidenceThreshold: Percentage::from(92.0),
|
||||
blockingMode: true,
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 5
|
||||
);
|
||||
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'command_injection';
|
||||
}
|
||||
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$detections = [];
|
||||
|
||||
try {
|
||||
// Check query parameters
|
||||
foreach ($request->queryParams as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$detection = $this->analyzeString($value, "query parameter '{$key}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check POST data
|
||||
if (isset($request->parsedBody->data) && is_array($request->parsedBody->data)) {
|
||||
foreach ($request->parsedBody->data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$detection = $this->analyzeString($value, "POST parameter '{$key}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check headers that commonly contain command injection attempts
|
||||
$suspiciousHeaders = ['User-Agent', 'Referer', 'X-Forwarded-For', 'Cookie', 'X-Real-IP'];
|
||||
foreach ($suspiciousHeaders as $headerName) {
|
||||
$headerValue = $request->headers->getFirst($headerName);
|
||||
if ($headerValue) {
|
||||
$detection = $this->analyzeString($headerValue, "header '{$headerName}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check request path
|
||||
$detection = $this->analyzeString($request->path, 'request path');
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (! empty($detections)) {
|
||||
return LayerResult::threat(
|
||||
$this->getName(),
|
||||
'Command injection attempt detected',
|
||||
LayerStatus::THREAT_DETECTED,
|
||||
$detections,
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
|
||||
return LayerResult::clean(
|
||||
$this->getName(),
|
||||
'No command injection patterns detected',
|
||||
$processingTime
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return LayerResult::error(
|
||||
$this->getName(),
|
||||
'Command injection analysis failed: ' . $e->getMessage(),
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze string for command injection patterns
|
||||
*/
|
||||
private function analyzeString(string $input, string $location): ?Detection
|
||||
{
|
||||
$originalInput = $input;
|
||||
|
||||
// Multiple decoding passes
|
||||
$input = urldecode($input);
|
||||
$input = html_entity_decode($input, ENT_QUOTES | ENT_HTML5);
|
||||
$input = urldecode($input); // Second pass for double encoding
|
||||
|
||||
foreach (self::COMMAND_INJECTION_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $input, $matches)) {
|
||||
|
||||
$severity = $this->calculateSeverity($pattern, $matches[0] ?? '', $input);
|
||||
$riskScore = $this->calculateRiskScore($pattern, $location, $input);
|
||||
|
||||
return new Detection(
|
||||
category: DetectionCategory::COMMAND_INJECTION,
|
||||
severity: $severity,
|
||||
message: "Command injection pattern detected in {$location}",
|
||||
details: [
|
||||
'location' => $location,
|
||||
'pattern' => $pattern,
|
||||
'matched_text' => $matches[0] ?? '',
|
||||
'input_length' => strlen($originalInput),
|
||||
'decoded_input' => substr($input, 0, 200),
|
||||
'original_input' => substr($originalInput, 0, 200),
|
||||
'command_type' => $this->identifyCommandType($pattern, $matches[0] ?? ''),
|
||||
'risk_factors' => $this->analyzeRiskFactors($input),
|
||||
],
|
||||
confidence: 0.88,
|
||||
riskScore: $riskScore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate severity based on pattern and context
|
||||
*/
|
||||
private function calculateSeverity(string $pattern, string $match, string $fullInput): DetectionSeverity
|
||||
{
|
||||
// Critical - System administration commands
|
||||
if (preg_match('/\b(sudo|su|passwd|useradd|rm|chmod|chown)\b/i', $match)) {
|
||||
return DetectionSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// Critical - Command execution functions
|
||||
if (preg_match('/\b(exec|system|shell_exec|eval)\b/i', $match)) {
|
||||
return DetectionSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// Critical - Multiple command separators
|
||||
if (preg_match('/[;&|`]{2,}/', $match)) {
|
||||
return DetectionSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// High - Network commands
|
||||
if (preg_match('/\b(wget|curl|nc|netcat|ssh|ftp)\b/i', $match)) {
|
||||
return DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// High - Command substitution
|
||||
if (preg_match('/(\$\(|\`|`|\${)/', $pattern)) {
|
||||
return DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// High - Process management
|
||||
if (preg_match('/\b(ps|kill|killall|top|htop)\b/i', $match)) {
|
||||
return DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Medium - File operations
|
||||
if (preg_match('/\b(cat|ls|find|grep|head|tail)\b/i', $match)) {
|
||||
return DetectionSeverity::MEDIUM;
|
||||
}
|
||||
|
||||
// Medium - Single command separators
|
||||
if (preg_match('/[;&|]/', $match)) {
|
||||
return DetectionSeverity::MEDIUM;
|
||||
}
|
||||
|
||||
return DetectionSeverity::LOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk score
|
||||
*/
|
||||
private function calculateRiskScore(string $pattern, string $location, string $input): float
|
||||
{
|
||||
$baseScore = 75.0;
|
||||
|
||||
// Location-based risk adjustment
|
||||
if ($location === 'request path') {
|
||||
$baseScore += 10.0;
|
||||
} elseif (str_contains($location, 'POST parameter')) {
|
||||
$baseScore += 5.0;
|
||||
}
|
||||
|
||||
// Pattern-based risk adjustment
|
||||
if (preg_match('/\b(sudo|su|rm|passwd)\b/i', $input)) {
|
||||
$baseScore += 20.0;
|
||||
}
|
||||
|
||||
if (preg_match('/[;&|`]{2,}/', $input)) {
|
||||
$baseScore += 15.0;
|
||||
}
|
||||
|
||||
if (preg_match('/(\$\(|\`|`|\${)/', $input)) {
|
||||
$baseScore += 10.0;
|
||||
}
|
||||
|
||||
// Multiple command indicators
|
||||
$commandCount = preg_match_all('/\b(ls|cat|pwd|whoami|wget|curl)\b/i', $input);
|
||||
if ($commandCount > 1) {
|
||||
$baseScore += ($commandCount * 5);
|
||||
}
|
||||
|
||||
return min(100.0, $baseScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify command type for classification
|
||||
*/
|
||||
private function identifyCommandType(string $pattern, string $match): string
|
||||
{
|
||||
if (preg_match('/\b(ls|cat|pwd|find|grep)\b/i', $match)) {
|
||||
return 'file_operations';
|
||||
}
|
||||
|
||||
if (preg_match('/\b(wget|curl|nc|ssh|ftp)\b/i', $match)) {
|
||||
return 'network_operations';
|
||||
}
|
||||
|
||||
if (preg_match('/\b(ps|kill|top|htop|service)\b/i', $match)) {
|
||||
return 'process_management';
|
||||
}
|
||||
|
||||
if (preg_match('/\b(sudo|su|passwd|useradd)\b/i', $match)) {
|
||||
return 'system_administration';
|
||||
}
|
||||
|
||||
if (preg_match('/[;&|`]+/', $pattern)) {
|
||||
return 'command_chaining';
|
||||
}
|
||||
|
||||
if (preg_match('/(\$\(|\`|\${)/', $pattern)) {
|
||||
return 'command_substitution';
|
||||
}
|
||||
|
||||
return 'general_command';
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze additional risk factors
|
||||
*/
|
||||
private function analyzeRiskFactors(string $input): array
|
||||
{
|
||||
$factors = [];
|
||||
|
||||
if (preg_match('/[;&|`]{2,}/', $input)) {
|
||||
$factors[] = 'multiple_command_separators';
|
||||
}
|
||||
|
||||
if (preg_match('/\b(sudo|su)\b/i', $input)) {
|
||||
$factors[] = 'privilege_escalation';
|
||||
}
|
||||
|
||||
if (preg_match('/(\$\w+|%\w+%)/', $input)) {
|
||||
$factors[] = 'environment_variables';
|
||||
}
|
||||
|
||||
if (preg_match('/[<>]+/', $input)) {
|
||||
$factors[] = 'io_redirection';
|
||||
}
|
||||
|
||||
if (preg_match('/%[0-9a-f]{2}/i', $input)) {
|
||||
$factors[] = 'encoded_content';
|
||||
}
|
||||
|
||||
return $factors;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->config->get('priority', 98);
|
||||
}
|
||||
|
||||
public function getConfidenceLevel(): Percentage
|
||||
{
|
||||
return Percentage::from($this->config->get('confidence', 0.92) * 100);
|
||||
}
|
||||
|
||||
public function getTimeoutThreshold(): Duration
|
||||
{
|
||||
return Duration::fromMilliseconds($this->config->get('timeout', 40));
|
||||
}
|
||||
|
||||
public function configure(LayerConfig $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function getConfig(): LayerConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function getMetrics(): LayerMetrics
|
||||
{
|
||||
return $this->metrics;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function warmUp(): void
|
||||
{
|
||||
// Pre-compile regex patterns if needed
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
// Cleanup resources
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function supportsParallelProcessing(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
public function getSupportedCategories(): array
|
||||
{
|
||||
return [DetectionCategory::COMMAND_INJECTION, DetectionCategory::INJECTION];
|
||||
}
|
||||
}
|
||||
362
src/Framework/Waf/Layers/IntelligentRateLimitLayer.php
Normal file
362
src/Framework/Waf/Layers/IntelligentRateLimitLayer.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Layers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\RateLimit\RateLimiter;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\LayerStatus;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\LayerConfig;
|
||||
use App\Framework\Waf\ValueObjects\LayerMetrics;
|
||||
|
||||
/**
|
||||
* Intelligent Rate Limiting WAF Layer
|
||||
*
|
||||
* Integrates advanced WAF threat analysis with the existing RateLimit framework.
|
||||
* Uses the enhanced RateLimiter with traffic pattern analysis, burst detection,
|
||||
* and baseline deviation tracking for sophisticated DDoS protection.
|
||||
*/
|
||||
final class IntelligentRateLimitLayer implements LayerInterface
|
||||
{
|
||||
private LayerConfig $config;
|
||||
|
||||
private LayerMetrics $metrics;
|
||||
|
||||
// Rate limiting tiers for different threat levels
|
||||
private const array RATE_LIMITS = [
|
||||
'normal' => ['limit' => 100, 'window' => 60], // 100 req/min
|
||||
'elevated' => ['limit' => 50, 'window' => 60], // 50 req/min
|
||||
'high' => ['limit' => 20, 'window' => 60], // 20 req/min
|
||||
'critical' => ['limit' => 5, 'window' => 60], // 5 req/min
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly RateLimiter $rateLimiter,
|
||||
private readonly Clock $clock
|
||||
) {
|
||||
$this->config = new LayerConfig(
|
||||
enabled: true,
|
||||
timeout: Duration::fromMilliseconds(100),
|
||||
confidenceThreshold: Percentage::from(85.0),
|
||||
blockingMode: true,
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 5,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$this->metrics = LayerMetrics::empty($this->clock);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'intelligent_rate_limit';
|
||||
}
|
||||
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$detections = [];
|
||||
|
||||
try {
|
||||
// Extract request context for intelligent analysis
|
||||
$requestContext = $this->buildRequestContext($request);
|
||||
$clientIp = $requestContext['client_ip'];
|
||||
$rateLimitKey = "ip:{$clientIp}";
|
||||
|
||||
// Determine appropriate rate limit based on request characteristics
|
||||
$rateLimitConfig = $this->selectRateLimitTier($requestContext);
|
||||
|
||||
// Perform intelligent rate limit check with threat analysis
|
||||
$rateLimitResult = $this->rateLimiter->checkLimitWithAnalysis(
|
||||
$rateLimitKey,
|
||||
$rateLimitConfig['limit'],
|
||||
$rateLimitConfig['window'],
|
||||
$requestContext
|
||||
);
|
||||
|
||||
// Process results and create detections
|
||||
if (! $rateLimitResult->isAllowed()) {
|
||||
$detections[] = $this->createRateLimitDetection($rateLimitResult, $requestContext);
|
||||
}
|
||||
|
||||
// Check for suspicious patterns even if rate limit not exceeded
|
||||
if ($rateLimitResult->isAttackSuspected()) {
|
||||
$detections[] = $this->createSuspiciousPatternDetection($rateLimitResult, $requestContext);
|
||||
}
|
||||
|
||||
// Check for anomalous traffic patterns
|
||||
if ($rateLimitResult->hasAnomalousTraffic()) {
|
||||
$detections[] = $this->createAnomalyDetection($rateLimitResult, $requestContext);
|
||||
}
|
||||
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
// Determine overall result
|
||||
if (! empty($detections)) {
|
||||
$threatLevel = $rateLimitResult->getThreatLevel();
|
||||
$status = $this->shouldBlock($threatLevel) ? LayerStatus::THREAT_DETECTED : LayerStatus::SUSPICIOUS;
|
||||
|
||||
return LayerResult::threat(
|
||||
$this->getName(),
|
||||
$this->buildThreatMessage($rateLimitResult, $threatLevel),
|
||||
$status,
|
||||
$detections,
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
|
||||
return LayerResult::clean(
|
||||
$this->getName(),
|
||||
'Traffic patterns within normal parameters',
|
||||
$processingTime
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return LayerResult::error(
|
||||
$this->getName(),
|
||||
'Intelligent rate limit analysis failed: ' . $e->getMessage(),
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build comprehensive request context for analysis
|
||||
*/
|
||||
private function buildRequestContext(Request $request): array
|
||||
{
|
||||
return [
|
||||
'client_ip' => $request->server->getRemoteAddr(),
|
||||
'request_path' => $request->path,
|
||||
'user_agent' => $request->headers->getFirst('User-Agent') ?? '',
|
||||
'request_method' => $request->method->value,
|
||||
'query_params' => $request->queryParams,
|
||||
'content_length' => $request->headers->getFirst('Content-Length') ?? '0',
|
||||
'timestamp' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select appropriate rate limit tier based on request characteristics
|
||||
*/
|
||||
private function selectRateLimitTier(array $requestContext): array
|
||||
{
|
||||
$userAgent = strtolower($requestContext['user_agent']);
|
||||
$path = $requestContext['request_path'];
|
||||
|
||||
// Critical paths get stricter limits
|
||||
if (str_contains($path, '/admin/') || str_contains($path, '/api/')) {
|
||||
return self::RATE_LIMITS['elevated'];
|
||||
}
|
||||
|
||||
// Known malicious user agents get strict limits
|
||||
$maliciousPatterns = ['sqlmap', 'nikto', 'scanner', 'bot', 'crawler'];
|
||||
foreach ($maliciousPatterns as $pattern) {
|
||||
if (str_contains($userAgent, $pattern)) {
|
||||
return self::RATE_LIMITS['critical'];
|
||||
}
|
||||
}
|
||||
|
||||
// Resource-intensive operations get elevated limits
|
||||
if (str_contains($path, '/search') || str_contains($path, '/export')) {
|
||||
return self::RATE_LIMITS['elevated'];
|
||||
}
|
||||
|
||||
return self::RATE_LIMITS['normal'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detection for rate limit exceeded
|
||||
*/
|
||||
private function createRateLimitDetection($rateLimitResult, array $context): Detection
|
||||
{
|
||||
$threatLevel = $rateLimitResult->getThreatLevel();
|
||||
$severity = match ($threatLevel) {
|
||||
'critical' => DetectionSeverity::CRITICAL,
|
||||
'high' => DetectionSeverity::HIGH,
|
||||
'medium' => DetectionSeverity::MEDIUM,
|
||||
default => DetectionSeverity::LOW
|
||||
};
|
||||
|
||||
return new Detection(
|
||||
category: DetectionCategory::RATE_LIMITING,
|
||||
severity: $severity,
|
||||
message: sprintf(
|
||||
'Rate limit exceeded: %d/%d requests from %s (threat level: %s)',
|
||||
$rateLimitResult->getCurrent(),
|
||||
$rateLimitResult->getLimit(),
|
||||
$context['client_ip'],
|
||||
$threatLevel
|
||||
),
|
||||
confidence: Percentage::from(95.0),
|
||||
location: 'request_rate',
|
||||
timestamp: null,
|
||||
context: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detection for suspicious attack patterns
|
||||
*/
|
||||
private function createSuspiciousPatternDetection($rateLimitResult, array $context): Detection
|
||||
{
|
||||
$attackPatterns = implode(', ', $rateLimitResult->attackPatterns);
|
||||
|
||||
return new Detection(
|
||||
category: DetectionCategory::SUSPICIOUS_BEHAVIOR,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
message: sprintf(
|
||||
'Suspicious attack patterns detected from %s: %s',
|
||||
$context['client_ip'],
|
||||
$attackPatterns
|
||||
),
|
||||
confidence: Percentage::from(85.0),
|
||||
location: 'traffic_analysis',
|
||||
timestamp: null,
|
||||
context: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detection for traffic anomalies
|
||||
*/
|
||||
private function createAnomalyDetection($rateLimitResult, array $context): Detection
|
||||
{
|
||||
return new Detection(
|
||||
category: DetectionCategory::ANOMALY,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
message: sprintf(
|
||||
'Anomalous traffic pattern from %s: %.2f standard deviations from baseline',
|
||||
$context['client_ip'],
|
||||
$rateLimitResult->baselineDeviation ?? 0.0
|
||||
),
|
||||
confidence: Percentage::from(75.0),
|
||||
location: 'baseline_analysis',
|
||||
timestamp: null,
|
||||
context: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if threat level warrants blocking
|
||||
*/
|
||||
private function shouldBlock(string $threatLevel): bool
|
||||
{
|
||||
return in_array($threatLevel, ['critical', 'high']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build descriptive threat message
|
||||
*/
|
||||
private function buildThreatMessage($rateLimitResult, string $threatLevel): string
|
||||
{
|
||||
$messages = ['Intelligent rate limiting detected threat'];
|
||||
|
||||
if (! $rateLimitResult->isAllowed()) {
|
||||
$messages[] = 'rate limit exceeded';
|
||||
}
|
||||
|
||||
if ($rateLimitResult->isAttackSuspected()) {
|
||||
$messages[] = 'attack patterns identified';
|
||||
}
|
||||
|
||||
if ($rateLimitResult->hasAnomalousTraffic()) {
|
||||
$messages[] = 'traffic anomalies detected';
|
||||
}
|
||||
|
||||
$messages[] = "threat level: {$threatLevel}";
|
||||
|
||||
return implode(', ', $messages);
|
||||
}
|
||||
|
||||
// ===== LayerInterface Implementation =====
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->config->enabled;
|
||||
}
|
||||
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 200; // High priority for rate limiting
|
||||
}
|
||||
|
||||
public function getConfidenceLevel(): Percentage
|
||||
{
|
||||
return $this->config->getEffectiveConfidenceThreshold();
|
||||
}
|
||||
|
||||
public function getTimeoutThreshold(): Duration
|
||||
{
|
||||
return $this->config->getEffectiveTimeout();
|
||||
}
|
||||
|
||||
public function configure(LayerConfig $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function getConfig(): LayerConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function getMetrics(): LayerMetrics
|
||||
{
|
||||
return $this->metrics;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = LayerMetrics::empty($this->clock);
|
||||
}
|
||||
|
||||
public function warmUp(): void
|
||||
{
|
||||
// No warmup needed
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
// No cleanup needed
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [RateLimiter::class, Clock::class];
|
||||
}
|
||||
|
||||
public function supportsParallelProcessing(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
public function getSupportedCategories(): array
|
||||
{
|
||||
return [
|
||||
DetectionCategory::RATE_LIMITING,
|
||||
DetectionCategory::SUSPICIOUS_BEHAVIOR,
|
||||
DetectionCategory::ANOMALY,
|
||||
];
|
||||
}
|
||||
}
|
||||
111
src/Framework/Waf/Layers/LayerInterface.php
Normal file
111
src/Framework/Waf/Layers/LayerInterface.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Layers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\ValueObjects\LayerConfig;
|
||||
use App\Framework\Waf\ValueObjects\LayerMetrics;
|
||||
|
||||
/**
|
||||
* Interface for all WAF security layers
|
||||
* Each layer analyzes requests and returns threat assessment
|
||||
*/
|
||||
interface LayerInterface
|
||||
{
|
||||
/**
|
||||
* Get unique layer name for identification
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Analyze request and return threat assessment
|
||||
*/
|
||||
public function analyze(Request $request): LayerResult;
|
||||
|
||||
/**
|
||||
* Check if layer is enabled and operational
|
||||
*/
|
||||
public function isEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Check layer health status
|
||||
*/
|
||||
public function isHealthy(): bool;
|
||||
|
||||
/**
|
||||
* Get layer priority (higher = processed first)
|
||||
* Allows for dependency-based processing order
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
|
||||
/**
|
||||
* Get layer confidence level in its assessments
|
||||
* Used for weighted threat scoring
|
||||
*/
|
||||
public function getConfidenceLevel(): Percentage;
|
||||
|
||||
/**
|
||||
* Get maximum processing time before timeout
|
||||
*/
|
||||
public function getTimeoutThreshold(): Duration;
|
||||
|
||||
/**
|
||||
* Configure layer at runtime
|
||||
* Allows dynamic reconfiguration without restart
|
||||
*/
|
||||
public function configure(LayerConfig $config): void;
|
||||
|
||||
/**
|
||||
* Get current layer configuration
|
||||
*/
|
||||
public function getConfig(): LayerConfig;
|
||||
|
||||
/**
|
||||
* Get layer performance metrics
|
||||
*/
|
||||
public function getMetrics(): LayerMetrics;
|
||||
|
||||
/**
|
||||
* Reset layer state (for testing/debugging)
|
||||
*/
|
||||
public function reset(): void;
|
||||
|
||||
/**
|
||||
* Warm up layer (preload rules, caches, etc.)
|
||||
* Called during WAF initialization
|
||||
*/
|
||||
public function warmUp(): void;
|
||||
|
||||
/**
|
||||
* Clean up layer resources
|
||||
* Called during WAF shutdown
|
||||
*/
|
||||
public function shutdown(): void;
|
||||
|
||||
/**
|
||||
* Get layer dependencies (other layers this depends on)
|
||||
* Used for proper initialization order
|
||||
*/
|
||||
public function getDependencies(): array;
|
||||
|
||||
/**
|
||||
* Check if layer supports parallel processing
|
||||
*/
|
||||
public function supportsParallelProcessing(): bool;
|
||||
|
||||
/**
|
||||
* Get layer version for compatibility checking
|
||||
*/
|
||||
public function getVersion(): string;
|
||||
|
||||
/**
|
||||
* Get supported threat categories
|
||||
* Returns array of DetectionCategory enums this layer can detect
|
||||
*/
|
||||
public function getSupportedCategories(): array;
|
||||
}
|
||||
387
src/Framework/Waf/Layers/PathTraversalLayer.php
Normal file
387
src/Framework/Waf/Layers/PathTraversalLayer.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Layers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\LayerStatus;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\LayerConfig;
|
||||
use App\Framework\Waf\ValueObjects\LayerMetrics;
|
||||
|
||||
/**
|
||||
* Path Traversal Detection Layer
|
||||
*
|
||||
* Detects directory traversal and path manipulation attempts.
|
||||
* Protects against unauthorized file access and system disclosure.
|
||||
*/
|
||||
final class PathTraversalLayer implements LayerInterface
|
||||
{
|
||||
private LayerConfig $config;
|
||||
|
||||
private LayerMetrics $metrics;
|
||||
|
||||
private bool $enabled = true;
|
||||
|
||||
/** Path traversal patterns */
|
||||
private const PATH_TRAVERSAL_PATTERNS = [
|
||||
// Classic directory traversal
|
||||
'/\.\.\//',
|
||||
'/\.\.\\\/',
|
||||
'/\.\.\\\\/',
|
||||
|
||||
// Encoded versions
|
||||
'/%2e%2e%2f/',
|
||||
'/%2e%2e%5c/',
|
||||
'/%252e%252e%252f/',
|
||||
'/\.\.\%2f/',
|
||||
'/\.\.\%5c/',
|
||||
|
||||
// Unicode encoded (using hex notation instead of \u)
|
||||
'/\x{002e}\x{002e}\x{002f}/u',
|
||||
'/\x{ff0e}\x{ff0e}\x{ff0f}/u',
|
||||
|
||||
// Alternative representations
|
||||
'/\.\.%252f/',
|
||||
'/\.\.%c0%af/',
|
||||
'/\.\.%c1%9c/',
|
||||
|
||||
// Null byte injection
|
||||
'/\.\.\/.*\x00/',
|
||||
'/\.\.\\\\.*\x00/',
|
||||
|
||||
// System file access attempts
|
||||
'/\/etc\/passwd/',
|
||||
'/\/etc\/shadow/',
|
||||
'/\/etc\/hosts/',
|
||||
'/\/proc\/version/',
|
||||
'/\/proc\/self\/environ/',
|
||||
'/\/windows\/system32\//',
|
||||
'/\/winnt\/system32\//',
|
||||
|
||||
// Common sensitive files
|
||||
'/\/etc\/.*/',
|
||||
'/\/var\/log\/.*/',
|
||||
'/\/root\/.*/',
|
||||
'/\/home\/.*\/\.\w+/',
|
||||
|
||||
// Web application files
|
||||
'/\/web\.config/',
|
||||
'/\/app\.config/',
|
||||
'/\/\.htaccess/',
|
||||
'/\/\.htpasswd/',
|
||||
'/\/wp-config\.php/',
|
||||
'/\/configuration\.php/',
|
||||
'/\/config\.inc\.php/',
|
||||
|
||||
// Backup and temporary files
|
||||
'/.*\.bak$/',
|
||||
'/.*\.backup$/',
|
||||
'/.*\.old$/',
|
||||
'/.*\.tmp$/',
|
||||
'/.*~$/',
|
||||
'/.*\.swp$/',
|
||||
|
||||
// Source code disclosure
|
||||
'/.*\.php\.bak/',
|
||||
'/.*\.asp\.old/',
|
||||
'/.*\.jsp\.tmp/',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new LayerConfig(
|
||||
enabled: true,
|
||||
timeout: Duration::fromMilliseconds(30),
|
||||
confidenceThreshold: Percentage::from(95.0),
|
||||
blockingMode: true,
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 5
|
||||
);
|
||||
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'path_traversal';
|
||||
}
|
||||
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$detections = [];
|
||||
|
||||
try {
|
||||
// Check query parameters
|
||||
foreach ($request->queryParams as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$detection = $this->analyzeString($value, "query parameter '{$key}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check POST data
|
||||
if (isset($request->parsedBody->data) && is_array($request->parsedBody->data)) {
|
||||
foreach ($request->parsedBody->data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$detection = $this->analyzeString($value, "POST parameter '{$key}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check request path (very important for path traversal)
|
||||
$detection = $this->analyzeString($request->path, 'request path');
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
|
||||
// Check specific headers that might contain file paths
|
||||
$pathHeaders = ['Referer', 'X-Original-URL', 'X-Rewrite-URL'];
|
||||
foreach ($pathHeaders as $headerName) {
|
||||
$headerValue = $request->headers->getFirst($headerName);
|
||||
if ($headerValue) {
|
||||
$detection = $this->analyzeString($headerValue, "header '{$headerName}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (! empty($detections)) {
|
||||
return LayerResult::threat(
|
||||
$this->getName(),
|
||||
'Path traversal attempt detected',
|
||||
LayerStatus::THREAT_DETECTED,
|
||||
$detections,
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
|
||||
return LayerResult::clean(
|
||||
$this->getName(),
|
||||
'No path traversal patterns detected',
|
||||
$processingTime
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return LayerResult::error(
|
||||
$this->getName(),
|
||||
'Path traversal analysis failed: ' . $e->getMessage(),
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze string for path traversal patterns
|
||||
*/
|
||||
private function analyzeString(string $input, string $location): ?Detection
|
||||
{
|
||||
$originalInput = $input;
|
||||
|
||||
// Multiple decoding passes to catch encoded attempts
|
||||
$input = urldecode($input);
|
||||
$input = urldecode($input); // Second pass for double encoding
|
||||
|
||||
foreach (self::PATH_TRAVERSAL_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $input, $matches)) {
|
||||
|
||||
$severity = $this->calculateSeverity($pattern, $matches[0] ?? '');
|
||||
$riskScore = $this->calculateRiskScore($pattern, $location);
|
||||
|
||||
return new Detection(
|
||||
category: DetectionCategory::PATH_TRAVERSAL,
|
||||
severity: $severity,
|
||||
message: "Path traversal pattern detected in {$location}",
|
||||
details: [
|
||||
'location' => $location,
|
||||
'pattern' => $pattern,
|
||||
'matched_text' => $matches[0] ?? '',
|
||||
'input_length' => strlen($originalInput),
|
||||
'decoded_input' => substr($input, 0, 200),
|
||||
'original_input' => substr($originalInput, 0, 200),
|
||||
'threat_type' => $this->identifyThreatType($pattern),
|
||||
],
|
||||
confidence: 0.9,
|
||||
riskScore: $riskScore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate severity based on pattern and context
|
||||
*/
|
||||
private function calculateSeverity(string $pattern, string $match): DetectionSeverity
|
||||
{
|
||||
// Critical - System file access
|
||||
if (preg_match('/\/(etc|proc|root|windows|winnt)\//', $pattern)) {
|
||||
return DetectionSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// Critical - Sensitive application files
|
||||
if (preg_match('/(passwd|shadow|config|htaccess|htpasswd)/', $pattern)) {
|
||||
return DetectionSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// High - Directory traversal with sensitive extensions
|
||||
if (preg_match('/\.\.(\/|\\\\).*\.(php|asp|jsp|config)/', $match)) {
|
||||
return DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// High - Multiple directory traversals
|
||||
if (substr_count($match, '..') > 2) {
|
||||
return DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Medium - Basic directory traversal
|
||||
return DetectionSeverity::MEDIUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk score based on pattern and location
|
||||
*/
|
||||
private function calculateRiskScore(string $pattern, string $location): float
|
||||
{
|
||||
$baseScore = 70.0;
|
||||
|
||||
// Higher risk in request path
|
||||
if ($location === 'request path') {
|
||||
$baseScore += 15.0;
|
||||
}
|
||||
|
||||
// System files are highest risk
|
||||
if (preg_match('/\/(etc|proc|root|windows)\//', $pattern)) {
|
||||
$baseScore += 20.0;
|
||||
}
|
||||
|
||||
// Config files are high risk
|
||||
if (preg_match('/(config|htaccess|passwd)/', $pattern)) {
|
||||
$baseScore += 15.0;
|
||||
}
|
||||
|
||||
// Encoded attempts show deliberate evasion
|
||||
if (preg_match('/%[0-9a-f]{2}/', $pattern)) {
|
||||
$baseScore += 10.0;
|
||||
}
|
||||
|
||||
return min(100.0, $baseScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the type of threat based on pattern
|
||||
*/
|
||||
private function identifyThreatType(string $pattern): string
|
||||
{
|
||||
if (preg_match('/\/(etc|proc)\//', $pattern)) {
|
||||
return 'system_file_access';
|
||||
}
|
||||
|
||||
if (preg_match('/(config|htaccess|passwd)/', $pattern)) {
|
||||
return 'configuration_disclosure';
|
||||
}
|
||||
|
||||
if (preg_match('/\.(bak|backup|old|tmp)/', $pattern)) {
|
||||
return 'backup_file_access';
|
||||
}
|
||||
|
||||
if (preg_match('/\.\./', $pattern)) {
|
||||
return 'directory_traversal';
|
||||
}
|
||||
|
||||
return 'file_access_attempt';
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->config->get('priority', 95);
|
||||
}
|
||||
|
||||
public function getConfidenceLevel(): Percentage
|
||||
{
|
||||
return Percentage::from($this->config->get('confidence', 0.95) * 100);
|
||||
}
|
||||
|
||||
public function getTimeoutThreshold(): Duration
|
||||
{
|
||||
return Duration::fromMilliseconds($this->config->get('timeout', 30));
|
||||
}
|
||||
|
||||
public function configure(LayerConfig $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function getConfig(): LayerConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function getMetrics(): LayerMetrics
|
||||
{
|
||||
return $this->metrics;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function warmUp(): void
|
||||
{
|
||||
// Pre-compile regex patterns if needed
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
// Cleanup resources
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function supportsParallelProcessing(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
public function getSupportedCategories(): array
|
||||
{
|
||||
return [DetectionCategory::PATH_TRAVERSAL, DetectionCategory::BROKEN_ACCESS_CONTROL];
|
||||
}
|
||||
}
|
||||
292
src/Framework/Waf/Layers/SqlInjectionLayer.php
Normal file
292
src/Framework/Waf/Layers/SqlInjectionLayer.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Layers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\LayerStatus;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\LayerConfig;
|
||||
use App\Framework\Waf\ValueObjects\LayerMetrics;
|
||||
|
||||
/**
|
||||
* SQL Injection Detection Layer
|
||||
*
|
||||
* Detects SQL injection attempts in request parameters, headers, and body.
|
||||
* Uses pattern matching and heuristic analysis to identify potential SQL injection attacks.
|
||||
*/
|
||||
final class SqlInjectionLayer implements LayerInterface
|
||||
{
|
||||
private LayerConfig $config;
|
||||
|
||||
private LayerMetrics $metrics;
|
||||
|
||||
private bool $enabled = true;
|
||||
|
||||
/** SQL Injection patterns (simplified for initial implementation) */
|
||||
private const SQL_PATTERNS = [
|
||||
// Classic injection patterns
|
||||
'/(\bunion\s+select\b)/i',
|
||||
'/(\bselect\b.*\bfrom\b)/i',
|
||||
'/(\binsert\s+into\b)/i',
|
||||
'/(\bupdate\s+.*\bset\b)/i',
|
||||
'/(\bdelete\s+from\b)/i',
|
||||
'/(\bdrop\s+(table|database)\b)/i',
|
||||
|
||||
// SQL comment indicators
|
||||
'/(--|\#|\/\*|\*\/)/i',
|
||||
|
||||
// SQL operators and functions
|
||||
'/(\bor\s+1\s*=\s*1\b)/i',
|
||||
'/(\band\s+1\s*=\s*1\b)/i',
|
||||
'/(\bor\s+\'[^\']*\'\s*=\s*\'[^\']*\'\b)/i',
|
||||
'/(\bunion\s+all\s+select\b)/i',
|
||||
|
||||
// SQL string manipulation
|
||||
'/(\bconcat\s*\()/i',
|
||||
'/(\bchar\s*\()/i',
|
||||
'/(\bhex\s*\()/i',
|
||||
'/(\bascii\s*\()/i',
|
||||
|
||||
// Database-specific functions
|
||||
'/(\bversion\s*\(\))/i',
|
||||
'/(\buser\s*\(\))/i',
|
||||
'/(\bdatabase\s*\(\))/i',
|
||||
'/(\bsleep\s*\()/i',
|
||||
'/(\bbenchmark\s*\()/i',
|
||||
|
||||
// Blind injection patterns
|
||||
'/(\bwaitfor\s+delay\b)/i',
|
||||
'/(\bif\s*\(.*,.*,.*\))/i',
|
||||
|
||||
// Quote and escape sequences
|
||||
'/([\'\"]\s*;\s*)/i',
|
||||
'/(\\\x[0-9a-f]{2})/i',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new LayerConfig(
|
||||
enabled: true,
|
||||
timeout: Duration::fromMilliseconds(50),
|
||||
confidenceThreshold: Percentage::from(95.0),
|
||||
blockingMode: true,
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 10
|
||||
);
|
||||
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'sql_injection';
|
||||
}
|
||||
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$detections = [];
|
||||
|
||||
// Debug logging
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: Starting analysis for path: " . ($request->path ?? '/') . "\n", FILE_APPEND);
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: Query params: " . json_encode($request->queryParams) . "\n", FILE_APPEND);
|
||||
|
||||
try {
|
||||
// Check query parameters
|
||||
foreach ($request->queryParams as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: Analyzing query param '{$key}' = '{$value}'\n", FILE_APPEND);
|
||||
$detection = $this->analyzeString($value, "query parameter '{$key}'");
|
||||
if ($detection) {
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: DETECTION FOUND in query param '{$key}'!\n", FILE_APPEND);
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check POST data
|
||||
if (isset($request->parsedBody->data) && is_array($request->parsedBody->data)) {
|
||||
foreach ($request->parsedBody->data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$detection = $this->analyzeString($value, "POST parameter '{$key}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check headers (common injection points)
|
||||
$suspiciousHeaders = ['User-Agent', 'Referer', 'X-Forwarded-For', 'Cookie'];
|
||||
foreach ($suspiciousHeaders as $headerName) {
|
||||
$headerValue = $request->headers->getFirst($headerName);
|
||||
if ($headerValue) {
|
||||
$detection = $this->analyzeString($headerValue, "header '{$headerName}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check request path
|
||||
$detection = $this->analyzeString($request->path, 'request path');
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (! empty($detections)) {
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: Returning THREAT result with " . count($detections) . " detections\n", FILE_APPEND);
|
||||
|
||||
return LayerResult::threat(
|
||||
$this->getName(),
|
||||
'SQL injection attempt detected',
|
||||
LayerStatus::THREAT_DETECTED,
|
||||
$detections,
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
|
||||
return LayerResult::clean(
|
||||
$this->getName(),
|
||||
'No SQL injection patterns detected',
|
||||
$processingTime
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return LayerResult::error(
|
||||
$this->getName(),
|
||||
'SQL injection analysis failed: ' . $e->getMessage(),
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze string for SQL injection patterns
|
||||
*/
|
||||
private function analyzeString(string $input, string $location): ?Detection
|
||||
{
|
||||
$originalInput = $input;
|
||||
$input = urldecode($input); // Decode URL encoding
|
||||
$input = html_entity_decode($input, ENT_QUOTES | ENT_HTML5); // Decode HTML entities
|
||||
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: analyzeString - original: '{$originalInput}', decoded: '{$input}'\n", FILE_APPEND);
|
||||
|
||||
foreach (self::SQL_PATTERNS as $pattern) {
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: Testing pattern: {$pattern}\n", FILE_APPEND);
|
||||
if (preg_match($pattern, $input, $matches)) {
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: PATTERN MATCH! Pattern: {$pattern}, Match: " . ($matches[0] ?? '') . "\n", FILE_APPEND);
|
||||
|
||||
try {
|
||||
$detection = new Detection(
|
||||
category: DetectionCategory::SQL_INJECTION,
|
||||
severity: DetectionSeverity::CRITICAL,
|
||||
message: "SQL injection pattern detected in {$location}: " . ($matches[0] ?? ''),
|
||||
ruleId: null,
|
||||
confidence: Percentage::from(90.0),
|
||||
payload: null,
|
||||
location: $location,
|
||||
timestamp: null, // Will be set automatically by static methods if needed
|
||||
context: null
|
||||
);
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: Created Detection object, returning it\n", FILE_APPEND);
|
||||
|
||||
return $detection;
|
||||
} catch (\Throwable $e) {
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: ERROR creating Detection: " . $e->getMessage() . "\n", FILE_APPEND);
|
||||
// Continue to next pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents('/tmp/waf_debug.log', "SqlInjectionLayer: No patterns matched for input: '{$input}'\n", FILE_APPEND);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return true; // Simple implementation - always healthy
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 100; // High priority for SQL injection detection
|
||||
}
|
||||
|
||||
public function getConfidenceLevel(): Percentage
|
||||
{
|
||||
return $this->config->getEffectiveConfidenceThreshold();
|
||||
}
|
||||
|
||||
public function getTimeoutThreshold(): Duration
|
||||
{
|
||||
return $this->config->getEffectiveTimeout();
|
||||
}
|
||||
|
||||
public function configure(LayerConfig $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function getConfig(): LayerConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function getMetrics(): LayerMetrics
|
||||
{
|
||||
return $this->metrics;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function warmUp(): void
|
||||
{
|
||||
// Pre-compile regex patterns if needed
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
// Cleanup resources
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return []; // No dependencies
|
||||
}
|
||||
|
||||
public function supportsParallelProcessing(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
public function getSupportedCategories(): array
|
||||
{
|
||||
return [DetectionCategory::SQL_INJECTION, DetectionCategory::INJECTION];
|
||||
}
|
||||
}
|
||||
541
src/Framework/Waf/Layers/SuspiciousUserAgentLayer.php
Normal file
541
src/Framework/Waf/Layers/SuspiciousUserAgentLayer.php
Normal file
@@ -0,0 +1,541 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Layers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\LayerStatus;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\LayerConfig;
|
||||
use App\Framework\Waf\ValueObjects\LayerMetrics;
|
||||
|
||||
/**
|
||||
* Suspicious User Agent Detection Layer
|
||||
*
|
||||
* Detects malicious bots, scanners, and suspicious user agents.
|
||||
* Helps identify automated attacks and reconnaissance attempts.
|
||||
*/
|
||||
final class SuspiciousUserAgentLayer implements LayerInterface
|
||||
{
|
||||
private LayerConfig $config;
|
||||
|
||||
private LayerMetrics $metrics;
|
||||
|
||||
private bool $enabled = true;
|
||||
|
||||
/** Suspicious User Agent patterns */
|
||||
private const SUSPICIOUS_PATTERNS = [
|
||||
// Security scanners and vulnerability assessment tools
|
||||
'/nikto/i',
|
||||
'/nessus/i',
|
||||
'/openvas/i',
|
||||
'/nmap/i',
|
||||
'/masscan/i',
|
||||
'/zmap/i',
|
||||
'/sqlmap/i',
|
||||
'/w3af/i',
|
||||
'/owasp/i',
|
||||
'/burp/i',
|
||||
'/dirbuster/i',
|
||||
'/dirb/i',
|
||||
'/gobuster/i',
|
||||
'/ffuf/i',
|
||||
'/wfuzz/i',
|
||||
|
||||
// Web application scanners
|
||||
'/acunetix/i',
|
||||
'/appscan/i',
|
||||
'/webinspect/i',
|
||||
'/netsparker/i',
|
||||
'/qualys/i',
|
||||
'/rapid7/i',
|
||||
'/veracode/i',
|
||||
'/checkmarx/i',
|
||||
|
||||
// Generic scanner indicators
|
||||
'/scanner/i',
|
||||
'/security/i',
|
||||
'/pentest/i',
|
||||
'/audit/i',
|
||||
'/vulnerability/i',
|
||||
'/exploit/i',
|
||||
|
||||
// Command line HTTP clients
|
||||
'/curl/i',
|
||||
'/wget/i',
|
||||
'/http_request/i',
|
||||
'/lwp-request/i',
|
||||
'/python-requests/i',
|
||||
'/python-urllib/i',
|
||||
'/go-http-client/i',
|
||||
'/node-fetch/i',
|
||||
'/axios/i',
|
||||
|
||||
// Scraping and crawling bots (malicious ones)
|
||||
'/scrapy/i',
|
||||
'/beautifulsoup/i',
|
||||
'/mechanize/i',
|
||||
'/selenium/i',
|
||||
'/phantomjs/i',
|
||||
'/headless/i',
|
||||
'/bot/i',
|
||||
'/spider/i',
|
||||
'/crawler/i',
|
||||
|
||||
// SQL injection tools
|
||||
'/havij/i',
|
||||
'/pangolin/i',
|
||||
'/safe3si/i',
|
||||
'/sqlninja/i',
|
||||
'/sqlsus/i',
|
||||
|
||||
// XSS and injection tools
|
||||
'/xsser/i',
|
||||
'/beef/i',
|
||||
'/metasploit/i',
|
||||
'/commix/i',
|
||||
|
||||
// Directory traversal and LFI tools
|
||||
'/fimap/i',
|
||||
'/dotdotpwn/i',
|
||||
'/padbuster/i',
|
||||
|
||||
// Generic attack tools
|
||||
'/hydra/i',
|
||||
'/john/i',
|
||||
'/hashcat/i',
|
||||
'/aircrack/i',
|
||||
'/reaver/i',
|
||||
|
||||
// Suspicious patterns
|
||||
'/\<script/i',
|
||||
'/javascript/i',
|
||||
'/vbscript/i',
|
||||
'/onload/i',
|
||||
'/onerror/i',
|
||||
|
||||
// Empty or very short user agents
|
||||
'/^$/i',
|
||||
'/^.{1,3}$/i',
|
||||
|
||||
// Suspicious single characters or numbers
|
||||
'/^[0-9]{1,2}$/i',
|
||||
'/^[a-z]{1,2}$/i',
|
||||
'/^[\-_\.]{1,3}$/i',
|
||||
|
||||
// Obvious fake user agents
|
||||
'/mozilla\/1\./i',
|
||||
'/mozilla\/2\./i',
|
||||
'/mozilla\/3\./i',
|
||||
'/msie [1-6]\./i',
|
||||
|
||||
// Library and framework indicators in suspicious contexts
|
||||
'/libwww/i',
|
||||
'/winhttp/i',
|
||||
'/java\/1\.[0-4]/i',
|
||||
|
||||
// Reconnaissance tools
|
||||
'/whatweb/i',
|
||||
'/wappalyzer/i',
|
||||
'/builtwith/i',
|
||||
'/httprint/i',
|
||||
'/webtech/i',
|
||||
|
||||
// Load testing tools (potentially abusive)
|
||||
'/apache-httpclient/i',
|
||||
'/jmeter/i',
|
||||
'/siege/i',
|
||||
'/bombardier/i',
|
||||
'/wrk/i',
|
||||
];
|
||||
|
||||
/** Legitimate bot patterns to whitelist */
|
||||
private const LEGITIMATE_BOTS = [
|
||||
'/googlebot/i',
|
||||
'/bingbot/i',
|
||||
'/slurp/i', // Yahoo
|
||||
'/duckduckbot/i',
|
||||
'/baiduspider/i',
|
||||
'/yandexbot/i',
|
||||
'/facebookexternalhit/i',
|
||||
'/twitterbot/i',
|
||||
'/linkedinbot/i',
|
||||
'/whatsapp/i',
|
||||
'/telegrambot/i',
|
||||
'/discordbot/i',
|
||||
'/slackbot/i',
|
||||
'/applebot/i',
|
||||
'/msnbot/i',
|
||||
'/archive\.org_bot/i',
|
||||
'/ia_archiver/i',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new LayerConfig(
|
||||
enabled: true,
|
||||
timeout: Duration::fromMilliseconds(20),
|
||||
confidenceThreshold: Percentage::from(70.0),
|
||||
blockingMode: false, // Usually just flag suspicious agents
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 3
|
||||
);
|
||||
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'suspicious_user_agent';
|
||||
}
|
||||
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$detections = [];
|
||||
|
||||
try {
|
||||
$userAgent = $request->headers->getFirst('User-Agent', '');
|
||||
|
||||
if (empty($userAgent)) {
|
||||
// Missing User-Agent can be suspicious
|
||||
$detections[] = new Detection(
|
||||
category: DetectionCategory::SUSPICIOUS_USER_AGENT,
|
||||
severity: DetectionSeverity::LOW,
|
||||
message: 'Missing User-Agent header',
|
||||
details: [
|
||||
'user_agent' => '',
|
||||
'reason' => 'missing_header',
|
||||
'client_ip' => $request->server->getClientIp()?->value ?? 'unknown',
|
||||
],
|
||||
confidence: 0.5,
|
||||
riskScore: 30.0
|
||||
);
|
||||
} else {
|
||||
// Check for suspicious patterns
|
||||
$detection = $this->analyzeUserAgent($userAgent);
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (! empty($detections)) {
|
||||
return LayerResult::threat(
|
||||
$this->getName(),
|
||||
'Suspicious User-Agent detected',
|
||||
LayerStatus::SUSPICIOUS, // Use SUSPICIOUS instead of THREAT_DETECTED for lower severity
|
||||
$detections,
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
|
||||
return LayerResult::clean(
|
||||
$this->getName(),
|
||||
'User-Agent appears legitimate',
|
||||
$processingTime
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return LayerResult::error(
|
||||
$this->getName(),
|
||||
'User-Agent analysis failed: ' . $e->getMessage(),
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze User-Agent string for suspicious patterns
|
||||
*/
|
||||
private function analyzeUserAgent(string $userAgent): ?Detection
|
||||
{
|
||||
// First check if it's a legitimate bot
|
||||
foreach (self::LEGITIMATE_BOTS as $pattern) {
|
||||
if (preg_match($pattern, $userAgent)) {
|
||||
return null; // Legitimate bot, no detection
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
foreach (self::SUSPICIOUS_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $userAgent, $matches)) {
|
||||
|
||||
$threatType = $this->identifyThreatType($pattern, $matches[0] ?? '');
|
||||
$severity = $this->calculateSeverity($threatType, $userAgent);
|
||||
$riskScore = $this->calculateRiskScore($threatType, $userAgent);
|
||||
|
||||
return new Detection(
|
||||
category: DetectionCategory::SUSPICIOUS_USER_AGENT,
|
||||
severity: $severity,
|
||||
message: "Suspicious User-Agent detected: {$threatType}",
|
||||
details: [
|
||||
'user_agent' => $userAgent,
|
||||
'matched_pattern' => $pattern,
|
||||
'matched_text' => $matches[0] ?? '',
|
||||
'threat_type' => $threatType,
|
||||
'user_agent_length' => strlen($userAgent),
|
||||
'analysis' => $this->analyzeUserAgentStructure($userAgent),
|
||||
],
|
||||
confidence: $this->calculateConfidence($threatType, $userAgent),
|
||||
riskScore: $riskScore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional heuristic checks
|
||||
return $this->performHeuristicAnalysis($userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify threat type based on pattern
|
||||
*/
|
||||
private function identifyThreatType(string $pattern, string $match): string
|
||||
{
|
||||
if (preg_match('/(nikto|nessus|nmap|sqlmap|burp|scanner|security)/i', $match)) {
|
||||
return 'security_scanner';
|
||||
}
|
||||
|
||||
if (preg_match('/(curl|wget|python|go-http)/i', $match)) {
|
||||
return 'automated_client';
|
||||
}
|
||||
|
||||
if (preg_match('/(scrapy|bot|spider|crawler)/i', $match)) {
|
||||
return 'scraping_bot';
|
||||
}
|
||||
|
||||
if (preg_match('/(script|javascript|vbscript|onload)/i', $match)) {
|
||||
return 'xss_attempt';
|
||||
}
|
||||
|
||||
if (preg_match('/(hydra|john|metasploit)/i', $match)) {
|
||||
return 'attack_tool';
|
||||
}
|
||||
|
||||
if (preg_match('/^.{1,3}$/i', $match)) {
|
||||
return 'suspicious_short';
|
||||
}
|
||||
|
||||
if (preg_match('/(mozilla\/[12]\.|msie [1-6]\.)/i', $match)) {
|
||||
return 'fake_browser';
|
||||
}
|
||||
|
||||
return 'unknown_suspicious';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate severity based on threat type
|
||||
*/
|
||||
private function calculateSeverity(string $threatType, string $userAgent): DetectionSeverity
|
||||
{
|
||||
return match ($threatType) {
|
||||
'security_scanner', 'attack_tool', 'xss_attempt' => DetectionSeverity::HIGH,
|
||||
'automated_client', 'scraping_bot', 'fake_browser' => DetectionSeverity::MEDIUM,
|
||||
'suspicious_short', 'unknown_suspicious' => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::LOW
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk score
|
||||
*/
|
||||
private function calculateRiskScore(string $threatType, string $userAgent): float
|
||||
{
|
||||
$baseScore = match ($threatType) {
|
||||
'security_scanner' => 85.0,
|
||||
'attack_tool' => 90.0,
|
||||
'xss_attempt' => 80.0,
|
||||
'automated_client' => 60.0,
|
||||
'scraping_bot' => 50.0,
|
||||
'fake_browser' => 45.0,
|
||||
'suspicious_short' => 35.0,
|
||||
'unknown_suspicious' => 40.0,
|
||||
default => 30.0
|
||||
};
|
||||
|
||||
// Adjust based on additional factors
|
||||
if (strlen($userAgent) < 10) {
|
||||
$baseScore += 10.0;
|
||||
}
|
||||
|
||||
if (preg_match('/[<>"\']/', $userAgent)) {
|
||||
$baseScore += 15.0; // HTML/script injection characters
|
||||
}
|
||||
|
||||
if (preg_match('/\b(admin|root|test|hack|exploit)\b/i', $userAgent)) {
|
||||
$baseScore += 10.0;
|
||||
}
|
||||
|
||||
return min(100.0, $baseScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence based on threat type and additional factors
|
||||
*/
|
||||
private function calculateConfidence(string $threatType, string $userAgent): float
|
||||
{
|
||||
$baseConfidence = match ($threatType) {
|
||||
'security_scanner', 'attack_tool' => 0.9,
|
||||
'automated_client', 'xss_attempt' => 0.8,
|
||||
'scraping_bot', 'fake_browser' => 0.7,
|
||||
'suspicious_short' => 0.6,
|
||||
'unknown_suspicious' => 0.5,
|
||||
default => 0.4
|
||||
};
|
||||
|
||||
// Adjust confidence based on additional evidence
|
||||
if (preg_match('/[<>"\']/', $userAgent)) {
|
||||
$baseConfidence += 0.1; // HTML/script characters increase confidence
|
||||
}
|
||||
|
||||
if (strlen($userAgent) < 5) {
|
||||
$baseConfidence += 0.1; // Very short user agents are more suspicious
|
||||
}
|
||||
|
||||
return min(1.0, $baseConfidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform heuristic analysis for patterns not caught by regex
|
||||
*/
|
||||
private function performHeuristicAnalysis(string $userAgent): ?Detection
|
||||
{
|
||||
// Check for extremely long user agents (potential buffer overflow attempts)
|
||||
if (strlen($userAgent) > 2000) {
|
||||
return new Detection(
|
||||
category: DetectionCategory::SUSPICIOUS_USER_AGENT,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
message: 'Extremely long User-Agent detected',
|
||||
details: [
|
||||
'user_agent' => substr($userAgent, 0, 200) . '...',
|
||||
'user_agent_length' => strlen($userAgent),
|
||||
'threat_type' => 'oversized_header',
|
||||
'reason' => 'potential_overflow_attempt',
|
||||
],
|
||||
confidence: 0.7,
|
||||
riskScore: 65.0
|
||||
);
|
||||
}
|
||||
|
||||
// Check for repeated suspicious characters
|
||||
if (preg_match('/([<>"\'])\1{5,}/', $userAgent)) {
|
||||
return new Detection(
|
||||
category: DetectionCategory::SUSPICIOUS_USER_AGENT,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
message: 'User-Agent contains repeated suspicious characters',
|
||||
details: [
|
||||
'user_agent' => $userAgent,
|
||||
'threat_type' => 'pattern_anomaly',
|
||||
'reason' => 'repeated_special_characters',
|
||||
],
|
||||
confidence: 0.6,
|
||||
riskScore: 55.0
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze User-Agent structure for additional insights
|
||||
*/
|
||||
private function analyzeUserAgentStructure(string $userAgent): array
|
||||
{
|
||||
return [
|
||||
'length' => strlen($userAgent),
|
||||
'has_mozilla' => str_contains(strtolower($userAgent), 'mozilla'),
|
||||
'has_webkit' => str_contains(strtolower($userAgent), 'webkit'),
|
||||
'has_version' => preg_match('/\d+\.\d+/', $userAgent) ? true : false,
|
||||
'special_chars' => preg_match_all('/[<>"\'\(\)\[\]{}]/', $userAgent),
|
||||
'suspicious_keywords' => preg_match_all('/\b(hack|exploit|attack|scan|test|admin|root)\b/i', $userAgent),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->config->get('priority', 20);
|
||||
}
|
||||
|
||||
public function getConfidenceLevel(): Percentage
|
||||
{
|
||||
return Percentage::from($this->config->get('confidence', 0.7) * 100);
|
||||
}
|
||||
|
||||
public function getTimeoutThreshold(): Duration
|
||||
{
|
||||
return Duration::fromMilliseconds($this->config->get('timeout', 20));
|
||||
}
|
||||
|
||||
public function configure(LayerConfig $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function getConfig(): LayerConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function getMetrics(): LayerMetrics
|
||||
{
|
||||
return $this->metrics;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function warmUp(): void
|
||||
{
|
||||
// Pre-compile regex patterns if needed
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
// Cleanup resources
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function supportsParallelProcessing(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
public function getSupportedCategories(): array
|
||||
{
|
||||
return [
|
||||
DetectionCategory::SUSPICIOUS_USER_AGENT,
|
||||
DetectionCategory::MALICIOUS_BOT,
|
||||
DetectionCategory::RECONNAISSANCE,
|
||||
];
|
||||
}
|
||||
}
|
||||
341
src/Framework/Waf/Layers/XssLayer.php
Normal file
341
src/Framework/Waf/Layers/XssLayer.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Layers;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\LayerResult;
|
||||
use App\Framework\Waf\LayerStatus;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\LayerConfig;
|
||||
use App\Framework\Waf\ValueObjects\LayerMetrics;
|
||||
|
||||
/**
|
||||
* Cross-Site Scripting (XSS) Detection Layer
|
||||
*
|
||||
* Detects XSS attempts in request parameters, headers, and body.
|
||||
* Covers reflected, stored, and DOM-based XSS patterns.
|
||||
*/
|
||||
final class XssLayer implements LayerInterface
|
||||
{
|
||||
private LayerConfig $config;
|
||||
|
||||
private LayerMetrics $metrics;
|
||||
|
||||
private bool $enabled = true;
|
||||
|
||||
/** XSS patterns (simplified for initial implementation) */
|
||||
private const XSS_PATTERNS = [
|
||||
// Script tags
|
||||
'/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/i',
|
||||
'/<script\b/i',
|
||||
'/<\/script>/i',
|
||||
|
||||
// Event handlers
|
||||
'/\bon\w+\s*=/i',
|
||||
'/\bonload\s*=/i',
|
||||
'/\bonclick\s*=/i',
|
||||
'/\bonerror\s*=/i',
|
||||
'/\bonmouseover\s*=/i',
|
||||
'/\bonfocus\s*=/i',
|
||||
|
||||
// JavaScript protocols
|
||||
'/javascript\s*:/i',
|
||||
'/vbscript\s*:/i',
|
||||
'/data\s*:/i',
|
||||
|
||||
// HTML entities for script injection
|
||||
'/<script/i',
|
||||
'/><\/script>/i',
|
||||
'/<script/i',
|
||||
'/<script/i',
|
||||
|
||||
// Common XSS vectors
|
||||
'/<iframe\b/i',
|
||||
'/<object\b/i',
|
||||
'/<embed\b/i',
|
||||
'/<applet\b/i',
|
||||
'/<meta\b/i',
|
||||
'/<link\b/i',
|
||||
'/<style\b/i',
|
||||
|
||||
// JavaScript functions
|
||||
'/alert\s*\(/i',
|
||||
'/confirm\s*\(/i',
|
||||
'/prompt\s*\(/i',
|
||||
'/eval\s*\(/i',
|
||||
'/setTimeout\s*\(/i',
|
||||
'/setInterval\s*\(/i',
|
||||
'/document\.(write|writeln)/i',
|
||||
'/window\.(location|open)/i',
|
||||
|
||||
// Encoded XSS attempts
|
||||
'/%3Cscript/i',
|
||||
'/%3C%2Fscript%3E/i',
|
||||
'/\\\x3cscript/i',
|
||||
'/\\\u003cscript/i',
|
||||
|
||||
// Expression() CSS injection
|
||||
'/expression\s*\(/i',
|
||||
'/javascript\s*\:/i',
|
||||
'/-moz-binding/i',
|
||||
|
||||
// SVG XSS vectors
|
||||
'/<svg\b/i',
|
||||
'/onload\s*=/i',
|
||||
'/onclick\s*=/i',
|
||||
|
||||
// Base64 encoded attempts (common patterns)
|
||||
'/PHNjcmlwdA==/i', // <script base64
|
||||
'/YWxlcnQ=/i', // alert base64
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new LayerConfig(
|
||||
enabled: true,
|
||||
timeout: Duration::fromMilliseconds(50),
|
||||
confidenceThreshold: Percentage::from(90.0),
|
||||
blockingMode: true,
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 10
|
||||
);
|
||||
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'xss';
|
||||
}
|
||||
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$detections = [];
|
||||
|
||||
try {
|
||||
// Check query parameters
|
||||
foreach ($request->queryParams as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$detection = $this->analyzeString($value, "query parameter '{$key}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check POST data
|
||||
if (isset($request->parsedBody->data) && is_array($request->parsedBody->data)) {
|
||||
foreach ($request->parsedBody->data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$detection = $this->analyzeString($value, "POST parameter '{$key}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check headers (especially User-Agent and Referer)
|
||||
$suspiciousHeaders = ['User-Agent', 'Referer', 'X-Forwarded-For', 'Cookie'];
|
||||
foreach ($suspiciousHeaders as $headerName) {
|
||||
$headerValue = $request->headers->getFirst($headerName);
|
||||
if ($headerValue) {
|
||||
$detection = $this->analyzeString($headerValue, "header '{$headerName}'");
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check request path
|
||||
$detection = $this->analyzeString($request->path, 'request path');
|
||||
if ($detection) {
|
||||
$detections[] = $detection;
|
||||
}
|
||||
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (! empty($detections)) {
|
||||
return LayerResult::threat(
|
||||
$this->getName(),
|
||||
'XSS attempt detected',
|
||||
LayerStatus::THREAT_DETECTED,
|
||||
$detections,
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
|
||||
return LayerResult::clean(
|
||||
$this->getName(),
|
||||
'No XSS patterns detected',
|
||||
$processingTime
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = Duration::fromMilliseconds((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return LayerResult::error(
|
||||
$this->getName(),
|
||||
'XSS analysis failed: ' . $e->getMessage(),
|
||||
$processingTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze string for XSS patterns
|
||||
*/
|
||||
private function analyzeString(string $input, string $location): ?Detection
|
||||
{
|
||||
$originalInput = $input;
|
||||
|
||||
// Multiple decoding passes to catch double-encoded attempts
|
||||
$input = urldecode($input);
|
||||
$input = html_entity_decode($input, ENT_QUOTES | ENT_HTML5);
|
||||
$input = urldecode($input); // Second pass for double encoding
|
||||
|
||||
foreach (self::XSS_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $input, $matches)) {
|
||||
|
||||
// Calculate severity based on pattern matched
|
||||
$severity = $this->calculateSeverity($pattern, $matches[0] ?? '');
|
||||
|
||||
return new Detection(
|
||||
category: DetectionCategory::XSS,
|
||||
severity: $severity,
|
||||
message: "XSS pattern detected in {$location}",
|
||||
details: [
|
||||
'location' => $location,
|
||||
'pattern' => $pattern,
|
||||
'matched_text' => $matches[0] ?? '',
|
||||
'input_length' => strlen($originalInput),
|
||||
'decoded_input' => substr($input, 0, 200), // Limit for logging
|
||||
'original_input' => substr($originalInput, 0, 200),
|
||||
],
|
||||
confidence: 0.85,
|
||||
riskScore: $this->calculateRiskScore($pattern)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate severity based on XSS pattern
|
||||
*/
|
||||
private function calculateSeverity(string $pattern, string $match): DetectionSeverity
|
||||
{
|
||||
// Critical patterns
|
||||
if (preg_match('/(script|eval|setTimeout|setInterval)/i', $pattern)) {
|
||||
return DetectionSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// High severity patterns
|
||||
if (preg_match('/(javascript|vbscript|on\w+|alert|confirm)/i', $pattern)) {
|
||||
return DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
// Medium severity for other patterns
|
||||
return DetectionSeverity::MEDIUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk score based on pattern
|
||||
*/
|
||||
private function calculateRiskScore(string $pattern): float
|
||||
{
|
||||
// Critical patterns get higher scores
|
||||
if (preg_match('/(script|eval|setTimeout|setInterval)/i', $pattern)) {
|
||||
return 90.0;
|
||||
}
|
||||
|
||||
if (preg_match('/(javascript|vbscript|on\w+)/i', $pattern)) {
|
||||
return 80.0;
|
||||
}
|
||||
|
||||
return 70.0;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return $this->config->get('priority', 90);
|
||||
}
|
||||
|
||||
public function getConfidenceLevel(): Percentage
|
||||
{
|
||||
return Percentage::from($this->config->get('confidence', 0.9) * 100);
|
||||
}
|
||||
|
||||
public function getTimeoutThreshold(): Duration
|
||||
{
|
||||
return Duration::fromMilliseconds($this->config->get('timeout', 50));
|
||||
}
|
||||
|
||||
public function configure(LayerConfig $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function getConfig(): LayerConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function getMetrics(): LayerMetrics
|
||||
{
|
||||
return $this->metrics;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = new LayerMetrics();
|
||||
}
|
||||
|
||||
public function warmUp(): void
|
||||
{
|
||||
// Pre-compile regex patterns if needed
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
// Cleanup resources
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function supportsParallelProcessing(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
public function getSupportedCategories(): array
|
||||
{
|
||||
return [DetectionCategory::XSS, DetectionCategory::INJECTION];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\AnomalyDetection;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorBaseline;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Interface for anomaly detection algorithms
|
||||
*/
|
||||
interface AnomalyDetectorInterface
|
||||
{
|
||||
/**
|
||||
* Get detector name
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Get supported behavior types
|
||||
*/
|
||||
public function getSupportedBehaviorTypes(): array;
|
||||
|
||||
/**
|
||||
* Check if detector can analyze the given features
|
||||
*/
|
||||
public function canAnalyze(array $features): bool;
|
||||
|
||||
/**
|
||||
* Detect anomalies in behavioral features
|
||||
*
|
||||
* @param BehaviorFeature[] $features
|
||||
* @return AnomalyDetection[]
|
||||
*/
|
||||
public function detectAnomalies(array $features, ?BehaviorBaseline $baseline = null): array;
|
||||
|
||||
/**
|
||||
* Update detector model with new data
|
||||
*
|
||||
* @param BehaviorFeature[] $features
|
||||
*/
|
||||
public function updateModel(array $features): void;
|
||||
|
||||
/**
|
||||
* Get detector configuration
|
||||
*/
|
||||
public function getConfiguration(): array;
|
||||
|
||||
/**
|
||||
* Check if detector is enabled
|
||||
*/
|
||||
public function isEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Get detector confidence threshold
|
||||
*/
|
||||
public function getConfidenceThreshold(): float;
|
||||
|
||||
/**
|
||||
* Set detector confidence threshold
|
||||
*/
|
||||
public function setConfidenceThreshold(float $threshold): void;
|
||||
|
||||
/**
|
||||
* Get expected processing time in milliseconds
|
||||
*/
|
||||
public function getExpectedProcessingTime(): int;
|
||||
|
||||
/**
|
||||
* Check if detector supports real-time analysis
|
||||
*/
|
||||
public function supportsRealTime(): bool;
|
||||
}
|
||||
147
src/Framework/Waf/MachineLearning/AnomalyType.php
Normal file
147
src/Framework/Waf/MachineLearning/AnomalyType.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
/**
|
||||
* Types of behavioral anomalies detected by ML analysis
|
||||
*/
|
||||
enum AnomalyType: string
|
||||
{
|
||||
case FREQUENCY_SPIKE = 'frequency_spike';
|
||||
case UNUSUAL_PATTERN = 'unusual_pattern';
|
||||
case GEOGRAPHIC_ANOMALY = 'geographic_anomaly';
|
||||
case TEMPORAL_ANOMALY = 'temporal_anomaly';
|
||||
case BEHAVIORAL_DRIFT = 'behavioral_drift';
|
||||
case OUTLIER_DETECTION = 'outlier_detection';
|
||||
case CLUSTERING_DEVIATION = 'clustering_deviation';
|
||||
case STATISTICAL_ANOMALY = 'statistical_anomaly';
|
||||
case SEQUENCE_ANOMALY = 'sequence_anomaly';
|
||||
case CORRELATION_BREAK = 'correlation_break';
|
||||
case CLUSTER_DEVIATION = 'cluster_deviation';
|
||||
case DENSITY_ANOMALY = 'density_anomaly';
|
||||
case GROUP_ANOMALY = 'group_anomaly';
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FREQUENCY_SPIKE => 'Unusual spike in request frequency',
|
||||
self::UNUSUAL_PATTERN => 'Deviation from established behavior patterns',
|
||||
self::GEOGRAPHIC_ANOMALY => 'Unusual geographic access patterns',
|
||||
self::TEMPORAL_ANOMALY => 'Abnormal timing or temporal patterns',
|
||||
self::BEHAVIORAL_DRIFT => 'Gradual shift in behavioral baseline',
|
||||
self::OUTLIER_DETECTION => 'Statistical outlier in behavior metrics',
|
||||
self::CLUSTERING_DEVIATION => 'Deviation from normal clustering patterns',
|
||||
self::STATISTICAL_ANOMALY => 'Statistical significance in behavior change',
|
||||
self::SEQUENCE_ANOMALY => 'Unusual sequence or order of actions',
|
||||
self::CORRELATION_BREAK => 'Break in normal correlation patterns',
|
||||
self::CLUSTER_DEVIATION => 'Deviation from cluster patterns',
|
||||
self::DENSITY_ANOMALY => 'Abnormal density in feature space',
|
||||
self::GROUP_ANOMALY => 'Anomalous group behavior patterns'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default confidence threshold for this anomaly type
|
||||
*/
|
||||
public function getConfidenceThreshold(): float
|
||||
{
|
||||
return match ($this) {
|
||||
self::FREQUENCY_SPIKE => 0.85,
|
||||
self::UNUSUAL_PATTERN => 0.75,
|
||||
self::GEOGRAPHIC_ANOMALY => 0.80,
|
||||
self::TEMPORAL_ANOMALY => 0.70,
|
||||
self::BEHAVIORAL_DRIFT => 0.60,
|
||||
self::OUTLIER_DETECTION => 0.85,
|
||||
self::CLUSTERING_DEVIATION => 0.75,
|
||||
self::STATISTICAL_ANOMALY => 0.90,
|
||||
self::SEQUENCE_ANOMALY => 0.80,
|
||||
self::CORRELATION_BREAK => 0.75,
|
||||
self::CLUSTER_DEVIATION => 0.80,
|
||||
self::DENSITY_ANOMALY => 0.85,
|
||||
self::GROUP_ANOMALY => 0.75
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for this anomaly type
|
||||
*/
|
||||
public function getSeverityLevel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FREQUENCY_SPIKE => 'high',
|
||||
self::UNUSUAL_PATTERN => 'medium',
|
||||
self::GEOGRAPHIC_ANOMALY => 'medium',
|
||||
self::TEMPORAL_ANOMALY => 'low',
|
||||
self::BEHAVIORAL_DRIFT => 'low',
|
||||
self::OUTLIER_DETECTION => 'medium',
|
||||
self::CLUSTERING_DEVIATION => 'medium',
|
||||
self::STATISTICAL_ANOMALY => 'high',
|
||||
self::SEQUENCE_ANOMALY => 'medium',
|
||||
self::CORRELATION_BREAK => 'medium',
|
||||
self::CLUSTER_DEVIATION => 'medium',
|
||||
self::DENSITY_ANOMALY => 'high',
|
||||
self::GROUP_ANOMALY => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this anomaly requires immediate action
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::FREQUENCY_SPIKE,
|
||||
self::STATISTICAL_ANOMALY => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended action for this anomaly type
|
||||
*/
|
||||
public function getRecommendedAction(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FREQUENCY_SPIKE => 'Rate limiting, temporary blocking',
|
||||
self::UNUSUAL_PATTERN => 'Enhanced monitoring, pattern analysis',
|
||||
self::GEOGRAPHIC_ANOMALY => 'Geographic verification, location analysis',
|
||||
self::TEMPORAL_ANOMALY => 'Schedule analysis, time-based rules',
|
||||
self::BEHAVIORAL_DRIFT => 'Baseline update, long-term monitoring',
|
||||
self::OUTLIER_DETECTION => 'Detailed analysis, manual review',
|
||||
self::CLUSTERING_DEVIATION => 'Cluster analysis, behavior profiling',
|
||||
self::STATISTICAL_ANOMALY => 'Immediate investigation, possible blocking',
|
||||
self::SEQUENCE_ANOMALY => 'Sequence analysis, flow monitoring',
|
||||
self::CORRELATION_BREAK => 'Correlation analysis, relationship mapping',
|
||||
self::CLUSTER_DEVIATION => 'Cluster reanalysis, pattern adjustment',
|
||||
self::DENSITY_ANOMALY => 'Density analysis, space examination',
|
||||
self::GROUP_ANOMALY => 'Group analysis, collective behavior review'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analysis complexity for this anomaly type
|
||||
*/
|
||||
public function getAnalysisComplexity(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FREQUENCY_SPIKE => 'low',
|
||||
self::UNUSUAL_PATTERN => 'high',
|
||||
self::GEOGRAPHIC_ANOMALY => 'medium',
|
||||
self::TEMPORAL_ANOMALY => 'medium',
|
||||
self::BEHAVIORAL_DRIFT => 'high',
|
||||
self::OUTLIER_DETECTION => 'medium',
|
||||
self::CLUSTERING_DEVIATION => 'high',
|
||||
self::STATISTICAL_ANOMALY => 'medium',
|
||||
self::SEQUENCE_ANOMALY => 'high',
|
||||
self::CORRELATION_BREAK => 'high',
|
||||
self::CLUSTER_DEVIATION => 'high',
|
||||
self::DENSITY_ANOMALY => 'high',
|
||||
self::GROUP_ANOMALY => 'medium'
|
||||
};
|
||||
}
|
||||
}
|
||||
529
src/Framework/Waf/MachineLearning/BaselineManager.php
Normal file
529
src/Framework/Waf/MachineLearning/BaselineManager.php
Normal file
@@ -0,0 +1,529 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorBaseline;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Manages behavioral baselines for anomaly detection
|
||||
*/
|
||||
final class BaselineManager
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Clock $clock,
|
||||
private readonly Duration $baselineUpdateInterval,
|
||||
private readonly Duration $baselineMaxAge,
|
||||
private readonly int $minSamplesForBaseline = 50,
|
||||
private readonly int $maxSamplesPerBaseline = 10000,
|
||||
private readonly float $learningRate = 0.1,
|
||||
private readonly bool $enableAdaptiveBaselines = true,
|
||||
private readonly bool $enableSeasonalAdjustment = true,
|
||||
private array $baselines = [],
|
||||
private array $featureHistory = [],
|
||||
private array $updateTimestamps = [],
|
||||
private array $performanceMetrics = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get baseline for a specific behavior type and feature
|
||||
*/
|
||||
public function getBaseline(BehaviorType $behaviorType, string $featureName = 'default'): ?BehaviorBaseline
|
||||
{
|
||||
$key = $this->generateBaselineKey($behaviorType, $featureName);
|
||||
|
||||
if (! isset($this->baselines[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseline = $this->baselines[$key];
|
||||
|
||||
// Check if baseline is too old
|
||||
if ($this->isBaselineExpired($baseline)) {
|
||||
unset($this->baselines[$key]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update baseline with new feature data
|
||||
*/
|
||||
public function updateBaseline(BehaviorFeature $feature): void
|
||||
{
|
||||
$key = $this->generateBaselineKey($feature->type, $feature->name);
|
||||
|
||||
// Record feature in history
|
||||
$this->recordFeature($key, $feature);
|
||||
|
||||
// Check if update is needed
|
||||
if (! $this->shouldUpdateBaseline($key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingBaseline = $this->baselines[$key] ?? null;
|
||||
$featureHistory = $this->featureHistory[$key] ?? [];
|
||||
|
||||
if (count($featureHistory) < $this->minSamplesForBaseline) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create or update baseline
|
||||
if ($existingBaseline === null) {
|
||||
$this->baselines[$key] = $this->createInitialBaseline($feature->type, $feature->name, $featureHistory);
|
||||
} else {
|
||||
$this->baselines[$key] = $this->updateExistingBaseline($existingBaseline, $featureHistory);
|
||||
}
|
||||
|
||||
$this->updateTimestamps[$key] = $this->clock->time();
|
||||
|
||||
// Record performance metrics
|
||||
$this->recordBaselineUpdate($key, count($featureHistory));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update baseline incrementally with new feature
|
||||
*/
|
||||
public function updateBaselineIncremental(BehaviorFeature $feature): void
|
||||
{
|
||||
$key = $this->generateBaselineKey($feature->type, $feature->name);
|
||||
$existingBaseline = $this->baselines[$key] ?? null;
|
||||
|
||||
if ($existingBaseline === null) {
|
||||
// Need enough samples for initial baseline
|
||||
$this->updateBaseline($feature);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Incremental update using exponential moving average
|
||||
$newBaseline = $this->incrementalUpdate($existingBaseline, $feature);
|
||||
$this->baselines[$key] = $newBaseline;
|
||||
$this->updateTimestamps[$key] = $this->clock->time();
|
||||
|
||||
// Record feature for history
|
||||
$this->recordFeature($key, $feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all baselines for a behavior type
|
||||
*/
|
||||
public function getBaselinesForBehaviorType(BehaviorType $behaviorType): array
|
||||
{
|
||||
$baselines = [];
|
||||
|
||||
foreach ($this->baselines as $key => $baseline) {
|
||||
if ($baseline->type === $behaviorType && ! $this->isBaselineExpired($baseline)) {
|
||||
$baselines[$key] = $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
return $baselines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired baselines
|
||||
*/
|
||||
public function cleanExpiredBaselines(): int
|
||||
{
|
||||
$removedCount = 0;
|
||||
|
||||
foreach ($this->baselines as $key => $baseline) {
|
||||
if ($this->isBaselineExpired($baseline)) {
|
||||
unset($this->baselines[$key]);
|
||||
unset($this->featureHistory[$key]);
|
||||
unset($this->updateTimestamps[$key]);
|
||||
$removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get baseline statistics
|
||||
*/
|
||||
public function getBaselineStats(): array
|
||||
{
|
||||
$totalBaselines = count($this->baselines);
|
||||
$expiredBaselines = 0;
|
||||
$avgSampleSize = 0;
|
||||
$avgAge = 0;
|
||||
|
||||
$now = $this->clock->time();
|
||||
|
||||
foreach ($this->baselines as $baseline) {
|
||||
if ($this->isBaselineExpired($baseline)) {
|
||||
$expiredBaselines++;
|
||||
}
|
||||
|
||||
$avgSampleSize += $baseline->sampleSize;
|
||||
$age = $baseline->lastUpdated->diff($now);
|
||||
$avgAge += $age->toSeconds();
|
||||
}
|
||||
|
||||
if ($totalBaselines > 0) {
|
||||
$avgSampleSize /= $totalBaselines;
|
||||
$avgAge /= $totalBaselines;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_baselines' => $totalBaselines,
|
||||
'expired_baselines' => $expiredBaselines,
|
||||
'active_baselines' => $totalBaselines - $expiredBaselines,
|
||||
'avg_sample_size' => $avgSampleSize,
|
||||
'avg_age_seconds' => $avgAge,
|
||||
'behavior_types' => $this->getBaselineBehaviorTypes(),
|
||||
'feature_history_size' => array_sum(array_map('count', $this->featureHistory)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial baseline from feature history
|
||||
*/
|
||||
private function createInitialBaseline(BehaviorType $behaviorType, string $featureName, array $featureHistory): BehaviorBaseline
|
||||
{
|
||||
$values = array_map(fn (BehaviorFeature $f) => $f->value, $featureHistory);
|
||||
|
||||
$stats = $this->calculateStatistics($values);
|
||||
$confidence = $this->calculateBaselineConfidence($stats['sample_size']);
|
||||
|
||||
// Apply seasonal adjustment if enabled
|
||||
if ($this->enableSeasonalAdjustment) {
|
||||
$stats = $this->applySeasonalAdjustment($stats, $featureHistory);
|
||||
}
|
||||
|
||||
return new BehaviorBaseline(
|
||||
type: $behaviorType,
|
||||
mean: $stats['mean'],
|
||||
standardDeviation: $stats['std_dev'],
|
||||
sampleSize: $stats['sample_size'],
|
||||
p50: $stats['p50'],
|
||||
p95: $stats['p95'],
|
||||
p99: $stats['p99'],
|
||||
confidence: $confidence,
|
||||
lastUpdated: $this->clock->time(),
|
||||
metadata: [
|
||||
'feature_name' => $featureName,
|
||||
'creation_method' => 'initial',
|
||||
'seasonal_adjusted' => $this->enableSeasonalAdjustment,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing baseline with new data
|
||||
*/
|
||||
private function updateExistingBaseline(BehaviorBaseline $existingBaseline, array $featureHistory): BehaviorBaseline
|
||||
{
|
||||
if (! $this->enableAdaptiveBaselines) {
|
||||
return $existingBaseline;
|
||||
}
|
||||
|
||||
$values = array_map(fn (BehaviorFeature $f) => $f->value, $featureHistory);
|
||||
$newStats = $this->calculateStatistics($values);
|
||||
|
||||
// Adaptive learning rate based on sample size and confidence
|
||||
$adaptiveLearningRate = $this->calculateAdaptiveLearningRate($existingBaseline, $newStats['sample_size']);
|
||||
|
||||
// Exponential moving average update
|
||||
$updatedMean = $existingBaseline->mean * (1 - $adaptiveLearningRate) + $newStats['mean'] * $adaptiveLearningRate;
|
||||
$updatedStdDev = $existingBaseline->standardDeviation * (1 - $adaptiveLearningRate) + $newStats['std_dev'] * $adaptiveLearningRate;
|
||||
|
||||
// Update percentiles with recent data
|
||||
$combinedValues = array_slice($values, -$this->maxSamplesPerBaseline);
|
||||
sort($combinedValues);
|
||||
|
||||
$p50 = $this->calculatePercentile($combinedValues, 50);
|
||||
$p95 = $this->calculatePercentile($combinedValues, 95);
|
||||
$p99 = $this->calculatePercentile($combinedValues, 99);
|
||||
|
||||
$newSampleSize = min($existingBaseline->sampleSize + $newStats['sample_size'], $this->maxSamplesPerBaseline);
|
||||
$confidence = $this->calculateBaselineConfidence($newSampleSize);
|
||||
|
||||
return new BehaviorBaseline(
|
||||
type: $existingBaseline->type,
|
||||
mean: $updatedMean,
|
||||
standardDeviation: $updatedStdDev,
|
||||
sampleSize: $newSampleSize,
|
||||
p50: $p50,
|
||||
p95: $p95,
|
||||
p99: $p99,
|
||||
confidence: $confidence,
|
||||
lastUpdated: $this->clock->time(),
|
||||
metadata: array_merge($existingBaseline->metadata, [
|
||||
'update_method' => 'adaptive',
|
||||
'learning_rate' => $adaptiveLearningRate,
|
||||
'updates_count' => ($existingBaseline->metadata['updates_count'] ?? 0) + 1,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental update using exponential moving average
|
||||
*/
|
||||
private function incrementalUpdate(BehaviorBaseline $baseline, BehaviorFeature $newFeature): BehaviorBaseline
|
||||
{
|
||||
$learningRate = $this->calculateAdaptiveLearningRate($baseline, 1);
|
||||
|
||||
$updatedMean = $baseline->mean * (1 - $learningRate) + $newFeature->value * $learningRate;
|
||||
|
||||
// Update variance using Welford's online algorithm
|
||||
$delta = $newFeature->value - $baseline->mean;
|
||||
$delta2 = $newFeature->value - $updatedMean;
|
||||
$variance = pow($baseline->standardDeviation, 2);
|
||||
$updatedVariance = $variance * (1 - $learningRate) + $delta * $delta2 * $learningRate;
|
||||
$updatedStdDev = sqrt(max(0, $updatedVariance));
|
||||
|
||||
return new BehaviorBaseline(
|
||||
type: $baseline->type,
|
||||
mean: $updatedMean,
|
||||
standardDeviation: $updatedStdDev,
|
||||
sampleSize: $baseline->sampleSize + 1,
|
||||
p50: $baseline->p50, // Keep existing percentiles for incremental updates
|
||||
p95: $baseline->p95,
|
||||
p99: $baseline->p99,
|
||||
confidence: $baseline->confidence,
|
||||
lastUpdated: $this->clock->time(),
|
||||
metadata: array_merge($baseline->metadata, [
|
||||
'update_method' => 'incremental',
|
||||
'last_value' => $newFeature->value,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate statistics from values
|
||||
*/
|
||||
private function calculateStatistics(array $values): array
|
||||
{
|
||||
if (empty($values)) {
|
||||
return [
|
||||
'mean' => 0.0,
|
||||
'std_dev' => 0.0,
|
||||
'sample_size' => 0,
|
||||
'p50' => 0.0,
|
||||
'p95' => 0.0,
|
||||
'p99' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$mean = array_sum($values) / count($values);
|
||||
$variance = array_sum(array_map(fn ($v) => pow($v - $mean, 2), $values)) / count($values);
|
||||
$stdDev = sqrt($variance);
|
||||
|
||||
sort($values);
|
||||
|
||||
return [
|
||||
'mean' => $mean,
|
||||
'std_dev' => $stdDev,
|
||||
'sample_size' => count($values),
|
||||
'p50' => $this->calculatePercentile($values, 50),
|
||||
'p95' => $this->calculatePercentile($values, 95),
|
||||
'p99' => $this->calculatePercentile($values, 99),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentile from sorted values
|
||||
*/
|
||||
private function calculatePercentile(array $sortedValues, float $percentile): float
|
||||
{
|
||||
if (empty($sortedValues)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$index = ($percentile / 100) * (count($sortedValues) - 1);
|
||||
$lower = (int)floor($index);
|
||||
$upper = (int)ceil($index);
|
||||
|
||||
if ($lower === $upper) {
|
||||
return $sortedValues[$lower];
|
||||
}
|
||||
|
||||
$weight = $index - $lower;
|
||||
|
||||
return $sortedValues[$lower] * (1 - $weight) + $sortedValues[$upper] * $weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate adaptive learning rate
|
||||
*/
|
||||
private function calculateAdaptiveLearningRate(BehaviorBaseline $baseline, int $newSamples): float
|
||||
{
|
||||
// Decrease learning rate as confidence increases
|
||||
$confidenceFactor = 1.0 - ($baseline->confidence->getValue() / 100.0);
|
||||
|
||||
// Increase learning rate for more new samples
|
||||
$sampleFactor = min(1.0, $newSamples / 10.0);
|
||||
|
||||
return $this->learningRate * $confidenceFactor * $sampleFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate baseline confidence based on sample size
|
||||
*/
|
||||
private function calculateBaselineConfidence(int $sampleSize): Percentage
|
||||
{
|
||||
// Confidence increases with sample size, plateaus at 95%
|
||||
$confidence = min(95.0, ($sampleSize / $this->minSamplesForBaseline) * 75.0);
|
||||
|
||||
return new Percentage(max(0.0, $confidence));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply seasonal adjustment to statistics
|
||||
*/
|
||||
private function applySeasonalAdjustment(array $stats, array $featureHistory): array
|
||||
{
|
||||
// Simple seasonal adjustment based on time of day/week patterns
|
||||
$hourCounts = array_fill(0, 24, 0);
|
||||
$dayOfWeekCounts = array_fill(0, 7, 0);
|
||||
|
||||
foreach ($featureHistory as $feature) {
|
||||
if ($feature->timestamp !== null) {
|
||||
$hour = (int)$feature->timestamp->format('H');
|
||||
$dayOfWeek = (int)$feature->timestamp->format('w');
|
||||
|
||||
$hourCounts[$hour]++;
|
||||
$dayOfWeekCounts[$dayOfWeek]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate seasonal factors (simplified)
|
||||
$currentHour = (int)$this->clock->time()->format('H');
|
||||
$currentDayOfWeek = (int)$this->clock->time()->format('w');
|
||||
|
||||
$avgHourlyCount = array_sum($hourCounts) / 24;
|
||||
$avgDailyCount = array_sum($dayOfWeekCounts) / 7;
|
||||
|
||||
$hourlyFactor = $avgHourlyCount > 0 ? $hourCounts[$currentHour] / $avgHourlyCount : 1.0;
|
||||
$dailyFactor = $avgDailyCount > 0 ? $dayOfWeekCounts[$currentDayOfWeek] / $avgDailyCount : 1.0;
|
||||
|
||||
// Apply seasonal adjustment (conservative)
|
||||
$seasonalFactor = ($hourlyFactor + $dailyFactor) / 2;
|
||||
$adjustmentWeight = 0.1; // Limit seasonal impact
|
||||
|
||||
$stats['mean'] *= (1 + ($seasonalFactor - 1) * $adjustmentWeight);
|
||||
$stats['std_dev'] *= (1 + abs($seasonalFactor - 1) * $adjustmentWeight);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record feature in history
|
||||
*/
|
||||
private function recordFeature(string $key, BehaviorFeature $feature): void
|
||||
{
|
||||
if (! isset($this->featureHistory[$key])) {
|
||||
$this->featureHistory[$key] = [];
|
||||
}
|
||||
|
||||
$this->featureHistory[$key][] = $feature;
|
||||
|
||||
// Limit history size
|
||||
if (count($this->featureHistory[$key]) > $this->maxSamplesPerBaseline) {
|
||||
array_shift($this->featureHistory[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if baseline should be updated
|
||||
*/
|
||||
private function shouldUpdateBaseline(string $key): bool
|
||||
{
|
||||
$lastUpdate = $this->updateTimestamps[$key] ?? null;
|
||||
|
||||
if ($lastUpdate === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$timeSinceUpdate = $lastUpdate->diff($this->clock->time());
|
||||
|
||||
return $timeSinceUpdate->toMilliseconds() >= $this->baselineUpdateInterval->toMilliseconds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if baseline is expired
|
||||
*/
|
||||
private function isBaselineExpired(BehaviorBaseline $baseline): bool
|
||||
{
|
||||
$age = $baseline->lastUpdated->diff($this->clock->time());
|
||||
|
||||
return $age->toMilliseconds() > $this->baselineMaxAge->toMilliseconds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate baseline key
|
||||
*/
|
||||
private function generateBaselineKey(BehaviorType $behaviorType, string $featureName): string
|
||||
{
|
||||
return $behaviorType->value . ':' . $featureName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get behavior types with baselines
|
||||
*/
|
||||
private function getBaselineBehaviorTypes(): array
|
||||
{
|
||||
$types = [];
|
||||
|
||||
foreach ($this->baselines as $baseline) {
|
||||
$types[] = $baseline->type->value;
|
||||
}
|
||||
|
||||
return array_unique($types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record baseline update metrics
|
||||
*/
|
||||
private function recordBaselineUpdate(string $key, int $sampleCount): void
|
||||
{
|
||||
$this->performanceMetrics[] = [
|
||||
'timestamp' => $this->clock->time()->toUnixTimestamp(),
|
||||
'baseline_key' => $key,
|
||||
'sample_count' => $sampleCount,
|
||||
'operation' => 'update',
|
||||
];
|
||||
|
||||
// Limit metrics history
|
||||
if (count($this->performanceMetrics) > 1000) {
|
||||
array_shift($this->performanceMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance metrics
|
||||
*/
|
||||
public function getPerformanceMetrics(): array
|
||||
{
|
||||
return $this->performanceMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration
|
||||
*/
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'baseline_update_interval_ms' => $this->baselineUpdateInterval->toMilliseconds(),
|
||||
'baseline_max_age_ms' => $this->baselineMaxAge->toMilliseconds(),
|
||||
'min_samples_for_baseline' => $this->minSamplesForBaseline,
|
||||
'max_samples_per_baseline' => $this->maxSamplesPerBaseline,
|
||||
'learning_rate' => $this->learningRate,
|
||||
'enable_adaptive_baselines' => $this->enableAdaptiveBaselines,
|
||||
'enable_seasonal_adjustment' => $this->enableSeasonalAdjustment,
|
||||
'active_baselines' => count($this->baselines),
|
||||
'total_feature_history' => array_sum(array_map('count', $this->featureHistory)),
|
||||
];
|
||||
}
|
||||
}
|
||||
145
src/Framework/Waf/MachineLearning/BehaviorType.php
Normal file
145
src/Framework/Waf/MachineLearning/BehaviorType.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
/**
|
||||
* Types of behavioral patterns for ML analysis
|
||||
*/
|
||||
enum BehaviorType: string
|
||||
{
|
||||
case REQUEST_FREQUENCY = 'request_frequency';
|
||||
case PATH_PATTERNS = 'path_patterns';
|
||||
case PARAMETER_PATTERNS = 'parameter_patterns';
|
||||
case USER_AGENT_PATTERNS = 'user_agent_patterns';
|
||||
case GEOGRAPHIC_PATTERNS = 'geographic_patterns';
|
||||
case TIME_PATTERNS = 'time_patterns';
|
||||
case ERROR_PATTERNS = 'error_patterns';
|
||||
case SESSION_PATTERNS = 'session_patterns';
|
||||
case PAYLOAD_PATTERNS = 'payload_patterns';
|
||||
case HEADER_PATTERNS = 'header_patterns';
|
||||
case RESPONSE_TIME_PATTERNS = 'response_time_patterns';
|
||||
case PROTOCOL_PATTERNS = 'protocol_patterns';
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::REQUEST_FREQUENCY => 'Request frequency and rate patterns',
|
||||
self::PATH_PATTERNS => 'URL path access patterns',
|
||||
self::PARAMETER_PATTERNS => 'Parameter usage and value patterns',
|
||||
self::USER_AGENT_PATTERNS => 'User-Agent behavior patterns',
|
||||
self::GEOGRAPHIC_PATTERNS => 'Geographic location patterns',
|
||||
self::TIME_PATTERNS => 'Temporal behavior patterns',
|
||||
self::ERROR_PATTERNS => 'Error generation patterns',
|
||||
self::SESSION_PATTERNS => 'Session management patterns',
|
||||
self::PAYLOAD_PATTERNS => 'Request payload patterns',
|
||||
self::HEADER_PATTERNS => 'HTTP header usage patterns',
|
||||
self::RESPONSE_TIME_PATTERNS => 'Response time behavior patterns',
|
||||
self::PROTOCOL_PATTERNS => 'Protocol usage patterns'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature extraction weight for this behavior type
|
||||
*/
|
||||
public function getWeight(): float
|
||||
{
|
||||
return match ($this) {
|
||||
self::REQUEST_FREQUENCY => 0.15,
|
||||
self::PATH_PATTERNS => 0.12,
|
||||
self::PARAMETER_PATTERNS => 0.10,
|
||||
self::USER_AGENT_PATTERNS => 0.08,
|
||||
self::GEOGRAPHIC_PATTERNS => 0.08,
|
||||
self::TIME_PATTERNS => 0.10,
|
||||
self::ERROR_PATTERNS => 0.12,
|
||||
self::SESSION_PATTERNS => 0.10,
|
||||
self::PAYLOAD_PATTERNS => 0.08,
|
||||
self::HEADER_PATTERNS => 0.05,
|
||||
self::RESPONSE_TIME_PATTERNS => 0.07,
|
||||
self::PROTOCOL_PATTERNS => 0.05
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum sample size needed for reliable analysis
|
||||
*/
|
||||
public function getMinSampleSize(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::REQUEST_FREQUENCY => 50,
|
||||
self::PATH_PATTERNS => 30,
|
||||
self::PARAMETER_PATTERNS => 20,
|
||||
self::USER_AGENT_PATTERNS => 10,
|
||||
self::GEOGRAPHIC_PATTERNS => 15,
|
||||
self::TIME_PATTERNS => 100,
|
||||
self::ERROR_PATTERNS => 25,
|
||||
self::SESSION_PATTERNS => 40,
|
||||
self::PAYLOAD_PATTERNS => 15,
|
||||
self::HEADER_PATTERNS => 20,
|
||||
self::RESPONSE_TIME_PATTERNS => 50,
|
||||
self::PROTOCOL_PATTERNS => 10
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analysis window duration in seconds
|
||||
*/
|
||||
public function getAnalysisWindow(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::REQUEST_FREQUENCY => 300, // 5 minutes
|
||||
self::PATH_PATTERNS => 1800, // 30 minutes
|
||||
self::PARAMETER_PATTERNS => 900, // 15 minutes
|
||||
self::USER_AGENT_PATTERNS => 3600, // 1 hour
|
||||
self::GEOGRAPHIC_PATTERNS => 7200, // 2 hours
|
||||
self::TIME_PATTERNS => 86400, // 24 hours
|
||||
self::ERROR_PATTERNS => 600, // 10 minutes
|
||||
self::SESSION_PATTERNS => 1800, // 30 minutes
|
||||
self::PAYLOAD_PATTERNS => 900, // 15 minutes
|
||||
self::HEADER_PATTERNS => 1800, // 30 minutes
|
||||
self::RESPONSE_TIME_PATTERNS => 600, // 10 minutes
|
||||
self::PROTOCOL_PATTERNS => 3600 // 1 hour
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this behavior type requires real-time analysis
|
||||
*/
|
||||
public function requiresRealTime(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::REQUEST_FREQUENCY,
|
||||
self::ERROR_PATTERNS,
|
||||
self::RESPONSE_TIME_PATTERNS => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all behavior types as array
|
||||
*/
|
||||
public static function getAll(): array
|
||||
{
|
||||
return array_map(fn ($case) => $case->value, self::cases());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time behavior types
|
||||
*/
|
||||
public static function getRealTime(): array
|
||||
{
|
||||
return array_filter(self::cases(), fn ($case) => $case->requiresRealTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch analysis behavior types
|
||||
*/
|
||||
public static function getBatch(): array
|
||||
{
|
||||
return array_filter(self::cases(), fn ($case) => ! $case->requiresRealTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Interface for anomaly detectors that support confidence adjustments
|
||||
*/
|
||||
interface ConfidenceAdjustableInterface
|
||||
{
|
||||
/**
|
||||
* Adjust the confidence calculation by the specified percentage
|
||||
*
|
||||
* Positive adjustment: Increase confidence (more certain about detections)
|
||||
* Negative adjustment: Decrease confidence (less certain about detections)
|
||||
*
|
||||
* @param Percentage $adjustment The percentage to adjust the confidence by
|
||||
* @return bool True if adjustment was successful
|
||||
*/
|
||||
public function adjustConfidence(Percentage $adjustment): bool;
|
||||
|
||||
/**
|
||||
* Get the current confidence multiplier
|
||||
*
|
||||
* @return float The current confidence multiplier
|
||||
*/
|
||||
public function getConfidenceMultiplier(): float;
|
||||
|
||||
/**
|
||||
* Set the confidence multiplier to a specific value
|
||||
*
|
||||
* @param float $multiplier The new confidence multiplier
|
||||
* @return bool True if setting was successful
|
||||
*/
|
||||
public function setConfidenceMultiplier(float $multiplier): bool;
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\Detectors;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Waf\MachineLearning\AnomalyDetectorInterface;
|
||||
use App\Framework\Waf\MachineLearning\AnomalyType;
|
||||
use App\Framework\Waf\MachineLearning\BehaviorType;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\AnomalyDetection;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorBaseline;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Clustering-based anomaly detector using K-means and density-based methods
|
||||
*/
|
||||
final class ClusteringAnomalyDetector implements AnomalyDetectorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Clock $clock,
|
||||
private readonly bool $enabled = true,
|
||||
private float $confidenceThreshold = 0.70,
|
||||
private readonly int $maxClusters = 10,
|
||||
private readonly int $minClusterSize = 5,
|
||||
private readonly float $outlierThreshold = 2.0,
|
||||
private readonly int $maxIterations = 50,
|
||||
private readonly float $convergenceThreshold = 0.001,
|
||||
private readonly bool $enableDensityAnalysis = true,
|
||||
private readonly bool $enableGroupAnomalyDetection = true,
|
||||
private array $clusterCenters = [],
|
||||
private array $clusterAssignments = [],
|
||||
private array $featureVectors = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Clustering Anomaly Detector';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<BehaviorType>
|
||||
*/
|
||||
public function getSupportedBehaviorTypes(): array
|
||||
{
|
||||
return [
|
||||
BehaviorType::REQUEST_FREQUENCY,
|
||||
BehaviorType::PATH_PATTERNS,
|
||||
BehaviorType::PARAMETER_PATTERNS,
|
||||
BehaviorType::USER_AGENT_PATTERNS,
|
||||
BehaviorType::GEOGRAPHIC_PATTERNS,
|
||||
BehaviorType::SESSION_PATTERNS,
|
||||
];
|
||||
}
|
||||
|
||||
public function canAnalyze(array $features): bool
|
||||
{
|
||||
if (count($features) < $this->minClusterSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we have numerical features that can be clustered
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature &&
|
||||
in_array($feature->type, $this->getSupportedBehaviorTypes(), true) &&
|
||||
is_numeric($feature->value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<BehaviorFeature> $features
|
||||
* @return array<AnomalyDetection>
|
||||
*/
|
||||
public function detectAnomalies(array $features, ?BehaviorBaseline $baseline = null): array
|
||||
{
|
||||
$anomalies = [];
|
||||
|
||||
// Group features by behavior type for separate clustering
|
||||
$featureGroups = $this->groupFeaturesByType($features);
|
||||
|
||||
foreach ($featureGroups as $behaviorType => $groupFeatures) {
|
||||
if (count($groupFeatures) < $this->minClusterSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert features to numerical vectors
|
||||
$vectors = $this->featuresToVectors($groupFeatures);
|
||||
|
||||
if (empty($vectors)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Perform clustering analysis
|
||||
$clusters = $this->performClustering($vectors, $behaviorType);
|
||||
|
||||
// Detect cluster-based anomalies
|
||||
$clusterAnomalies = $this->detectClusterAnomalies($groupFeatures, $clusters, $behaviorType);
|
||||
$anomalies = array_merge($anomalies, $clusterAnomalies);
|
||||
|
||||
// Density-based anomaly detection
|
||||
if ($this->enableDensityAnalysis) {
|
||||
$densityAnomalies = $this->detectDensityAnomalies($groupFeatures, $vectors, $behaviorType);
|
||||
$anomalies = array_merge($anomalies, $densityAnomalies);
|
||||
}
|
||||
|
||||
// Group anomaly detection
|
||||
if ($this->enableGroupAnomalyDetection) {
|
||||
$groupAnomalies = $this->detectGroupAnomalies($groupFeatures, $clusters, $behaviorType);
|
||||
$anomalies = array_merge($anomalies, $groupAnomalies);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by confidence threshold
|
||||
return array_filter(
|
||||
$anomalies,
|
||||
fn (AnomalyDetection $anomaly) => $anomaly->confidence->getValue() >= $this->confidenceThreshold * 100
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group features by behavior type
|
||||
* @param array<BehaviorFeature> $features
|
||||
* @return array<string, array<BehaviorFeature>>
|
||||
*/
|
||||
private function groupFeaturesByType(array $features): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature) {
|
||||
$typeKey = $feature->type->value;
|
||||
if (! isset($groups[$typeKey])) {
|
||||
$groups[$typeKey] = [];
|
||||
}
|
||||
$groups[$typeKey][] = $feature;
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert features to numerical vectors for clustering
|
||||
* @param array<BehaviorFeature> $features
|
||||
* @return array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}>
|
||||
*/
|
||||
private function featuresToVectors(array $features): array
|
||||
{
|
||||
$vectors = [];
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature && is_numeric($feature->value)) {
|
||||
$vector = [
|
||||
'value' => (float)$feature->value,
|
||||
'normalized_value' => $feature->normalizedValue ?? $feature->value,
|
||||
'z_score' => $feature->zScore ?? 0.0,
|
||||
'feature' => $feature,
|
||||
];
|
||||
|
||||
$vectors[] = $vector;
|
||||
}
|
||||
}
|
||||
|
||||
return $vectors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform K-means clustering on feature vectors
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $vectors
|
||||
* @return array{centers: array<array<float>>, assignments: array<array{cluster: int, distance: float}>, cost: float, iterations: int}
|
||||
*/
|
||||
private function performClustering(array $vectors, string $behaviorType): array
|
||||
{
|
||||
if (count($vectors) < $this->minClusterSize) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine optimal number of clusters (between 2 and maxClusters)
|
||||
$k = min($this->maxClusters, max(2, (int)sqrt(count($vectors) / 2)));
|
||||
|
||||
// Initialize cluster centers randomly
|
||||
$centers = $this->initializeClusterCenters($vectors, $k);
|
||||
|
||||
$assignments = [];
|
||||
$previousCost = PHP_FLOAT_MAX;
|
||||
|
||||
for ($iteration = 0; $iteration < $this->maxIterations; $iteration++) {
|
||||
// Assign points to nearest cluster
|
||||
$assignments = $this->assignPointsToClusters($vectors, $centers);
|
||||
|
||||
// Update cluster centers
|
||||
$newCenters = $this->updateClusterCenters($vectors, $assignments, $k);
|
||||
|
||||
// Check for convergence
|
||||
$cost = $this->calculateClusteringCost($vectors, $newCenters, $assignments);
|
||||
|
||||
if (abs($previousCost - $cost) < $this->convergenceThreshold) {
|
||||
break;
|
||||
}
|
||||
|
||||
$centers = $newCenters;
|
||||
$previousCost = $cost;
|
||||
}
|
||||
|
||||
// Store results for this behavior type
|
||||
$this->clusterCenters[$behaviorType] = $centers;
|
||||
$this->clusterAssignments[$behaviorType] = $assignments;
|
||||
|
||||
return [
|
||||
'centers' => $centers,
|
||||
'assignments' => $assignments,
|
||||
'cost' => $previousCost,
|
||||
'iterations' => $iteration + 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cluster centers using K-means++ algorithm
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $vectors
|
||||
* @return array<array<float>>
|
||||
*/
|
||||
private function initializeClusterCenters(array $vectors, int $k): array
|
||||
{
|
||||
if (empty($vectors)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$centers = [];
|
||||
|
||||
// Choose first center randomly
|
||||
$centers[] = $this->extractNumericVector($vectors[array_rand($vectors)]);
|
||||
|
||||
// Choose remaining centers with probability proportional to squared distance
|
||||
for ($i = 1; $i < $k; $i++) {
|
||||
$distances = [];
|
||||
$totalDistance = 0.0;
|
||||
|
||||
foreach ($vectors as $vector) {
|
||||
$minDistance = PHP_FLOAT_MAX;
|
||||
$numericVector = $this->extractNumericVector($vector);
|
||||
|
||||
foreach ($centers as $center) {
|
||||
$distance = $this->calculateEuclideanDistance($numericVector, $center);
|
||||
$minDistance = min($minDistance, $distance);
|
||||
}
|
||||
|
||||
$squaredDistance = $minDistance * $minDistance;
|
||||
$distances[] = $squaredDistance;
|
||||
$totalDistance += $squaredDistance;
|
||||
}
|
||||
|
||||
// Select next center with weighted probability
|
||||
$threshold = mt_rand() / mt_getrandmax() * $totalDistance;
|
||||
$cumulative = 0.0;
|
||||
|
||||
foreach ($vectors as $index => $vector) {
|
||||
$cumulative += $distances[$index];
|
||||
if ($cumulative >= $threshold) {
|
||||
$centers[] = $this->extractNumericVector($vector);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $centers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each point to the nearest cluster center
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $vectors
|
||||
* @param array<array<float>> $centers
|
||||
* @return array<array{cluster: int, distance: float}>
|
||||
*/
|
||||
private function assignPointsToClusters(array $vectors, array $centers): array
|
||||
{
|
||||
$assignments = [];
|
||||
|
||||
foreach ($vectors as $index => $vector) {
|
||||
$minDistance = PHP_FLOAT_MAX;
|
||||
$assignedCluster = 0;
|
||||
$numericVector = $this->extractNumericVector($vector);
|
||||
|
||||
foreach ($centers as $clusterIndex => $center) {
|
||||
$distance = $this->calculateEuclideanDistance($numericVector, $center);
|
||||
if ($distance < $minDistance) {
|
||||
$minDistance = $distance;
|
||||
$assignedCluster = $clusterIndex;
|
||||
}
|
||||
}
|
||||
|
||||
$assignments[$index] = [
|
||||
'cluster' => $assignedCluster,
|
||||
'distance' => $minDistance,
|
||||
];
|
||||
}
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cluster centers based on assigned points
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $vectors
|
||||
* @param array<array{cluster: int, distance: float}> $assignments
|
||||
* @return array<array<float>>
|
||||
*/
|
||||
private function updateClusterCenters(array $vectors, array $assignments, int $k): array
|
||||
{
|
||||
$centers = [];
|
||||
|
||||
for ($cluster = 0; $cluster < $k; $cluster++) {
|
||||
$clusterPoints = [];
|
||||
|
||||
foreach ($assignments as $index => $assignment) {
|
||||
if ($assignment['cluster'] === $cluster) {
|
||||
$clusterPoints[] = $this->extractNumericVector($vectors[$index]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($clusterPoints)) {
|
||||
$centers[$cluster] = $this->calculateCentroid($clusterPoints);
|
||||
} else {
|
||||
// Handle empty cluster by reinitializing
|
||||
$centers[$cluster] = $this->extractNumericVector($vectors[array_rand($vectors)]);
|
||||
}
|
||||
}
|
||||
|
||||
return $centers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect anomalies based on cluster analysis
|
||||
* @param array<BehaviorFeature> $features
|
||||
* @param array{centers: array<array<float>>, assignments: array<array{cluster: int, distance: float}>, cost: float, iterations: int} $clusters
|
||||
* @return array<AnomalyDetection>
|
||||
*/
|
||||
private function detectClusterAnomalies(array $features, array $clusters, string $behaviorType): array
|
||||
{
|
||||
$anomalies = [];
|
||||
|
||||
if (empty($clusters['assignments'])) {
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
$assignments = $clusters['assignments'];
|
||||
$centers = $clusters['centers'];
|
||||
|
||||
foreach ($assignments as $index => $assignment) {
|
||||
$distance = $assignment['distance'];
|
||||
$clusterIndex = $assignment['cluster'];
|
||||
|
||||
if (! isset($centers[$clusterIndex])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate cluster-specific threshold
|
||||
$clusterDistances = array_map(
|
||||
fn ($a) => $a['cluster'] === $clusterIndex ? $a['distance'] : null,
|
||||
$assignments
|
||||
);
|
||||
$clusterDistances = array_filter($clusterDistances, fn ($d) => $d !== null);
|
||||
|
||||
if (empty($clusterDistances)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meanDistance = array_sum($clusterDistances) / count($clusterDistances);
|
||||
$variance = array_sum(array_map(fn ($d) => pow($d - $meanDistance, 2), $clusterDistances)) / count($clusterDistances);
|
||||
$stdDev = sqrt($variance);
|
||||
|
||||
// Detect outliers (points far from their cluster center)
|
||||
if ($distance > $meanDistance + $this->outlierThreshold * $stdDev) {
|
||||
$anomalyScore = min(1.0, ($distance - $meanDistance) / max($stdDev, 0.01));
|
||||
|
||||
$anomalies[] = AnomalyDetection::create(
|
||||
type: AnomalyType::CLUSTER_DEVIATION,
|
||||
behaviorType: BehaviorType::from($behaviorType),
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Point deviates significantly from cluster {$clusterIndex}: distance={$distance}, threshold=" . round($meanDistance + $this->outlierThreshold * $stdDev, 3),
|
||||
features: [ $features[$index] ?? null],
|
||||
evidence: [
|
||||
'cluster_index' => $clusterIndex,
|
||||
'distance_to_center' => $distance,
|
||||
'cluster_mean_distance' => $meanDistance,
|
||||
'cluster_std_dev' => $stdDev,
|
||||
'threshold' => $meanDistance + $this->outlierThreshold * $stdDev,
|
||||
'cluster_size' => count($clusterDistances),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect density-based anomalies using local outlier factor
|
||||
* @param array<BehaviorFeature> $features
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $vectors
|
||||
* @return array<AnomalyDetection>
|
||||
*/
|
||||
private function detectDensityAnomalies(array $features, array $vectors, string $behaviorType): array
|
||||
{
|
||||
$anomalies = [];
|
||||
|
||||
if (count($vectors) < $this->minClusterSize) {
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
$k = min(5, count($vectors) - 1); // Number of neighbors for density calculation
|
||||
|
||||
foreach ($vectors as $index => $vector) {
|
||||
$lof = $this->calculateLocalOutlierFactor($vector, $vectors, $k);
|
||||
|
||||
// LOF > 1.5 indicates potential outlier
|
||||
if ($lof > 1.5) {
|
||||
$anomalyScore = min(1.0, ($lof - 1.0) / 2.0);
|
||||
|
||||
$anomalies[] = AnomalyDetection::create(
|
||||
type: AnomalyType::DENSITY_ANOMALY,
|
||||
behaviorType: BehaviorType::from($behaviorType),
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Low density region detected: LOF={$lof}",
|
||||
features: [$features[$index] ?? null],
|
||||
evidence: [
|
||||
'local_outlier_factor' => $lof,
|
||||
'k_neighbors' => $k,
|
||||
'threshold' => 1.5,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect group-level anomalies in cluster patterns
|
||||
* @param array<BehaviorFeature> $features
|
||||
* @param array{centers: array<array<float>>, assignments: array<array{cluster: int, distance: float}>, cost: float, iterations: int} $clusters
|
||||
* @return array<AnomalyDetection>
|
||||
*/
|
||||
private function detectGroupAnomalies(array $features, array $clusters, string $behaviorType): array
|
||||
{
|
||||
$anomalies = [];
|
||||
|
||||
if (empty($clusters['assignments'])) {
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
// Analyze cluster size distribution
|
||||
$clusterSizes = [];
|
||||
foreach ($clusters['assignments'] as $assignment) {
|
||||
$cluster = $assignment['cluster'];
|
||||
$clusterSizes[$cluster] = ($clusterSizes[$cluster] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$totalPoints = count($clusters['assignments']);
|
||||
$meanClusterSize = $totalPoints / count($clusterSizes);
|
||||
|
||||
// Detect abnormally small or large clusters
|
||||
foreach ($clusterSizes as $clusterIndex => $size) {
|
||||
$deviation = abs($size - $meanClusterSize) / max($meanClusterSize, 1.0);
|
||||
|
||||
// Very small clusters might indicate anomalous behavior
|
||||
if ($size < $this->minClusterSize && $deviation > 0.5) {
|
||||
$anomalyScore = min(1.0, $deviation);
|
||||
|
||||
$anomalies[] = AnomalyDetection::create(
|
||||
type: AnomalyType::GROUP_ANOMALY,
|
||||
behaviorType: BehaviorType::from($behaviorType),
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Anomalously small cluster detected: size={$size}, expected≈{$meanClusterSize}",
|
||||
features: $this->getFeaturesForCluster($features, $clusters['assignments'], $clusterIndex),
|
||||
evidence: [
|
||||
'cluster_index' => $clusterIndex,
|
||||
'cluster_size' => $size,
|
||||
'mean_cluster_size' => $meanClusterSize,
|
||||
'size_deviation' => $deviation,
|
||||
'total_clusters' => count($clusterSizes),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get features belonging to a specific cluster
|
||||
* @param array<BehaviorFeature> $features
|
||||
* @param array<array{cluster: int, distance: float}> $assignments
|
||||
* @return array<BehaviorFeature>
|
||||
*/
|
||||
private function getFeaturesForCluster(array $features, array $assignments, int $clusterIndex): array
|
||||
{
|
||||
$clusterFeatures = [];
|
||||
|
||||
foreach ($assignments as $index => $assignment) {
|
||||
if ($assignment['cluster'] === $clusterIndex && isset($features[$index])) {
|
||||
$clusterFeatures[] = $features[$index];
|
||||
}
|
||||
}
|
||||
|
||||
return $clusterFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract numeric vector from feature vector
|
||||
* @param array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature} $vector
|
||||
* @return array<float>
|
||||
*/
|
||||
private function extractNumericVector(array $vector): array
|
||||
{
|
||||
return [
|
||||
$vector['value'] ?? 0.0,
|
||||
$vector['normalized_value'] ?? 0.0,
|
||||
$vector['z_score'] ?? 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Euclidean distance between two vectors
|
||||
* @param array<float> $vector1
|
||||
* @param array<float> $vector2
|
||||
*/
|
||||
private function calculateEuclideanDistance(array $vector1, array $vector2): float
|
||||
{
|
||||
if (count($vector1) !== count($vector2)) {
|
||||
return PHP_FLOAT_MAX;
|
||||
}
|
||||
|
||||
$sum = 0.0;
|
||||
for ($i = 0; $i < count($vector1); $i++) {
|
||||
$diff = $vector1[$i] - $vector2[$i];
|
||||
$sum += $diff * $diff;
|
||||
}
|
||||
|
||||
return sqrt($sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate centroid of a set of points
|
||||
* @param array<array<float>> $points
|
||||
* @return array<float>
|
||||
*/
|
||||
private function calculateCentroid(array $points): array
|
||||
{
|
||||
if (empty($points)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dimensions = count($points[0]);
|
||||
$centroid = array_fill(0, $dimensions, 0.0);
|
||||
|
||||
foreach ($points as $point) {
|
||||
for ($i = 0; $i < $dimensions; $i++) {
|
||||
$centroid[$i] += $point[$i];
|
||||
}
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $dimensions; $i++) {
|
||||
$centroid[$i] /= count($points);
|
||||
}
|
||||
|
||||
return $centroid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total clustering cost (within-cluster sum of squares)
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $vectors
|
||||
* @param array<array<float>> $centers
|
||||
* @param array<array{cluster: int, distance: float}> $assignments
|
||||
*/
|
||||
private function calculateClusteringCost(array $vectors, array $centers, array $assignments): float
|
||||
{
|
||||
$cost = 0.0;
|
||||
|
||||
foreach ($assignments as $index => $assignment) {
|
||||
$clusterIndex = $assignment['cluster'];
|
||||
if (isset($centers[$clusterIndex])) {
|
||||
$distance = $this->calculateEuclideanDistance(
|
||||
$this->extractNumericVector($vectors[$index]),
|
||||
$centers[$clusterIndex]
|
||||
);
|
||||
$cost += $distance * $distance;
|
||||
}
|
||||
}
|
||||
|
||||
return $cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Local Outlier Factor for density-based anomaly detection
|
||||
* @param array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature} $targetVector
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $allVectors
|
||||
*/
|
||||
private function calculateLocalOutlierFactor(array $targetVector, array $allVectors, int $k): float
|
||||
{
|
||||
$targetNumeric = $this->extractNumericVector($targetVector);
|
||||
|
||||
// Find k-nearest neighbors
|
||||
$distances = [];
|
||||
foreach ($allVectors as $index => $vector) {
|
||||
$numeric = $this->extractNumericVector($vector);
|
||||
$distance = $this->calculateEuclideanDistance($targetNumeric, $numeric);
|
||||
$distances[] = ['index' => $index, 'distance' => $distance];
|
||||
}
|
||||
|
||||
usort($distances, fn ($a, $b) => $a['distance'] <=> $b['distance']);
|
||||
$neighbors = array_slice($distances, 1, $k); // Exclude self
|
||||
|
||||
if (empty($neighbors)) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Calculate local reachability density
|
||||
$lrd = $this->calculateLocalReachabilityDensity($targetVector, $allVectors, $neighbors);
|
||||
|
||||
if ($lrd == 0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Calculate LOF
|
||||
$lrdSum = 0.0;
|
||||
foreach ($neighbors as $neighbor) {
|
||||
$neighborLrd = $this->calculateLocalReachabilityDensity(
|
||||
$allVectors[$neighbor['index']],
|
||||
$allVectors,
|
||||
$this->getKNearestNeighbors($allVectors[$neighbor['index']], $allVectors, $k)
|
||||
);
|
||||
$lrdSum += $neighborLrd;
|
||||
}
|
||||
|
||||
$avgNeighborLrd = $lrdSum / count($neighbors);
|
||||
|
||||
return $avgNeighborLrd / $lrd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate local reachability density
|
||||
* @param array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature} $targetVector
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $allVectors
|
||||
* @param array<array{index: int, distance: float}> $neighbors
|
||||
*/
|
||||
private function calculateLocalReachabilityDensity(array $targetVector, array $allVectors, array $neighbors): float
|
||||
{
|
||||
if (empty($neighbors)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$targetNumeric = $this->extractNumericVector($targetVector);
|
||||
$reachabilitySum = 0.0;
|
||||
|
||||
foreach ($neighbors as $neighbor) {
|
||||
$neighborNumeric = $this->extractNumericVector($allVectors[$neighbor['index']]);
|
||||
$distance = $this->calculateEuclideanDistance($targetNumeric, $neighborNumeric);
|
||||
|
||||
// Reachability distance is max of actual distance and k-distance of neighbor
|
||||
$kDistance = $neighbor['distance'];
|
||||
$reachabilityDistance = max($distance, $kDistance);
|
||||
$reachabilitySum += $reachabilityDistance;
|
||||
}
|
||||
|
||||
$avgReachabilityDistance = $reachabilitySum / count($neighbors);
|
||||
|
||||
return $avgReachabilityDistance > 0 ? 1.0 / $avgReachabilityDistance : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get k-nearest neighbors for a vector
|
||||
* @param array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature} $targetVector
|
||||
* @param array<array{value: float, normalized_value: float, z_score: float, feature: BehaviorFeature}> $allVectors
|
||||
* @return array<array{index: int, distance: float}>
|
||||
*/
|
||||
private function getKNearestNeighbors(array $targetVector, array $allVectors, int $k): array
|
||||
{
|
||||
$targetNumeric = $this->extractNumericVector($targetVector);
|
||||
$distances = [];
|
||||
|
||||
foreach ($allVectors as $index => $vector) {
|
||||
$numeric = $this->extractNumericVector($vector);
|
||||
$distance = $this->calculateEuclideanDistance($targetNumeric, $numeric);
|
||||
$distances[] = ['index' => $index, 'distance' => $distance];
|
||||
}
|
||||
|
||||
usort($distances, fn ($a, $b) => $a['distance'] <=> $b['distance']);
|
||||
|
||||
return array_slice($distances, 1, $k); // Exclude self
|
||||
}
|
||||
|
||||
public function updateModel(array $features): void
|
||||
{
|
||||
// Store feature vectors for ongoing clustering analysis
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature) {
|
||||
$typeKey = $feature->type->value;
|
||||
if (! isset($this->featureVectors[$typeKey])) {
|
||||
$this->featureVectors[$typeKey] = [];
|
||||
}
|
||||
|
||||
$this->featureVectors[$typeKey][] = [
|
||||
'value' => $feature->value,
|
||||
'normalized_value' => $feature->normalizedValue ?? $feature->value,
|
||||
'z_score' => $feature->zScore ?? 0.0,
|
||||
'timestamp' => $this->clock->time(),
|
||||
'feature' => $feature,
|
||||
];
|
||||
|
||||
// Limit memory usage
|
||||
if (count($this->featureVectors[$typeKey]) > 1000) {
|
||||
array_shift($this->featureVectors[$typeKey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'confidence_threshold' => $this->confidenceThreshold,
|
||||
'max_clusters' => $this->maxClusters,
|
||||
'min_cluster_size' => $this->minClusterSize,
|
||||
'outlier_threshold' => $this->outlierThreshold,
|
||||
'max_iterations' => $this->maxIterations,
|
||||
'convergence_threshold' => $this->convergenceThreshold,
|
||||
'enable_density_analysis' => $this->enableDensityAnalysis,
|
||||
'enable_group_anomaly_detection' => $this->enableGroupAnomalyDetection,
|
||||
'supported_behavior_types' => array_map(fn ($type) => $type->value, $this->getSupportedBehaviorTypes()),
|
||||
'stored_vectors_count' => array_sum(array_map('count', $this->featureVectors)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getConfidenceThreshold(): float
|
||||
{
|
||||
return $this->confidenceThreshold;
|
||||
}
|
||||
|
||||
public function setConfidenceThreshold(float $threshold): void
|
||||
{
|
||||
$this->confidenceThreshold = max(0.0, min(1.0, $threshold));
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return 150; // milliseconds - more intensive than statistical methods
|
||||
}
|
||||
|
||||
public function supportsRealTime(): bool
|
||||
{
|
||||
return false; // Clustering requires batch processing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\Detectors;
|
||||
|
||||
use App\Framework\Waf\MachineLearning\AnomalyDetectorInterface;
|
||||
use App\Framework\Waf\MachineLearning\AnomalyType;
|
||||
use App\Framework\Waf\MachineLearning\BehaviorType;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\AnomalyDetection;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorBaseline;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Statistical anomaly detector using Z-score and other statistical methods
|
||||
*/
|
||||
final class StatisticalAnomalyDetector implements AnomalyDetectorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private float $confidenceThreshold = 0.75,
|
||||
private readonly float $zScoreThreshold = 2.0,
|
||||
private readonly float $extremeZScoreThreshold = 3.0,
|
||||
private readonly int $minSampleSize = 20,
|
||||
private readonly bool $enableOutlierDetection = true,
|
||||
private readonly bool $enableTrendAnalysis = true,
|
||||
private array $featureHistory = [] // For trend analysis
|
||||
) {
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Statistical Anomaly Detector';
|
||||
}
|
||||
|
||||
public function getSupportedBehaviorTypes(): array
|
||||
{
|
||||
return [
|
||||
BehaviorType::REQUEST_FREQUENCY,
|
||||
BehaviorType::PATH_PATTERNS,
|
||||
BehaviorType::PARAMETER_PATTERNS,
|
||||
BehaviorType::TIME_PATTERNS,
|
||||
BehaviorType::ERROR_PATTERNS,
|
||||
BehaviorType::RESPONSE_TIME_PATTERNS,
|
||||
];
|
||||
}
|
||||
|
||||
public function canAnalyze(array $features): bool
|
||||
{
|
||||
if (empty($features)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we have features from supported behavior types
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature &&
|
||||
in_array($feature->type, $this->getSupportedBehaviorTypes(), true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function detectAnomalies(array $features, ?BehaviorBaseline $baseline = null): array
|
||||
{
|
||||
$anomalies = [];
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if (! ($feature instanceof BehaviorFeature)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store feature for trend analysis
|
||||
$this->recordFeature($feature);
|
||||
|
||||
// Z-score based anomaly detection
|
||||
$zScoreAnomalies = $this->detectZScoreAnomalies($feature, $baseline);
|
||||
$anomalies = array_merge($anomalies, $zScoreAnomalies);
|
||||
|
||||
// Outlier detection using IQR method
|
||||
if ($this->enableOutlierDetection) {
|
||||
$outlierAnomalies = $this->detectOutliers($feature);
|
||||
$anomalies = array_merge($anomalies, $outlierAnomalies);
|
||||
}
|
||||
|
||||
// Trend-based anomaly detection
|
||||
if ($this->enableTrendAnalysis) {
|
||||
$trendAnomalies = $this->detectTrendAnomalies($feature);
|
||||
$anomalies = array_merge($anomalies, $trendAnomalies);
|
||||
}
|
||||
|
||||
// Frequency spike detection
|
||||
if ($feature->type === BehaviorType::REQUEST_FREQUENCY) {
|
||||
$spikeAnomalies = $this->detectFrequencySpikes($feature, $baseline);
|
||||
$anomalies = array_merge($anomalies, $spikeAnomalies);
|
||||
}
|
||||
|
||||
// Pattern deviation detection
|
||||
if (in_array($feature->type, [BehaviorType::PATH_PATTERNS, BehaviorType::PARAMETER_PATTERNS], true)) {
|
||||
$patternAnomalies = $this->detectPatternDeviations($feature, $baseline);
|
||||
$anomalies = array_merge($anomalies, $patternAnomalies);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter anomalies by confidence threshold
|
||||
return array_filter(
|
||||
$anomalies,
|
||||
fn (AnomalyDetection $anomaly) => $anomaly->confidence->getValue() >= $this->confidenceThreshold * 100
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Z-score based statistical anomalies
|
||||
*/
|
||||
private function detectZScoreAnomalies(BehaviorFeature $feature, ?BehaviorBaseline $baseline): array
|
||||
{
|
||||
$anomalies = [];
|
||||
|
||||
// Use baseline if provided, otherwise use feature's own Z-score
|
||||
if ($baseline !== null && $baseline->type === $feature->type) {
|
||||
$zScore = $baseline->calculateZScore($feature->value);
|
||||
$anomalyScore = $baseline->getAnomalyScore($feature->value);
|
||||
} elseif ($feature->zScore !== null) {
|
||||
$zScore = $feature->zScore;
|
||||
$anomalyScore = $feature->getAnomalyScore();
|
||||
} else {
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
$absZScore = abs($zScore);
|
||||
|
||||
// Extreme anomaly (> 3 sigma)
|
||||
if ($absZScore > $this->extremeZScoreThreshold) {
|
||||
$anomalies[] = AnomalyDetection::statisticalAnomaly(
|
||||
behaviorType: $feature->type,
|
||||
metric: $feature->name,
|
||||
value: $feature->value,
|
||||
expectedValue: $baseline?->mean ?? 0.0,
|
||||
standardDeviation: $baseline?->standardDeviation ?? 1.0
|
||||
);
|
||||
}
|
||||
// Moderate anomaly (> 2 sigma)
|
||||
elseif ($absZScore > $this->zScoreThreshold) {
|
||||
$confidence = min(90.0, 60.0 + ($absZScore - $this->zScoreThreshold) * 30.0);
|
||||
|
||||
$anomaly = AnomalyDetection::create(
|
||||
type: AnomalyType::STATISTICAL_ANOMALY,
|
||||
behaviorType: $feature->type,
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Moderate statistical anomaly in {$feature->name}: Z-score = " . round($zScore, 2),
|
||||
features: [$feature],
|
||||
evidence: [
|
||||
'z_score' => $zScore,
|
||||
'threshold' => $this->zScoreThreshold,
|
||||
'feature_value' => $feature->value,
|
||||
'baseline_mean' => $baseline?->mean,
|
||||
'baseline_std_dev' => $baseline?->standardDeviation,
|
||||
]
|
||||
);
|
||||
|
||||
$anomalies[] = $anomaly;
|
||||
}
|
||||
|
||||
return $anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect outliers using Interquartile Range (IQR) method
|
||||
*/
|
||||
private function detectOutliers(BehaviorFeature $feature): array
|
||||
{
|
||||
$history = $this->getFeatureHistory($feature->type, $feature->name);
|
||||
|
||||
if (count($history) < $this->minSampleSize) {
|
||||
return [];
|
||||
}
|
||||
|
||||
sort($history);
|
||||
|
||||
$q1Index = (int)(count($history) * 0.25);
|
||||
$q3Index = (int)(count($history) * 0.75);
|
||||
|
||||
$q1 = $history[$q1Index];
|
||||
$q3 = $history[$q3Index];
|
||||
$iqr = $q3 - $q1;
|
||||
|
||||
$lowerBound = $q1 - 1.5 * $iqr;
|
||||
$upperBound = $q3 + 1.5 * $iqr;
|
||||
|
||||
// Check if current value is an outlier
|
||||
if ($feature->value < $lowerBound || $feature->value > $upperBound) {
|
||||
$distance = min(abs($feature->value - $lowerBound), abs($feature->value - $upperBound));
|
||||
$anomalyScore = min(1.0, $distance / max($iqr, 0.01));
|
||||
|
||||
return [
|
||||
AnomalyDetection::create(
|
||||
type: AnomalyType::OUTLIER_DETECTION,
|
||||
behaviorType: $feature->type,
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Statistical outlier detected in {$feature->name}: value={$feature->value}, bounds=[{$lowerBound}, {$upperBound}]",
|
||||
features: [$feature],
|
||||
evidence: [
|
||||
'value' => $feature->value,
|
||||
'q1' => $q1,
|
||||
'q3' => $q3,
|
||||
'iqr' => $iqr,
|
||||
'lower_bound' => $lowerBound,
|
||||
'upper_bound' => $upperBound,
|
||||
'sample_size' => count($history),
|
||||
]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect trend-based anomalies using moving averages
|
||||
*/
|
||||
private function detectTrendAnomalies(BehaviorFeature $feature): array
|
||||
{
|
||||
$history = $this->getFeatureHistory($feature->type, $feature->name);
|
||||
|
||||
if (count($history) < 10) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate short-term and long-term moving averages
|
||||
$shortWindow = min(5, count($history) / 2);
|
||||
$longWindow = count($history);
|
||||
|
||||
$shortMA = $this->calculateMovingAverage($history, $shortWindow);
|
||||
$longMA = $this->calculateMovingAverage($history, $longWindow);
|
||||
|
||||
// Detect sudden changes in trend
|
||||
if ($shortMA > 0 && $longMA > 0) {
|
||||
$trendRatio = $shortMA / $longMA;
|
||||
|
||||
// Significant upward trend
|
||||
if ($trendRatio > 2.0) {
|
||||
return [
|
||||
AnomalyDetection::create(
|
||||
type: AnomalyType::BEHAVIORAL_DRIFT,
|
||||
behaviorType: $feature->type,
|
||||
anomalyScore: min(1.0, ($trendRatio - 1.0) / 2.0),
|
||||
description: "Upward trend anomaly in {$feature->name}: short MA={$shortMA}, long MA={$longMA}",
|
||||
features: [$feature],
|
||||
evidence: [
|
||||
'short_ma' => $shortMA,
|
||||
'long_ma' => $longMA,
|
||||
'trend_ratio' => $trendRatio,
|
||||
'direction' => 'upward',
|
||||
]
|
||||
),
|
||||
];
|
||||
}
|
||||
// Significant downward trend
|
||||
elseif ($trendRatio < 0.5) {
|
||||
return [
|
||||
AnomalyDetection::create(
|
||||
type: AnomalyType::BEHAVIORAL_DRIFT,
|
||||
behaviorType: $feature->type,
|
||||
anomalyScore: min(1.0, (1.0 - $trendRatio) / 0.5),
|
||||
description: "Downward trend anomaly in {$feature->name}: short MA={$shortMA}, long MA={$longMA}",
|
||||
features: [$feature],
|
||||
evidence: [
|
||||
'short_ma' => $shortMA,
|
||||
'long_ma' => $longMA,
|
||||
'trend_ratio' => $trendRatio,
|
||||
'direction' => 'downward',
|
||||
]
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect frequency spikes
|
||||
*/
|
||||
private function detectFrequencySpikes(BehaviorFeature $feature, ?BehaviorBaseline $baseline): array
|
||||
{
|
||||
if (! str_contains($feature->name, 'rate') && ! str_contains($feature->name, 'frequency')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$history = $this->getFeatureHistory($feature->type, $feature->name);
|
||||
|
||||
if (count($history) < 5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$recentAverage = $this->calculateMovingAverage($history, min(5, count($history)));
|
||||
$historicalAverage = $this->calculateMovingAverage($history, count($history));
|
||||
|
||||
// Detect spikes
|
||||
if ($recentAverage > 0 && $historicalAverage > 0) {
|
||||
$spikeRatio = $recentAverage / $historicalAverage;
|
||||
|
||||
if ($spikeRatio > 3.0) {
|
||||
return [
|
||||
AnomalyDetection::frequencySpike(
|
||||
currentRate: $recentAverage,
|
||||
baseline: $historicalAverage,
|
||||
threshold: 3.0
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect pattern deviations
|
||||
*/
|
||||
private function detectPatternDeviations(BehaviorFeature $feature, ?BehaviorBaseline $baseline): array
|
||||
{
|
||||
$history = $this->getFeatureHistory($feature->type, $feature->name);
|
||||
|
||||
if (count($history) < $this->minSampleSize) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate coefficient of variation for pattern stability
|
||||
$mean = array_sum($history) / count($history);
|
||||
$variance = array_sum(array_map(fn ($x) => pow($x - $mean, 2), $history)) / count($history);
|
||||
$stdDev = sqrt($variance);
|
||||
|
||||
$coefficientOfVariation = $mean > 0 ? $stdDev / $mean : 0.0;
|
||||
|
||||
// High variation indicates pattern instability
|
||||
if ($coefficientOfVariation > 0.5) {
|
||||
$anomalyScore = min(1.0, $coefficientOfVariation / 1.0);
|
||||
|
||||
return [
|
||||
AnomalyDetection::patternDeviation(
|
||||
behaviorType: $feature->type,
|
||||
pattern: $feature->name,
|
||||
deviationScore: $anomalyScore,
|
||||
features: [$feature]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record feature for historical analysis
|
||||
*/
|
||||
private function recordFeature(BehaviorFeature $feature): void
|
||||
{
|
||||
$key = $feature->type->value . ':' . $feature->name;
|
||||
|
||||
if (! isset($this->featureHistory[$key])) {
|
||||
$this->featureHistory[$key] = [];
|
||||
}
|
||||
|
||||
$this->featureHistory[$key][] = $feature->value;
|
||||
|
||||
// Limit history size to prevent memory issues
|
||||
if (count($this->featureHistory[$key]) > 1000) {
|
||||
array_shift($this->featureHistory[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature history for analysis
|
||||
*/
|
||||
private function getFeatureHistory(BehaviorType $type, string $name): array
|
||||
{
|
||||
$key = $type->value . ':' . $name;
|
||||
|
||||
return $this->featureHistory[$key] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate moving average
|
||||
*/
|
||||
private function calculateMovingAverage(array $values, int $window): float
|
||||
{
|
||||
if (empty($values) || $window <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$window = min($window, count($values));
|
||||
$recent = array_slice($values, -$window);
|
||||
|
||||
return array_sum($recent) / count($recent);
|
||||
}
|
||||
|
||||
public function updateModel(array $features): void
|
||||
{
|
||||
// For statistical detector, just record the features for baseline calculation
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature) {
|
||||
$this->recordFeature($feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'confidence_threshold' => $this->confidenceThreshold,
|
||||
'z_score_threshold' => $this->zScoreThreshold,
|
||||
'extreme_z_score_threshold' => $this->extremeZScoreThreshold,
|
||||
'min_sample_size' => $this->minSampleSize,
|
||||
'enable_outlier_detection' => $this->enableOutlierDetection,
|
||||
'enable_trend_analysis' => $this->enableTrendAnalysis,
|
||||
'supported_behavior_types' => array_map(fn ($type) => $type->value, $this->getSupportedBehaviorTypes()),
|
||||
'feature_history_size' => array_sum(array_map('count', $this->featureHistory)),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getConfidenceThreshold(): float
|
||||
{
|
||||
return $this->confidenceThreshold;
|
||||
}
|
||||
|
||||
public function setConfidenceThreshold(float $threshold): void
|
||||
{
|
||||
$this->confidenceThreshold = max(0.0, min(1.0, $threshold));
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return 25; // milliseconds
|
||||
}
|
||||
|
||||
public function supportsRealTime(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\Extractors;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\MachineLearning\BehaviorType;
|
||||
use App\Framework\Waf\MachineLearning\FeatureExtractorInterface;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Extracts request frequency and rate-based behavioral features
|
||||
*/
|
||||
final class FrequencyFeatureExtractor implements FeatureExtractorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $timeWindowSeconds = 300, // 5 minutes
|
||||
private readonly int $maxStoredRequests = 1000,
|
||||
private readonly float $burstThreshold = 10.0, // requests per second
|
||||
private array $requestHistory = [] // In-memory storage (would be Redis in production)
|
||||
) {
|
||||
}
|
||||
|
||||
public function getBehaviorType(): BehaviorType
|
||||
{
|
||||
return BehaviorType::REQUEST_FREQUENCY;
|
||||
}
|
||||
|
||||
public function canExtract(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return $requestData->clientIp !== null;
|
||||
}
|
||||
|
||||
public function extractFeatures(RequestAnalysisData $requestData, array $context = []): array
|
||||
{
|
||||
$clientId = $this->getClientId($requestData);
|
||||
$currentTime = $requestData->timestamp ?? Timestamp::now();
|
||||
|
||||
// Record current request
|
||||
$this->recordRequest($clientId, $currentTime);
|
||||
|
||||
// Clean old requests
|
||||
$this->cleanOldRequests($clientId, $currentTime);
|
||||
|
||||
// Get request history for analysis
|
||||
$requests = $this->getRequestHistory($clientId, $currentTime);
|
||||
|
||||
if (empty($requests)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$features = [];
|
||||
|
||||
// Basic frequency features
|
||||
$features[] = $this->extractRequestRate($requests, $this->timeWindowSeconds);
|
||||
$features[] = $this->extractBurstRate($requests, 60); // 1 minute bursts
|
||||
$features[] = $this->extractSustainedRate($requests, 1800); // 30 minute sustained
|
||||
|
||||
// Pattern-based features
|
||||
$features[] = $this->extractInterArrivalVariance($requests);
|
||||
$features[] = $this->extractRequestSpacing($requests);
|
||||
$features[] = $this->extractPeriodicityScore($requests);
|
||||
|
||||
// Time-based features
|
||||
$features[] = $this->extractTimeOfDayPattern($requests);
|
||||
$features[] = $this->extractWeekdayPattern($requests);
|
||||
|
||||
// Advanced statistical features
|
||||
$features[] = $this->extractFrequencyEntropy($requests);
|
||||
$features[] = $this->extractBurstiness($requests);
|
||||
|
||||
return array_filter($features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract basic request rate
|
||||
*/
|
||||
private function extractRequestRate(array $requests, int $windowSeconds): BehaviorFeature
|
||||
{
|
||||
$count = count($requests);
|
||||
$rate = $windowSeconds > 0 ? $count / $windowSeconds : 0.0;
|
||||
|
||||
return BehaviorFeature::frequency(
|
||||
name: "request_rate_{$windowSeconds}s",
|
||||
count: $count,
|
||||
timeWindow: $windowSeconds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract burst detection rate
|
||||
*/
|
||||
private function extractBurstRate(array $requests, int $windowSeconds): BehaviorFeature
|
||||
{
|
||||
if (count($requests) < 2) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: "burst_rate_{$windowSeconds}s",
|
||||
value: 0.0,
|
||||
unit: 'requests/second'
|
||||
);
|
||||
}
|
||||
|
||||
$maxRate = 0.0;
|
||||
$windowSize = $windowSeconds;
|
||||
|
||||
// Sliding window to find maximum rate
|
||||
for ($i = 0; $i < count($requests) - 1; $i++) {
|
||||
$windowStart = $requests[$i];
|
||||
$requestsInWindow = 0;
|
||||
|
||||
for ($j = $i; $j < count($requests); $j++) {
|
||||
if ($requests[$j] - $windowStart <= $windowSize) {
|
||||
$requestsInWindow++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$rate = $requestsInWindow / $windowSize;
|
||||
$maxRate = max($maxRate, $rate);
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: "burst_rate_{$windowSeconds}s",
|
||||
value: $maxRate,
|
||||
unit: 'requests/second'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sustained rate (longer window)
|
||||
*/
|
||||
private function extractSustainedRate(array $requests, int $windowSeconds): BehaviorFeature
|
||||
{
|
||||
$count = count($requests);
|
||||
|
||||
// Filter requests within the sustained window
|
||||
$currentTime = time();
|
||||
$sustainedRequests = array_filter(
|
||||
$requests,
|
||||
fn ($timestamp) => ($currentTime - $timestamp) <= $windowSeconds
|
||||
);
|
||||
|
||||
$sustainedCount = count($sustainedRequests);
|
||||
$rate = $windowSeconds > 0 ? $sustainedCount / $windowSeconds : 0.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: "sustained_rate_{$windowSeconds}s",
|
||||
value: $rate,
|
||||
unit: 'requests/second'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inter-arrival time variance
|
||||
*/
|
||||
private function extractInterArrivalVariance(array $requests): BehaviorFeature
|
||||
{
|
||||
if (count($requests) < 3) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'inter_arrival_variance',
|
||||
value: 0.0,
|
||||
unit: 'seconds²'
|
||||
);
|
||||
}
|
||||
|
||||
// Sort requests by timestamp
|
||||
sort($requests);
|
||||
|
||||
// Calculate inter-arrival times
|
||||
$interArrivals = [];
|
||||
for ($i = 1; $i < count($requests); $i++) {
|
||||
$interArrivals[] = $requests[$i] - $requests[$i - 1];
|
||||
}
|
||||
|
||||
return BehaviorFeature::statistical(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'inter_arrival_variance',
|
||||
values: $interArrivals,
|
||||
statistic: 'variance'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract request spacing regularity
|
||||
*/
|
||||
private function extractRequestSpacing(array $requests): BehaviorFeature
|
||||
{
|
||||
if (count($requests) < 3) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'request_spacing_regularity',
|
||||
value: 0.0,
|
||||
unit: 'coefficient'
|
||||
);
|
||||
}
|
||||
|
||||
sort($requests);
|
||||
|
||||
// Calculate inter-arrival times
|
||||
$interArrivals = [];
|
||||
for ($i = 1; $i < count($requests); $i++) {
|
||||
$interArrivals[] = $requests[$i] - $requests[$i - 1];
|
||||
}
|
||||
|
||||
$mean = array_sum($interArrivals) / count($interArrivals);
|
||||
$variance = BehaviorFeature::statistical(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'temp_variance',
|
||||
values: $interArrivals,
|
||||
statistic: 'variance'
|
||||
)->value;
|
||||
|
||||
// Coefficient of variation (lower = more regular)
|
||||
$regularity = $mean > 0 ? sqrt($variance) / $mean : 1.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'request_spacing_regularity',
|
||||
value: 1.0 / (1.0 + $regularity), // Normalize: 1 = perfectly regular, 0 = very irregular
|
||||
unit: 'regularity_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract periodicity score using autocorrelation
|
||||
*/
|
||||
private function extractPeriodicityScore(array $requests): BehaviorFeature
|
||||
{
|
||||
if (count($requests) < 10) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'periodicity_score',
|
||||
value: 0.0,
|
||||
unit: 'correlation'
|
||||
);
|
||||
}
|
||||
|
||||
sort($requests);
|
||||
|
||||
// Create time series with 1-second buckets
|
||||
$minTime = min($requests);
|
||||
$maxTime = max($requests);
|
||||
$duration = $maxTime - $minTime;
|
||||
|
||||
if ($duration <= 0) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'periodicity_score',
|
||||
value: 0.0,
|
||||
unit: 'correlation'
|
||||
);
|
||||
}
|
||||
|
||||
// Create histogram
|
||||
$buckets = [];
|
||||
foreach ($requests as $timestamp) {
|
||||
$bucket = (int)($timestamp - $minTime);
|
||||
$buckets[$bucket] = ($buckets[$bucket] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Calculate autocorrelation for common periods (10s, 30s, 60s)
|
||||
$maxCorrelation = 0.0;
|
||||
$periods = [10, 30, 60];
|
||||
|
||||
foreach ($periods as $period) {
|
||||
if ($period >= $duration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$correlation = $this->calculateAutocorrelation($buckets, $period, (int)$duration);
|
||||
$maxCorrelation = max($maxCorrelation, abs($correlation));
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'periodicity_score',
|
||||
value: $maxCorrelation,
|
||||
unit: 'correlation'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract time of day pattern
|
||||
*/
|
||||
private function extractTimeOfDayPattern(array $requests): BehaviorFeature
|
||||
{
|
||||
if (empty($requests)) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'time_of_day_entropy',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
// Group by hour of day
|
||||
$hourDistribution = array_fill(0, 24, 0);
|
||||
|
||||
foreach ($requests as $timestamp) {
|
||||
$hour = (int)date('H', $timestamp);
|
||||
$hourDistribution[$hour]++;
|
||||
}
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'time_of_day_entropy',
|
||||
distribution: $hourDistribution
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract weekday pattern
|
||||
*/
|
||||
private function extractWeekdayPattern(array $requests): BehaviorFeature
|
||||
{
|
||||
if (empty($requests)) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'weekday_entropy',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
// Group by day of week (0 = Sunday, 6 = Saturday)
|
||||
$dayDistribution = array_fill(0, 7, 0);
|
||||
|
||||
foreach ($requests as $timestamp) {
|
||||
$day = (int)date('w', $timestamp);
|
||||
$dayDistribution[$day]++;
|
||||
}
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'weekday_entropy',
|
||||
distribution: $dayDistribution
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract frequency distribution entropy
|
||||
*/
|
||||
private function extractFrequencyEntropy(array $requests): BehaviorFeature
|
||||
{
|
||||
if (count($requests) < 5) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'frequency_entropy',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
// Create frequency distribution in 10-second buckets
|
||||
$buckets = [];
|
||||
$minTime = min($requests);
|
||||
$bucketSize = 10; // seconds
|
||||
|
||||
foreach ($requests as $timestamp) {
|
||||
$bucket = (int)(($timestamp - $minTime) / $bucketSize);
|
||||
$buckets[$bucket] = ($buckets[$bucket] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'frequency_entropy',
|
||||
distribution: array_values($buckets)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract burstiness measure
|
||||
*/
|
||||
private function extractBurstiness(array $requests): BehaviorFeature
|
||||
{
|
||||
if (count($requests) < 5) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'burstiness',
|
||||
value: 0.0,
|
||||
unit: 'burstiness_coefficient'
|
||||
);
|
||||
}
|
||||
|
||||
sort($requests);
|
||||
|
||||
// Calculate inter-arrival times
|
||||
$interArrivals = [];
|
||||
for ($i = 1; $i < count($requests); $i++) {
|
||||
$interArrivals[] = $requests[$i] - $requests[$i - 1];
|
||||
}
|
||||
|
||||
$mean = array_sum($interArrivals) / count($interArrivals);
|
||||
$variance = BehaviorFeature::statistical(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'temp_variance',
|
||||
values: $interArrivals,
|
||||
statistic: 'variance'
|
||||
)->value;
|
||||
|
||||
// Burstiness coefficient: (σ - μ) / (σ + μ)
|
||||
// Range: -1 (regular) to +1 (bursty)
|
||||
$stdDev = sqrt($variance);
|
||||
$burstiness = ($stdDev + $mean) > 0 ? ($stdDev - $mean) / ($stdDev + $mean) : 0.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'burstiness',
|
||||
value: $burstiness,
|
||||
unit: 'burstiness_coefficient'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a request timestamp
|
||||
*/
|
||||
private function recordRequest(string $clientId, Timestamp $timestamp): void
|
||||
{
|
||||
if (! isset($this->requestHistory[$clientId])) {
|
||||
$this->requestHistory[$clientId] = [];
|
||||
}
|
||||
|
||||
$this->requestHistory[$clientId][] = $timestamp->toUnixTimestamp();
|
||||
|
||||
// Limit memory usage
|
||||
if (count($this->requestHistory[$clientId]) > $this->maxStoredRequests) {
|
||||
array_shift($this->requestHistory[$clientId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old requests outside the analysis window
|
||||
*/
|
||||
private function cleanOldRequests(string $clientId, Timestamp $currentTime): void
|
||||
{
|
||||
if (! isset($this->requestHistory[$clientId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoffTime = $currentTime->toUnixTimestamp() - $this->timeWindowSeconds;
|
||||
|
||||
$this->requestHistory[$clientId] = array_filter(
|
||||
$this->requestHistory[$clientId],
|
||||
fn ($timestamp) => $timestamp >= $cutoffTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request history for analysis
|
||||
*/
|
||||
private function getRequestHistory(string $clientId, Timestamp $currentTime): array
|
||||
{
|
||||
if (! isset($this->requestHistory[$clientId])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cutoffTime = $currentTime->toUnixTimestamp() - $this->timeWindowSeconds;
|
||||
|
||||
return array_filter(
|
||||
$this->requestHistory[$clientId],
|
||||
fn ($timestamp) => $timestamp >= $cutoffTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier
|
||||
*/
|
||||
private function getClientId(RequestAnalysisData $requestData): string
|
||||
{
|
||||
// Prefer session ID, fallback to IP address
|
||||
if (! empty($requestData->sessionId)) {
|
||||
return 'session:' . $requestData->sessionId;
|
||||
}
|
||||
|
||||
if ($requestData->clientIp !== null) {
|
||||
return 'ip:' . $requestData->clientIp->toString();
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate autocorrelation for a given lag
|
||||
*/
|
||||
private function calculateAutocorrelation(array $buckets, int $lag, int $duration): float
|
||||
{
|
||||
if ($lag >= $duration || $lag <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$sum = 0.0;
|
||||
$sumSquares = 0.0;
|
||||
$count = 0;
|
||||
|
||||
for ($i = 0; $i < $duration - $lag; $i++) {
|
||||
$x = $buckets[$i] ?? 0;
|
||||
$y = $buckets[$i + $lag] ?? 0;
|
||||
|
||||
$sum += $x * $y;
|
||||
$sumSquares += $x * $x + $y * $y;
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($count === 0 || $sumSquares === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $sum / sqrt($sumSquares / 2);
|
||||
}
|
||||
|
||||
public function getFeatureNames(): array
|
||||
{
|
||||
return [
|
||||
"request_rate_{$this->timeWindowSeconds}s",
|
||||
'burst_rate_60s',
|
||||
'sustained_rate_1800s',
|
||||
'inter_arrival_variance',
|
||||
'request_spacing_regularity',
|
||||
'periodicity_score',
|
||||
'time_of_day_entropy',
|
||||
'weekday_entropy',
|
||||
'frequency_entropy',
|
||||
'burstiness',
|
||||
];
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'time_window_seconds' => $this->timeWindowSeconds,
|
||||
'max_stored_requests' => $this->maxStoredRequests,
|
||||
'burst_threshold' => $this->burstThreshold,
|
||||
'feature_count' => count($this->getFeatureNames()),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 100; // High priority for frequency analysis
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return 50; // milliseconds
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return false; // Needs sequential access for request history
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return []; // No dependencies
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\Extractors;
|
||||
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\MachineLearning\BehaviorType;
|
||||
use App\Framework\Waf\MachineLearning\FeatureExtractorInterface;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Extracts behavioral patterns from URL paths, parameters, and request structure
|
||||
*/
|
||||
final class PatternFeatureExtractor implements FeatureExtractorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $enabled = true,
|
||||
private readonly int $maxPathSegments = 20,
|
||||
private readonly int $maxParameterKeys = 100,
|
||||
private readonly int $historySize = 100,
|
||||
private array $pathHistory = [],
|
||||
private array $parameterHistory = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function getBehaviorType(): BehaviorType
|
||||
{
|
||||
return BehaviorType::PATH_PATTERNS;
|
||||
}
|
||||
|
||||
public function canExtract(RequestAnalysisData $requestData): bool
|
||||
{
|
||||
return ! empty($requestData->path);
|
||||
}
|
||||
|
||||
public function extractFeatures(RequestAnalysisData $requestData, array $context = []): array
|
||||
{
|
||||
$clientId = $this->getClientId($requestData);
|
||||
|
||||
// Record current request patterns
|
||||
$this->recordPatterns($clientId, $requestData);
|
||||
|
||||
$features = [];
|
||||
|
||||
// Path-based features
|
||||
$features = array_merge($features, $this->extractPathFeatures($requestData, $clientId));
|
||||
|
||||
// Parameter-based features
|
||||
$features = array_merge($features, $this->extractParameterFeatures($requestData, $clientId));
|
||||
|
||||
// Sequence-based features
|
||||
$features = array_merge($features, $this->extractSequenceFeatures($requestData, $clientId));
|
||||
|
||||
// Structure-based features
|
||||
$features = array_merge($features, $this->extractStructureFeatures($requestData));
|
||||
|
||||
return array_filter($features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path-related behavioral features
|
||||
*/
|
||||
private function extractPathFeatures(RequestAnalysisData $requestData, string $clientId): array
|
||||
{
|
||||
$features = [];
|
||||
$path = $requestData->path;
|
||||
|
||||
// Path structure features
|
||||
$features[] = $this->extractPathDepth($path);
|
||||
$features[] = $this->extractPathComplexity($path);
|
||||
$features[] = $this->extractPathEntropy($path);
|
||||
|
||||
// Path pattern features
|
||||
$features[] = $this->extractPathUniqueness($clientId);
|
||||
$features[] = $this->extractPathRepetition($clientId);
|
||||
$features[] = $this->extractPathDiversity($clientId);
|
||||
|
||||
// Suspicious path characteristics
|
||||
$features[] = $this->extractSuspiciousPathScore($path);
|
||||
$features[] = $this->extractFileExtensionPattern($path);
|
||||
$features[] = $this->extractDirectoryTraversalScore($path);
|
||||
|
||||
return $features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter-related behavioral features
|
||||
*/
|
||||
private function extractParameterFeatures(RequestAnalysisData $requestData, string $clientId): array
|
||||
{
|
||||
$features = [];
|
||||
$allParams = $requestData->getAllParameters();
|
||||
|
||||
if (empty($allParams)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parameter count and structure
|
||||
$features[] = $this->extractParameterCount($allParams);
|
||||
$features[] = $this->extractParameterComplexity($allParams);
|
||||
$features[] = $this->extractParameterEntropy($allParams);
|
||||
|
||||
// Parameter patterns
|
||||
$features[] = $this->extractParameterUniqueness($clientId);
|
||||
$features[] = $this->extractParameterKeyDiversity($clientId);
|
||||
$features[] = $this->extractParameterValueEntropy($allParams);
|
||||
|
||||
// Suspicious parameter characteristics
|
||||
$features[] = $this->extractSuspiciousParameterScore($allParams);
|
||||
$features[] = $this->extractInjectionPatternScore($allParams);
|
||||
|
||||
return $features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sequence-based features
|
||||
*/
|
||||
private function extractSequenceFeatures(RequestAnalysisData $requestData, string $clientId): array
|
||||
{
|
||||
$features = [];
|
||||
|
||||
// Path sequence analysis
|
||||
$pathHistory = $this->pathHistory[$clientId] ?? [];
|
||||
if (count($pathHistory) >= 2) {
|
||||
$features[] = $this->extractPathSequenceEntropy($pathHistory);
|
||||
$features[] = $this->extractPathTransitionScore($pathHistory);
|
||||
$features[] = $this->extractNavigationPattern($pathHistory);
|
||||
}
|
||||
|
||||
return $features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract structural features
|
||||
*/
|
||||
private function extractStructureFeatures(RequestAnalysisData $requestData): array
|
||||
{
|
||||
$features = [];
|
||||
|
||||
// Request structure
|
||||
$features[] = $this->extractRequestComplexity($requestData);
|
||||
$features[] = $this->extractHeaderToBodyRatio($requestData);
|
||||
$features[] = $this->extractContentTypeConsistency($requestData);
|
||||
|
||||
return $features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path depth (number of segments)
|
||||
*/
|
||||
private function extractPathDepth(string $path): BehaviorFeature
|
||||
{
|
||||
$segments = array_filter(explode('/', trim($path, '/')));
|
||||
$depth = count($segments);
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_depth',
|
||||
value: $depth,
|
||||
unit: 'segments'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path complexity score
|
||||
*/
|
||||
private function extractPathComplexity(string $path): BehaviorFeature
|
||||
{
|
||||
$segments = array_filter(explode('/', trim($path, '/')));
|
||||
|
||||
$complexity = 0.0;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
// Length complexity
|
||||
$complexity += strlen($segment) / 20.0;
|
||||
|
||||
// Character diversity
|
||||
$uniqueChars = count(array_unique(str_split($segment)));
|
||||
$complexity += $uniqueChars / 10.0;
|
||||
|
||||
// Special characters
|
||||
$specialChars = preg_match_all('/[^a-zA-Z0-9_-]/', $segment);
|
||||
$complexity += $specialChars * 0.5;
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_complexity',
|
||||
value: $complexity,
|
||||
unit: 'complexity_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path entropy
|
||||
*/
|
||||
private function extractPathEntropy(string $path): BehaviorFeature
|
||||
{
|
||||
// Character frequency distribution
|
||||
$chars = str_split(strtolower($path));
|
||||
$distribution = array_count_values($chars);
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_entropy',
|
||||
distribution: array_values($distribution)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path uniqueness for this client
|
||||
*/
|
||||
private function extractPathUniqueness(string $clientId): BehaviorFeature
|
||||
{
|
||||
$pathHistory = $this->pathHistory[$clientId] ?? [];
|
||||
|
||||
if (empty($pathHistory)) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_uniqueness',
|
||||
value: 1.0,
|
||||
unit: 'ratio'
|
||||
);
|
||||
}
|
||||
|
||||
$uniquePaths = count(array_unique($pathHistory));
|
||||
$totalPaths = count($pathHistory);
|
||||
|
||||
$uniqueness = $totalPaths > 0 ? $uniquePaths / $totalPaths : 0.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_uniqueness',
|
||||
value: $uniqueness,
|
||||
unit: 'ratio'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path repetition score
|
||||
*/
|
||||
private function extractPathRepetition(string $clientId): BehaviorFeature
|
||||
{
|
||||
$pathHistory = $this->pathHistory[$clientId] ?? [];
|
||||
|
||||
if (count($pathHistory) < 2) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_repetition',
|
||||
value: 0.0,
|
||||
unit: 'score'
|
||||
);
|
||||
}
|
||||
|
||||
$pathCounts = array_count_values($pathHistory);
|
||||
$maxCount = max($pathCounts);
|
||||
$totalCount = count($pathHistory);
|
||||
|
||||
$repetition = $totalCount > 0 ? $maxCount / $totalCount : 0.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_repetition',
|
||||
value: $repetition,
|
||||
unit: 'ratio'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path diversity score
|
||||
*/
|
||||
private function extractPathDiversity(string $clientId): BehaviorFeature
|
||||
{
|
||||
$pathHistory = $this->pathHistory[$clientId] ?? [];
|
||||
|
||||
if (empty($pathHistory)) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_diversity',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
$pathCounts = array_count_values($pathHistory);
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_diversity',
|
||||
distribution: array_values($pathCounts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract suspicious path characteristics score
|
||||
*/
|
||||
private function extractSuspiciousPathScore(string $path): BehaviorFeature
|
||||
{
|
||||
$suspiciousScore = 0.0;
|
||||
|
||||
// Admin/system paths
|
||||
$adminPatterns = ['/admin', '/administrator', '/config', '/debug', '/test'];
|
||||
foreach ($adminPatterns as $pattern) {
|
||||
if (stripos($path, $pattern) !== false) {
|
||||
$suspiciousScore += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Encoded characters
|
||||
if (preg_match('/%[0-9a-fA-F]{2}/', $path)) {
|
||||
$suspiciousScore += 0.2;
|
||||
}
|
||||
|
||||
// Double encoding
|
||||
if (preg_match('/%25[0-9a-fA-F]{2}/', $path)) {
|
||||
$suspiciousScore += 0.4;
|
||||
}
|
||||
|
||||
// Null bytes
|
||||
if (strpos($path, '%00') !== false) {
|
||||
$suspiciousScore += 0.5;
|
||||
}
|
||||
|
||||
// Excessive length
|
||||
if (strlen($path) > 200) {
|
||||
$suspiciousScore += 0.2;
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'suspicious_path_score',
|
||||
value: min($suspiciousScore, 1.0),
|
||||
unit: 'score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension pattern
|
||||
*/
|
||||
private function extractFileExtensionPattern(string $path): BehaviorFeature
|
||||
{
|
||||
$extension = pathinfo($path, PATHINFO_EXTENSION);
|
||||
$extension = strtolower($extension);
|
||||
|
||||
$riskScore = 0.0;
|
||||
|
||||
$dangerousExtensions = [
|
||||
'php', 'asp', 'aspx', 'jsp', 'py', 'pl', 'cgi', 'sh', 'bat', 'exe',
|
||||
];
|
||||
|
||||
if (in_array($extension, $dangerousExtensions, true)) {
|
||||
$riskScore = 1.0;
|
||||
} elseif (! empty($extension)) {
|
||||
$riskScore = 0.1; // Any extension is slightly suspicious
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'file_extension_risk',
|
||||
value: $riskScore,
|
||||
unit: 'risk_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract directory traversal score
|
||||
*/
|
||||
private function extractDirectoryTraversalScore(string $path): BehaviorFeature
|
||||
{
|
||||
$traversalScore = 0.0;
|
||||
|
||||
// Count directory traversal patterns
|
||||
$patterns = ['../', '..\\', '%2e%2e%2f', '%2e%2e%5c'];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$matches = substr_count(strtolower($path), strtolower($pattern));
|
||||
$traversalScore += $matches * 0.3;
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'directory_traversal_score',
|
||||
value: min($traversalScore, 1.0),
|
||||
unit: 'score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter count
|
||||
*/
|
||||
private function extractParameterCount(array $parameters): BehaviorFeature
|
||||
{
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_count',
|
||||
value: count($parameters),
|
||||
unit: 'count'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter complexity
|
||||
*/
|
||||
private function extractParameterComplexity(array $parameters): BehaviorFeature
|
||||
{
|
||||
$complexity = 0.0;
|
||||
|
||||
foreach ($parameters as $key => $value) {
|
||||
// Key complexity
|
||||
$complexity += strlen($key) / 50.0;
|
||||
$complexity += preg_match_all('/[^a-zA-Z0-9_]/', $key) * 0.1;
|
||||
|
||||
// Value complexity
|
||||
if (is_string($value)) {
|
||||
$complexity += strlen($value) / 200.0;
|
||||
$complexity += preg_match_all('/[^a-zA-Z0-9\\s]/', $value) * 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_complexity',
|
||||
value: $complexity,
|
||||
unit: 'complexity_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter key entropy
|
||||
*/
|
||||
private function extractParameterEntropy(array $parameters): BehaviorFeature
|
||||
{
|
||||
if (empty($parameters)) {
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_entropy',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
// Character distribution across all parameter keys
|
||||
$allKeys = implode('', array_keys($parameters));
|
||||
$chars = str_split(strtolower($allKeys));
|
||||
$distribution = array_count_values($chars);
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_entropy',
|
||||
distribution: array_values($distribution)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter uniqueness for this client
|
||||
*/
|
||||
private function extractParameterUniqueness(string $clientId): BehaviorFeature
|
||||
{
|
||||
$paramHistory = $this->parameterHistory[$clientId] ?? [];
|
||||
|
||||
if (empty($paramHistory)) {
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_uniqueness',
|
||||
value: 1.0,
|
||||
unit: 'ratio'
|
||||
);
|
||||
}
|
||||
|
||||
$uniqueParams = count(array_unique($paramHistory, SORT_REGULAR));
|
||||
$totalParams = count($paramHistory);
|
||||
|
||||
$uniqueness = $totalParams > 0 ? $uniqueParams / $totalParams : 0.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_uniqueness',
|
||||
value: $uniqueness,
|
||||
unit: 'ratio'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter key diversity
|
||||
*/
|
||||
private function extractParameterKeyDiversity(string $clientId): BehaviorFeature
|
||||
{
|
||||
$paramHistory = $this->parameterHistory[$clientId] ?? [];
|
||||
|
||||
if (empty($paramHistory)) {
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_key_diversity',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
// Collect all parameter keys
|
||||
$allKeys = [];
|
||||
foreach ($paramHistory as $params) {
|
||||
if (is_array($params)) {
|
||||
$allKeys = array_merge($allKeys, array_keys($params));
|
||||
}
|
||||
}
|
||||
|
||||
$keyCounts = array_count_values($allKeys);
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_key_diversity',
|
||||
distribution: array_values($keyCounts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter value entropy
|
||||
*/
|
||||
private function extractParameterValueEntropy(array $parameters): BehaviorFeature
|
||||
{
|
||||
if (empty($parameters)) {
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_value_entropy',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
// Character distribution across all parameter values
|
||||
$allValues = implode('', array_filter(array_values($parameters), 'is_string'));
|
||||
|
||||
if (empty($allValues)) {
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_value_entropy',
|
||||
value: 0.0,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
$chars = str_split(strtolower($allValues));
|
||||
$distribution = array_count_values($chars);
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'parameter_value_entropy',
|
||||
distribution: array_values($distribution)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract suspicious parameter score
|
||||
*/
|
||||
private function extractSuspiciousParameterScore(array $parameters): BehaviorFeature
|
||||
{
|
||||
$suspiciousScore = 0.0;
|
||||
|
||||
$suspiciousKeys = [
|
||||
'eval', 'exec', 'system', 'cmd', 'command', 'shell',
|
||||
'admin', 'root', 'password', 'pass', 'auth', 'token',
|
||||
'debug', 'test', 'dev', 'config', 'settings',
|
||||
];
|
||||
|
||||
foreach ($parameters as $key => $value) {
|
||||
$lowerKey = strtolower($key);
|
||||
|
||||
// Check for suspicious parameter names
|
||||
foreach ($suspiciousKeys as $suspicious) {
|
||||
if (strpos($lowerKey, $suspicious) !== false) {
|
||||
$suspiciousScore += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for encoded values
|
||||
if (is_string($value) && preg_match('/%[0-9a-fA-F]{2}/', $value)) {
|
||||
$suspiciousScore += 0.1;
|
||||
}
|
||||
|
||||
// Check for extremely long values
|
||||
if (is_string($value) && strlen($value) > 1000) {
|
||||
$suspiciousScore += 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'suspicious_parameter_score',
|
||||
value: min($suspiciousScore, 1.0),
|
||||
unit: 'score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract injection pattern score
|
||||
*/
|
||||
private function extractInjectionPatternScore(array $parameters): BehaviorFeature
|
||||
{
|
||||
$injectionScore = 0.0;
|
||||
|
||||
$injectionPatterns = [
|
||||
'sql' => ['/union\\s+select/i', '/or\\s+1\\s*=\\s*1/i', '/\\s*;\\s*drop\\s+table/i'],
|
||||
'xss' => ['/<script/i', '/javascript:/i', '/onerror\\s*=/i'],
|
||||
'cmd' => ['/;\\s*(cat|ls|pwd|id)/i', '/\\|\\s*(nc|netcat)/i'],
|
||||
];
|
||||
|
||||
foreach ($parameters as $key => $value) {
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($injectionPatterns as $type => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
$injectionScore += 0.4;
|
||||
|
||||
break 2; // Break out of both loops
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: BehaviorType::PARAMETER_PATTERNS,
|
||||
name: 'injection_pattern_score',
|
||||
value: min($injectionScore, 1.0),
|
||||
unit: 'score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path sequence entropy
|
||||
*/
|
||||
private function extractPathSequenceEntropy(array $pathHistory): BehaviorFeature
|
||||
{
|
||||
// Create bigrams (consecutive path pairs)
|
||||
$bigrams = [];
|
||||
for ($i = 0; $i < count($pathHistory) - 1; $i++) {
|
||||
$bigram = $pathHistory[$i] . ' -> ' . $pathHistory[$i + 1];
|
||||
$bigrams[] = $bigram;
|
||||
}
|
||||
|
||||
$bigramCounts = array_count_values($bigrams);
|
||||
|
||||
return BehaviorFeature::entropy(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_sequence_entropy',
|
||||
distribution: array_values($bigramCounts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path transition score
|
||||
*/
|
||||
private function extractPathTransitionScore(array $pathHistory): BehaviorFeature
|
||||
{
|
||||
if (count($pathHistory) < 2) {
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_transition_score',
|
||||
value: 0.0,
|
||||
unit: 'score'
|
||||
);
|
||||
}
|
||||
|
||||
$transitionScore = 0.0;
|
||||
|
||||
for ($i = 0; $i < count($pathHistory) - 1; $i++) {
|
||||
$current = $pathHistory[$i];
|
||||
$next = $pathHistory[$i + 1];
|
||||
|
||||
// Calculate path similarity (Levenshtein distance)
|
||||
$similarity = 1.0 - (levenshtein($current, $next) / max(strlen($current), strlen($next)));
|
||||
$transitionScore += $similarity;
|
||||
}
|
||||
|
||||
$averageTransition = $transitionScore / (count($pathHistory) - 1);
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'path_transition_score',
|
||||
value: $averageTransition,
|
||||
unit: 'similarity_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract navigation pattern
|
||||
*/
|
||||
private function extractNavigationPattern(array $pathHistory): BehaviorFeature
|
||||
{
|
||||
$backtrackingScore = 0.0;
|
||||
|
||||
// Detect backtracking patterns (returning to previously visited paths)
|
||||
for ($i = 2; $i < count($pathHistory); $i++) {
|
||||
$current = $pathHistory[$i];
|
||||
|
||||
// Check if current path was visited in the last few requests
|
||||
for ($j = max(0, $i - 5); $j < $i; $j++) {
|
||||
if ($pathHistory[$j] === $current) {
|
||||
$backtrackingScore += 1.0 / ($i - $j); // More recent = higher score
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$normalizedScore = count($pathHistory) > 2 ? $backtrackingScore / (count($pathHistory) - 2) : 0.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'navigation_backtracking',
|
||||
value: $normalizedScore,
|
||||
unit: 'backtracking_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract request complexity
|
||||
*/
|
||||
private function extractRequestComplexity(RequestAnalysisData $requestData): BehaviorFeature
|
||||
{
|
||||
$complexity = 0.0;
|
||||
|
||||
// Path complexity
|
||||
$complexity += strlen($requestData->path) / 100.0;
|
||||
|
||||
// Parameter complexity
|
||||
$paramCount = count($requestData->getAllParameters());
|
||||
$complexity += $paramCount / 20.0;
|
||||
|
||||
// Header complexity
|
||||
$headerCount = count($requestData->headers);
|
||||
$complexity += $headerCount / 30.0;
|
||||
|
||||
// Body complexity
|
||||
$bodySize = strlen($requestData->body);
|
||||
$complexity += $bodySize / 5000.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'request_complexity',
|
||||
value: $complexity,
|
||||
unit: 'complexity_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract header to body ratio
|
||||
*/
|
||||
private function extractHeaderToBodyRatio(RequestAnalysisData $requestData): BehaviorFeature
|
||||
{
|
||||
$headerSize = array_sum(array_map(
|
||||
fn ($name, $value) => strlen($name) + strlen($value),
|
||||
array_keys($requestData->headers),
|
||||
array_values($requestData->headers)
|
||||
));
|
||||
|
||||
$bodySize = strlen($requestData->body);
|
||||
|
||||
$ratio = ($headerSize + $bodySize) > 0 ? $headerSize / ($headerSize + $bodySize) : 0.0;
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'header_body_ratio',
|
||||
value: $ratio,
|
||||
unit: 'ratio'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content type consistency
|
||||
*/
|
||||
private function extractContentTypeConsistency(RequestAnalysisData $requestData): BehaviorFeature
|
||||
{
|
||||
$consistencyScore = 1.0;
|
||||
|
||||
// Check if content type matches the actual content
|
||||
if ($requestData->contentType !== null) {
|
||||
if ($requestData->isJson() && ! empty($requestData->body)) {
|
||||
json_decode($requestData->body);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$consistencyScore -= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
if ($requestData->isXml() && ! empty($requestData->body)) {
|
||||
$previousSetting = libxml_use_internal_errors(true);
|
||||
simplexml_load_string($requestData->body);
|
||||
$errors = libxml_get_errors();
|
||||
libxml_use_internal_errors($previousSetting);
|
||||
libxml_clear_errors();
|
||||
|
||||
if (! empty($errors)) {
|
||||
$consistencyScore -= 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BehaviorFeature::create(
|
||||
type: $this->getBehaviorType(),
|
||||
name: 'content_type_consistency',
|
||||
value: max(0.0, $consistencyScore),
|
||||
unit: 'consistency_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record patterns for this client
|
||||
*/
|
||||
private function recordPatterns(string $clientId, RequestAnalysisData $requestData): void
|
||||
{
|
||||
// Record path
|
||||
if (! isset($this->pathHistory[$clientId])) {
|
||||
$this->pathHistory[$clientId] = [];
|
||||
}
|
||||
|
||||
$this->pathHistory[$clientId][] = $requestData->path;
|
||||
|
||||
// Limit history size
|
||||
if (count($this->pathHistory[$clientId]) > $this->historySize) {
|
||||
array_shift($this->pathHistory[$clientId]);
|
||||
}
|
||||
|
||||
// Record parameters
|
||||
if (! isset($this->parameterHistory[$clientId])) {
|
||||
$this->parameterHistory[$clientId] = [];
|
||||
}
|
||||
|
||||
$allParams = $requestData->getAllParameters();
|
||||
if (! empty($allParams)) {
|
||||
$this->parameterHistory[$clientId][] = $allParams;
|
||||
|
||||
// Limit history size
|
||||
if (count($this->parameterHistory[$clientId]) > $this->historySize) {
|
||||
array_shift($this->parameterHistory[$clientId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier
|
||||
*/
|
||||
private function getClientId(RequestAnalysisData $requestData): string
|
||||
{
|
||||
if (! empty($requestData->sessionId)) {
|
||||
return 'session:' . $requestData->sessionId;
|
||||
}
|
||||
|
||||
if ($requestData->clientIp !== null) {
|
||||
return 'ip:' . $requestData->clientIp->toString();
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
public function getFeatureNames(): array
|
||||
{
|
||||
return [
|
||||
// Path features
|
||||
'path_depth', 'path_complexity', 'path_entropy', 'path_uniqueness',
|
||||
'path_repetition', 'path_diversity', 'suspicious_path_score',
|
||||
'file_extension_risk', 'directory_traversal_score',
|
||||
|
||||
// Parameter features
|
||||
'parameter_count', 'parameter_complexity', 'parameter_entropy',
|
||||
'parameter_uniqueness', 'parameter_key_diversity', 'parameter_value_entropy',
|
||||
'suspicious_parameter_score', 'injection_pattern_score',
|
||||
|
||||
// Sequence features
|
||||
'path_sequence_entropy', 'path_transition_score', 'navigation_backtracking',
|
||||
|
||||
// Structure features
|
||||
'request_complexity', 'header_body_ratio', 'content_type_consistency',
|
||||
];
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'max_path_segments' => $this->maxPathSegments,
|
||||
'max_parameter_keys' => $this->maxParameterKeys,
|
||||
'history_size' => $this->historySize,
|
||||
'feature_count' => count($this->getFeatureNames()),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 80; // Medium-high priority
|
||||
}
|
||||
|
||||
public function getExpectedProcessingTime(): int
|
||||
{
|
||||
return 75; // milliseconds
|
||||
}
|
||||
|
||||
public function supportsParallelExecution(): bool
|
||||
{
|
||||
return false; // Needs sequential access for pattern history
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return []; // No dependencies
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Interface for extracting behavioral features from request data
|
||||
*/
|
||||
interface FeatureExtractorInterface
|
||||
{
|
||||
/**
|
||||
* Get the behavior type this extractor handles
|
||||
*/
|
||||
public function getBehaviorType(): BehaviorType;
|
||||
|
||||
/**
|
||||
* Check if extractor can process the given request data
|
||||
*/
|
||||
public function canExtract(RequestAnalysisData $requestData): bool;
|
||||
|
||||
/**
|
||||
* Extract behavioral features from request data
|
||||
*
|
||||
* @return BehaviorFeature[]
|
||||
*/
|
||||
public function extractFeatures(RequestAnalysisData $requestData, array $context = []): array;
|
||||
|
||||
/**
|
||||
* Get feature names this extractor produces
|
||||
*/
|
||||
public function getFeatureNames(): array;
|
||||
|
||||
/**
|
||||
* Get extractor configuration
|
||||
*/
|
||||
public function getConfiguration(): array;
|
||||
|
||||
/**
|
||||
* Check if extractor is enabled
|
||||
*/
|
||||
public function isEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Get processing priority (higher = runs earlier)
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
|
||||
/**
|
||||
* Get expected processing time in milliseconds
|
||||
*/
|
||||
public function getExpectedProcessingTime(): int;
|
||||
|
||||
/**
|
||||
* Check if extractor supports parallel execution
|
||||
*/
|
||||
public function supportsParallelExecution(): bool;
|
||||
|
||||
/**
|
||||
* Get extractor dependencies (extractors that must run before this one)
|
||||
*/
|
||||
public function getDependencies(): array;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
/**
|
||||
* Interface for anomaly detectors that support feature weight adjustments
|
||||
*/
|
||||
interface FeatureWeightAdjustableInterface
|
||||
{
|
||||
/**
|
||||
* Adjust the weights of specific features
|
||||
*
|
||||
* Positive adjustment: Increase feature importance
|
||||
* Negative adjustment: Decrease feature importance
|
||||
*
|
||||
* @param array<string, float> $adjustments Map of feature names to adjustment values
|
||||
* @return array<string, float> Map of feature names to their new weights
|
||||
*/
|
||||
public function adjustFeatureWeights(array $adjustments): array;
|
||||
|
||||
/**
|
||||
* Get the current weight for a specific feature
|
||||
*
|
||||
* @param string $featureName Name of the feature
|
||||
* @return float|null Current weight or null if feature not found
|
||||
*/
|
||||
public function getFeatureWeight(string $featureName): ?float;
|
||||
|
||||
/**
|
||||
* Set the weight for a specific feature
|
||||
*
|
||||
* @param string $featureName Name of the feature
|
||||
* @param float $weight New weight value
|
||||
* @return bool True if setting was successful
|
||||
*/
|
||||
public function setFeatureWeight(string $featureName, float $weight): bool;
|
||||
|
||||
/**
|
||||
* Get all feature weights
|
||||
*
|
||||
* @return array<string, float> Map of feature names to their weights
|
||||
*/
|
||||
public function getAllFeatureWeights(): array;
|
||||
|
||||
/**
|
||||
* Reset all feature weights to their default values
|
||||
*
|
||||
* @return bool True if reset was successful
|
||||
*/
|
||||
public function resetFeatureWeights(): bool;
|
||||
}
|
||||
718
src/Framework/Waf/MachineLearning/MachineLearningEngine.php
Normal file
718
src/Framework/Waf/MachineLearning/MachineLearningEngine.php
Normal file
@@ -0,0 +1,718 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\AnomalyDetection;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorBaseline;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Main machine learning engine for behavioral analysis and anomaly detection
|
||||
*/
|
||||
final class MachineLearningEngine
|
||||
{
|
||||
/**
|
||||
* @param FeatureExtractorInterface[] $extractors
|
||||
* @param AnomalyDetectorInterface[] $detectors
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly bool $enabled,
|
||||
private readonly array $extractors,
|
||||
private readonly array $detectors,
|
||||
private readonly Clock $clock,
|
||||
private readonly Duration $analysisTimeout,
|
||||
private readonly Percentage $confidenceThreshold,
|
||||
private readonly bool $enableParallelProcessing = false,
|
||||
private readonly bool $enableFeatureCaching = true,
|
||||
private readonly int $maxFeaturesPerRequest = 100,
|
||||
private array $featureCache = [],
|
||||
private array $baselineCache = [],
|
||||
private array $performanceMetrics = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request for behavioral anomalies
|
||||
*/
|
||||
public function analyzeRequest(RequestAnalysisData $requestData, array $context = []): MachineLearningResult
|
||||
{
|
||||
$startTime = $this->clock->time();
|
||||
|
||||
if (! $this->enabled) {
|
||||
return new MachineLearningResult(
|
||||
features: [],
|
||||
anomalies: [],
|
||||
confidence: Percentage::from(0.0),
|
||||
processingTime: Duration::zero(),
|
||||
enabled: false
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract behavioral features
|
||||
$features = $this->extractFeatures($requestData, $context);
|
||||
|
||||
// Get relevant baselines
|
||||
$baselines = $this->getBaselines($features);
|
||||
|
||||
// Detect anomalies
|
||||
$anomalies = $this->detectAnomalies($features, $baselines);
|
||||
|
||||
// Calculate overall confidence
|
||||
$confidence = $this->calculateOverallConfidence($anomalies);
|
||||
|
||||
// Update models with new data
|
||||
$this->updateModels($features);
|
||||
|
||||
$processingTime = $startTime->diff($this->clock->time());
|
||||
|
||||
// Record performance metrics
|
||||
$this->recordPerformanceMetrics($processingTime, count($features), count($anomalies));
|
||||
|
||||
return new MachineLearningResult(
|
||||
features: $features,
|
||||
anomalies: $anomalies,
|
||||
confidence: $confidence,
|
||||
processingTime: $processingTime,
|
||||
enabled: true,
|
||||
extractorResults: $this->getExtractorResults($features),
|
||||
detectorResults: $this->getDetectorResults($anomalies),
|
||||
baselineStats: $this->getBaselineStats($baselines)
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$processingTime = $startTime->diff($this->clock->time());
|
||||
|
||||
return new MachineLearningResult(
|
||||
features: [],
|
||||
anomalies: [],
|
||||
confidence: Percentage::from(0.0),
|
||||
processingTime: $processingTime,
|
||||
enabled: true,
|
||||
error: $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract behavioral features from request data
|
||||
*/
|
||||
private function extractFeatures(RequestAnalysisData $requestData, array $context): array
|
||||
{
|
||||
$allFeatures = [];
|
||||
$extractorResults = [];
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = $this->generateFeatureCacheKey($requestData);
|
||||
if ($this->enableFeatureCaching && isset($this->featureCache[$cacheKey])) {
|
||||
return $this->featureCache[$cacheKey];
|
||||
}
|
||||
|
||||
// Sort extractors by priority
|
||||
$sortedExtractors = $this->extractors;
|
||||
usort($sortedExtractors, fn ($a, $b) => $b->getPriority() <=> $a->getPriority());
|
||||
|
||||
foreach ($sortedExtractors as $extractor) {
|
||||
if (! $extractor->isEnabled() || ! $extractor->canExtract($requestData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$extractorStart = $this->clock->time();
|
||||
|
||||
// Check timeout
|
||||
if ($extractorStart->diff($this->clock->time())->toMilliseconds() > $this->analysisTimeout->toMilliseconds()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$features = $extractor->extractFeatures($requestData, $context);
|
||||
|
||||
$extractorTime = $extractorStart->diff($this->clock->time());
|
||||
|
||||
// Validate and filter features
|
||||
$validFeatures = $this->validateFeatures($features);
|
||||
$allFeatures = array_merge($allFeatures, $validFeatures);
|
||||
|
||||
$extractorResults[] = [
|
||||
'extractor' => get_class($extractor),
|
||||
'behavior_type' => $extractor->getBehaviorType()->value,
|
||||
'feature_count' => count($validFeatures),
|
||||
'processing_time' => $extractorTime->toMilliseconds(),
|
||||
'success' => true,
|
||||
];
|
||||
|
||||
// Check feature limit
|
||||
if (count($allFeatures) >= $this->maxFeaturesPerRequest) {
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$extractorResults[] = [
|
||||
'extractor' => get_class($extractor),
|
||||
'behavior_type' => $extractor->getBehaviorType()->value,
|
||||
'feature_count' => 0,
|
||||
'processing_time' => 0,
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Cache results
|
||||
if ($this->enableFeatureCaching) {
|
||||
$this->featureCache[$cacheKey] = $allFeatures;
|
||||
|
||||
// Limit cache size
|
||||
if (count($this->featureCache) > 100) {
|
||||
array_shift($this->featureCache);
|
||||
}
|
||||
}
|
||||
|
||||
return $allFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect anomalies in extracted features
|
||||
*/
|
||||
private function detectAnomalies(array $features, array $baselines): array
|
||||
{
|
||||
$allAnomalies = [];
|
||||
$detectorResults = [];
|
||||
|
||||
foreach ($this->detectors as $detector) {
|
||||
if (! $detector->isEnabled() || ! $detector->canAnalyze($features)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$detectorStart = $this->clock->time();
|
||||
|
||||
// Get relevant baseline for this detector
|
||||
$relevantBaseline = $this->getRelevantBaseline($detector, $baselines);
|
||||
|
||||
$anomalies = $detector->detectAnomalies($features, $relevantBaseline);
|
||||
|
||||
$detectorTime = $detectorStart->diff($this->clock->time());
|
||||
|
||||
// Filter by confidence threshold
|
||||
$validAnomalies = array_filter(
|
||||
$anomalies,
|
||||
fn (AnomalyDetection $anomaly) => $anomaly->confidence->getValue() >= $this->confidenceThreshold->getValue()
|
||||
);
|
||||
|
||||
$allAnomalies = array_merge($allAnomalies, $validAnomalies);
|
||||
|
||||
$detectorResults[] = [
|
||||
'detector' => get_class($detector),
|
||||
'detector_name' => $detector->getName(),
|
||||
'anomaly_count' => count($validAnomalies),
|
||||
'processing_time' => $detectorTime->toMilliseconds(),
|
||||
'success' => true,
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$detectorResults[] = [
|
||||
'detector' => get_class($detector),
|
||||
'detector_name' => $detector->getName(),
|
||||
'anomaly_count' => 0,
|
||||
'processing_time' => 0,
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate anomalies and rank by severity
|
||||
return $this->deduplicateAndRankAnomalies($allAnomalies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get behavioral baselines for analysis
|
||||
*/
|
||||
private function getBaselines(array $features): array
|
||||
{
|
||||
$baselines = [];
|
||||
|
||||
// Group features by behavior type
|
||||
$featureGroups = [];
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature) {
|
||||
$typeKey = $feature->type->value;
|
||||
if (! isset($featureGroups[$typeKey])) {
|
||||
$featureGroups[$typeKey] = [];
|
||||
}
|
||||
$featureGroups[$typeKey][] = $feature;
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create baselines for each behavior type
|
||||
foreach ($featureGroups as $behaviorType => $groupFeatures) {
|
||||
$cacheKey = "baseline:{$behaviorType}";
|
||||
|
||||
if (isset($this->baselineCache[$cacheKey])) {
|
||||
$baselines[$behaviorType] = $this->baselineCache[$cacheKey];
|
||||
} else {
|
||||
// Create new baseline from features
|
||||
$baseline = $this->createBaselineFromFeatures($groupFeatures, BehaviorType::from($behaviorType));
|
||||
if ($baseline !== null) {
|
||||
$baselines[$behaviorType] = $baseline;
|
||||
$this->baselineCache[$cacheKey] = $baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $baselines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create behavioral baseline from feature set
|
||||
*/
|
||||
private function createBaselineFromFeatures(array $features, BehaviorType $behaviorType): ?BehaviorBaseline
|
||||
{
|
||||
if (empty($features)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$values = array_map(fn (BehaviorFeature $f) => $f->value, $features);
|
||||
|
||||
if (empty($values)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mean = array_sum($values) / count($values);
|
||||
$variance = array_sum(array_map(fn ($v) => pow($v - $mean, 2), $values)) / count($values);
|
||||
$stdDev = sqrt($variance);
|
||||
|
||||
sort($values);
|
||||
$p50 = $values[(int)(count($values) * 0.5)];
|
||||
$p95 = $values[(int)(count($values) * 0.95)];
|
||||
$p99 = $values[(int)(count($values) * 0.99)];
|
||||
|
||||
return new BehaviorBaseline(
|
||||
type: $behaviorType,
|
||||
mean: $mean,
|
||||
standardDeviation: $stdDev,
|
||||
sampleSize: count($values),
|
||||
p50: $p50,
|
||||
p95: $p95,
|
||||
p99: $p99,
|
||||
confidence: Percentage::from(min(100.0, count($values) * 5.0)), // 5% per sample, max 100%
|
||||
lastUpdated: $this->clock->time()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relevant baseline for a detector
|
||||
*/
|
||||
private function getRelevantBaseline(AnomalyDetectorInterface $detector, array $baselines): ?BehaviorBaseline
|
||||
{
|
||||
$supportedTypes = $detector->getSupportedBehaviorTypes();
|
||||
|
||||
foreach ($supportedTypes as $behaviorType) {
|
||||
$typeKey = $behaviorType->value;
|
||||
if (isset($baselines[$typeKey])) {
|
||||
return $baselines[$typeKey];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate extracted features
|
||||
*/
|
||||
private function validateFeatures(array $features): array
|
||||
{
|
||||
$validFeatures = [];
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature) {
|
||||
// Validate feature values
|
||||
if (is_numeric($feature->value) &&
|
||||
! is_nan($feature->value) &&
|
||||
! is_infinite($feature->value)) {
|
||||
$validFeatures[] = $feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $validFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall confidence from anomaly detections
|
||||
*/
|
||||
private function calculateOverallConfidence(array $anomalies): Percentage
|
||||
{
|
||||
if (empty($anomalies)) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$confidenceSum = 0.0;
|
||||
$weightSum = 0.0;
|
||||
|
||||
foreach ($anomalies as $anomaly) {
|
||||
if ($anomaly instanceof AnomalyDetection) {
|
||||
$weight = $anomaly->anomalyScore; // Use anomaly score as weight
|
||||
$confidenceSum += $anomaly->confidence->getValue() * $weight;
|
||||
$weightSum += $weight;
|
||||
}
|
||||
}
|
||||
|
||||
$overallConfidence = $weightSum > 0 ? $confidenceSum / $weightSum : 0.0;
|
||||
|
||||
return Percentage::from(min(100.0, $overallConfidence));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine learning models with new data
|
||||
*/
|
||||
private function updateModels(array $features): void
|
||||
{
|
||||
foreach ($this->detectors as $detector) {
|
||||
if ($detector->isEnabled()) {
|
||||
try {
|
||||
$detector->updateModel($features);
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but continue processing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply feedback-based adjustments to machine learning models
|
||||
*
|
||||
* @param array<string, ValueObjects\ModelAdjustment> $adjustments Adjustments to apply
|
||||
* @return array<string, mixed> Results of applying adjustments
|
||||
*/
|
||||
public function applyFeedbackAdjustments(array $adjustments): array
|
||||
{
|
||||
if (empty($adjustments)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'applied_count' => 0,
|
||||
'message' => 'No adjustments to apply',
|
||||
];
|
||||
}
|
||||
|
||||
$appliedCount = 0;
|
||||
$failedCount = 0;
|
||||
$results = [];
|
||||
|
||||
foreach ($adjustments as $id => $adjustment) {
|
||||
try {
|
||||
// Find detectors that handle this category
|
||||
$applicableDetectors = $this->findDetectorsForCategory($adjustment->category);
|
||||
|
||||
if (empty($applicableDetectors)) {
|
||||
$results[$id] = [
|
||||
'success' => false,
|
||||
'message' => 'No applicable detectors found for category: ' . $adjustment->category->value,
|
||||
];
|
||||
$failedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply adjustments to each applicable detector
|
||||
$detectorResults = [];
|
||||
foreach ($applicableDetectors as $detector) {
|
||||
$detectorResult = $this->applyAdjustmentToDetector($detector, $adjustment);
|
||||
$detectorResults[$detector::class] = $detectorResult;
|
||||
}
|
||||
|
||||
$results[$id] = [
|
||||
'success' => true,
|
||||
'detector_results' => $detectorResults,
|
||||
'adjustment' => $adjustment->toArray(),
|
||||
];
|
||||
|
||||
$appliedCount++;
|
||||
} catch (\Throwable $e) {
|
||||
$results[$id] = [
|
||||
'success' => false,
|
||||
'message' => 'Error applying adjustment: ' . $e->getMessage(),
|
||||
'adjustment' => $adjustment->toArray(),
|
||||
];
|
||||
$failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $failedCount === 0,
|
||||
'applied_count' => $appliedCount,
|
||||
'failed_count' => $failedCount,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find detectors that handle a specific category
|
||||
*
|
||||
* @param DetectionCategory $category The category to find detectors for
|
||||
* @return array<AnomalyDetectorInterface> Applicable detectors
|
||||
*/
|
||||
private function findDetectorsForCategory(DetectionCategory $category): array
|
||||
{
|
||||
// In a real implementation, this would use detector metadata or capabilities
|
||||
// to determine which detectors can handle which categories
|
||||
|
||||
// For now, we'll use a simplified approach based on detector class names
|
||||
$applicableDetectors = [];
|
||||
|
||||
foreach ($this->detectors as $detector) {
|
||||
// Check if detector handles this category based on class name or metadata
|
||||
$detectorClass = get_class($detector);
|
||||
$categoryName = $category->value;
|
||||
|
||||
// Simple heuristic: if detector class name contains category name or is generic
|
||||
if (
|
||||
stripos($detectorClass, $categoryName) !== false ||
|
||||
stripos($detectorClass, 'Generic') !== false ||
|
||||
stripos($detectorClass, 'Statistical') !== false ||
|
||||
stripos($detectorClass, 'Clustering') !== false
|
||||
) {
|
||||
$applicableDetectors[] = $detector;
|
||||
}
|
||||
}
|
||||
|
||||
return $applicableDetectors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a model adjustment to a specific detector
|
||||
*
|
||||
* @param AnomalyDetectorInterface $detector The detector to adjust
|
||||
* @param ValueObjects\ModelAdjustment $adjustment The adjustment to apply
|
||||
* @return array<string, mixed> Result of applying the adjustment
|
||||
*/
|
||||
private function applyAdjustmentToDetector(
|
||||
AnomalyDetectorInterface $detector,
|
||||
ValueObjects\ModelAdjustment $adjustment
|
||||
): array {
|
||||
$result = [
|
||||
'threshold_adjusted' => false,
|
||||
'confidence_adjusted' => false,
|
||||
'features_adjusted' => 0,
|
||||
];
|
||||
|
||||
// Apply threshold adjustment if detector supports it
|
||||
if ($detector instanceof ThresholdAdjustableInterface && ! $adjustment->thresholdAdjustment->isZero()) {
|
||||
$detector->adjustThreshold($adjustment->thresholdAdjustment);
|
||||
$result['threshold_adjusted'] = true;
|
||||
}
|
||||
|
||||
// Apply confidence adjustment if detector supports it
|
||||
if ($detector instanceof ConfidenceAdjustableInterface && ! $adjustment->confidenceAdjustment->isZero()) {
|
||||
$detector->adjustConfidence($adjustment->confidenceAdjustment);
|
||||
$result['confidence_adjusted'] = true;
|
||||
}
|
||||
|
||||
// Apply feature weight adjustments if detector supports it
|
||||
if ($detector instanceof FeatureWeightAdjustableInterface && $adjustment->hasFeatureWeightAdjustments()) {
|
||||
$adjustedFeatures = $detector->adjustFeatureWeights($adjustment->featureWeightAdjustments);
|
||||
$result['features_adjusted'] = count($adjustedFeatures);
|
||||
$result['adjusted_features'] = $adjustedFeatures;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate and rank anomalies by severity
|
||||
*/
|
||||
private function deduplicateAndRankAnomalies(array $anomalies): array
|
||||
{
|
||||
// Remove duplicates based on type and behavior type
|
||||
$seen = [];
|
||||
$unique = [];
|
||||
|
||||
foreach ($anomalies as $anomaly) {
|
||||
if ($anomaly instanceof AnomalyDetection) {
|
||||
$key = $anomaly->type->value . ':' . $anomaly->behaviorType->value;
|
||||
if (! isset($seen[$key]) || $anomaly->confidence->getValue() > $seen[$key]->confidence->getValue()) {
|
||||
$seen[$key] = $anomaly;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$unique = array_values($seen);
|
||||
|
||||
// Sort by anomaly score (descending)
|
||||
usort($unique, fn ($a, $b) => $b->anomalyScore <=> $a->anomalyScore);
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for features
|
||||
*/
|
||||
private function generateFeatureCacheKey(RequestAnalysisData $requestData): string
|
||||
{
|
||||
return md5(serialize([
|
||||
'path' => $requestData->path,
|
||||
'method' => $requestData->method,
|
||||
'params' => $requestData->getAllParameters(),
|
||||
'user_agent' => $requestData->userAgent?->toString(),
|
||||
'ip' => $requestData->clientIp?->toString(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extractor results summary
|
||||
*/
|
||||
private function getExtractorResults(array $features): array
|
||||
{
|
||||
$results = [];
|
||||
$featuresByType = [];
|
||||
|
||||
foreach ($features as $feature) {
|
||||
if ($feature instanceof BehaviorFeature) {
|
||||
$typeKey = $feature->type->value;
|
||||
if (! isset($featuresByType[$typeKey])) {
|
||||
$featuresByType[$typeKey] = [];
|
||||
}
|
||||
$featuresByType[$typeKey][] = $feature;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($featuresByType as $behaviorType => $typeFeatures) {
|
||||
$results[] = [
|
||||
'behavior_type' => $behaviorType,
|
||||
'feature_count' => count($typeFeatures),
|
||||
'avg_value' => array_sum(array_map(fn ($f) => $f->value, $typeFeatures)) / count($typeFeatures),
|
||||
'feature_names' => array_unique(array_map(fn ($f) => $f->name, $typeFeatures)),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detector results summary
|
||||
*/
|
||||
private function getDetectorResults(array $anomalies): array
|
||||
{
|
||||
$results = [];
|
||||
$anomaliesByDetector = [];
|
||||
|
||||
foreach ($anomalies as $anomaly) {
|
||||
if ($anomaly instanceof AnomalyDetection) {
|
||||
$detectorKey = $anomaly->type->value;
|
||||
if (! isset($anomaliesByDetector[$detectorKey])) {
|
||||
$anomaliesByDetector[$detectorKey] = [];
|
||||
}
|
||||
$anomaliesByDetector[$detectorKey][] = $anomaly;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($anomaliesByDetector as $detectorType => $detectorAnomalies) {
|
||||
$results[] = [
|
||||
'detector_type' => $detectorType,
|
||||
'anomaly_count' => count($detectorAnomalies),
|
||||
'avg_confidence' => array_sum(array_map(fn ($a) => $a->confidence->getValue(), $detectorAnomalies)) / count($detectorAnomalies),
|
||||
'max_score' => max(array_map(fn ($a) => $a->anomalyScore, $detectorAnomalies)),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get baseline statistics summary
|
||||
*/
|
||||
private function getBaselineStats(array $baselines): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($baselines as $behaviorType => $baseline) {
|
||||
if ($baseline instanceof BehaviorBaseline) {
|
||||
$stats[] = [
|
||||
'behavior_type' => $behaviorType,
|
||||
'sample_size' => $baseline->sampleSize,
|
||||
'mean' => $baseline->mean,
|
||||
'std_dev' => $baseline->standardDeviation,
|
||||
'confidence' => $baseline->confidence->getValue(),
|
||||
'last_updated' => $baseline->lastUpdated->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record performance metrics
|
||||
*/
|
||||
private function recordPerformanceMetrics(Duration $processingTime, int $featureCount, int $anomalyCount): void
|
||||
{
|
||||
$this->performanceMetrics[] = [
|
||||
'timestamp' => $this->clock->time()->toUnixTimestamp(),
|
||||
'processing_time_ms' => $processingTime->toMilliseconds(),
|
||||
'feature_count' => $featureCount,
|
||||
'anomaly_count' => $anomalyCount,
|
||||
];
|
||||
|
||||
// Limit metrics history
|
||||
if (count($this->performanceMetrics) > 1000) {
|
||||
array_shift($this->performanceMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
*/
|
||||
public function getPerformanceStats(): array
|
||||
{
|
||||
if (empty($this->performanceMetrics)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$processingTimes = array_column($this->performanceMetrics, 'processing_time_ms');
|
||||
$featureCounts = array_column($this->performanceMetrics, 'feature_count');
|
||||
$anomalyCounts = array_column($this->performanceMetrics, 'anomaly_count');
|
||||
|
||||
return [
|
||||
'total_requests' => count($this->performanceMetrics),
|
||||
'avg_processing_time_ms' => array_sum($processingTimes) / count($processingTimes),
|
||||
'max_processing_time_ms' => max($processingTimes),
|
||||
'avg_feature_count' => array_sum($featureCounts) / count($featureCounts),
|
||||
'avg_anomaly_count' => array_sum($anomalyCounts) / count($anomalyCounts),
|
||||
'cache_hit_ratio' => $this->enableFeatureCaching ? count($this->featureCache) / max(count($this->performanceMetrics), 1) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration
|
||||
*/
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'analysis_timeout_ms' => $this->analysisTimeout->toMilliseconds(),
|
||||
'confidence_threshold' => $this->confidenceThreshold->getValue(),
|
||||
'enable_parallel_processing' => $this->enableParallelProcessing,
|
||||
'enable_feature_caching' => $this->enableFeatureCaching,
|
||||
'max_features_per_request' => $this->maxFeaturesPerRequest,
|
||||
'extractor_count' => count($this->extractors),
|
||||
'detector_count' => count($this->detectors),
|
||||
'cache_size' => count($this->featureCache),
|
||||
'baseline_count' => count($this->baselineCache),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if engine is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
}
|
||||
189
src/Framework/Waf/MachineLearning/MachineLearningResult.php
Normal file
189
src/Framework/Waf/MachineLearning/MachineLearningResult.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\AnomalyDetection;
|
||||
use App\Framework\Waf\MachineLearning\ValueObjects\BehaviorFeature;
|
||||
|
||||
/**
|
||||
* Result of machine learning analysis
|
||||
*/
|
||||
final readonly class MachineLearningResult
|
||||
{
|
||||
/**
|
||||
* @param BehaviorFeature[] $features
|
||||
* @param AnomalyDetection[] $anomalies
|
||||
*/
|
||||
public function __construct(
|
||||
public array $features,
|
||||
public array $anomalies,
|
||||
public Percentage $confidence,
|
||||
public Duration $processingTime,
|
||||
public bool $enabled,
|
||||
public array $extractorResults = [],
|
||||
public array $detectorResults = [],
|
||||
public array $baselineStats = [],
|
||||
public ?string $error = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any anomalies were detected
|
||||
*/
|
||||
public function hasAnomalies(): bool
|
||||
{
|
||||
return ! empty($this->anomalies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest anomaly score
|
||||
*/
|
||||
public function getMaxAnomalyScore(): float
|
||||
{
|
||||
if (empty($this->anomalies)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return max(array_map(fn (AnomalyDetection $a) => $a->anomalyScore, $this->anomalies));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anomalies by type
|
||||
*/
|
||||
public function getAnomaliesByType(AnomalyType $type): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->anomalies,
|
||||
fn (AnomalyDetection $a) => $a->type === $type
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get features by behavior type
|
||||
*/
|
||||
public function getFeaturesByBehaviorType(BehaviorType $type): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->features,
|
||||
fn (BehaviorFeature $f) => $f->type === $type
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical anomalies (high confidence and score)
|
||||
*/
|
||||
public function getCriticalAnomalies(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->anomalies,
|
||||
fn (AnomalyDetection $a) => $a->confidence->getValue() >= 90.0 && $a->anomalyScore >= 0.8
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing summary
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'feature_count' => count($this->features),
|
||||
'anomaly_count' => count($this->anomalies),
|
||||
'critical_anomaly_count' => count($this->getCriticalAnomalies()),
|
||||
'max_anomaly_score' => $this->getMaxAnomalyScore(),
|
||||
'confidence' => $this->confidence->getValue(),
|
||||
'processing_time_ms' => $this->processingTime->toMilliseconds(),
|
||||
'has_error' => $this->error !== null,
|
||||
'error_message' => $this->error,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed analysis report
|
||||
*/
|
||||
public function getDetailedReport(): array
|
||||
{
|
||||
$report = $this->getSummary();
|
||||
|
||||
// Add extractor details
|
||||
$report['extractors'] = $this->extractorResults;
|
||||
|
||||
// Add detector details
|
||||
$report['detectors'] = $this->detectorResults;
|
||||
|
||||
// Add baseline statistics
|
||||
$report['baselines'] = $this->baselineStats;
|
||||
|
||||
// Add anomaly details
|
||||
$report['anomaly_details'] = array_map(
|
||||
fn (AnomalyDetection $a) => [
|
||||
'type' => $a->type->value,
|
||||
'behavior_type' => $a->behaviorType->value,
|
||||
'score' => $a->anomalyScore,
|
||||
'confidence' => $a->confidence->getValue(),
|
||||
'description' => $a->description,
|
||||
'feature_count' => count($a->features),
|
||||
'evidence_keys' => array_keys($a->evidence),
|
||||
],
|
||||
$this->anomalies
|
||||
);
|
||||
|
||||
// Add feature summary by type
|
||||
$featuresByType = [];
|
||||
foreach ($this->features as $feature) {
|
||||
$typeKey = $feature->type->value;
|
||||
if (! isset($featuresByType[$typeKey])) {
|
||||
$featuresByType[$typeKey] = [];
|
||||
}
|
||||
$featuresByType[$typeKey][] = [
|
||||
'name' => $feature->name,
|
||||
'value' => $feature->value,
|
||||
'unit' => $feature->unit,
|
||||
'normalized' => $feature->normalizedValue,
|
||||
'z_score' => $feature->zScore,
|
||||
];
|
||||
}
|
||||
$report['features_by_type'] = $featuresByType;
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'confidence' => $this->confidence->getValue(),
|
||||
'processing_time_ms' => $this->processingTime->toMilliseconds(),
|
||||
'feature_count' => count($this->features),
|
||||
'anomaly_count' => count($this->anomalies),
|
||||
'max_anomaly_score' => $this->getMaxAnomalyScore(),
|
||||
'has_anomalies' => $this->hasAnomalies(),
|
||||
'critical_anomaly_count' => count($this->getCriticalAnomalies()),
|
||||
'error' => $this->error,
|
||||
'summary' => $this->getSummary(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analysis was successful
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this->enabled && $this->error === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if immediate action is required
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return $this->getMaxAnomalyScore() >= 0.9 || count($this->getCriticalAnomalies()) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Interface for anomaly detectors that support threshold adjustments
|
||||
*/
|
||||
interface ThresholdAdjustableInterface
|
||||
{
|
||||
/**
|
||||
* Adjust the detection threshold by the specified percentage
|
||||
*
|
||||
* Positive adjustment: Increase threshold (less sensitive, fewer detections)
|
||||
* Negative adjustment: Decrease threshold (more sensitive, more detections)
|
||||
*
|
||||
* @param Percentage $adjustment The percentage to adjust the threshold by
|
||||
* @return bool True if adjustment was successful
|
||||
*/
|
||||
public function adjustThreshold(Percentage $adjustment): bool;
|
||||
|
||||
/**
|
||||
* Get the current threshold value
|
||||
*
|
||||
* @return float The current threshold value
|
||||
*/
|
||||
public function getThreshold(): float;
|
||||
|
||||
/**
|
||||
* Set the threshold to a specific value
|
||||
*
|
||||
* @param float $threshold The new threshold value
|
||||
* @return bool True if setting was successful
|
||||
*/
|
||||
public function setThreshold(float $threshold): bool;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\MachineLearning\AnomalyType;
|
||||
use App\Framework\Waf\MachineLearning\BehaviorType;
|
||||
|
||||
/**
|
||||
* Represents a detected behavioral anomaly
|
||||
*/
|
||||
final readonly class AnomalyDetection
|
||||
{
|
||||
public function __construct(
|
||||
public AnomalyType $type,
|
||||
public BehaviorType $behaviorType,
|
||||
public Percentage $confidence,
|
||||
public float $anomalyScore,
|
||||
public string $description,
|
||||
public array $features,
|
||||
public array $evidence,
|
||||
public ?string $clientId = null,
|
||||
public ?string $sessionId = null,
|
||||
public ?Timestamp $detectedAt = null,
|
||||
public ?Duration $analysisWindow = null,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anomaly detection with automatic confidence calculation
|
||||
*/
|
||||
public static function create(
|
||||
AnomalyType $type,
|
||||
BehaviorType $behaviorType,
|
||||
float $anomalyScore,
|
||||
string $description,
|
||||
array $features = [],
|
||||
array $evidence = []
|
||||
): self {
|
||||
// Calculate confidence based on anomaly score and feature consistency
|
||||
$baseConfidence = min($anomalyScore * 100, 100.0);
|
||||
|
||||
// Adjust confidence based on feature agreement
|
||||
if (! empty($features)) {
|
||||
$featureAnomalyScores = array_map(
|
||||
fn (BehaviorFeature $feature) => $feature->getAnomalyScore(),
|
||||
$features
|
||||
);
|
||||
|
||||
$meanFeatureScore = array_sum($featureAnomalyScores) / count($featureAnomalyScores);
|
||||
$featureConsistency = 1.0 - (abs($anomalyScore - $meanFeatureScore) / max($anomalyScore, 0.01));
|
||||
|
||||
$baseConfidence *= $featureConsistency;
|
||||
}
|
||||
|
||||
$confidence = Percentage::from(max(0.0, min(100.0, $baseConfidence)));
|
||||
|
||||
return new self(
|
||||
type: $type,
|
||||
behaviorType: $behaviorType,
|
||||
confidence: $confidence,
|
||||
anomalyScore: $anomalyScore,
|
||||
description: $description,
|
||||
features: $features,
|
||||
evidence: $evidence,
|
||||
detectedAt: Timestamp::fromFloat(microtime(true))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create frequency spike anomaly
|
||||
*/
|
||||
public static function frequencySpike(
|
||||
float $currentRate,
|
||||
float $baseline,
|
||||
float $threshold = 3.0,
|
||||
?string $clientId = null
|
||||
): self {
|
||||
$ratio = $baseline > 0 ? $currentRate / $baseline : $currentRate;
|
||||
$anomalyScore = min(($ratio - 1.0) / $threshold, 1.0);
|
||||
|
||||
return self::create(
|
||||
type: AnomalyType::FREQUENCY_SPIKE,
|
||||
behaviorType: BehaviorType::REQUEST_FREQUENCY,
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Request frequency spike detected: {$currentRate}/s (baseline: {$baseline}/s, ratio: " . round($ratio, 2) . "x)",
|
||||
evidence: [
|
||||
'current_rate' => $currentRate,
|
||||
'baseline_rate' => $baseline,
|
||||
'spike_ratio' => $ratio,
|
||||
'threshold' => $threshold,
|
||||
]
|
||||
);
|
||||
|
||||
return $clientId !== null ? $anomaly->withClientId($clientId) : $anomaly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create geographic anomaly
|
||||
*/
|
||||
public static function geographicAnomaly(
|
||||
string $currentLocation,
|
||||
array $normalLocations,
|
||||
float $distance,
|
||||
?string $clientId = null
|
||||
): self {
|
||||
$anomalyScore = min($distance / 10000, 1.0); // Normalize by 10,000 km
|
||||
|
||||
return self::create(
|
||||
type: AnomalyType::GEOGRAPHIC_ANOMALY,
|
||||
behaviorType: BehaviorType::GEOGRAPHIC_PATTERNS,
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Geographic anomaly: access from {$currentLocation}, distance: " . round($distance) . "km from normal locations",
|
||||
evidence: [
|
||||
'current_location' => $currentLocation,
|
||||
'normal_locations' => $normalLocations,
|
||||
'distance_km' => $distance,
|
||||
]
|
||||
);
|
||||
|
||||
return $clientId !== null ? $anomaly->withClientId($clientId) : $anomaly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pattern deviation anomaly
|
||||
*/
|
||||
public static function patternDeviation(
|
||||
BehaviorType $behaviorType,
|
||||
string $pattern,
|
||||
float $deviationScore,
|
||||
array $features = []
|
||||
): self {
|
||||
return self::create(
|
||||
type: AnomalyType::UNUSUAL_PATTERN,
|
||||
behaviorType: $behaviorType,
|
||||
anomalyScore: $deviationScore,
|
||||
description: "Unusual pattern detected in {$behaviorType->getDescription()}: {$pattern}",
|
||||
features: $features,
|
||||
evidence: [
|
||||
'pattern' => $pattern,
|
||||
'deviation_score' => $deviationScore,
|
||||
'feature_count' => count($features),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create statistical anomaly
|
||||
*/
|
||||
public static function statisticalAnomaly(
|
||||
BehaviorType $behaviorType,
|
||||
string $metric,
|
||||
float $value,
|
||||
float $expectedValue,
|
||||
float $standardDeviation,
|
||||
?string $clientId = null
|
||||
): self {
|
||||
$zScore = $standardDeviation > 0 ? abs($value - $expectedValue) / $standardDeviation : 0;
|
||||
$anomalyScore = min($zScore / 3.0, 1.0); // Normalize by 3 sigma
|
||||
|
||||
return self::create(
|
||||
type: AnomalyType::STATISTICAL_ANOMALY,
|
||||
behaviorType: $behaviorType,
|
||||
anomalyScore: $anomalyScore,
|
||||
description: "Statistical anomaly in {$metric}: value={$value}, expected={$expectedValue}, z-score=" . round($zScore, 2),
|
||||
evidence: [
|
||||
'metric' => $metric,
|
||||
'value' => $value,
|
||||
'expected_value' => $expectedValue,
|
||||
'standard_deviation' => $standardDeviation,
|
||||
'z_score' => $zScore,
|
||||
]
|
||||
);
|
||||
|
||||
return $clientId !== null ? $anomaly->withClientId($clientId) : $anomaly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add client ID
|
||||
*/
|
||||
public function withClientId(string $clientId): self
|
||||
{
|
||||
return new self(
|
||||
type: $this->type,
|
||||
behaviorType: $this->behaviorType,
|
||||
confidence: $this->confidence,
|
||||
anomalyScore: $this->anomalyScore,
|
||||
description: $this->description,
|
||||
features: $this->features,
|
||||
evidence: $this->evidence,
|
||||
clientId: $clientId,
|
||||
sessionId: $this->sessionId,
|
||||
detectedAt: $this->detectedAt,
|
||||
analysisWindow: $this->analysisWindow,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add session ID
|
||||
*/
|
||||
public function withSessionId(string $sessionId): self
|
||||
{
|
||||
return new self(
|
||||
type: $this->type,
|
||||
behaviorType: $this->behaviorType,
|
||||
confidence: $this->confidence,
|
||||
anomalyScore: $this->anomalyScore,
|
||||
description: $this->description,
|
||||
features: $this->features,
|
||||
evidence: $this->evidence,
|
||||
clientId: $this->clientId,
|
||||
sessionId: $sessionId,
|
||||
detectedAt: $this->detectedAt,
|
||||
analysisWindow: $this->analysisWindow,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add analysis window
|
||||
*/
|
||||
public function withAnalysisWindow(Duration $window): self
|
||||
{
|
||||
return new self(
|
||||
type: $this->type,
|
||||
behaviorType: $this->behaviorType,
|
||||
confidence: $this->confidence,
|
||||
anomalyScore: $this->anomalyScore,
|
||||
description: $this->description,
|
||||
features: $this->features,
|
||||
evidence: $this->evidence,
|
||||
clientId: $this->clientId,
|
||||
sessionId: $this->sessionId,
|
||||
detectedAt: $this->detectedAt,
|
||||
analysisWindow: $window,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if anomaly requires immediate action
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return $this->type->requiresImmediateAction() &&
|
||||
$this->confidence->getValue() >= $this->type->getConfidenceThreshold() * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level
|
||||
*/
|
||||
public function getRiskLevel(): string
|
||||
{
|
||||
$confidenceScore = $this->confidence->getValue() / 100.0;
|
||||
$combinedScore = ($this->anomalyScore + $confidenceScore) / 2.0;
|
||||
|
||||
return match (true) {
|
||||
$combinedScore >= 0.8 => 'critical',
|
||||
$combinedScore >= 0.6 => 'high',
|
||||
$combinedScore >= 0.4 => 'medium',
|
||||
$combinedScore >= 0.2 => 'low',
|
||||
default => 'info'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended action
|
||||
*/
|
||||
public function getRecommendedAction(): string
|
||||
{
|
||||
return $this->type->getRecommendedAction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity score (0-100)
|
||||
*/
|
||||
public function getSeverityScore(): float
|
||||
{
|
||||
$typeWeight = match ($this->type->getSeverityLevel()) {
|
||||
'high' => 0.9,
|
||||
'medium' => 0.6,
|
||||
'low' => 0.3,
|
||||
default => 0.5
|
||||
};
|
||||
|
||||
$confidenceWeight = $this->confidence->getValue() / 100.0;
|
||||
$anomalyWeight = $this->anomalyScore;
|
||||
|
||||
return ($typeWeight * 0.4 + $confidenceWeight * 0.3 + $anomalyWeight * 0.3) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/storage
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'behavior_type' => $this->behaviorType->value,
|
||||
'confidence' => $this->confidence->getValue(),
|
||||
'anomaly_score' => $this->anomalyScore,
|
||||
'description' => $this->description,
|
||||
'client_id' => $this->clientId,
|
||||
'session_id' => $this->sessionId,
|
||||
'detected_at' => $this->detectedAt?->format('c'),
|
||||
'analysis_window_seconds' => $this->analysisWindow?->toSeconds(),
|
||||
'features' => array_map(fn (BehaviorFeature $f) => $f->toArray(), $this->features),
|
||||
'evidence' => $this->evidence,
|
||||
'risk_level' => $this->getRiskLevel(),
|
||||
'severity_score' => $this->getSeverityScore(),
|
||||
'requires_immediate_action' => $this->requiresImmediateAction(),
|
||||
'recommended_action' => $this->getRecommendedAction(),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create summary for dashboard/alerting
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'id' => md5($this->type->value . $this->behaviorType->value . ($this->detectedAt?->format('c') ?? '')),
|
||||
'type' => $this->type->value,
|
||||
'description' => $this->description,
|
||||
'risk_level' => $this->getRiskLevel(),
|
||||
'confidence' => $this->confidence->getValue(),
|
||||
'client_id' => $this->clientId,
|
||||
'detected_at' => $this->detectedAt?->format('c'),
|
||||
'requires_action' => $this->requiresImmediateAction(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\MachineLearning\BehaviorType;
|
||||
|
||||
/**
|
||||
* Represents a behavioral baseline for anomaly detection
|
||||
*/
|
||||
final readonly class BehaviorBaseline
|
||||
{
|
||||
public function __construct(
|
||||
public BehaviorType $type,
|
||||
public string $identifier, // Client ID, IP, or global
|
||||
public float $mean,
|
||||
public float $standardDeviation,
|
||||
public float $median,
|
||||
public float $minimum,
|
||||
public float $maximum,
|
||||
public array $percentiles, // 25th, 75th, 90th, 95th, 99th
|
||||
public int $sampleCount,
|
||||
public Timestamp $createdAt,
|
||||
public Timestamp $lastUpdated,
|
||||
public Duration $windowSize,
|
||||
public float $confidence,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create baseline from statistical data
|
||||
*/
|
||||
public static function fromStatistics(
|
||||
BehaviorType $type,
|
||||
string $identifier,
|
||||
array $values,
|
||||
Duration $windowSize
|
||||
): self {
|
||||
if (empty($values)) {
|
||||
throw new \InvalidArgumentException('Cannot create baseline from empty values');
|
||||
}
|
||||
|
||||
sort($values);
|
||||
$count = count($values);
|
||||
|
||||
$mean = array_sum($values) / $count;
|
||||
$variance = self::calculateVariance($values, $mean);
|
||||
$standardDeviation = sqrt($variance);
|
||||
|
||||
$median = self::calculatePercentile($values, 50);
|
||||
$percentiles = [
|
||||
25 => self::calculatePercentile($values, 25),
|
||||
75 => self::calculatePercentile($values, 75),
|
||||
90 => self::calculatePercentile($values, 90),
|
||||
95 => self::calculatePercentile($values, 95),
|
||||
99 => self::calculatePercentile($values, 99),
|
||||
];
|
||||
|
||||
// Calculate confidence based on sample size and data consistency
|
||||
$confidence = self::calculateConfidence($count, $standardDeviation, $mean);
|
||||
|
||||
$now = Timestamp::now();
|
||||
|
||||
return new self(
|
||||
type: $type,
|
||||
identifier: $identifier,
|
||||
mean: $mean,
|
||||
standardDeviation: $standardDeviation,
|
||||
median: $median,
|
||||
minimum: min($values),
|
||||
maximum: max($values),
|
||||
percentiles: $percentiles,
|
||||
sampleCount: $count,
|
||||
createdAt: $now,
|
||||
lastUpdated: $now,
|
||||
windowSize: $windowSize,
|
||||
confidence: $confidence,
|
||||
metadata: [
|
||||
'variance' => $variance,
|
||||
'range' => max($values) - min($values),
|
||||
'coefficient_of_variation' => $mean > 0 ? $standardDeviation / $mean : 0,
|
||||
'skewness' => self::calculateSkewness($values, $mean, $standardDeviation),
|
||||
'kurtosis' => self::calculateKurtosis($values, $mean, $standardDeviation),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update baseline with new values (exponential moving average)
|
||||
*/
|
||||
public function updateWith(array $newValues, float $learningRate = 0.1): self
|
||||
{
|
||||
if (empty($newValues)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$newMean = array_sum($newValues) / count($newValues);
|
||||
$newVariance = self::calculateVariance($newValues, $newMean);
|
||||
$newStdDev = sqrt($newVariance);
|
||||
|
||||
// Exponential moving average update
|
||||
$updatedMean = $this->mean * (1 - $learningRate) + $newMean * $learningRate;
|
||||
$updatedStdDev = $this->standardDeviation * (1 - $learningRate) + $newStdDev * $learningRate;
|
||||
|
||||
// Update other statistics with weighted average
|
||||
sort($newValues);
|
||||
$newMedian = self::calculatePercentile($newValues, 50);
|
||||
$updatedMedian = $this->median * (1 - $learningRate) + $newMedian * $learningRate;
|
||||
|
||||
$newMin = min($newValues);
|
||||
$newMax = max($newValues);
|
||||
$updatedMin = min($this->minimum, $newMin);
|
||||
$updatedMax = max($this->maximum, $newMax);
|
||||
|
||||
// Update percentiles
|
||||
$updatedPercentiles = [];
|
||||
foreach ([25, 75, 90, 95, 99] as $percentile) {
|
||||
$newPercentileValue = self::calculatePercentile($newValues, $percentile);
|
||||
$updatedPercentiles[$percentile] = $this->percentiles[$percentile] * (1 - $learningRate) +
|
||||
$newPercentileValue * $learningRate;
|
||||
}
|
||||
|
||||
$newSampleCount = $this->sampleCount + count($newValues);
|
||||
$updatedConfidence = self::calculateConfidence($newSampleCount, $updatedStdDev, $updatedMean);
|
||||
|
||||
return new self(
|
||||
type: $this->type,
|
||||
identifier: $this->identifier,
|
||||
mean: $updatedMean,
|
||||
standardDeviation: $updatedStdDev,
|
||||
median: $updatedMedian,
|
||||
minimum: $updatedMin,
|
||||
maximum: $updatedMax,
|
||||
percentiles: $updatedPercentiles,
|
||||
sampleCount: $newSampleCount,
|
||||
createdAt: $this->createdAt,
|
||||
lastUpdated: Timestamp::now(),
|
||||
windowSize: $this->windowSize,
|
||||
confidence: $updatedConfidence,
|
||||
metadata: array_merge($this->metadata, [
|
||||
'last_update_sample_count' => count($newValues),
|
||||
'learning_rate' => $learningRate,
|
||||
'update_timestamp' => Timestamp::now()->toIsoString(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Z-score for a given value
|
||||
*/
|
||||
public function calculateZScore(float $value): float
|
||||
{
|
||||
if ($this->standardDeviation <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return ($value - $this->mean) / $this->standardDeviation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is anomalous based on Z-score threshold
|
||||
*/
|
||||
public function isAnomalous(float $value, float $threshold = 2.0): bool
|
||||
{
|
||||
return abs($this->calculateZScore($value)) > $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anomaly score for a value (0-1, where 1 is most anomalous)
|
||||
*/
|
||||
public function getAnomalyScore(float $value): float
|
||||
{
|
||||
$zScore = abs($this->calculateZScore($value));
|
||||
|
||||
// Use sigmoid function to convert Z-score to 0-1 range
|
||||
return 1 / (1 + exp(-($zScore - 2))); // Threshold at 2 standard deviations
|
||||
}
|
||||
|
||||
/**
|
||||
* Get percentile rank for a value
|
||||
*/
|
||||
public function getPercentileRank(float $value): float
|
||||
{
|
||||
if ($value <= $this->minimum) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($value >= $this->maximum) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
// Approximate percentile rank using normal distribution
|
||||
$zScore = $this->calculateZScore($value);
|
||||
|
||||
// Using standard normal CDF approximation
|
||||
$cdf = 0.5 * (1 + self::erf($zScore / sqrt(2)));
|
||||
|
||||
return $cdf * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if baseline is reliable for detection
|
||||
*/
|
||||
public function isReliable(): bool
|
||||
{
|
||||
$minSamples = $this->type->getMinSampleSize();
|
||||
$minConfidence = 0.7;
|
||||
|
||||
return $this->sampleCount >= $minSamples &&
|
||||
$this->confidence >= $minConfidence &&
|
||||
$this->standardDeviation > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age of baseline
|
||||
*/
|
||||
public function getAge(): Duration
|
||||
{
|
||||
return $this->createdAt->diff(Timestamp::now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if baseline needs refresh
|
||||
*/
|
||||
public function needsRefresh(Duration $maxAge): bool
|
||||
{
|
||||
return $this->getAge()->isGreaterThan($maxAge) ||
|
||||
$this->confidence < 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create feature for anomaly detection
|
||||
*/
|
||||
public function createFeature(string $name, float $value): BehaviorFeature
|
||||
{
|
||||
return BehaviorFeature::create(
|
||||
type: $this->type,
|
||||
name: $name,
|
||||
value: $value,
|
||||
baseline: $this->mean,
|
||||
standardDeviation: $this->standardDeviation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'identifier' => $this->identifier,
|
||||
'mean' => round($this->mean, 4),
|
||||
'std_dev' => round($this->standardDeviation, 4),
|
||||
'median' => round($this->median, 4),
|
||||
'min' => round($this->minimum, 4),
|
||||
'max' => round($this->maximum, 4),
|
||||
'sample_count' => $this->sampleCount,
|
||||
'confidence' => round($this->confidence, 3),
|
||||
'age_hours' => round($this->getAge()->toHours(), 1),
|
||||
'is_reliable' => $this->isReliable(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for storage
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'identifier' => $this->identifier,
|
||||
'mean' => $this->mean,
|
||||
'standard_deviation' => $this->standardDeviation,
|
||||
'median' => $this->median,
|
||||
'minimum' => $this->minimum,
|
||||
'maximum' => $this->maximum,
|
||||
'percentiles' => $this->percentiles,
|
||||
'sample_count' => $this->sampleCount,
|
||||
'created_at' => $this->createdAt->toIsoString(),
|
||||
'last_updated' => $this->lastUpdated->toIsoString(),
|
||||
'window_size_seconds' => $this->windowSize->toSeconds(),
|
||||
'confidence' => $this->confidence,
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate variance
|
||||
*/
|
||||
private static function calculateVariance(array $values, float $mean): float
|
||||
{
|
||||
if (count($values) < 2) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$sumSquaredDifferences = array_sum(array_map(
|
||||
fn ($value) => pow($value - $mean, 2),
|
||||
$values
|
||||
));
|
||||
|
||||
return $sumSquaredDifferences / (count($values) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentile
|
||||
*/
|
||||
private static function calculatePercentile(array $sortedValues, float $percentile): float
|
||||
{
|
||||
$count = count($sortedValues);
|
||||
$index = ($percentile / 100) * ($count - 1);
|
||||
|
||||
if ($index == floor($index)) {
|
||||
return $sortedValues[(int)$index];
|
||||
}
|
||||
|
||||
$lower = $sortedValues[(int)floor($index)];
|
||||
$upper = $sortedValues[(int)ceil($index)];
|
||||
$fraction = $index - floor($index);
|
||||
|
||||
return $lower + ($upper - $lower) * $fraction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence based on sample size and consistency
|
||||
*/
|
||||
private static function calculateConfidence(int $sampleCount, float $stdDev, float $mean): float
|
||||
{
|
||||
// Base confidence on sample size (asymptotic to 1)
|
||||
$sizeConfidence = 1 - exp(-$sampleCount / 50);
|
||||
|
||||
// Penalize high variability
|
||||
$coefficientOfVariation = $mean > 0 ? $stdDev / $mean : 1;
|
||||
$consistencyConfidence = 1 / (1 + $coefficientOfVariation);
|
||||
|
||||
return min(1.0, $sizeConfidence * $consistencyConfidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate skewness
|
||||
*/
|
||||
private static function calculateSkewness(array $values, float $mean, float $stdDev): float
|
||||
{
|
||||
if ($stdDev <= 0 || count($values) < 3) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$n = count($values);
|
||||
$sum = array_sum(array_map(
|
||||
fn ($value) => pow(($value - $mean) / $stdDev, 3),
|
||||
$values
|
||||
));
|
||||
|
||||
return ($n / (($n - 1) * ($n - 2))) * $sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate kurtosis
|
||||
*/
|
||||
private static function calculateKurtosis(array $values, float $mean, float $stdDev): float
|
||||
{
|
||||
if ($stdDev <= 0 || count($values) < 4) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$n = count($values);
|
||||
$sum = array_sum(array_map(
|
||||
fn ($value) => pow(($value - $mean) / $stdDev, 4),
|
||||
$values
|
||||
));
|
||||
|
||||
$kurtosis = (($n * ($n + 1)) / (($n - 1) * ($n - 2) * ($n - 3))) * $sum;
|
||||
$correction = (3 * pow($n - 1, 2)) / (($n - 2) * ($n - 3));
|
||||
|
||||
return $kurtosis - $correction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error function approximation for normal distribution CDF
|
||||
*/
|
||||
private static function erf(float $x): float
|
||||
{
|
||||
// Abramowitz and Stegun approximation
|
||||
$a1 = 0.254829592;
|
||||
$a2 = -0.284496736;
|
||||
$a3 = 1.421413741;
|
||||
$a4 = -1.453152027;
|
||||
$a5 = 1.061405429;
|
||||
$p = 0.3275911;
|
||||
|
||||
$sign = $x < 0 ? -1 : 1;
|
||||
$x = abs($x);
|
||||
|
||||
$t = 1.0 / (1.0 + $p * $x);
|
||||
$y = 1.0 - ((((($a5 * $t + $a4) * $t) + $a3) * $t + $a2) * $t + $a1) * $t * exp(-$x * $x);
|
||||
|
||||
return $sign * $y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\MachineLearning\BehaviorType;
|
||||
|
||||
/**
|
||||
* Represents a single behavioral feature extracted from request data
|
||||
*/
|
||||
final readonly class BehaviorFeature
|
||||
{
|
||||
public function __construct(
|
||||
public BehaviorType $type,
|
||||
public string $name,
|
||||
public float $value,
|
||||
public string $unit,
|
||||
public ?float $normalizedValue = null,
|
||||
public ?float $zScore = null,
|
||||
public ?Timestamp $timestamp = null,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create feature with automatic normalization
|
||||
*/
|
||||
public static function create(
|
||||
BehaviorType $type,
|
||||
string $name,
|
||||
float $value,
|
||||
string $unit = 'count',
|
||||
?float $baseline = null,
|
||||
?float $standardDeviation = null
|
||||
): self {
|
||||
$normalizedValue = null;
|
||||
$zScore = null;
|
||||
|
||||
// Calculate Z-score if baseline and std dev are provided
|
||||
if ($baseline !== null && $standardDeviation !== null && $standardDeviation > 0) {
|
||||
$zScore = ($value - $baseline) / $standardDeviation;
|
||||
// Normalize to 0-1 range using sigmoid function
|
||||
$normalizedValue = 1 / (1 + exp(-$zScore));
|
||||
}
|
||||
|
||||
return new self(
|
||||
type: $type,
|
||||
name: $name,
|
||||
value: $value,
|
||||
unit: $unit,
|
||||
normalizedValue: $normalizedValue,
|
||||
zScore: $zScore,
|
||||
timestamp: Timestamp::now()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create frequency feature
|
||||
*/
|
||||
public static function frequency(
|
||||
string $name,
|
||||
int $count,
|
||||
int $timeWindow,
|
||||
?float $baseline = null
|
||||
): self {
|
||||
$rate = $timeWindow > 0 ? $count / $timeWindow : 0.0;
|
||||
|
||||
return self::create(
|
||||
type: BehaviorType::REQUEST_FREQUENCY,
|
||||
name: $name,
|
||||
value: $rate,
|
||||
unit: 'requests/second',
|
||||
baseline: $baseline
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ratio feature
|
||||
*/
|
||||
public static function ratio(
|
||||
BehaviorType $type,
|
||||
string $name,
|
||||
int $numerator,
|
||||
int $denominator
|
||||
): self {
|
||||
$ratio = $denominator > 0 ? $numerator / $denominator : 0.0;
|
||||
|
||||
return self::create(
|
||||
type: $type,
|
||||
name: $name,
|
||||
value: $ratio,
|
||||
unit: 'ratio'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entropy feature (for measuring randomness/diversity)
|
||||
*/
|
||||
public static function entropy(
|
||||
BehaviorType $type,
|
||||
string $name,
|
||||
array $distribution
|
||||
): self {
|
||||
$entropy = 0.0;
|
||||
$total = array_sum($distribution);
|
||||
|
||||
if ($total > 0) {
|
||||
foreach ($distribution as $count) {
|
||||
if ($count > 0) {
|
||||
$probability = $count / $total;
|
||||
$entropy -= $probability * log($probability, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::create(
|
||||
type: $type,
|
||||
name: $name,
|
||||
value: $entropy,
|
||||
unit: 'bits'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create statistical feature
|
||||
*/
|
||||
public static function statistical(
|
||||
BehaviorType $type,
|
||||
string $name,
|
||||
array $values,
|
||||
string $statistic = 'mean'
|
||||
): self {
|
||||
if (empty($values)) {
|
||||
return self::create($type, $name, 0.0, $statistic);
|
||||
}
|
||||
|
||||
$value = match ($statistic) {
|
||||
'mean' => array_sum($values) / count($values),
|
||||
'median' => self::calculateMedian($values),
|
||||
'std_dev' => self::calculateStandardDeviation($values),
|
||||
'variance' => self::calculateVariance($values),
|
||||
'min' => min($values),
|
||||
'max' => max($values),
|
||||
'range' => max($values) - min($values),
|
||||
default => array_sum($values) / count($values)
|
||||
};
|
||||
|
||||
return self::create(
|
||||
type: $type,
|
||||
name: $name,
|
||||
value: $value,
|
||||
unit: $statistic
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if feature value is anomalous based on Z-score
|
||||
*/
|
||||
public function isAnomalous(float $threshold = 2.0): bool
|
||||
{
|
||||
return $this->zScore !== null && abs($this->zScore) > $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anomaly score (0-1, where 1 is most anomalous)
|
||||
*/
|
||||
public function getAnomalyScore(): float
|
||||
{
|
||||
if ($this->zScore === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Convert Z-score to anomaly score using sigmoid function
|
||||
$absZScore = abs($this->zScore);
|
||||
|
||||
return 1 / (1 + exp(-($absZScore - 2))); // Threshold at 2 standard deviations
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature importance weight
|
||||
*/
|
||||
public function getImportanceWeight(): float
|
||||
{
|
||||
return $this->type->getWeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine with another feature (for ensemble methods)
|
||||
*/
|
||||
public function combineWith(self $other, float $weight = 0.5): self
|
||||
{
|
||||
if ($this->type !== $other->type) {
|
||||
throw new \InvalidArgumentException('Cannot combine features of different types');
|
||||
}
|
||||
|
||||
$combinedValue = ($this->value * $weight) + ($other->value * (1 - $weight));
|
||||
$combinedNormalized = null;
|
||||
$combinedZScore = null;
|
||||
|
||||
if ($this->normalizedValue !== null && $other->normalizedValue !== null) {
|
||||
$combinedNormalized = ($this->normalizedValue * $weight) + ($other->normalizedValue * (1 - $weight));
|
||||
}
|
||||
|
||||
if ($this->zScore !== null && $other->zScore !== null) {
|
||||
$combinedZScore = ($this->zScore * $weight) + ($other->zScore * (1 - $weight));
|
||||
}
|
||||
|
||||
return new self(
|
||||
type: $this->type,
|
||||
name: $this->name . '_combined',
|
||||
value: $combinedValue,
|
||||
unit: $this->unit,
|
||||
normalizedValue: $combinedNormalized,
|
||||
zScore: $combinedZScore,
|
||||
timestamp: Timestamp::now(),
|
||||
metadata: array_merge($this->metadata, $other->metadata, [
|
||||
'combination_weight' => $weight,
|
||||
'combined_from' => [$this->name, $other->name],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for analysis
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'name' => $this->name,
|
||||
'value' => $this->value,
|
||||
'unit' => $this->unit,
|
||||
'normalized_value' => $this->normalizedValue,
|
||||
'z_score' => $this->zScore,
|
||||
'anomaly_score' => $this->getAnomalyScore(),
|
||||
'importance_weight' => $this->getImportanceWeight(),
|
||||
'is_anomalous' => $this->isAnomalous(),
|
||||
'timestamp' => $this->timestamp?->toIsoString(),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate median of array
|
||||
*/
|
||||
private static function calculateMedian(array $values): float
|
||||
{
|
||||
sort($values);
|
||||
$count = count($values);
|
||||
$middle = floor($count / 2);
|
||||
|
||||
if ($count % 2 === 0) {
|
||||
return ($values[$middle - 1] + $values[$middle]) / 2;
|
||||
}
|
||||
|
||||
return $values[$middle];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate standard deviation
|
||||
*/
|
||||
private static function calculateStandardDeviation(array $values): float
|
||||
{
|
||||
return sqrt(self::calculateVariance($values));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate variance
|
||||
*/
|
||||
private static function calculateVariance(array $values): float
|
||||
{
|
||||
if (count($values) < 2) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$mean = array_sum($values) / count($values);
|
||||
$sumSquaredDifferences = array_sum(array_map(
|
||||
fn ($value) => pow($value - $mean, 2),
|
||||
$values
|
||||
));
|
||||
|
||||
return $sumSquaredDifferences / (count($values) - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\MachineLearning\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
|
||||
/**
|
||||
* Value object representing an adjustment to a machine learning model
|
||||
* based on feedback data
|
||||
*/
|
||||
final readonly class ModelAdjustment
|
||||
{
|
||||
/**
|
||||
* @param string $id Unique identifier for this adjustment
|
||||
* @param DetectionCategory $category Detection category this adjustment applies to
|
||||
* @param Percentage $thresholdAdjustment Adjustment to detection threshold (positive = higher threshold, negative = lower threshold)
|
||||
* @param Percentage $confidenceAdjustment Adjustment to confidence score (positive = higher confidence, negative = lower confidence)
|
||||
* @param Timestamp $timestamp When this adjustment was created
|
||||
* @param array<string, float> $featureWeightAdjustments Adjustments to feature weights (feature name => adjustment value)
|
||||
* @param string $description Human-readable description of this adjustment
|
||||
* @param array<string, mixed> $metadata Additional metadata about this adjustment
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public DetectionCategory $category,
|
||||
public Percentage $thresholdAdjustment,
|
||||
public Percentage $confidenceAdjustment,
|
||||
public Timestamp $timestamp,
|
||||
public array $featureWeightAdjustments = [],
|
||||
public string $description = '',
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model adjustment for reducing false positives
|
||||
*
|
||||
* @param DetectionCategory $category The detection category
|
||||
* @param float $adjustmentFactor How strong the adjustment should be (0.0-1.0)
|
||||
* @param array<string, float> $featureWeightAdjustments Specific feature weight adjustments
|
||||
* @param string $reason Reason for this adjustment
|
||||
* @return self
|
||||
*/
|
||||
public static function forReducingFalsePositives(
|
||||
DetectionCategory $category,
|
||||
float $adjustmentFactor,
|
||||
array $featureWeightAdjustments = [],
|
||||
string $reason = ''
|
||||
): self {
|
||||
// Clamp adjustment factor to valid range
|
||||
$factor = max(0.0, min(1.0, $adjustmentFactor));
|
||||
|
||||
// For false positives, we want to:
|
||||
// - Increase threshold (make detection harder)
|
||||
// - Decrease confidence (be less certain about detections)
|
||||
return new self(
|
||||
id: 'fp_' . $category->value . '_' . time(),
|
||||
category: $category,
|
||||
thresholdAdjustment: Percentage::from($factor * 100),
|
||||
confidenceAdjustment: Percentage::from(-$factor * 100),
|
||||
timestamp: Timestamp::now(),
|
||||
featureWeightAdjustments: $featureWeightAdjustments,
|
||||
description: $reason ?: "Adjustment to reduce false positives for {$category->value}",
|
||||
metadata: [
|
||||
'type' => 'false_positive_reduction',
|
||||
'adjustment_factor' => $factor,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model adjustment for reducing false negatives
|
||||
*
|
||||
* @param DetectionCategory $category The detection category
|
||||
* @param float $adjustmentFactor How strong the adjustment should be (0.0-1.0)
|
||||
* @param array<string, float> $featureWeightAdjustments Specific feature weight adjustments
|
||||
* @param string $reason Reason for this adjustment
|
||||
* @return self
|
||||
*/
|
||||
public static function forReducingFalseNegatives(
|
||||
DetectionCategory $category,
|
||||
float $adjustmentFactor,
|
||||
array $featureWeightAdjustments = [],
|
||||
string $reason = ''
|
||||
): self {
|
||||
// Clamp adjustment factor to valid range
|
||||
$factor = max(0.0, min(1.0, $adjustmentFactor));
|
||||
|
||||
// For false negatives, we want to:
|
||||
// - Decrease threshold (make detection easier)
|
||||
// - Increase confidence (be more certain about detections)
|
||||
return new self(
|
||||
id: 'fn_' . $category->value . '_' . time(),
|
||||
category: $category,
|
||||
thresholdAdjustment: Percentage::from(-$factor * 100),
|
||||
confidenceAdjustment: Percentage::from($factor * 100),
|
||||
timestamp: Timestamp::now(),
|
||||
featureWeightAdjustments: $featureWeightAdjustments,
|
||||
description: $reason ?: "Adjustment to reduce false negatives for {$category->value}",
|
||||
metadata: [
|
||||
'type' => 'false_negative_reduction',
|
||||
'adjustment_factor' => $factor,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model adjustment for changing severity
|
||||
*
|
||||
* @param DetectionCategory $category The detection category
|
||||
* @param string $fromSeverity Original severity
|
||||
* @param string $toSeverity New severity
|
||||
* @param float $consensusPercentage Percentage of feedback suggesting this change
|
||||
* @return self
|
||||
*/
|
||||
public static function forSeverityChange(
|
||||
DetectionCategory $category,
|
||||
string $fromSeverity,
|
||||
string $toSeverity,
|
||||
float $consensusPercentage
|
||||
): self {
|
||||
// Determine confidence adjustment based on severity change
|
||||
// If severity is increased, increase confidence
|
||||
// If severity is decreased, decrease confidence
|
||||
$severityValues = [
|
||||
'INFO' => 1,
|
||||
'LOW' => 2,
|
||||
'MEDIUM' => 3,
|
||||
'HIGH' => 4,
|
||||
'CRITICAL' => 5,
|
||||
];
|
||||
|
||||
$fromValue = $severityValues[$fromSeverity] ?? 3;
|
||||
$toValue = $severityValues[$toSeverity] ?? 3;
|
||||
$severityDifference = $toValue - $fromValue;
|
||||
|
||||
// Scale confidence adjustment based on severity difference and consensus
|
||||
$confidenceAdjustment = $severityDifference * 10 * ($consensusPercentage / 100);
|
||||
|
||||
return new self(
|
||||
id: 'sev_' . $category->value . '_' . time(),
|
||||
category: $category,
|
||||
thresholdAdjustment: Percentage::from(0.0), // No threshold adjustment for severity changes
|
||||
confidenceAdjustment: Percentage::from($confidenceAdjustment),
|
||||
timestamp: Timestamp::now(),
|
||||
featureWeightAdjustments: [],
|
||||
description: "Severity adjustment from {$fromSeverity} to {$toSeverity} for {$category->value}",
|
||||
metadata: [
|
||||
'type' => 'severity_adjustment',
|
||||
'from_severity' => $fromSeverity,
|
||||
'to_severity' => $toSeverity,
|
||||
'consensus_percentage' => $consensusPercentage,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this adjustment has feature weight adjustments
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasFeatureWeightAdjustments(): bool
|
||||
{
|
||||
return ! empty($this->featureWeightAdjustments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the adjustment for a specific feature
|
||||
*
|
||||
* @param string $featureName Name of the feature
|
||||
* @return float|null Adjustment value or null if not found
|
||||
*/
|
||||
public function getFeatureAdjustment(string $featureName): ?float
|
||||
{
|
||||
return $this->featureWeightAdjustments[$featureName] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'category' => $this->category->value,
|
||||
'threshold_adjustment' => $this->thresholdAdjustment->getValue(),
|
||||
'confidence_adjustment' => $this->confidenceAdjustment->getValue(),
|
||||
'feature_weight_adjustments' => $this->featureWeightAdjustments,
|
||||
'description' => $this->description,
|
||||
'timestamp' => $this->timestamp->toIso8601String(),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
}
|
||||
261
src/Framework/Waf/PerformanceService.php
Normal file
261
src/Framework/Waf/PerformanceService.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* WAF performance monitoring and optimization service
|
||||
*/
|
||||
final class PerformanceService
|
||||
{
|
||||
private array $timings = [];
|
||||
|
||||
private array $metrics = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly WafConfig $config
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timing measurement
|
||||
*/
|
||||
public function startTiming(string $operation): void
|
||||
{
|
||||
$this->timings[$operation] = [
|
||||
'start' => Timestamp::now(),
|
||||
'end' => null,
|
||||
'duration' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* End timing measurement
|
||||
*/
|
||||
public function endTiming(string $operation): Duration
|
||||
{
|
||||
if (! isset($this->timings[$operation])) {
|
||||
return Duration::fromMilliseconds(0);
|
||||
}
|
||||
|
||||
$timing = $this->timings[$operation];
|
||||
$endTime = Timestamp::now();
|
||||
$duration = $timing['start']->diff($endTime);
|
||||
|
||||
$this->timings[$operation]['end'] = $endTime;
|
||||
$this->timings[$operation]['duration'] = $duration;
|
||||
|
||||
return $duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timing for operation
|
||||
*/
|
||||
public function getTiming(string $operation): ?Duration
|
||||
{
|
||||
return $this->timings[$operation]['duration'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all timings
|
||||
*/
|
||||
public function getAllTimings(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($this->timings as $operation => $timing) {
|
||||
if ($timing['duration'] !== null) {
|
||||
$result[$operation] = $timing['duration'];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record metric
|
||||
*/
|
||||
public function recordMetric(string $name, mixed $value): void
|
||||
{
|
||||
if (! isset($this->metrics[$name])) {
|
||||
$this->metrics[$name] = [];
|
||||
}
|
||||
|
||||
$this->metrics[$name][] = [
|
||||
'value' => $value,
|
||||
'timestamp' => Timestamp::now(),
|
||||
];
|
||||
|
||||
// Keep only last 100 measurements per metric
|
||||
if (count($this->metrics[$name]) > 100) {
|
||||
$this->metrics[$name] = array_slice($this->metrics[$name], -100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metric values
|
||||
*/
|
||||
public function getMetric(string $name): array
|
||||
{
|
||||
return $this->metrics[$name] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest metric value
|
||||
*/
|
||||
public function getLatestMetric(string $name): mixed
|
||||
{
|
||||
$values = $this->getMetric($name);
|
||||
|
||||
if (empty($values)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return end($values)['value'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average metric value
|
||||
*/
|
||||
public function getAverageMetric(string $name): ?float
|
||||
{
|
||||
$values = $this->getMetric($name);
|
||||
|
||||
if (empty($values)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numericValues = array_filter(
|
||||
array_map(fn ($item) => $item['value'], $values),
|
||||
fn ($value) => is_numeric($value)
|
||||
);
|
||||
|
||||
if (empty($numericValues)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_sum($numericValues) / count($numericValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if operation exceeded timeout
|
||||
*/
|
||||
public function hasExceededTimeout(string $operation): bool
|
||||
{
|
||||
$timing = $this->getTiming($operation);
|
||||
|
||||
if ($timing === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $timing->isGreaterThan($this->config->globalTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total processing time
|
||||
*/
|
||||
public function getTotalProcessingTime(): Duration
|
||||
{
|
||||
$totalMs = 0;
|
||||
|
||||
foreach ($this->timings as $timing) {
|
||||
if ($timing['duration'] !== null) {
|
||||
$totalMs += $timing['duration']->toMilliseconds();
|
||||
}
|
||||
}
|
||||
|
||||
return Duration::fromMilliseconds($totalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing efficiency score
|
||||
*/
|
||||
public function getEfficiencyScore(): Percentage
|
||||
{
|
||||
$totalTime = $this->getTotalProcessingTime();
|
||||
$targetTime = $this->config->globalTimeout;
|
||||
|
||||
if ($totalTime->toMilliseconds() === 0) {
|
||||
return Percentage::from(100.0);
|
||||
}
|
||||
|
||||
// Calculate efficiency: lower time = higher efficiency
|
||||
$efficiency = max(0, 100 - (($totalTime->toMilliseconds() / $targetTime->toMilliseconds()) * 100));
|
||||
|
||||
return Percentage::from($efficiency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if performance is acceptable
|
||||
*/
|
||||
public function isPerformanceAcceptable(): bool
|
||||
{
|
||||
$totalTime = $this->getTotalProcessingTime();
|
||||
|
||||
return ! $totalTime->isGreaterThan($this->config->globalTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance warnings
|
||||
*/
|
||||
public function getWarnings(): array
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
// Check total time
|
||||
$totalTime = $this->getTotalProcessingTime();
|
||||
if ($totalTime->isGreaterThan($this->config->globalTimeout)) {
|
||||
$warnings[] = sprintf(
|
||||
'Total processing time (%dms) exceeded global timeout (%dms)',
|
||||
$totalTime->toMilliseconds(),
|
||||
$this->config->globalTimeout->toMilliseconds()
|
||||
);
|
||||
}
|
||||
|
||||
// Check individual operations
|
||||
foreach ($this->timings as $operation => $timing) {
|
||||
if ($timing['duration'] !== null && $this->hasExceededTimeout($operation)) {
|
||||
$warnings[] = sprintf(
|
||||
'Operation "%s" exceeded timeout (%dms)',
|
||||
$operation,
|
||||
$timing['duration']->toMilliseconds()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all measurements
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->timings = [];
|
||||
$this->metrics = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance summary
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'total_processing_time_ms' => $this->getTotalProcessingTime()->toMilliseconds(),
|
||||
'efficiency_score' => $this->getEfficiencyScore()->getValue(),
|
||||
'is_acceptable' => $this->isPerformanceAcceptable(),
|
||||
'operation_count' => count($this->timings),
|
||||
'completed_operations' => count(array_filter($this->timings, fn ($t) => $t['duration'] !== null)),
|
||||
'warnings' => $this->getWarnings(),
|
||||
'individual_timings' => array_map(
|
||||
fn ($duration) => $duration->toMilliseconds(),
|
||||
$this->getAllTimings()
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
473
src/Framework/Waf/Rules/OWASPCoreRuleSet.php
Normal file
473
src/Framework/Waf/Rules/OWASPCoreRuleSet.php
Normal file
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules;
|
||||
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\Rules\ValueObjects\RuleCondition;
|
||||
use App\Framework\Waf\Rules\ValueObjects\RulePattern;
|
||||
use App\Framework\Waf\ValueObjects\RuleId;
|
||||
|
||||
/**
|
||||
* OWASP ModSecurity Core Rule Set (CRS) implementation
|
||||
* Based on OWASP CRS v3.3+ patterns and rules
|
||||
*/
|
||||
final class OWASPCoreRuleSet
|
||||
{
|
||||
/**
|
||||
* Get all OWASP CRS rules
|
||||
*/
|
||||
public static function getAllRules(): array
|
||||
{
|
||||
return array_merge(
|
||||
self::getSqlInjectionRules(),
|
||||
self::getXssRules(),
|
||||
self::getPathTraversalRules(),
|
||||
self::getCommandInjectionRules(),
|
||||
self::getFileUploadRules(),
|
||||
self::getUserAgentRules(),
|
||||
self::getHttpProtocolRules(),
|
||||
self::getApplicationAttackRules(),
|
||||
self::getGenericAttackRules()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Injection Detection Rules (OWASP Top 10 #3)
|
||||
*/
|
||||
public static function getSqlInjectionRules(): array
|
||||
{
|
||||
return [
|
||||
// Basic SQL Injection patterns
|
||||
new Rule(
|
||||
id: RuleId::sql('920100'),
|
||||
name: 'SQL Injection - Union Attack',
|
||||
description: 'Detects SQL UNION attack patterns',
|
||||
category: DetectionCategory::SQL_INJECTION,
|
||||
severity: DetectionSeverity::CRITICAL,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:union[\s\/\*]+(?:all[\s\/\*]+)?select)', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 95,
|
||||
tags: ['sql', 'injection', 'union', 'owasp-top10']
|
||||
),
|
||||
|
||||
// SQL meta-characters
|
||||
new Rule(
|
||||
id: RuleId::sql('920110'),
|
||||
name: 'SQL Injection - Meta Characters',
|
||||
description: 'Detects SQL meta-characters in parameters',
|
||||
category: DetectionCategory::SQL_INJECTION,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:[\'\"`][\s]*(?:or|and)[\s]*[\'\"`]*[\s]*(?:[\'\"`]*[\w]+[\'\"`]*[\s]*=[\s]*[\'\"`]*[\w]+|[\d]+[\s]*=[\s]*[\d]+))', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 90,
|
||||
tags: ['sql', 'injection', 'meta-characters', 'owasp-top10']
|
||||
),
|
||||
|
||||
// SQL comment attacks
|
||||
new Rule(
|
||||
id: RuleId::sql('920120'),
|
||||
name: 'SQL Injection - Comment Attack',
|
||||
description: 'Detects SQL comment-based injection',
|
||||
category: DetectionCategory::SQL_INJECTION,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:(?:--|#|\/\*|\*\/))', 'i')
|
||||
),
|
||||
action: RuleAction::SCORE,
|
||||
priority: 80,
|
||||
tags: ['sql', 'injection', 'comments']
|
||||
),
|
||||
|
||||
// SQL functions
|
||||
new Rule(
|
||||
id: RuleId::sql('920130'),
|
||||
name: 'SQL Injection - Function Detection',
|
||||
description: 'Detects SQL functions in input',
|
||||
category: DetectionCategory::SQL_INJECTION,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:(?:concat|substring|ascii|char|count|group_concat|version|database|user|current_user|system_user|schema|table_name|column_name)[\s]*\()', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 85,
|
||||
tags: ['sql', 'injection', 'functions', 'owasp-top10']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-Site Scripting (XSS) Rules (OWASP Top 10 #7)
|
||||
*/
|
||||
public static function getXssRules(): array
|
||||
{
|
||||
return [
|
||||
// Script tag injection
|
||||
new Rule(
|
||||
id: RuleId::xss('941100'),
|
||||
name: 'XSS - Script Tag Attack',
|
||||
description: 'Detects script tag injection attempts',
|
||||
category: DetectionCategory::XSS,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:<script[^>]*>.*?<\/script>)', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 90,
|
||||
tags: ['xss', 'script', 'owasp-top10']
|
||||
),
|
||||
|
||||
// Event handler injection
|
||||
new Rule(
|
||||
id: RuleId::xss('941110'),
|
||||
name: 'XSS - Event Handler Attack',
|
||||
description: 'Detects JavaScript event handler injection',
|
||||
category: DetectionCategory::XSS,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\s]*=)', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 85,
|
||||
tags: ['xss', 'events', 'owasp-top10']
|
||||
),
|
||||
|
||||
// JavaScript pseudo protocol
|
||||
new Rule(
|
||||
id: RuleId::xss('941120'),
|
||||
name: 'XSS - JavaScript Pseudo Protocol',
|
||||
description: 'Detects javascript: pseudo protocol usage',
|
||||
category: DetectionCategory::XSS,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:javascript[\s]*:)', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 80,
|
||||
tags: ['xss', 'javascript', 'protocol']
|
||||
),
|
||||
|
||||
// HTML injection
|
||||
new Rule(
|
||||
id: RuleId::xss('941130'),
|
||||
name: 'XSS - HTML Tag Injection',
|
||||
description: 'Detects potentially dangerous HTML tags',
|
||||
category: DetectionCategory::XSS,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:<(?:iframe|object|embed|applet|meta|link|style|img|svg|math|details|template)[^>]*>)', 'i')
|
||||
),
|
||||
action: RuleAction::SCORE,
|
||||
priority: 70,
|
||||
tags: ['xss', 'html', 'tags']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Path Traversal Rules
|
||||
*/
|
||||
public static function getPathTraversalRules(): array
|
||||
{
|
||||
return [
|
||||
// Directory traversal
|
||||
new Rule(
|
||||
id: RuleId::pathTraversal('930100'),
|
||||
name: 'Path Traversal - Directory Traversal',
|
||||
description: 'Detects directory traversal attempts',
|
||||
category: DetectionCategory::PATH_TRAVERSAL,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::urlPath(
|
||||
RulePattern::regex('(?i:(?:\.\.[\\/])|(?:[\\/]\.\.)|(?:\.\.\\\\)|(?:\\\\\.\.)|(?:%2e%2e%2f)|(?:%2e%2e\\\\)|(?:\.\.%2f)|(?:\.\.%5c)|(?:%2e%2e%5c)|(?:%c0%ae%c0%ae%c0%af)|(?:%c1%9c%c1%9c%c1%af))', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 85,
|
||||
tags: ['path-traversal', 'directory', 'file-access']
|
||||
),
|
||||
|
||||
// Absolute path access
|
||||
new Rule(
|
||||
id: RuleId::pathTraversal('930110'),
|
||||
name: 'Path Traversal - Absolute Path Access',
|
||||
description: 'Detects absolute path access attempts',
|
||||
category: DetectionCategory::PATH_TRAVERSAL,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:(?:\/etc\/passwd|\/etc\/shadow|\/etc\/hosts|\/proc\/|\/sys\/|c:[\\\\\\/]|\\\\\\\\))', 'i')
|
||||
),
|
||||
action: RuleAction::SCORE,
|
||||
priority: 75,
|
||||
tags: ['path-traversal', 'absolute-path', 'system-files']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Injection Rules
|
||||
*/
|
||||
public static function getCommandInjectionRules(): array
|
||||
{
|
||||
return [
|
||||
// OS Command injection
|
||||
new Rule(
|
||||
id: RuleId::commandInjection('932100'),
|
||||
name: 'Command Injection - OS Commands',
|
||||
description: 'Detects OS command injection attempts',
|
||||
category: DetectionCategory::COMMAND_INJECTION,
|
||||
severity: DetectionSeverity::CRITICAL,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:(?:;|\||\|\||&&|&|`|\$\(|\${)[\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown|find|grep|awk|sed|sort|head|tail|wc|netstat|ifconfig|ping|wget|curl|nc|telnet|ssh|su|sudo|passwd|shadow|etc\/passwd|etc\/shadow|proc\/))', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 95,
|
||||
tags: ['command-injection', 'os-injection', 'rce', 'owasp-top10']
|
||||
),
|
||||
|
||||
// Shell metacharacters
|
||||
new Rule(
|
||||
id: RuleId::commandInjection('932110'),
|
||||
name: 'Command Injection - Shell Metacharacters',
|
||||
description: 'Detects shell metacharacters',
|
||||
category: DetectionCategory::COMMAND_INJECTION,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:[;&|`$(){}[\]<>])', 'i')
|
||||
),
|
||||
action: RuleAction::SCORE,
|
||||
priority: 80,
|
||||
tags: ['command-injection', 'shell', 'metacharacters']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* File Upload Rules
|
||||
*/
|
||||
public static function getFileUploadRules(): array
|
||||
{
|
||||
return [
|
||||
// Malicious file extensions
|
||||
new Rule(
|
||||
id: RuleId::generic('933100'),
|
||||
name: 'File Upload - Malicious Extensions',
|
||||
description: 'Detects potentially malicious file extensions',
|
||||
category: DetectionCategory::FILE_UPLOAD_ABUSE,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::header(
|
||||
'Content-Type',
|
||||
RulePattern::regex('(?i:application\/(?:x-)?(?:php|jsp|asp|exe|bat|cmd|sh|python|perl|ruby|javascript)|text\/(?:x-)?(?:php|jsp|asp|python|perl|ruby))', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 85,
|
||||
tags: ['file-upload', 'malware', 'webshell']
|
||||
),
|
||||
|
||||
// Double extensions
|
||||
new Rule(
|
||||
id: RuleId::generic('933110'),
|
||||
name: 'File Upload - Double Extensions',
|
||||
description: 'Detects double file extensions',
|
||||
category: DetectionCategory::FILE_UPLOAD_ABUSE,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:\.(?:jpg|jpeg|png|gif|bmp|doc|docx|pdf|txt)\.(?:php|jsp|asp|exe|bat|cmd|sh|py|pl|rb|js))', 'i')
|
||||
),
|
||||
action: RuleAction::SCORE,
|
||||
priority: 70,
|
||||
tags: ['file-upload', 'double-extension', 'bypass']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* User Agent Rules
|
||||
*/
|
||||
public static function getUserAgentRules(): array
|
||||
{
|
||||
return [
|
||||
// Security scanners
|
||||
new Rule(
|
||||
id: RuleId::generic('913100'),
|
||||
name: 'User-Agent - Security Scanner Detection',
|
||||
description: 'Detects known security scanning tools',
|
||||
category: DetectionCategory::BOT_DETECTION,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::userAgent(
|
||||
RulePattern::regex('(?i:(?:sqlmap|nmap|nikto|w3af|acunetix|nessus|openvas|burp|havij|hydra|metasploit|python-requests|curl|wget))', 'i')
|
||||
),
|
||||
action: RuleAction::CHALLENGE,
|
||||
priority: 70,
|
||||
tags: ['user-agent', 'scanner', 'tool-detection']
|
||||
),
|
||||
|
||||
// Empty or missing User-Agent
|
||||
new Rule(
|
||||
id: RuleId::generic('913110'),
|
||||
name: 'User-Agent - Missing or Empty',
|
||||
description: 'Detects missing or empty user agent strings',
|
||||
category: DetectionCategory::BOT_DETECTION,
|
||||
severity: DetectionSeverity::LOW,
|
||||
condition: RuleCondition::userAgent(
|
||||
RulePattern::regex('^$', '')
|
||||
),
|
||||
action: RuleAction::SCORE,
|
||||
priority: 40,
|
||||
tags: ['user-agent', 'empty', 'suspicious']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Protocol Rules
|
||||
*/
|
||||
public static function getHttpProtocolRules(): array
|
||||
{
|
||||
return [
|
||||
// Invalid HTTP methods
|
||||
new Rule(
|
||||
id: RuleId::generic('911100'),
|
||||
name: 'HTTP Protocol - Invalid Method',
|
||||
description: 'Detects invalid HTTP methods',
|
||||
category: DetectionCategory::PROTOCOL_ATTACK,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::httpMethod(
|
||||
RulePattern::regex('^(?!GET|POST|HEAD|PUT|DELETE|OPTIONS|PATCH|TRACE|CONNECT).*', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 60,
|
||||
tags: ['http', 'protocol', 'method', 'invalid']
|
||||
),
|
||||
|
||||
// Oversized request headers
|
||||
new Rule(
|
||||
id: RuleId::generic('911110'),
|
||||
name: 'HTTP Protocol - Oversized Headers',
|
||||
description: 'Detects oversized HTTP headers',
|
||||
category: DetectionCategory::PROTOCOL_ATTACK,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::header(
|
||||
'*',
|
||||
RulePattern::regex('.{8192,}', '') // Headers over 8KB
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 65,
|
||||
tags: ['http', 'protocol', 'headers', 'size']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Attack Rules
|
||||
*/
|
||||
public static function getApplicationAttackRules(): array
|
||||
{
|
||||
return [
|
||||
// LDAP injection
|
||||
new Rule(
|
||||
id: RuleId::generic('950100'),
|
||||
name: 'Application Attack - LDAP Injection',
|
||||
description: 'Detects LDAP injection attempts',
|
||||
category: DetectionCategory::INJECTION_ATTACK,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:(?:\()(?:&|\|)(?:\(|\)))', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 80,
|
||||
tags: ['ldap', 'injection', 'application']
|
||||
),
|
||||
|
||||
// XPath injection
|
||||
new Rule(
|
||||
id: RuleId::generic('950110'),
|
||||
name: 'Application Attack - XPath Injection',
|
||||
description: 'Detects XPath injection attempts',
|
||||
category: DetectionCategory::INJECTION_ATTACK,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:(?:\/\/|\.\.|\[@|position\(\)|text\(\)|node\(\)|ancestor|descendant|following|preceding|self))', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 75,
|
||||
tags: ['xpath', 'injection', 'xml']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Attack Rules
|
||||
*/
|
||||
public static function getGenericAttackRules(): array
|
||||
{
|
||||
return [
|
||||
// Null byte injection
|
||||
new Rule(
|
||||
id: RuleId::generic('960100'),
|
||||
name: 'Generic Attack - Null Byte Injection',
|
||||
description: 'Detects null byte injection attempts',
|
||||
category: DetectionCategory::PROTOCOL_ATTACK,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:%00|\\x00|\0)', 'i')
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 80,
|
||||
tags: ['null-byte', 'injection', 'bypass']
|
||||
),
|
||||
|
||||
// Unicode evasion
|
||||
new Rule(
|
||||
id: RuleId::generic('960110'),
|
||||
name: 'Generic Attack - Unicode Evasion',
|
||||
description: 'Detects Unicode-based evasion attempts',
|
||||
category: DetectionCategory::EVASION,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::requestBody(
|
||||
RulePattern::regex('(?i:%u[0-9a-f]{4}|\\\\u[0-9a-f]{4})', 'i')
|
||||
),
|
||||
action: RuleAction::SCORE,
|
||||
priority: 60,
|
||||
tags: ['unicode', 'evasion', 'encoding']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rules by OWASP Top 10 category
|
||||
*/
|
||||
public static function getOwaspTop10Rules(): array
|
||||
{
|
||||
return array_filter(
|
||||
self::getAllRules(),
|
||||
fn (Rule $rule) => $rule->hasTag('owasp-top10')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical severity rules only
|
||||
*/
|
||||
public static function getCriticalRules(): array
|
||||
{
|
||||
return array_filter(
|
||||
self::getAllRules(),
|
||||
fn (Rule $rule) => $rule->severity === DetectionSeverity::CRITICAL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get high-priority rules (priority >= 80)
|
||||
*/
|
||||
public static function getHighPriorityRules(): array
|
||||
{
|
||||
return array_filter(
|
||||
self::getAllRules(),
|
||||
fn (Rule $rule) => $rule->priority >= 80
|
||||
);
|
||||
}
|
||||
}
|
||||
413
src/Framework/Waf/Rules/Rule.php
Normal file
413
src/Framework/Waf/Rules/Rule.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\Rules\ValueObjects\RuleCondition;
|
||||
use App\Framework\Waf\Rules\ValueObjects\RuleMatch;
|
||||
use App\Framework\Waf\ValueObjects\RuleId;
|
||||
|
||||
/**
|
||||
* WAF security rule
|
||||
*/
|
||||
final readonly class Rule
|
||||
{
|
||||
public function __construct(
|
||||
public RuleId $id,
|
||||
public string $name,
|
||||
public string $description,
|
||||
public DetectionCategory $category,
|
||||
public DetectionSeverity $severity,
|
||||
public RuleCondition $condition,
|
||||
public RuleAction $action,
|
||||
public bool $enabled = true,
|
||||
public int $priority = 50,
|
||||
public ?Percentage $confidenceThreshold = null,
|
||||
public array $tags = [],
|
||||
public array $actionParameters = [],
|
||||
public ?string $version = null,
|
||||
public ?string $author = null,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SQL injection detection rule
|
||||
*/
|
||||
public static function sqlInjectionDetection(): self
|
||||
{
|
||||
return new self(
|
||||
id: RuleId::sql('001'),
|
||||
name: 'SQL Injection Detection',
|
||||
description: 'Detects common SQL injection patterns in request parameters',
|
||||
category: DetectionCategory::SQL_INJECTION,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
\App\Framework\Waf\Rules\ValueObjects\RulePattern::sqlInjection()
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 90,
|
||||
tags: ['sql', 'injection', 'owasp-top10', 'database'],
|
||||
version: '1.0',
|
||||
author: 'WAF Team'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create XSS detection rule
|
||||
*/
|
||||
public static function xssDetection(): self
|
||||
{
|
||||
return new self(
|
||||
id: RuleId::xss('001'),
|
||||
name: 'Cross-Site Scripting Detection',
|
||||
description: 'Detects XSS attack patterns in request data',
|
||||
category: DetectionCategory::XSS,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::requestBody(
|
||||
\App\Framework\Waf\Rules\ValueObjects\RulePattern::xssDetection()
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 85,
|
||||
tags: ['xss', 'scripting', 'owasp-top10', 'client-side'],
|
||||
version: '1.0',
|
||||
author: 'WAF Team'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create path traversal detection rule
|
||||
*/
|
||||
public static function pathTraversalDetection(): self
|
||||
{
|
||||
return new self(
|
||||
id: RuleId::pathTraversal('001'),
|
||||
name: 'Path Traversal Detection',
|
||||
description: 'Detects directory traversal attempts',
|
||||
category: DetectionCategory::PATH_TRAVERSAL,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::urlPath(
|
||||
\App\Framework\Waf\Rules\ValueObjects\RulePattern::pathTraversal()
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 80,
|
||||
tags: ['path-traversal', 'directory-traversal', 'file-access'],
|
||||
version: '1.0',
|
||||
author: 'WAF Team'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create command injection detection rule
|
||||
*/
|
||||
public static function commandInjectionDetection(): self
|
||||
{
|
||||
return new self(
|
||||
id: RuleId::commandInjection('001'),
|
||||
name: 'Command Injection Detection',
|
||||
description: 'Detects OS command injection attempts',
|
||||
category: DetectionCategory::COMMAND_INJECTION,
|
||||
severity: DetectionSeverity::CRITICAL,
|
||||
condition: RuleCondition::requestBody(
|
||||
\App\Framework\Waf\Rules\ValueObjects\RulePattern::commandInjection()
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 95,
|
||||
tags: ['command-injection', 'os-injection', 'rce'],
|
||||
version: '1.0',
|
||||
author: 'WAF Team'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create User-Agent validation rule
|
||||
*/
|
||||
public static function userAgentValidation(): self
|
||||
{
|
||||
return new self(
|
||||
id: RuleId::generic('UA001'),
|
||||
name: 'Suspicious User-Agent Detection',
|
||||
description: 'Detects suspicious or malicious user agent strings',
|
||||
category: DetectionCategory::BOT_DETECTION,
|
||||
severity: DetectionSeverity::MEDIUM,
|
||||
condition: RuleCondition::userAgent(
|
||||
\App\Framework\Waf\Rules\ValueObjects\RulePattern::regex(
|
||||
'(?i:(?:sqlmap|nmap|nikto|w3af|acunetix|nessus|openvas|burp|havij|hydra|metasploit|python-requests|curl|wget))',
|
||||
'i'
|
||||
)
|
||||
),
|
||||
action: RuleAction::CHALLENGE,
|
||||
priority: 60,
|
||||
tags: ['user-agent', 'bot', 'scanner', 'tool-detection'],
|
||||
version: '1.0',
|
||||
author: 'WAF Team'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create file upload validation rule
|
||||
*/
|
||||
public static function fileUploadValidation(): self
|
||||
{
|
||||
return new self(
|
||||
id: RuleId::generic('UP001'),
|
||||
name: 'Malicious File Upload Detection',
|
||||
description: 'Detects potentially malicious file uploads',
|
||||
category: DetectionCategory::FILE_UPLOAD_ABUSE,
|
||||
severity: DetectionSeverity::HIGH,
|
||||
condition: RuleCondition::header(
|
||||
'Content-Type',
|
||||
\App\Framework\Waf\Rules\ValueObjects\RulePattern::regex(
|
||||
'(?i:application\/(?:x-)?(?:php|jsp|asp|exe|bat|cmd|sh|python|perl|ruby|javascript)|text\/(?:x-)?(?:php|jsp|asp|python|perl|ruby))',
|
||||
'i'
|
||||
)
|
||||
),
|
||||
action: RuleAction::BLOCK,
|
||||
priority: 75,
|
||||
tags: ['file-upload', 'malware', 'webshell'],
|
||||
version: '1.0',
|
||||
author: 'WAF Team'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate rule against request data
|
||||
*/
|
||||
public function evaluate(array $requestData): ?RuleMatch
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->condition->evaluate($requestData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$match = RuleMatch::fromCondition(
|
||||
ruleId: $this->id,
|
||||
ruleName: $this->name,
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
condition: $this->condition,
|
||||
requestData: $requestData,
|
||||
message: $this->description
|
||||
);
|
||||
|
||||
// Check confidence threshold
|
||||
if ($this->confidenceThreshold !== null &&
|
||||
$match->confidence !== null &&
|
||||
$match->confidence->getValue() < $this->confidenceThreshold->getValue()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $match->withMetadata([
|
||||
'rule_version' => $this->version,
|
||||
'rule_author' => $this->author,
|
||||
'rule_tags' => $this->tags,
|
||||
'rule_priority' => $this->priority,
|
||||
'additional_metadata' => $this->metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable rule
|
||||
*/
|
||||
public function enable(): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
name: $this->name,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
condition: $this->condition,
|
||||
action: $this->action,
|
||||
enabled: true,
|
||||
priority: $this->priority,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
tags: $this->tags,
|
||||
actionParameters: $this->actionParameters,
|
||||
version: $this->version,
|
||||
author: $this->author,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable rule
|
||||
*/
|
||||
public function disable(): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
name: $this->name,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
condition: $this->condition,
|
||||
action: $this->action,
|
||||
enabled: false,
|
||||
priority: $this->priority,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
tags: $this->tags,
|
||||
actionParameters: $this->actionParameters,
|
||||
version: $this->version,
|
||||
author: $this->author,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set confidence threshold
|
||||
*/
|
||||
public function withConfidenceThreshold(Percentage $threshold): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
name: $this->name,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
condition: $this->condition,
|
||||
action: $this->action,
|
||||
enabled: $this->enabled,
|
||||
priority: $this->priority,
|
||||
confidenceThreshold: $threshold,
|
||||
tags: $this->tags,
|
||||
actionParameters: $this->actionParameters,
|
||||
version: $this->version,
|
||||
author: $this->author,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set priority
|
||||
*/
|
||||
public function withPriority(int $priority): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
name: $this->name,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
condition: $this->condition,
|
||||
action: $this->action,
|
||||
enabled: $this->enabled,
|
||||
priority: $priority,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
tags: $this->tags,
|
||||
actionParameters: $this->actionParameters,
|
||||
version: $this->version,
|
||||
author: $this->author,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags
|
||||
*/
|
||||
public function withTags(array $tags): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
name: $this->name,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
condition: $this->condition,
|
||||
action: $this->action,
|
||||
enabled: $this->enabled,
|
||||
priority: $this->priority,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
tags: array_unique(array_merge($this->tags, $tags)),
|
||||
actionParameters: $this->actionParameters,
|
||||
version: $this->version,
|
||||
author: $this->author,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule has specific tag
|
||||
*/
|
||||
public function hasTag(string $tag): bool
|
||||
{
|
||||
return in_array($tag, $this->tags, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule is OWASP Top 10 related
|
||||
*/
|
||||
public function isOwaspTop10(): bool
|
||||
{
|
||||
return $this->hasTag('owasp-top10') || $this->category->isOwaspTop10();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule complexity score
|
||||
*/
|
||||
public function getComplexityScore(): int
|
||||
{
|
||||
$score = 1;
|
||||
|
||||
// Pattern complexity
|
||||
if ($this->condition->pattern->isRegex) {
|
||||
$score += $this->condition->pattern->isPotentiallyDangerous() ? 5 : 2;
|
||||
}
|
||||
|
||||
// Action complexity
|
||||
if ($this->action->requiresParameters()) {
|
||||
$score += 1;
|
||||
}
|
||||
|
||||
// Transformation complexity
|
||||
if ($this->condition->transformation !== null) {
|
||||
$score += 1;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule is computationally expensive
|
||||
*/
|
||||
public function isExpensive(): bool
|
||||
{
|
||||
return $this->condition->type->isExpensive() ||
|
||||
$this->condition->pattern->isPotentiallyDangerous() ||
|
||||
$this->getComplexityScore() > 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id->value,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'category' => $this->category->value,
|
||||
'severity' => $this->severity->value,
|
||||
'action' => $this->action->value,
|
||||
'enabled' => $this->enabled,
|
||||
'priority' => $this->priority,
|
||||
'confidence_threshold' => $this->confidenceThreshold?->getValue(),
|
||||
'tags' => $this->tags,
|
||||
'action_parameters' => $this->actionParameters,
|
||||
'version' => $this->version,
|
||||
'author' => $this->author,
|
||||
'is_owasp_top10' => $this->isOwaspTop10(),
|
||||
'complexity_score' => $this->getComplexityScore(),
|
||||
'is_expensive' => $this->isExpensive(),
|
||||
'condition' => $this->condition->toArray(),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
}
|
||||
166
src/Framework/Waf/Rules/RuleAction.php
Normal file
166
src/Framework/Waf/Rules/RuleAction.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules;
|
||||
|
||||
/**
|
||||
* Actions that can be taken when a rule matches
|
||||
*/
|
||||
enum RuleAction: string
|
||||
{
|
||||
case ALLOW = 'allow';
|
||||
case BLOCK = 'block';
|
||||
case LOG = 'log';
|
||||
case WARN = 'warn';
|
||||
case CHALLENGE = 'challenge';
|
||||
case RATE_LIMIT = 'rate_limit';
|
||||
case REDIRECT = 'redirect';
|
||||
case SANITIZE = 'sanitize';
|
||||
case SCORE = 'score';
|
||||
case CHAIN = 'chain';
|
||||
case SKIP = 'skip';
|
||||
case DENY_WITH_STATUS = 'deny_with_status';
|
||||
|
||||
/**
|
||||
* Get action description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW => 'Allow request to proceed',
|
||||
self::BLOCK => 'Block request with 403 Forbidden',
|
||||
self::LOG => 'Log detection but allow request',
|
||||
self::WARN => 'Log warning and allow request',
|
||||
self::CHALLENGE => 'Present CAPTCHA or similar challenge',
|
||||
self::RATE_LIMIT => 'Apply rate limiting to client',
|
||||
self::REDIRECT => 'Redirect to different URL',
|
||||
self::SANITIZE => 'Clean/sanitize input and continue',
|
||||
self::SCORE => 'Add to anomaly score',
|
||||
self::CHAIN => 'Chain to next rule for combined evaluation',
|
||||
self::SKIP => 'Skip next N rules',
|
||||
self::DENY_WITH_STATUS => 'Deny with custom HTTP status code'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default HTTP status code for action
|
||||
*/
|
||||
public function getDefaultHttpStatus(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW, self::LOG, self::WARN, self::SANITIZE,
|
||||
self::SCORE, self::CHAIN, self::SKIP => 200,
|
||||
self::BLOCK => 403,
|
||||
self::CHALLENGE => 429,
|
||||
self::RATE_LIMIT => 429,
|
||||
self::REDIRECT => 302,
|
||||
self::DENY_WITH_STATUS => 403
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action blocks request processing
|
||||
*/
|
||||
public function blocksRequest(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::BLOCK, self::CHALLENGE, self::RATE_LIMIT,
|
||||
self::REDIRECT, self::DENY_WITH_STATUS => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action allows request to continue
|
||||
*/
|
||||
public function allowsContinuation(): bool
|
||||
{
|
||||
return ! $this->blocksRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action requires logging
|
||||
*/
|
||||
public function requiresLogging(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW => false,
|
||||
default => true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logging severity level
|
||||
*/
|
||||
public function getLogLevel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW, self::SKIP => 'debug',
|
||||
self::LOG, self::SANITIZE, self::SCORE => 'info',
|
||||
self::WARN, self::CHAIN => 'warning',
|
||||
self::CHALLENGE, self::RATE_LIMIT, self::REDIRECT => 'notice',
|
||||
self::BLOCK, self::DENY_WITH_STATUS => 'error'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action affects anomaly score
|
||||
*/
|
||||
public function affectsAnomalyScore(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SCORE, self::WARN, self::BLOCK, self::CHALLENGE => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default anomaly score impact
|
||||
*/
|
||||
public function getAnomalyScoreImpact(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::SCORE => 5,
|
||||
self::WARN => 3,
|
||||
self::CHALLENGE => 8,
|
||||
self::BLOCK => 10,
|
||||
default => 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action can be chained
|
||||
*/
|
||||
public function canBeChained(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SCORE, self::LOG, self::WARN, self::CHAIN => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action requires additional parameters
|
||||
*/
|
||||
public function requiresParameters(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::REDIRECT, self::DENY_WITH_STATUS, self::SKIP => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required parameter names
|
||||
*/
|
||||
public function getRequiredParameters(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::REDIRECT => ['url'],
|
||||
self::DENY_WITH_STATUS => ['status_code'],
|
||||
self::SKIP => ['count'],
|
||||
default => []
|
||||
};
|
||||
}
|
||||
}
|
||||
426
src/Framework/Waf/Rules/RuleEngine.php
Normal file
426
src/Framework/Waf/Rules/RuleEngine.php
Normal file
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* WAF Rule Engine - evaluates rules against requests
|
||||
*/
|
||||
final class RuleEngine
|
||||
{
|
||||
private array $rules = [];
|
||||
|
||||
private array $rulesByCategory = [];
|
||||
|
||||
private array $rulesByPriority = [];
|
||||
|
||||
private array $evaluationStats = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly Logger $logger,
|
||||
private readonly Duration $maxEvaluationTime,
|
||||
private readonly bool $enableCaching = true,
|
||||
private readonly int $maxCacheSize = 10000
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rule to engine
|
||||
*/
|
||||
public function addRule(Rule $rule): void
|
||||
{
|
||||
$this->rules[$rule->id->value] = $rule;
|
||||
|
||||
// Index by category
|
||||
$categoryKey = $rule->category->value;
|
||||
if (! isset($this->rulesByCategory[$categoryKey])) {
|
||||
$this->rulesByCategory[$categoryKey] = [];
|
||||
}
|
||||
$this->rulesByCategory[$categoryKey][] = $rule;
|
||||
|
||||
// Index by priority (sorted)
|
||||
$this->rulesByPriority = [];
|
||||
foreach ($this->rules as $r) {
|
||||
$this->rulesByPriority[] = $r;
|
||||
}
|
||||
|
||||
// Sort by priority (descending - higher priority first)
|
||||
usort($this->rulesByPriority, fn (Rule $a, Rule $b) => $b->priority <=> $a->priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple rules
|
||||
*/
|
||||
public function addRules(array $rules): void
|
||||
{
|
||||
foreach ($rules as $rule) {
|
||||
$this->addRule($rule);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove rule from engine
|
||||
*/
|
||||
public function removeRule(string $ruleId): void
|
||||
{
|
||||
if (! isset($this->rules[$ruleId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rule = $this->rules[$ruleId];
|
||||
unset($this->rules[$ruleId]);
|
||||
|
||||
// Remove from category index
|
||||
$categoryKey = $rule->category->value;
|
||||
if (isset($this->rulesByCategory[$categoryKey])) {
|
||||
$this->rulesByCategory[$categoryKey] = array_filter(
|
||||
$this->rulesByCategory[$categoryKey],
|
||||
fn (Rule $r) => $r->id->value !== $ruleId
|
||||
);
|
||||
}
|
||||
|
||||
// Rebuild priority index
|
||||
$this->rulesByPriority = array_filter(
|
||||
$this->rulesByPriority,
|
||||
fn (Rule $r) => $r->id->value !== $ruleId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable rule
|
||||
*/
|
||||
public function enableRule(string $ruleId): void
|
||||
{
|
||||
if (isset($this->rules[$ruleId])) {
|
||||
$this->rules[$ruleId] = $this->rules[$ruleId]->enable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable rule
|
||||
*/
|
||||
public function disableRule(string $ruleId): void
|
||||
{
|
||||
if (isset($this->rules[$ruleId])) {
|
||||
$this->rules[$ruleId] = $this->rules[$ruleId]->disable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule by ID
|
||||
*/
|
||||
public function getRule(string $ruleId): ?Rule
|
||||
{
|
||||
return $this->rules[$ruleId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules
|
||||
*/
|
||||
public function getRules(): array
|
||||
{
|
||||
return array_values($this->rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled rules only
|
||||
*/
|
||||
public function getEnabledRules(): array
|
||||
{
|
||||
return array_filter($this->rules, fn (Rule $rule) => $rule->enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rules by category
|
||||
*/
|
||||
public function getRulesByCategory(string $category): array
|
||||
{
|
||||
return $this->rulesByCategory[$category] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rules by tag
|
||||
*/
|
||||
public function getRulesByTag(string $tag): array
|
||||
{
|
||||
return array_filter($this->rules, fn (Rule $rule) => $rule->hasTag($tag));
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all rules against request data
|
||||
*/
|
||||
public function evaluateAll(array $requestData): RuleEvaluationResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$matches = [];
|
||||
$evaluatedRules = 0;
|
||||
$skippedRules = 0;
|
||||
$errorCount = 0;
|
||||
$errors = [];
|
||||
|
||||
// Process rules by priority
|
||||
foreach ($this->rulesByPriority as $rule) {
|
||||
if (! $rule->enabled) {
|
||||
$skippedRules++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
$elapsed = $startTime->diff(Timestamp::now());
|
||||
if ($elapsed->isGreaterThan($this->maxEvaluationTime)) {
|
||||
$this->logger->warning('Rule evaluation timeout reached', [
|
||||
'elapsed_ms' => $elapsed->toMilliseconds(),
|
||||
'max_ms' => $this->maxEvaluationTime->toMilliseconds(),
|
||||
'evaluated_rules' => $evaluatedRules,
|
||||
'remaining_rules' => count($this->rulesByPriority) - $evaluatedRules - $skippedRules,
|
||||
]);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
$match = $rule->evaluate($requestData);
|
||||
if ($match !== null) {
|
||||
$matches[] = $match;
|
||||
|
||||
// Log high-severity matches immediately
|
||||
if ($match->isHighSeverityThreat()) {
|
||||
$this->logger->warning('High-severity threat detected', [
|
||||
'rule_id' => $rule->id->value,
|
||||
'category' => $rule->category->value,
|
||||
'severity' => $rule->severity->value,
|
||||
'message' => $match->message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$evaluatedRules++;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$errorCount++;
|
||||
$errors[] = [
|
||||
'rule_id' => $rule->id->value,
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
];
|
||||
|
||||
$this->logger->error('Rule evaluation error', [
|
||||
'rule_id' => $rule->id->value,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$totalDuration = $startTime->diff($endTime);
|
||||
|
||||
// Update stats
|
||||
$this->updateEvaluationStats($evaluatedRules, $totalDuration, $errorCount);
|
||||
|
||||
return new RuleEvaluationResult(
|
||||
matches: $matches,
|
||||
evaluatedRules: $evaluatedRules,
|
||||
skippedRules: $skippedRules,
|
||||
errorCount: $errorCount,
|
||||
errors: $errors,
|
||||
evaluationTime: $totalDuration,
|
||||
timestamp: $endTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate specific rule categories only
|
||||
*/
|
||||
public function evaluateCategories(array $categories, array $requestData): RuleEvaluationResult
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$matches = [];
|
||||
$evaluatedRules = 0;
|
||||
$errorCount = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$categoryRules = $this->getRulesByCategory($category);
|
||||
|
||||
foreach ($categoryRules as $rule) {
|
||||
if (! $rule->enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$match = $rule->evaluate($requestData);
|
||||
if ($match !== null) {
|
||||
$matches[] = $match;
|
||||
}
|
||||
$evaluatedRules++;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$errorCount++;
|
||||
$errors[] = [
|
||||
'rule_id' => $rule->id->value,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$totalDuration = $startTime->diff($endTime);
|
||||
|
||||
return new RuleEvaluationResult(
|
||||
matches: $matches,
|
||||
evaluatedRules: $evaluatedRules,
|
||||
skippedRules: 0,
|
||||
errorCount: $errorCount,
|
||||
errors: $errors,
|
||||
evaluationTime: $totalDuration,
|
||||
timestamp: $endTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick evaluation for high-priority rules only
|
||||
*/
|
||||
public function quickEvaluate(array $requestData): RuleEvaluationResult
|
||||
{
|
||||
// Only evaluate rules with priority >= 80
|
||||
$highPriorityRules = array_filter(
|
||||
$this->rulesByPriority,
|
||||
fn (Rule $rule) => $rule->enabled && $rule->priority >= 80
|
||||
);
|
||||
|
||||
$startTime = Timestamp::now();
|
||||
$matches = [];
|
||||
$evaluatedRules = 0;
|
||||
|
||||
foreach ($highPriorityRules as $rule) {
|
||||
try {
|
||||
$match = $rule->evaluate($requestData);
|
||||
if ($match !== null) {
|
||||
$matches[] = $match;
|
||||
}
|
||||
$evaluatedRules++;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Quick evaluation error', [
|
||||
'rule_id' => $rule->id->value,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = Timestamp::now();
|
||||
$totalDuration = $startTime->diff($endTime);
|
||||
|
||||
return new RuleEvaluationResult(
|
||||
matches: $matches,
|
||||
evaluatedRules: $evaluatedRules,
|
||||
skippedRules: count($this->getEnabledRules()) - count($highPriorityRules),
|
||||
errorCount: 0,
|
||||
errors: [],
|
||||
evaluationTime: $totalDuration,
|
||||
timestamp: $endTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engine statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$enabledRules = $this->getEnabledRules();
|
||||
$expensiveRules = array_filter($enabledRules, fn (Rule $rule) => $rule->isExpensive());
|
||||
$owaspRules = array_filter($enabledRules, fn (Rule $rule) => $rule->isOwaspTop10());
|
||||
|
||||
$categoryStats = [];
|
||||
foreach ($this->rulesByCategory as $category => $rules) {
|
||||
$categoryStats[$category] = [
|
||||
'total' => count($rules),
|
||||
'enabled' => count(array_filter($rules, fn (Rule $r) => $r->enabled)),
|
||||
'expensive' => count(array_filter($rules, fn (Rule $r) => $r->enabled && $r->isExpensive())),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_rules' => count($this->rules),
|
||||
'enabled_rules' => count($enabledRules),
|
||||
'expensive_rules' => count($expensiveRules),
|
||||
'owasp_top10_rules' => count($owaspRules),
|
||||
'categories' => array_keys($this->rulesByCategory),
|
||||
'category_stats' => $categoryStats,
|
||||
'evaluation_stats' => $this->evaluationStats,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance metrics
|
||||
*/
|
||||
public function getPerformanceMetrics(): array
|
||||
{
|
||||
return [
|
||||
'avg_evaluation_time_ms' => $this->evaluationStats['avg_evaluation_time'] ?? 0,
|
||||
'max_evaluation_time_ms' => $this->maxEvaluationTime->toMilliseconds(),
|
||||
'total_evaluations' => $this->evaluationStats['total_evaluations'] ?? 0,
|
||||
'total_errors' => $this->evaluationStats['total_errors'] ?? 0,
|
||||
'success_rate' => $this->calculateSuccessRate(),
|
||||
'cache_enabled' => $this->enableCaching,
|
||||
'cache_max_size' => $this->maxCacheSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rules
|
||||
*/
|
||||
public function clearRules(): void
|
||||
{
|
||||
$this->rules = [];
|
||||
$this->rulesByCategory = [];
|
||||
$this->rulesByPriority = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update evaluation statistics
|
||||
*/
|
||||
private function updateEvaluationStats(int $evaluatedRules, Duration $duration, int $errorCount): void
|
||||
{
|
||||
if (! isset($this->evaluationStats['total_evaluations'])) {
|
||||
$this->evaluationStats['total_evaluations'] = 0;
|
||||
$this->evaluationStats['total_duration_ms'] = 0;
|
||||
$this->evaluationStats['total_errors'] = 0;
|
||||
}
|
||||
|
||||
$this->evaluationStats['total_evaluations']++;
|
||||
$this->evaluationStats['total_duration_ms'] += $duration->toMilliseconds();
|
||||
$this->evaluationStats['total_errors'] += $errorCount;
|
||||
$this->evaluationStats['avg_evaluation_time'] =
|
||||
$this->evaluationStats['total_duration_ms'] / $this->evaluationStats['total_evaluations'];
|
||||
$this->evaluationStats['last_evaluation'] = [
|
||||
'rules_evaluated' => $evaluatedRules,
|
||||
'duration_ms' => $duration->toMilliseconds(),
|
||||
'errors' => $errorCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate success rate
|
||||
*/
|
||||
private function calculateSuccessRate(): float
|
||||
{
|
||||
$totalEvaluations = $this->evaluationStats['total_evaluations'] ?? 0;
|
||||
$totalErrors = $this->evaluationStats['total_errors'] ?? 0;
|
||||
|
||||
if ($totalEvaluations === 0) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
return ((($totalEvaluations - $totalErrors) / $totalEvaluations) * 100.0);
|
||||
}
|
||||
}
|
||||
300
src/Framework/Waf/Rules/RuleEvaluationResult.php
Normal file
300
src/Framework/Waf/Rules/RuleEvaluationResult.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\Rules\ValueObjects\RuleMatch;
|
||||
|
||||
/**
|
||||
* Result of rule engine evaluation
|
||||
*/
|
||||
final readonly class RuleEvaluationResult
|
||||
{
|
||||
public function __construct(
|
||||
public array $matches,
|
||||
public int $evaluatedRules,
|
||||
public int $skippedRules,
|
||||
public int $errorCount,
|
||||
public array $errors,
|
||||
public Duration $evaluationTime,
|
||||
public Timestamp $timestamp
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any rules matched
|
||||
*/
|
||||
public function hasMatches(): bool
|
||||
{
|
||||
return ! empty($this->matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of matches
|
||||
*/
|
||||
public function getMatchCount(): int
|
||||
{
|
||||
return count($this->matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matches by severity
|
||||
*/
|
||||
public function getMatchesBySeverity(string $severity): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->matches,
|
||||
fn (RuleMatch $match) => $match->severity->value === $severity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical matches
|
||||
*/
|
||||
public function getCriticalMatches(): array
|
||||
{
|
||||
return $this->getMatchesBySeverity('critical');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get high severity matches
|
||||
*/
|
||||
public function getHighSeverityMatches(): array
|
||||
{
|
||||
return $this->getMatchesBySeverity('high');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocking matches (matches that should block the request)
|
||||
*/
|
||||
public function getBlockingMatches(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->matches,
|
||||
fn (RuleMatch $match) => $match->shouldBlock()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matches that should trigger alerts
|
||||
*/
|
||||
public function getAlertingMatches(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->matches,
|
||||
fn (RuleMatch $match) => $match->shouldAlert()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OWASP Top 10 matches
|
||||
*/
|
||||
public function getOwaspTop10Matches(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->matches,
|
||||
fn (RuleMatch $match) => $match->category->isOwaspTop10()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matches by category
|
||||
*/
|
||||
public function getMatchesByCategory(string $category): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->matches,
|
||||
fn (RuleMatch $match) => $match->category->value === $category
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any blocking matches exist
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return ! empty($this->getBlockingMatches());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any alerting matches exist
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return ! empty($this->getAlertingMatches());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if evaluation had errors
|
||||
*/
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return $this->errorCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get highest severity level from matches
|
||||
*/
|
||||
public function getHighestSeverity(): ?string
|
||||
{
|
||||
if (empty($this->matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$severityOrder = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
|
||||
foreach ($severityOrder as $severity) {
|
||||
if (! empty($this->getMatchesBySeverity($severity))) {
|
||||
return $severity;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories of all matches
|
||||
*/
|
||||
public function getMatchedCategories(): array
|
||||
{
|
||||
$categories = [];
|
||||
|
||||
foreach ($this->matches as $match) {
|
||||
$categories[$match->category->value] = $match->category;
|
||||
}
|
||||
|
||||
return array_values($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule IDs of all matches
|
||||
*/
|
||||
public function getMatchedRuleIds(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (RuleMatch $match) => $match->ruleId->value,
|
||||
$this->matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall confidence score
|
||||
*/
|
||||
public function getOverallConfidence(): Percentage
|
||||
{
|
||||
if (empty($this->matches)) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$totalConfidence = 0.0;
|
||||
$confidenceCount = 0;
|
||||
|
||||
foreach ($this->matches as $match) {
|
||||
if ($match->confidence !== null) {
|
||||
$totalConfidence += $match->confidence->getValue();
|
||||
$confidenceCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($confidenceCount === 0) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
return Percentage::from($totalConfidence / $confidenceCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate threat score based on matches
|
||||
*/
|
||||
public function getThreatScore(): Percentage
|
||||
{
|
||||
if (empty($this->matches)) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$score = 0.0;
|
||||
$maxScore = 0.0;
|
||||
|
||||
foreach ($this->matches as $match) {
|
||||
$matchScore = match ($match->severity->value) {
|
||||
'critical' => 25.0,
|
||||
'high' => 20.0,
|
||||
'medium' => 10.0,
|
||||
'low' => 5.0,
|
||||
'info' => 1.0,
|
||||
default => 0.0
|
||||
};
|
||||
|
||||
// Apply confidence modifier
|
||||
if ($match->confidence !== null) {
|
||||
$matchScore *= ($match->confidence->getValue() / 100.0);
|
||||
}
|
||||
|
||||
$score += $matchScore;
|
||||
$maxScore = max($maxScore, $matchScore);
|
||||
}
|
||||
|
||||
// Use weighted combination: 70% cumulative + 30% maximum
|
||||
$finalScore = ($score * 0.7) + ($maxScore * 0.3);
|
||||
|
||||
return Percentage::from(min(100.0, $finalScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get evaluation performance summary
|
||||
*/
|
||||
public function getPerformanceSummary(): array
|
||||
{
|
||||
$efficiency = 0.0;
|
||||
if ($this->evaluatedRules > 0) {
|
||||
$rulesPerMs = $this->evaluatedRules / max(1, $this->evaluationTime->toMilliseconds());
|
||||
$efficiency = $rulesPerMs * 1000; // Rules per second
|
||||
}
|
||||
|
||||
return [
|
||||
'evaluation_time_ms' => $this->evaluationTime->toMilliseconds(),
|
||||
'evaluated_rules' => $this->evaluatedRules,
|
||||
'skipped_rules' => $this->skippedRules,
|
||||
'error_count' => $this->errorCount,
|
||||
'rules_per_second' => round($efficiency, 2),
|
||||
'error_rate' => $this->evaluatedRules > 0
|
||||
? round(($this->errorCount / $this->evaluatedRules) * 100, 2)
|
||||
: 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'timestamp' => $this->timestamp->toIsoString(),
|
||||
'evaluation_time_ms' => $this->evaluationTime->toMilliseconds(),
|
||||
'evaluated_rules' => $this->evaluatedRules,
|
||||
'skipped_rules' => $this->skippedRules,
|
||||
'error_count' => $this->errorCount,
|
||||
'match_count' => $this->getMatchCount(),
|
||||
'has_matches' => $this->hasMatches(),
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
'highest_severity' => $this->getHighestSeverity(),
|
||||
'threat_score' => $this->getThreatScore()->getValue(),
|
||||
'overall_confidence' => $this->getOverallConfidence()->getValue(),
|
||||
'matched_categories' => array_map(fn ($cat) => $cat->value, $this->getMatchedCategories()),
|
||||
'matched_rule_ids' => $this->getMatchedRuleIds(),
|
||||
'owasp_top10_matches' => count($this->getOwaspTop10Matches()),
|
||||
'critical_matches' => count($this->getCriticalMatches()),
|
||||
'high_severity_matches' => count($this->getHighSeverityMatches()),
|
||||
'blocking_matches' => count($this->getBlockingMatches()),
|
||||
'alerting_matches' => count($this->getAlertingMatches()),
|
||||
'performance_summary' => $this->getPerformanceSummary(),
|
||||
'matches' => array_map(fn (RuleMatch $match) => $match->toArray(), $this->matches),
|
||||
'errors' => $this->errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
133
src/Framework/Waf/Rules/RuleType.php
Normal file
133
src/Framework/Waf/Rules/RuleType.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules;
|
||||
|
||||
/**
|
||||
* Types of WAF security rules
|
||||
*/
|
||||
enum RuleType: string
|
||||
{
|
||||
case PATTERN_MATCH = 'pattern_match';
|
||||
case REGEX_MATCH = 'regex_match';
|
||||
case HEADER_CHECK = 'header_check';
|
||||
case PARAMETER_CHECK = 'parameter_check';
|
||||
case BODY_CHECK = 'body_check';
|
||||
case SIZE_LIMIT = 'size_limit';
|
||||
case RATE_LIMIT = 'rate_limit';
|
||||
case IP_WHITELIST = 'ip_whitelist';
|
||||
case IP_BLACKLIST = 'ip_blacklist';
|
||||
case GEOGRAPHIC_FILTER = 'geographic_filter';
|
||||
case USER_AGENT_CHECK = 'user_agent_check';
|
||||
case CONTENT_TYPE_CHECK = 'content_type_check';
|
||||
case METHOD_CHECK = 'method_check';
|
||||
case PATH_CHECK = 'path_check';
|
||||
case COOKIE_CHECK = 'cookie_check';
|
||||
|
||||
/**
|
||||
* Get rule type description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PATTERN_MATCH => 'Pattern-based string matching',
|
||||
self::REGEX_MATCH => 'Regular expression matching',
|
||||
self::HEADER_CHECK => 'HTTP header validation',
|
||||
self::PARAMETER_CHECK => 'Query/form parameter validation',
|
||||
self::BODY_CHECK => 'Request body content validation',
|
||||
self::SIZE_LIMIT => 'Content size limitations',
|
||||
self::RATE_LIMIT => 'Request rate limiting',
|
||||
self::IP_WHITELIST => 'IP address whitelist check',
|
||||
self::IP_BLACKLIST => 'IP address blacklist check',
|
||||
self::GEOGRAPHIC_FILTER => 'Geographic location filtering',
|
||||
self::USER_AGENT_CHECK => 'User agent validation',
|
||||
self::CONTENT_TYPE_CHECK => 'Content-Type header validation',
|
||||
self::METHOD_CHECK => 'HTTP method validation',
|
||||
self::PATH_CHECK => 'URL path validation',
|
||||
self::COOKIE_CHECK => 'Cookie validation'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default processing priority
|
||||
*/
|
||||
public function getDefaultPriority(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::IP_WHITELIST => 100, // Process first - bypass other rules
|
||||
self::IP_BLACKLIST => 95, // Block malicious IPs early
|
||||
self::RATE_LIMIT => 90, // Rate limiting before content analysis
|
||||
self::METHOD_CHECK => 85, // HTTP method validation
|
||||
self::SIZE_LIMIT => 80, // Size checks before content parsing
|
||||
self::CONTENT_TYPE_CHECK => 75, // Content type validation
|
||||
self::HEADER_CHECK => 70, // Header validation
|
||||
self::PATH_CHECK => 65, // Path validation
|
||||
self::GEOGRAPHIC_FILTER => 60, // Geographic filtering
|
||||
self::USER_AGENT_CHECK => 55, // User agent checks
|
||||
self::COOKIE_CHECK => 50, // Cookie validation
|
||||
self::PARAMETER_CHECK => 45, // Parameter validation
|
||||
self::BODY_CHECK => 40, // Body content analysis
|
||||
self::PATTERN_MATCH => 35, // Pattern matching
|
||||
self::REGEX_MATCH => 30 // Complex regex last (most expensive)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule type requires request body
|
||||
*/
|
||||
public function requiresBody(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::BODY_CHECK, self::PARAMETER_CHECK => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule type is computationally expensive
|
||||
*/
|
||||
public function isExpensive(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::REGEX_MATCH, self::BODY_CHECK, self::PATTERN_MATCH => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule type can cause false positives
|
||||
*/
|
||||
public function canCauseFalsePositives(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::PATTERN_MATCH, self::REGEX_MATCH, self::BODY_CHECK,
|
||||
self::PARAMETER_CHECK, self::USER_AGENT_CHECK => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expected processing time category
|
||||
*/
|
||||
public function getProcessingTimeCategory(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::IP_WHITELIST, self::IP_BLACKLIST, self::METHOD_CHECK => 'fast',
|
||||
self::HEADER_CHECK, self::PATH_CHECK, self::SIZE_LIMIT => 'medium',
|
||||
self::REGEX_MATCH, self::BODY_CHECK, self::PATTERN_MATCH => 'slow',
|
||||
default => 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule type supports caching
|
||||
*/
|
||||
public function supportsCaching(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::IP_WHITELIST, self::IP_BLACKLIST, self::GEOGRAPHIC_FILTER => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
}
|
||||
299
src/Framework/Waf/Rules/ValueObjects/RuleCondition.php
Normal file
299
src/Framework/Waf/Rules/ValueObjects/RuleCondition.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules\ValueObjects;
|
||||
|
||||
use App\Framework\Waf\Rules\RuleType;
|
||||
|
||||
/**
|
||||
* Condition that defines what to check in a request
|
||||
*/
|
||||
final readonly class RuleCondition
|
||||
{
|
||||
public function __construct(
|
||||
public RuleType $type,
|
||||
public string $target,
|
||||
public RulePattern $pattern,
|
||||
public bool $negated = false,
|
||||
public ?string $transformation = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for request headers
|
||||
*/
|
||||
public static function header(string $headerName, RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::HEADER_CHECK,
|
||||
target: "headers.{$headerName}",
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for query parameters
|
||||
*/
|
||||
public static function queryParameter(string $paramName, RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::PARAMETER_CHECK,
|
||||
target: "query.{$paramName}",
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for POST parameters
|
||||
*/
|
||||
public static function postParameter(string $paramName, RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::PARAMETER_CHECK,
|
||||
target: "post.{$paramName}",
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for request body
|
||||
*/
|
||||
public static function requestBody(RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::BODY_CHECK,
|
||||
target: 'body',
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for URL path
|
||||
*/
|
||||
public static function urlPath(RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::PATH_CHECK,
|
||||
target: 'path',
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for HTTP method
|
||||
*/
|
||||
public static function httpMethod(RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::METHOD_CHECK,
|
||||
target: 'method',
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for User-Agent header
|
||||
*/
|
||||
public static function userAgent(RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::USER_AGENT_CHECK,
|
||||
target: 'headers.User-Agent',
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for Content-Type header
|
||||
*/
|
||||
public static function contentType(RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::CONTENT_TYPE_CHECK,
|
||||
target: 'headers.Content-Type',
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition for cookies
|
||||
*/
|
||||
public static function cookie(string $cookieName, RulePattern $pattern, bool $negated = false): self
|
||||
{
|
||||
return new self(
|
||||
type: RuleType::COOKIE_CHECK,
|
||||
target: "cookies.{$cookieName}",
|
||||
pattern: $pattern,
|
||||
negated: $negated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create condition with transformation
|
||||
*/
|
||||
public function withTransformation(string $transformation): self
|
||||
{
|
||||
return new self(
|
||||
type: $this->type,
|
||||
target: $this->target,
|
||||
pattern: $this->pattern,
|
||||
negated: $this->negated,
|
||||
transformation: $transformation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create negated version of condition
|
||||
*/
|
||||
public function negate(): self
|
||||
{
|
||||
return new self(
|
||||
type: $this->type,
|
||||
target: $this->target,
|
||||
pattern: $this->pattern,
|
||||
negated: ! $this->negated,
|
||||
transformation: $this->transformation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract value from request data based on target
|
||||
*/
|
||||
public function extractValue(array $requestData): ?string
|
||||
{
|
||||
$parts = explode('.', $this->target);
|
||||
$value = $requestData;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (! is_array($value) || ! isset($value[$part])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
|
||||
// Apply transformation if specified
|
||||
if ($this->transformation) {
|
||||
$value = $this->applyTransformation($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate condition against request data
|
||||
*/
|
||||
public function evaluate(array $requestData): bool
|
||||
{
|
||||
$value = $this->extractValue($requestData);
|
||||
|
||||
if ($value === null) {
|
||||
// No value found - condition fails unless negated
|
||||
return $this->negated;
|
||||
}
|
||||
|
||||
$matches = $this->pattern->matches($value);
|
||||
|
||||
// Apply negation if specified
|
||||
return $this->negated ? ! $matches : $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all matches from request data
|
||||
*/
|
||||
public function getMatches(array $requestData): array
|
||||
{
|
||||
$value = $this->extractValue($requestData);
|
||||
|
||||
if ($value === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->pattern->getAllMatches($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if condition targets sensitive data
|
||||
*/
|
||||
public function targetsSensitiveData(): bool
|
||||
{
|
||||
$sensitiveTargets = [
|
||||
'headers.Authorization',
|
||||
'headers.Cookie',
|
||||
'headers.X-Auth-Token',
|
||||
'post.password',
|
||||
'post.passwd',
|
||||
'post.secret',
|
||||
'post.token',
|
||||
'query.password',
|
||||
'query.passwd',
|
||||
'query.secret',
|
||||
'query.token',
|
||||
];
|
||||
|
||||
return in_array($this->target, $sensitiveTargets, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target description
|
||||
*/
|
||||
public function getTargetDescription(): string
|
||||
{
|
||||
return match (true) {
|
||||
str_starts_with($this->target, 'headers.') => 'HTTP Header: ' . substr($this->target, 8),
|
||||
str_starts_with($this->target, 'query.') => 'Query Parameter: ' . substr($this->target, 6),
|
||||
str_starts_with($this->target, 'post.') => 'POST Parameter: ' . substr($this->target, 5),
|
||||
str_starts_with($this->target, 'cookies.') => 'Cookie: ' . substr($this->target, 8),
|
||||
$this->target === 'body' => 'Request Body',
|
||||
$this->target === 'path' => 'URL Path',
|
||||
$this->target === 'method' => 'HTTP Method',
|
||||
default => ucfirst($this->target)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'target' => $this->target,
|
||||
'target_description' => $this->getTargetDescription(),
|
||||
'pattern' => $this->pattern->toArray(),
|
||||
'negated' => $this->negated,
|
||||
'transformation' => $this->transformation,
|
||||
'targets_sensitive_data' => $this->targetsSensitiveData(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply transformation to value
|
||||
*/
|
||||
private function applyTransformation(string $value): string
|
||||
{
|
||||
return match ($this->transformation) {
|
||||
'lowercase' => strtolower($value),
|
||||
'uppercase' => strtoupper($value),
|
||||
'trim' => trim($value),
|
||||
'url_decode' => urldecode($value),
|
||||
'html_decode' => html_entity_decode($value, ENT_QUOTES | ENT_HTML5),
|
||||
'base64_decode' => base64_decode($value, true) ?: $value,
|
||||
'remove_whitespace' => preg_replace('/\s+/', '', $value),
|
||||
'normalize_path' => str_replace(['\\', '//'], ['/', '/'], $value),
|
||||
default => $value
|
||||
};
|
||||
}
|
||||
}
|
||||
306
src/Framework/Waf/Rules/ValueObjects/RuleMatch.php
Normal file
306
src/Framework/Waf/Rules/ValueObjects/RuleMatch.php
Normal file
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
use App\Framework\Waf\ValueObjects\RuleId;
|
||||
|
||||
/**
|
||||
* Represents a successful rule match
|
||||
*/
|
||||
final readonly class RuleMatch
|
||||
{
|
||||
public function __construct(
|
||||
public RuleId $ruleId,
|
||||
public string $ruleName,
|
||||
public DetectionCategory $category,
|
||||
public DetectionSeverity $severity,
|
||||
public RuleCondition $condition,
|
||||
public string $matchedValue,
|
||||
public array $matches,
|
||||
public ?Percentage $confidence = null,
|
||||
public ?string $message = null,
|
||||
public ?Timestamp $timestamp = null,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule match from condition evaluation
|
||||
*/
|
||||
public static function fromCondition(
|
||||
RuleId $ruleId,
|
||||
string $ruleName,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
RuleCondition $condition,
|
||||
array $requestData,
|
||||
?string $message = null
|
||||
): self {
|
||||
$matchedValue = $condition->extractValue($requestData) ?? '';
|
||||
$matches = $condition->getMatches($requestData);
|
||||
|
||||
// Calculate confidence based on match quality
|
||||
$confidence = self::calculateConfidence($matches, $condition->pattern);
|
||||
|
||||
$generatedMessage = $message ?? self::generateMessage($condition, $matchedValue);
|
||||
|
||||
return new self(
|
||||
ruleId: $ruleId,
|
||||
ruleName: $ruleName,
|
||||
category: $category,
|
||||
severity: $severity,
|
||||
condition: $condition,
|
||||
matchedValue: $matchedValue,
|
||||
matches: $matches,
|
||||
confidence: $confidence,
|
||||
message: $generatedMessage,
|
||||
timestamp: Timestamp::now(),
|
||||
metadata: [
|
||||
'target' => $condition->target,
|
||||
'pattern_type' => $condition->pattern->isRegex ? 'regex' : 'string',
|
||||
'negated' => $condition->negated,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create high-confidence match
|
||||
*/
|
||||
public static function highConfidence(
|
||||
RuleId $ruleId,
|
||||
string $ruleName,
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
RuleCondition $condition,
|
||||
string $matchedValue,
|
||||
array $matches,
|
||||
string $message
|
||||
): self {
|
||||
return new self(
|
||||
ruleId: $ruleId,
|
||||
ruleName: $ruleName,
|
||||
category: $category,
|
||||
severity: $severity,
|
||||
condition: $condition,
|
||||
matchedValue: $matchedValue,
|
||||
matches: $matches,
|
||||
confidence: Percentage::from(95.0),
|
||||
message: $message,
|
||||
timestamp: Timestamp::now()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sanitized matched value (safe for logging)
|
||||
*/
|
||||
public function getSanitizedMatchedValue(): string
|
||||
{
|
||||
// Truncate long values
|
||||
$value = strlen($this->matchedValue) > 200
|
||||
? substr($this->matchedValue, 0, 200) . '...'
|
||||
: $this->matchedValue;
|
||||
|
||||
// Remove potential log injection attacks
|
||||
$value = str_replace(["\r", "\n", "\t"], ['\r', '\n', '\t'], $value);
|
||||
|
||||
// Mask sensitive patterns
|
||||
if ($this->condition->targetsSensitiveData()) {
|
||||
return '***REDACTED***';
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get match excerpt showing context around matches
|
||||
*/
|
||||
public function getMatchExcerpt(int $contextLength = 50): array
|
||||
{
|
||||
$excerpts = [];
|
||||
|
||||
foreach ($this->matches as $match) {
|
||||
if (is_array($match) && isset($match['offset'])) {
|
||||
$start = max(0, $match['offset'] - $contextLength);
|
||||
$length = strlen($match['match']) + (2 * $contextLength);
|
||||
|
||||
$excerpt = substr($this->matchedValue, $start, $length);
|
||||
$relativeOffset = $match['offset'] - $start;
|
||||
|
||||
$excerpts[] = [
|
||||
'excerpt' => $excerpt,
|
||||
'match_start' => $relativeOffset,
|
||||
'match_length' => strlen($match['match']),
|
||||
'absolute_offset' => $match['offset'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $excerpts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if match indicates high-severity threat
|
||||
*/
|
||||
public function isHighSeverityThreat(): bool
|
||||
{
|
||||
return $this->severity === DetectionSeverity::CRITICAL ||
|
||||
$this->severity === DetectionSeverity::HIGH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if match should trigger immediate blocking
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
if ($this->isHighSeverityThreat()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// High confidence medium threats should also block
|
||||
if ($this->severity === DetectionSeverity::MEDIUM &&
|
||||
$this->confidence !== null &&
|
||||
$this->confidence->getValue() >= 90.0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if match should trigger alert
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return $this->severity->shouldAlert() ||
|
||||
($this->confidence !== null && $this->confidence->getValue() >= 70.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OWASP category information
|
||||
*/
|
||||
public function getOwaspInfo(): array
|
||||
{
|
||||
return [
|
||||
'category' => $this->category->value,
|
||||
'owasp_rank' => $this->category->getOwaspRank(),
|
||||
'is_top10' => $this->category->isOwaspTop10(),
|
||||
'description' => $this->category->getDescription(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule performance metrics
|
||||
*/
|
||||
public function getPerformanceMetrics(): array
|
||||
{
|
||||
$complexity = 'low';
|
||||
|
||||
if ($this->condition->pattern->isRegex) {
|
||||
$complexity = $this->condition->pattern->isPotentiallyDangerous() ? 'high' : 'medium';
|
||||
}
|
||||
|
||||
return [
|
||||
'pattern_complexity' => $complexity,
|
||||
'pattern_type' => $this->condition->pattern->isRegex ? 'regex' : 'string',
|
||||
'match_count' => count($this->matches),
|
||||
'value_length' => strlen($this->matchedValue),
|
||||
'targets_sensitive_data' => $this->condition->targetsSensitiveData(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add metadata
|
||||
*/
|
||||
public function withMetadata(array $metadata): self
|
||||
{
|
||||
return new self(
|
||||
ruleId: $this->ruleId,
|
||||
ruleName: $this->ruleName,
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
condition: $this->condition,
|
||||
matchedValue: $this->matchedValue,
|
||||
matches: $this->matches,
|
||||
confidence: $this->confidence,
|
||||
message: $this->message,
|
||||
timestamp: $this->timestamp,
|
||||
metadata: array_merge($this->metadata, $metadata)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'rule_id' => $this->ruleId->value,
|
||||
'rule_name' => $this->ruleName,
|
||||
'category' => $this->category->value,
|
||||
'severity' => $this->severity->value,
|
||||
'message' => $this->message,
|
||||
'confidence' => $this->confidence?->getValue(),
|
||||
'matched_value' => $this->getSanitizedMatchedValue(),
|
||||
'match_excerpts' => $this->getMatchExcerpt(),
|
||||
'timestamp' => $this->timestamp?->toIsoString(),
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
'owasp_info' => $this->getOwaspInfo(),
|
||||
'performance_metrics' => $this->getPerformanceMetrics(),
|
||||
'condition' => $this->condition->toArray(),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence based on match quality
|
||||
*/
|
||||
private static function calculateConfidence(array $matches, RulePattern $pattern): Percentage
|
||||
{
|
||||
if (empty($matches)) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$baseConfidence = 70.0;
|
||||
|
||||
// Boost confidence for regex patterns (more precise)
|
||||
if ($pattern->isRegex) {
|
||||
$baseConfidence += 10.0;
|
||||
}
|
||||
|
||||
// Boost confidence for multiple matches
|
||||
$matchBonus = min(20.0, count($matches) * 5.0);
|
||||
$baseConfidence += $matchBonus;
|
||||
|
||||
// Reduce confidence for potentially dangerous patterns (could be false positive)
|
||||
if ($pattern->isPotentiallyDangerous()) {
|
||||
$baseConfidence -= 15.0;
|
||||
}
|
||||
|
||||
return Percentage::from(max(10.0, min(100.0, $baseConfidence)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate descriptive message for match
|
||||
*/
|
||||
private static function generateMessage(RuleCondition $condition, string $matchedValue): string
|
||||
{
|
||||
$target = $condition->getTargetDescription();
|
||||
$patternType = $condition->pattern->isRegex ? 'pattern' : 'string';
|
||||
$valuePreview = strlen($matchedValue) > 50
|
||||
? substr($matchedValue, 0, 50) . '...'
|
||||
: $matchedValue;
|
||||
|
||||
if ($condition->negated) {
|
||||
return "Negative match: {$target} does not contain expected {$patternType}";
|
||||
}
|
||||
|
||||
return "Suspicious {$patternType} detected in {$target}: '{$valuePreview}'";
|
||||
}
|
||||
}
|
||||
314
src/Framework/Waf/Rules/ValueObjects/RulePattern.php
Normal file
314
src/Framework/Waf/Rules/ValueObjects/RulePattern.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\Rules\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Pattern for rule matching (string or regex)
|
||||
*/
|
||||
final readonly class RulePattern
|
||||
{
|
||||
public function __construct(
|
||||
public string $pattern,
|
||||
public bool $isRegex = false,
|
||||
public bool $caseSensitive = false,
|
||||
public bool $multiline = false,
|
||||
public ?string $modifiers = null
|
||||
) {
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create simple string pattern
|
||||
*/
|
||||
public static function string(string $pattern, bool $caseSensitive = false): self
|
||||
{
|
||||
return new self(
|
||||
pattern: $pattern,
|
||||
isRegex: false,
|
||||
caseSensitive: $caseSensitive
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create regex pattern
|
||||
*/
|
||||
public static function regex(string $pattern, ?string $modifiers = null): self
|
||||
{
|
||||
return new self(
|
||||
pattern: $pattern,
|
||||
isRegex: true,
|
||||
caseSensitive: true, // Controlled by regex modifiers
|
||||
multiline: str_contains($modifiers ?? '', 'm'),
|
||||
modifiers: $modifiers
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create case-insensitive string pattern
|
||||
*/
|
||||
public static function stringIgnoreCase(string $pattern): self
|
||||
{
|
||||
return new self(
|
||||
pattern: $pattern,
|
||||
isRegex: false,
|
||||
caseSensitive: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SQL injection detection pattern
|
||||
*/
|
||||
public static function sqlInjection(): self
|
||||
{
|
||||
return self::regex(
|
||||
pattern: '(?i:(?:[\s\'"`´«»₴¢£¥€₽₹₨₩₪₫₦₡₵₲₴₸₼₽﷼]*(?:select|union|insert|delete|update|create|drop|alter|exec|execute|declare|cast|convert|script)[\s\'"`´«»₴¢£¥€₽₹₨₩₪₫₦₡₵₲₴₸₼₽﷼]*|(?:\'(?:[^\'\\\\]|\\\\.)*\'|\"(?:[^\"\\\\]|\\\\.)*\"|`(?:[^`\\\\]|\\\\.)*`)[\s\'"`´«»₴¢£¥€₽₹₨₩₪₫₦₡₵₲₴₸₼₽﷼]*(?:=|<|>|<>|!=|like)[\s\'"`´«»₴¢£¥€₽₹₨₩₪₫₦₡₵₲₴₸₼₽﷼]*(?:\'(?:[^\'\\\\]|\\\\.)*\'|\"(?:[^\"\\\\]|\\\\.)*\"|`(?:[^`\\\\]|\\\\.)*`)|(?:or|and)[\s\'"`´«»₴¢£¥€₽₹₨₩₪₫₦₡₵₲₴₸₼₽﷼]+(?:\'(?:[^\'\\\\]|\\\\.)*\'|\"(?:[^\"\\\\]|\\\\.)*\"|`(?:[^`\\\\]|\\\\.)*`|[\d]+)[\s\'"`´«»₴¢£¥€₽₹₨₩₪₫₦₡₵₲₴₸₼₽﷼]*(?:=|<|>|<>|!=|like))',
|
||||
modifiers: 'i'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create XSS detection pattern
|
||||
*/
|
||||
public static function xssDetection(): self
|
||||
{
|
||||
return self::regex(
|
||||
pattern: '(?i:<(?:script|iframe|object|embed|applet|meta|link|style|img|svg|math|details|template)[^>]*>|(?:javascript|vbscript|data|file|about):|on(?:load|error|click|focus|blur|change|submit|reset|select|keydown|keyup|keypress|mouseover|mouseout|mousedown|mouseup|mousemove)[\s]*=|(?:eval|setTimeout|setInterval|Function|execScript|mshtml|expression)\s*\()',
|
||||
modifiers: 'i'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create path traversal detection pattern
|
||||
*/
|
||||
public static function pathTraversal(): self
|
||||
{
|
||||
return self::regex(
|
||||
pattern: '(?i:(?:\.\.[\\/])|(?:[\\/]\.\.)|(?:\.\.\\\\)|(?:\\\\\.\.)|(?:%2e%2e%2f)|(?:%2e%2e\\\\)|(?:\.\.%2f)|(?:\.\.%5c)|(?:%2e%2e%5c)|(?:%c0%ae%c0%ae%c0%af)|(?:%c1%9c%c1%9c%c1%af))',
|
||||
modifiers: 'i'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create command injection detection pattern
|
||||
*/
|
||||
public static function commandInjection(): self
|
||||
{
|
||||
return self::regex(
|
||||
pattern: '(?i:(?:;|\||\|\||&&|&|`|\$\(|\${)[\s]*(?:cat|ls|pwd|id|uname|whoami|ps|kill|rm|mv|cp|chmod|chown|find|grep|awk|sed|sort|head|tail|wc|netstat|ifconfig|ping|wget|curl|nc|telnet|ssh|su|sudo|passwd|shadow|etc\/passwd|etc\/shadow|proc\/)|(?:(?:cmd|command)\.exe|powershell|bash|sh|zsh|csh|tcsh|fish)[\s]*(?:\/c|\/k|-c|-e))',
|
||||
modifiers: 'i'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create PHP code injection detection pattern
|
||||
*/
|
||||
public static function phpCodeInjection(): self
|
||||
{
|
||||
return self::regex(
|
||||
pattern: '(?i:(?:<\?(?:php)?|<\?=|\?>)|(?:eval|assert|create_function|call_user_func|call_user_func_array|preg_replace|system|exec|shell_exec|passthru|file_get_contents|file_put_contents|fopen|fwrite|include|require|include_once|require_once)[\s]*\(|(?:\$_(?:GET|POST|REQUEST|COOKIE|SESSION|SERVER|ENV)\[))',
|
||||
modifiers: 'i'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test pattern against input string
|
||||
*/
|
||||
public function matches(string $input): bool
|
||||
{
|
||||
if ($this->isRegex) {
|
||||
return $this->matchesRegex($input);
|
||||
}
|
||||
|
||||
return $this->matchesString($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all matches from input
|
||||
*/
|
||||
public function getAllMatches(string $input): array
|
||||
{
|
||||
if ($this->isRegex) {
|
||||
$matches = [];
|
||||
$flags = PREG_SET_ORDER;
|
||||
|
||||
if ($this->multiline) {
|
||||
$flags = $flags | PREG_UNMATCHED_AS_NULL;
|
||||
}
|
||||
|
||||
$pattern = $this->getCompiledRegex();
|
||||
preg_match_all($pattern, $input, $matches, $flags);
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
// For string patterns, find all occurrences
|
||||
$matches = [];
|
||||
$searchInput = $this->caseSensitive ? $input : strtolower($input);
|
||||
$searchPattern = $this->caseSensitive ? $this->pattern : strtolower($this->pattern);
|
||||
|
||||
$offset = 0;
|
||||
while (($pos = strpos($searchInput, $searchPattern, $offset)) !== false) {
|
||||
$matches[] = [
|
||||
'match' => substr($input, $pos, strlen($this->pattern)),
|
||||
'offset' => $pos,
|
||||
];
|
||||
$offset = $pos + 1;
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get match position in input
|
||||
*/
|
||||
public function getMatchPosition(string $input): ?int
|
||||
{
|
||||
if ($this->isRegex) {
|
||||
$matches = [];
|
||||
$pattern = $this->getCompiledRegex();
|
||||
|
||||
if (preg_match($pattern, $input, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
return $matches[0][1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$searchInput = $this->caseSensitive ? $input : strtolower($input);
|
||||
$searchPattern = $this->caseSensitive ? $this->pattern : strtolower($this->pattern);
|
||||
|
||||
$pos = strpos($searchInput, $searchPattern);
|
||||
|
||||
return $pos !== false ? $pos : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compiled regex pattern
|
||||
*/
|
||||
public function getCompiledRegex(): string
|
||||
{
|
||||
if (! $this->isRegex) {
|
||||
throw new InvalidArgumentException('Cannot compile non-regex pattern');
|
||||
}
|
||||
|
||||
$delimiters = ['/', '#', '~', '@', '%', '!'];
|
||||
$delimiter = '/';
|
||||
|
||||
// Find a delimiter that's not in the pattern
|
||||
foreach ($delimiters as $d) {
|
||||
if (strpos($this->pattern, $d) === false) {
|
||||
$delimiter = $d;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$compiled = $delimiter . $this->pattern . $delimiter;
|
||||
|
||||
if ($this->modifiers) {
|
||||
$compiled .= $this->modifiers;
|
||||
}
|
||||
|
||||
return $compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pattern is potentially dangerous
|
||||
*/
|
||||
public function isPotentiallyDangerous(): bool
|
||||
{
|
||||
if (! $this->isRegex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for potentially expensive regex patterns
|
||||
$dangerousPatterns = [
|
||||
'.*.*', // Nested quantifiers
|
||||
'.+.+', // Nested quantifiers
|
||||
'(.*)*', // Nested groups with quantifiers
|
||||
'(.+)+', // Nested groups with quantifiers
|
||||
'([^x]*)*', // Negated character class with quantifier
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $dangerous) {
|
||||
if (strpos($this->pattern, $dangerous) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'pattern' => $this->pattern,
|
||||
'is_regex' => $this->isRegex,
|
||||
'case_sensitive' => $this->caseSensitive,
|
||||
'multiline' => $this->multiline,
|
||||
'modifiers' => $this->modifiers,
|
||||
'is_dangerous' => $this->isPotentiallyDangerous(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match against regex pattern
|
||||
*/
|
||||
private function matchesRegex(string $input): bool
|
||||
{
|
||||
$pattern = $this->getCompiledRegex();
|
||||
|
||||
return preg_match($pattern, $input) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match against string pattern
|
||||
*/
|
||||
private function matchesString(string $input): bool
|
||||
{
|
||||
$searchInput = $this->caseSensitive ? $input : strtolower($input);
|
||||
$searchPattern = $this->caseSensitive ? $this->pattern : strtolower($this->pattern);
|
||||
|
||||
return strpos($searchInput, $searchPattern) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate pattern
|
||||
*/
|
||||
private function validate(): void
|
||||
{
|
||||
if (empty($this->pattern)) {
|
||||
throw new InvalidArgumentException('Pattern cannot be empty');
|
||||
}
|
||||
|
||||
if ($this->isRegex) {
|
||||
// Test regex compilation
|
||||
$testPattern = $this->getCompiledRegex();
|
||||
|
||||
// Suppress warnings and test the regex
|
||||
$result = @preg_match($testPattern, '');
|
||||
|
||||
if ($result === false) {
|
||||
throw new InvalidArgumentException("Invalid regex pattern: {$this->pattern}");
|
||||
}
|
||||
|
||||
// Check for dangerous patterns
|
||||
if ($this->isPotentiallyDangerous()) {
|
||||
// Log warning but don't fail - let the user decide
|
||||
error_log("Warning: Potentially dangerous regex pattern detected: {$this->pattern}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->pattern;
|
||||
}
|
||||
}
|
||||
370
src/Framework/Waf/ThreatAssessment.php
Normal file
370
src/Framework/Waf/ThreatAssessment.php
Normal file
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\ValueObjects\Detection;
|
||||
use App\Framework\Waf\ValueObjects\DetectionCollection;
|
||||
|
||||
/**
|
||||
* Unified threat assessment system
|
||||
* Analyzes and scores all detections from WAF layers
|
||||
*/
|
||||
final readonly class ThreatAssessment
|
||||
{
|
||||
public function __construct(
|
||||
public DetectionCollection $detections,
|
||||
public Percentage $overallThreatScore,
|
||||
public DetectionSeverity $maxSeverity,
|
||||
public int $owaspTop10Count,
|
||||
public int $criticalCount,
|
||||
public int $highCount,
|
||||
public Percentage $averageConfidence,
|
||||
public ?Timestamp $assessmentTime = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create assessment from detection collection
|
||||
*/
|
||||
public static function fromDetections(DetectionCollection $detections): self
|
||||
{
|
||||
if ($detections->isEmpty()) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
$overallScore = self::calculateOverallThreatScore($detections);
|
||||
$maxSeverity = self::findMaxSeverity($detections);
|
||||
$owaspTop10Count = self::countOwaspTop10($detections);
|
||||
$criticalCount = self::countBySeverity($detections, DetectionSeverity::CRITICAL);
|
||||
$highCount = self::countBySeverity($detections, DetectionSeverity::HIGH);
|
||||
$averageConfidence = self::calculateAverageConfidence($detections);
|
||||
|
||||
return new self(
|
||||
detections: $detections,
|
||||
overallThreatScore: $overallScore,
|
||||
maxSeverity: $maxSeverity,
|
||||
owaspTop10Count: $owaspTop10Count,
|
||||
criticalCount: $criticalCount,
|
||||
highCount: $highCount,
|
||||
averageConfidence: $averageConfidence,
|
||||
assessmentTime: Timestamp::fromFloat(microtime(true))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty assessment
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return self::createEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty assessment
|
||||
*/
|
||||
public static function createEmpty(): self
|
||||
{
|
||||
return new self(
|
||||
detections: DetectionCollection::empty(),
|
||||
overallThreatScore: Percentage::from(0.0),
|
||||
maxSeverity: DetectionSeverity::INFO,
|
||||
owaspTop10Count: 0,
|
||||
criticalCount: 0,
|
||||
highCount: 0,
|
||||
averageConfidence: Percentage::from(0.0),
|
||||
assessmentTime: Timestamp::fromFloat(microtime(true))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be blocked
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
// Block if any detection explicitly requires blocking
|
||||
if ($this->hasBlockingDetection()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Block based on threat score threshold
|
||||
if ($this->overallThreatScore->getValue() >= 80.0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Block if multiple high-severity threats
|
||||
if ($this->criticalCount > 0 || $this->highCount >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Block if multiple OWASP Top 10 violations
|
||||
if ($this->owaspTop10Count >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if assessment should trigger alert
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return $this->maxSeverity->shouldAlert() ||
|
||||
$this->overallThreatScore->getValue() >= 50.0 ||
|
||||
$this->owaspTop10Count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if assessment requires immediate action
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return $this->criticalCount > 0 ||
|
||||
$this->maxSeverity->requiresImmediateAction() ||
|
||||
$this->overallThreatScore->getValue() >= 90.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level description
|
||||
*/
|
||||
public function getRiskLevel(): string
|
||||
{
|
||||
$score = $this->overallThreatScore->getValue();
|
||||
|
||||
return match (true) {
|
||||
$score >= 90.0 => 'CRITICAL',
|
||||
$score >= 70.0 => 'HIGH',
|
||||
$score >= 40.0 => 'MEDIUM',
|
||||
$score >= 10.0 => 'LOW',
|
||||
default => 'MINIMAL'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat categories present in assessment
|
||||
*/
|
||||
public function getThreatCategories(): array
|
||||
{
|
||||
$categories = [];
|
||||
|
||||
foreach ($this->detections->detections as $detection) {
|
||||
$categories[$detection->category->value] = $detection->category;
|
||||
}
|
||||
|
||||
return array_values($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detections by severity
|
||||
*/
|
||||
public function getDetectionsBySeverity(DetectionSeverity $severity): DetectionCollection
|
||||
{
|
||||
return $this->detections->filterBySeverity($severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detections by category
|
||||
*/
|
||||
public function getDetectionsByCategory(DetectionCategory $category): DetectionCollection
|
||||
{
|
||||
return $this->detections->filterByCategory($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OWASP Top 10 detections
|
||||
*/
|
||||
public function getOwaspTop10Detections(): DetectionCollection
|
||||
{
|
||||
return $this->detections->filter(fn (Detection $detection) => $detection->isOwaspTop10());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocking detections
|
||||
*/
|
||||
public function getBlockingDetections(): DetectionCollection
|
||||
{
|
||||
return $this->detections->filter(fn (Detection $detection) => $detection->shouldBlock());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if assessment has any blocking detection
|
||||
*/
|
||||
public function hasBlockingDetection(): bool
|
||||
{
|
||||
return ! $this->getBlockingDetections()->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if assessment is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->detections->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total detection count
|
||||
*/
|
||||
public function getDetectionCount(): int
|
||||
{
|
||||
return $this->detections->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with another assessment
|
||||
*/
|
||||
public function merge(self $other): self
|
||||
{
|
||||
if ($other->isEmpty()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($this->isEmpty()) {
|
||||
return $other;
|
||||
}
|
||||
|
||||
$mergedDetections = $this->detections->merge($other->detections);
|
||||
|
||||
return self::fromDetections($mergedDetections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'overall_threat_score' => $this->overallThreatScore->getValue(),
|
||||
'risk_level' => $this->getRiskLevel(),
|
||||
'max_severity' => $this->maxSeverity->value,
|
||||
'detection_count' => $this->getDetectionCount(),
|
||||
'owasp_top10_count' => $this->owaspTop10Count,
|
||||
'critical_count' => $this->criticalCount,
|
||||
'high_count' => $this->highCount,
|
||||
'average_confidence' => $this->averageConfidence->getValue(),
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
'requires_immediate_action' => $this->requiresImmediateAction(),
|
||||
'threat_categories' => array_map(fn ($cat) => $cat->value, $this->getThreatCategories()),
|
||||
'assessment_time' => $this->assessmentTime?->toIsoString(),
|
||||
'detections' => $this->detections->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall threat score from detections
|
||||
*/
|
||||
private static function calculateOverallThreatScore(DetectionCollection $detections): Percentage
|
||||
{
|
||||
if ($detections->isEmpty()) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$totalScore = 0.0;
|
||||
$maxScore = 0.0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($detections->detections as $detection) {
|
||||
$threatScore = $detection->getThreatScore()->getValue();
|
||||
$totalScore += $threatScore;
|
||||
$maxScore = max($maxScore, $threatScore);
|
||||
$count++;
|
||||
}
|
||||
|
||||
// Use weighted average: 70% average + 30% maximum
|
||||
$averageScore = $totalScore / $count;
|
||||
$finalScore = ($averageScore * 0.7) + ($maxScore * 0.3);
|
||||
|
||||
// Apply severity multipliers
|
||||
$criticalCount = self::countBySeverity($detections, DetectionSeverity::CRITICAL);
|
||||
$highCount = self::countBySeverity($detections, DetectionSeverity::HIGH);
|
||||
|
||||
if ($criticalCount > 0) {
|
||||
$finalScore = min(100.0, $finalScore * (1.0 + ($criticalCount * 0.2)));
|
||||
}
|
||||
|
||||
if ($highCount > 0) {
|
||||
$finalScore = min(100.0, $finalScore * (1.0 + ($highCount * 0.1)));
|
||||
}
|
||||
|
||||
return Percentage::from($finalScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find maximum severity in detections
|
||||
*/
|
||||
private static function findMaxSeverity(DetectionCollection $detections): DetectionSeverity
|
||||
{
|
||||
$maxSeverity = DetectionSeverity::INFO;
|
||||
|
||||
foreach ($detections->detections as $detection) {
|
||||
if ($detection->severity->getCvssScore() > $maxSeverity->getCvssScore()) {
|
||||
$maxSeverity = $detection->severity;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxSeverity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count OWASP Top 10 detections
|
||||
*/
|
||||
private static function countOwaspTop10(DetectionCollection $detections): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($detections->detections as $detection) {
|
||||
if ($detection->isOwaspTop10()) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count detections by severity
|
||||
*/
|
||||
private static function countBySeverity(DetectionCollection $detections, DetectionSeverity $severity): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($detections->detections as $detection) {
|
||||
if ($detection->severity === $severity) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average confidence from detections
|
||||
*/
|
||||
private static function calculateAverageConfidence(DetectionCollection $detections): Percentage
|
||||
{
|
||||
if ($detections->isEmpty()) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
$totalConfidence = 0.0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($detections->detections as $detection) {
|
||||
if ($detection->confidence !== null) {
|
||||
$totalConfidence += $detection->confidence->getValue();
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($count === 0) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
return Percentage::from($totalConfidence / $count);
|
||||
}
|
||||
}
|
||||
116
src/Framework/Waf/ThreatAssessmentService.php
Normal file
116
src/Framework/Waf/ThreatAssessmentService.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Waf\MachineLearning\MachineLearningResult;
|
||||
|
||||
/**
|
||||
* Service for evaluating layer results and creating WAF decisions
|
||||
*/
|
||||
final readonly class ThreatAssessmentService
|
||||
{
|
||||
public function __construct(
|
||||
private Percentage $blockingThreshold,
|
||||
private Percentage $warningThreshold,
|
||||
private bool $learningMode,
|
||||
private Clock $clock
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all layer results and ML analysis to create a decision
|
||||
* @param array<string, mixed> $layerResults
|
||||
*/
|
||||
public function evaluate(
|
||||
array $layerResults,
|
||||
Request $request,
|
||||
Duration $totalDuration,
|
||||
?MachineLearningResult $mlResult = null
|
||||
): WafDecision {
|
||||
// Collect all detections from layers
|
||||
$allDetections = [];
|
||||
$totalThreatScore = 0.0;
|
||||
$maxConfidence = 0.0;
|
||||
$matchedRules = [];
|
||||
|
||||
// Skip layer result processing for now due to missing classes
|
||||
// foreach ($layerResults as $layerName => $result) {
|
||||
// Process layer results
|
||||
// }
|
||||
|
||||
// Skip ML anomaly processing for now
|
||||
// if ($mlResult !== null && $mlResult->hasAnomalies()) {
|
||||
// Process ML anomalies
|
||||
// }
|
||||
|
||||
// Calculate normalized threat score (0-100)
|
||||
$normalizedThreatScore = min(100.0, $totalThreatScore);
|
||||
$threatScorePercentage = Percentage::from($normalizedThreatScore);
|
||||
$confidencePercentage = Percentage::from($maxConfidence);
|
||||
|
||||
// Determine action based on threat score and mode
|
||||
$action = $this->determineAction($threatScorePercentage);
|
||||
|
||||
// Create empty threat assessment for now
|
||||
$threatAssessment = ThreatAssessment::createEmpty();
|
||||
|
||||
// Create and return WAF decision
|
||||
return WafDecision::fromAssessment($threatAssessment, $totalDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine action based on threat score and configuration
|
||||
*/
|
||||
private function determineAction(Percentage $threatScore): WafAction
|
||||
{
|
||||
if ($this->learningMode) {
|
||||
return WafAction::MONITOR;
|
||||
}
|
||||
|
||||
if ($threatScore->greaterThan($this->blockingThreshold) || $threatScore->equals($this->blockingThreshold)) {
|
||||
return WafAction::BLOCK;
|
||||
}
|
||||
|
||||
if ($threatScore->greaterThan($this->warningThreshold) || $threatScore->equals($this->warningThreshold)) {
|
||||
return WafAction::CHALLENGE;
|
||||
}
|
||||
|
||||
return WafAction::ALLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weight for severity level
|
||||
*/
|
||||
private function getSeverityWeight(DetectionSeverity $severity): float
|
||||
{
|
||||
return match($severity) {
|
||||
DetectionSeverity::CRITICAL => 1.0,
|
||||
DetectionSeverity::HIGH => 0.8,
|
||||
DetectionSeverity::MEDIUM => 0.5,
|
||||
DetectionSeverity::LOW => 0.3,
|
||||
DetectionSeverity::INFO => 0.1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map anomaly score to severity
|
||||
*/
|
||||
private function mapAnomalyScoreToSeverity(float $score): DetectionSeverity
|
||||
{
|
||||
return match(true) {
|
||||
$score >= 0.9 => DetectionSeverity::CRITICAL,
|
||||
$score >= 0.7 => DetectionSeverity::HIGH,
|
||||
$score >= 0.5 => DetectionSeverity::MEDIUM,
|
||||
$score >= 0.3 => DetectionSeverity::LOW,
|
||||
default => DetectionSeverity::INFO
|
||||
};
|
||||
}
|
||||
|
||||
// Methods already defined above - removed duplicates
|
||||
}
|
||||
250
src/Framework/Waf/ValueObjects/AdditionalContext.php
Normal file
250
src/Framework/Waf/ValueObjects/AdditionalContext.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
/**
|
||||
* Additional contextual information for WAF detections and analysis
|
||||
* Flexible container for various types of context data
|
||||
*/
|
||||
final readonly class AdditionalContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?RequestContext $request = null,
|
||||
public ?SessionContext $session = null,
|
||||
public ?GeographicContext $geographic = null,
|
||||
public ?TechnicalContext $technical = null,
|
||||
public ?BusinessContext $business = null,
|
||||
public array $customData = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty context
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context with request information
|
||||
*/
|
||||
public static function withRequest(RequestContext $request): self
|
||||
{
|
||||
return new self(request: $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context with session information
|
||||
*/
|
||||
public static function withSession(SessionContext $session): self
|
||||
{
|
||||
return new self(session: $session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context with geographic information
|
||||
*/
|
||||
public static function withGeographic(GeographicContext $geographic): self
|
||||
{
|
||||
return new self(geographic: $geographic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context with custom data
|
||||
*/
|
||||
public static function withCustomData(array $data): self
|
||||
{
|
||||
return new self(customData: $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context has request information
|
||||
*/
|
||||
public function hasRequest(): bool
|
||||
{
|
||||
return $this->request !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context has session information
|
||||
*/
|
||||
public function hasSession(): bool
|
||||
{
|
||||
return $this->session !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context has geographic information
|
||||
*/
|
||||
public function hasGeographic(): bool
|
||||
{
|
||||
return $this->geographic !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context has technical information
|
||||
*/
|
||||
public function hasTechnical(): bool
|
||||
{
|
||||
return $this->technical !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context has business information
|
||||
*/
|
||||
public function hasBusiness(): bool
|
||||
{
|
||||
return $this->business !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context has custom data
|
||||
*/
|
||||
public function hasCustomData(): bool
|
||||
{
|
||||
return ! empty($this->customData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with another context
|
||||
*/
|
||||
public function merge(?self $other): self
|
||||
{
|
||||
if ($other === null) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self(
|
||||
request: $other->request ?? $this->request,
|
||||
session: $other->session ?? $this->session,
|
||||
geographic: $other->geographic ?? $this->geographic,
|
||||
technical: $other->technical ?? $this->technical,
|
||||
business: $other->business ?? $this->business,
|
||||
customData: array_merge($this->customData, $other->customData)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add request context
|
||||
*/
|
||||
public function addRequest(RequestContext $request): self
|
||||
{
|
||||
return new self(
|
||||
request: $request,
|
||||
session: $this->session,
|
||||
geographic: $this->geographic,
|
||||
technical: $this->technical,
|
||||
business: $this->business,
|
||||
customData: $this->customData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add session context
|
||||
*/
|
||||
public function addSession(SessionContext $session): self
|
||||
{
|
||||
return new self(
|
||||
request: $this->request,
|
||||
session: $session,
|
||||
geographic: $this->geographic,
|
||||
technical: $this->technical,
|
||||
business: $this->business,
|
||||
customData: $this->customData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add geographic context
|
||||
*/
|
||||
public function addGeographic(GeographicContext $geographic): self
|
||||
{
|
||||
return new self(
|
||||
request: $this->request,
|
||||
session: $this->session,
|
||||
geographic: $geographic,
|
||||
technical: $this->technical,
|
||||
business: $this->business,
|
||||
customData: $this->customData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add technical context
|
||||
*/
|
||||
public function addTechnical(TechnicalContext $technical): self
|
||||
{
|
||||
return new self(
|
||||
request: $this->request,
|
||||
session: $this->session,
|
||||
geographic: $this->geographic,
|
||||
technical: $technical,
|
||||
business: $this->business,
|
||||
customData: $this->customData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add business context
|
||||
*/
|
||||
public function addBusiness(BusinessContext $business): self
|
||||
{
|
||||
return new self(
|
||||
request: $this->request,
|
||||
session: $this->session,
|
||||
geographic: $this->geographic,
|
||||
technical: $this->technical,
|
||||
business: $business,
|
||||
customData: $this->customData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom data
|
||||
*/
|
||||
public function addCustomData(array $data): self
|
||||
{
|
||||
return new self(
|
||||
request: $this->request,
|
||||
session: $this->session,
|
||||
geographic: $this->geographic,
|
||||
technical: $this->technical,
|
||||
business: $this->business,
|
||||
customData: array_merge($this->customData, $data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom value by key
|
||||
*/
|
||||
public function getCustomValue(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->customData[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if custom key exists
|
||||
*/
|
||||
public function hasCustomKey(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->customData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'request' => $this->request?->toArray(),
|
||||
'session' => $this->session?->toArray(),
|
||||
'geographic' => $this->geographic?->toArray(),
|
||||
'technical' => $this->technical?->toArray(),
|
||||
'business' => $this->business?->toArray(),
|
||||
'custom_data' => $this->customData,
|
||||
], fn ($value) => $value !== null && $value !== []);
|
||||
}
|
||||
}
|
||||
33
src/Framework/Waf/ValueObjects/BusinessContext.php
Normal file
33
src/Framework/Waf/ValueObjects/BusinessContext.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
/**
|
||||
* Business context information
|
||||
*/
|
||||
final readonly class BusinessContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $application = null,
|
||||
public ?string $environment = null,
|
||||
public ?string $tenant = null,
|
||||
public ?string $apiVersion = null,
|
||||
public ?string $feature = null,
|
||||
public ?array $businessRules = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'application' => $this->application,
|
||||
'environment' => $this->environment,
|
||||
'tenant' => $this->tenant,
|
||||
'api_version' => $this->apiVersion,
|
||||
'feature' => $this->feature,
|
||||
'business_rules' => $this->businessRules,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
258
src/Framework/Waf/ValueObjects/Detection.php
Normal file
258
src/Framework/Waf/ValueObjects/Detection.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Waf\DetectionCategory;
|
||||
use App\Framework\Waf\DetectionSeverity;
|
||||
|
||||
/**
|
||||
* Represents a single security threat detection
|
||||
*/
|
||||
final readonly class Detection
|
||||
{
|
||||
public function __construct(
|
||||
public DetectionCategory $category,
|
||||
public DetectionSeverity $severity,
|
||||
public string $message,
|
||||
public ?RuleId $ruleId = null,
|
||||
public ?Percentage $confidence = null,
|
||||
public ?PayloadSample $payload = null,
|
||||
public ?string $location = null,
|
||||
public ?Timestamp $timestamp = null,
|
||||
public ?AdditionalContext $context = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detection with automatic severity from category
|
||||
*/
|
||||
public static function create(
|
||||
DetectionCategory $category,
|
||||
string $message,
|
||||
?RuleId $ruleId = null,
|
||||
?Percentage $confidence = null
|
||||
): self {
|
||||
return new self(
|
||||
category: $category,
|
||||
severity: $category->getDefaultSeverity(),
|
||||
message: $message,
|
||||
ruleId: $ruleId,
|
||||
confidence: $confidence,
|
||||
timestamp: Timestamp::now()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detection with custom severity
|
||||
*/
|
||||
public static function withSeverity(
|
||||
DetectionCategory $category,
|
||||
DetectionSeverity $severity,
|
||||
string $message,
|
||||
?RuleId $ruleId = null,
|
||||
?Percentage $confidence = null
|
||||
): self {
|
||||
return new self(
|
||||
category: $category,
|
||||
severity: $severity,
|
||||
message: $message,
|
||||
ruleId: $ruleId,
|
||||
confidence: $confidence,
|
||||
timestamp: Timestamp::now()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create high-confidence detection
|
||||
*/
|
||||
public static function highConfidence(
|
||||
DetectionCategory $category,
|
||||
string $message,
|
||||
RuleId $ruleId
|
||||
): self {
|
||||
return new self(
|
||||
category: $category,
|
||||
severity: $category->getDefaultSeverity(),
|
||||
message: $message,
|
||||
ruleId: $ruleId,
|
||||
confidence: Percentage::from(95.0),
|
||||
timestamp: Timestamp::now()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detection with payload sample
|
||||
*/
|
||||
public static function withPayload(
|
||||
DetectionCategory $category,
|
||||
string $message,
|
||||
PayloadSample $payload,
|
||||
?RuleId $ruleId = null
|
||||
): self {
|
||||
return new self(
|
||||
category: $category,
|
||||
severity: $category->getDefaultSeverity(),
|
||||
message: $message,
|
||||
ruleId: $ruleId,
|
||||
payload: $payload,
|
||||
timestamp: Timestamp::now()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detection is critical
|
||||
*/
|
||||
public function isCritical(): bool
|
||||
{
|
||||
return $this->severity === DetectionSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detection should block request
|
||||
*/
|
||||
public function shouldBlock(): bool
|
||||
{
|
||||
return $this->severity->shouldBlock() || $this->category->shouldAutoBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detection should trigger alert
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return $this->severity->shouldAlert();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detection requires immediate action
|
||||
*/
|
||||
public function requiresImmediateAction(): bool
|
||||
{
|
||||
return $this->severity->requiresImmediateAction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OWASP rank if applicable
|
||||
*/
|
||||
public function getOwaspRank(): ?int
|
||||
{
|
||||
return $this->category->getOwaspRank();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an OWASP Top 10 detection
|
||||
*/
|
||||
public function isOwaspTop10(): bool
|
||||
{
|
||||
return $this->category->isOwaspTop10();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat score for risk calculation
|
||||
*/
|
||||
public function getThreatScore(): Percentage
|
||||
{
|
||||
$baseScore = $this->severity->getScore();
|
||||
|
||||
// Adjust score based on confidence
|
||||
if ($this->confidence !== null) {
|
||||
$confidenceMultiplier = $this->confidence->getValue() / 100.0;
|
||||
$adjustedScore = $baseScore * $confidenceMultiplier;
|
||||
|
||||
return Percentage::from($adjustedScore);
|
||||
}
|
||||
|
||||
return Percentage::from((float) $baseScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add location information
|
||||
*/
|
||||
public function withLocation(string $location): self
|
||||
{
|
||||
return new self(
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
message: $this->message,
|
||||
ruleId: $this->ruleId,
|
||||
confidence: $this->confidence,
|
||||
payload: $this->payload,
|
||||
location: $location,
|
||||
timestamp: $this->timestamp,
|
||||
context: $this->context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add additional context
|
||||
*/
|
||||
public function withContext(AdditionalContext $context): self
|
||||
{
|
||||
return new self(
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
message: $this->message,
|
||||
ruleId: $this->ruleId,
|
||||
confidence: $this->confidence,
|
||||
payload: $this->payload,
|
||||
location: $this->location,
|
||||
timestamp: $this->timestamp,
|
||||
context: $context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timestamp
|
||||
*/
|
||||
public function withTimestamp(Timestamp $timestamp): self
|
||||
{
|
||||
return new self(
|
||||
category: $this->category,
|
||||
severity: $this->severity,
|
||||
message: $this->message,
|
||||
ruleId: $this->ruleId,
|
||||
confidence: $this->confidence,
|
||||
payload: $this->payload,
|
||||
location: $this->location,
|
||||
timestamp: $timestamp,
|
||||
context: $this->context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'category' => $this->category->value,
|
||||
'severity' => $this->severity->value,
|
||||
'message' => $this->message,
|
||||
'rule_id' => $this->ruleId,
|
||||
'confidence' => $this->confidence?->getValue(),
|
||||
'threat_score' => $this->getThreatScore()->getValue(),
|
||||
'location' => $this->location,
|
||||
'timestamp' => $this->timestamp?->toIsoString(),
|
||||
'owasp_rank' => $this->getOwaspRank(),
|
||||
'should_block' => $this->shouldBlock(),
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
'payload' => $this->payload?->toArray(),
|
||||
'context' => $this->context?->toArray(),
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted message for display
|
||||
*/
|
||||
public function getFormattedMessage(): string
|
||||
{
|
||||
$prefix = $this->severity->value . ':';
|
||||
$suffix = $this->ruleId ? " [Rule: {$this->ruleId->value}]" : '';
|
||||
|
||||
return "{$prefix} {$this->message}{$suffix}";
|
||||
}
|
||||
}
|
||||
167
src/Framework/Waf/ValueObjects/DetectionCollection.php
Normal file
167
src/Framework/Waf/ValueObjects/DetectionCollection.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Waf\Detection;
|
||||
|
||||
/**
|
||||
* Collection of security detections from WAF layers
|
||||
*/
|
||||
final readonly class DetectionCollection implements \IteratorAggregate, \Countable
|
||||
{
|
||||
/** @param Detection[] $detections */
|
||||
public function __construct(
|
||||
public array $detections
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty detection collection
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collection from single detection
|
||||
*/
|
||||
public static function single(Detection $detection): self
|
||||
{
|
||||
return new self([$detection]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collection from array of detections
|
||||
*/
|
||||
public static function fromArray(array $detections): self
|
||||
{
|
||||
// Validate all items are Detection instances
|
||||
foreach ($detections as $detection) {
|
||||
if (! $detection instanceof Detection) {
|
||||
throw new \InvalidArgumentException('All items must be Detection instances');
|
||||
}
|
||||
}
|
||||
|
||||
return new self($detections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of detections
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->detections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collection is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->detections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collection has any detections
|
||||
*/
|
||||
public function hasDetections(): bool
|
||||
{
|
||||
return ! $this->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add detection to collection
|
||||
*/
|
||||
public function add(Detection $detection): self
|
||||
{
|
||||
return new self([...$this->detections, $detection]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with another collection
|
||||
*/
|
||||
public function merge(self $other): self
|
||||
{
|
||||
return new self([...$this->detections, ...$other->detections]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter detections by severity
|
||||
*/
|
||||
public function filterBySeverity(DetectionSeverity $severity): self
|
||||
{
|
||||
$filtered = array_filter(
|
||||
$this->detections,
|
||||
fn (Detection $detection) => $detection->severity === $severity
|
||||
);
|
||||
|
||||
return new self(array_values($filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter detections by category
|
||||
*/
|
||||
public function filterByCategory(DetectionCategory $category): self
|
||||
{
|
||||
$filtered = array_filter(
|
||||
$this->detections,
|
||||
fn (Detection $detection) => $detection->category === $category
|
||||
);
|
||||
|
||||
return new self(array_values($filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get highest severity detection
|
||||
*/
|
||||
public function getHighestSeverity(): ?Detection
|
||||
{
|
||||
if ($this->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$highest = $this->detections[0];
|
||||
foreach ($this->detections as $detection) {
|
||||
if ($detection->severity->isHigherThan($highest->severity)) {
|
||||
$highest = $detection;
|
||||
}
|
||||
}
|
||||
|
||||
return $highest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique categories in collection
|
||||
*/
|
||||
public function getCategories(): array
|
||||
{
|
||||
$categories = array_map(
|
||||
fn (Detection $detection) => $detection->category,
|
||||
$this->detections
|
||||
);
|
||||
|
||||
return array_unique($categories, SORT_REGULAR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (Detection $detection) => $detection->toArray(),
|
||||
$this->detections
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get iterator for foreach loops
|
||||
*/
|
||||
public function getIterator(): \ArrayIterator
|
||||
{
|
||||
return new \ArrayIterator($this->detections);
|
||||
}
|
||||
}
|
||||
182
src/Framework/Waf/ValueObjects/GeographicContext.php
Normal file
182
src/Framework/Waf/ValueObjects/GeographicContext.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Coordinates;
|
||||
use App\Framework\Core\ValueObjects\CountryCode;
|
||||
use App\Framework\Core\ValueObjects\Timezone;
|
||||
|
||||
/**
|
||||
* Geographic context information using value objects
|
||||
*/
|
||||
final readonly class GeographicContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $country = null,
|
||||
public ?CountryCode $countryCode = null,
|
||||
public ?string $region = null,
|
||||
public ?string $city = null,
|
||||
public ?Coordinates $coordinates = null,
|
||||
public ?Timezone $timezone = null,
|
||||
public ?string $isp = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from basic location data
|
||||
*/
|
||||
public static function fromLocation(
|
||||
string $country,
|
||||
string $countryCode,
|
||||
?string $city = null,
|
||||
?float $latitude = null,
|
||||
?float $longitude = null
|
||||
): self {
|
||||
$coordinates = ($latitude !== null && $longitude !== null)
|
||||
? Coordinates::fromLatLng($latitude, $longitude)
|
||||
: null;
|
||||
|
||||
return new self(
|
||||
country: $country,
|
||||
countryCode: CountryCode::fromString($countryCode),
|
||||
city: $city,
|
||||
coordinates: $coordinates
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from GeoIP data
|
||||
*/
|
||||
public static function fromGeoIp(array $geoData): self
|
||||
{
|
||||
$coordinates = (isset($geoData['latitude']) && isset($geoData['longitude']))
|
||||
? Coordinates::fromLatLng($geoData['latitude'], $geoData['longitude'])
|
||||
: null;
|
||||
|
||||
$timezone = isset($geoData['timezone'])
|
||||
? Timezone::fromString($geoData['timezone'])
|
||||
: null;
|
||||
|
||||
$countryCode = isset($geoData['country_code'])
|
||||
? CountryCode::fromString($geoData['country_code'])
|
||||
: null;
|
||||
|
||||
return new self(
|
||||
country: $geoData['country'] ?? null,
|
||||
countryCode: $countryCode,
|
||||
region: $geoData['region'] ?? null,
|
||||
city: $geoData['city'] ?? null,
|
||||
coordinates: $coordinates,
|
||||
timezone: $timezone,
|
||||
isp: $geoData['isp'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if location is in Europe
|
||||
*/
|
||||
public function isInEurope(): bool
|
||||
{
|
||||
return $this->countryCode?->isEuropeanUnion() ?? false ||
|
||||
$this->coordinates?->getHemisphere()['east_west'] === 'Eastern' &&
|
||||
$this->coordinates?->latitude > 35.0 && $this->coordinates?->latitude < 70.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if location is high-risk for security
|
||||
*/
|
||||
public function isHighRiskLocation(): bool
|
||||
{
|
||||
return $this->countryCode?->isHighRiskCountry() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distance to another location in kilometers
|
||||
*/
|
||||
public function distanceTo(self $other): ?float
|
||||
{
|
||||
if ($this->coordinates === null || $other->coordinates === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->coordinates->distanceTo($other->coordinates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if location is within radius of another location
|
||||
*/
|
||||
public function isWithinRadius(self $center, float $radiusKm): bool
|
||||
{
|
||||
$distance = $this->distanceTo($center);
|
||||
|
||||
return $distance !== null && $distance <= $radiusKm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get continent name
|
||||
*/
|
||||
public function getContinent(): ?string
|
||||
{
|
||||
return $this->countryCode?->getContinent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timezone offset
|
||||
*/
|
||||
public function getTimezoneOffset(): ?string
|
||||
{
|
||||
return $this->timezone?->getOffsetString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if coordinates are available
|
||||
*/
|
||||
public function hasCoordinates(): bool
|
||||
{
|
||||
return $this->coordinates !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if timezone information is available
|
||||
*/
|
||||
public function hasTimezone(): bool
|
||||
{
|
||||
return $this->timezone !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location summary
|
||||
*/
|
||||
public function getLocationSummary(): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$this->city,
|
||||
$this->region,
|
||||
$this->country ?? $this->countryCode?->getCountryName(),
|
||||
]);
|
||||
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'country' => $this->country,
|
||||
'country_code' => $this->countryCode?->value,
|
||||
'region' => $this->region,
|
||||
'city' => $this->city,
|
||||
'coordinates' => $this->coordinates?->toArray(),
|
||||
'latitude' => $this->coordinates?->latitude,
|
||||
'longitude' => $this->coordinates?->longitude,
|
||||
'timezone' => $this->timezone?->value,
|
||||
'timezone_offset' => $this->timezone?->getOffsetString(),
|
||||
'continent' => $this->getContinent(),
|
||||
'isp' => $this->isp,
|
||||
'is_high_risk' => $this->isHighRiskLocation(),
|
||||
'is_in_europe' => $this->isInEurope(),
|
||||
'location_summary' => $this->getLocationSummary(),
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
299
src/Framework/Waf/ValueObjects/LayerConfig.php
Normal file
299
src/Framework/Waf/ValueObjects/LayerConfig.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Configuration for WAF layers
|
||||
* Immutable configuration object with validation
|
||||
*/
|
||||
final readonly class LayerConfig
|
||||
{
|
||||
public function __construct(
|
||||
public bool $enabled = true,
|
||||
public ?Duration $timeout = null,
|
||||
public ?Percentage $confidenceThreshold = null,
|
||||
public bool $blockingMode = true,
|
||||
public bool $logDetections = true,
|
||||
public int $maxDetectionsPerRequest = 100,
|
||||
public array $customSettings = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default configuration
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: true,
|
||||
timeout: Duration::fromSeconds(5),
|
||||
confidenceThreshold: Percentage::from(70.0),
|
||||
blockingMode: true,
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 100
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create production configuration (stricter)
|
||||
*/
|
||||
public static function production(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: true,
|
||||
timeout: Duration::fromSeconds(3),
|
||||
confidenceThreshold: Percentage::from(85.0),
|
||||
blockingMode: true,
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 50
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create development configuration (more permissive)
|
||||
*/
|
||||
public static function development(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: true,
|
||||
timeout: Duration::fromSeconds(10),
|
||||
confidenceThreshold: Percentage::from(50.0),
|
||||
blockingMode: false, // Log only in development
|
||||
logDetections: true,
|
||||
maxDetectionsPerRequest: 200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create testing configuration
|
||||
*/
|
||||
public static function testing(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: true,
|
||||
timeout: Duration::fromSeconds(1),
|
||||
confidenceThreshold: Percentage::from(1.0), // Very low threshold for testing
|
||||
blockingMode: false,
|
||||
logDetections: false, // Reduce noise in tests
|
||||
maxDetectionsPerRequest: 1000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable layer
|
||||
*/
|
||||
public function enable(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: true,
|
||||
timeout: $this->timeout,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
blockingMode: $this->blockingMode,
|
||||
logDetections: $this->logDetections,
|
||||
maxDetectionsPerRequest: $this->maxDetectionsPerRequest,
|
||||
customSettings: $this->customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable layer
|
||||
*/
|
||||
public function disable(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: false,
|
||||
timeout: $this->timeout,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
blockingMode: $this->blockingMode,
|
||||
logDetections: $this->logDetections,
|
||||
maxDetectionsPerRequest: $this->maxDetectionsPerRequest,
|
||||
customSettings: $this->customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set timeout
|
||||
*/
|
||||
public function withTimeout(Duration $timeout): self
|
||||
{
|
||||
return new self(
|
||||
enabled: $this->enabled,
|
||||
timeout: $timeout,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
blockingMode: $this->blockingMode,
|
||||
logDetections: $this->logDetections,
|
||||
maxDetectionsPerRequest: $this->maxDetectionsPerRequest,
|
||||
customSettings: $this->customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set confidence threshold
|
||||
*/
|
||||
public function withConfidenceThreshold(Percentage $threshold): self
|
||||
{
|
||||
return new self(
|
||||
enabled: $this->enabled,
|
||||
timeout: $this->timeout,
|
||||
confidenceThreshold: $threshold,
|
||||
blockingMode: $this->blockingMode,
|
||||
logDetections: $this->logDetections,
|
||||
maxDetectionsPerRequest: $this->maxDetectionsPerRequest,
|
||||
customSettings: $this->customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable blocking mode
|
||||
*/
|
||||
public function withBlockingMode(bool $blockingMode): self
|
||||
{
|
||||
return new self(
|
||||
enabled: $this->enabled,
|
||||
timeout: $this->timeout,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
blockingMode: $blockingMode,
|
||||
logDetections: $this->logDetections,
|
||||
maxDetectionsPerRequest: $this->maxDetectionsPerRequest,
|
||||
customSettings: $this->customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable detection logging
|
||||
*/
|
||||
public function withLogging(bool $logDetections): self
|
||||
{
|
||||
return new self(
|
||||
enabled: $this->enabled,
|
||||
timeout: $this->timeout,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
blockingMode: $this->blockingMode,
|
||||
logDetections: $logDetections,
|
||||
maxDetectionsPerRequest: $this->maxDetectionsPerRequest,
|
||||
customSettings: $this->customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set maximum detections per request
|
||||
*/
|
||||
public function withMaxDetections(int $maxDetections): self
|
||||
{
|
||||
return new self(
|
||||
enabled: $this->enabled,
|
||||
timeout: $this->timeout,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
blockingMode: $this->blockingMode,
|
||||
logDetections: $this->logDetections,
|
||||
maxDetectionsPerRequest: $maxDetections,
|
||||
customSettings: $this->customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom setting
|
||||
*/
|
||||
public function withCustomSetting(string $key, mixed $value): self
|
||||
{
|
||||
$customSettings = $this->customSettings;
|
||||
$customSettings[$key] = $value;
|
||||
|
||||
return new self(
|
||||
enabled: $this->enabled,
|
||||
timeout: $this->timeout,
|
||||
confidenceThreshold: $this->confidenceThreshold,
|
||||
blockingMode: $this->blockingMode,
|
||||
logDetections: $this->logDetections,
|
||||
maxDetectionsPerRequest: $this->maxDetectionsPerRequest,
|
||||
customSettings: $customSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom setting value
|
||||
*/
|
||||
public function getCustomSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->customSettings[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if custom setting exists
|
||||
*/
|
||||
public function hasCustomSetting(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->customSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective timeout with fallback
|
||||
*/
|
||||
public function getEffectiveTimeout(): Duration
|
||||
{
|
||||
return $this->timeout ?? Duration::fromSeconds(5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective confidence threshold with fallback
|
||||
*/
|
||||
public function getEffectiveConfidenceThreshold(): Percentage
|
||||
{
|
||||
return $this->confidenceThreshold ?? Percentage::from(70.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration
|
||||
*/
|
||||
public function validate(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if ($this->timeout !== null && $this->timeout->toSeconds() <= 0) {
|
||||
$errors[] = 'Timeout must be greater than 0 seconds';
|
||||
}
|
||||
|
||||
if ($this->timeout !== null && $this->timeout->toSeconds() > 60) {
|
||||
$errors[] = 'Timeout should not exceed 60 seconds';
|
||||
}
|
||||
|
||||
if ($this->maxDetectionsPerRequest <= 0) {
|
||||
$errors[] = 'Max detections per request must be greater than 0';
|
||||
}
|
||||
|
||||
if ($this->maxDetectionsPerRequest > 10000) {
|
||||
$errors[] = 'Max detections per request should not exceed 10000';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if configuration is valid
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return empty($this->validate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'timeout_seconds' => $this->timeout?->toSeconds(),
|
||||
'confidence_threshold' => $this->confidenceThreshold?->getValue(),
|
||||
'blocking_mode' => $this->blockingMode,
|
||||
'log_detections' => $this->logDetections,
|
||||
'max_detections_per_request' => $this->maxDetectionsPerRequest,
|
||||
'custom_settings' => $this->customSettings,
|
||||
];
|
||||
}
|
||||
}
|
||||
324
src/Framework/Waf/ValueObjects/LayerMetrics.php
Normal file
324
src/Framework/Waf/ValueObjects/LayerMetrics.php
Normal file
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Clock;
|
||||
|
||||
/**
|
||||
* Performance and operational metrics for WAF layers
|
||||
*/
|
||||
final readonly class LayerMetrics
|
||||
{
|
||||
public function __construct(
|
||||
public int $totalRequests = 0,
|
||||
public int $threatsDetected = 0,
|
||||
public int $falsePositives = 0,
|
||||
public int $errors = 0,
|
||||
public int $timeouts = 0,
|
||||
public ?Duration $averageProcessingTime = null,
|
||||
public ?Duration $maxProcessingTime = null,
|
||||
public ?Duration $minProcessingTime = null,
|
||||
public ?Timestamp $lastRequest = null,
|
||||
public ?Timestamp $lastThreat = null,
|
||||
public ?Timestamp $lastError = null,
|
||||
public array $categoryCounts = [],
|
||||
public array $severityCounts = [],
|
||||
public ?Clock $clock = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty metrics
|
||||
*/
|
||||
public static function empty(?Clock $clock = null): self
|
||||
{
|
||||
return new self(
|
||||
totalRequests: 0,
|
||||
threatsDetected: 0,
|
||||
falsePositives: 0,
|
||||
errors: 0,
|
||||
timeouts: 0,
|
||||
averageProcessingTime: Duration::fromSeconds(0),
|
||||
maxProcessingTime: Duration::fromSeconds(0),
|
||||
minProcessingTime: Duration::fromSeconds(0),
|
||||
clock: $clock
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat detection rate
|
||||
*/
|
||||
public function getThreatRate(): Percentage
|
||||
{
|
||||
if ($this->totalRequests === 0) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromRatio($this->threatsDetected, $this->totalRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get false positive rate
|
||||
*/
|
||||
public function getFalsePositiveRate(): Percentage
|
||||
{
|
||||
if ($this->threatsDetected === 0) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromRatio($this->falsePositives, $this->threatsDetected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error rate
|
||||
*/
|
||||
public function getErrorRate(): Percentage
|
||||
{
|
||||
if ($this->totalRequests === 0) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromRatio($this->errors, $this->totalRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timeout rate
|
||||
*/
|
||||
public function getTimeoutRate(): Percentage
|
||||
{
|
||||
if ($this->totalRequests === 0) {
|
||||
return Percentage::from(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromRatio($this->timeouts, $this->totalRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accuracy (1 - false positive rate)
|
||||
*/
|
||||
public function getAccuracy(): Percentage
|
||||
{
|
||||
$falsePositiveRate = $this->getFalsePositiveRate();
|
||||
|
||||
return Percentage::from(100.0 - $falsePositiveRate->getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer health score (0-100)
|
||||
*/
|
||||
public function getHealthScore(): Percentage
|
||||
{
|
||||
$errorRate = $this->getErrorRate()->getValue();
|
||||
$timeoutRate = $this->getTimeoutRate()->getValue();
|
||||
|
||||
// Penalize errors and timeouts heavily
|
||||
$healthPenalty = ($errorRate * 2) + ($timeoutRate * 3);
|
||||
$healthScore = max(0.0, 100.0 - $healthPenalty);
|
||||
|
||||
return Percentage::from($healthScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if layer is performing well
|
||||
*/
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return $this->getHealthScore()->getValue() >= 80.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if layer has recent activity
|
||||
*/
|
||||
public function hasRecentActivity(Duration $threshold): bool
|
||||
{
|
||||
if ($this->lastRequest === null || $this->clock === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = Timestamp::fromClock($this->clock);
|
||||
$timeSinceLastRequest = $this->lastRequest->diff($now);
|
||||
|
||||
return $timeSinceLastRequest->isLessThan($threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a new request
|
||||
*/
|
||||
public function recordRequest(Duration $processingTime, bool $threatDetected = false): self
|
||||
{
|
||||
$newAverage = $this->calculateNewAverage($processingTime);
|
||||
$newMax = ($this->maxProcessingTime === null || $this->maxProcessingTime->isLessThan($processingTime))
|
||||
? $processingTime
|
||||
: $this->maxProcessingTime;
|
||||
$newMin = ($this->totalRequests === 0 || $this->minProcessingTime === null || $this->minProcessingTime->isGreaterThan($processingTime))
|
||||
? $processingTime
|
||||
: $this->minProcessingTime;
|
||||
|
||||
$now = $this->clock ? Timestamp::fromClock($this->clock) : null;
|
||||
|
||||
return new self(
|
||||
totalRequests: $this->totalRequests + 1,
|
||||
threatsDetected: $this->threatsDetected + ($threatDetected ? 1 : 0),
|
||||
falsePositives: $this->falsePositives,
|
||||
errors: $this->errors,
|
||||
timeouts: $this->timeouts,
|
||||
averageProcessingTime: $newAverage,
|
||||
maxProcessingTime: $newMax,
|
||||
minProcessingTime: $newMin,
|
||||
lastRequest: $now,
|
||||
lastThreat: $threatDetected ? $now : $this->lastThreat,
|
||||
lastError: $this->lastError,
|
||||
categoryCounts: $this->categoryCounts,
|
||||
severityCounts: $this->severityCounts,
|
||||
clock: $this->clock
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error
|
||||
*/
|
||||
public function recordError(): self
|
||||
{
|
||||
$now = $this->clock ? Timestamp::fromClock($this->clock) : null;
|
||||
|
||||
return new self(
|
||||
totalRequests: $this->totalRequests,
|
||||
threatsDetected: $this->threatsDetected,
|
||||
falsePositives: $this->falsePositives,
|
||||
errors: $this->errors + 1,
|
||||
timeouts: $this->timeouts,
|
||||
averageProcessingTime: $this->averageProcessingTime,
|
||||
maxProcessingTime: $this->maxProcessingTime,
|
||||
minProcessingTime: $this->minProcessingTime,
|
||||
lastRequest: $this->lastRequest,
|
||||
lastThreat: $this->lastThreat,
|
||||
lastError: $now,
|
||||
categoryCounts: $this->categoryCounts,
|
||||
severityCounts: $this->severityCounts,
|
||||
clock: $this->clock
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a timeout
|
||||
*/
|
||||
public function recordTimeout(): self
|
||||
{
|
||||
return new self(
|
||||
totalRequests: $this->totalRequests,
|
||||
threatsDetected: $this->threatsDetected,
|
||||
falsePositives: $this->falsePositives,
|
||||
errors: $this->errors,
|
||||
timeouts: $this->timeouts + 1,
|
||||
averageProcessingTime: $this->averageProcessingTime,
|
||||
maxProcessingTime: $this->maxProcessingTime,
|
||||
minProcessingTime: $this->minProcessingTime,
|
||||
lastRequest: $this->lastRequest,
|
||||
lastThreat: $this->lastThreat,
|
||||
lastError: $this->lastError,
|
||||
categoryCounts: $this->categoryCounts,
|
||||
severityCounts: $this->severityCounts
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a false positive
|
||||
*/
|
||||
public function recordFalsePositive(): self
|
||||
{
|
||||
return new self(
|
||||
totalRequests: $this->totalRequests,
|
||||
threatsDetected: $this->threatsDetected,
|
||||
falsePositives: $this->falsePositives + 1,
|
||||
errors: $this->errors,
|
||||
timeouts: $this->timeouts,
|
||||
averageProcessingTime: $this->averageProcessingTime,
|
||||
maxProcessingTime: $this->maxProcessingTime,
|
||||
minProcessingTime: $this->minProcessingTime,
|
||||
lastRequest: $this->lastRequest,
|
||||
lastThreat: $this->lastThreat,
|
||||
lastError: $this->lastError,
|
||||
categoryCounts: $this->categoryCounts,
|
||||
severityCounts: $this->severityCounts
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update category counts
|
||||
*/
|
||||
public function updateCategoryCounts(array $categoryCounts): self
|
||||
{
|
||||
return new self(
|
||||
totalRequests: $this->totalRequests,
|
||||
threatsDetected: $this->threatsDetected,
|
||||
falsePositives: $this->falsePositives,
|
||||
errors: $this->errors,
|
||||
timeouts: $this->timeouts,
|
||||
averageProcessingTime: $this->averageProcessingTime,
|
||||
maxProcessingTime: $this->maxProcessingTime,
|
||||
minProcessingTime: $this->minProcessingTime,
|
||||
lastRequest: $this->lastRequest,
|
||||
lastThreat: $this->lastThreat,
|
||||
lastError: $this->lastError,
|
||||
categoryCounts: $categoryCounts,
|
||||
severityCounts: $this->severityCounts
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metrics
|
||||
*/
|
||||
public function reset(): self
|
||||
{
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate new average processing time
|
||||
*/
|
||||
private function calculateNewAverage(Duration $newTime): Duration
|
||||
{
|
||||
if ($this->totalRequests === 0 || $this->averageProcessingTime === null) {
|
||||
return $newTime;
|
||||
}
|
||||
|
||||
$currentTotal = $this->averageProcessingTime->multiply($this->totalRequests);
|
||||
$newTotal = $currentTotal->add($newTime);
|
||||
|
||||
return $newTotal->divide($this->totalRequests + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_requests' => $this->totalRequests,
|
||||
'threats_detected' => $this->threatsDetected,
|
||||
'false_positives' => $this->falsePositives,
|
||||
'errors' => $this->errors,
|
||||
'timeouts' => $this->timeouts,
|
||||
'threat_rate' => $this->getThreatRate()->getValue(),
|
||||
'false_positive_rate' => $this->getFalsePositiveRate()->getValue(),
|
||||
'error_rate' => $this->getErrorRate()->getValue(),
|
||||
'timeout_rate' => $this->getTimeoutRate()->getValue(),
|
||||
'accuracy' => $this->getAccuracy()->getValue(),
|
||||
'health_score' => $this->getHealthScore()->getValue(),
|
||||
'is_healthy' => $this->isHealthy(),
|
||||
'average_processing_time_ms' => $this->averageProcessingTime?->toMilliseconds() ?? 0,
|
||||
'max_processing_time_ms' => $this->maxProcessingTime?->toMilliseconds() ?? 0,
|
||||
'min_processing_time_ms' => $this->minProcessingTime?->toMilliseconds() ?? 0,
|
||||
'last_request' => $this->lastRequest?->toIsoString(),
|
||||
'last_threat' => $this->lastThreat?->toIsoString(),
|
||||
'last_error' => $this->lastError?->toIsoString(),
|
||||
'category_counts' => $this->categoryCounts,
|
||||
'severity_counts' => $this->severityCounts,
|
||||
];
|
||||
}
|
||||
}
|
||||
246
src/Framework/Waf/ValueObjects/PayloadSample.php
Normal file
246
src/Framework/Waf/ValueObjects/PayloadSample.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
|
||||
/**
|
||||
* Safe representation of malicious payload samples for logging and analysis
|
||||
* Automatically sanitizes and truncates payloads to prevent log injection
|
||||
*/
|
||||
final readonly class PayloadSample
|
||||
{
|
||||
private const int DEFAULT_MAX_LENGTH = 200;
|
||||
private const int ABSOLUTE_MAX_LENGTH = 1000;
|
||||
|
||||
public function __construct(
|
||||
public string $original,
|
||||
public string $sanitized,
|
||||
public Byte $originalSize,
|
||||
public bool $wasTruncated,
|
||||
public ?string $encoding = null,
|
||||
public ?string $contentType = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create payload sample from raw input
|
||||
*/
|
||||
public static function fromInput(
|
||||
string $input,
|
||||
int $maxLength = self::DEFAULT_MAX_LENGTH,
|
||||
?string $contentType = null
|
||||
): self {
|
||||
$originalSize = Byte::fromBytes(strlen($input));
|
||||
$wasTruncated = strlen($input) > $maxLength;
|
||||
|
||||
// Truncate if necessary
|
||||
$truncated = $wasTruncated ? substr($input, 0, $maxLength) : $input;
|
||||
|
||||
// Sanitize for safe logging
|
||||
$sanitized = self::sanitizeForLogging($truncated);
|
||||
|
||||
// Detect encoding
|
||||
$encoding = mb_detect_encoding($input, ['UTF-8', 'ASCII', 'ISO-8859-1'], true) ?: 'unknown';
|
||||
|
||||
return new self(
|
||||
original: $truncated, // Already truncated for safety
|
||||
sanitized: $sanitized,
|
||||
originalSize: $originalSize,
|
||||
wasTruncated: $wasTruncated,
|
||||
encoding: $encoding,
|
||||
contentType: $contentType
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sample with custom max length
|
||||
*/
|
||||
public static function withMaxLength(
|
||||
string $input,
|
||||
int $maxLength,
|
||||
?string $contentType = null
|
||||
): self {
|
||||
// Enforce absolute maximum
|
||||
$maxLength = min($maxLength, self::ABSOLUTE_MAX_LENGTH);
|
||||
|
||||
return self::fromInput($input, $maxLength, $contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create minimal sample for high-risk payloads
|
||||
*/
|
||||
public static function minimal(string $input, ?string $contentType = null): self
|
||||
{
|
||||
return self::fromInput($input, 50, $contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create extended sample for analysis
|
||||
*/
|
||||
public static function extended(string $input, ?string $contentType = null): self
|
||||
{
|
||||
return self::fromInput($input, 500, $contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize payload for safe logging (prevent log injection)
|
||||
*/
|
||||
private static function sanitizeForLogging(string $input): string
|
||||
{
|
||||
// Remove null bytes
|
||||
$sanitized = str_replace("\0", '', $input);
|
||||
|
||||
// Replace control characters with placeholders
|
||||
$sanitized = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '?', $sanitized);
|
||||
|
||||
// Escape potential log injection patterns
|
||||
$sanitized = str_replace(["\r\n", "\r", "\n"], ['\\r\\n', '\\r', '\\n'], $sanitized);
|
||||
|
||||
// Replace multiple consecutive whitespace with single space
|
||||
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
|
||||
|
||||
// Trim whitespace
|
||||
$sanitized = trim($sanitized);
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hex representation for binary data
|
||||
*/
|
||||
public function getHexDump(): string
|
||||
{
|
||||
// Only show first 100 bytes as hex
|
||||
$bytes = substr($this->original, 0, 100);
|
||||
|
||||
return bin2hex($bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if payload appears to be binary data
|
||||
*/
|
||||
public function isBinary(): bool
|
||||
{
|
||||
// Check for null bytes or high ratio of non-printable characters
|
||||
if (str_contains($this->original, "\0")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$printableCount = 0;
|
||||
$totalLength = strlen($this->original);
|
||||
|
||||
for ($i = 0; $i < $totalLength; $i++) {
|
||||
$char = ord($this->original[$i]);
|
||||
if ($char >= 32 && $char <= 126) {
|
||||
$printableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If less than 70% printable characters, consider it binary
|
||||
return $totalLength > 0 && ($printableCount / $totalLength) < 0.7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample representation for logging
|
||||
*/
|
||||
public function getSample(): string
|
||||
{
|
||||
return $this->getSafeDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get safe representation for display
|
||||
*/
|
||||
public function getSafeDisplay(): string
|
||||
{
|
||||
if ($this->isBinary()) {
|
||||
return '[Binary data - ' . $this->originalSize->toHumanReadable() . ']';
|
||||
}
|
||||
|
||||
$display = $this->sanitized;
|
||||
|
||||
if ($this->wasTruncated) {
|
||||
$display .= ' [...truncated]';
|
||||
}
|
||||
|
||||
return $display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analysis-friendly representation
|
||||
*/
|
||||
public function getAnalysisView(): string
|
||||
{
|
||||
if ($this->isBinary()) {
|
||||
return $this->getHexDump();
|
||||
}
|
||||
|
||||
return $this->original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sample was truncated
|
||||
*/
|
||||
public function wasTruncated(): bool
|
||||
{
|
||||
return $this->wasTruncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get original size in bytes
|
||||
*/
|
||||
public function getOriginalSize(): Byte
|
||||
{
|
||||
return $this->originalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type if known
|
||||
*/
|
||||
public function getContentType(): ?string
|
||||
{
|
||||
return $this->contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detected encoding
|
||||
*/
|
||||
public function getEncoding(): ?string
|
||||
{
|
||||
return $this->encoding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create with content type
|
||||
*/
|
||||
public function withContentType(string $contentType): self
|
||||
{
|
||||
return new self(
|
||||
original: $this->original,
|
||||
sanitized: $this->sanitized,
|
||||
originalSize: $this->originalSize,
|
||||
wasTruncated: $this->wasTruncated,
|
||||
encoding: $this->encoding,
|
||||
contentType: $contentType
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'sanitized' => $this->sanitized,
|
||||
'original_size_bytes' => $this->originalSize->toBytes(),
|
||||
'was_truncated' => $this->wasTruncated,
|
||||
'encoding' => $this->encoding,
|
||||
'content_type' => $this->contentType,
|
||||
'is_binary' => $this->isBinary(),
|
||||
'safe_display' => $this->getSafeDisplay(),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
src/Framework/Waf/ValueObjects/RequestContext.php
Normal file
33
src/Framework/Waf/ValueObjects/RequestContext.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
/**
|
||||
* Request-specific context information
|
||||
*/
|
||||
final readonly class RequestContext
|
||||
{
|
||||
public function __construct(
|
||||
public string $method,
|
||||
public string $path,
|
||||
public ?string $queryString = null,
|
||||
public ?string $referer = null,
|
||||
public ?string $host = null,
|
||||
public array $headers = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'method' => $this->method,
|
||||
'path' => $this->path,
|
||||
'query_string' => $this->queryString,
|
||||
'referer' => $this->referer,
|
||||
'host' => $this->host,
|
||||
'headers_count' => count($this->headers),
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
179
src/Framework/Waf/ValueObjects/ResultMetadata.php
Normal file
179
src/Framework/Waf/ValueObjects/ResultMetadata.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Http\IpAddress;
|
||||
use App\Framework\UserAgent\UserAgent;
|
||||
|
||||
/**
|
||||
* Metadata associated with WAF layer results
|
||||
*/
|
||||
final readonly class ResultMetadata
|
||||
{
|
||||
public function __construct(
|
||||
public ?Percentage $confidence = null,
|
||||
public ?RuleId $ruleId = null,
|
||||
public ?string $attackVector = null,
|
||||
public ?IpAddress $sourceIp = null,
|
||||
public ?UserAgent $userAgent = null,
|
||||
public ?PayloadSample $payloadSample = null,
|
||||
public ?AdditionalContext $context = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty metadata
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata with confidence level
|
||||
*/
|
||||
public static function withConfidence(Percentage $confidence): self
|
||||
{
|
||||
return new self(confidence: $confidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata with rule information
|
||||
*/
|
||||
public static function withRule(RuleId $ruleId, ?Percentage $confidence = null): self
|
||||
{
|
||||
return new self(
|
||||
confidence: $confidence,
|
||||
ruleId: $ruleId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata with attack vector information
|
||||
*/
|
||||
public static function withAttackVector(
|
||||
string $attackVector,
|
||||
?RuleId $ruleId = null,
|
||||
?Percentage $confidence = null
|
||||
): self {
|
||||
return new self(
|
||||
confidence: $confidence,
|
||||
ruleId: $ruleId,
|
||||
attackVector: $attackVector
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata with request context
|
||||
*/
|
||||
public static function withRequestContext(
|
||||
IpAddress $sourceIp,
|
||||
UserAgent $userAgent,
|
||||
?PayloadSample $payloadSample = null
|
||||
): self {
|
||||
return new self(
|
||||
sourceIp: $sourceIp,
|
||||
userAgent: $userAgent,
|
||||
payloadSample: $payloadSample
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata has confidence information
|
||||
*/
|
||||
public function hasConfidence(): bool
|
||||
{
|
||||
return $this->confidence !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata has rule information
|
||||
*/
|
||||
public function hasRule(): bool
|
||||
{
|
||||
return $this->ruleId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata has attack vector information
|
||||
*/
|
||||
public function hasAttackVector(): bool
|
||||
{
|
||||
return $this->attackVector !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata has request context
|
||||
*/
|
||||
public function hasRequestContext(): bool
|
||||
{
|
||||
return $this->sourceIp !== null || $this->userAgent !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with another metadata object
|
||||
*/
|
||||
public function merge(self $other): self
|
||||
{
|
||||
return new self(
|
||||
confidence: $other->confidence ?? $this->confidence,
|
||||
ruleId: $other->ruleId ?? $this->ruleId,
|
||||
attackVector: $other->attackVector ?? $this->attackVector,
|
||||
sourceIp: $other->sourceIp ?? $this->sourceIp,
|
||||
userAgent: $other->userAgent ?? $this->userAgent,
|
||||
payloadSample: $other->payloadSample ?? $this->payloadSample,
|
||||
context: $other->context?->merge($this->context) ?? $this->context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add payload sample
|
||||
*/
|
||||
public function withPayloadSample(PayloadSample $sample): self
|
||||
{
|
||||
return new self(
|
||||
confidence: $this->confidence,
|
||||
ruleId: $this->ruleId,
|
||||
attackVector: $this->attackVector,
|
||||
sourceIp: $this->sourceIp,
|
||||
userAgent: $this->userAgent,
|
||||
payloadSample: $sample,
|
||||
context: $this->context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add additional context
|
||||
*/
|
||||
public function withContext(AdditionalContext $context): self
|
||||
{
|
||||
return new self(
|
||||
confidence: $this->confidence,
|
||||
ruleId: $this->ruleId,
|
||||
attackVector: $this->attackVector,
|
||||
sourceIp: $this->sourceIp,
|
||||
userAgent: $this->userAgent,
|
||||
payloadSample: $this->payloadSample,
|
||||
context: $context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'confidence' => $this->confidence?->getValue(),
|
||||
'rule_id' => $this->ruleId?->value,
|
||||
'attack_vector' => $this->attackVector,
|
||||
'source_ip' => $this->sourceIp?->value,
|
||||
'user_agent' => $this->userAgent?->value,
|
||||
'payload_sample' => $this->payloadSample?->toArray(),
|
||||
'context' => $this->context?->toArray(),
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
256
src/Framework/Waf/ValueObjects/RuleId.php
Normal file
256
src/Framework/Waf/ValueObjects/RuleId.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
/**
|
||||
* WAF Rule Identifier Value Object
|
||||
* Provides structured, validated rule IDs for WAF layers
|
||||
*/
|
||||
final readonly class RuleId
|
||||
{
|
||||
private const string PATTERN = '/^[A-Z]{2,4}-\d{4,6}(-[A-Z0-9]{1,8})?$/';
|
||||
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
if (! self::isValid($value)) {
|
||||
throw new \InvalidArgumentException("Invalid rule ID format: {$value}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from string with validation
|
||||
*/
|
||||
public static function from(string $ruleId): self
|
||||
{
|
||||
return new self($ruleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OWASP ModSecurity rule ID
|
||||
*/
|
||||
public static function modsecurity(int $ruleNumber): self
|
||||
{
|
||||
return new self("MS-{$ruleNumber}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom framework rule ID
|
||||
*/
|
||||
public static function custom(string $category, int $number, ?string $variant = null): self
|
||||
{
|
||||
$ruleId = strtoupper($category) . "-{$number}";
|
||||
if ($variant !== null) {
|
||||
$ruleId .= "-" . strtoupper($variant);
|
||||
}
|
||||
|
||||
return new self($ruleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SQL injection rule ID
|
||||
*/
|
||||
public static function sqlInjection(int $number): self
|
||||
{
|
||||
return new self("SQL-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create XSS rule ID
|
||||
*/
|
||||
public static function xss(int $number): self
|
||||
{
|
||||
return new self("XSS-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CSRF rule ID
|
||||
*/
|
||||
public static function csrf(int $number): self
|
||||
{
|
||||
return new self("CSRF-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bot detection rule ID
|
||||
*/
|
||||
public static function botDetection(int $number): self
|
||||
{
|
||||
return new self("BOT-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate limiting rule ID
|
||||
*/
|
||||
public static function rateLimit(int $number): self
|
||||
{
|
||||
return new self("RATE-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DDoS protection rule ID
|
||||
*/
|
||||
public static function ddos(int $number): self
|
||||
{
|
||||
return new self("DDOS-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create IP reputation rule ID
|
||||
*/
|
||||
public static function ipReputation(int $number): self
|
||||
{
|
||||
return new self("IP-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create behavioral analysis rule ID
|
||||
*/
|
||||
public static function behavioral(int $number): self
|
||||
{
|
||||
return new self("BEH-{$number}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate rule ID format
|
||||
*/
|
||||
public static function isValid(string $ruleId): bool
|
||||
{
|
||||
return preg_match(self::PATTERN, $ruleId) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule category (prefix)
|
||||
*/
|
||||
public function getCategory(): string
|
||||
{
|
||||
$parts = explode('-', $this->value);
|
||||
|
||||
return $parts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule number
|
||||
*/
|
||||
public function getNumber(): int
|
||||
{
|
||||
$parts = explode('-', $this->value);
|
||||
|
||||
return (int) $parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule variant (if any)
|
||||
*/
|
||||
public function getVariant(): ?string
|
||||
{
|
||||
$parts = explode('-', $this->value);
|
||||
|
||||
return $parts[2] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a ModSecurity rule
|
||||
*/
|
||||
public function isModSecurity(): bool
|
||||
{
|
||||
return str_starts_with($this->value, 'MS-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a custom framework rule
|
||||
*/
|
||||
public function isCustom(): bool
|
||||
{
|
||||
return ! $this->isModSecurity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an OWASP category rule
|
||||
*/
|
||||
public function isOwaspCategory(): bool
|
||||
{
|
||||
$category = $this->getCategory();
|
||||
|
||||
return in_array($category, [
|
||||
'SQL', 'XSS', 'CSRF', 'XXE', 'PATH', 'CMD', 'LDAP', 'XPATH',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
$category = $this->getCategory();
|
||||
|
||||
$descriptions = [
|
||||
'MS' => 'ModSecurity Core Rule Set',
|
||||
'SQL' => 'SQL Injection Detection',
|
||||
'XSS' => 'Cross-Site Scripting Detection',
|
||||
'CSRF' => 'Cross-Site Request Forgery Detection',
|
||||
'XXE' => 'XML External Entity Detection',
|
||||
'PATH' => 'Path Traversal Detection',
|
||||
'CMD' => 'Command Injection Detection',
|
||||
'BOT' => 'Bot Detection',
|
||||
'RATE' => 'Rate Limiting',
|
||||
'DDOS' => 'DDoS Protection',
|
||||
'IP' => 'IP Reputation',
|
||||
'BEH' => 'Behavioral Analysis',
|
||||
];
|
||||
|
||||
return $descriptions[$category] ?? "Custom Rule ({$category})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another rule ID
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this rule belongs to the same category
|
||||
*/
|
||||
public function isSameCategory(self $other): bool
|
||||
{
|
||||
return $this->getCategory() === $other->getCategory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create variant of this rule
|
||||
*/
|
||||
public function withVariant(string $variant): self
|
||||
{
|
||||
$baseId = $this->getCategory() . '-' . $this->getNumber();
|
||||
|
||||
return new self($baseId . '-' . strtoupper($variant));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'value' => $this->value,
|
||||
'category' => $this->getCategory(),
|
||||
'number' => $this->getNumber(),
|
||||
'variant' => $this->getVariant(),
|
||||
'description' => $this->getDescription(),
|
||||
'is_modsecurity' => $this->isModSecurity(),
|
||||
'is_owasp_category' => $this->isOwaspCategory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
35
src/Framework/Waf/ValueObjects/SessionContext.php
Normal file
35
src/Framework/Waf/ValueObjects/SessionContext.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Session-specific context information
|
||||
*/
|
||||
final readonly class SessionContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $sessionId = null,
|
||||
public ?string $userId = null,
|
||||
public ?Timestamp $sessionStart = null,
|
||||
public ?string $fingerprint = null,
|
||||
public bool $isAuthenticated = false,
|
||||
public array $roles = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'session_id' => $this->sessionId ? substr($this->sessionId, 0, 8) . '...' : null, // Partial for privacy
|
||||
'user_id' => $this->userId,
|
||||
'session_start' => $this->sessionStart?->toIsoString(),
|
||||
'fingerprint' => $this->fingerprint ? substr($this->fingerprint, 0, 8) . '...' : null,
|
||||
'is_authenticated' => $this->isAuthenticated,
|
||||
'roles' => $this->roles,
|
||||
], fn ($value) => $value !== null && $value !== []);
|
||||
}
|
||||
}
|
||||
33
src/Framework/Waf/ValueObjects/TechnicalContext.php
Normal file
33
src/Framework/Waf/ValueObjects/TechnicalContext.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf\ValueObjects;
|
||||
|
||||
/**
|
||||
* Technical context information
|
||||
*/
|
||||
final readonly class TechnicalContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $protocol = null,
|
||||
public ?string $tlsVersion = null,
|
||||
public ?string $cipherSuite = null,
|
||||
public ?int $requestSize = null,
|
||||
public ?string $acceptLanguage = null,
|
||||
public ?string $acceptEncoding = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'protocol' => $this->protocol,
|
||||
'tls_version' => $this->tlsVersion,
|
||||
'cipher_suite' => $this->cipherSuite,
|
||||
'request_size' => $this->requestSize,
|
||||
'accept_language' => $this->acceptLanguage,
|
||||
'accept_encoding' => $this->acceptEncoding,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
87
src/Framework/Waf/WafAction.php
Normal file
87
src/Framework/Waf/WafAction.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
/**
|
||||
* WAF action types for request handling decisions
|
||||
*/
|
||||
enum WafAction: string
|
||||
{
|
||||
case ALLOW = 'allow';
|
||||
case BLOCK = 'block';
|
||||
case MONITOR = 'monitor';
|
||||
case CHALLENGE = 'challenge';
|
||||
|
||||
/**
|
||||
* Get action description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW => 'Allow request to proceed normally',
|
||||
self::BLOCK => 'Block request with error response',
|
||||
self::MONITOR => 'Allow request but log for analysis',
|
||||
self::CHALLENGE => 'Request additional verification (CAPTCHA, rate limit)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default HTTP status code
|
||||
*/
|
||||
public function getDefaultHttpStatus(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW, self::MONITOR => 200,
|
||||
self::BLOCK => 403,
|
||||
self::CHALLENGE => 429
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action allows request continuation
|
||||
*/
|
||||
public function allowsContinuation(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW, self::MONITOR => true,
|
||||
self::BLOCK, self::CHALLENGE => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action requires logging
|
||||
*/
|
||||
public function requiresLogging(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW => false,
|
||||
self::BLOCK, self::MONITOR, self::CHALLENGE => true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logging priority level
|
||||
*/
|
||||
public function getLogLevel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW => 'debug',
|
||||
self::MONITOR => 'info',
|
||||
self::CHALLENGE => 'warning',
|
||||
self::BLOCK => 'error'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action should trigger alerts
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALLOW, self::MONITOR => false,
|
||||
self::CHALLENGE, self::BLOCK => true
|
||||
};
|
||||
}
|
||||
}
|
||||
324
src/Framework/Waf/WafDecision.php
Normal file
324
src/Framework/Waf/WafDecision.php
Normal file
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* WAF decision system
|
||||
* Final decision-making for request handling based on threat assessment
|
||||
*/
|
||||
final readonly class WafDecision
|
||||
{
|
||||
public function __construct(
|
||||
public WafAction $action,
|
||||
public ThreatAssessment $assessment,
|
||||
public string $reason,
|
||||
public ?Duration $processingTime = null,
|
||||
public ?Timestamp $decisionTime = null,
|
||||
public ?int $httpStatusCode = null,
|
||||
public ?string $responseMessage = null,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create decision from threat assessment
|
||||
*/
|
||||
public static function fromAssessment(ThreatAssessment $assessment, Duration $processingTime): self
|
||||
{
|
||||
$action = self::determineAction($assessment);
|
||||
$reason = self::generateReason($assessment, $action);
|
||||
$httpStatusCode = self::getHttpStatusCode($action);
|
||||
$responseMessage = self::getResponseMessage($action, $assessment);
|
||||
|
||||
return new self(
|
||||
action: $action,
|
||||
assessment: $assessment,
|
||||
reason: $reason,
|
||||
processingTime: $processingTime,
|
||||
decisionTime: Timestamp::fromFloat(microtime(true)),
|
||||
httpStatusCode: $httpStatusCode,
|
||||
responseMessage: $responseMessage,
|
||||
metadata: self::generateMetadata($assessment)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create allow decision
|
||||
*/
|
||||
public static function allow(ThreatAssessment $assessment, Duration $processingTime): self
|
||||
{
|
||||
return new self(
|
||||
action: WafAction::ALLOW,
|
||||
assessment: $assessment,
|
||||
reason: 'Request passed all security checks',
|
||||
processingTime: $processingTime,
|
||||
decisionTime: Timestamp::fromFloat(microtime(true)),
|
||||
httpStatusCode: 200,
|
||||
responseMessage: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create block decision
|
||||
*/
|
||||
public static function block(ThreatAssessment $assessment, Duration $processingTime, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
action: WafAction::BLOCK,
|
||||
assessment: $assessment,
|
||||
reason: $reason,
|
||||
processingTime: $processingTime,
|
||||
decisionTime: Timestamp::fromFloat(microtime(true)),
|
||||
httpStatusCode: 403,
|
||||
responseMessage: 'Request blocked by security policy'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create monitor decision (log but allow)
|
||||
*/
|
||||
public static function monitor(ThreatAssessment $assessment, Duration $processingTime, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
action: WafAction::MONITOR,
|
||||
assessment: $assessment,
|
||||
reason: $reason,
|
||||
processingTime: $processingTime,
|
||||
decisionTime: Timestamp::fromFloat(microtime(true)),
|
||||
httpStatusCode: 200,
|
||||
responseMessage: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create challenge decision (CAPTCHA/rate limit)
|
||||
*/
|
||||
public static function challenge(ThreatAssessment $assessment, Duration $processingTime, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
action: WafAction::CHALLENGE,
|
||||
assessment: $assessment,
|
||||
reason: $reason,
|
||||
processingTime: $processingTime,
|
||||
decisionTime: Timestamp::fromFloat(microtime(true)),
|
||||
httpStatusCode: 429,
|
||||
responseMessage: 'Request requires verification'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be allowed
|
||||
*/
|
||||
public function isAllowed(): bool
|
||||
{
|
||||
return $this->action === WafAction::ALLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be blocked
|
||||
*/
|
||||
public function isBlocked(): bool
|
||||
{
|
||||
return $this->action === WafAction::BLOCK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be monitored
|
||||
*/
|
||||
public function isMonitored(): bool
|
||||
{
|
||||
return $this->action === WafAction::MONITOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be challenged
|
||||
*/
|
||||
public function isChallenged(): bool
|
||||
{
|
||||
return $this->action === WafAction::CHALLENGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should continue processing
|
||||
*/
|
||||
public function shouldContinue(): bool
|
||||
{
|
||||
return $this->action === WafAction::ALLOW || $this->action === WafAction::MONITOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response should be modified
|
||||
*/
|
||||
public function shouldModifyResponse(): bool
|
||||
{
|
||||
return $this->action !== WafAction::ALLOW && $this->action !== WafAction::MONITOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decision priority for logging
|
||||
*/
|
||||
public function getPriority(): string
|
||||
{
|
||||
return match ($this->action) {
|
||||
WafAction::BLOCK => 'HIGH',
|
||||
WafAction::CHALLENGE => 'MEDIUM',
|
||||
WafAction::MONITOR => 'LOW',
|
||||
WafAction::ALLOW => 'INFO'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance metrics
|
||||
*/
|
||||
public function getPerformanceMetrics(): array
|
||||
{
|
||||
return [
|
||||
'processing_time_ms' => $this->processingTime?->toMilliseconds(),
|
||||
'detection_count' => $this->assessment->getDetectionCount(),
|
||||
'threat_score' => $this->assessment->overallThreatScore->getValue(),
|
||||
'decision_time' => $this->decisionTime?->toIsoString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'action' => $this->action->value,
|
||||
'reason' => $this->reason,
|
||||
'priority' => $this->getPriority(),
|
||||
'http_status_code' => $this->httpStatusCode,
|
||||
'response_message' => $this->responseMessage,
|
||||
'processing_time_ms' => $this->processingTime?->toMilliseconds(),
|
||||
'decision_time' => $this->decisionTime?->toIsoString(),
|
||||
'should_continue' => $this->shouldContinue(),
|
||||
'should_modify_response' => $this->shouldModifyResponse(),
|
||||
'performance_metrics' => $this->getPerformanceMetrics(),
|
||||
'metadata' => $this->metadata,
|
||||
'assessment' => $this->assessment->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine action from threat assessment
|
||||
*/
|
||||
private static function determineAction(ThreatAssessment $assessment): WafAction
|
||||
{
|
||||
// Empty assessment = allow
|
||||
if ($assessment->isEmpty()) {
|
||||
return WafAction::ALLOW;
|
||||
}
|
||||
|
||||
// Critical threats = block immediately
|
||||
if ($assessment->requiresImmediateAction()) {
|
||||
return WafAction::BLOCK;
|
||||
}
|
||||
|
||||
// Blocking detections = block
|
||||
if ($assessment->shouldBlock()) {
|
||||
return WafAction::BLOCK;
|
||||
}
|
||||
|
||||
// Medium risk with bot indicators = challenge
|
||||
$threatScore = $assessment->overallThreatScore->getValue();
|
||||
if ($threatScore >= 30.0 && $threatScore < 70.0) {
|
||||
// Check for bot-like behavior in threat categories
|
||||
$categories = $assessment->getThreatCategories();
|
||||
foreach ($categories as $category) {
|
||||
if (in_array($category->value, ['bot_detection', 'automation_attack', 'brute_force_attack'])) {
|
||||
return WafAction::CHALLENGE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Low-medium threats = monitor
|
||||
if ($assessment->shouldAlert()) {
|
||||
return WafAction::MONITOR;
|
||||
}
|
||||
|
||||
// No significant threats = allow
|
||||
return WafAction::ALLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate human-readable reason
|
||||
*/
|
||||
private static function generateReason(ThreatAssessment $assessment, WafAction $action): string
|
||||
{
|
||||
if ($assessment->isEmpty()) {
|
||||
return 'No security threats detected';
|
||||
}
|
||||
|
||||
$detectionCount = $assessment->getDetectionCount();
|
||||
$threatScore = $assessment->overallThreatScore->getValue();
|
||||
$riskLevel = $assessment->getRiskLevel();
|
||||
|
||||
return match ($action) {
|
||||
WafAction::BLOCK => sprintf(
|
||||
'Request blocked: %d threats detected with %s risk level (score: %.1f)',
|
||||
$detectionCount,
|
||||
$riskLevel,
|
||||
$threatScore
|
||||
),
|
||||
WafAction::CHALLENGE => sprintf(
|
||||
'Request challenged: %d potential threats detected (score: %.1f)',
|
||||
$detectionCount,
|
||||
$threatScore
|
||||
),
|
||||
WafAction::MONITOR => sprintf(
|
||||
'Request monitored: %d suspicious patterns detected (score: %.1f)',
|
||||
$detectionCount,
|
||||
$threatScore
|
||||
),
|
||||
WafAction::ALLOW => sprintf(
|
||||
'Request allowed: %d minor issues detected (score: %.1f)',
|
||||
$detectionCount,
|
||||
$threatScore
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTTP status code for action
|
||||
*/
|
||||
private static function getHttpStatusCode(WafAction $action): int
|
||||
{
|
||||
return match ($action) {
|
||||
WafAction::ALLOW, WafAction::MONITOR => 200,
|
||||
WafAction::BLOCK => 403,
|
||||
WafAction::CHALLENGE => 429
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get response message for action
|
||||
*/
|
||||
private static function getResponseMessage(WafAction $action, ThreatAssessment $assessment): ?string
|
||||
{
|
||||
return match ($action) {
|
||||
WafAction::ALLOW, WafAction::MONITOR => null,
|
||||
WafAction::BLOCK => 'Access denied by security policy',
|
||||
WafAction::CHALLENGE => 'Please verify you are human to continue'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate metadata from assessment
|
||||
*/
|
||||
private static function generateMetadata(ThreatAssessment $assessment): array
|
||||
{
|
||||
return [
|
||||
'threat_categories' => array_map(fn ($cat) => $cat->value, $assessment->getThreatCategories()),
|
||||
'owasp_top10_violations' => $assessment->owaspTop10Count,
|
||||
'max_severity' => $assessment->maxSeverity->value,
|
||||
'average_confidence' => $assessment->averageConfidence->getValue(),
|
||||
];
|
||||
}
|
||||
}
|
||||
345
src/Framework/Waf/WafEngine.php
Normal file
345
src/Framework/Waf/WafEngine.php
Normal file
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
use App\Framework\Config\WafConfig;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Performance\PerformanceService;
|
||||
use App\Framework\Waf\Analysis\ValueObjects\RequestAnalysisData;
|
||||
use App\Framework\Waf\Layers\LayerInterface;
|
||||
use App\Framework\Waf\MachineLearning\MachineLearningEngine;
|
||||
|
||||
/**
|
||||
* Central WAF Engine - Orchestrates all security layers
|
||||
* Provides unified threat assessment and decision making
|
||||
*/
|
||||
final class WafEngine
|
||||
{
|
||||
/** @var LayerInterface[] */
|
||||
private array $layers = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $layerResults = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly WafConfig $config,
|
||||
private readonly ThreatAssessmentService $threatAssessmentService,
|
||||
private readonly PerformanceService $performance,
|
||||
private readonly Logger $logger,
|
||||
private readonly Clock $clock,
|
||||
private readonly ?MachineLearningEngine $mlEngine = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a security layer
|
||||
*/
|
||||
public function registerLayer(LayerInterface $layer): void
|
||||
{
|
||||
$this->layers[$layer->getName()] = $layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request through all registered layers (alias for analyzeRequest)
|
||||
*/
|
||||
public function analyze(Request $request): LayerResult
|
||||
{
|
||||
if (! $this->config->enabled) {
|
||||
return LayerResult::clean('waf_engine', 'WAF disabled');
|
||||
}
|
||||
|
||||
if (empty($this->layers)) {
|
||||
return LayerResult::clean('waf_engine', 'No security layers registered');
|
||||
}
|
||||
|
||||
// Process all layers and aggregate results
|
||||
$allDetections = [];
|
||||
$highestRisk = 0.0;
|
||||
$threatDetected = false;
|
||||
|
||||
foreach ($this->layers as $layerName => $layer) {
|
||||
if (! $layer->isEnabled()) {
|
||||
$this->logger->debug("WafEngine: Layer '{$layerName}' is disabled, skipping");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->debug("WafEngine: Analyzing with layer '{$layerName}'");
|
||||
|
||||
try {
|
||||
$layerResult = $layer->analyze($request);
|
||||
|
||||
$this->logger->debug("WafEngine: Layer '{$layerName}' returned status: " . $layerResult->status->value . ", has detections: " . ($layerResult->hasDetections() ? 'YES' : 'NO'));
|
||||
|
||||
if ($layerResult->hasDetections()) {
|
||||
$detectionCollection = $layerResult->getDetections();
|
||||
$detections = $detectionCollection->detections; // Access the array property
|
||||
$allDetections = array_merge($allDetections, $detections);
|
||||
|
||||
// Check if any detection indicates a threat
|
||||
foreach ($detections as $detection) {
|
||||
$threatScore = $detection->getThreatScore()->getValue();
|
||||
if ($threatScore > $highestRisk) {
|
||||
$highestRisk = $threatScore;
|
||||
}
|
||||
|
||||
if ($detection->severity === \App\Framework\Waf\DetectionSeverity::CRITICAL ||
|
||||
$detection->severity === \App\Framework\Waf\DetectionSeverity::HIGH) {
|
||||
$threatDetected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('WAF layer analysis failed', [
|
||||
'layer' => $layerName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
// Continue with other layers
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall result
|
||||
$this->logger->debug("WafEngine: Final decision - threatDetected: " . ($threatDetected ? 'YES' : 'NO') . ", highestRisk: {$highestRisk}, detections: " . count($allDetections));
|
||||
|
||||
if ($threatDetected && $highestRisk >= 70.0) {
|
||||
$this->logger->debug("WafEngine: Returning THREAT result");
|
||||
|
||||
return LayerResult::threat(
|
||||
'waf_engine',
|
||||
'Security threat detected',
|
||||
LayerStatus::THREAT_DETECTED,
|
||||
$allDetections
|
||||
);
|
||||
} elseif (! empty($allDetections)) {
|
||||
$this->logger->debug("WafEngine: Returning SUSPICIOUS result");
|
||||
|
||||
return LayerResult::suspicious(
|
||||
'waf_engine',
|
||||
'Suspicious activity detected',
|
||||
$allDetections
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->debug("WafEngine: Returning CLEAN result");
|
||||
|
||||
return LayerResult::clean('waf_engine', 'All security layers passed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request through all registered layers
|
||||
*/
|
||||
public function analyzeRequest(Request $request): WafDecision
|
||||
{
|
||||
if (! $this->config->enabled) {
|
||||
// Create empty assessment and return allow decision
|
||||
$emptyAssessment = ThreatAssessment::createEmpty();
|
||||
|
||||
return WafDecision::allow($emptyAssessment, Duration::zero());
|
||||
}
|
||||
|
||||
return $this->performance->measure('waf_analysis', function () use ($request) {
|
||||
$analysisStart = $this->clock->now();
|
||||
$this->layerResults = [];
|
||||
|
||||
// Process all layers in parallel for performance
|
||||
$layerPromises = [];
|
||||
|
||||
foreach ($this->layers as $layerName => $layer) {
|
||||
if (! $layer->isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$layerPromises[$layerName] = $this->processLayer($layer, $request);
|
||||
}
|
||||
|
||||
// Collect all layer results
|
||||
foreach ($layerPromises as $layerName => $promise) {
|
||||
$this->layerResults[$layerName] = $promise;
|
||||
}
|
||||
|
||||
$analysisEnd = $this->clock->now();
|
||||
$endTime = $analysisEnd->getTimestamp() + (float)$analysisEnd->format('u') / 1_000_000;
|
||||
$startTime = $analysisStart->getTimestamp() + (float)$analysisStart->format('u') / 1_000_000;
|
||||
$totalDuration = Duration::fromSeconds($endTime - $startTime);
|
||||
|
||||
// Perform machine learning analysis if enabled
|
||||
$mlResult = null;
|
||||
if ($this->mlEngine?->isEnabled()) {
|
||||
$requestData = $this->createRequestAnalysisData($request);
|
||||
$mlResult = $this->mlEngine->analyzeRequest($requestData, ['layer_results' => $this->layerResults]);
|
||||
}
|
||||
|
||||
// Create unified threat assessment
|
||||
return $this->threatAssessmentService->evaluate($this->layerResults, $request, $totalDuration, $mlResult);
|
||||
}, \App\Framework\Performance\PerformanceCategory::SECURITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process individual security layer
|
||||
*/
|
||||
private function processLayer(LayerInterface $layer, Request $request): LayerResult
|
||||
{
|
||||
$startTime = $this->clock->now();
|
||||
|
||||
try {
|
||||
$result = $layer->analyze($request);
|
||||
|
||||
$endTime = $this->clock->now();
|
||||
$endTimestamp = $endTime->getTimestamp() + (float)$endTime->format('u') / 1_000_000;
|
||||
$startTimestamp = $startTime->getTimestamp() + (float)$startTime->format('u') / 1_000_000;
|
||||
$executionDuration = Duration::fromSeconds($endTimestamp - $startTimestamp);
|
||||
|
||||
// Log slow layers for performance optimization
|
||||
if ($executionDuration->greaterThan($this->config->globalTimeout)) {
|
||||
$this->logger->warning('Slow WAF layer detected', [
|
||||
'layer' => $layer->getName(),
|
||||
'execution_duration' => $executionDuration->toMilliseconds(),
|
||||
'threat_score' => 0.0, // Simplified for now
|
||||
]);
|
||||
}
|
||||
|
||||
return $result->withExecutionDuration($executionDuration);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('WAF layer failed', [
|
||||
'layer' => $layer->getName(),
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Return neutral result on layer failure - fail open for availability
|
||||
return LayerResult::neutral($layer->getName(), 'Layer execution failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed analysis results for debugging/monitoring
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getLayerResults(): array
|
||||
{
|
||||
return $this->layerResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics for all layers
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getPerformanceStats(): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($this->layerResults as $layerName => $result) {
|
||||
$stats[$layerName] = [
|
||||
'execution_duration' => Duration::zero(),
|
||||
'threat_score' => Percentage::zero(),
|
||||
'detections' => 0,
|
||||
'status' => 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update WAF configuration at runtime
|
||||
*/
|
||||
public function updateConfig(WafConfig $config): void
|
||||
{
|
||||
// Note: Cannot update readonly property, would need to recreate the engine
|
||||
// For now, we'll log the configuration change
|
||||
// $this->config = $config;
|
||||
|
||||
$this->logger->info('WAF configuration updated', [
|
||||
'enabled' => $config->enabled,
|
||||
'layers_count' => count($this->layers),
|
||||
'timestamp' => $this->clock->now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current WAF health status
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getHealthStatus(): array
|
||||
{
|
||||
$healthyLayers = 0;
|
||||
$totalLayers = count($this->layers);
|
||||
|
||||
foreach ($this->layers as $layer) {
|
||||
if ($layer->isHealthy()) {
|
||||
$healthyLayers++;
|
||||
}
|
||||
}
|
||||
|
||||
$healthPercentage = $totalLayers > 0
|
||||
? Percentage::fromRatio($healthyLayers, $totalLayers)
|
||||
: Percentage::from(100.0);
|
||||
|
||||
return [
|
||||
'status' => $healthyLayers === $totalLayers ? 'healthy' : 'degraded',
|
||||
'health_percentage' => $healthPercentage,
|
||||
'healthy_layers' => $healthyLayers,
|
||||
'total_layers' => $totalLayers,
|
||||
'enabled' => $this->config->enabled,
|
||||
'average_processing_time' => $this->getAverageProcessingTime(),
|
||||
'last_check' => Timestamp::fromClock($this->clock),
|
||||
];
|
||||
}
|
||||
|
||||
private function getAverageProcessingTime(): Duration
|
||||
{
|
||||
if (empty($this->layerResults)) {
|
||||
return Duration::zero();
|
||||
}
|
||||
|
||||
$totalMilliseconds = 0.0;
|
||||
foreach ($this->layerResults as $result) {
|
||||
// Simplified for now
|
||||
$totalMilliseconds += 0.0;
|
||||
}
|
||||
|
||||
$averageMs = $totalMilliseconds / count($this->layerResults);
|
||||
|
||||
return Duration::fromMilliseconds($averageMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RequestAnalysisData from HTTP Request for ML analysis
|
||||
*/
|
||||
private function createRequestAnalysisData(Request $request): RequestAnalysisData
|
||||
{
|
||||
if (! $request instanceof HttpRequest) {
|
||||
throw new \RuntimeException('Expected HttpRequest instance');
|
||||
}
|
||||
|
||||
$httpRequest = $request;
|
||||
|
||||
// Simplified implementation - just create with basic data
|
||||
return new RequestAnalysisData(
|
||||
method: $httpRequest->method->value,
|
||||
url: $httpRequest->getUri()->__toString(),
|
||||
path: $httpRequest->path,
|
||||
queryString: http_build_query($httpRequest->queryParams),
|
||||
headers: [], // Simplified for now
|
||||
queryParameters: $httpRequest->queryParams,
|
||||
postParameters: $httpRequest->parsedBody->data ?? [],
|
||||
cookies: [],
|
||||
body: $httpRequest->body, // Simplified for now
|
||||
files: [], // Simplified for now
|
||||
clientIp: $httpRequest->server->getRemoteAddr(), // Will be string or empty
|
||||
userAgent: null, // Simplified for now
|
||||
contentType: null, // Simplified for now
|
||||
contentLength: null, // Simplified for now
|
||||
timestamp: Timestamp::fromClock($this->clock)
|
||||
);
|
||||
}
|
||||
}
|
||||
112
src/Framework/Waf/WafEngineInitializer.php
Normal file
112
src/Framework/Waf/WafEngineInitializer.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Waf;
|
||||
|
||||
use App\Framework\Config\WafConfig;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Performance\PerformanceService;
|
||||
use App\Framework\Waf\Layers\CommandInjectionLayer;
|
||||
use App\Framework\Waf\Layers\PathTraversalLayer;
|
||||
use App\Framework\Waf\Layers\SqlInjectionLayer;
|
||||
use App\Framework\Waf\Layers\SuspiciousUserAgentLayer;
|
||||
use App\Framework\Waf\Layers\XssLayer;
|
||||
use App\Framework\Waf\MachineLearning\MachineLearningEngine;
|
||||
|
||||
/**
|
||||
* WAF Engine Initializer
|
||||
*
|
||||
* Registers security layers with the WAF Engine during framework startup.
|
||||
* This enables proper threat detection by configuring all available security layers.
|
||||
*/
|
||||
final readonly class WafEngineInitializer
|
||||
{
|
||||
private WafEngine $wafEngine;
|
||||
|
||||
private Logger $logger;
|
||||
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
$this->wafEngine = new WafEngine(
|
||||
WafConfig::development(),
|
||||
$this->container->get(ThreatAssessmentService::class),
|
||||
$this->container->get(PerformanceService::class),
|
||||
$this->container->get(Logger::class),
|
||||
$this->container->get(Clock::class),
|
||||
$this->container->get(MachineLearningEngine::class)
|
||||
);
|
||||
$this->logger = $this->container->get(Logger::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize WAF Engine with all security layers
|
||||
*/
|
||||
#[Initializer]
|
||||
public function __invoke(): WafEngine
|
||||
{
|
||||
$this->logger->info('Initializing WAF Engine with security layers');
|
||||
|
||||
try {
|
||||
// Register core security layers in priority order
|
||||
$this->registerSecurityLayers();
|
||||
|
||||
$this->logger->info('WAF Engine initialized successfully', [
|
||||
'registered_layers' => $this->getRegisteredLayerNames(),
|
||||
'health_status' => $this->wafEngine->getHealthStatus(),
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to initialize WAF Engine', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Re-throw to prevent application startup with broken WAF
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->wafEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all security layers with the WAF Engine
|
||||
*/
|
||||
private function registerSecurityLayers(): void
|
||||
{
|
||||
// High priority layers (processed first)
|
||||
$this->wafEngine->registerLayer(new SqlInjectionLayer());
|
||||
$this->wafEngine->registerLayer(new CommandInjectionLayer());
|
||||
$this->wafEngine->registerLayer(new PathTraversalLayer());
|
||||
|
||||
// Medium priority layers
|
||||
$this->wafEngine->registerLayer(new XssLayer());
|
||||
|
||||
// Low priority layers (processed last)
|
||||
$this->wafEngine->registerLayer(new SuspiciousUserAgentLayer());
|
||||
|
||||
$this->logger->debug('Security layers registered', [
|
||||
'layers_count' => count($this->getRegisteredLayerNames()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get names of registered layers for logging
|
||||
* @return string[]
|
||||
*/
|
||||
private function getRegisteredLayerNames(): array
|
||||
{
|
||||
// Since WafEngine doesn't expose layer names, we'll return what we registered
|
||||
return [
|
||||
'sql_injection',
|
||||
'command_injection',
|
||||
'path_traversal',
|
||||
'xss',
|
||||
'suspicious_user_agent',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user