Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"notifications",
"CVE"
],
"version": "1.5.1",
"version": "1.5.3",
"license": "MIT",
"autoload": {
"psr-4": {
Expand Down
15 changes: 14 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

</div>
</div>
71 changes: 71 additions & 0 deletions src/Commands/WardenAuditCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -232,6 +234,75 @@ protected function processResults(array $allFindings, array $abandonedPackages,
return $hasFailures ? 2 : 0;
}

/**
* Remove findings that match configured ignore rules.
*
* @param array<array<string, mixed>> $findings
* @return array<array<string, mixed>>
*/
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<string, mixed> $finding
* @param array<mixed> $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<string, mixed> $finding
* @param array<mixed> $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.
*/
Expand Down
2 changes: 0 additions & 2 deletions src/Services/Audits/DebugModeAuditService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ class DebugModeAuditService extends AbstractAuditService
private array $devPackages = [
'barryvdh/laravel-debugbar',
'laravel/telescope',
'laravel/horizon',
'beyondcode/laravel-dump-server',
'laravel/dusk',
];
Expand Down Expand Up @@ -124,7 +123,6 @@ private function hasExposedTestingRoutes(): bool
// Check other testing routes that should never be exposed in production
$testingRoutes = [
'telescope',
'horizon',
'_dusk',
];

Expand Down
19 changes: 19 additions & 0 deletions src/config/warden.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 143 additions & 3 deletions tests/Commands/WardenAuditCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
}
}
Loading
Loading