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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user