- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
465 lines
17 KiB
PHP
465 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Framework\Waf\Feedback;
|
|
|
|
use App\Framework\Core\ValueObjects\Percentage;
|
|
use App\Framework\Core\ValueObjects\Timestamp;
|
|
use App\Framework\Logging\Logger;
|
|
use App\Framework\Waf\DetectionCategory;
|
|
use App\Framework\Waf\DetectionSeverity;
|
|
use App\Framework\Waf\Feedback\DetectionFeedback;
|
|
use App\Framework\Waf\Feedback\FeedbackLearningService;
|
|
use App\Framework\Waf\Feedback\FeedbackRepositoryInterface;
|
|
use App\Framework\Waf\Feedback\FeedbackType;
|
|
use App\Framework\Waf\MachineLearning\ValueObjects\ModelAdjustment;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Tests for the FeedbackLearningService
|
|
*/
|
|
class FeedbackLearningServiceTest extends TestCase
|
|
{
|
|
private FeedbackRepositoryInterface $repository;
|
|
|
|
private TestMachineLearningEngine $mlEngine;
|
|
|
|
private TestClock $clock;
|
|
|
|
private Logger $logger;
|
|
|
|
private FeedbackLearningService $service;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->repository = $this->createMock(FeedbackRepositoryInterface::class);
|
|
$this->mlEngine = new TestMachineLearningEngine();
|
|
$this->clock = new TestClock('2025-08-04 18:39:00');
|
|
$this->logger = $this->createMock(Logger::class);
|
|
|
|
$this->service = new FeedbackLearningService(
|
|
$this->repository,
|
|
$this->mlEngine,
|
|
$this->clock,
|
|
$this->logger,
|
|
5, // minimumFeedbackThreshold
|
|
0.3 // learningRate
|
|
);
|
|
}
|
|
|
|
public function testLearnFromFeedbackWithInsufficientData(): void
|
|
{
|
|
// Set up repository to return stats with insufficient feedback
|
|
$this->repository->method('getFeedbackStats')->willReturn([
|
|
'total_count' => 3, // Less than minimum threshold of 5
|
|
]);
|
|
|
|
// Execute the learning process
|
|
$result = $this->service->learnFromFeedback();
|
|
|
|
// Verify the result
|
|
$this->assertFalse($result['success']);
|
|
$this->assertEquals('Not enough feedback for learning', $result['message']);
|
|
$this->assertEquals(3, $result['feedback_count']);
|
|
$this->assertEquals(5, $result['minimum_threshold']);
|
|
}
|
|
|
|
public function testLearnFromFeedbackWithSufficientData(): void
|
|
{
|
|
// Set up repository to return stats with sufficient feedback
|
|
$this->repository->method('getFeedbackStats')->willReturn([
|
|
'total_count' => 10,
|
|
]);
|
|
|
|
// Set up repository to return feedback data
|
|
$falsePositives = [
|
|
$this->createFalsePositiveFeedback('detection1', DetectionCategory::SQL_INJECTION),
|
|
$this->createFalsePositiveFeedback('detection2', DetectionCategory::SQL_INJECTION),
|
|
$this->createFalsePositiveFeedback('detection3', DetectionCategory::SQL_INJECTION),
|
|
$this->createFalsePositiveFeedback('detection4', DetectionCategory::XSS),
|
|
];
|
|
|
|
$falseNegatives = [
|
|
$this->createFalseNegativeFeedback('detection5', DetectionCategory::COMMAND_INJECTION),
|
|
$this->createFalseNegativeFeedback('detection6', DetectionCategory::COMMAND_INJECTION),
|
|
];
|
|
|
|
$severityAdjustments = [
|
|
$this->createSeverityAdjustmentFeedback(
|
|
'detection7',
|
|
DetectionCategory::PATH_TRAVERSAL,
|
|
DetectionSeverity::MEDIUM,
|
|
DetectionSeverity::HIGH
|
|
),
|
|
$this->createSeverityAdjustmentFeedback(
|
|
'detection8',
|
|
DetectionCategory::PATH_TRAVERSAL,
|
|
DetectionSeverity::MEDIUM,
|
|
DetectionSeverity::HIGH
|
|
),
|
|
$this->createSeverityAdjustmentFeedback(
|
|
'detection9',
|
|
DetectionCategory::PATH_TRAVERSAL,
|
|
DetectionSeverity::MEDIUM,
|
|
DetectionSeverity::HIGH
|
|
),
|
|
];
|
|
|
|
$this->repository->method('getFeedbackByFeedbackType')
|
|
->willReturnMap([
|
|
[FeedbackType::FALSE_POSITIVE, null, $falsePositives],
|
|
[FeedbackType::FALSE_NEGATIVE, null, $falseNegatives],
|
|
[FeedbackType::SEVERITY_ADJUSTMENT, null, $severityAdjustments],
|
|
[FeedbackType::CORRECT_DETECTION, null, []],
|
|
]);
|
|
|
|
// Set up ML engine to return success for adjustments
|
|
$this->mlEngine->withApplyFeedbackAdjustmentsResult([
|
|
'success' => true,
|
|
'applied_count' => 3,
|
|
'failed_count' => 0,
|
|
'results' => [],
|
|
]);
|
|
|
|
// Execute the learning process
|
|
$result = $this->service->learnFromFeedback();
|
|
|
|
// Verify the result
|
|
$this->assertTrue($result['success']);
|
|
$this->assertEquals(4, $result['false_positives_processed']);
|
|
$this->assertEquals(2, $result['false_negatives_processed']);
|
|
$this->assertEquals(3, $result['severity_adjustments_processed']);
|
|
$this->assertGreaterThan(0, $result['total_adjustments_applied']);
|
|
}
|
|
|
|
public function testProcessFalsePositives(): void
|
|
{
|
|
// Create a service with reflection to access private methods
|
|
$service = new FeedbackLearningService(
|
|
$this->repository,
|
|
$this->mlEngine,
|
|
$this->clock,
|
|
$this->logger,
|
|
5,
|
|
0.3
|
|
);
|
|
|
|
$reflectionMethod = new \ReflectionMethod(FeedbackLearningService::class, 'processfalsePositives');
|
|
$reflectionMethod->setAccessible(true);
|
|
|
|
// Create test data
|
|
$falsePositives = [
|
|
$this->createFalsePositiveFeedback('detection1', DetectionCategory::SQL_INJECTION),
|
|
$this->createFalsePositiveFeedback('detection2', DetectionCategory::SQL_INJECTION),
|
|
$this->createFalsePositiveFeedback('detection3', DetectionCategory::SQL_INJECTION),
|
|
$this->createFalsePositiveFeedback('detection4', DetectionCategory::XSS),
|
|
];
|
|
|
|
// Execute the method
|
|
$adjustments = $reflectionMethod->invoke($service, $falsePositives);
|
|
|
|
// Verify the adjustments
|
|
$this->assertCount(2, $adjustments); // One for SQL_INJECTION, one for XSS
|
|
|
|
// Check SQL_INJECTION adjustment
|
|
$sqlInjectionAdjustment = null;
|
|
$xssAdjustment = null;
|
|
|
|
foreach ($adjustments as $adjustment) {
|
|
if ($adjustment->category === DetectionCategory::SQL_INJECTION) {
|
|
$sqlInjectionAdjustment = $adjustment;
|
|
} elseif ($adjustment->category === DetectionCategory::XSS) {
|
|
$xssAdjustment = $adjustment;
|
|
}
|
|
}
|
|
|
|
$this->assertNotNull($sqlInjectionAdjustment);
|
|
$this->assertNotNull($xssAdjustment);
|
|
|
|
// SQL_INJECTION had 3 false positives, so should have stronger adjustment
|
|
$this->assertGreaterThan(
|
|
$xssAdjustment->thresholdAdjustment->getValue(),
|
|
$sqlInjectionAdjustment->thresholdAdjustment->getValue()
|
|
);
|
|
|
|
// Threshold adjustments should be positive for false positives (make detection harder)
|
|
$this->assertGreaterThan(0, $sqlInjectionAdjustment->thresholdAdjustment->getValue());
|
|
$this->assertGreaterThan(0, $xssAdjustment->thresholdAdjustment->getValue());
|
|
|
|
// Confidence adjustments should be negative for false positives (less certain)
|
|
$this->assertLessThan(0, $sqlInjectionAdjustment->confidenceAdjustment->getValue());
|
|
$this->assertLessThan(0, $xssAdjustment->confidenceAdjustment->getValue());
|
|
}
|
|
|
|
public function testProcessFalseNegatives(): void
|
|
{
|
|
// Create a service with reflection to access private methods
|
|
$service = new FeedbackLearningService(
|
|
$this->repository,
|
|
$this->mlEngine,
|
|
$this->clock,
|
|
$this->logger,
|
|
5,
|
|
0.3
|
|
);
|
|
|
|
$reflectionMethod = new \ReflectionMethod(FeedbackLearningService::class, 'processFalseNegatives');
|
|
$reflectionMethod->setAccessible(true);
|
|
|
|
// Create test data
|
|
$falseNegatives = [
|
|
$this->createFalseNegativeFeedback('detection1', DetectionCategory::COMMAND_INJECTION),
|
|
$this->createFalseNegativeFeedback('detection2', DetectionCategory::COMMAND_INJECTION),
|
|
];
|
|
|
|
// Execute the method
|
|
$adjustments = $reflectionMethod->invoke($service, $falseNegatives);
|
|
|
|
// Verify the adjustments
|
|
$this->assertCount(1, $adjustments); // One for COMMAND_INJECTION
|
|
|
|
$adjustment = reset($adjustments);
|
|
|
|
// Threshold adjustments should be negative for false negatives (make detection easier)
|
|
$this->assertLessThan(0, $adjustment->thresholdAdjustment->getValue());
|
|
|
|
// Confidence adjustments should be positive for false negatives (more certain)
|
|
$this->assertGreaterThan(0, $adjustment->confidenceAdjustment->getValue());
|
|
}
|
|
|
|
public function testProcessSeverityAdjustments(): void
|
|
{
|
|
// Create a service with reflection to access private methods
|
|
$service = new FeedbackLearningService(
|
|
$this->repository,
|
|
$this->mlEngine,
|
|
$this->clock,
|
|
$this->logger,
|
|
5,
|
|
0.3
|
|
);
|
|
|
|
$reflectionMethod = new \ReflectionMethod(FeedbackLearningService::class, 'processSeverityAdjustments');
|
|
$reflectionMethod->setAccessible(true);
|
|
|
|
// Create test data
|
|
$severityAdjustments = [
|
|
$this->createSeverityAdjustmentFeedback(
|
|
'detection1',
|
|
DetectionCategory::PATH_TRAVERSAL,
|
|
DetectionSeverity::MEDIUM,
|
|
DetectionSeverity::HIGH
|
|
),
|
|
$this->createSeverityAdjustmentFeedback(
|
|
'detection2',
|
|
DetectionCategory::PATH_TRAVERSAL,
|
|
DetectionSeverity::MEDIUM,
|
|
DetectionSeverity::HIGH
|
|
),
|
|
$this->createSeverityAdjustmentFeedback(
|
|
'detection3',
|
|
DetectionCategory::PATH_TRAVERSAL,
|
|
DetectionSeverity::MEDIUM,
|
|
DetectionSeverity::HIGH
|
|
),
|
|
];
|
|
|
|
// Execute the method
|
|
$result = $reflectionMethod->invoke($service, $severityAdjustments);
|
|
|
|
// Verify the result
|
|
$this->assertArrayHasKey('adjustments', $result);
|
|
$this->assertArrayHasKey('severity_changes', $result);
|
|
|
|
$adjustments = $result['adjustments'];
|
|
$severityChanges = $result['severity_changes'];
|
|
|
|
$this->assertCount(1, $adjustments); // One for PATH_TRAVERSAL
|
|
$this->assertCount(1, $severityChanges); // One change from MEDIUM to HIGH
|
|
|
|
$adjustment = reset($adjustments);
|
|
|
|
// For severity increase, confidence adjustment should be positive
|
|
$this->assertGreaterThan(0, $adjustment->confidenceAdjustment->getValue());
|
|
|
|
// Threshold should not be adjusted for severity changes
|
|
$this->assertEquals(0, $adjustment->thresholdAdjustment->getValue());
|
|
}
|
|
|
|
public function testGenerateFeatureWeightAdjustments(): void
|
|
{
|
|
// Create a service with reflection to access private methods
|
|
$service = new FeedbackLearningService(
|
|
$this->repository,
|
|
$this->mlEngine,
|
|
$this->clock,
|
|
$this->logger,
|
|
5,
|
|
0.3
|
|
);
|
|
|
|
$reflectionMethod = new \ReflectionMethod(FeedbackLearningService::class, 'generateFeatureWeightAdjustments');
|
|
$reflectionMethod->setAccessible(true);
|
|
|
|
// Test for SQL_INJECTION false positive
|
|
$sqlInjectionFalsePositiveAdjustments = $reflectionMethod->invoke(
|
|
$service,
|
|
DetectionCategory::SQL_INJECTION,
|
|
true // isfalsePositive
|
|
);
|
|
|
|
// Test for SQL_INJECTION false negative
|
|
$sqlInjectionFalseNegativeAdjustments = $reflectionMethod->invoke(
|
|
$service,
|
|
DetectionCategory::SQL_INJECTION,
|
|
false // isfalsePositive
|
|
);
|
|
|
|
// Verify the adjustments
|
|
$this->assertNotEmpty($sqlInjectionFalsePositiveAdjustments);
|
|
$this->assertNotEmpty($sqlInjectionFalseNegativeAdjustments);
|
|
|
|
// For false positives, adjustments should be negative (reduce feature importance)
|
|
foreach ($sqlInjectionFalsePositiveAdjustments as $feature => $adjustment) {
|
|
$this->assertLessThan(0, $adjustment);
|
|
}
|
|
|
|
// For false negatives, adjustments should be positive (increase feature importance)
|
|
foreach ($sqlInjectionFalseNegativeAdjustments as $feature => $adjustment) {
|
|
$this->assertGreaterThan(0, $adjustment);
|
|
}
|
|
}
|
|
|
|
public function testApplyModelAdjustments(): void
|
|
{
|
|
// Create test data
|
|
$adjustments = [
|
|
'adjustment1' => new ModelAdjustment(
|
|
'adjustment1',
|
|
DetectionCategory::SQL_INJECTION,
|
|
Percentage::from(10.0),
|
|
Percentage::from(-10.0),
|
|
['sql_keywords_count' => -0.2],
|
|
'Test adjustment 1',
|
|
Timestamp::fromClock($this->clock)
|
|
),
|
|
'adjustment2' => new ModelAdjustment(
|
|
'adjustment2',
|
|
DetectionCategory::XSS,
|
|
Percentage::from(5.0),
|
|
Percentage::from(-5.0),
|
|
['script_tag_count' => -0.3],
|
|
'Test adjustment 2',
|
|
Timestamp::fromClock($this->clock)
|
|
),
|
|
];
|
|
|
|
// Set up ML engine to return success
|
|
$this->mlEngine->withApplyFeedbackAdjustmentsResult([
|
|
'success' => true,
|
|
'applied_count' => 2,
|
|
'failed_count' => 0,
|
|
'results' => [],
|
|
]);
|
|
|
|
// Create a service with reflection to access private methods
|
|
$service = new FeedbackLearningService(
|
|
$this->repository,
|
|
$this->mlEngine,
|
|
$this->clock,
|
|
$this->logger,
|
|
5,
|
|
0.3
|
|
);
|
|
|
|
$reflectionMethod = new \ReflectionMethod(FeedbackLearningService::class, 'applyModelAdjustments');
|
|
$reflectionMethod->setAccessible(true);
|
|
|
|
// Execute the method
|
|
$result = $reflectionMethod->invoke($service, $adjustments);
|
|
|
|
// Verify the result
|
|
$this->assertTrue($result['success']);
|
|
$this->assertEquals(2, $result['applied_count']);
|
|
$this->assertEquals(0, $result['failed_count']);
|
|
|
|
// Verify that the correct adjustments were passed to the ML engine
|
|
$this->assertSame($adjustments, $this->mlEngine->getReceivedAdjustments());
|
|
}
|
|
|
|
public function testWithMinimumFeedbackThreshold(): void
|
|
{
|
|
$newService = $this->service->withMinimumFeedbackThreshold(10);
|
|
|
|
$this->assertNotSame($this->service, $newService);
|
|
$this->assertEquals(10, $newService->getMinimumFeedbackThreshold());
|
|
$this->assertEquals(5, $this->service->getMinimumFeedbackThreshold());
|
|
}
|
|
|
|
public function testWithLearningRate(): void
|
|
{
|
|
$newService = $this->service->withLearningRate(0.5);
|
|
|
|
$this->assertNotSame($this->service, $newService);
|
|
$this->assertEquals(0.5, $newService->getLearningRate());
|
|
$this->assertEquals(0.3, $this->service->getLearningRate());
|
|
}
|
|
|
|
/**
|
|
* Helper method to create a false positive feedback
|
|
*/
|
|
private function createFalsePositiveFeedback(
|
|
string $detectionId,
|
|
DetectionCategory $category,
|
|
DetectionSeverity $severity = DetectionSeverity::MEDIUM
|
|
): DetectionFeedback {
|
|
return DetectionFeedback::falsePositive(
|
|
$detectionId,
|
|
'test-user',
|
|
'This is a false positive',
|
|
$category,
|
|
$severity,
|
|
['test_context' => true]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Helper method to create a false negative feedback
|
|
*/
|
|
private function createFalseNegativeFeedback(
|
|
string $detectionId,
|
|
DetectionCategory $category,
|
|
DetectionSeverity $severity = DetectionSeverity::MEDIUM
|
|
): DetectionFeedback {
|
|
return DetectionFeedback::falseNegative(
|
|
$detectionId,
|
|
'test-user',
|
|
'This is a false negative',
|
|
$category,
|
|
$severity,
|
|
['test_context' => true]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Helper method to create a severity adjustment feedback
|
|
*/
|
|
private function createSeverityAdjustmentFeedback(
|
|
string $detectionId,
|
|
DetectionCategory $category,
|
|
DetectionSeverity $currentSeverity,
|
|
DetectionSeverity $suggestedSeverity
|
|
): DetectionFeedback {
|
|
return DetectionFeedback::severityAdjustment(
|
|
$detectionId,
|
|
'test-user',
|
|
'This severity should be adjusted',
|
|
$category,
|
|
$currentSeverity,
|
|
$suggestedSeverity,
|
|
['test_context' => true]
|
|
);
|
|
}
|
|
}
|