diff --git a/composer.json b/composer.json index 13b4afc..9865160 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "notifications", "CVE" ], - "version": "1.5.1", + "version": "1.5.3", "license": "MIT", "autoload": { "psr-4": { diff --git a/readme.md b/readme.md index 6d758ae..f23b1b9 100644 --- a/readme.md +++ b/readme.md @@ -166,6 +166,19 @@ WARDEN_SCHEDULE_TIME=03:00 WARDEN_SCHEDULE_TIMEZONE=UTC ``` +### Ignoring Accepted Findings + +If your team has reviewed a finding and wants to suppress it without forking the package, add an `ignore_findings` rule to `config/warden.php`. + +```php +'ignore_findings' => [ + ['source' => 'debug-mode', 'package' => 'laravel/horizon'], + ['source' => 'debug-mode', 'title' => 'Testing routes*'], +], +``` + +All provided keys in a rule must match for the finding to be ignored. String values support wildcard matching. + --- ## 🔍 Security Audits @@ -604,4 +617,4 @@ If you find Warden useful for your organization's security needs, please conside [⭐ Star on GitHub](https://github.com/dgtlss/warden) | [📦 Packagist](https://packagist.org/packages/dgtlss/warden) | [🐦 Follow Updates](https://twitter.com/nlangerdev) - \ No newline at end of file + diff --git a/src/Commands/WardenAuditCommand.php b/src/Commands/WardenAuditCommand.php index 5220799..d7f3633 100644 --- a/src/Commands/WardenAuditCommand.php +++ b/src/Commands/WardenAuditCommand.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Carbon\Carbon; use Symfony\Component\Process\Process; use Dgtlss\Warden\Services\Audits\ComposerAuditService; @@ -193,6 +194,7 @@ protected function runSequentialAudits(bool $isMachineOutput = false): int protected function processResults(array $allFindings, array $abandonedPackages, bool $hasFailures): int { + $allFindings = $this->filterIgnoredFindings($allFindings); $totalBeforeFilter = count($allFindings); $severityOption = null; @@ -232,6 +234,75 @@ protected function processResults(array $allFindings, array $abandonedPackages, return $hasFailures ? 2 : 0; } + /** + * Remove findings that match configured ignore rules. + * + * @param array> $findings + * @return array> + */ + protected function filterIgnoredFindings(array $findings): array + { + $ignoreRules = config('warden.ignore_findings', []); + + if (!is_array($ignoreRules) || $ignoreRules === []) { + return $findings; + } + + return array_values(array_filter( + $findings, + fn (array $finding): bool => !$this->shouldIgnoreFinding($finding, $ignoreRules) + )); + } + + /** + * @param array $finding + * @param array $ignoreRules + */ + protected function shouldIgnoreFinding(array $finding, array $ignoreRules): bool + { + foreach ($ignoreRules as $rule) { + if (!is_array($rule) || !$this->findingMatchesIgnoreRule($finding, $rule)) { + continue; + } + + return true; + } + + return false; + } + + /** + * @param array $finding + * @param array $rule + */ + protected function findingMatchesIgnoreRule(array $finding, array $rule): bool + { + $matchedKey = false; + + foreach ($rule as $key => $expectedValue) { + if (!is_string($key) || $key === '' || !array_key_exists($key, $finding)) { + return false; + } + + $matchedKey = true; + + if (!$this->findingValueMatchesRule($finding[$key], $expectedValue)) { + return false; + } + } + + return $matchedKey; + } + + protected function findingValueMatchesRule(mixed $actualValue, mixed $expectedValue): bool + { + if (is_string($actualValue) && is_string($expectedValue)) { + return Str::is($expectedValue, $actualValue); + } + + return $actualValue === $expectedValue; + } + /** * Display the current version of Warden. */ diff --git a/src/Services/Audits/DebugModeAuditService.php b/src/Services/Audits/DebugModeAuditService.php index ccd9dc0..825640e 100644 --- a/src/Services/Audits/DebugModeAuditService.php +++ b/src/Services/Audits/DebugModeAuditService.php @@ -7,7 +7,6 @@ class DebugModeAuditService extends AbstractAuditService private array $devPackages = [ 'barryvdh/laravel-debugbar', 'laravel/telescope', - 'laravel/horizon', 'beyondcode/laravel-dump-server', 'laravel/dusk', ]; @@ -124,7 +123,6 @@ private function hasExposedTestingRoutes(): bool // Check other testing routes that should never be exposed in production $testingRoutes = [ 'telescope', - 'horizon', '_dusk', ]; diff --git a/src/config/warden.php b/src/config/warden.php index a7621b6..53daa0c 100644 --- a/src/config/warden.php +++ b/src/config/warden.php @@ -88,6 +88,25 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Ignored Findings + |-------------------------------------------------------------------------- + | + | Suppress accepted or context-specific findings without forking the package. + | Each rule is matched against the final finding payload and all provided + | keys must match for the finding to be ignored. + | + | Examples: + | - ['source' => 'debug-mode', 'package' => 'laravel/horizon'] + | - ['source' => 'debug-mode', 'title' => 'Testing routes*'] + | + */ + + 'ignore_findings' => [ + // ['source' => 'debug-mode', 'package' => 'laravel/horizon'], + ], + /* |-------------------------------------------------------------------------- | Custom Audits diff --git a/tests/Commands/WardenAuditCommandTest.php b/tests/Commands/WardenAuditCommandTest.php index b8bca5f..485e67d 100644 --- a/tests/Commands/WardenAuditCommandTest.php +++ b/tests/Commands/WardenAuditCommandTest.php @@ -2,12 +2,17 @@ namespace Dgtlss\Warden\Tests\Commands; -use Dgtlss\Warden\Commands\WardenAuditCommand; -use Illuminate\Support\Facades\Artisan; -use Orchestra\Testbench\TestCase; use Dgtlss\Warden\Providers\WardenServiceProvider; +use Dgtlss\Warden\Services\AuditCacheService; use Dgtlss\Warden\Services\AuditExecutor; +use Dgtlss\Warden\Services\Audits\ComposerAuditService; +use Dgtlss\Warden\Services\Audits\DebugModeAuditService; +use Dgtlss\Warden\Services\Audits\EnvAuditService; +use Dgtlss\Warden\Services\Audits\StorageAuditService; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Mail; use Mockery\MockInterface; +use Orchestra\Testbench\TestCase; class WardenAuditCommandTest extends TestCase { @@ -56,4 +61,139 @@ public function testAuditCommandHandlesFindings(): void ->expectsOutputToContain('1 security issue found.') ->assertExitCode(1); } + + public function testAuditCommandIgnoresConfiguredFindingsBeforeNotifications(): void + { + Http::fake(); + Mail::fake(); + + config([ + 'warden.webhook_url' => 'https://example.com/webhook', + 'warden.email_recipients' => '[email protected]', + 'warden.ignore_findings' => [ + ['source' => 'debug-mode', 'package' => 'laravel/horizon'], + ], + ]); + + $findings = [ + [ + 'source' => 'debug-mode', + 'title' => 'Development package detected in production', + 'severity' => 'high', + 'package' => 'laravel/horizon', + ], + ]; + + $this->mock(AuditExecutor::class, function (MockInterface $mock) use ($findings): void { + $mock->shouldReceive('addAudit')->zeroOrMoreTimes(); + $mock->shouldReceive('execute')->once()->andReturn([ + 'debug-mode' => [ + 'success' => true, + 'findings' => $findings, + 'service' => new \stdClass(), + ], + ]); + }); + + $this->artisan('warden:audit') + ->expectsOutputToContain('Warden') + ->expectsOutputToContain('No security issues found.') + ->assertExitCode(0); + + Http::assertNothingSent(); + Mail::assertNothingSent(); + } + + public function testAuditCommandSupportsWildcardIgnoreRulesInJsonOutput(): void + { + config([ + 'warden.ignore_findings' => [ + ['source' => 'debug-mode', 'title' => 'Testing routes*'], + ], + ]); + + $findings = [ + [ + 'source' => 'debug-mode', + 'title' => 'Testing routes are exposed', + 'severity' => 'high', + 'package' => 'routes', + ], + ]; + + $this->mock(AuditExecutor::class, function (MockInterface $mock) use ($findings): void { + $mock->shouldReceive('addAudit')->zeroOrMoreTimes(); + $mock->shouldReceive('execute')->once()->andReturn([ + 'debug-mode' => [ + 'success' => true, + 'findings' => $findings, + 'service' => new \stdClass(), + ], + ]); + }); + + $this->artisan('warden:audit', ['--output' => 'json']) + ->expectsOutputToContain('"vulnerabilities_found": 0') + ->assertExitCode(0); + } + + public function testAuditCommandFiltersCachedFindingsInSequentialMode(): void + { + config([ + 'warden.audits.parallel_execution' => false, + 'warden.ignore_findings' => [ + ['source' => 'debug-mode', 'package' => 'laravel/horizon'], + ], + ]); + + $this->mock(AuditCacheService::class, function (MockInterface $mock): void { + $mock->shouldReceive('hasRecentAudit') + ->times(4) + ->andReturnUsing(fn (string $auditName): bool => $auditName === 'debug-mode'); + + $mock->shouldReceive('getCachedResult') + ->once() + ->with('debug-mode') + ->andReturn([ + 'result' => [ + [ + 'source' => 'debug-mode', + 'title' => 'Development package detected in production', + 'severity' => 'high', + 'package' => 'laravel/horizon', + ], + ], + 'timestamp' => now()->toIso8601String(), + 'cached' => true, + ]); + }); + + $this->mock(ComposerAuditService::class, function (MockInterface $mock): void { + $mock->shouldReceive('getName')->once()->andReturn('composer'); + $mock->shouldReceive('run')->once()->andReturn(true); + $mock->shouldReceive('getFindings')->once()->andReturn([]); + $mock->shouldReceive('getAbandonedPackages')->once()->andReturn([]); + }); + + $this->mock(EnvAuditService::class, function (MockInterface $mock): void { + $mock->shouldReceive('getName')->once()->andReturn('environment'); + $mock->shouldReceive('run')->once()->andReturn(true); + $mock->shouldReceive('getFindings')->once()->andReturn([]); + }); + + $this->mock(StorageAuditService::class, function (MockInterface $mock): void { + $mock->shouldReceive('getName')->once()->andReturn('storage'); + $mock->shouldReceive('run')->once()->andReturn(true); + $mock->shouldReceive('getFindings')->once()->andReturn([]); + }); + + $this->mock(DebugModeAuditService::class, function (MockInterface $mock): void { + $mock->shouldReceive('getName')->once()->andReturn('debug-mode'); + }); + + $this->artisan('warden:audit', ['--no-notify' => true]) + ->expectsOutputToContain('Warden') + ->expectsOutputToContain('No security issues found.') + ->assertExitCode(0); + } } diff --git a/tests/Services/Audits/DebugModeAuditServiceTest.php b/tests/Services/Audits/DebugModeAuditServiceTest.php new file mode 100644 index 0000000..bc6734a --- /dev/null +++ b/tests/Services/Audits/DebugModeAuditServiceTest.php @@ -0,0 +1,205 @@ +originalBasePath = $this->app->basePath(); + $this->unsetCiEnvironmentFlags(); + + config([ + 'app.env' => 'production', + 'app.debug' => false, + 'horizon.enabled' => false, + 'telescope.enabled' => false, + ]); + } + + protected function tearDown(): void + { + if ($this->originalBasePath !== null) { + $this->app->setBasePath($this->originalBasePath); + } + + $this->deleteTemporaryBasePath(); + $this->restoreCiEnvironmentFlags(); + + parent::tearDown(); + } + + public function testHorizonIsNotFlaggedAsADevelopmentPackageInProduction(): void + { + $this->setInstalledPackages([ + ['name' => 'laravel/horizon'], + ]); + + $service = app(DebugModeAuditService::class); + $service->run(); + + $this->assertFalse($this->findingExists( + $service->getFindings(), + 'Development package detected in production', + 'laravel/horizon' + )); + } + + public function testHorizonRoutesAreNotFlaggedAsTestingRoutes(): void + { + $this->setInstalledPackages([]); + Route::get('horizon/dashboard', static fn () => 'ok'); + + $service = app(DebugModeAuditService::class); + $service->run(); + + $this->assertFalse($this->findingExists( + $service->getFindings(), + 'Testing routes are exposed', + 'routes' + )); + } + + public function testTelescopeRoutesAreStillFlaggedAsTestingRoutes(): void + { + $this->setInstalledPackages([]); + Route::get('telescope/dashboard', static fn () => 'ok'); + + $service = app(DebugModeAuditService::class); + $service->run(); + + $this->assertTrue($this->findingExists( + $service->getFindings(), + 'Testing routes are exposed', + 'routes' + )); + } + + public function testDuskRoutesAreStillFlaggedAsTestingRoutes(): void + { + $this->setInstalledPackages([]); + Route::get('_dusk/ping', static fn () => 'ok'); + + $service = app(DebugModeAuditService::class); + $service->run(); + + $this->assertTrue($this->findingExists( + $service->getFindings(), + 'Testing routes are exposed', + 'routes' + )); + } + + /** + * @param array> $packages + */ + private function setInstalledPackages(array $packages): void + { + $this->deleteTemporaryBasePath(); + + $this->temporaryBasePath = sys_get_temp_dir() . '/warden-debug-mode-' . bin2hex(random_bytes(8)); + $installedPath = $this->temporaryBasePath . '/vendor/composer'; + + mkdir($installedPath, 0777, true); + file_put_contents( + $installedPath . '/installed.json', + json_encode(['packages' => $packages], JSON_THROW_ON_ERROR) + ); + + $this->app->setBasePath($this->temporaryBasePath); + } + + /** + * @param array> $findings + */ + private function findingExists(array $findings, string $title, string $package): bool + { + foreach ($findings as $finding) { + if (($finding['title'] ?? null) === $title && ($finding['package'] ?? null) === $package) { + return true; + } + } + + return false; + } + + private function unsetCiEnvironmentFlags(): void + { + foreach ($this->ciEnvironmentFlags() as $flag) { + $this->originalCiEnvironmentValues[$flag] = getenv($flag); + putenv($flag); + } + } + + private function restoreCiEnvironmentFlags(): void + { + foreach ($this->originalCiEnvironmentValues as $flag => $value) { + if ($value === false) { + putenv($flag); + continue; + } + + putenv($flag . '=' . $value); + } + } + + /** + * @return array + */ + private function ciEnvironmentFlags(): array + { + return [ + 'CI', + 'CONTINUOUS_INTEGRATION', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'JENKINS_URL', + 'TRAVIS', + 'CIRCLECI', + ]; + } + + private function deleteTemporaryBasePath(): void + { + if ($this->temporaryBasePath === null || !is_dir($this->temporaryBasePath)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->temporaryBasePath, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + continue; + } + + unlink($item->getPathname()); + } + + rmdir($this->temporaryBasePath); + $this->temporaryBasePath = null; + } +}