From 87f3682ac81aff92c50465f53242a56d892c9985 Mon Sep 17 00:00:00 2001 From: Nathan Langer <32520453+dgtlss@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:07:35 +0100 Subject: [PATCH 1/4] Refactor Warden for CI-first 2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the package around the new CI-focused audit flow: add typed audit/report value objects, replace the old cache/schedule/history model, introduce baseline and new reporter commands, tighten notifications and suppressions, and update the package metadata/docs for Laravel 12–13 support. --- .github/workflows/tests.yml | 58 +- AGENTS.md | 6 +- UPGRADE.md | 44 + composer.json | 30 +- composer.lock | 1638 +++++++++-------- contributing.md | 8 +- phpstan.neon | 15 +- readme.md | 661 ++----- resources/schemas/warden-report-2.0.0.json | 136 ++ src/Commands/WardenAuditCommand.php | 997 ++-------- src/Commands/WardenBaselineCommand.php | 161 ++ src/Commands/WardenScheduleCommand.php | 169 -- src/Commands/WardenSyntaxCommand.php | 60 +- src/Contracts/AuditServiceInterface.php | 12 +- src/Contracts/CustomAudit.php | 42 +- src/Contracts/NotificationChannel.php | 8 +- src/Contracts/ReportFormatter.php | 12 + src/Enums/Severity.php | 28 + src/Examples/DatabasePasswordAudit.php | 184 +- src/Notifications/Channels/DiscordChannel.php | 15 +- src/Notifications/Channels/EmailChannel.php | 10 +- src/Notifications/Channels/SlackChannel.php | 22 +- src/Notifications/Channels/TeamsChannel.php | 12 +- .../Concerns/HasSeverityHelpers.php | 9 + src/Providers/WardenServiceProvider.php | 73 +- src/Reporters/ConsoleReporter.php | 67 + src/Reporters/GitHubReporter.php | 49 + src/Reporters/GitLabReporter.php | 112 ++ src/Reporters/JsonReporter.php | 18 + src/Reporters/JunitReporter.php | 65 + src/Reporters/SarifReporter.php | 73 + src/Services/AuditCacheService.php | 123 -- src/Services/AuditExecutor.php | 113 +- src/Services/AuditRunner.php | 130 ++ src/Services/Audits/AbstractAuditService.php | 32 - src/Services/Audits/ComposerAuditService.php | 229 ++- src/Services/Audits/ConfigAuditService.php | 49 - src/Services/Audits/DebugModeAuditService.php | 186 -- src/Services/Audits/EnvAuditService.php | 71 - .../Audits/LaravelConfigAuditService.php | 182 ++ src/Services/Audits/NpmAuditService.php | 202 +- src/Services/Audits/PhpSyntaxAuditService.php | 132 +- src/Services/Audits/StorageAuditService.php | 60 +- .../Audits/SupplyChainAuditService.php | 231 +++ src/Services/CustomAuditWrapper.php | 33 +- src/Services/NotificationDispatcher.php | 55 + src/Services/ReportFormatterFactory.php | 30 + src/Services/SuppressionService.php | 176 ++ src/ValueObjects/AuditContext.php | 46 + src/ValueObjects/AuditError.php | 23 + src/ValueObjects/AuditReport.php | 130 ++ src/ValueObjects/AuditResult.php | 55 + src/ValueObjects/Finding.php | 69 + src/config/warden.php | 173 +- ...0000_create_warden_audit_history_table.php | 47 - tests/Commands/WardenAuditCommandTest.php | 232 +-- tests/Commands/WardenBaselineCommandTest.php | 45 + tests/Commands/WardenSyntaxCommandTest.php | 59 +- tests/Reporters/ReporterTest.php | 91 + tests/Services/AuditExecutorTest.php | 48 + .../Audits/DebugModeAuditServiceTest.php | 205 --- .../Audits/DependencyAuditServiceTest.php | 106 ++ .../Audits/LaravelConfigAuditServiceTest.php | 63 + .../Audits/SupplyChainAuditServiceTest.php | 78 + tests/Services/NotificationDispatcherTest.php | 33 + tests/Services/SuppressionServiceTest.php | 86 + tests/TestCase.php | 16 + tests/ValueObjects/AuditReportTest.php | 58 + 68 files changed, 4388 insertions(+), 4103 deletions(-) create mode 100644 UPGRADE.md create mode 100644 resources/schemas/warden-report-2.0.0.json create mode 100644 src/Commands/WardenBaselineCommand.php delete mode 100644 src/Commands/WardenScheduleCommand.php create mode 100644 src/Contracts/ReportFormatter.php create mode 100644 src/Enums/Severity.php create mode 100644 src/Reporters/ConsoleReporter.php create mode 100644 src/Reporters/GitHubReporter.php create mode 100644 src/Reporters/GitLabReporter.php create mode 100644 src/Reporters/JsonReporter.php create mode 100644 src/Reporters/JunitReporter.php create mode 100644 src/Reporters/SarifReporter.php delete mode 100644 src/Services/AuditCacheService.php create mode 100644 src/Services/AuditRunner.php delete mode 100644 src/Services/Audits/AbstractAuditService.php delete mode 100644 src/Services/Audits/ConfigAuditService.php delete mode 100644 src/Services/Audits/DebugModeAuditService.php delete mode 100644 src/Services/Audits/EnvAuditService.php create mode 100644 src/Services/Audits/LaravelConfigAuditService.php create mode 100644 src/Services/Audits/SupplyChainAuditService.php create mode 100644 src/Services/NotificationDispatcher.php create mode 100644 src/Services/ReportFormatterFactory.php create mode 100644 src/Services/SuppressionService.php create mode 100644 src/ValueObjects/AuditContext.php create mode 100644 src/ValueObjects/AuditError.php create mode 100644 src/ValueObjects/AuditReport.php create mode 100644 src/ValueObjects/AuditResult.php create mode 100644 src/ValueObjects/Finding.php delete mode 100644 src/database/migrations/2024_01_01_000000_create_warden_audit_history_table.php create mode 100644 tests/Commands/WardenBaselineCommandTest.php create mode 100644 tests/Reporters/ReporterTest.php create mode 100644 tests/Services/AuditExecutorTest.php delete mode 100644 tests/Services/Audits/DebugModeAuditServiceTest.php create mode 100644 tests/Services/Audits/DependencyAuditServiceTest.php create mode 100644 tests/Services/Audits/LaravelConfigAuditServiceTest.php create mode 100644 tests/Services/Audits/SupplyChainAuditServiceTest.php create mode 100644 tests/Services/NotificationDispatcherTest.php create mode 100644 tests/Services/SuppressionServiceTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/ValueObjects/AuditReportTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea3cd94..1935ff0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,43 +2,67 @@ name: Tests on: push: - branches: [ main, master ] + branches: [main, master] pull_request: - branches: [ main, master ] + branches: [main, master] jobs: - tests: + test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: ['8.4'] + include: + - { php: '8.3', laravel: '12', testbench: '^10.9' } + - { php: '8.4', laravel: '12', testbench: '^10.9' } + - { php: '8.5', laravel: '12', testbench: '^10.9' } + - { php: '8.3', laravel: '13', testbench: '^11.0' } + - { php: '8.4', laravel: '13', testbench: '^11.0' } + - { php: '8.5', laravel: '13', testbench: '^11.0' } - name: PHP ${{ matrix.php }} + name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up PHP - uses: shivammathur/setup-php@v2 + - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} tools: composer coverage: none - - name: Cache Composer dependencies - uses: actions/cache@v4 + - uses: actions/cache@v4 with: path: ~/.composer/cache - key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} - restore-keys: composer-${{ matrix.php }}- + key: composer-${{ matrix.php }}-laravel-${{ matrix.laravel }}-${{ hashFiles('composer.json') }} + + - name: Select framework version + run: composer require --dev orchestra/testbench:${{ matrix.testbench }} --no-update --no-interaction - name: Install dependencies - run: composer install --no-interaction --no-progress + run: composer update --with-all-dependencies --no-interaction --no-progress - - name: Run PHPStan - run: vendor/bin/phpstan analyse --memory-limit=2G + - name: Validate package metadata + run: composer validate --strict - name: Run tests - run: vendor/bin/phpunit tests/ \ No newline at end of file + run: composer test + + - name: Run PHPStan + run: composer phpstan + + - name: Run Rector dry-run + run: composer rector + + lowest-dependencies: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer + coverage: none + - run: composer require --dev orchestra/testbench:^10.9 --no-update --no-interaction + - run: composer update --prefer-lowest --with-all-dependencies --no-interaction --no-progress + - run: composer test diff --git a/AGENTS.md b/AGENTS.md index 9e1dcb1..a8e3cbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,13 +17,13 @@ Warden is a Laravel security audit **package** (library), not a standalone appli |---|---| | Install dependencies | `composer install --no-interaction` | | Static analysis (lint) | `composer phpstan` or `vendor/bin/phpstan analyse --memory-limit=2G` | -| Run tests | `vendor/bin/phpunit tests/` | +| Run tests | `composer test` or `vendor/bin/phpunit tests/` | | Code quality (dry-run) | `vendor/bin/rector process --dry-run` | | Run package commands | `vendor/bin/testbench warden:audit` / `vendor/bin/testbench warden:syntax` | ### Gotchas -- **Tests target Laravel 12+**: The package supports Laravel 7–13, but `orchestra/testbench ^10.9` pins the test suite to Laravel 12+; CI runs against the latest supported Laravel. +- **Supported framework matrix**: Warden 2 supports Laravel 12–13 on PHP 8.3–8.5. CI covers every declared PHP/Laravel combination using Testbench 10 and 11. - **PHPStan with full framework**: When `orchestra/testbench` is installed (which brings in `laravel/framework`), PHPStan may report additional errors due to full type information replacing stubs. The CI workflow installs testbench, so check PHPStan passes with `composer phpstan`. -- **testbench.yaml**: Registers `WardenServiceProvider` so `vendor/bin/testbench` can run the package's artisan commands (e.g., `warden:audit`). +- **testbench.yaml**: Registers `WardenServiceProvider` so `vendor/bin/testbench` can run package commands. Testbench uses its fixture application's base path; service fixtures should set an explicit temporary base path when testing manifests and lockfiles. - **Test app**: To test the package inside a real Laravel app, create one in `/tmp`: `cd /tmp && composer create-project laravel/laravel warden-test-app` then `cd warden-test-app && composer config repositories.warden path /workspace && composer require dgtlss/warden:* --dev`. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..e7f3b52 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,44 @@ +# Upgrading from Warden 1.x to 2.0 + +Warden 2.0 is a CI-first major release. Review the following changes before updating pipeline configuration. + +## Supported platforms + +- PHP 8.3–8.5 +- Laravel 12–13 +- Install with `composer require --dev dgtlss/warden` + +Run Warden before the production artifact is rebuilt with `composer install --no-dev`. + +## Command changes + +| Warden 1.x | Warden 2.0 | +|---|---| +| `--severity=high` | `--fail-on=high` | +| `--output=json` | `--format=json` | +| Shell redirection | `--output-file=report.json` | +| `--npm` | Automatic when `package-lock.json` exists | +| `--ignore-abandoned` | Reviewed suppression or a higher `--fail-on` threshold | +| `--force` | Removed; Warden no longer caches audit results | +| `--no-notify` | Notifications are off by default; use `--notify` to enable | +| `warden:schedule` | Removed; schedule the CI pipeline instead | +| `--output=jenkins` | `--format=junit` | + +The exit code contract is now strict: findings return `1`, while an incomplete or invalid audit returns `2` even when `--fail-on=never` is used. + +## Configuration changes + +- Remove `cache`, `schedule`, `history`, `sensitive_keys`, `webhook_url`, and top-level `email_recipients` entries. +- Replace wildcard `ignore_findings` rules with entries containing `id`, `reason`, and `expires_at`; add `fingerprint` when only one occurrence should be accepted. +- Replace custom audit implementations with the typed `run(AuditContext): AuditResult` contract. +- Configure notifications only under `warden.notifications`; the legacy webhook path is no longer dispatched. + +Republish the configuration or merge the new defaults manually: + +```bash +php artisan vendor:publish --tag=warden-config --force +``` + +## Removed runtime features + +The production scheduler and audit-history migration were removed because a development dependency is not present after a production `--no-dev` install. Use CI scheduling and persist JSON, SARIF, GitLab, or JUnit artifacts in the CI platform instead. diff --git a/composer.json b/composer.json index 9865160..2bf2085 100644 --- a/composer.json +++ b/composer.json @@ -1,36 +1,43 @@ { "name": "dgtlss/warden", - "description": "A Laravel package that proactively monitors your dependencies for security vulnerabilities by running automated composer audits and sending notifications via webhooks and email", + "description": "A deterministic Laravel security gate for CI and deployment pipelines", "keywords": [ "laravel", "composer", "security", "vulnerabilities", "audits", + "ci", + "supply-chain", "notifications", "CVE" ], - "version": "1.5.3", "license": "MIT", "autoload": { "psr-4": { "Dgtlss\\Warden\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Dgtlss\\Warden\\Tests\\": "tests/" + } + }, "authors": [ { "name": "Nathan Langer", "email": "nathanlanger@googlemail.com" } ], - "minimum-stability": "dev", - "prefer-stable": true, "require": { - "php": ">=8.3", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/cache": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=8.3 <8.6", + "illuminate/console": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/encryption": "^12.0|^13.0", + "illuminate/http": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", "guzzlehttp/guzzle": "^7.0", - "laravel/prompts": "^0.3" + "symfony/process": "^7.2|^8.0" }, "extra": { "laravel": { @@ -43,9 +50,12 @@ "phpstan/phpstan": "^2.1", "rector/rector": "^2.2", "larastan/larastan": "^3.0", - "orchestra/testbench": "^10.9|^11.0" + "orchestra/testbench": "^10.9|^11.0", + "phpunit/phpunit": "^11.5|^12.0" }, "scripts": { - "phpstan": "vendor/bin/phpstan analyse --memory-limit=2G" + "phpstan": "vendor/bin/phpstan analyse --memory-limit=2G", + "test": "vendor/bin/phpunit tests/", + "rector": "vendor/bin/rector process --dry-run --no-progress-bar" } } diff --git a/composer.lock b/composer.lock index 8c4d070..51fe0c7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,26 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b3cc1b4e42aa5fd1ff5221dbc8eb4e1e", + "content-hash": "db2350f3b26bb90f021689e584848eb1", "packages": [ { "name": "brick/math", - "version": "0.14.8", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "2.1.22", "phpunit/phpunit": "^11.5" }, @@ -56,7 +55,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.8" + "source": "https://github.com/brick/math/tree/0.18.0" }, "funding": [ { @@ -64,7 +63,7 @@ "type": "github" } ], - "time": "2026-02-10T14:33:43+00:00" + "time": "2026-06-14T18:21:03+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -643,25 +642,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.14.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "aef242412e13128b5049864867bb49fc37dd39de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de", + "reference": "aef242412e13128b5049864867bb49fc37dd39de", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.12.4", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -669,9 +669,10 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -749,7 +750,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.14.0" }, "funding": [ { @@ -765,28 +766,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-07-08T22:54:09+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -832,7 +834,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -848,27 +850,29 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.12.4", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -876,8 +880,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -948,7 +953,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.12.4" }, "funding": [ { @@ -964,29 +969,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-07-08T15:56:20+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.5", + "version": "v1.0.9", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d7580af6d3f8384325d9cd3e99b21c3ed1848176", + "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1034,7 +1039,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.9" }, "funding": [ { @@ -1050,28 +1055,28 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:27:06+00:00" + "time": "2026-07-08T16:19:22+00:00" }, { "name": "laravel/framework", - "version": "v12.53.0", + "version": "v13.19.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f" + "reference": "514502b38e11bd676ecf83b271c9452cc7500f16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f", - "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f", + "url": "https://api.github.com/repos/laravel/framework/zipball/514502b38e11bd676ecf83b271c9452cc7500f16", + "reference": "514502b38e11bd676ecf83b271c9452cc7500f16", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", - "egulias/email-validator": "^3.2.1|^4.0", + "egulias/email-validator": "^4.0", "ext-ctype": "*", "ext-filter": "*", "ext-hash": "*", @@ -1081,35 +1086,36 @@ "ext-tokenizer": "*", "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/promises": "^2.0.3", "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", - "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.7", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", "monolog/monolog": "^3.0", "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", - "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1", - "psr/log": "^1.0|^2.0|^3.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^7.2.0", - "symfony/error-handler": "^7.2.0", - "symfony/finder": "^7.2.0", - "symfony/http-foundation": "^7.2.0", - "symfony/http-kernel": "^7.2.0", - "symfony/mailer": "^7.2.0", - "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.33", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", - "symfony/process": "^7.2.0", - "symfony/routing": "^7.2.0", - "symfony/uid": "^7.2.0", - "symfony/var-dumper": "^7.2.0", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" @@ -1118,9 +1124,9 @@ "tightenco/collect": "<5.5.33" }, "provide": { - "psr/container-implementation": "1.1|2.0", - "psr/log-implementation": "1.0|2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0" + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" }, "replace": { "illuminate/auth": "self.version", @@ -1166,8 +1172,7 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/promises": "^2.0.3", - "guzzlehttp/psr7": "^2.4", + "guzzlehttp/psr7": "^2.9", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -1176,22 +1181,23 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.9.0", - "pda/pheanstalk": "^5.0.6|^7.0.0", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3|^3.0", - "resend/resend-php": "^0.10.0|^1.0", - "symfony/cache": "^7.2.0", - "symfony/http-client": "^7.2.0", - "symfony/psr-http-message-bridge": "^7.2.0", - "symfony/translation": "^7.2.0" + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", - "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -1200,7 +1206,7 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", "laravel/tinker": "Required to use the tinker console command (^2.0).", @@ -1210,24 +1216,25 @@ "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", "mockery/mockery": "Required to use mocking (^1.6).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", - "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "12.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { @@ -1272,20 +1279,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-24T14:35:15+00:00" + "time": "2026-07-07T14:13:33+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.13", + "version": "v0.3.21", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", "shasum": "" }, "require": { @@ -1329,22 +1336,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.13" + "source": "https://github.com/laravel/prompts/tree/v0.3.21" }, - "time": "2026-02-06T12:17:10+00:00" + "time": "2026-06-26T00:11:25+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.13", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", "shasum": "" }, "require": { @@ -1392,20 +1399,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-16T14:03:50+00:00" }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -1430,9 +1437,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -1499,7 +1506,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -1585,16 +1592,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -1662,9 +1669,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-local", @@ -1717,16 +1724,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -1736,7 +1743,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -1757,7 +1764,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -1769,24 +1776,24 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -1859,7 +1866,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -1867,20 +1874,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -1943,7 +1950,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -1951,7 +1958,7 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "monolog/monolog", @@ -2058,16 +2065,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.1", + "version": "3.13.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", "shasum": "" }, "require": { @@ -2159,7 +2166,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:26:29+00:00" + "time": "2026-07-09T18:23:49+00:00" }, { "name": "nette/schema", @@ -2230,16 +2237,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -2315,9 +2322,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nunomaduro/termwind", @@ -3015,20 +3022,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -3087,26 +3094,26 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/clock": "^1.0" }, "provide": { @@ -3146,7 +3153,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.1.0" }, "funding": [ { @@ -3166,51 +3173,53 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/console", - "version": "v7.4.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d643a93b47398599124022eb24d97c153c12f27" + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", - "reference": "6d643a93b47398599124022eb24d97c153c12f27", + "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" + "symfony/string": "^7.4.6|^8.0.6" }, "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3244,7 +3253,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.6" + "source": "https://github.com/symfony/console/tree/v8.1.1" }, "funding": [ { @@ -3264,24 +3273,24 @@ "type": "tidelift" } ], - "time": "2026-02-25T17:02:47+00:00" + "time": "2026-06-16T12:55:20+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262" + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -3313,7 +3322,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.6" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, "funding": [ { @@ -3333,20 +3342,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3359,7 +3368,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3384,7 +3393,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3395,42 +3404,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^7.4|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" + "symfony/deprecation-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -3462,7 +3474,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v8.1.0" }, "funding": [ { @@ -3482,24 +3494,25 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -3547,7 +3560,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" }, "funding": [ { @@ -3567,20 +3580,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-06-09T12:28:30+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -3594,7 +3607,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3627,7 +3640,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -3638,32 +3651,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3691,7 +3708,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v8.1.1" }, "funding": [ { @@ -3711,41 +3728,40 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-06-27T09:05:56+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" + "reference": "6a168c8fcee806b57ac020244da14293d1f9a883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883", + "reference": "6a168c8fcee806b57ac020244da14293d1f9a883", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "doctrine/dbal": "<4.3" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3773,7 +3789,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.1" }, "funding": [ { @@ -3793,78 +3809,68 @@ "type": "tidelift" } ], - "time": "2026-02-21T16:25:55+00:00" + "time": "2026-06-12T08:43:41+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" + "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2", + "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", + "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" }, "type": "library", "autoload": { @@ -3892,7 +3898,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.6" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.1" }, "funding": [ { @@ -3912,43 +3918,39 @@ "type": "tidelift" } ], - "time": "2026-02-26T08:30:57+00:00" + "time": "2026-06-27T09:27:36+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/4fa583a7377f28d54e4de442fba76375b2e20a12", + "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", + "php": ">=8.4.1", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" + "symfony/http-client-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3976,7 +3978,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v8.1.1" }, "funding": [ { @@ -3996,44 +3998,41 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-06-16T12:55:20+00:00" }, { "name": "symfony/mime", - "version": "v7.4.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4065,7 +4064,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.6" + "source": "https://github.com/symfony/mime/tree/v8.1.0" }, "funding": [ { @@ -4085,20 +4084,20 @@ "type": "tidelift" } ], - "time": "2026-02-05T15:57:06+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4148,7 +4147,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4168,20 +4167,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -4230,7 +4229,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -4250,20 +4249,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -4317,7 +4316,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -4337,20 +4336,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -4402,7 +4401,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -4422,20 +4421,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -4487,7 +4486,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -4507,20 +4506,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -4571,7 +4570,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4591,20 +4590,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -4622,7 +4621,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -4642,7 +4641,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -4651,7 +4650,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -4671,20 +4670,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "name": "symfony/polyfill-php85", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -4702,7 +4701,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -4722,7 +4721,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -4731,7 +4730,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -4751,20 +4750,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "name": "symfony/polyfill-php86", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", "shasum": "" }, "require": { @@ -4782,7 +4781,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Polyfill\\Php86\\": "" }, "classmap": [ "Resources/stubs" @@ -4802,7 +4801,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -4811,7 +4810,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0" }, "funding": [ { @@ -4831,20 +4830,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-05-25T11:52:35+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -4894,7 +4893,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -4914,24 +4913,24 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -4959,7 +4958,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -4979,38 +4978,33 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" - }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5044,7 +5038,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v8.1.0" }, "funding": [ { @@ -5064,20 +5058,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -5095,7 +5089,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5131,7 +5125,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -5151,24 +5145,24 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -5221,7 +5215,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -5241,24 +5235,24 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0", "symfony/translation-contracts": "^3.6.1" }, @@ -5314,7 +5308,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.1.1" }, "funding": [ { @@ -5334,20 +5328,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-06-06T11:11:44+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -5360,7 +5354,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5396,7 +5390,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -5416,28 +5410,28 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5474,7 +5468,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v8.1.0" }, "funding": [ { @@ -5494,35 +5488,35 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "40096a2515a979f3125c5c928603995b8664c62a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a", + "reference": "40096a2515a979f3125c5c928603995b8664c62a", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -5561,7 +5555,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.1" }, "funding": [ { @@ -5581,7 +5575,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-06-09T10:54:51+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -5640,16 +5634,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -5708,7 +5702,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -5720,27 +5714,27 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -5770,7 +5764,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -5794,7 +5788,7 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" } ], "packages-dev": [ @@ -6062,16 +6056,16 @@ }, { "name": "iamcal/sql-parser", - "version": "v0.6", + "version": "v0.7", "source": { "type": "git", "url": "https://github.com/iamcal/SQLParser.git", - "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", - "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", "shasum": "" }, "require-dev": { @@ -6097,46 +6091,46 @@ "description": "MySQL schema parser", "support": { "issues": "https://github.com/iamcal/SQLParser/issues", - "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" }, - "time": "2025-03-17T16:59:46+00:00" + "time": "2026-01-28T22:20:33+00:00" }, { "name": "larastan/larastan", - "version": "v3.8.1", + "version": "v3.10.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9" + "reference": "2970f83398154178a739609c244577267c7ee8eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/ff3725291bc4c7e6032b5a54776e3e5104c86db9", - "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", "shasum": "" }, "require": { "ext-json": "*", - "iamcal/sql-parser": "^0.6.0", - "illuminate/console": "^11.44.2 || ^12.4.1", - "illuminate/container": "^11.44.2 || ^12.4.1", - "illuminate/contracts": "^11.44.2 || ^12.4.1", - "illuminate/database": "^11.44.2 || ^12.4.1", - "illuminate/http": "^11.44.2 || ^12.4.1", - "illuminate/pipeline": "^11.44.2 || ^12.4.1", - "illuminate/support": "^11.44.2 || ^12.4.1", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", "php": "^8.2", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.2.0" }, "require-dev": { - "doctrine/coding-standard": "^13", - "laravel/framework": "^11.44.2 || ^12.7.2", + "doctrine/coding-standard": "^14", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", "mockery/mockery": "^1.6.12", "nikic/php-parser": "^5.4", - "orchestra/canvas": "^v9.2.2 || ^10.0.1", - "orchestra/testbench-core": "^9.12.0 || ^10.1", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15" + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", @@ -6181,7 +6175,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.8.1" + "source": "https://github.com/larastan/larastan/tree/v3.10.0" }, "funding": [ { @@ -6189,20 +6183,20 @@ "type": "github" } ], - "time": "2025-12-11T16:37:35+00:00" + "time": "2026-05-28T08:00:58+00:00" }, { "name": "laravel/pail", - "version": "v1.2.6", + "version": "v1.2.7", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", "shasum": "" }, "require": { @@ -6269,37 +6263,37 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2026-02-09T13:44:54+00:00" + "time": "2026-05-20T22:24:57+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.1", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + "reference": "4faba77764bd33411735936acdf30446d058c78b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + "phpunit/phpunit": "^10.5|^11.5" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." }, "type": "library", "extra": { @@ -6307,6 +6301,9 @@ "providers": [ "Laravel\\Tinker\\TinkerServiceProvider" ] + }, + "branch-alias": { + "dev-master": "3.x-dev" } }, "autoload": { @@ -6333,9 +6330,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.1" + "source": "https://github.com/laravel/tinker/tree/v3.0.2" }, - "time": "2026-02-06T14:12:35+00:00" + "time": "2026-03-17T14:54:13+00:00" }, { "name": "mockery/mockery", @@ -6482,20 +6479,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -6534,29 +6530,29 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -6564,12 +6560,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -6632,46 +6628,41 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "orchestra/canvas", - "version": "v10.1.1", + "version": "v11.0.1", "source": { "type": "git", "url": "https://github.com/orchestral/canvas.git", - "reference": "6e63f56acd46b0ee842e922d0ebb18af8f7a60f6" + "reference": "d240410f4cd89b380d7d89b5bbaf60c32f4fb691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/canvas/zipball/6e63f56acd46b0ee842e922d0ebb18af8f7a60f6", - "reference": "6e63f56acd46b0ee842e922d0ebb18af8f7a60f6", + "url": "https://api.github.com/repos/orchestral/canvas/zipball/d240410f4cd89b380d7d89b5bbaf60c32f4fb691", + "reference": "d240410f4cd89b380d7d89b5bbaf60c32f4fb691", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "composer/semver": "^3.0", - "illuminate/console": "^12.40.0", - "illuminate/database": "^12.40.0", - "illuminate/filesystem": "^12.40.0", - "illuminate/support": "^12.40.0", - "orchestra/canvas-core": "^10.1.2", - "orchestra/sidekick": "^1.2.7", - "orchestra/testbench-core": "^10.8.0", - "php": "^8.2", - "symfony/polyfill-php83": "^1.33", - "symfony/yaml": "^7.2.0" - }, - "conflict": { - "laravel/framework": "<12.40.0|>=13.0.0" + "illuminate/console": "^13.0.0", + "illuminate/database": "^13.0.0", + "illuminate/filesystem": "^13.0.0", + "illuminate/support": "^13.0.0", + "orchestra/canvas-core": "^11.0.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^11.0.0", + "php": "^8.3", + "symfony/yaml": "^7.4.0|^8.0.0" }, "require-dev": { - "laravel/framework": "^12.40.0", + "laravel/framework": "^13.0.0", "laravel/pint": "^1.24", - "mockery/mockery": "^1.6.12", + "mockery/mockery": "^1.6.10", "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^11.5.18|^12.0", - "spatie/laravel-ray": "^1.42.0" + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0" }, "bin": [ "canvas" @@ -6706,42 +6697,40 @@ "description": "Code Generators for Laravel Applications and Packages", "support": { "issues": "https://github.com/orchestral/canvas/issues", - "source": "https://github.com/orchestral/canvas/tree/v10.1.1" + "source": "https://github.com/orchestral/canvas/tree/v11.0.1" }, - "time": "2025-11-24T04:53:34+00:00" + "time": "2026-03-18T22:46:12+00:00" }, { "name": "orchestra/canvas-core", - "version": "v10.1.2", + "version": "v11.0.0", "source": { "type": "git", "url": "https://github.com/orchestral/canvas-core.git", - "reference": "af1ac73bb0e4f5a65eeb3aadc1030983c6ea0aea" + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/af1ac73bb0e4f5a65eeb3aadc1030983c6ea0aea", - "reference": "af1ac73bb0e4f5a65eeb3aadc1030983c6ea0aea", + "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/88d091ff989748e2ca447bca0cd06ab14671ba82", + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "composer/semver": "^3.0", - "illuminate/console": "^12.40.0", - "illuminate/support": "^12.40.0", - "orchestra/sidekick": "^1.2.0", - "php": "^8.2", - "symfony/polyfill-php83": "^1.33" + "illuminate/console": "^13.0", + "illuminate/support": "^13.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.3" }, "require-dev": { - "laravel/framework": "^12.40.0", + "laravel/framework": "^13.0", "laravel/pint": "^1.24", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.8.0", - "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^11.5.12|^12.0.1", - "spatie/laravel-ray": "^1.40.2", - "symfony/yaml": "^7.2" + "orchestra/testbench-core": "^11.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "extra": { @@ -6773,9 +6762,9 @@ "description": "Code Generators Builder for Laravel Applications and Packages", "support": { "issues": "https://github.com/orchestral/canvas/issues", - "source": "https://github.com/orchestral/canvas-core/tree/v10.1.2" + "source": "https://github.com/orchestral/canvas-core/tree/v11.0.0" }, - "time": "2025-11-24T04:41:15+00:00" + "time": "2026-03-16T15:10:50+00:00" }, { "name": "orchestra/sidekick", @@ -6838,29 +6827,29 @@ }, { "name": "orchestra/testbench", - "version": "v10.9.0", + "version": "v11.1.0", "source": { "type": "git", "url": "https://github.com/orchestral/testbench.git", - "reference": "040a37b60e1a9d7ae10b496407b6c3bb63b47038" + "reference": "997f33e5200c7e8db4756b35a9deb3f5f3086759" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench/zipball/040a37b60e1a9d7ae10b496407b6c3bb63b47038", - "reference": "040a37b60e1a9d7ae10b496407b6c3bb63b47038", + "url": "https://api.github.com/repos/orchestral/testbench/zipball/997f33e5200c7e8db4756b35a9deb3f5f3086759", + "reference": "997f33e5200c7e8db4756b35a9deb3f5f3086759", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "fakerphp/faker": "^1.23", - "laravel/framework": "^12.40.0", + "laravel/framework": "^13.1.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.9.0", - "orchestra/workbench": "^10.0.8", - "php": "^8.2", - "phpunit/phpunit": "^11.5.3|^12.0.1", - "symfony/process": "^7.2", - "symfony/yaml": "^7.2", + "orchestra/testbench-core": "^11.2.0", + "orchestra/workbench": "^11.0.1", + "php": "^8.3", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4|^8.0", "vlucas/phpdotenv": "^5.6.1" }, "type": "library", @@ -6887,62 +6876,62 @@ ], "support": { "issues": "https://github.com/orchestral/testbench/issues", - "source": "https://github.com/orchestral/testbench/tree/v10.9.0" + "source": "https://github.com/orchestral/testbench/tree/v11.1.0" }, - "time": "2026-01-14T03:26:57+00:00" + "time": "2026-04-09T05:11:06+00:00" }, { "name": "orchestra/testbench-core", - "version": "v10.9.0", + "version": "v11.3.5", "source": { "type": "git", "url": "https://github.com/orchestral/testbench-core.git", - "reference": "754d2b71601822d1f57f28119e4dea27ed1a5205" + "reference": "a6773cd554177edb800f1e610d128b5acf813783" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/754d2b71601822d1f57f28119e4dea27ed1a5205", - "reference": "754d2b71601822d1f57f28119e4dea27ed1a5205", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/a6773cd554177edb800f1e610d128b5acf813783", + "reference": "a6773cd554177edb800f1e610d128b5acf813783", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "orchestra/sidekick": "~1.1.23|~1.2.20", - "php": "^8.2", + "php": "^8.3", "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-php83": "^1.33" + "symfony/polyfill-php84": "^1.34.0" }, "conflict": { "brianium/paratest": "<7.3.0|>=8.0.0", - "laravel/framework": "<12.40.0|>=13.0.0", - "laravel/serializable-closure": "<1.3.0|>=2.0.0 <2.0.3|>=3.0.0", - "nunomaduro/collision": "<8.0.0|>=9.0.0", - "phpunit/phpunit": "<10.5.35|>=11.0.0 <11.5.3|12.0.0|>=12.6.0" + "laravel/framework": "<13.10.0|>=14.0.0", + "laravel/serializable-closure": ">=2.0.0 <2.0.10|>=3.0.0", + "nunomaduro/collision": "<8.9.0|>=9.0.0", + "phpunit/phpunit": "<11.5.50|>=12.0.0 <12.5.8|>=13.3.0" }, "require-dev": { "fakerphp/faker": "^1.24", - "laravel/framework": "^12.40.0", + "laravel/framework": "^13.10.0", "laravel/pint": "^1.24", - "laravel/serializable-closure": "^1.3|^2.0.4", + "laravel/serializable-closure": "^2.0.10", "mockery/mockery": "^1.6.10", - "phpstan/phpstan": "^2.1.33", - "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "spatie/laravel-ray": "^1.42.0", - "symfony/process": "^7.2.0", - "symfony/yaml": "^7.2.0", + "phpstan/phpstan": "^2.1.38", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4.0|^8.0.0", "vlucas/phpdotenv": "^5.6.1" }, "suggest": { "brianium/paratest": "Allow using parallel testing (^7.3).", "ext-pcntl": "Required to use all features of the console signal trapping.", "fakerphp/faker": "Allow using Faker for testing (^1.23).", - "laravel/framework": "Required for testing (^12.40.0).", + "laravel/framework": "Required for testing (^13.9.0).", "mockery/mockery": "Allow using Mockery for testing (^1.6).", - "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.0).", - "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^10.0).", - "phpunit/phpunit": "Allow using PHPUnit for testing (^10.5.35|^11.5.3|^12.0.1).", - "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.2).", - "symfony/yaml": "Required for Testbench CLI (^7.2).", + "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.9).", + "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^11.0).", + "phpunit/phpunit": "Allow using PHPUnit for testing (^11.5.50|^12.5.8|^13.0.0).", + "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.4|^8.0).", + "symfony/yaml": "Required for Testbench CLI (^7.4|^8.0).", "vlucas/phpdotenv": "Required for Testbench CLI (^5.6.1)." }, "bin": [ @@ -6982,43 +6971,42 @@ "issues": "https://github.com/orchestral/testbench/issues", "source": "https://github.com/orchestral/testbench-core" }, - "time": "2026-01-13T05:19:42+00:00" + "time": "2026-06-23T11:50:08+00:00" }, { "name": "orchestra/workbench", - "version": "v10.0.8", + "version": "v11.1.0", "source": { "type": "git", "url": "https://github.com/orchestral/workbench.git", - "reference": "88bb9b5872539dd8b556b232a1b466f639c18259" + "reference": "e750c7bcae4405e054ff286475502e23274de04b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/workbench/zipball/88bb9b5872539dd8b556b232a1b466f639c18259", - "reference": "88bb9b5872539dd8b556b232a1b466f639c18259", + "url": "https://api.github.com/repos/orchestral/workbench/zipball/e750c7bcae4405e054ff286475502e23274de04b", + "reference": "e750c7bcae4405e054ff286475502e23274de04b", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "fakerphp/faker": "^1.23", - "laravel/framework": "^12.40.0", - "laravel/pail": "^1.2.2", - "laravel/tinker": "^2.10.1", - "nunomaduro/collision": "^8.6", - "orchestra/canvas": "^10.1.1", + "laravel/framework": "^13.0.0", + "laravel/pail": "^1.2.5", + "laravel/tinker": "^3.0.0", + "nunomaduro/collision": "^8.9", + "orchestra/canvas": "^11.0.1", "orchestra/sidekick": "~1.1.23|~1.2.20", - "orchestra/testbench-core": "^10.8.0", - "php": "^8.2", - "symfony/polyfill-php83": "^1.33", - "symfony/process": "^7.2", - "symfony/yaml": "^7.2" + "orchestra/testbench-core": "^11.1.0", + "php": "^8.3", + "symfony/process": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "require-dev": { "laravel/pint": "^1.22.0", "mockery/mockery": "^1.6.12", "phpstan/phpstan": "^2.1.33", - "phpunit/phpunit": "^11.5.3|^12.0.1", - "spatie/laravel-ray": "^1.42.0" + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6" }, "suggest": { "ext-pcntl": "Required to use all features of the console signal trapping." @@ -7048,9 +7036,9 @@ ], "support": { "issues": "https://github.com/orchestral/workbench/issues", - "source": "https://github.com/orchestral/workbench/tree/v10.0.8" + "source": "https://github.com/orchestral/workbench/tree/v11.1.0" }, - "time": "2026-01-12T14:48:09+00:00" + "time": "2026-03-24T23:09:55+00:00" }, { "name": "phar-io/manifest", @@ -7172,11 +7160,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.2.5", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "shasum": "" }, "require": { @@ -7199,6 +7187,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -7221,20 +7220,20 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-07-05T06:31:06+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -7243,16 +7242,15 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -7290,7 +7288,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -7310,7 +7308,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", @@ -7571,43 +7569,43 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.31", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "0608d157a284f15cc73b99a3327eff06b66a176d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d", + "reference": "0608d157a284f15cc73b99a3327eff06b66a176d", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.7", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -7649,44 +7647,28 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-07-06T14:54:16+00:00" }, { "name": "psy/psysh", - "version": "v0.12.20", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -7750,27 +7732,27 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2026-02-11T15:05:28+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "rector/rector", - "version": "2.3.8", + "version": "2.5.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" + "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e", + "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.38" + "phpstan/phpstan": "^2.2.2" }, "conflict": { "rector/rector-doctrine": "*", @@ -7804,7 +7786,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.8" + "source": "https://github.com/rectorphp/rector/tree/2.5.5" }, "funding": [ { @@ -7812,27 +7794,27 @@ "type": "github" } ], - "time": "2026-02-22T09:45:50+00:00" + "time": "2026-07-09T09:48:44+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -7861,7 +7843,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -7881,20 +7863,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -7902,10 +7884,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -7953,7 +7935,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -7973,7 +7955,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -8102,23 +8084,23 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -8126,7 +8108,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -8154,7 +8136,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { @@ -8174,29 +8156,29 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -8244,7 +8226,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -8264,30 +8246,30 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -8318,7 +8300,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -8338,28 +8320,28 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -8388,15 +8370,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -8590,23 +8584,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -8635,7 +8629,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -8655,7 +8649,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -8763,30 +8757,110 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:51:48+00:00" + }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "8e4cdd4311683516be06944f4b85244063cdb886" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8e4cdd4311683516be06944f4b85244063cdb886", + "reference": "8e4cdd4311683516be06944f4b85244063cdb886", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -8817,7 +8891,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v8.1.1" }, "funding": [ { @@ -8837,7 +8911,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-06-09T11:06:24+00:00" }, { "name": "theseer/tokenizer", @@ -8891,12 +8965,12 @@ } ], "aliases": [], - "minimum-stability": "dev", + "minimum-stability": "stable", "stability-flags": {}, - "prefer-stable": true, + "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.3" + "php": ">=8.3 <8.6" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/contributing.md b/contributing.md index 4850fb7..941b5ec 100644 --- a/contributing.md +++ b/contributing.md @@ -37,12 +37,16 @@ If you have ideas for new features or improvements: 6. Push to the branch (`git push origin feature/amazing-feature`) 7. Open a Pull Request +Warden targets PHP 8.3–8.5 and Laravel 12–13. New behavior must work across that declared compatibility matrix. + #### Pull Request Guidelines - Follow the existing code style and conventions - Update documentation as needed - Add tests for new features -- Ensure the test suite passes +- Ensure `composer test`, `composer phpstan`, `composer rector`, and `composer validate --strict` pass +- Preserve the documented exit-code and machine-report contracts +- Use stable finding IDs and include remediation for every new rule - Keep pull requests focused - one feature/fix per PR -### Have fun, and thank you for your contribution. \ No newline at end of file +### Have fun, and thank you for your contribution. diff --git a/phpstan.neon b/phpstan.neon index 45b3a7b..d1449d7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,7 +5,7 @@ # to ensure code quality and reliability. # # Configuration highlights: -# - Level 5: Balanced strictness for production code +# - Level 8: Strict analysis for security-gating production code # - Larastan integration: Proper Laravel framework understanding # - Carbon extension: Enhanced DateTime analysis # - Focused analysis: Only src directory, excluding vendor and tests @@ -18,7 +18,7 @@ includes: - vendor/nesbot/carbon/extension.neon parameters: - level: 5 + level: 8 paths: - src @@ -27,22 +27,11 @@ parameters: - '*/vendor/*' - '*/tests/*' - '*/config/*' - - '*/examples/*' - - '*/Examples/*' bootstrapFiles: - phpstan-bootstrap.php - vendor/autoload.php - reportUnmatchedIgnoredErrors: false - - ignoreErrors: - - message: '#^Internal error:\s*Call to a member function version\(\) on null#' - path: src/Commands/WardenAuditCommand.php - - - message: '#^Internal error:\s*Class Illuminate\\Cache\\CacheManager was not found while trying to analyse it#' - path: src/Services/AuditCacheService.php - typeAliases: LaravelApp: 'Illuminate\Foundation\Application' LaravelConfig: 'array' diff --git a/readme.md b/readme.md index f23b1b9..a998b03 100644 --- a/readme.md +++ b/readme.md @@ -1,620 +1,211 @@ # Warden -[![Latest Version on Packagist](https://img.shields.io/packagist/v/dgtlss/warden.svg?style=flat-square)](https://packagist.org/packages/dgtlss/warden) -[![Total Downloads](https://img.shields.io/packagist/dt/dgtlss/warden.svg?style=flat-square)](https://packagist.org/packages/dgtlss/warden) -[![License](https://img.shields.io/packagist/l/dgtlss/warden.svg?style=flat-square)](https://packagist.org/packages/dgtlss/warden) -[![PHP Version Require](https://img.shields.io/packagist/php-v/dgtlss/warden.svg?style=flat-square)](https://packagist.org/packages/dgtlss/warden) -![GitHub repo size](https://img.shields.io/github/repo-size/dgtlss/warden) +Warden is a deterministic Laravel security gate for CI and deployment pipelines. It audits locked production dependencies, supply-chain configuration, and high-confidence Laravel production settings without becoming part of the deployed application. -**Warden** is a comprehensive Laravel security audit package that proactively monitors your dependencies and application configuration for security vulnerabilities. Built for enterprise-grade security scanning, Warden provides powerful features for modern Laravel applications, ensuring your projects remain secure from development to production. +## Requirements -## 🚀 Key Features +- PHP 8.3–8.5 +- Laravel 12 or 13 +- Composer 2 +- npm only when a `package-lock.json` is present -### ✅ Core Security Audits -- **🔍 Dependency Scanning**: Composer and NPM vulnerability detection -- **⚙️ Configuration Audits**: Environment, storage permissions, and Laravel config -- **📝 Code Analysis**: PHP syntax validation and security checks -- **🔧 Custom Audit Rules**: Organization-specific security policies +## Installation -### ✅ Performance & Scalability -- **⚡ Parallel Execution**: Up to 5x faster audit performance -- **🗄️ Intelligent Caching**: Prevents redundant scans with configurable TTL -- **🎯 Severity Filtering**: Focus on critical issues only - -### ✅ Integration & Automation -- **📊 Multiple Output Formats**: JSON, GitHub Actions, GitLab CI, Jenkins -- **🔔 Rich Notifications**: Slack, Discord, Email with formatted reports -- **⏰ Automated Scheduling**: Laravel scheduler integration -- **🔄 CI/CD Ready**: Native support for all major platforms - -Perfect for continuous security monitoring and DevOps pipelines. - ---- - -## 📋 Table of Contents - -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Command Reference](#command-reference) -- [Configuration](#configuration) -- [Security Audits](#security-audits) -- [Usage Examples](#usage-examples) -- [Notifications](#notifications) -- [Custom Audits](#custom-audits) -- [Scheduling](#scheduling) -- [CI/CD Integration](#cicd-integration) -- [Advanced Features](#advanced-features) -- [FAQ](#faq) -- [Troubleshooting](#troubleshooting) - ---- - -## 🚀 Installation - -To install Warden, use Composer: +Install Warden as a development dependency: ```bash -composer require dgtlss/warden +composer require --dev dgtlss/warden ``` -Publish configuration: +Run Warden **before** pruning development dependencies from the production artifact: ```bash -php artisan vendor:publish --tag="warden-config" +composer install --no-interaction +php artisan warden:audit --profile=production --scope=production +composer install --no-dev --no-interaction --optimize-autoloader ``` -This creates `config/warden.php` with all available options. - -**Note**: The package includes `.idea` in `.gitignore` for improved support with IntelliJ IDEA and JetBrains IDEs. - ---- +Publishing the configuration is optional: -## ⚡ Quick Start - -Dive into Warden's powerful security auditing capabilities with these simple commands: - -### Basic Security Audit -Run a comprehensive security scan of your Laravel application: ```bash -php artisan warden:audit +php artisan vendor:publish --tag=warden-config ``` -### With NPM Dependencies -Include JavaScript vulnerabilities in your audit: -```bash -php artisan warden:audit --npm -``` +## CI usage -### JSON Output for CI/CD -Generate machine-readable reports for automated pipelines: -```bash -php artisan warden:audit --output=json --severity=high -``` +The default command uses the CI profile, audits production dependencies, reports every finding, and fails on low severity or higher: -### No Notifications -Run audits without sending notifications (useful for CI or local checks): ```bash -php artisan warden:audit --no-notify +php artisan warden:audit ``` -> **Note:** `--silent` still works for backward compatibility. ---- +Common examples: -## 📌 Command Reference - -Quick reference for all commands and options. +```bash +# Gate only on high and critical findings while still reporting everything +php artisan warden:audit --fail-on=high -| Command | Options | Description | -|--------|---------|-------------| -| `warden:audit` | — | Run all security audits | -| | `--no-notify` | Suppress notifications (CI/local use) | -| | `--npm` | Include NPM dependency scan | -| | `--ignore-abandoned` | Don't fail on abandoned packages | -| | `--output=json\|github\|gitlab\|jenkins` | Machine-readable output | -| | `--severity=low\|medium\|high\|critical` | Filter by minimum severity | -| | `--force` | Clear cache and re-run all audits | -| `warden:syntax` | — | PHP syntax validation only | -| `warden:schedule` | `--enable` | Enable scheduled audits | -| | `--disable` | Disable scheduled audits | -| | `--status` | Show schedule status | +# Validate effective production configuration +php artisan warden:audit --profile=production ---- +# Audit production and development dependencies +php artisan warden:audit --scope=all -## ⚙️ Configuration +# Select or skip audits +php artisan warden:audit --only=supply-chain,composer,laravel-config +php artisan warden:audit --skip=npm,storage -### Environment Variables +# Produce CI artifacts +php artisan warden:audit --format=json --output-file=warden-report.json +php artisan warden:audit --format=sarif --output-file=warden.sarif +php artisan warden:audit --format=gitlab --output-file=gl-dependency-scanning-report.json +php artisan warden:audit --format=junit --output-file=warden-junit.xml +``` -Add these to your `.env` file: +Machine formats never mix progress or diagnostic prose into stdout. Scanner failures are included in the report and exit with code `2`. -#### 🔔 Notifications -```env -# Slack (recommended - rich formatting) -WARDEN_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +### Exit codes -# Discord -WARDEN_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR/WEBHOOK +| Code | Meaning | +|---|---| +| `0` | Every audit completed and no blocking finding met `--fail-on` | +| `1` | One or more blocking findings met `--fail-on` | +| `2` | Configuration, tool, timeout, malformed output, or audit execution failure | -# Microsoft Teams -WARDEN_TEAMS_WEBHOOK_URL=https://outlook.office.com/webhook/YOUR/WEBHOOK +### Profiles -# Email -WARDEN_EMAIL_RECIPIENTS=security@company.com,admin@company.com -WARDEN_EMAIL_FROM=security@company.com -WARDEN_EMAIL_FROM_NAME="Security Team" +| Profile | Behavior | +|---|---| +| `ci` | Default. Static repository and dependency checks without assuming a runtime `.env` exists | +| `production` | Adds effective Laravel configuration and deployment filesystem checks | +| `local` | Static checks suitable for a developer workstation | -# Legacy webhook (backward compatibility) -WARDEN_WEBHOOK_URL=https://your-webhook-url.com -``` +CI environment variables do not disable production checks. Use `--profile=production` when the pipeline has loaded the intended deployment configuration. -#### ⚡ Performance -```env -WARDEN_CACHE_ENABLED=true -WARDEN_CACHE_DURATION=3600 # Cache for 1 hour -WARDEN_PARALLEL_EXECUTION=true # Enable parallel audits -``` +### Built-in audits -#### 🔬 PHP Syntax Audit -```env -WARDEN_PHP_SYNTAX_AUDIT_ENABLED=false # Enable via warden:syntax or config -``` +- `supply-chain`: lockfile presence/synchronization, secure Composer repositories, plugin allow-listing, and JavaScript lockfile support +- `composer`: Composer advisories, malware, and abandoned production packages from `composer.lock` +- `npm`: npm advisories from `package-lock.json`, auto-detected without a flag +- `laravel-config`: tracked `.env` detection and production application/session/tooling rules +- `storage`: production-only operational warnings; these do not fail the security gate -#### ⏰ Scheduling -```env -WARDEN_SCHEDULE_ENABLED=false -WARDEN_SCHEDULE_FREQUENCY=daily # hourly|daily|weekly|monthly -WARDEN_SCHEDULE_TIME=03:00 -WARDEN_SCHEDULE_TIMEZONE=UTC -``` +Yarn, pnpm, and Bun lockfiles are detected but are not yet parsed. Warden reports the limitation so the package-manager-native audit can be added as a separate CI step. -### Ignoring Accepted Findings +## Reviewed suppressions -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`. +Suppressions are exact, documented, and expiring. Wildcards are not supported. ```php 'ignore_findings' => [ - ['source' => 'debug-mode', 'package' => 'laravel/horizon'], - ['source' => 'debug-mode', 'title' => 'Testing routes*'], + [ + 'id' => 'composer.advisory.ghsa-example', + 'fingerprint' => 'optional-fingerprint-for-one-occurrence', + 'reason' => 'Compensating control reviewed in SEC-123', + 'expires_at' => '2099-12-31', + ], ], ``` -All provided keys in a rule must match for the finding to be ignored. String values support wildcard matching. - ---- - -## 🔍 Security Audits - -Warden performs comprehensive security analysis across multiple areas: - -### 1. **Composer Dependencies** -- Scans PHP dependencies for known vulnerabilities -- Uses official `composer audit` command -- Identifies abandoned packages with replacement suggestions - -### 2. **NPM Dependencies** -- Analyzes JavaScript dependencies (when `--npm` flag used) -- Detects vulnerable packages in `package.json` -- Validates `package-lock.json` integrity - -### 3. **Environment Configuration** -- Verifies `.env` file presence and `.gitignore` status -- Checks for missing critical environment variables -- Validates sensitive key configuration - -### 4. **Storage & Permissions** -- Audits Laravel storage directories (`storage/`, `bootstrap/cache/`) -- Ensures proper write permissions -- Identifies missing or misconfigured paths - -### 5. **Laravel Configuration** -- **Enhanced debug mode auditing**: Accurately detects development packages in production by scanning `vendor/composer/installed.json` -- Session security settings -- CSRF protection validation -- General security misconfigurations - -### 6. **PHP Syntax Analysis** -- Code syntax validation across your application -- Configurable directory exclusions -- Integration with existing audit workflow - ---- - -## 💡 Usage Examples - -### Basic Commands - -```bash -# Standard audit -php artisan warden:audit - -# Include NPM + severity filtering -php artisan warden:audit --npm --severity=medium - -# Force cache refresh -php artisan warden:audit --force - -# Ignore abandoned packages -php artisan warden:audit --ignore-abandoned -``` - -### Output Formats +An expired or malformed suppression is a configuration error and exits `2`. -```bash -# JSON for processing -php artisan warden:audit --output=json > security-report.json - -# GitHub Actions annotations -php artisan warden:audit --output=github - -# GitLab CI dependency scanning -php artisan warden:audit --output=gitlab > gl-dependency-scanning-report.json - -# Jenkins format -php artisan warden:audit --output=jenkins -``` +## Baselines -### Advanced Usage +Legacy applications can commit an explicit fingerprint baseline while continuing to fail on new findings: ```bash -# Combined options -php artisan warden:audit --npm --severity=high --output=json --no-notify - -# PHP syntax check -php artisan warden:syntax - -# Schedule management -php artisan warden:schedule --enable -php artisan warden:schedule --status +php artisan warden:baseline \ + --reason="Existing findings tracked in SEC-123" \ + --expires=2099-12-31 ``` ---- +This creates `warden-baseline.json`. Baseline generation refuses to write a file if any audit is incomplete. -## 🔔 Notifications +## Reports -Warden supports multiple notification channels with rich formatting: +Warden supports: -### ✅ Slack (Recommended) -- Color-coded severity levels -- Organized finding blocks -- Clickable CVE links -- Professional formatting +- `console`: readable terminal report +- `json`: versioned Warden schema with audits, findings, ignored findings, errors, and summary; the schema ships at `resources/schemas/warden-report-2.0.0.json` +- `github`: GitHub Actions workflow annotations +- `gitlab`: GitLab dependency scanning report schema 15.2.4 +- `sarif`: SARIF 2.1.0 for GitHub code scanning and compatible platforms +- `junit`: portable JUnit XML for Jenkins and other CI systems -```env -WARDEN_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL -``` +`--output-file=-` writes to stdout. Relative file paths are resolved from the Laravel application root. -### ✅ Discord -- Rich embeds with color coding -- Grouped findings by source -- Custom branding +## Notifications -```env -WARDEN_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR/WEBHOOK -``` - -### ✅ Microsoft Teams -- Adaptive Cards with structured layouts -- Color-coded severity indicators -- Action buttons and rich formatting +Notifications are opt-in and never affect the audit exit code: -```env -WARDEN_TEAMS_WEBHOOK_URL=https://outlook.office.com/webhook/YOUR/WEBHOOK +```bash +php artisan warden:audit --notify ``` -### ✅ Email -- Professional HTML templates with modern styling -- Severity-based color coding and summary statistics -- Grouped findings by source with detailed information -- Separate templates for vulnerabilities and abandoned packages +Configure any combination of: ```env -WARDEN_EMAIL_RECIPIENTS=security@company.com,admin@company.com -WARDEN_EMAIL_FROM=security@company.com -WARDEN_EMAIL_FROM_NAME="Security Team" +WARDEN_SLACK_WEBHOOK_URL= +WARDEN_DISCORD_WEBHOOK_URL= +WARDEN_TEAMS_WEBHOOK_URL= +WARDEN_EMAIL_RECIPIENTS=security@example.com +WARDEN_EMAIL_FROM=warden@example.com ``` -### Multiple Channels -Configure multiple channels simultaneously - Warden sends to all configured endpoints. - ---- +Each channel is dispatched once. Delivery failures are written to stderr after the report is produced. -## 🔧 Custom Audits +## Custom audits -Create organization-specific security rules: - -### 1. Implement Custom Audit +Custom audits receive the immutable audit context and return a typed result: ```php - 'Database Password Security', - 'package' => 'environment', - 'title' => 'Weak Database Password', - 'severity' => 'critical', - 'description' => 'Database password is weak or commonly used', - 'remediation' => 'Use a strong, unique password' - ] - ]; - } - - public function getName(): string - { - return 'Database Password Security'; - } - - public function getDescription(): string - { - return 'Checks for weak database passwords'; - } + public function getName(): string { return 'public-bucket'; } + public function getDescription(): string { return 'Checks the effective filesystem configuration.'; } + public function shouldRun(AuditContext $context): bool { return $context->profile === 'production'; } - public function shouldRun(): bool + public function run(AuditContext $context): AuditResult { - return !empty(env('DB_CONNECTION')); + $findings = config('filesystems.disks.s3.visibility') === 'public' + ? [new Finding( + id: 'custom.storage.public', + source: $this->getName(), + title: 'S3 disk is public', + severity: Severity::High, + description: 'The default S3 disk visibility is public.', + remediation: 'Set the disk visibility to private.', + path: 'config/filesystems.php', + )] + : []; + + return AuditResult::complete($this->getName(), $findings); } } ``` -### 2. Register Custom Audit +Register the class in `config/warden.php` under `custom_audits`. -Add to `config/warden.php`: - -```php -'custom_audits' => [ - \App\Audits\DatabasePasswordAudit::class, - \App\Audits\ApiKeySecurityAudit::class, - // Add more custom audits -], -``` - ---- - -## ⏰ Scheduling - -### Enable Automated Audits - -```bash -# Enable scheduling -php artisan warden:schedule --enable - -# Check status -php artisan warden:schedule --status - -# Disable scheduling -php artisan warden:schedule --disable -``` - -### Configure Schedule - -```env -WARDEN_SCHEDULE_ENABLED=true -WARDEN_SCHEDULE_FREQUENCY=daily -WARDEN_SCHEDULE_TIME=03:00 -``` - -### Laravel Cron Setup - -Ensure Laravel's scheduler is running: +## Development ```bash -* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1 -``` - ---- - -## 🔄 CI/CD Integration - -### GitHub Actions - -```yaml -name: Security Audit -on: [push, pull_request] - -jobs: - security: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - - - name: Install dependencies - run: composer install --no-progress --prefer-dist - - - name: Security Audit - run: php artisan warden:audit --output=github --severity=high +composer install +composer test +composer phpstan +composer rector +composer validate --strict ``` -### GitLab CI - -```yaml - security_audit: - stage: test - script: - - composer install --no-progress --prefer-dist - - php artisan warden:audit --output=gitlab --no-notify > gl-dependency-scanning-report.json - artifacts: - reports: - dependency_scanning: gl-dependency-scanning-report.json - expire_in: 1 week - allow_failure: false -``` - -### Jenkins - -```groovy -pipeline { - agent any - stages { - stage('Security Audit') { - steps { - sh 'composer install --no-progress --prefer-dist' - sh 'php artisan warden:audit --output=jenkins --severity=high' - } - post { - always { - publishHTML([ - allowMissing: false, - alwaysLinkToLastBuild: true, - keepAll: true, - reportDir: '.', - reportFiles: 'audit-report.json', - reportName: 'Security Audit Report' - ]) - } - } - } - } -} -``` - ---- - -## 🎯 Advanced Features - -### Performance Optimization - -1. **Parallel Execution**: Enabled by default for 5x speed improvement -2. **Intelligent Caching**: Configurable cache duration prevents redundant API calls -3. **Severity Filtering**: Focus resources on critical issues - -### Audit Results - -**Exit Codes:** -- `0`: No vulnerabilities found -- `1`: Vulnerabilities detected -- `2`: Audit process failures - -**Severity Levels:** -- `critical`: Immediate attention required -- `high`: Address as soon as possible -- `medium`: Should be reviewed and fixed -- `low`: Minor security concerns - -### Configuration Examples - -```php -// config/warden.php - -'audits' => [ - 'parallel_execution' => true, - 'timeout' => 300, // seconds -], - -'cache' => [ - 'enabled' => true, - 'duration' => 3600, // 1 hour -], - -'sensitive_keys' => [ - 'DB_PASSWORD', - 'STRIPE_SECRET', - 'AWS_SECRET_ACCESS_KEY', -], -``` - -> **Output & severity:** Use `--output` and `--severity` CLI options (not config). See [Command Reference](#-command-reference) above. - ---- - -## 📈 Roadmap - -### Coming Soon -- 📊 **Audit history tracking** and trend analysis -- 🔍 **Additional audit types** (Docker, Git, API security) -- 📋 **Web dashboard** for audit management -- 🤖 **AI-powered vulnerability analysis** and recommendations - ---- - -## ❓ FAQ - -### How does Warden differ from built-in Composer audit? -Warden extends beyond Composer audit with NPM scanning, environment checks, storage permissions, Laravel-specific configurations, and custom audit rules for comprehensive security monitoring. - -### Can Warden run in CI/CD without notifications? -Yes! Use `--no-notify` to suppress notifications while still generating reports for your pipeline. (`--silent` also works.) - -### What are the performance impacts? -Minimal! Parallel execution and intelligent caching ensure audits complete in seconds, with configurable timeouts and retry logic. - -### How do I handle false positives? -Use severity filtering (`--severity=high`) and custom audits to tune findings for your organization's security policies. - -### Is my data secure? -Absolutely. Warden processes everything locally - no external data transmission except for configured notification webhooks. - ---- - -## 🛠️ Troubleshooting - -### Common Issues - -**Command not found:** -```bash -php artisan config:clear -composer dump-autoload -``` - -**Composer audit failures:** -```bash -# Update Composer to latest version -composer self-update -``` - ---- - -## 📄 License - -This package is open source and released under the [MIT License](LICENSE). - ---- - -## 🤝 Contributing - -We welcome contributions! Please see our [CONTRIBUTING GUIDELINES](CONTRIBUTING.md) for details on: - -- 🐛 Bug reports -- ✨ Feature requests -- 🔧 Code contributions -- 📚 Documentation improvements - ---- - -## 💬 Support - -- 🐛 **Issues**: [GitHub Issues](https://github.com/dgtlss/warden/issues) -- 💬 **Discussions**: [GitHub Discussions](https://github.com/dgtlss/warden/discussions) -- 📋 **Releases**: [Version History & Changelogs](https://github.com/dgtlss/warden/releases) - ---- - -## 💝 Support Development - -If you find Warden useful for your organization's security needs, please consider [supporting its development](https://github.com/sponsors/dgtlss). - ---- - -
+See [contributing.md](contributing.md) for contribution guidelines. -**Made with ❤️ for the Laravel community** +Upgrading from Warden 1.x? Read [UPGRADE.md](UPGRADE.md) before changing the dependency constraint. -[⭐ Star on GitHub](https://github.com/dgtlss/warden) | [📦 Packagist](https://packagist.org/packages/dgtlss/warden) | [🐦 Follow Updates](https://twitter.com/nlangerdev) +## License -
+Warden is released under the [MIT License](LICENSE). diff --git a/resources/schemas/warden-report-2.0.0.json b/resources/schemas/warden-report-2.0.0.json new file mode 100644 index 0000000..20d8099 --- /dev/null +++ b/resources/schemas/warden-report-2.0.0.json @@ -0,0 +1,136 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/dgtlss/warden/main/resources/schemas/warden-report-2.0.0.json", + "title": "Warden 2.0 Audit Report", + "type": "object", + "required": [ + "schema_version", + "warden_version", + "run", + "summary", + "audits", + "findings", + "ignored_findings", + "errors" + ], + "properties": { + "schema_version": { "const": "2.0.0" }, + "warden_version": { "type": "string" }, + "run": { + "type": "object", + "required": ["status", "profile", "scope", "scanned_at"], + "properties": { + "status": { "enum": ["completed", "failed"] }, + "profile": { "enum": ["ci", "production", "local"] }, + "scope": { "enum": ["production", "all"] }, + "scanned_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false + }, + "summary": { + "type": "object", + "required": ["total", "ignored", "errors", "severity"], + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "ignored": { "type": "integer", "minimum": 0 }, + "errors": { "type": "integer", "minimum": 0 }, + "severity": { + "type": "object", + "required": ["critical", "high", "medium", "low"], + "properties": { + "critical": { "type": "integer", "minimum": 0 }, + "high": { "type": "integer", "minimum": 0 }, + "medium": { "type": "integer", "minimum": 0 }, + "low": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "audits": { + "type": "array", + "items": { "$ref": "#/definitions/audit" } + }, + "findings": { + "type": "array", + "items": { "$ref": "#/definitions/finding" } + }, + "ignored_findings": { + "type": "array", + "items": { "$ref": "#/definitions/finding" } + }, + "errors": { + "type": "array", + "items": { "$ref": "#/definitions/error" } + } + }, + "definitions": { + "error": { + "type": "object", + "required": ["audit", "code", "message"], + "properties": { + "audit": { "type": "string", "minLength": 1 }, + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "audit": { + "type": "object", + "required": ["id", "status", "duration_ms", "findings", "errors"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "status": { "enum": ["completed", "failed"] }, + "duration_ms": { "type": "number", "minimum": 0 }, + "findings": { "type": "integer", "minimum": 0 }, + "errors": { + "type": "array", + "items": { "$ref": "#/definitions/error" } + } + }, + "additionalProperties": false + }, + "finding": { + "type": "object", + "required": [ + "id", + "fingerprint", + "source", + "title", + "severity", + "description", + "blocking" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "fingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "source": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "severity": { "enum": ["critical", "high", "medium", "low"] }, + "description": { "type": "string", "minLength": 1 }, + "remediation": { "type": "string" }, + "package": { "type": "string" }, + "reference": { "type": "string" }, + "location": { + "type": "object", + "required": ["path"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "line": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "blocking": { "type": "boolean" }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "integer", "boolean", "null"] + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/src/Commands/WardenAuditCommand.php b/src/Commands/WardenAuditCommand.php index d7f3633..9da04c6 100644 --- a/src/Commands/WardenAuditCommand.php +++ b/src/Commands/WardenAuditCommand.php @@ -1,917 +1,216 @@ cacheService = $auditCacheService; - $this->executor = $auditExecutor; - } - - /** - * Check whether notifications should be suppressed. - * - * Supports both the new --no-notify flag and the legacy --silent flag. - * On Symfony Console 7.2+ (Laravel 11+), --silent is a framework-level - * option that also suppresses output. We detect it via output verbosity - * so that existing users passing --silent still get notification suppression. - */ - protected function shouldSuppressNotifications(): bool - { - if ($this->option('no-notify')) { - return true; - } - - // Check if isSilent() exists (Symfony Console 7.2+/Laravel 11+) - // @phpstan-ignore function.alreadyNarrowedType (runtime check needed for older Symfony versions) - if (method_exists($this->output, 'isSilent')) { - return $this->output->isSilent(); - } - - // Fallback for older Symfony Console versions: do not suppress notifications - return false; } - /** - * Execute the console command. - * - * @return int Exit code: 0 for success, 1 for vulnerabilities found, 2 for audit failures - */ public function handle(): int { - $isMachineOutput = $this->option('output') !== null; - - if (!$isMachineOutput) { - $this->displayVersion(); - } - - if ($this->option('force')) { - $this->cacheService->clearCache(); - if (!$isMachineOutput) { - $this->info('Cache cleared.'); - } - } - - $useParallel = config('warden.audits.parallel_execution', true); - - if ($useParallel) { - return $this->runParallelAudits($isMachineOutput); - } - - return $this->runSequentialAudits($isMachineOutput); - } - - protected function runParallelAudits(bool $isMachineOutput = false): int - { - $auditServices = $this->initializeAuditServices(); - - foreach ($auditServices as $auditService) { - $this->executor->addAudit($auditService); - } - - $progress = $isMachineOutput ? null : function (string $name, string $status, ?float $durationMs): void { - $this->renderAuditProgress($name, $status, $durationMs); - }; - - $results = $this->executor->execute($progress); - - // Collect findings and abandoned packages - $allFindings = []; - $abandonedPackages = []; - $hasFailures = false; - - foreach ($results as $result) { - if (!$result['success']) { - $this->handleAuditFailure($result['service']); - $hasFailures = true; - continue; - } - - if (!empty($result['findings'])) { - $allFindings = array_merge($allFindings, $result['findings']); - } - - // Collect abandoned packages from composer audit - if ($result['service'] instanceof ComposerAuditService) { - $abandonedPackages = $result['service']->getAbandonedPackages(); - } - } - - return $this->processResults($allFindings, $abandonedPackages, $hasFailures); - } - - protected function runSequentialAudits(bool $isMachineOutput = false): int - { - $auditServices = $this->initializeAuditServices(); - $hasFailures = false; - $allFindings = []; - $abandonedPackages = []; - - foreach ($auditServices as $auditService) { - $auditName = $auditService->getName(); - - // Check cache first (unless force is used) - if (!$this->option('force') && $this->cacheService->hasRecentAudit($auditName)) { - if (!$isMachineOutput) { - $this->info(sprintf('Using cached results for %s audit...', $auditName)); - } - - $cached = $this->cacheService->getCachedResult($auditName); - if (!empty($cached['result'])) { - $allFindings = array_merge($allFindings, $cached['result']); + $profile = (string) $this->option('profile'); + $scope = (string) $this->option('scope'); + $failOn = (string) $this->option('fail-on'); + $format = (string) $this->option('format'); + $outputFile = (string) $this->option('output-file'); + $only = $this->csvOption('only'); + $skip = $this->csvOption('skip'); + $timeout = config('warden.audits.timeout', 300); + + $validationError = $this->validateOptions($profile, $scope, $failOn, $format, $outputFile, $timeout, $only, $skip); + if ($validationError !== null) { + return $this->renderInvalidConfiguration($validationError, $profile, $scope, $format, $outputFile); + } + + $auditContext = new AuditContext( + profile: $profile, + scope: $scope, + timeout: (int) $timeout, + only: $only, + skip: $skip, + ); + $progress = $format === 'console' && $outputFile === '-' + ? function (string $audit, string $status, ?float $duration): void { + if ($status !== 'running') { + $this->line(sprintf('%s %s (%sms)', $status === 'done' ? '✓' : '✗', $audit, number_format($duration ?? 0, 1))); } - - continue; } + : null; - if (!$isMachineOutput) { - $this->info(sprintf('Running %s audit...', $auditName)); - } + $scannedAt = CarbonImmutable::now(); + $suppressionErrors = $this->suppressionService->errors(now: $scannedAt); + $auditReport = $suppressionErrors === [] + ? $this->suppressionService->apply($this->auditRunner->run($auditContext, $progress)) + : new AuditReport($auditContext, [], configurationErrors: $suppressionErrors, scannedAt: $scannedAt); - if (!$auditService->run()) { - $this->handleAuditFailure($auditService); - $hasFailures = true; - continue; - } + try { + $rendered = $this->reportFormatterFactory->make($format)->format($auditReport); + } catch (Throwable $throwable) { + $this->writeError('Report generation failed: ' . $throwable->getMessage()); - $findings = $auditService->getFindings(); - if (!empty($findings)) { - $allFindings = array_merge($allFindings, $findings); - // Cache the results - $this->cacheService->storeResult($auditName, $findings); - } - - // Collect abandoned packages - if ($auditService instanceof ComposerAuditService) { - $abandonedPackages = $auditService->getAbandonedPackages(); - } + return 2; } - return $this->processResults($allFindings, $abandonedPackages, $hasFailures); - } - - protected function processResults(array $allFindings, array $abandonedPackages, bool $hasFailures): int - { - $allFindings = $this->filterIgnoredFindings($allFindings); - $totalBeforeFilter = count($allFindings); - $severityOption = null; - - // Apply severity filtering if specified - if ($this->option('severity')) { - $severityOption = (string) $this->option('severity'); - $allFindings = $this->filterBySeverity($allFindings, $severityOption); + if (!$this->writeReport($rendered, $outputFile)) { + return 2; } - $outputFormat = $this->option('output'); - if ($outputFormat) { - $this->outputFormattedResults($allFindings, (string) $outputFormat); - return $allFindings === [] ? ($hasFailures ? 2 : 0) : 1; - } - - $this->handleAbandonedPackages($abandonedPackages); - - $this->newLine(); - - if ($allFindings !== []) { - $this->displayFindings($allFindings); - - if (!$this->shouldSuppressNotifications()) { - $this->sendNotifications($allFindings); + if ((bool) $this->option('notify')) { + foreach ($this->notificationDispatcher->send($auditReport) as $warning) { + $this->writeError($warning); } - - return 1; - } - - $filtered = $totalBeforeFilter - count($allFindings); - if ($filtered > 0 && $severityOption !== null) { - info(sprintf('No issues at %s severity or above (%d lower-severity %s filtered).', $severityOption, $filtered, $filtered === 1 ? 'issue' : 'issues')); - } else { - info('✅ No security issues found.'); } - return $hasFailures ? 2 : 0; + return $auditReport->exitCode($failOn); } - /** - * Remove findings that match configured ignore rules. - * - * @param array> $findings - * @return array> - */ - protected function filterIgnoredFindings(array $findings): array + /** @return list */ + private function csvOption(string $name): array { - $ignoreRules = config('warden.ignore_findings', []); - - if (!is_array($ignoreRules) || $ignoreRules === []) { - return $findings; + $value = $this->option($name); + if (!is_string($value) || trim($value) === '') { + return []; } - 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; - } + $items = array_map('trim', explode(',', $value)); + $items = array_values(array_unique(array_filter($items, static fn (string $item): bool => $item !== ''))); + sort($items); - return true; - } - - return false; + return $items; } /** - * @param array $finding - * @param array $rule + * @param list $only + * @param list $skip */ - 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; + private function validateOptions( + string $profile, + string $scope, + string $failOn, + string $format, + string $outputFile, + mixed $timeout, + array $only, + array $skip, + ): ?string { + $valid = [ + 'profile' => ['ci', 'production', 'local'], + 'scope' => ['production', 'all'], + 'fail-on' => ['low', 'medium', 'high', 'critical', 'never'], + 'format' => ['console', 'json', 'github', 'gitlab', 'sarif', 'junit'], + ]; - if (!$this->findingValueMatchesRule($finding[$key], $expectedValue)) { - return false; + foreach (['profile' => $profile, 'scope' => $scope, 'fail-on' => $failOn, 'format' => $format] as $option => $value) { + if (!in_array($value, $valid[$option], true)) { + return sprintf('Invalid --%s value "%s". Expected one of: %s.', $option, $value, implode(', ', $valid[$option])); } } - 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. - */ - protected function displayVersion(): void - { - $this->newLine(); - $this->line(sprintf(' Warden v%s', $this->getWardenVersion())); - $this->newLine(); - } - - /** - * Render per-audit progress line. - */ - protected function renderAuditProgress(string $name, string $status, ?float $durationMs): void - { - $label = ucfirst($name); - - if ($status === 'running') { - $this->output->write(sprintf(' ⏳ Running %s audit ...', $label)); - return; + if ($outputFile === '') { + return '--output-file cannot be empty.'; } - // Overwrite the "running" line if terminal supports it - if (stream_isatty(STDOUT)) { - $this->output->write("\r\033[2K"); - } else { - $this->newLine(); + if (!is_int($timeout) || $timeout < 1 || $timeout > 3600) { + return 'warden.audits.timeout must be an integer between 1 and 3600 seconds.'; } - $duration = $durationMs !== null ? sprintf(' (%sms)', number_format($durationMs, 0)) : ''; - - if ($status === 'done') { - $this->line(sprintf(' ✓ %s audit%s', $label, $duration)); - } else { - $this->line(sprintf(' ✗ %s audit failed%s', $label, $duration)); + $overlap = array_intersect($only, $skip); + if ($overlap !== []) { + return sprintf('The same audit cannot be selected and skipped: %s.', implode(', ', $overlap)); } - } - - /** - * Initialize and return all audit services based on command options. - * - * @return array Array of audit service instances - */ - protected function initializeAuditServices(): array - { - $services = [ - app(ComposerAuditService::class), - app(EnvAuditService::class), - app(StorageAuditService::class), - app(DebugModeAuditService::class), - ]; - if ($this->option('npm')) { - $services[] = app(NpmAuditService::class); - } - - // Load custom audits from configuration - $customAudits = config('warden.custom_audits', []); - foreach ($customAudits as $customAuditClass) { - if (!class_exists($customAuditClass)) { - $this->warn('Custom audit class not found: ' . $customAuditClass); - continue; - } - - try { - $customAudit = app()->make($customAuditClass); - if (!$customAudit instanceof CustomAudit) { - $this->warn(sprintf('Custom audit %s must implement ', $customAuditClass) . CustomAudit::class); - continue; + if ($only !== [] || $skip !== []) { + $available = $this->auditRunner->availableAuditIds(); + foreach ([...$only, ...$skip] as $audit) { + if (!in_array($audit, $available, true)) { + return sprintf('Unknown audit "%s". Available audits: %s.', $audit, implode(', ', $available)); } - - if (!$customAudit->shouldRun()) { - continue; - } - - $services[] = new CustomAuditWrapper($customAudit); - $this->info('Loaded custom audit: ' . $customAudit->getName()); - } catch (\Exception $e) { - $this->warn(sprintf('Failed to load custom audit %s: %s', $customAuditClass, $e->getMessage())); } } - return $services; - } - - /** - * Handle a failed audit service. - * - * @param AuditServiceInterface $auditService The audit service that failed - */ - protected function handleAuditFailure(AuditServiceInterface $auditService): void - { - $serviceName = $auditService->getName(); - if ($serviceName === '') { - $serviceName = 'Unknown service'; - } - $this->error($serviceName . ' audit failed to run.'); - if ($auditService instanceof ComposerAuditService) { - $findings = $auditService->getFindings(); - $error = Collection::make($findings)->last()['error'] ?? 'Unknown error'; - $this->error("Error: " . $error); - } + return null; } - /** - * Process and display abandoned packages information. - * - * @param array $abandonedPackages List of abandoned packages - */ - protected function handleAbandonedPackages(array $abandonedPackages): void - { - if ($abandonedPackages === []) { - return; + private function renderInvalidConfiguration( + string $message, + string $profile, + string $scope, + string $format, + string $outputFile, + ): int { + $this->writeError($message); + if (!in_array($format, ['json', 'github', 'gitlab', 'sarif', 'junit'], true)) { + return 2; } - if ($this->option('ignore-abandoned')) { - $this->warn(count($abandonedPackages) . ' abandoned packages found (ignored due to --ignore-abandoned flag)'); - return; - } - - $this->warn(count($abandonedPackages) . ' abandoned packages found.'); - - $headers = ['Package', 'Recommended Replacement']; - $rows = []; - - foreach ($abandonedPackages as $abandonedPackage) { - $rows[] = [ - $abandonedPackage['package'], - $abandonedPackage['replacement'] ?? 'No replacement suggested' - ]; - } - - table( - headers: $headers, - rows: $rows + $auditContext = new AuditContext( + profile: in_array($profile, ['ci', 'production', 'local'], true) ? $profile : 'ci', + scope: in_array($scope, ['production', 'all'], true) ? $scope : 'production', ); - - if (!$this->shouldSuppressNotifications()) { - $this->sendAbandonedPackagesNotification($abandonedPackages); - } - } - - /** - * Display audit findings in a formatted table. - * - * @param array $findings List of vulnerability findings - */ - protected function displayFindings(array $findings): void - { - $count = count($findings); - $this->error($count . ' security ' . ($count === 1 ? 'issue' : 'issues') . ' found.'); - - $hasCveData = collect($findings)->contains(fn ($f) => !empty($f['cve'])); - - $headers = ['Source', 'Package', 'Title', 'Severity']; - if ($hasCveData) { - $headers = array_merge($headers, ['CVE', 'Affected Versions']); - } - - $rows = []; - foreach ($findings as $finding) { - $severity = $finding['severity'] ?? 'unknown'; - $severityDisplay = match ($severity) { - 'critical' => '🔴 Critical', - 'high' => '🟠 High', - 'medium' => '🟡 Medium', - 'low' => '🟢 Low', - default => $severity, - }; - - $row = [ - $finding['source'], - $finding['package'], - $finding['title'], - $severityDisplay, - ]; - - if ($hasCveData) { - $cve = $finding['cve'] ?? null; - $row[] = $cve ?: '-'; - $row[] = $finding['affected_versions'] ?? '-'; - } - - $rows[] = $row; - } - - table( - headers: $headers, - rows: $rows + $auditReport = new AuditReport( + $auditContext, + [], + configurationErrors: [new AuditError('configuration', 'invalid_option', $message)], + scannedAt: CarbonImmutable::now(), ); - } - - /** - * Prepare a structured report from advisory data. - * - * @param array>> $advisories Advisory data organized by package - * @return array>> Structured report data - */ - protected function prepareReport(array $advisories): array - { - $reportData = []; - foreach ($advisories as $package => $issues) { - $packageIssues = []; - foreach ($issues as $issue) { - $packageIssues[] = [ - 'title' => $issue['title'], - 'cve' => $issue['cve'], - 'link' => 'https://www.cve.org/CVERecord?id=' . $issue['cve'], - 'affected_versions' => $issue['affected_versions'] - ]; - } - - $reportData[$package] = $packageIssues; - } - - return $reportData; - } - - /** - * Send notifications about vulnerabilities through configured channels. - * - * @param array> $findings List of vulnerability findings - */ - protected function sendNotifications(array $findings): void - { - $channels = $this->getNotificationChannels(); - $sent = false; - - foreach ($channels as $channel) { - try { - $channel->send($findings); - $this->info('Notification sent via ' . $channel->getName()); - $sent = true; - } catch (\Exception $e) { - $this->warn(sprintf('Failed to send notification via %s: %s', $channel->getName(), $e->getMessage())); - } - } - - $legacySent = $this->sendLegacyNotifications($findings); - $sent = $sent || $legacySent; - - if (!$sent) { - $this->warn('No notification channels configured. Set up Slack, Discord, Teams, or Email in config/warden.php.'); - } - } - - /** - * Get configured notification channels. - * - * @return array - */ - protected function getNotificationChannels(): array - { - $channels = []; - - // Slack channel - $slackChannel = new SlackChannel(); - if ($slackChannel->isConfigured()) { - $channels[] = $slackChannel; - } - - // Discord channel - $discordChannel = new DiscordChannel(); - if ($discordChannel->isConfigured()) { - $channels[] = $discordChannel; - } - - // Email channel - $emailChannel = new EmailChannel(); - if ($emailChannel->isConfigured()) { - $channels[] = $emailChannel; - } - - // Microsoft Teams channel - $teamsChannel = new TeamsChannel(); - if ($teamsChannel->isConfigured()) { - $channels[] = $teamsChannel; - } - - return $channels; - } - - /** - * Send notifications via legacy webhook and email methods. - * - * @param array> $findings List of vulnerability findings - */ - protected function sendLegacyNotifications(array $findings): bool - { - $webhookUrl = config('warden.webhook_url'); - $emailRecipients = config('warden.email_recipients'); - $sent = false; - - if ($webhookUrl) { - $this->sendWebhookNotification($webhookUrl, $findings); - $sent = true; - } - - if ($emailRecipients) { - $recipients = is_string($emailRecipients) ? explode(',', $emailRecipients) : $emailRecipients; - $this->sendEmailReport($findings, $recipients); - $sent = true; - } - - return $sent; - } - - /** - * Send a webhook notification with audit findings. - * - * @param string $webhookUrl The URL to send webhook to - * @param array> $findings List of vulnerability findings - */ - protected function sendWebhookNotification(string $webhookUrl, array $findings): void - { - // Format findings for webhook - $formattedReport = $this->formatFindingsForWebhook($findings); - Http::post($webhookUrl, ['text' => $formattedReport]); - } - - /** - * Format findings into a readable message for webhook notifications. - * - * @param array> $findings List of vulnerability findings - * @return string Formatted message - */ - protected function formatFindingsForWebhook(array $findings): string - { - // Implement a better formatting for webhook notifications - $message = "🚨 *Warden Security Audit Report* 🚨\n\n"; - $message .= count($findings) . " vulnerabilities found:\n\n"; - - foreach ($findings as $finding) { - $message .= "• *{$finding['package']}*: {$finding['title']} ({$finding['severity']})\n"; - if (!empty($finding['cve'])) { - $message .= sprintf(' CVE: %s - https://www.cve.org/CVERecord?id=%s%s', $finding['cve'], $finding['cve'], PHP_EOL); - } - - $message .= "\n"; - } - - return $message; - } - - /** - * Send an email report with audit findings. - * - * @param array> $report Report data to include in email - * @param array $emailRecipients Recipients of email - */ - protected function sendEmailReport(array $report, array $emailRecipients): void - { - Mail::send('warden::mail.report', ['report' => $report], function ($message) use ($emailRecipients): void { - $message->to($emailRecipients) - ->subject('Warden Audit Report'); - }); - } - - /** - * Send notifications about abandoned packages. - * - * @param array> $abandonedPackages List of abandoned packages - */ - protected function sendAbandonedPackagesNotification(array $abandonedPackages): void - { - $channels = $this->getNotificationChannels(); - - foreach ($channels as $channel) { - try { - $channel->sendAbandonedPackages($abandonedPackages); - $this->info('Abandoned packages notification sent via ' . $channel->getName()); - } catch (\Exception $e) { - $this->warn(sprintf('Failed to send abandoned packages notification via %s: %s', $channel->getName(), $e->getMessage())); - } - } + $rendered = $this->reportFormatterFactory->make($format)->format($auditReport); + $this->writeReport($rendered, $outputFile === '' ? '-' : $outputFile); - // Legacy support - $this->sendLegacyAbandonedPackagesNotification($abandonedPackages); + return 2; } - /** - * Send legacy notifications for abandoned packages. - * - * @param array> $abandonedPackages List of abandoned packages - */ - protected function sendLegacyAbandonedPackagesNotification(array $abandonedPackages): void + private function writeReport(string $report, string $outputFile): bool { - $webhookUrl = config('warden.webhook_url'); - $emailRecipients = config('warden.email_recipients'); - - $message = "The following packages are marked as abandoned:\n\n"; - foreach ($abandonedPackages as $abandonedPackage) { - $message .= '- ' . $abandonedPackage['package']; - if ($abandonedPackage['replacement']) { - $message .= sprintf(' (Recommended replacement: %s)', $abandonedPackage['replacement']); - } + if ($outputFile === '-') { + $this->output->write($report); - $message .= "\n"; - } - - if ($webhookUrl) { - Http::post($webhookUrl, ['text' => $message]); - } - - if ($emailRecipients) { - $recipients = is_string($emailRecipients) ? explode(',', $emailRecipients) : $emailRecipients; - Mail::raw($message, function ($message) use ($recipients): void { - $message->to($recipients) - ->subject('Warden Audit - Abandoned Packages Found'); - }); + return true; } - } - /** - * Filter findings by severity level. - * - * @param array> $findings List of vulnerability findings - * @param string $minSeverity Minimum severity level to include - * @return array> Filtered findings - */ - protected function filterBySeverity(array $findings, string $minSeverity): array - { - $severityLevels = [ - 'low' => 1, - 'medium' => 2, - 'high' => 3, - 'critical' => 4 - ]; + $path = str_starts_with($outputFile, DIRECTORY_SEPARATOR) ? $outputFile : base_path($outputFile); + if (@file_put_contents($path, $report) === false) { + $this->writeError(sprintf('Unable to write report to %s.', $outputFile)); - $minLevel = $severityLevels[$minSeverity] ?? 1; - - return array_filter($findings, function ($finding) use ($severityLevels, $minLevel) { - $findingSeverity = $finding['severity'] ?? 'low'; - $findingLevel = $severityLevels[$findingSeverity] ?? 1; - return $findingLevel >= $minLevel; - }); - } - - /** - * Output results in the specified format. - * - * @param array> $findings List of vulnerability findings - * @param string $format Output format (json|github|gitlab|jenkins) - */ - protected function outputFormattedResults(array $findings, string $format): void - { - switch ($format) { - case 'json': - $this->outputJson($findings); - break; - case 'github': - $this->outputGitHubActions($findings); - break; - case 'gitlab': - $this->outputGitLabCI($findings); - break; - case 'jenkins': - $this->outputJenkins($findings); - break; - default: - $this->error('Unsupported output format: ' . $format); - $this->info("Supported formats: json, github, gitlab, jenkins"); - break; + return false; } - } - /** - * Output findings in JSON format. - * - * @param array> $findings List of vulnerability findings - */ - protected function outputJson(array $findings): void - { - $output = [ - 'warden_version' => $this->getWardenVersion(), - 'scan_date' => Carbon::now()->toISOString(), - 'vulnerabilities_found' => count($findings), - 'findings' => $findings - ]; - - $jsonOutput = json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->output->writeln($jsonOutput); + return true; } - /** - * Output findings in GitHub Actions format. - * - * @param array> $findings List of vulnerability findings - */ - protected function outputGitHubActions(array $findings): void + private function writeError(string $message): void { - if ($findings === []) { - $this->output->writeln('::notice title=Warden Security Audit::No security issues found.'); - return; - } - - foreach ($findings as $finding) { - $level = in_array($finding['severity'], ['critical', 'high']) ? 'error' : 'warning'; - $title = $finding['title'] ?? 'Security vulnerability'; - $package = $finding['package'] ?? 'unknown'; - $severity = $finding['severity'] ?? 'unknown'; - - $titleEncoded = $this->escapeGitHubWorkflowKeyValue($title); - $messageEncoded = $this->escapeGitHubWorkflowMessage( - sprintf('%s - %s severity vulnerability found', $package, $severity) - ); - - $this->output->writeln(sprintf('::%s title=%s::%s', $level, $titleEncoded, $messageEncoded)); - } - } - - /** - * Output findings in GitLab CI format. - * - * @param array> $findings List of vulnerability findings - */ - protected function outputGitLabCI(array $findings): void - { - $vulnerabilities = []; - - foreach ($findings as $finding) { - $vulnerabilities[] = [ - 'id' => hash('sha256', serialize($finding)), - 'category' => 'dependency_scanning', - 'name' => $finding['title'] ?? 'Security vulnerability', - 'description' => $finding['description'] ?? $finding['title'] ?? 'Security vulnerability found', - 'severity' => strtoupper($finding['severity'] ?? 'Medium'), - 'scanner' => [ - 'id' => 'warden', - 'name' => 'Warden' - ], - 'location' => [ - 'file' => 'composer.json', - 'dependency' => [ - 'package' => [ - 'name' => $finding['package'] ?? 'unknown' - ] - ] - ] - ]; - } - - $output = [ - 'version' => '15.0.0', - 'vulnerabilities' => $vulnerabilities - ]; - - $jsonOutput = json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->output->writeln($jsonOutput); - } - - /** - * Output findings in Jenkins format. - * - * @param array> $findings List of vulnerability findings - */ - protected function outputJenkins(array $findings): void - { - $output = [ - 'warden_report' => [ - 'timestamp' => Carbon::now()->toISOString(), - 'total_vulnerabilities' => count($findings), - 'vulnerabilities' => $findings - ] - ]; - - $jsonOutput = json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->output->writeln($jsonOutput); - } - - /** - * Escape a value for GitHub Actions workflow command key=value pairs. - * Must escape: % → %25, \r → %0D, \n → %0A, : → %3A, , → %2C - */ - protected function escapeGitHubWorkflowKeyValue(string $value): string - { - return str_replace(['%', "\r", "\n", ':', ','], ['%25', '%0D', '%0A', '%3A', '%2C'], $value); - } - - /** - * Escape message content for GitHub Actions workflow command. - * Must escape: % → %25, \r → %0D, \n → %0A - */ - protected function escapeGitHubWorkflowMessage(string $value): string - { - return str_replace(['%', "\r", "\n"], ['%25', '%0D', '%0A'], $value); - } - - /** - * Get the current Warden version. - */ - protected function getWardenVersion(): string - { - $composerPath = __DIR__ . '/../../composer.json'; - - if (!file_exists($composerPath)) { - return 'unknown'; - } - - $composerJsonContent = file_get_contents($composerPath); - if ($composerJsonContent === false) { - return 'unknown'; - } - - $composerJson = json_decode($composerJsonContent, true); - if (!is_array($composerJson) || !isset($composerJson['version'])) { - return 'unknown'; - } - - return $composerJson['version']; + fwrite(STDERR, $message . PHP_EOL); } } diff --git a/src/Commands/WardenBaselineCommand.php b/src/Commands/WardenBaselineCommand.php new file mode 100644 index 0000000..0c9cfeb --- /dev/null +++ b/src/Commands/WardenBaselineCommand.php @@ -0,0 +1,161 @@ +option('profile'); + $scope = (string) $this->option('scope'); + $reason = trim((string) $this->option('reason')); + $expires = trim((string) $this->option('expires')); + $file = trim((string) ($this->option('file') ?: config('warden.baseline.file', 'warden-baseline.json'))); + $only = $this->csvOption('only'); + $skip = $this->csvOption('skip'); + $timeout = config('warden.audits.timeout', 300); + + if (!in_array($profile, ['ci', 'production', 'local'], true) || !in_array($scope, ['production', 'all'], true)) { + $this->error('Invalid profile or scope.'); + return 2; + } + + if ($reason === '' || $expires === '') { + $this->error('--reason and --expires are required.'); + return 2; + } + + if ($file === '' || !is_int($timeout) || $timeout < 1 || $timeout > 3600) { + $this->error('The baseline path and audit timeout must be valid.'); + return 2; + } + + if (array_intersect($only, $skip) !== []) { + $this->error('The same audit cannot be selected and skipped.'); + return 2; + } + + if ($only !== [] || $skip !== []) { + $available = $this->auditRunner->availableAuditIds(); + foreach ([...$only, ...$skip] as $audit) { + if (!in_array($audit, $available, true)) { + $this->error(sprintf('Unknown audit "%s".', $audit)); + return 2; + } + } + } + + try { + $expiry = CarbonImmutable::createFromFormat('!Y-m-d', $expires); + } catch (Throwable) { + $expiry = null; + } + + if (!$expiry instanceof \Carbon\CarbonImmutable || $expiry->format('Y-m-d') !== $expires || $expiry->endOfDay()->isPast()) { + $this->error('--expires must be a future date in YYYY-MM-DD format.'); + return 2; + } + + $auditContext = new AuditContext( + profile: $profile, + scope: $scope, + timeout: $timeout, + only: $only, + skip: $skip, + ); + $suppressionErrors = $this->suppressionService->errors(includeBaseline: false); + if ($suppressionErrors !== []) { + foreach ($suppressionErrors as $suppressionError) { + $this->error(sprintf('[%s:%s] %s', $suppressionError->audit, $suppressionError->code, $suppressionError->message)); + } + + return 2; + } + + $auditReport = $this->suppressionService->apply($this->auditRunner->run($auditContext), includeBaseline: false); + if ($auditReport->errors() !== []) { + foreach ($auditReport->errors() as $error) { + $this->error(sprintf('[%s:%s] %s', $error->audit, $error->code, $error->message)); + } + + return 2; + } + + $entries = array_map(static fn ($finding): array => [ + 'id' => $finding->id, + 'fingerprint' => $finding->fingerprint(), + 'reason' => $reason, + 'expires_at' => $expires, + ], $auditReport->findings()); + + $payload = [ + 'schema_version' => '1.0.0', + 'generated_at' => CarbonImmutable::now()->toISOString(), + 'profile' => $profile, + 'scope' => $scope, + 'findings' => $entries, + ]; + + try { + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + } catch (JsonException $jsonException) { + $this->error($jsonException->getMessage()); + return 2; + } + + $path = str_starts_with($file, DIRECTORY_SEPARATOR) ? $file : base_path($file); + if (@file_put_contents($path, $json) === false) { + $this->error(sprintf('Unable to write baseline to %s.', $file)); + return 2; + } + + $this->info(sprintf('Wrote %d baseline finding(s) to %s.', count($entries), $file)); + + return 0; + } + + /** @return list */ + private function csvOption(string $name): array + { + $value = $this->option($name); + if (!is_string($value) || trim($value) === '') { + return []; + } + + $items = array_values(array_unique(array_filter( + array_map('trim', explode(',', $value)), + static fn (string $item): bool => $item !== '', + ))); + sort($items); + + return $items; + } +} diff --git a/src/Commands/WardenScheduleCommand.php b/src/Commands/WardenScheduleCommand.php deleted file mode 100644 index bf9e881..0000000 --- a/src/Commands/WardenScheduleCommand.php +++ /dev/null @@ -1,169 +0,0 @@ -option('enable') && $this->option('disable')) { - $this->error('Cannot use --enable and --disable at the same time.'); - return 1; - } - - if ($this->option('enable')) { - return $this->enableSchedule(); - } - - if ($this->option('disable')) { - return $this->disableSchedule(); - } - - return $this->showStatus(); - } - - protected function enableSchedule(): int - { - $this->info('Enabling Warden scheduled audits...'); - - // Update .env file - $this->updateEnvironmentFile('WARDEN_SCHEDULE_ENABLED', 'true'); - - $this->info('✓ Scheduled audits enabled'); - $this->info(''); - $this->showScheduleInfo(); - - return 0; - } - - protected function disableSchedule(): int - { - $this->info('Disabling Warden scheduled audits...'); - - // Update .env file - $this->updateEnvironmentFile('WARDEN_SCHEDULE_ENABLED', 'false'); - - $this->info('✓ Scheduled audits disabled'); - - return 0; - } - - protected function showStatus(): int - { - $enabled = config('warden.schedule.enabled', false); - - $this->info('Warden Schedule Status'); - $this->info('======================'); - $this->info(''); - - if ($enabled) { - $this->info('Status: ENABLED'); - $this->showScheduleInfo(); - } else { - $this->info('Status: DISABLED'); - $this->info(''); - $this->info('Run php artisan warden:schedule --enable to enable scheduled audits'); - } - - return 0; - } - - protected function showScheduleInfo(): void - { - $frequency = config('warden.schedule.frequency', 'daily'); - $time = config('warden.schedule.time', '03:00'); - $timezone = config('warden.schedule.timezone', config('app.timezone')); - - $this->info(''); - $this->info('Schedule Configuration:'); - $this->info(sprintf(' Frequency: %s', $frequency)); - - if (in_array($frequency, ['daily', 'weekly', 'monthly'])) { - $this->info(sprintf(' Time: %s', $time)); - } - - $this->info(sprintf(' Timezone: %s', $timezone)); - $this->info(''); - - // Show next run time - $nextRun = $this->calculateNextRunTime($frequency, $time); - $this->info(sprintf('Next audit will run: %s', $nextRun)); - - $this->info(''); - $this->info('Note: Make sure your Laravel scheduler is running:'); - $this->info(' * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1'); - } - - protected function calculateNextRunTime(string $frequency, string $time): string - { - $now = now(); - - switch ($frequency) { - case 'hourly': - return $now->addHour()->startOfHour()->format('Y-m-d H:i:s T'); - - case 'daily': - $scheduledTime = today()->setTimeFromTimeString($time); - if ($scheduledTime->isPast()) { - $scheduledTime->addDay(); - } - - return $scheduledTime->format('Y-m-d H:i:s T'); - - case 'weekly': - $scheduledTime = today()->setTimeFromTimeString($time); - if ($scheduledTime->isPast()) { - $scheduledTime->addWeek(); - } - - return $scheduledTime->startOfWeek()->format('Y-m-d H:i:s T'); - - case 'monthly': - $scheduledTime = today()->setTimeFromTimeString($time); - if ($scheduledTime->isPast()) { - $scheduledTime->addMonth(); - } - - return $scheduledTime->startOfMonth()->format('Y-m-d H:i:s T'); - - default: - return 'Unknown'; - } - } - - protected function updateEnvironmentFile(string $key, string $value): void - { - $path = base_path('.env'); - - if (!file_exists($path)) { - return; - } - - $content = file_get_contents($path); - - // Check if key exists - if (preg_match(sprintf('/^%s=.*/m', $key), $content)) { - // Update existing key - $content = preg_replace( - sprintf('/^%s=.*/m', $key), - sprintf('%s=%s', $key, $value), - $content - ); - } else { - // Add new key - $content .= sprintf('%s%s=%s%s', PHP_EOL, $key, $value, PHP_EOL); - } - - file_put_contents($path, $content); - } -} \ No newline at end of file diff --git a/src/Commands/WardenSyntaxCommand.php b/src/Commands/WardenSyntaxCommand.php index 9ca4547..16dfcfb 100644 --- a/src/Commands/WardenSyntaxCommand.php +++ b/src/Commands/WardenSyntaxCommand.php @@ -1,69 +1,41 @@ syntaxService = $syntaxService; } public function handle(): int { - $this->info('Warden PHP Syntax Audit'); - - $result = spin( - fn() => $this->syntaxService->run(), - 'Running PHP syntax check...' - ); - - if ($result) { - info('✅ No PHP syntax errors found.'); - return 0; + $auditResult = $this->phpSyntaxAuditService->run(new AuditContext(profile: 'ci', scope: 'all')); + foreach ($auditResult->findings as $finding) { + $this->error(sprintf('%s: %s', $finding->path ?? 'unknown', $finding->description)); } - $findings = $this->syntaxService->getFindings(); - $this->displayFindings($findings); - - if (collect($findings)->contains('severity', 'error')) { + if ($auditResult->errors !== []) { return 2; } - return 1; - } - - protected function displayFindings(array $findings): void - { - $count = count($findings); - $this->error($count . ' syntax ' . ($count === 1 ? 'error' : 'errors') . ' found.'); - - $headers = ['File', 'Error Description']; - $rows = []; - - foreach ($findings as $finding) { - $rows[] = [ - $finding['title'], - $finding['description'], - ]; + if ($auditResult->findings !== []) { + return 1; } - table( - headers: $headers, - rows: $rows - ); + $this->info('No PHP syntax errors found.'); + + return 0; } } diff --git a/src/Contracts/AuditServiceInterface.php b/src/Contracts/AuditServiceInterface.php index cec0014..0070e76 100644 --- a/src/Contracts/AuditServiceInterface.php +++ b/src/Contracts/AuditServiceInterface.php @@ -1,15 +1,15 @@ > - */ - public function getFindings(): array; + public function run(AuditContext $auditContext): AuditResult; } diff --git a/src/Contracts/CustomAudit.php b/src/Contracts/CustomAudit.php index fd62bd4..3c9ba66 100644 --- a/src/Contracts/CustomAudit.php +++ b/src/Contracts/CustomAudit.php @@ -1,43 +1,19 @@ 'package-name', - * 'title' => 'Issue title', - * 'severity' => 'critical|high|medium|low', - * 'description' => 'Detailed description', - * 'cve' => 'CVE-2023-XXXXX', // optional - * 'affected_versions' => '< 2.0', // optional - * ] - */ - public function getFindings(): array; - - /** - * Get the name of this audit. - */ public function getName(): string; - /** - * Get the description of what this audit checks. - */ public function getDescription(): string; - /** - * Determine if this audit should be run. - */ - public function shouldRun(): bool; -} \ No newline at end of file + public function shouldRun(AuditContext $auditContext): bool; + + public function run(AuditContext $auditContext): AuditResult; +} diff --git a/src/Contracts/NotificationChannel.php b/src/Contracts/NotificationChannel.php index eb7b5fc..ddbf681 100644 --- a/src/Contracts/NotificationChannel.php +++ b/src/Contracts/NotificationChannel.php @@ -1,16 +1,22 @@ > $findings */ public function send(array $findings): void; /** * Send abandoned packages notification through this channel. + * + * @param array> $abandonedPackages */ public function sendAbandonedPackages(array $abandonedPackages): void; @@ -23,4 +29,4 @@ public function isConfigured(): bool; * Get the channel name. */ public function getName(): string; -} \ No newline at end of file +} diff --git a/src/Contracts/ReportFormatter.php b/src/Contracts/ReportFormatter.php new file mode 100644 index 0000000..010556b --- /dev/null +++ b/src/Contracts/ReportFormatter.php @@ -0,0 +1,12 @@ + 1, + self::Medium => 2, + self::High => 3, + self::Critical => 4, + }; + } + + public static function fromScannerValue(mixed $value, self $default = self::Medium): self + { + return is_string($value) ? (self::tryFrom(strtolower($value)) ?? $default) : $default; + } +} diff --git a/src/Examples/DatabasePasswordAudit.php b/src/Examples/DatabasePasswordAudit.php index 4fa2966..8ec9025 100644 --- a/src/Examples/DatabasePasswordAudit.php +++ b/src/Examples/DatabasePasswordAudit.php @@ -1,169 +1,49 @@ findings = []; - - // Check for common weak passwords in environment - $this->checkWeakPasswords(); - - // Check for password length - $this->checkPasswordLength(); - - // Check for default passwords - $this->checkDefaultPasswords(); - - // Check for passwords in version control - $this->checkPasswordsInVersionControl(); - - return $this->findings === []; - } - - /** - * Get the findings from this audit. - */ - public function getFindings(): array - { - return $this->findings; - } - - /** - * Get the name of this audit. - */ public function getName(): string { - return 'Database Password Security'; + return 'database-password'; } - - /** - * Get the description of what this audit checks. - */ + public function getDescription(): string { - return 'Checks for common database password security issues including weak passwords, short passwords, and default credentials.'; - } - - /** - * Determine if this audit should run. - */ - public function shouldRun(): bool - { - // Only run if database is configured - return !empty(env('DB_CONNECTION')); + return 'Checks the effective database configuration for common placeholder passwords.'; } - - /** - * Check for common weak passwords. - */ - protected function checkWeakPasswords(): void - { - $weakPasswords = [ - 'password', '123456', 'admin', 'root', 'pass', - 'secret', 'qwerty', 'letmein', 'password123' - ]; - - $dbPassword = env('DB_PASSWORD', ''); - - if (in_array(strtolower($dbPassword), $weakPasswords)) { - $this->findings[] = [ - 'package' => 'environment', - 'title' => 'Weak Database Password Detected', - 'severity' => 'critical', - 'description' => 'The database password is using a commonly known weak password. This poses a severe security risk.', - 'remediation' => 'Change the DB_PASSWORD to a strong, unique password with at least 16 characters including uppercase, lowercase, numbers, and special characters.', - ]; - } - } - - /** - * Check password length. - */ - protected function checkPasswordLength(): void + + public function shouldRun(AuditContext $auditContext): bool { - $dbPassword = env('DB_PASSWORD', ''); - - if (!empty($dbPassword) && strlen($dbPassword) < 12) { - $this->findings[] = [ - 'package' => 'environment', - 'title' => 'Short Database Password', - 'severity' => 'high', - 'description' => sprintf('The database password is only %d characters long. Short passwords are easier to crack.', strlen($dbPassword)), - 'remediation' => 'Use a database password with at least 16 characters for better security.', - ]; - } + return $auditContext->profile === 'production' && is_string(config('database.default')); } - - /** - * Check for default passwords. - */ - protected function checkDefaultPasswords(): void - { - $defaultPasswords = [ - 'mysql' => ['root' => ''], - 'postgres' => ['postgres' => 'postgres'], - 'sqlserver' => ['sa' => 'sa'], - ]; - - $connection = env('DB_CONNECTION'); - $username = env('DB_USERNAME'); - $password = env('DB_PASSWORD'); - - if (isset($defaultPasswords[$connection][$username]) && - $defaultPasswords[$connection][$username] === $password) { - $this->findings[] = [ - 'package' => 'environment', - 'title' => 'Default Database Credentials Detected', - 'severity' => 'critical', - 'description' => 'The database is using default credentials. This is a severe security vulnerability.', - 'remediation' => 'Immediately change both the database username and password from their default values.', - ]; - } - } - - /** - * Check for passwords in version control. - */ - protected function checkPasswordsInVersionControl(): void + + public function run(AuditContext $auditContext): AuditResult { - $suspiciousFiles = [ - '.env.example', - 'config/database.php', - 'docker-compose.yml', - 'docker-compose.yaml', - ]; - - foreach ($suspiciousFiles as $suspiciouFile) { - if (file_exists(base_path($suspiciouFile))) { - $content = file_get_contents(base_path($suspiciouFile)); - - // Look for actual passwords (not placeholders) - if (preg_match('/DB_PASSWORD\s*=\s*["\']?(?!your-password|password|secret|<[^>]+>|\$\{[^}]+\})[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};:\'",.<>?\\/]+["\']?/i', $content, $matches)) { - $this->findings[] = [ - 'package' => 'configuration', - 'title' => 'Potential Password in Version Control', - 'severity' => 'high', - 'description' => sprintf('File "%s" may contain an actual database password instead of a placeholder.', $suspiciouFile), - 'remediation' => 'Ensure only placeholder values are committed to version control. Use environment variables for actual passwords.', - ]; - } - } + $connection = config('database.default'); + $password = is_string($connection) ? config(sprintf('database.connections.%s.password', $connection)) : null; + if (!is_string($password) || !in_array(strtolower($password), ['password', '123456', 'admin'], true)) { + return AuditResult::complete($this->getName()); } - } -} \ No newline at end of file + + return AuditResult::complete($this->getName(), [new Finding( + id: 'custom.database-password.placeholder', + source: $this->getName(), + title: 'Database uses a common placeholder password', + severity: Severity::Critical, + description: 'The effective database password matches a commonly guessed placeholder.', + remediation: 'Rotate it to a unique secret stored by the deployment platform.', + package: 'application', + path: 'config/database.php', + )]); + } +} diff --git a/src/Notifications/Channels/DiscordChannel.php b/src/Notifications/Channels/DiscordChannel.php index 443dbd6..66d70c6 100644 --- a/src/Notifications/Channels/DiscordChannel.php +++ b/src/Notifications/Channels/DiscordChannel.php @@ -14,6 +14,7 @@ public function __construct() $this->webhookUrl = config('warden.notifications.discord.webhook_url'); } + /** @param array> $findings */ public function send(array $findings): void { if (!$this->isConfigured()) { @@ -32,9 +33,10 @@ public function send(array $findings): void 'avatar_url' => 'https://raw.githubusercontent.com/dgtlss/warden/main/public/warden-logo.png', 'content' => sprintf('🚨 **[%s] Security Audit Alert** - %d vulnerabilities found', $appName, count($findings)), 'embeds' => $embeds - ]); + ])->throw(); } + /** @param array> $abandonedPackages */ public function sendAbandonedPackages(array $abandonedPackages): void { if (!$this->isConfigured()) { @@ -53,7 +55,7 @@ public function sendAbandonedPackages(array $abandonedPackages): void 'avatar_url' => 'https://raw.githubusercontent.com/dgtlss/warden/main/public/warden-logo.png', 'content' => sprintf('⚠️ **[%s] Abandoned Packages Alert** - %d packages need attention', $appName, count($abandonedPackages)), 'embeds' => [$embed] - ]); + ])->throw(); } public function isConfigured(): bool @@ -97,7 +99,11 @@ protected function buildFindingsEmbeds(array $findings): array $value = $finding['title'] ?? 'Unknown vulnerability'; if (!empty($finding['cve'])) { - $value .= sprintf("\n[CVE: %s](https://www.cve.org/CVERecord?id=%s)", $finding['cve'], $finding['cve']); + $reference = (string) $finding['cve']; + $referenceUrl = filter_var($reference, FILTER_VALIDATE_URL) + ? $reference + : 'https://www.cve.org/CVERecord?id=' . rawurlencode($reference); + $value .= sprintf("\n[Reference: %s](%s)", $reference, $referenceUrl); } $fields[] = [ @@ -175,6 +181,7 @@ protected function buildAbandonedPackagesEmbed(array $abandonedPackages): array ]; } + /** @param array> $findings */ protected function getSeverityColor(array $findings): int { $hasCritical = false; @@ -205,4 +212,4 @@ protected function getSeverityColor(array $findings): int return 0x00FF00; // Green } -} \ No newline at end of file +} diff --git a/src/Notifications/Channels/EmailChannel.php b/src/Notifications/Channels/EmailChannel.php index 94a6bcd..33a0ead 100644 --- a/src/Notifications/Channels/EmailChannel.php +++ b/src/Notifications/Channels/EmailChannel.php @@ -23,6 +23,7 @@ public function __construct() $this->fromName = config('warden.notifications.email.from_name', 'Warden Security'); } + /** @param array> $findings */ public function send(array $findings): void { if (!$this->isConfigured()) { @@ -39,6 +40,7 @@ public function send(array $findings): void }); } + /** @param array> $abandonedPackages */ public function sendAbandonedPackages(array $abandonedPackages): void { if (!$this->isConfigured()) { @@ -112,6 +114,10 @@ protected function prepareAbandonedPackagesData(array $abandonedPackages): array ]; } + /** + * @param array> $findings + * @param array{critical: int, high: int, medium: int, low: int} $severityCounts + */ protected function generateSummary(array $findings, array $severityCounts): string { $totalFindings = count($findings); @@ -133,6 +139,7 @@ protected function generateSummary(array $findings, array $severityCounts): stri return $severityCounts['low'] . ' low severity vulnerabilities detected.'; } + /** @param array> $findings */ protected function generateSubject(array $findings): string { $appName = config('warden.app_name', 'Application'); @@ -156,6 +163,7 @@ protected function generateSubject(array $findings): string sprintf(' found (%s severity)', $highestSeverity); } + /** @param array> $abandonedPackages */ protected function generateAbandonedPackagesSubject(array $abandonedPackages): string { $appName = config('warden.app_name', 'Application'); @@ -163,4 +171,4 @@ protected function generateAbandonedPackagesSubject(array $abandonedPackages): s return sprintf('⚠️ [%s] Warden Alert: %d abandoned ', $appName, $count) . ($count === 1 ? 'package' : 'packages') . " detected"; } -} \ No newline at end of file +} diff --git a/src/Notifications/Channels/SlackChannel.php b/src/Notifications/Channels/SlackChannel.php index 951c5b0..118b383 100644 --- a/src/Notifications/Channels/SlackChannel.php +++ b/src/Notifications/Channels/SlackChannel.php @@ -14,6 +14,7 @@ public function __construct() $this->webhookUrl = config('warden.notifications.slack.webhook_url'); } + /** @param array> $findings */ public function send(array $findings): void { if (!$this->isConfigured()) { @@ -31,9 +32,10 @@ public function send(array $findings): void Http::post($this->webhookUrl, [ 'blocks' => $blocks, 'text' => sprintf('🚨 [%s] Warden Security Audit: %d vulnerabilities found', $appName, count($findings)) - ]); + ])->throw(); } + /** @param array> $abandonedPackages */ public function sendAbandonedPackages(array $abandonedPackages): void { if (!$this->isConfigured()) { @@ -51,7 +53,7 @@ public function sendAbandonedPackages(array $abandonedPackages): void Http::post($this->webhookUrl, [ 'blocks' => $blocks, 'text' => sprintf('⚠️ [%s] Warden Audit: %d abandoned packages found', $appName, count($abandonedPackages)) - ]); + ])->throw(); } public function isConfigured(): bool @@ -111,22 +113,26 @@ protected function buildFindingsBlocks(array $findings): array $severityEmoji, ucfirst($finding['severity']), $finding['title'], - $finding['package'], - $finding['source'] + $finding['package'] ?? 'application', + $finding['source'] ?? 'unknown' ) ] ]; if (!empty($finding['cve'])) { + $reference = (string) $finding['cve']; + $referenceUrl = filter_var($reference, FILTER_VALIDATE_URL) + ? $reference + : 'https://www.cve.org/CVERecord?id=' . rawurlencode($reference); $blocks[] = [ 'type' => 'context', 'elements' => [ [ 'type' => 'mrkdwn', 'text' => sprintf( - '*CVE:* <%s|%s>', - 'https://www.cve.org/CVERecord?id=' . $finding['cve'], - $finding['cve'] + '*Reference:* <%s|%s>', + $referenceUrl, + $reference ) ] ] @@ -183,4 +189,4 @@ protected function buildAbandonedPackagesBlocks(array $abandonedPackages): array return $blocks; } -} \ No newline at end of file +} diff --git a/src/Notifications/Channels/TeamsChannel.php b/src/Notifications/Channels/TeamsChannel.php index 1c18cee..8a6423c 100644 --- a/src/Notifications/Channels/TeamsChannel.php +++ b/src/Notifications/Channels/TeamsChannel.php @@ -16,6 +16,7 @@ public function __construct() $this->webhookUrl = config('warden.notifications.teams.webhook_url'); } + /** @param array> $findings */ public function send(array $findings): void { if (!$this->isConfigured()) { @@ -28,9 +29,10 @@ public function send(array $findings): void return; } - Http::post($this->webhookUrl, $card); + Http::post($this->webhookUrl, $card)->throw(); } + /** @param array> $abandonedPackages */ public function sendAbandonedPackages(array $abandonedPackages): void { if (!$this->isConfigured()) { @@ -43,7 +45,7 @@ public function sendAbandonedPackages(array $abandonedPackages): void return; } - Http::post($this->webhookUrl, $card); + Http::post($this->webhookUrl, $card)->throw(); } public function isConfigured(): bool @@ -225,6 +227,10 @@ protected function getSeverityColor(string $severity): string }; } + /** + * @param array> $findings + * @param array{critical: int, high: int, medium: int, low: int} $severityCounts + */ protected function generateSummary(array $findings, array $severityCounts): string { $criticalAndHigh = $severityCounts['critical'] + $severityCounts['high']; @@ -239,4 +245,4 @@ protected function generateSummary(array $findings, array $severityCounts): stri return $severityCounts['low'] . ' low severity vulnerabilities detected'; } -} \ No newline at end of file +} diff --git a/src/Notifications/Concerns/HasSeverityHelpers.php b/src/Notifications/Concerns/HasSeverityHelpers.php index 6d37688..a86d765 100644 --- a/src/Notifications/Concerns/HasSeverityHelpers.php +++ b/src/Notifications/Concerns/HasSeverityHelpers.php @@ -4,6 +4,10 @@ trait HasSeverityHelpers { + /** + * @param array> $findings + * @return array{critical: int, high: int, medium: int, low: int} + */ protected function getSeverityCounts(array $findings): array { $counts = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0]; @@ -18,6 +22,7 @@ protected function getSeverityCounts(array $findings): array return $counts; } + /** @param array> $findings */ protected function getHighestSeverity(array $findings): string { $severityLevels = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1]; @@ -48,6 +53,10 @@ protected function getSeverityEmoji(string $severity): string }; } + /** + * @param array> $findings + * @return array>> + */ protected function groupFindingsBySource(array $findings): array { $grouped = []; diff --git a/src/Providers/WardenServiceProvider.php b/src/Providers/WardenServiceProvider.php index 636a7b3..d830af8 100644 --- a/src/Providers/WardenServiceProvider.php +++ b/src/Providers/WardenServiceProvider.php @@ -1,86 +1,35 @@ mergeConfigFrom(__DIR__.'/../config/warden.php', 'warden'); - - // Register services - $this->app->singleton(AuditCacheService::class, function ($app) { - return new AuditCacheService(); - }); - - $this->app->bind(AuditExecutor::class, function () { - return new AuditExecutor(); - }); + $this->mergeConfigFrom(__DIR__ . '/../config/warden.php', 'warden'); } public function boot(): void { - // Publish configuration $this->publishes([ - __DIR__.'/../config/warden.php' => config_path('warden.php'), + __DIR__ . '/../config/warden.php' => config_path('warden.php'), ], 'warden-config'); - // Publish migrations - if (config('warden.history.enabled', false)) { - $this->publishes([ - __DIR__.'/../database/migrations/' => database_path('migrations'), - ], 'warden-migrations'); - } - - // Register commands if ($this->app->runningInConsole()) { $this->commands([ WardenAuditCommand::class, - WardenScheduleCommand::class, + WardenBaselineCommand::class, WardenSyntaxCommand::class, ]); - - // Schedule the command if enabled - if (config('warden.schedule.enabled', false)) { - $this->app->booted(function (): void { - $schedule = $this->app->make(Schedule::class); - $frequency = config('warden.schedule.frequency', 'daily'); - $time = config('warden.schedule.time', '03:00'); - - $event = $schedule->command('warden:audit --no-notify'); - - switch ($frequency) { - case 'hourly': - $event->hourly(); - break; - case 'daily': - $event->dailyAt($time); - break; - case 'weekly': - $event->weeklyOn(1, $time); // Monday - break; - case 'monthly': - $event->monthlyOn(1, $time); // 1st of month - break; - default: - $event->daily(); - } - - if ($timezone = config('warden.schedule.timezone')) { - $event->timezone($timezone); - } - }); - } } - $this->loadViewsFrom(__DIR__.'/../views', 'warden'); + $this->loadViewsFrom(__DIR__ . '/../views', 'warden'); } -} \ No newline at end of file +} diff --git a/src/Reporters/ConsoleReporter.php b/src/Reporters/ConsoleReporter.php new file mode 100644 index 0000000..0a97ad3 --- /dev/null +++ b/src/Reporters/ConsoleReporter.php @@ -0,0 +1,67 @@ +context->profile, $auditReport->context->scope), + '', + ]; + + foreach ($auditReport->audits as $audit) { + $lines[] = sprintf( + '[%s] %s (%sms, %d findings)', + $audit->succeeded() ? 'PASS' : 'FAIL', + $audit->audit, + number_format($audit->durationMs, 1), + count($audit->findings), + ); + } + + if ($auditReport->errors() !== []) { + $lines[] = ''; + $lines[] = sprintf('%d audit/configuration error(s):', count($auditReport->errors())); + foreach ($auditReport->errors() as $error) { + $lines[] = sprintf(' - [%s:%s] %s', $error->audit, $error->code, $error->message); + } + } + + $lines[] = ''; + if ($auditReport->findings() === []) { + $lines[] = 'No active security findings.'; + } else { + $lines[] = sprintf('%d active finding(s):', count($auditReport->findings())); + foreach ($auditReport->findings() as $finding) { + $location = $finding->path === null ? '' : sprintf(' [%s%s]', $finding->path, $finding->line === null ? '' : ':' . $finding->line); + $lines[] = sprintf( + ' - %s %s: %s%s%s', + strtoupper($finding->severity->value), + $finding->id, + $finding->title, + $location, + $finding->blocking ? '' : ' (warning only)', + ); + $lines[] = ' ' . $finding->description; + if ($finding->remediation !== null) { + $lines[] = ' Fix: ' . $finding->remediation; + } + } + } + + if ($auditReport->ignoredFindings !== []) { + $lines[] = ''; + $lines[] = sprintf('%d finding(s) suppressed by reviewed policy or baseline.', count($auditReport->ignoredFindings)); + } + + return implode(PHP_EOL, $lines) . PHP_EOL; + } +} diff --git a/src/Reporters/GitHubReporter.php b/src/Reporters/GitHubReporter.php new file mode 100644 index 0000000..80ef73d --- /dev/null +++ b/src/Reporters/GitHubReporter.php @@ -0,0 +1,49 @@ +errors() as $auditError) { + $lines[] = sprintf('::error title=%s::%s', $this->key($auditError->code), $this->message($auditError->message)); + } + + foreach ($auditReport->findings() as $finding) { + $level = in_array($finding->severity->value, ['critical', 'high'], true) ? 'error' : 'warning'; + $properties = ['title=' . $this->key($finding->id)]; + if ($finding->path !== null) { + $properties[] = 'file=' . $this->key($finding->path); + } + + if ($finding->line !== null) { + $properties[] = 'line=' . $finding->line; + } + + $lines[] = sprintf('::%s %s::%s', $level, implode(',', $properties), $this->message($finding->title . ' — ' . $finding->description)); + } + + if ($lines === []) { + $lines[] = '::notice title=Warden::No security issues found.'; + } + + return implode(PHP_EOL, $lines) . PHP_EOL; + } + + private function key(string $value): string + { + return str_replace(['%', "\r", "\n", ':', ','], ['%25', '%0D', '%0A', '%3A', '%2C'], $value); + } + + private function message(string $value): string + { + return str_replace(['%', "\r", "\n"], ['%25', '%0D', '%0A'], $value); + } +} diff --git a/src/Reporters/GitLabReporter.php b/src/Reporters/GitLabReporter.php new file mode 100644 index 0000000..ecb2b14 --- /dev/null +++ b/src/Reporters/GitLabReporter.php @@ -0,0 +1,112 @@ +scannedAt ?? now())->format('Y-m-d\TH:i:s'); + $version = '2.0.0'; + $component = [ + 'id' => 'warden', + 'name' => 'Warden', + 'url' => 'https://github.com/dgtlss/warden', + 'vendor' => ['name' => 'Dgtlss'], + 'version' => $version, + ]; + + $payload = [ + 'version' => '15.2.4', + 'scan' => [ + 'analyzer' => $component, + 'scanner' => $component, + 'type' => 'dependency_scanning', + 'start_time' => $timestamp, + 'end_time' => $timestamp, + 'status' => $auditReport->errors() === [] ? 'success' : 'failure', + 'messages' => array_map(static fn ($error): array => [ + 'level' => 'fatal', + 'value' => sprintf('%s: %s', $error->code, $error->message), + ], $auditReport->errors()), + ], + 'vulnerabilities' => array_map(fn (Finding $finding): array => $this->vulnerability($finding), $auditReport->findings()), + 'dependency_files' => $this->dependencyFiles($auditReport), + ]; + + return json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + } + + /** @return array */ + private function vulnerability(Finding $finding): array + { + $path = $finding->path ?? ($finding->source === 'npm' ? 'package-lock.json' : 'composer.lock'); + $identifier = $finding->reference ?? $finding->id; + $identifierData = [ + 'type' => str_contains(strtoupper($identifier), 'CVE-') ? 'cve' : 'warden', + 'name' => $identifier, + 'value' => $identifier, + ]; + if ($finding->reference !== null) { + $identifierData['url'] = $finding->reference; + } + + return [ + 'id' => $finding->fingerprint(), + 'category' => 'dependency_scanning', + 'name' => $finding->title, + 'description' => $finding->description, + 'severity' => ucfirst($finding->severity->value), + 'solution' => $finding->remediation ?? 'Review and remediate the finding.', + 'scanner' => ['id' => 'warden', 'name' => 'Warden'], + 'location' => [ + 'file' => $path, + 'dependency' => [ + 'package' => ['name' => $finding->package ?? 'application'], + 'version' => is_string($finding->metadata['affected_versions'] ?? null) + ? $finding->metadata['affected_versions'] + : 'unknown', + ], + ], + 'identifiers' => [$identifierData], + 'links' => $finding->reference === null ? [] : [['url' => $finding->reference]], + ]; + } + + /** @return list> */ + private function dependencyFiles(AuditReport $auditReport): array + { + $files = []; + foreach ($auditReport->findings() as $finding) { + if ($finding->package === null || $finding->path === null) { + continue; + } + + $files[$finding->path][] = [ + 'package' => ['name' => $finding->package], + 'version' => is_string($finding->metadata['affected_versions'] ?? null) + ? $finding->metadata['affected_versions'] + : 'unknown', + ]; + } + + $output = []; + foreach ($files as $path => $dependencies) { + $output[] = [ + 'path' => $path, + 'package_manager' => $path === 'package-lock.json' ? 'npm' : 'composer', + 'dependencies' => $dependencies, + ]; + } + + return $output; + } +} diff --git a/src/Reporters/JsonReporter.php b/src/Reporters/JsonReporter.php new file mode 100644 index 0000000..c9f6783 --- /dev/null +++ b/src/Reporters/JsonReporter.php @@ -0,0 +1,18 @@ +errors() as $auditError) { + $cases[] = sprintf( + ' %s', + $this->escape('warden.' . $auditError->audit), + $this->escape($auditError->code), + $this->escape($auditError->message), + $this->escape($auditError->message), + ); + } + + foreach ($auditReport->findings() as $finding) { + $className = $this->escape('warden.' . $finding->source); + $name = $this->escape($finding->id . ':' . $finding->fingerprint()); + if ($finding->blocking) { + $cases[] = sprintf( + ' %s', + $className, + $name, + $this->escape($finding->severity->value), + $this->escape($finding->title), + $this->escape($finding->description), + ); + } else { + $cases[] = sprintf( + ' %s', + $className, + $name, + $this->escape($finding->description), + ); + } + } + + if ($cases === []) { + $cases[] = ' '; + } + + return sprintf( + "\n\n%s\n\n", + count($cases), + count(array_filter($auditReport->findings(), static fn ($finding): bool => $finding->blocking)), + count($auditReport->errors()), + implode("\n", $cases), + ); + } + + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'); + } +} diff --git a/src/Reporters/SarifReporter.php b/src/Reporters/SarifReporter.php new file mode 100644 index 0000000..7847e4c --- /dev/null +++ b/src/Reporters/SarifReporter.php @@ -0,0 +1,73 @@ +findings(); + $rules = []; + foreach ($findings as $finding) { + $rules[$finding->id] = [ + 'id' => $finding->id, + 'shortDescription' => ['text' => $finding->title], + 'fullDescription' => ['text' => $finding->description], + 'help' => ['text' => $finding->remediation ?? $finding->description], + 'properties' => ['security-severity' => (string) ($finding->severity->weight() * 2.5)], + ]; + } + + $payload = [ + 'version' => '2.1.0', + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'runs' => [[ + 'tool' => ['driver' => ['name' => 'Warden', 'informationUri' => 'https://github.com/dgtlss/warden', 'rules' => array_values($rules)]], + 'results' => array_map(fn (Finding $finding): array => $this->result($finding), $findings), + 'invocations' => [[ + 'executionSuccessful' => $auditReport->errors() === [], + 'toolExecutionNotifications' => array_map(static fn ($error): array => [ + 'level' => 'error', + 'message' => ['text' => sprintf('%s: %s', $error->code, $error->message)], + ], $auditReport->errors()), + ]], + ]], + ]; + + return json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + } + + /** @return array */ + private function result(Finding $finding): array + { + $result = [ + 'ruleId' => $finding->id, + 'level' => match ($finding->severity->value) { + 'critical', 'high' => 'error', + 'medium' => 'warning', + default => 'note', + }, + 'message' => ['text' => $finding->title . ' — ' . $finding->description], + 'partialFingerprints' => ['wardenFingerprint/v1' => $finding->fingerprint()], + ]; + + if ($finding->path !== null) { + $result['locations'] = [[ + 'physicalLocation' => [ + 'artifactLocation' => ['uri' => $finding->path], + 'region' => ['startLine' => $finding->line ?? 1], + ], + ]]; + } + + return $result; + } +} diff --git a/src/Services/AuditCacheService.php b/src/Services/AuditCacheService.php deleted file mode 100644 index c8016f4..0000000 --- a/src/Services/AuditCacheService.php +++ /dev/null @@ -1,123 +0,0 @@ -cacheDuration = config('warden.cache.duration', 3600); // Default 1 hour - } - - /** - * Check if an audit has been recently run. - */ - public function hasRecentAudit(string $auditName): bool - { - return Cache::has($this->getCacheKey($auditName)); - } - - /** - * Get cached audit result. - * - * @return array>|null - */ - /** - * @return array{result: array>, timestamp: string, cached: bool}|null - */ - public function getCachedResult(string $auditName): ?array - { - $cached = Cache::get($this->getCacheKey($auditName)); - - if (!is_array($cached)) { - return null; - } - - if (!isset($cached['timestamp']) || !is_string($cached['timestamp'])) { - return null; - } - - if (!isset($cached['result']) || !is_array($cached['result'])) { - return null; - } - - $cached['cached'] = (bool) ($cached['cached'] ?? false); - - return $cached; - } - - /** - * Store audit result in cache. - */ - public function storeResult(string $auditName, array $result): void - { - Cache::put( - $this->getCacheKey($auditName), - [ - 'result' => $result, - 'timestamp' => Carbon::now()->toIso8601String(), - 'cached' => true - ], - $this->cacheDuration - ); - } - - /** - * Clear cached audit results. - */ - public function clearCache(?string $auditName = null): void - { - if ($auditName) { - Cache::forget($this->getCacheKey($auditName)); - } else { - foreach (self::AUDIT_NAMES as $name) { - Cache::forget($this->getCacheKey($name)); - } - } - } - - /** - * Get the cache key for an audit. - */ - protected function getCacheKey(string $auditName): string - { - return $this->cachePrefix . md5($auditName); - } - - /** - * Get time until next audit is allowed. - * - * @return int|null Seconds until next audit, null if not cached - */ - public function getTimeUntilNextAudit(string $auditName): ?int - { - $cached = $this->getCachedResult($auditName); - if ($cached === null) { - return null; - } - - $cachedAt = Carbon::parse($cached['timestamp']); - $expiresAt = $cachedAt->addSeconds($this->cacheDuration); - - return (int) max(0, $expiresAt->diffInSeconds(Carbon::now())); - } -} diff --git a/src/Services/AuditExecutor.php b/src/Services/AuditExecutor.php index dab6955..2afb62c 100644 --- a/src/Services/AuditExecutor.php +++ b/src/Services/AuditExecutor.php @@ -1,87 +1,90 @@ audits[$auditService->getName()] = $auditService; - } - /** - * @return array Registered audit services keyed by name. + * @param list $audits + * @param callable(string, string, ?float): void|null $onProgress + * @return list */ - public function getAudits(): array + public function execute(AuditContext $auditContext, array $audits, ?callable $onProgress = null): array { - return $this->audits; - } - - /** - * Execute all registered audits. - * - * @param callable|null $onProgress Called with (string $name, string $status, ?float $durationMs) per audit - * @return array - */ - public function execute(?callable $onProgress = null): array - { - if ($this->audits === []) { - return []; - } - $results = []; - foreach ($this->audits as $name => $auditService) { + foreach ($audits as $audit) { + $name = $audit->getName(); if ($onProgress !== null) { $onProgress($name, 'running', null); } - $start = microtime(true); - $success = $auditService->run(); - $durationMs = round((microtime(true) - $start) * 1000, 1); + $startedAt = microtime(true); - $results[$name] = [ - 'success' => $success, - 'findings' => $auditService->getFindings(), - 'service' => $auditService, - ]; + try { + $result = $audit->run($auditContext); + if (!$this->isValidResult($name, $result)) { + $result = AuditResult::failed($name, 'invalid_result', 'The audit returned an invalid or mismatched result.'); + } + } catch (Throwable $throwable) { + $result = AuditResult::failed($name, 'unhandled_exception', $throwable->getMessage()); + } + $duration = round((microtime(true) - $startedAt) * 1000, 1); + $result = $result->withDuration($duration); + $results[] = $result; if ($onProgress !== null) { - $status = $success ? 'done' : 'failed'; - $onProgress($name, $status, $durationMs); + $onProgress($name, $result->succeeded() ? 'done' : 'failed', $duration); } } - $this->results = $results; - return $results; } - public function getAllFindings(): Collection + private function isValidResult(string $audit, AuditResult $auditResult): bool { - return collect($this->results) - ->filter(fn($result) => !empty($result['findings'])) - ->pluck('findings') - ->flatten(1); - } + if ($auditResult->audit !== $audit) { + return false; + } - public function hasFailures(): bool - { - return collect($this->results) - ->contains(fn($result) => !$result['success']); + if (!$this->containsOnly($auditResult->findings, Finding::class)) { + return false; + } + + if (!$this->containsOnly($auditResult->errors, AuditError::class)) { + return false; + } + + foreach ($auditResult->errors as $error) { + if ($error->audit !== $audit) { + return false; + } + } + + return true; } - public function getFailedAudits(): Collection + /** + * @param array $items + * @param class-string $type + */ + private function containsOnly(array $items, string $type): bool { - return collect($this->results) - ->filter(fn($result) => !$result['success']) - ->keys(); + foreach ($items as $item) { + if (!$item instanceof $type) { + return false; + } + } + + return true; } } diff --git a/src/Services/AuditRunner.php b/src/Services/AuditRunner.php new file mode 100644 index 0000000..cb8d078 --- /dev/null +++ b/src/Services/AuditRunner.php @@ -0,0 +1,130 @@ +services($auditContext); + if ($errors !== []) { + return new AuditReport($auditContext, [], configurationErrors: $errors, scannedAt: CarbonImmutable::now()); + } + + $results = $this->auditExecutor->execute($auditContext, $services, $onProgress); + + return new AuditReport($auditContext, $results, configurationErrors: $errors, scannedAt: CarbonImmutable::now()); + } + + /** @return list */ + public function availableAuditIds(): array + { + $ids = ['supply-chain', 'composer', 'npm', 'laravel-config', 'storage']; + $customAudits = config('warden.custom_audits', []); + if (is_array($customAudits)) { + foreach ($customAudits as $customAudit) { + if (!is_string($customAudit) || !class_exists($customAudit)) { + continue; + } + + try { + $audit = $this->container->make($customAudit); + if ($audit instanceof CustomAudit) { + $ids[] = $audit->getName(); + } + } catch (Throwable) { + // Invalid custom audits are represented as report errors when a run starts. + } + } + } + + return array_values(array_unique($ids)); + } + + /** + * @return array{0: list, 1: list} + */ + private function services(AuditContext $auditContext): array + { + $services = [ + $this->container->make(SupplyChainAuditService::class), + $this->container->make(ComposerAuditService::class), + $this->container->make(NpmAuditService::class), + $this->container->make(LaravelConfigAuditService::class), + $this->container->make(StorageAuditService::class), + ]; + $errors = []; + + $customAudits = config('warden.custom_audits', []); + if (!is_array($customAudits)) { + $errors[] = new AuditError('configuration', 'invalid_custom_audits', 'warden.custom_audits must be an array.'); + $customAudits = []; + } + + foreach ($customAudits as $class) { + if (!is_string($class) || !class_exists($class)) { + $errors[] = new AuditError('configuration', 'custom_audit_not_found', sprintf('Custom audit class %s was not found.', is_scalar($class) ? (string) $class : 'unknown')); + continue; + } + + try { + $customAudit = $this->container->make($class); + if (!$customAudit instanceof CustomAudit) { + $errors[] = new AuditError('configuration', 'invalid_custom_audit', sprintf('%s must implement %s.', $class, CustomAudit::class)); + continue; + } + + $customName = $customAudit->getName(); + if (in_array($customName, array_map(static fn (AuditServiceInterface $auditService): string => $auditService->getName(), $services), true)) { + $errors[] = new AuditError('configuration', 'duplicate_audit_id', sprintf('Audit ID "%s" is already registered.', $customName)); + continue; + } + + if ($customAudit->shouldRun($auditContext)) { + $services[] = new CustomAuditWrapper($customAudit); + } + } catch (Throwable $throwable) { + $errors[] = new AuditError('configuration', 'custom_audit_initialization_failed', sprintf('%s: %s', $class, $throwable->getMessage())); + } + } + + $available = array_map(static fn (AuditServiceInterface $auditService): string => $auditService->getName(), $services); + foreach ([...$auditContext->only, ...$auditContext->skip] as $requested) { + if (!in_array($requested, $available, true)) { + $errors[] = new AuditError('configuration', 'unknown_audit', sprintf('Unknown audit "%s".', $requested)); + } + } + + $selected = array_values(array_filter( + $services, + static fn (AuditServiceInterface $auditService): bool => $auditContext->includes($auditService->getName()), + )); + + return [$selected, $errors]; + } +} diff --git a/src/Services/Audits/AbstractAuditService.php b/src/Services/Audits/AbstractAuditService.php deleted file mode 100644 index 544c35d..0000000 --- a/src/Services/Audits/AbstractAuditService.php +++ /dev/null @@ -1,32 +0,0 @@ -> - */ - protected array $findings = []; - - abstract public function run(): bool; - - abstract public function getName(): string; - - /** - * @return array> - */ - public function getFindings(): array - { - return $this->findings; - } - - protected function addFinding(array $finding): void - { - $this->findings[] = array_merge($finding, [ - 'source' => $this->getName() - ]); - } -} \ No newline at end of file diff --git a/src/Services/Audits/ComposerAuditService.php b/src/Services/Audits/ComposerAuditService.php index af55cc9..1b0b1db 100644 --- a/src/Services/Audits/ComposerAuditService.php +++ b/src/Services/Audits/ComposerAuditService.php @@ -1,93 +1,190 @@ > - */ - private array $abandonedPackages = []; - public function getName(): string { return 'composer'; } - public function run(): bool + public function run(AuditContext $auditContext): AuditResult { - $process = new Process(['composer', 'audit', '--format=json']); - $process->setWorkingDirectory(base_path()); - $process->setTimeout(60); - + if (!is_file(base_path('composer.lock'))) { + return AuditResult::complete($this->getName()); + } + + $command = ['composer', 'audit', '--locked', '--format=json', '--no-interaction', '--abandoned=report']; + if ($auditContext->scope === 'production') { + $command[] = '--no-dev'; + } + + $process = $this->createProcess($command, $auditContext->timeout); + try { $process->run(); - - // Exit code 1 from composer audit means vulnerabilities were found, which is okay - // Only treat it as an error if we can't parse the output as JSON - $output = json_decode($process->getOutput(), true); - if ($output === null) { - $errorOutput = $process->getErrorOutput() ?: $process->getOutput() ?: 'No error output available'; - $exitCode = $process->getExitCode(); - - $this->addFinding([ - 'source' => $this->getName(), - 'package' => 'composer', - 'title' => 'Composer audit failed to run', - 'severity' => 'high', - 'error' => "Exit Code: {$exitCode}\nError: {$errorOutput}" - ]); - - return false; + } catch (ProcessTimedOutException) { + return AuditResult::failed($this->getName(), 'timeout', 'Composer audit exceeded the configured timeout.'); + } + + $output = trim($process->getOutput()); + if ($output === '') { + return AuditResult::failed( + $this->getName(), + 'scanner_failed', + trim($process->getErrorOutput()) ?: 'Composer audit produced no JSON output.', + ); + } + + try { + $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + return AuditResult::failed($this->getName(), 'malformed_output', $jsonException->getMessage()); + } + + if (!is_array($decoded)) { + return AuditResult::failed($this->getName(), 'malformed_output', 'Composer audit returned an unexpected JSON value.'); + } + + $findings = [ + ...$this->advisoryFindings($decoded['advisories'] ?? []), + ...$this->abandonedFindings($decoded['abandoned'] ?? []), + ...$this->malwareFindings($decoded['malware'] ?? $decoded['malicious'] ?? []), + ]; + + if (!$process->isSuccessful() && $findings === []) { + return AuditResult::failed( + $this->getName(), + 'scanner_failed', + trim($process->getErrorOutput()) ?: sprintf('Composer audit exited with code %d.', $process->getExitCode()), + ); + } + + return AuditResult::complete($this->getName(), $findings); + } + + /** @param list $command */ + protected function createProcess(array $command, int $timeout): Process + { + $process = new Process($command, base_path()); + $process->setTimeout($timeout); + + return $process; + } + + /** @return list */ + private function advisoryFindings(mixed $advisories): array + { + if (!is_array($advisories)) { + return []; + } + + $findings = []; + foreach ($advisories as $package => $issues) { + if (!is_string($package) || !is_array($issues)) { + continue; } - // Handle abandoned packages (but don't fail the audit) - if (isset($output['abandoned']) && !empty($output['abandoned'])) { - foreach ($output['abandoned'] as $package => $replacement) { - $this->abandonedPackages[] = [ - 'package' => $package, - 'replacement' => is_string($replacement) ? $replacement : null - ]; + foreach ($issues as $issue) { + if (!is_array($issue)) { + continue; } + + $advisoryId = $this->stringValue($issue['advisoryId'] ?? $issue['cve'] ?? null) ?? 'unknown'; + $reference = $this->stringValue($issue['link'] ?? $issue['url'] ?? $issue['cve'] ?? null); + $findings[] = new Finding( + id: 'composer.advisory.' . strtolower($advisoryId), + source: $this->getName(), + title: $this->stringValue($issue['title'] ?? null) ?? 'Dependency security advisory', + severity: Severity::fromScannerValue($issue['severity'] ?? null), + description: sprintf('%s is affected by advisory %s.', $package, $advisoryId), + remediation: 'Upgrade the package to a version outside the affected range.', + package: $package, + reference: $reference, + path: 'composer.lock', + metadata: array_filter([ + 'affected_versions' => $this->stringValue($issue['affectedVersions'] ?? null), + ], static fn (mixed $value): bool => $value !== null), + ); } + } - // Handle security advisories - if (isset($output['advisories']) && !empty($output['advisories'])) { - foreach ($output['advisories'] as $package => $issues) { - foreach ($issues as $issue) { - $this->addFinding([ - 'source' => $this->getName(), - 'package' => $package, - 'title' => $issue['title'], - 'severity' => $issue['severity'] ?? 'unknown', - 'cve' => $issue['cve'] ?? null, - 'affected_versions' => $issue['affectedVersions'] ?? null - ]); - } - } + return $findings; + } + + /** @return list */ + private function abandonedFindings(mixed $abandoned): array + { + if (!is_array($abandoned)) { + return []; + } + + $findings = []; + foreach ($abandoned as $package => $replacement) { + if (!is_string($package)) { + continue; } - return true; - } catch (\Exception $exception) { - $this->addFinding([ - 'source' => $this->getName(), - 'package' => 'composer', - 'title' => 'Composer audit failed with exception', - 'severity' => 'high', - 'error' => $exception->getMessage() - ]); - - return false; + $replacement = is_string($replacement) && $replacement !== '' ? $replacement : null; + $findings[] = new Finding( + id: 'composer.abandoned', + source: $this->getName(), + title: 'Abandoned Composer package', + severity: Severity::Medium, + description: sprintf('%s is no longer maintained.', $package), + remediation: $replacement === null ? 'Replace or remove the package.' : 'Migrate to ' . $replacement . '.', + package: $package, + path: 'composer.lock', + metadata: $replacement === null ? [] : ['replacement' => $replacement], + ); + } + + return $findings; + } + + /** @return list */ + private function malwareFindings(mixed $malware): array + { + if (!is_array($malware)) { + return []; } + + $findings = []; + foreach ($malware as $package => $details) { + if (!is_string($package)) { + continue; + } + + $findings[] = new Finding( + id: 'composer.malware', + source: $this->getName(), + title: 'Package identified as malicious', + severity: Severity::Critical, + description: sprintf('%s was identified by Composer as malicious.', $package), + remediation: 'Remove the package and investigate the affected build immediately.', + package: $package, + reference: is_array($details) ? $this->stringValue($details['link'] ?? $details['url'] ?? null) : null, + path: 'composer.lock', + ); + } + + return $findings; } - /** - * @return array> - */ - public function getAbandonedPackages(): array + private function stringValue(mixed $value): ?string { - return $this->abandonedPackages; + return is_string($value) && $value !== '' ? $value : null; } -} \ No newline at end of file +} diff --git a/src/Services/Audits/ConfigAuditService.php b/src/Services/Audits/ConfigAuditService.php deleted file mode 100644 index 75c1ae3..0000000 --- a/src/Services/Audits/ConfigAuditService.php +++ /dev/null @@ -1,49 +0,0 @@ -addFinding([ - 'package' => 'config', - 'title' => 'Debug mode is enabled', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); - } - - // Check session configuration - if (config('session.secure') !== true) { - $this->addFinding([ - 'package' => 'config', - 'title' => 'Session cookies are not secure', - 'severity' => 'low', - 'cve' => null, - 'affected_versions' => null - ]); - } - - // Check CSRF protection - if (!in_array('web', config('app.middleware_groups.web', []))) { - $this->addFinding([ - 'package' => 'config', - 'title' => 'CSRF middleware may be missing', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); - } - - return true; - } -} \ No newline at end of file diff --git a/src/Services/Audits/DebugModeAuditService.php b/src/Services/Audits/DebugModeAuditService.php deleted file mode 100644 index 825640e..0000000 --- a/src/Services/Audits/DebugModeAuditService.php +++ /dev/null @@ -1,186 +0,0 @@ -addFinding([ - 'package' => 'app-config', - 'title' => 'Debug mode is enabled in production', - 'severity' => 'critical', - 'cve' => null, - 'affected_versions' => null - ]); - } - - // Only check for development packages if we're actually running in production - if ($this->isActuallyProduction()) { - // Check for development packages in vendor/composer/installed.json - $installedPackagesNames = $this->getInstalledPackagesNames(); - - foreach ($this->devPackages as $devPackage) { - if (in_array($devPackage, $installedPackagesNames)) { - $this->addFinding([ - 'package' => $devPackage, - 'title' => 'Development package detected in production', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); - } - } - - // Check if Telescope is enabled - if (class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled')) { - $this->addFinding([ - 'package' => 'laravel/telescope', - 'title' => 'Laravel Telescope is enabled in production', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); - } - - // Check if Horizon is enabled - if (class_exists(\Laravel\Horizon\Horizon::class) && config('horizon.enabled')) { - $this->addFinding([ - 'package' => 'laravel/horizon', - 'title' => 'Laravel Horizon dashboard is enabled in production', - 'severity' => 'medium', - 'cve' => null, - 'affected_versions' => null - ]); - } - } - - // Check for exposed testing routes only in production - if ($this->isActuallyProduction() && $this->hasExposedTestingRoutes()) { - $this->addFinding([ - 'package' => 'routes', - 'title' => 'Testing routes are exposed', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); - } - - return true; - } - - private function getInstalledPackagesNames(): array - { - $installedPackages = $this->getInstalledPackages(); - - return isset($installedPackages['packages']) - ? array_column($installedPackages['packages'], 'name') - : []; - } - - private function getInstalledPackages(): array - { - $installedPath = base_path('vendor/composer/installed.json'); - - if (!file_exists($installedPath)) { - return []; - } - - $installedContents = file_get_contents($installedPath); - return json_decode($installedContents, true); - } - - private function hasExposedTestingRoutes(): bool - { - $routes = \Route::getRoutes()->getRoutes(); - - // Check debugbar routes separately as they're allowed when APP_DEBUG is true - foreach ($routes as $route) { - $uri = $route->uri(); - if (str_starts_with($uri, '_debugbar')) { - // Only flag debugbar routes as exposed if APP_DEBUG is false and there's no protective middleware - if (!config('app.debug') && !$this->hasProtectiveMiddleware($route)) { - return true; - } - - continue; - } - - // Check other testing routes that should never be exposed in production - $testingRoutes = [ - 'telescope', - '_dusk', - ]; - - foreach ($testingRoutes as $testingRoute) { - if (str_starts_with($uri, $testingRoute)) { - return true; - } - } - } - - return false; - } - - /** - * @param object $route - */ - private function hasProtectiveMiddleware($route): bool - { - $middleware = $route->middleware(); - $protectiveMiddleware = [ - 'auth', - 'admin', - 'can:', - 'ability:', - 'role:', - 'Barryvdh\Debugbar\Middleware\DebugbarEnabled' - ]; - - foreach ($middleware as $m) { - foreach ($protectiveMiddleware as $protect) { - if (str_starts_with($m, $protect)) { - return true; - } - } - } - - return false; - } - - private function isActuallyProduction(): bool - { - // Check for common CI/CD environment variables - $ciEnvironments = [ - 'CI', - 'CONTINUOUS_INTEGRATION', - 'GITHUB_ACTIONS', - 'GITLAB_CI', - 'JENKINS_URL', - 'TRAVIS', - 'CIRCLECI' - ]; - - foreach ($ciEnvironments as $ciEnvironment) { - if (getenv($ciEnvironment) !== false) { - return false; - } - } - - return config('app.env') === 'production'; - } -} diff --git a/src/Services/Audits/EnvAuditService.php b/src/Services/Audits/EnvAuditService.php deleted file mode 100644 index d1b8f01..0000000 --- a/src/Services/Audits/EnvAuditService.php +++ /dev/null @@ -1,71 +0,0 @@ -sensitiveKeys = config('warden.sensitive_keys'); - } - - public function getName(): string - { - return 'environment'; - } - - public function run(): bool - { - // Check if .env exists - if (!file_exists(base_path('.env'))) { - $this->addFinding([ - 'package' => 'environment', - 'title' => 'Missing .env file', - 'severity' => 'critical', - 'cve' => null, - 'affected_versions' => null - ]); - return false; - } - - // Check if .env is in .gitignore - if (!$this->isEnvIgnored()) { - $this->addFinding([ - 'package' => 'environment', - 'title' => '.env file not listed in .gitignore', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); - } - - // Check for missing sensitive variables - foreach ($this->sensitiveKeys as $sensitiveKey) { - /** @phpstan-ignore-next-line Calling env is intentional inside audit */ - if (empty(env($sensitiveKey))) { - $this->addFinding([ - 'package' => 'environment', - 'title' => 'Missing sensitive environment variable: ' . $sensitiveKey, - 'severity' => 'medium', - 'cve' => null, - 'affected_versions' => null - ]); - } - } - - return true; - } - - private function isEnvIgnored(): bool - { - $gitignorePath = base_path('.gitignore'); - if (!file_exists($gitignorePath)) { - return false; - } - - $gitignore = file_get_contents($gitignorePath); - return strpos((string) $gitignore, '.env') !== false; - } -} diff --git a/src/Services/Audits/LaravelConfigAuditService.php b/src/Services/Audits/LaravelConfigAuditService.php new file mode 100644 index 0000000..305bf56 --- /dev/null +++ b/src/Services/Audits/LaravelConfigAuditService.php @@ -0,0 +1,182 @@ +trackedEnvironmentFindings(); + if ($auditContext->profile !== 'production') { + return AuditResult::complete($this->getName(), $findings); + } + + return AuditResult::complete($this->getName(), [ + ...$findings, + ...$this->applicationFindings(), + ...$this->sessionFindings(), + ...$this->toolingFindings(), + ]); + } + + /** @return list */ + private function trackedEnvironmentFindings(): array + { + if (!is_file(base_path('.env'))) { + return []; + } + + $process = new Process(['git', 'ls-files', '--error-unmatch', '.env'], base_path()); + $process->run(); + if (!$process->isSuccessful()) { + return []; + } + + return [new Finding( + id: 'laravel.env.tracked', + source: $this->getName(), + title: 'Environment file is tracked by Git', + severity: Severity::Critical, + description: '.env is present in the Git index and may expose application secrets.', + remediation: 'Remove .env from Git history and rotate every exposed secret.', + path: '.env', + )]; + } + + /** @return list */ + private function applicationFindings(): array + { + $findings = []; + if (config('app.debug') === true) { + $findings[] = $this->finding( + 'laravel.debug.enabled', + 'Debug mode is enabled in production', + Severity::Critical, + 'Detailed exception output can disclose secrets, source paths, and configuration.', + 'Set APP_DEBUG=false in the production configuration.', + 'config/app.php', + ); + } + + $cipher = config('app.cipher', 'AES-256-CBC'); + $key = config('app.key'); + if (!is_string($key) || !$this->validEncryptionKey($key, is_string($cipher) ? $cipher : '')) { + $findings[] = $this->finding( + 'laravel.app-key.invalid', + 'Application encryption key is missing or invalid', + Severity::Critical, + 'Laravel cannot safely encrypt cookies and application data with the configured key and cipher.', + 'Generate a correctly sized APP_KEY and inject it through the deployment secret store.', + 'config/app.php', + ); + } + + $url = config('app.url'); + if (!is_string($url) || !str_starts_with(strtolower($url), 'https://')) { + $findings[] = $this->finding( + 'laravel.url.insecure', + 'Production application URL is not HTTPS', + Severity::High, + 'Generated absolute URLs may use an insecure HTTP origin.', + 'Set APP_URL to the public HTTPS origin.', + 'config/app.php', + ); + } + + return $findings; + } + + /** @return list */ + private function sessionFindings(): array + { + $checks = [ + ['session.secure', true, 'laravel.session.secure', 'Session cookies are not restricted to HTTPS', Severity::High], + ['session.http_only', true, 'laravel.session.http-only', 'Session cookies are accessible to JavaScript', Severity::High], + ]; + $findings = []; + + foreach ($checks as [$key, $expected, $id, $title, $severity]) { + if (config($key) !== $expected) { + $findings[] = $this->finding( + $id, + $title, + $severity, + sprintf('%s should be enabled for production sessions.', $key), + sprintf('Set %s to true in the effective production configuration.', $key), + 'config/session.php', + ); + } + } + + $sameSite = strtolower((string) config('session.same_site', '')); + if (!in_array($sameSite, ['lax', 'strict', 'none'], true)) { + $findings[] = $this->finding( + 'laravel.session.same-site', + 'Session cookie SameSite policy is weak', + Severity::Medium, + 'The production session cookie is not configured with Lax or Strict SameSite protection.', + 'Set session.same_site to lax, strict, or an explicitly reviewed none policy.', + 'config/session.php', + ); + } + + return $findings; + } + + /** @return list */ + private function toolingFindings(): array + { + if (class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled') === true) { + return [new Finding( + id: 'laravel.telescope.enabled', + source: $this->getName(), + title: 'Laravel Telescope is enabled in production', + severity: Severity::High, + description: 'Telescope records sensitive request, query, job, and exception data.', + remediation: 'Disable Telescope or verify access authorization and data filtering before deployment.', + package: 'laravel/telescope', + path: 'config/telescope.php', + )]; + } + + return []; + } + + private function validEncryptionKey(string $key, string $cipher): bool + { + try { + $decoded = str_starts_with($key, 'base64:') ? base64_decode(substr($key, 7), true) : $key; + + return is_string($decoded) && Encrypter::supported($decoded, $cipher); + } catch (Throwable) { + return false; + } + } + + private function finding( + string $id, + string $title, + Severity $severity, + string $description, + string $remediation, + string $path, + ): Finding { + return new Finding($id, $this->getName(), $title, $severity, $description, $remediation, path: $path); + } +} diff --git a/src/Services/Audits/NpmAuditService.php b/src/Services/Audits/NpmAuditService.php index f404073..4be20e5 100644 --- a/src/Services/Audits/NpmAuditService.php +++ b/src/Services/Audits/NpmAuditService.php @@ -1,119 +1,133 @@ addFinding([ - 'package' => 'npm', - 'title' => 'Missing package.json', - 'severity' => 'error', - 'cve' => null, - 'affected_versions' => null - ]); - return false; + if (!is_file(base_path('package-lock.json'))) { + return AuditResult::complete($this->getName()); } - if (!file_exists(base_path('package-lock.json'))) { - $this->addFinding([ - 'package' => 'npm', - 'title' => 'Missing package-lock.json', - 'severity' => 'error', - 'cve' => null, - 'affected_versions' => null - ]); - return false; + $command = ['npm', 'audit', '--json', '--package-lock-only']; + if ($auditContext->scope === 'production') { + $command[] = '--omit=dev'; } - $process = new Process(['npm', 'audit', '--json']); - $process->setWorkingDirectory(base_path()); - $process->setTimeout(config('warden.audits.timeout', 300)); - + $process = $this->createProcess($command, $auditContext->timeout); try { $process->run(); - - // npm audit returns non-zero exit codes when vulnerabilities are found, which is normal - // Only treat it as an error if we can't parse the JSON output - $output = json_decode($process->getOutput(), true); - if ($output === null) { - $errorOutput = $process->getErrorOutput() ?: $process->getOutput() ?: 'No error output available'; - $exitCode = $process->getExitCode(); - - $this->addFinding([ - 'package' => 'npm', - 'title' => 'npm audit failed to run', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null, - 'error' => "Exit Code: {$exitCode}\nError: {$errorOutput}" - ]); - return false; - } + } catch (ProcessTimedOutException) { + return AuditResult::failed($this->getName(), 'timeout', 'npm audit exceeded the configured timeout.'); + } + + try { + $decoded = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + return AuditResult::failed( + $this->getName(), + 'malformed_output', + trim($process->getErrorOutput()) ?: $jsonException->getMessage(), + ); + } - // Handle modern npm audit format (npm 7+) - if (isset($output['vulnerabilities'])) { - foreach ($output['vulnerabilities'] as $package => $vulnerability) { - // Modern format has vulnerability details in the 'via' array - if (isset($vulnerability['via']) && is_array($vulnerability['via'])) { - foreach ($vulnerability['via'] as $viaItem) { - // Skip string entries (they're just package names), process array entries - if (is_array($viaItem)) { - $this->addFinding([ - 'package' => $package, - 'title' => $viaItem['title'] ?? 'Unknown vulnerability', - 'severity' => $viaItem['severity'] ?? 'unknown', - 'cve' => $viaItem['url'] ?? null, - 'affected_versions' => $viaItem['range'] ?? ($vulnerability['range'] ?? 'unknown') - ]); - } - } - } else { - // Fallback for potential legacy format or missing via array - $this->addFinding([ - 'package' => $package, - 'title' => $vulnerability['title'] ?? 'Unknown vulnerability', - 'severity' => $vulnerability['severity'] ?? 'unknown', - 'cve' => $vulnerability['url'] ?? null, - 'affected_versions' => $vulnerability['range'] ?? 'unknown' - ]); - } - } + if (!is_array($decoded)) { + return AuditResult::failed($this->getName(), 'malformed_output', 'npm audit returned an unexpected JSON value.'); + } + + $findings = $this->findingsFrom($decoded['vulnerabilities'] ?? []); + if (!$process->isSuccessful() && $findings === []) { + return AuditResult::failed( + $this->getName(), + 'scanner_failed', + $this->npmError($decoded, $process), + ); + } + + return AuditResult::complete($this->getName(), $findings); + } + + /** @param list $command */ + protected function createProcess(array $command, int $timeout): Process + { + $process = new Process($command, base_path()); + $process->setTimeout($timeout); + + return $process; + } + + /** @return list */ + private function findingsFrom(mixed $vulnerabilities): array + { + if (!is_array($vulnerabilities)) { + return []; + } + + $findings = []; + foreach ($vulnerabilities as $package => $vulnerability) { + if (!is_string($package) || !is_array($vulnerability)) { + continue; } - - // Handle legacy npm audit format (npm v6 and earlier) - advisories format - if (isset($output['advisories'])) { - foreach ($output['advisories'] as $advisory) { - $this->addFinding([ - 'package' => $advisory['module_name'] ?? 'unknown', - 'title' => $advisory['title'] ?? 'Unknown vulnerability', - 'severity' => $advisory['severity'] ?? 'unknown', - 'cve' => $advisory['cves'][0] ?? $advisory['url'] ?? null, - 'affected_versions' => $advisory['vulnerable_versions'] ?? 'unknown' - ]); - } + + $advisories = array_values(array_filter( + is_array($vulnerability['via'] ?? null) ? $vulnerability['via'] : [], + 'is_array', + )); + if ($advisories === []) { + $advisories = [$vulnerability]; } - return true; - } catch (\Exception $exception) { - $this->addFinding([ - 'package' => 'npm', - 'title' => 'npm audit failed with exception', - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null, - 'error' => $exception->getMessage() - ]); - return false; + foreach ($advisories as $advisory) { + $url = is_string($advisory['url'] ?? null) ? $advisory['url'] : null; + $identifier = is_int($advisory['source'] ?? null) + ? (string) $advisory['source'] + : ($url ?? (is_string($advisory['title'] ?? null) ? $advisory['title'] : 'unknown')); + $findings[] = new Finding( + id: 'npm.advisory.' . hash('sha256', $identifier), + source: $this->getName(), + title: is_string($advisory['title'] ?? null) ? $advisory['title'] : 'Dependency security advisory', + severity: Severity::fromScannerValue($advisory['severity'] ?? $vulnerability['severity'] ?? null), + description: sprintf('%s contains a known vulnerability.', $package), + remediation: 'Upgrade the dependency using the remediation provided by npm.', + package: $package, + reference: $url, + path: 'package-lock.json', + metadata: array_filter([ + 'affected_versions' => is_string($advisory['range'] ?? null) + ? $advisory['range'] + : (is_string($vulnerability['range'] ?? null) ? $vulnerability['range'] : null), + ], static fn (mixed $value): bool => $value !== null), + ); + } } + + return $findings; + } + + /** @param array $decoded */ + private function npmError(array $decoded, Process $process): string + { + $message = $decoded['error']['summary'] ?? $decoded['error']['detail'] ?? null; + + return is_string($message) && $message !== '' + ? $message + : (trim($process->getErrorOutput()) ?: sprintf('npm audit exited with code %d.', $process->getExitCode())); } -} \ No newline at end of file +} diff --git a/src/Services/Audits/PhpSyntaxAuditService.php b/src/Services/Audits/PhpSyntaxAuditService.php index fcf7b4b..7b3df5d 100644 --- a/src/Services/Audits/PhpSyntaxAuditService.php +++ b/src/Services/Audits/PhpSyntaxAuditService.php @@ -1,96 +1,92 @@ getProcess(); - $process->setTimeout(config('warden.audits.timeout', 300)); - $process->run(); - - // The command's output can be on stdout or stderr, so we combine them. - $output = $process->getOutput() . $process->getErrorOutput(); - $errors = $this->parseOutput($output); - - foreach ($errors as $error) { - $filePath = str_replace(base_path() . '/', '', $error['file']); - $this->addFinding([ - 'package' => 'Application Code', - 'title' => 'PHP Syntax Error in ' . $filePath, - 'severity' => 'high', - 'description' => $error['message'], - 'remediation' => 'Fix the syntax error in the specified file.', - ]); - } - - // If the process failed for a reason other than finding lint errors (e.g., command not found). - if (!$process->isSuccessful() && $errors === []) { - $this->addFinding([ - 'package' => 'Application Code', - 'title' => 'PHP Syntax Audit Failed to Run', - 'severity' => 'error', - 'description' => 'The PHP syntax audit process failed without reporting specific syntax errors.', - 'remediation' => 'Ensure `find`, `xargs`, and `php` are available. Error: ' . $process->getErrorOutput(), - ]); + $findings = []; + foreach ($this->phpFiles() as $path) { + $process = new Process([PHP_BINARY, '-l', $path], base_path(), null, null, $auditContext->timeout); + $process->run(); + if ($process->isSuccessful()) { + continue; + } + + $relativePath = ltrim(str_replace(base_path(), '', $path), DIRECTORY_SEPARATOR); + $findings[] = new Finding( + id: 'quality.php.syntax', + source: $this->getName(), + title: 'PHP syntax error', + severity: Severity::High, + description: trim($process->getErrorOutput() . "\n" . $process->getOutput()), + remediation: 'Correct the parse error before deployment.', + path: $relativePath, + ); } - // The audit passes if no findings were added. - return $this->findings === []; + return AuditResult::complete($this->getName(), $findings); } - protected function getProcess(): Process + /** @return list */ + private function phpFiles(): array { - $excludedDirs = config('warden.audits.php_syntax.exclude', [ - 'vendor', - 'node_modules', - 'storage', - 'bootstrap/cache', - '.git', + $excluded = config('warden.audits.php_syntax.exclude', [ + 'vendor', 'node_modules', 'storage', 'bootstrap/cache', '.git', ]); + $excluded = is_array($excluded) ? array_values(array_filter($excluded, 'is_string')) : []; + + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(base_path(), RecursiveDirectoryIterator::SKIP_DOTS), + ); - $pathsToPrune = collect($excludedDirs) - ->map(fn ($dir) => sprintf("-path './%s' -prune", $dir)) - ->implode(' -o '); + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relativePath = ltrim(str_replace(base_path(), '', $file->getPathname()), DIRECTORY_SEPARATOR); + if ($this->isExcluded($relativePath, $excluded)) { + continue; + } + + $files[] = $file->getPathname(); + } - $command = sprintf("find . %s -o -name '*.php' -print0 | xargs -0 -n1 -I{} php -l {}", $pathsToPrune); + sort($files); - // fromShellCommandline is used to properly handle shell piping. - return Process::fromShellCommandline($command, base_path()); + return $files; } - protected function parseOutput(string $output): array + /** @param list $excluded */ + private function isExcluded(string $path, array $excluded): bool { - $errors = []; - $lines = explode("\n", trim($output)); - $counter = count($lines); - - for ($i = 0; $i < $counter; $i++) { - if (str_contains($lines[$i], 'Errors parsing')) { - // The filename is on the same line as "Errors parsing". - $file = trim(substr($lines[$i], strpos($lines[$i], 'parsing') + 7)); - $errorMessage = 'Syntax error detected.'; - - // The detailed parse error message is usually on the next line. - if (isset($lines[$i + 1]) && str_contains($lines[$i + 1], 'Parse error:')) { - $errorMessage = trim($lines[$i + 1]); - } - - $errors[] = [ - 'file' => $file, - 'message' => $errorMessage, - ]; + foreach ($excluded as $directory) { + if ($path === $directory || str_starts_with($path, rtrim($directory, '/') . DIRECTORY_SEPARATOR)) { + return true; } } - return $errors; + return false; } -} \ No newline at end of file +} diff --git a/src/Services/Audits/StorageAuditService.php b/src/Services/Audits/StorageAuditService.php index fcf5e93..727f725 100644 --- a/src/Services/Audits/StorageAuditService.php +++ b/src/Services/Audits/StorageAuditService.php @@ -1,50 +1,48 @@ - */ - private array $directories = [ - 'storage/framework', - 'storage/logs', - 'bootstrap/cache', - ]; + /** @var list */ + private array $directories = ['storage/framework', 'storage/logs', 'bootstrap/cache']; public function getName(): string { return 'storage'; } - public function run(): bool + public function run(AuditContext $auditContext): AuditResult { + if ($auditContext->profile !== 'production') { + return AuditResult::complete($this->getName()); + } + + $findings = []; foreach ($this->directories as $directory) { $path = base_path($directory); - - if (!file_exists($path)) { - $this->addFinding([ - 'package' => 'storage', - 'title' => 'Missing directory: ' . $directory, - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); - continue; - } - - if (!is_writable($path)) { - $this->addFinding([ - 'package' => 'storage', - 'title' => 'Directory not writable: ' . $directory, - 'severity' => 'high', - 'cve' => null, - 'affected_versions' => null - ]); + if (!is_dir($path) || !is_writable($path)) { + $findings[] = new Finding( + id: 'deployment.storage.not-writable', + source: $this->getName(), + title: sprintf('Required directory is not writable: %s', $directory), + severity: Severity::Low, + description: 'Laravel may fail to write caches, compiled views, sessions, or logs.', + remediation: 'Grant the deployment user write access without making the directory world-writable.', + path: $directory, + blocking: false, + ); } } - return true; + return AuditResult::complete($this->getName(), $findings); } } diff --git a/src/Services/Audits/SupplyChainAuditService.php b/src/Services/Audits/SupplyChainAuditService.php new file mode 100644 index 0000000..eff9610 --- /dev/null +++ b/src/Services/Audits/SupplyChainAuditService.php @@ -0,0 +1,231 @@ +readJson('composer.json'); + if ($composer === null) { + return AuditResult::failed($this->getName(), 'invalid_composer_json', 'composer.json is missing or invalid JSON.'); + } + + [$composerFindings, $composerErrors] = $this->composerValidation(); + $findings = [ + ...$composerFindings, + ...$this->composerConfigurationFindings($composer), + ...$this->javascriptLockFindings(), + ]; + + return new AuditResult($this->getName(), $findings, $composerErrors); + } + + /** @return array{0: list, 1: list} */ + private function composerValidation(): array + { + if (!is_file(base_path('composer.lock'))) { + return [[new Finding( + id: 'supply-chain.composer-lock.missing', + source: $this->getName(), + title: 'Composer lockfile is missing', + severity: Severity::High, + description: 'Dependency versions are not pinned by composer.lock.', + remediation: 'Run composer update and commit composer.lock.', + path: 'composer.json', + )], []]; + } + + $process = $this->createComposerValidationProcess(); + $process->run(); + if ($process->isSuccessful()) { + return [[], []]; + } + + $output = trim($process->getErrorOutput() . "\n" . $process->getOutput()); + if (!str_contains(strtolower($output), 'lock file')) { + return [[], [new AuditError( + $this->getName(), + 'composer_validation_failed', + $output !== '' ? $output : 'Composer could not validate the dependency manifest.', + )]]; + } + + return [[new Finding( + id: 'supply-chain.composer-lock.stale', + source: $this->getName(), + title: 'Composer lockfile is not synchronized', + severity: Severity::High, + description: $output, + remediation: 'Run composer update for the changed constraints and commit the refreshed lockfile.', + path: 'composer.lock', + )], []]; + } + + protected function createComposerValidationProcess(): Process + { + return new Process( + ['composer', 'validate', '--no-check-publish', '--no-interaction'], + base_path(), + null, + null, + 60, + ); + } + + /** + * @param array $composer + * @return list + */ + private function composerConfigurationFindings(array $composer): array + { + $findings = []; + $config = is_array($composer['config'] ?? null) ? $composer['config'] : []; + + if (($config['secure-http'] ?? true) === false) { + $findings[] = new Finding( + id: 'supply-chain.composer.insecure-http', + source: $this->getName(), + title: 'Composer secure HTTP is disabled', + severity: Severity::High, + description: 'Composer is allowed to download packages over unencrypted HTTP.', + remediation: 'Remove secure-http=false and use HTTPS repositories.', + path: 'composer.json', + ); + } + + $allowPlugins = $config['allow-plugins'] ?? null; + if ($allowPlugins === true || (is_array($allowPlugins) && ($allowPlugins['*'] ?? false) === true)) { + $findings[] = new Finding( + id: 'supply-chain.composer.plugins.unrestricted', + source: $this->getName(), + title: 'Composer plugins are unrestricted', + severity: Severity::High, + description: 'Any dependency may execute Composer plugin code during installation.', + remediation: 'Allow only explicitly reviewed plugin package names.', + path: 'composer.json', + ); + } + + $repositories = $composer['repositories'] ?? []; + if (is_array($repositories)) { + foreach ($repositories as $repository) { + if (!is_array($repository)) { + continue; + } + + $url = $repository['url'] ?? null; + if (is_string($url) && str_starts_with(strtolower($url), 'http://')) { + $findings[] = new Finding( + id: 'supply-chain.composer.repository.insecure', + source: $this->getName(), + title: 'Insecure Composer repository URL', + severity: Severity::High, + description: sprintf('Composer repository %s uses unencrypted HTTP.', $url), + remediation: 'Use an HTTPS or authenticated SSH repository URL.', + reference: $url, + path: 'composer.json', + ); + } + } + } + + return $findings; + } + + /** @return list */ + private function javascriptLockFindings(): array + { + if (!is_file(base_path('package.json'))) { + return []; + } + + $supportedLock = is_file(base_path('package-lock.json')); + $unsupported = array_values(array_filter( + ['yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb'], + static fn (string $path): bool => is_file(base_path($path)), + )); + + if (!$supportedLock) { + return [new Finding( + id: $unsupported === [] ? 'supply-chain.javascript-lock.missing' : 'supply-chain.javascript-lock.unsupported', + source: $this->getName(), + title: $unsupported === [] ? 'JavaScript lockfile is missing' : 'JavaScript lockfile is not yet supported', + severity: $unsupported === [] ? Severity::High : Severity::Medium, + description: $unsupported === [] + ? 'JavaScript dependency versions are not pinned.' + : sprintf('Warden cannot audit %s yet.', implode(', ', $unsupported)), + remediation: $unsupported === [] + ? 'Generate and commit a package-lock.json file.' + : 'Run the package manager audit separately and select --skip=npm.', + path: 'package.json', + )]; + } + + $package = $this->readJson('package.json'); + $lock = $this->readJson('package-lock.json'); + if ($package === null || $lock === null) { + return [new Finding( + id: 'supply-chain.javascript-lock.invalid', + source: $this->getName(), + title: 'JavaScript manifest or lockfile is invalid', + severity: Severity::High, + description: 'package.json and package-lock.json must contain valid JSON.', + remediation: 'Regenerate package-lock.json with npm install --package-lock-only.', + path: 'package-lock.json', + )]; + } + + $root = is_array($lock['packages'][''] ?? null) ? $lock['packages'][''] : []; + foreach (['dependencies', 'devDependencies', 'optionalDependencies'] as $key) { + $manifestDependencies = is_array($package[$key] ?? null) ? $package[$key] : []; + $lockedDependencies = is_array($root[$key] ?? null) ? $root[$key] : []; + if ($manifestDependencies !== $lockedDependencies) { + return [new Finding( + id: 'supply-chain.javascript-lock.stale', + source: $this->getName(), + title: 'JavaScript lockfile is not synchronized', + severity: Severity::High, + description: sprintf('%s differs between package.json and package-lock.json.', $key), + remediation: 'Run npm install --package-lock-only and commit package-lock.json.', + path: 'package-lock.json', + )]; + } + } + + return []; + } + + /** @return array|null */ + private function readJson(string $path): ?array + { + $contents = @file_get_contents(base_path($path)); + if (!is_string($contents)) { + return null; + } + + try { + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } +} diff --git a/src/Services/CustomAuditWrapper.php b/src/Services/CustomAuditWrapper.php index 62a9af8..b43b9d9 100644 --- a/src/Services/CustomAuditWrapper.php +++ b/src/Services/CustomAuditWrapper.php @@ -1,19 +1,18 @@ customAudit = $customAudit; } public function getName(): string @@ -21,24 +20,8 @@ public function getName(): string return $this->customAudit->getName(); } - public function run(): bool - { - $success = $this->customAudit->audit(); - $this->findings = $this->customAudit->getFindings(); - - return $success; - } - - /** - * @return array> - */ - public function getFindings(): array - { - return $this->findings; - } - - public function shouldRun(): bool + public function run(AuditContext $auditContext): AuditResult { - return $this->customAudit->shouldRun(); + return $this->customAudit->run($auditContext); } } diff --git a/src/Services/NotificationDispatcher.php b/src/Services/NotificationDispatcher.php new file mode 100644 index 0000000..fb714a5 --- /dev/null +++ b/src/Services/NotificationDispatcher.php @@ -0,0 +1,55 @@ + Non-gating delivery warnings. */ + public function send(AuditReport $auditReport): array + { + $warnings = []; + foreach ($this->channels() as $notificationChannel) { + if (!$notificationChannel->isConfigured()) { + continue; + } + + try { + $notificationChannel->send(array_map( + static fn ($finding): array => [ + 'id' => $finding->id, + 'fingerprint' => $finding->fingerprint(), + 'source' => $finding->source, + 'package' => $finding->package ?? 'application', + 'title' => $finding->title, + 'severity' => $finding->severity->value, + 'description' => $finding->description, + 'remediation' => $finding->remediation, + 'cve' => $finding->reference, + 'affected_versions' => $finding->metadata['affected_versions'] ?? null, + ], + $auditReport->findings(), + )); + } catch (Throwable $throwable) { + $warnings[] = sprintf('%s notification failed: %s', $notificationChannel->getName(), $throwable->getMessage()); + } + } + + return $warnings; + } + + /** @return list */ + private function channels(): array + { + return [new SlackChannel(), new DiscordChannel(), new TeamsChannel(), new EmailChannel()]; + } +} diff --git a/src/Services/ReportFormatterFactory.php b/src/Services/ReportFormatterFactory.php new file mode 100644 index 0000000..368481b --- /dev/null +++ b/src/Services/ReportFormatterFactory.php @@ -0,0 +1,30 @@ + new ConsoleReporter(), + 'json' => new JsonReporter(), + 'github' => new GitHubReporter(), + 'gitlab' => new GitLabReporter(), + 'sarif' => new SarifReporter(), + 'junit' => new JunitReporter(), + default => throw new InvalidArgumentException(sprintf('Unsupported report format "%s".', $format)), + }; + } +} diff --git a/src/Services/SuppressionService.php b/src/Services/SuppressionService.php new file mode 100644 index 0000000..4d68259 --- /dev/null +++ b/src/Services/SuppressionService.php @@ -0,0 +1,176 @@ + */ + public function errors(bool $includeBaseline = true, ?CarbonImmutable $now = null): array + { + [, $errors] = $this->configuredRules($now ?? CarbonImmutable::now()); + if ($includeBaseline) { + [, $baselineErrors] = $this->baselineRules($now ?? CarbonImmutable::now()); + array_push($errors, ...$baselineErrors); + } + + return $errors; + } + + public function apply(AuditReport $auditReport, bool $includeBaseline = true): AuditReport + { + [$rules, $errors] = $this->configuredRules($auditReport->scannedAt ?? CarbonImmutable::now()); + if ($includeBaseline) { + [$baselineRules, $baselineErrors] = $this->baselineRules($auditReport->scannedAt ?? CarbonImmutable::now()); + array_push($rules, ...$baselineRules); + array_push($errors, ...$baselineErrors); + } + + $ignored = []; + $results = []; + + foreach ($auditReport->audits as $audit) { + $active = []; + foreach ($audit->findings as $finding) { + if ($this->isIgnored($finding, $rules)) { + $ignored[] = $finding; + } else { + $active[] = $finding; + } + } + + $results[] = new AuditResult($audit->audit, $active, $audit->errors, $audit->durationMs); + } + + return $auditReport->withFilteredFindings( + $results, + $ignored, + [...$auditReport->configurationErrors, ...$errors], + ); + } + + /** + * @param list $rules + */ + private function isIgnored(Finding $finding, array $rules): bool + { + foreach ($rules as $rule) { + if ($rule['id'] !== $finding->id) { + continue; + } + + if ($rule['fingerprint'] === null || hash_equals($rule['fingerprint'], $finding->fingerprint())) { + return true; + } + } + + return false; + } + + /** + * @return array{0: list, 1: list} + */ + private function configuredRules(CarbonImmutable $now): array + { + $configured = config('warden.ignore_findings', []); + if (!is_array($configured)) { + return [[], [new AuditError('configuration', 'invalid_suppressions', 'warden.ignore_findings must be an array.')]]; + } + + return $this->validateRules($configured, $now, 'configuration'); + } + + /** + * @return array{0: list, 1: list} + */ + private function baselineRules(CarbonImmutable $now): array + { + $configuredPath = (string) config('warden.baseline.file', 'warden-baseline.json'); + $path = str_starts_with($configuredPath, DIRECTORY_SEPARATOR) ? $configuredPath : base_path($configuredPath); + if (!is_file($path)) { + return [[], []]; + } + + $contents = file_get_contents($path); + if (!is_string($contents)) { + return [[], [new AuditError('baseline', 'baseline_unreadable', 'The Warden baseline could not be read.')]]; + } + + try { + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + return [[], [new AuditError('baseline', 'baseline_invalid', $jsonException->getMessage())]]; + } + + if (!is_array($decoded) || ($decoded['schema_version'] ?? null) !== '1.0.0' || !is_array($decoded['findings'] ?? null)) { + return [[], [new AuditError('baseline', 'baseline_invalid', 'The Warden baseline has an unsupported schema.')]]; + } + + return $this->validateRules($decoded['findings'], $now, 'baseline', true); + } + + /** + * @param array $configured + * @return array{0: list, 1: list} + */ + private function validateRules(array $configured, CarbonImmutable $now, string $source, bool $fingerprintRequired = false): array + { + $rules = []; + $errors = []; + + foreach ($configured as $index => $rule) { + if (!is_array($rule)) { + $errors[] = new AuditError($source, 'suppression_invalid', sprintf('Suppression %s must be an object.', $index)); + continue; + } + + $id = $rule['id'] ?? null; + $reason = $rule['reason'] ?? null; + $expiresAt = $rule['expires_at'] ?? null; + $fingerprint = $rule['fingerprint'] ?? null; + + if (!is_string($id) || $id === '' || !is_string($reason) || $reason === '' || !is_string($expiresAt) || $expiresAt === '') { + $errors[] = new AuditError($source, 'suppression_invalid', sprintf('Suppression %s requires id, reason, and expires_at.', $index)); + continue; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $expiresAt) !== 1) { + $errors[] = new AuditError($source, 'suppression_invalid', sprintf('Suppression %s expires_at must use YYYY-MM-DD.', $index)); + continue; + } + + if ($fingerprintRequired && (!is_string($fingerprint) || $fingerprint === '')) { + $errors[] = new AuditError($source, 'suppression_invalid', sprintf('Baseline entry %s requires a fingerprint.', $index)); + continue; + } + + try { + $expiry = CarbonImmutable::parse($expiresAt)->endOfDay(); + } catch (Throwable) { + $errors[] = new AuditError($source, 'suppression_invalid', sprintf('Suppression %s has an invalid expires_at date.', $index)); + continue; + } + + if ($expiry->isBefore($now)) { + $errors[] = new AuditError($source, 'suppression_expired', sprintf('Suppression for %s expired on %s.', $id, $expiresAt)); + continue; + } + + $rules[] = [ + 'id' => $id, + 'fingerprint' => is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null, + ]; + } + + return [$rules, $errors]; + } +} diff --git a/src/ValueObjects/AuditContext.php b/src/ValueObjects/AuditContext.php new file mode 100644 index 0000000..25d54c1 --- /dev/null +++ b/src/ValueObjects/AuditContext.php @@ -0,0 +1,46 @@ + $only + * @param list $skip + */ + public function __construct( + public string $profile = 'ci', + public string $scope = 'production', + public int $timeout = 300, + public array $only = [], + public array $skip = [], + ) { + if (!in_array($this->profile, ['ci', 'production', 'local'], true)) { + throw new InvalidArgumentException(sprintf('Invalid audit profile "%s".', $this->profile)); + } + + if (!in_array($this->scope, ['production', 'all'], true)) { + throw new InvalidArgumentException(sprintf('Invalid dependency scope "%s".', $this->scope)); + } + + if ($this->timeout < 1 || $this->timeout > 3600) { + throw new InvalidArgumentException('Audit timeout must be between 1 and 3600 seconds.'); + } + + foreach ([...$this->only, ...$this->skip] as $audit) { + if ($audit === '') { + throw new InvalidArgumentException('Audit IDs cannot be empty.'); + } + } + } + + public function includes(string $auditId): bool + { + return !in_array($auditId, $this->skip, true) + && ($this->only === [] || in_array($auditId, $this->only, true)); + } +} diff --git a/src/ValueObjects/AuditError.php b/src/ValueObjects/AuditError.php new file mode 100644 index 0000000..c833290 --- /dev/null +++ b/src/ValueObjects/AuditError.php @@ -0,0 +1,23 @@ + $this->audit, 'code' => $this->code, 'message' => $this->message]; + } +} diff --git a/src/ValueObjects/AuditReport.php b/src/ValueObjects/AuditReport.php new file mode 100644 index 0000000..f956b95 --- /dev/null +++ b/src/ValueObjects/AuditReport.php @@ -0,0 +1,130 @@ + $audits + * @param list $ignoredFindings + * @param list $configurationErrors + */ + public function __construct( + public AuditContext $context, + public array $audits, + public array $ignoredFindings = [], + public array $configurationErrors = [], + public ?CarbonImmutable $scannedAt = null, + ) { + } + + /** @return list */ + public function findings(): array + { + $findings = []; + foreach ($this->audits as $audit) { + array_push($findings, ...$audit->findings); + } + + usort($findings, static function (Finding $left, Finding $right): int { + $severity = $right->severity->weight() <=> $left->severity->weight(); + if ($severity !== 0) { + return $severity; + } + + return [$left->source, $left->id, $left->fingerprint()] + <=> [$right->source, $right->id, $right->fingerprint()]; + }); + + return $findings; + } + + /** @return list */ + public function errors(): array + { + $errors = $this->configurationErrors; + foreach ($this->audits as $audit) { + array_push($errors, ...$audit->errors); + } + + return $errors; + } + + public function exitCode(string $failOn): int + { + if ($this->errors() !== []) { + return 2; + } + + if ($failOn === 'never') { + return 0; + } + + $threshold = Severity::from($failOn)->weight(); + foreach ($this->findings() as $finding) { + if ($finding->blocking && $finding->severity->weight() >= $threshold) { + return 1; + } + } + + return 0; + } + + /** + * @param list $audits + * @param list $ignoredFindings + * @param list $configurationErrors + */ + public function withFilteredFindings(array $audits, array $ignoredFindings, array $configurationErrors): self + { + return new self($this->context, $audits, $ignoredFindings, $configurationErrors, $this->scannedAt); + } + + /** @return array */ + public function jsonSerialize(): array + { + $counts = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0]; + foreach ($this->findings() as $finding) { + $counts[$finding->severity->value]++; + } + + return [ + 'schema_version' => '2.0.0', + 'warden_version' => $this->wardenVersion(), + 'run' => [ + 'status' => $this->errors() === [] ? 'completed' : 'failed', + 'profile' => $this->context->profile, + 'scope' => $this->context->scope, + 'scanned_at' => ($this->scannedAt ?? CarbonImmutable::now())->toISOString(), + ], + 'summary' => [ + 'total' => count($this->findings()), + 'ignored' => count($this->ignoredFindings), + 'errors' => count($this->errors()), + 'severity' => $counts, + ], + 'audits' => $this->audits, + 'findings' => $this->findings(), + 'ignored_findings' => $this->ignoredFindings, + 'errors' => $this->errors(), + ]; + } + + private function wardenVersion(): string + { + if (InstalledVersions::isInstalled('dgtlss/warden')) { + return InstalledVersions::getPrettyVersion('dgtlss/warden') ?? 'unknown'; + } + + $root = InstalledVersions::getRootPackage(); + + return $root['pretty_version']; + } +} diff --git a/src/ValueObjects/AuditResult.php b/src/ValueObjects/AuditResult.php new file mode 100644 index 0000000..764a09b --- /dev/null +++ b/src/ValueObjects/AuditResult.php @@ -0,0 +1,55 @@ + $findings + * @param list $errors + */ + public function __construct( + public string $audit, + public array $findings = [], + public array $errors = [], + public float $durationMs = 0.0, + ) { + } + + /** @param list $findings */ + public static function complete(string $audit, array $findings = []): self + { + return new self($audit, $findings); + } + + public static function failed(string $audit, string $code, string $message): self + { + return new self($audit, [], [new AuditError($audit, $code, $message)]); + } + + public function withDuration(float $durationMs): self + { + return new self($this->audit, $this->findings, $this->errors, $durationMs); + } + + public function succeeded(): bool + { + return $this->errors === []; + } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->audit, + 'status' => $this->succeeded() ? 'completed' : 'failed', + 'duration_ms' => round($this->durationMs, 1), + 'findings' => count($this->findings), + 'errors' => $this->errors, + ]; + } +} diff --git a/src/ValueObjects/Finding.php b/src/ValueObjects/Finding.php new file mode 100644 index 0000000..dd8e5f5 --- /dev/null +++ b/src/ValueObjects/Finding.php @@ -0,0 +1,69 @@ + $metadata */ + public function __construct( + public string $id, + public string $source, + public string $title, + public Severity $severity, + public string $description, + public ?string $remediation = null, + public ?string $package = null, + public ?string $reference = null, + public ?string $path = null, + public ?int $line = null, + public bool $blocking = true, + public array $metadata = [], + ) { + if ($this->id === '' || $this->source === '' || $this->title === '' || $this->description === '') { + throw new InvalidArgumentException('A finding requires a non-empty ID, source, title, and description.'); + } + + if ($this->line !== null && $this->line < 1) { + throw new InvalidArgumentException('A finding line number must be positive.'); + } + } + + public function fingerprint(): string + { + return hash('sha256', implode('|', [ + $this->id, + $this->package ?? '', + $this->reference ?? '', + $this->path ?? '', + (string) ($this->line ?? ''), + ])); + } + + /** @return array */ + public function jsonSerialize(): array + { + return array_filter([ + 'id' => $this->id, + 'fingerprint' => $this->fingerprint(), + 'source' => $this->source, + 'title' => $this->title, + 'severity' => $this->severity->value, + 'description' => $this->description, + 'remediation' => $this->remediation, + 'package' => $this->package, + 'reference' => $this->reference, + 'location' => $this->path === null ? null : array_filter([ + 'path' => $this->path, + 'line' => $this->line, + ], static fn (mixed $value): bool => $value !== null), + 'blocking' => $this->blocking, + 'metadata' => $this->metadata === [] ? null : $this->metadata, + ], static fn (mixed $value): bool => $value !== null); + } +} diff --git a/src/config/warden.php b/src/config/warden.php index 53daa0c..41cf61f 100644 --- a/src/config/warden.php +++ b/src/config/warden.php @@ -1,83 +1,13 @@ env('WARDEN_APP_NAME', config('app.name', 'Application')), - /* - |-------------------------------------------------------------------------- - | Notification Settings - |-------------------------------------------------------------------------- - | - | Configure where Warden should send security audit notifications. - | Multiple notification channels are supported: - | - Legacy webhook_url for backward compatibility - | - Email recipients - | - Slack, Discord, Microsoft Teams webhooks - | - */ - - 'webhook_url' => env('WARDEN_WEBHOOK_URL'), // Legacy support - 'email_recipients' => env('WARDEN_EMAIL_RECIPIENTS'), - - 'notifications' => [ - 'slack' => [ - 'webhook_url' => env('WARDEN_SLACK_WEBHOOK_URL', env('WARDEN_WEBHOOK_URL')), - ], - 'discord' => [ - 'webhook_url' => env('WARDEN_DISCORD_WEBHOOK_URL'), - ], - 'teams' => [ - 'webhook_url' => env('WARDEN_TEAMS_WEBHOOK_URL'), - ], - 'email' => [ - 'recipients' => env('WARDEN_EMAIL_RECIPIENTS'), - 'from_address' => env('WARDEN_EMAIL_FROM', config('mail.from.address')), - 'from_name' => env('WARDEN_EMAIL_FROM_NAME', 'Warden Security'), - ], - ], - - /* - |-------------------------------------------------------------------------- - | Cache Configuration - |-------------------------------------------------------------------------- - | - | Configure caching behavior for audit results to prevent running - | audits too frequently. This helps with rate limiting and performance. - | - */ - - 'cache' => [ - 'enabled' => env('WARDEN_CACHE_ENABLED', true), - 'duration' => env('WARDEN_CACHE_DURATION', 3600), // seconds (default: 1 hour) - 'driver' => env('WARDEN_CACHE_DRIVER', config('cache.default')), - ], - - /* - |-------------------------------------------------------------------------- - | Audit Configuration - |-------------------------------------------------------------------------- - | - | Configure audit behavior and filtering options. - | - */ - 'audits' => [ - 'parallel_execution' => env('WARDEN_PARALLEL_EXECUTION', true), - 'timeout' => env('WARDEN_AUDIT_TIMEOUT', 300), // seconds + 'timeout' => (int) env('WARDEN_AUDIT_TIMEOUT', 300), 'php_syntax' => [ - 'enabled' => env('WARDEN_PHP_SYNTAX_AUDIT_ENABLED', false), 'exclude' => [ 'vendor', 'node_modules', @@ -89,91 +19,34 @@ ], /* - |-------------------------------------------------------------------------- - | 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*'] - | + | Every suppression must be reviewed and time-limited. Add fingerprint to + | suppress only one concrete occurrence of a rule. */ - 'ignore_findings' => [ - // ['source' => 'debug-mode', 'package' => 'laravel/horizon'], + // [ + // 'id' => 'composer.advisory.ghsa-example', + // 'fingerprint' => 'optional-fingerprint', + // 'reason' => 'Compensating control reviewed in SEC-123', + // 'expires_at' => '2099-12-31', + // ], ], - /* - |-------------------------------------------------------------------------- - | Custom Audits - |-------------------------------------------------------------------------- - | - | Register your custom audit classes here. Each class must implement - | the Dgtlss\Warden\Contracts\CustomAudit interface. - | - */ + 'baseline' => [ + 'file' => env('WARDEN_BASELINE_FILE', 'warden-baseline.json'), + ], 'custom_audits' => [ // \App\Audits\MyCustomAudit::class, ], - /* - |-------------------------------------------------------------------------- - | Scheduling Configuration - |-------------------------------------------------------------------------- - | - | Configure automated audit scheduling. Set to false to disable. - | - */ - - 'schedule' => [ - 'enabled' => env('WARDEN_SCHEDULE_ENABLED', false), - 'frequency' => env('WARDEN_SCHEDULE_FREQUENCY', 'daily'), // hourly|daily|weekly|monthly - 'time' => env('WARDEN_SCHEDULE_TIME', '03:00'), // Time in 24h format - 'timezone' => env('WARDEN_SCHEDULE_TIMEZONE', config('app.timezone')), - ], - - /* - |-------------------------------------------------------------------------- - | Audit History - |-------------------------------------------------------------------------- - | - | Configure database storage for audit history tracking. - | - */ - - 'history' => [ - 'enabled' => env('WARDEN_HISTORY_ENABLED', false), - 'table' => env('WARDEN_HISTORY_TABLE', 'warden_audit_history'), - 'retention_days' => env('WARDEN_HISTORY_RETENTION_DAYS', 90), - ], - - /* - |-------------------------------------------------------------------------- - | Security Audit Configuration - |-------------------------------------------------------------------------- - | - | Define environment variables that should be checked during security audits. - | These keys are considered security-critical and should be properly set - | in your production environment. - | - | Add your own sensitive keys based on your application's requirements. - | The check will fail if these keys are missing from your .env file, - | encouraging proper security configuration from the start. - | - | Example key formats: - | - Database: DB_PASSWORD - | - Email: SMTP_PASSWORD, MAILGUN_SECRET - | - Payment: STRIPE_SECRET_KEY, PAYPAL_SECRET - | - Cloud: AWS_SECRET_KEY, GOOGLE_CLOUD_KEY - | - */ - - 'sensitive_keys' => [ - // Add your sensitive keys here + 'notifications' => [ + 'slack' => ['webhook_url' => env('WARDEN_SLACK_WEBHOOK_URL')], + 'discord' => ['webhook_url' => env('WARDEN_DISCORD_WEBHOOK_URL')], + 'teams' => ['webhook_url' => env('WARDEN_TEAMS_WEBHOOK_URL')], + 'email' => [ + 'recipients' => env('WARDEN_EMAIL_RECIPIENTS'), + 'from_address' => env('WARDEN_EMAIL_FROM', config('mail.from.address')), + 'from_name' => env('WARDEN_EMAIL_FROM_NAME', 'Warden Security'), + ], ], ]; diff --git a/src/database/migrations/2024_01_01_000000_create_warden_audit_history_table.php b/src/database/migrations/2024_01_01_000000_create_warden_audit_history_table.php deleted file mode 100644 index 24209ff..0000000 --- a/src/database/migrations/2024_01_01_000000_create_warden_audit_history_table.php +++ /dev/null @@ -1,47 +0,0 @@ -id(); - $blueprint->string('audit_type', 50); - $blueprint->integer('total_findings')->default(0); - $blueprint->integer('critical_findings')->default(0); - $blueprint->integer('high_findings')->default(0); - $blueprint->integer('medium_findings')->default(0); - $blueprint->integer('low_findings')->default(0); - $blueprint->json('findings')->nullable(); - $blueprint->json('metadata')->nullable(); - $blueprint->boolean('has_failures')->default(false); - $blueprint->string('trigger', 50)->default('manual'); // manual, scheduled, api - $blueprint->string('triggered_by')->nullable(); - $blueprint->integer('duration_ms')->nullable(); - $blueprint->timestamps(); - - // Indexes for performance - $blueprint->index('audit_type'); - $blueprint->index('created_at'); - $blueprint->index(['audit_type', 'created_at']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - $tableName = config('warden.history.table', 'warden_audit_history'); - Schema::dropIfExists($tableName); - } -}; \ No newline at end of file diff --git a/tests/Commands/WardenAuditCommandTest.php b/tests/Commands/WardenAuditCommandTest.php index 485e67d..0bbe7b2 100644 --- a/tests/Commands/WardenAuditCommandTest.php +++ b/tests/Commands/WardenAuditCommandTest.php @@ -1,199 +1,107 @@ bindReport(new AuditReport( + new AuditContext(), + [AuditResult::complete('test', [$finding])], + scannedAt: CarbonImmutable::parse('2026-01-01T00:00:00Z'), + )); + + $exitCode = Artisan::call('warden:audit', ['--format' => 'json']); + $output = Artisan::output(); + + self::assertStringContainsString('"schema_version": "2.0.0"', $output); + self::assertStringContainsString('"id": "test.high"', $output); + self::assertSame(1, $exitCode); } - public function testAuditCommandHandlesNoFindings(): void + public function testFailOnChangesOnlyTheGateNotTheReport(): void { - $this->mock(AuditExecutor::class, function (MockInterface $mock): void { - $mock->shouldReceive('addAudit')->zeroOrMoreTimes(); - $mock->shouldReceive('execute')->once()->andReturn([]); - }); + $finding = new Finding('test.medium', 'test', 'Medium issue', Severity::Medium, 'Description'); + $this->bindReport(new AuditReport(new AuditContext(), [AuditResult::complete('test', [$finding])])); - $this->artisan('warden:audit') - ->expectsOutputToContain('Warden') - ->expectsOutputToContain('No security issues found.') - ->assertExitCode(0); + $exitCode = Artisan::call('warden:audit', ['--format' => 'json', '--fail-on' => 'high']); + + self::assertStringContainsString('"id": "test.medium"', Artisan::output()); + self::assertSame(0, $exitCode); } - public function testAuditCommandHandlesFindings(): void + public function testAuditFailureIsMachineReadableAndExitsTwo(): void { - $findings = [ - [ - 'source' => 'composer', - 'title' => 'some/package - High severity vulnerability', - 'severity' => 'high', - 'package' => 'some/package', - ], - ]; - - $this->mock(AuditExecutor::class, function (MockInterface $mock) use ($findings): void { - $mock->shouldReceive('addAudit')->zeroOrMoreTimes(); - $mock->shouldReceive('execute')->once()->andReturn([ - 'composer' => [ - 'success' => true, - 'findings' => $findings, - 'service' => new \stdClass(), - ], - ]); - }); + $this->bindReport(new AuditReport( + new AuditContext(), + [AuditResult::failed('composer', 'scanner_failed', 'Registry offline')], + )); - $this->artisan('warden:audit', ['--no-notify' => true]) - ->expectsOutputToContain('Warden') - ->expectsOutputToContain('1 security issue found.') - ->assertExitCode(1); + $exitCode = Artisan::call('warden:audit', ['--format' => 'json', '--fail-on' => 'never']); + $output = Artisan::output(); + + self::assertStringContainsString('"status": "failed"', $output); + self::assertStringContainsString('"code": "scanner_failed"', $output); + self::assertSame(2, $exitCode); } - public function testAuditCommandIgnoresConfiguredFindingsBeforeNotifications(): void + public function testInvalidMachineOptionReturnsStructuredErrorBeforeAuditsRun(): 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->mock(AuditRunner::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('run'); }); - $this->artisan('warden:audit') - ->expectsOutputToContain('Warden') - ->expectsOutputToContain('No security issues found.') - ->assertExitCode(0); + $exitCode = Artisan::call('warden:audit', ['--format' => 'json', '--profile' => 'invalid']); - Http::assertNothingSent(); - Mail::assertNothingSent(); + self::assertStringContainsString('"code": "invalid_option"', Artisan::output()); + self::assertSame(2, $exitCode); } - public function testAuditCommandSupportsWildcardIgnoreRulesInJsonOutput(): void + public function testUnknownAuditIsRejectedBeforeExecution(): 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->mock(AuditRunner::class, function (MockInterface $mock): void { + $mock->shouldReceive('availableAuditIds')->once()->andReturn(['composer']); + $mock->shouldNotReceive('run'); }); - $this->artisan('warden:audit', ['--output' => 'json']) - ->expectsOutputToContain('"vulnerabilities_found": 0') - ->assertExitCode(0); + $exitCode = Artisan::call('warden:audit', ['--format' => 'json', '--only' => 'unknown']); + + self::assertStringContainsString('Unknown audit', Artisan::output()); + self::assertSame(2, $exitCode); } - public function testAuditCommandFiltersCachedFindingsInSequentialMode(): void + public function testInvalidSuppressionIsRejectedBeforeExecution(): 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, - ]); + config(['warden.ignore_findings' => [['id' => 'missing-review-metadata']]]); + $this->mock(AuditRunner::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('run'); }); - $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([]); - }); + $exitCode = Artisan::call('warden:audit', ['--format' => 'json']); - $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([]); - }); + self::assertStringContainsString('"code": "suppression_invalid"', Artisan::output()); + self::assertSame(2, $exitCode); + } - $this->mock(DebugModeAuditService::class, function (MockInterface $mock): void { - $mock->shouldReceive('getName')->once()->andReturn('debug-mode'); + private function bindReport(AuditReport $auditReport): void + { + $this->mock(AuditRunner::class, function (MockInterface $mock) use ($auditReport): void { + $mock->shouldReceive('run')->once()->andReturn($auditReport); }); - - $this->artisan('warden:audit', ['--no-notify' => true]) - ->expectsOutputToContain('Warden') - ->expectsOutputToContain('No security issues found.') - ->assertExitCode(0); } } diff --git a/tests/Commands/WardenBaselineCommandTest.php b/tests/Commands/WardenBaselineCommandTest.php new file mode 100644 index 0000000..349f564 --- /dev/null +++ b/tests/Commands/WardenBaselineCommandTest.php @@ -0,0 +1,45 @@ +mock(AuditRunner::class, function (MockInterface $mock) use ($auditReport): void { + $mock->shouldReceive('run')->once()->andReturn($auditReport); + }); + + try { + $this->artisan('warden:baseline', [ + '--file' => $path, + '--reason' => 'Tracked in SEC-123', + '--expires' => '2099-01-01', + ])->assertExitCode(0); + + $decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('1.0.0', $decoded['schema_version']); + self::assertSame('test.legacy', $decoded['findings'][0]['id']); + self::assertSame($finding->fingerprint(), $decoded['findings'][0]['fingerprint']); + self::assertSame('Tracked in SEC-123', $decoded['findings'][0]['reason']); + } finally { + if (is_file($path)) { + unlink($path); + } + } + } +} diff --git a/tests/Commands/WardenSyntaxCommandTest.php b/tests/Commands/WardenSyntaxCommandTest.php index d969465..64bf8b3 100644 --- a/tests/Commands/WardenSyntaxCommandTest.php +++ b/tests/Commands/WardenSyntaxCommandTest.php @@ -1,69 +1,38 @@ mock(PhpSyntaxAuditService::class, function (MockInterface $mock): void { - $mock->shouldReceive('run')->once()->andReturn(true); + $mock->shouldReceive('run')->once()->andReturn(AuditResult::complete('php-syntax')); }); $this->artisan('warden:syntax') - ->expectsOutputToContain('Warden PHP Syntax Audit') ->expectsOutputToContain('No PHP syntax errors found.') ->assertExitCode(0); } - public function testSyntaxCommandHandlesFindings(): void + public function testSyntaxFindingExitsOne(): void { - $findings = [ - [ - 'title' => 'test.php', - 'description' => 'Parse error: syntax error, unexpected T_STRING', - ], - ]; - - $this->mock(PhpSyntaxAuditService::class, function (MockInterface $mock) use ($findings): void { - $mock->shouldReceive('run')->once()->andReturn(false); - $mock->shouldReceive('getFindings')->once()->andReturn($findings); + $finding = new Finding('quality.php.syntax', 'php-syntax', 'Syntax error', Severity::High, 'Parse error', path: 'app/Broken.php'); + $this->mock(PhpSyntaxAuditService::class, function (MockInterface $mock) use ($finding): void { + $mock->shouldReceive('run')->once()->andReturn(AuditResult::complete('php-syntax', [$finding])); }); $this->artisan('warden:syntax') - ->expectsOutputToContain('Warden PHP Syntax Audit') - ->expectsOutputToContain('1 syntax error found.') + ->expectsOutputToContain('app/Broken.php: Parse error') ->assertExitCode(1); } - - public function testSyntaxCommandHandlesAuditError(): void - { - $findings = [ - [ - 'title' => 'Error', - 'description' => 'The audit could not be run.', - 'severity' => 'error', - ], - ]; - - $this->mock(PhpSyntaxAuditService::class, function (MockInterface $mock) use ($findings): void { - $mock->shouldReceive('run')->once()->andReturn(false); - $mock->shouldReceive('getFindings')->once()->andReturn($findings); - }); - - $this->artisan('warden:syntax') - ->expectsOutputToContain('Warden PHP Syntax Audit') - ->expectsOutputToContain('1 syntax error found.') - ->assertExitCode(2); - } } diff --git a/tests/Reporters/ReporterTest.php b/tests/Reporters/ReporterTest.php new file mode 100644 index 0000000..229d648 --- /dev/null +++ b/tests/Reporters/ReporterTest.php @@ -0,0 +1,91 @@ +format($this->report()), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame('2.0.0', $decoded['schema_version']); + self::assertSame('completed', $decoded['run']['status']); + self::assertSame('reporter.high', $decoded['findings'][0]['id']); + self::assertArrayHasKey('audits', $decoded); + self::assertArrayHasKey('errors', $decoded); + + $schema = json_decode( + (string) file_get_contents(dirname(__DIR__, 2) . '/resources/schemas/warden-report-2.0.0.json'), + true, + 512, + JSON_THROW_ON_ERROR, + ); + self::assertSame('2.0.0', $schema['properties']['schema_version']['const']); + } + + #[DataProvider('jsonReporterProvider')] + public function testStandardJsonReportersProduceParseableContracts(string $reporter, string $versionPath, string $expected): void + { + $instance = new $reporter(); + $decoded = json_decode($instance->format($this->report()), true, 512, JSON_THROW_ON_ERROR); + $value = $decoded; + foreach (explode('.', $versionPath) as $segment) { + $value = $value[$segment]; + } + + self::assertSame($expected, $value); + } + + /** @return iterable */ + public static function jsonReporterProvider(): iterable + { + yield 'SARIF 2.1.0' => [SarifReporter::class, 'version', '2.1.0']; + yield 'GitLab 15.2.4' => [GitLabReporter::class, 'version', '15.2.4']; + } + + public function testJunitIsValidXmlAndRepresentsFindingsAsFailures(): void + { + $domDocument = new DOMDocument(); + + self::assertTrue($domDocument->loadXML((new JunitReporter())->format($this->report()))); + self::assertSame(1, $domDocument->getElementsByTagName('failure')->length); + } + + private function report(): AuditReport + { + $finding = new Finding( + 'reporter.high', + 'composer', + 'Vulnerable dependency', + Severity::High, + 'A known vulnerability is present.', + 'Upgrade the package.', + 'vendor/package', + 'https://example.com/advisory', + 'composer.lock', + metadata: ['affected_versions' => '<2.0'], + ); + + return new AuditReport( + new AuditContext(), + [AuditResult::complete('composer', [$finding])], + scannedAt: CarbonImmutable::parse('2026-01-01T00:00:00Z'), + ); + } +} diff --git a/tests/Services/AuditExecutorTest.php b/tests/Services/AuditExecutorTest.php new file mode 100644 index 0000000..e861aab --- /dev/null +++ b/tests/Services/AuditExecutorTest.php @@ -0,0 +1,48 @@ +execute(new AuditContext(), [$broken, $healthy]); + + self::assertCount(2, $results); + self::assertSame('unhandled_exception', $results[0]->errors[0]->code); + self::assertTrue($results[1]->succeeded()); + } + + public function testMismatchedResultIdentityBecomesExecutionError(): void + { + $audit = new class implements AuditServiceInterface { + public function getName(): string { return 'expected'; } + + public function run(AuditContext $auditContext): AuditResult { return AuditResult::complete('different'); } + }; + + $result = (new AuditExecutor())->execute(new AuditContext(), [$audit])[0]; + + self::assertSame('invalid_result', $result->errors[0]->code); + } +} diff --git a/tests/Services/Audits/DebugModeAuditServiceTest.php b/tests/Services/Audits/DebugModeAuditServiceTest.php deleted file mode 100644 index bc6734a..0000000 --- a/tests/Services/Audits/DebugModeAuditServiceTest.php +++ /dev/null @@ -1,205 +0,0 @@ -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; - } -} diff --git a/tests/Services/Audits/DependencyAuditServiceTest.php b/tests/Services/Audits/DependencyAuditServiceTest.php new file mode 100644 index 0000000..b6a1434 --- /dev/null +++ b/tests/Services/Audits/DependencyAuditServiceTest.php @@ -0,0 +1,106 @@ +originalBasePath = $this->app->basePath(); + $this->temporaryBasePath = sys_get_temp_dir() . '/warden-dependencies-' . bin2hex(random_bytes(8)); + mkdir($this->temporaryBasePath, 0777, true); + file_put_contents($this->temporaryBasePath . '/composer.lock', '{}'); + $this->app->setBasePath($this->temporaryBasePath); + } + + protected function tearDown(): void + { + $this->app->setBasePath($this->originalBasePath); + foreach (glob($this->temporaryBasePath . '/*') ?: [] as $path) { + unlink($path); + } + + rmdir($this->temporaryBasePath); + parent::tearDown(); + } + + public function testComposerUsesLockedProductionScopeAndParsesFindings(): void + { + $service = new class extends ComposerAuditService { + /** @var list */ + public array $command = []; + + protected function createProcess(array $command, int $timeout): Process + { + $this->command = $command; + $json = json_encode([ + 'advisories' => ['vendor/package' => [[ + 'advisoryId' => 'GHSA-test', + 'title' => 'Test advisory', + 'severity' => 'high', + 'affectedVersions' => '<2.0', + 'link' => 'https://example.com/GHSA-test', + ]]], + 'abandoned' => ['old/package' => 'new/package'], + ], JSON_THROW_ON_ERROR); + + return new Process([PHP_BINARY, '-r', sprintf('echo %s; exit(1);', var_export($json, true))]); + } + }; + + $auditResult = $service->run(new AuditContext(scope: 'production')); + + self::assertContains('--locked', $service->command); + self::assertContains('--no-dev', $service->command); + self::assertCount(2, $auditResult->findings); + self::assertTrue($auditResult->succeeded()); + } + + public function testMalformedComposerOutputIsAnExecutionError(): void + { + $service = new class extends ComposerAuditService { + protected function createProcess(array $command, int $timeout): Process + { + return new Process([PHP_BINARY, '-r', 'echo "not-json"; exit(2);']); + } + }; + + $auditResult = $service->run(new AuditContext()); + + self::assertSame('malformed_output', $auditResult->errors[0]->code); + } + + public function testNpmUsesLockfileOnlyAndProductionDependencyScope(): void + { + file_put_contents(base_path('package-lock.json'), '{}'); + + $service = new class extends NpmAuditService { + /** @var list */ + public array $command = []; + + protected function createProcess(array $command, int $timeout): Process + { + $this->command = $command; + return new Process([PHP_BINARY, '-r', 'echo "{\"vulnerabilities\":[]}";']); + } + }; + $auditResult = $service->run(new AuditContext(scope: 'production')); + + self::assertContains('--package-lock-only', $service->command); + self::assertContains('--omit=dev', $service->command); + self::assertTrue($auditResult->succeeded()); + } +} diff --git a/tests/Services/Audits/LaravelConfigAuditServiceTest.php b/tests/Services/Audits/LaravelConfigAuditServiceTest.php new file mode 100644 index 0000000..3030d25 --- /dev/null +++ b/tests/Services/Audits/LaravelConfigAuditServiceTest.php @@ -0,0 +1,63 @@ + true, 'app.key' => null]); + + $auditResult = (new LaravelConfigAuditService())->run(new AuditContext(profile: 'ci')); + + self::assertSame([], $auditResult->findings); + self::assertTrue($auditResult->succeeded()); + } + + public function testSecureProductionConfigurationPasses(): void + { + config([ + 'app.debug' => false, + 'app.key' => 'base64:' . base64_encode(random_bytes(32)), + 'app.cipher' => 'AES-256-CBC', + 'app.url' => 'https://example.com', + 'session.secure' => true, + 'session.http_only' => true, + 'session.same_site' => 'lax', + 'telescope.enabled' => false, + ]); + + $auditResult = (new LaravelConfigAuditService())->run(new AuditContext(profile: 'production')); + + self::assertSame([], $auditResult->findings); + } + + public function testInsecureProductionConfigurationProducesHighConfidenceRules(): void + { + config([ + 'app.debug' => true, + 'app.key' => 'short', + 'app.cipher' => 'AES-256-CBC', + 'app.url' => 'http://example.com', + 'session.secure' => false, + 'session.http_only' => false, + 'session.same_site' => '', + ]); + + $auditResult = (new LaravelConfigAuditService())->run(new AuditContext(profile: 'production')); + $ids = array_map(static fn ($finding): string => $finding->id, $auditResult->findings); + + self::assertContains('laravel.debug.enabled', $ids); + self::assertContains('laravel.app-key.invalid', $ids); + self::assertContains('laravel.url.insecure', $ids); + self::assertContains('laravel.session.secure', $ids); + self::assertContains('laravel.session.http-only', $ids); + self::assertContains('laravel.session.same-site', $ids); + } +} diff --git a/tests/Services/Audits/SupplyChainAuditServiceTest.php b/tests/Services/Audits/SupplyChainAuditServiceTest.php new file mode 100644 index 0000000..0cccbea --- /dev/null +++ b/tests/Services/Audits/SupplyChainAuditServiceTest.php @@ -0,0 +1,78 @@ +originalBasePath = $this->app->basePath(); + $this->temporaryBasePath = sys_get_temp_dir() . '/warden-supply-' . bin2hex(random_bytes(8)); + mkdir($this->temporaryBasePath, 0777, true); + $this->app->setBasePath($this->temporaryBasePath); + } + + protected function tearDown(): void + { + $this->app->setBasePath($this->originalBasePath); + foreach (glob($this->temporaryBasePath . '/*') ?: [] as $path) { + unlink($path); + } + + rmdir($this->temporaryBasePath); + parent::tearDown(); + } + + public function testInsecureComposerAndMissingJavascriptLockAreReported(): void + { + file_put_contents(base_path('composer.json'), json_encode([ + 'config' => ['secure-http' => false, 'allow-plugins' => true], + 'repositories' => [['type' => 'composer', 'url' => 'http://packages.example.com']], + ], JSON_THROW_ON_ERROR)); + file_put_contents(base_path('composer.lock'), '{}'); + file_put_contents(base_path('package.json'), '{"dependencies":{"example":"^1.0"}}'); + + $service = new class extends SupplyChainAuditService { + protected function createComposerValidationProcess(): Process + { + return new Process([PHP_BINARY, '-r', 'exit(0);']); + } + }; + $auditResult = $service->run(new AuditContext()); + $ids = array_map(static fn ($finding): string => $finding->id, $auditResult->findings); + + self::assertContains('supply-chain.composer.insecure-http', $ids); + self::assertContains('supply-chain.composer.plugins.unrestricted', $ids); + self::assertContains('supply-chain.composer.repository.insecure', $ids); + self::assertContains('supply-chain.javascript-lock.missing', $ids); + } + + public function testManifestValidationFailureIsAnAuditError(): void + { + file_put_contents(base_path('composer.json'), '{"name":"invalid uppercase/name"}'); + file_put_contents(base_path('composer.lock'), '{}'); + + $service = new class extends SupplyChainAuditService { + protected function createComposerValidationProcess(): Process + { + return new Process([PHP_BINARY, '-r', 'fwrite(STDERR, "composer.json schema invalid"); exit(2);']); + } + }; + + $auditResult = $service->run(new AuditContext()); + + self::assertSame('composer_validation_failed', $auditResult->errors[0]->code); + } +} diff --git a/tests/Services/NotificationDispatcherTest.php b/tests/Services/NotificationDispatcherTest.php new file mode 100644 index 0000000..f32665b --- /dev/null +++ b/tests/Services/NotificationDispatcherTest.php @@ -0,0 +1,33 @@ + 'https://example.com/slack', + 'warden.notifications.discord.webhook_url' => null, + 'warden.notifications.teams.webhook_url' => null, + 'warden.notifications.email.recipients' => null, + ]); + $finding = new Finding('test', 'test', 'Finding', Severity::High, 'Description'); + $auditReport = new AuditReport(new AuditContext(), [AuditResult::complete('test', [$finding])]); + + self::assertSame([], (new NotificationDispatcher())->send($auditReport)); + Http::assertSentCount(1); + } +} diff --git a/tests/Services/SuppressionServiceTest.php b/tests/Services/SuppressionServiceTest.php new file mode 100644 index 0000000..dd3b6c5 --- /dev/null +++ b/tests/Services/SuppressionServiceTest.php @@ -0,0 +1,86 @@ + [[ + 'id' => 'accepted', + 'reason' => 'Reviewed in SEC-123', + 'expires_at' => '2099-01-01', + ]]]); + $report = $this->report([ + new Finding('accepted', 'test', 'Accepted', Severity::High, 'Description'), + new Finding('active', 'test', 'Active', Severity::High, 'Description'), + ]); + + $auditReport = (new SuppressionService())->apply($report, includeBaseline: false); + + self::assertSame(['active'], array_map(static fn ($finding): string => $finding->id, $auditReport->findings())); + self::assertCount(1, $auditReport->ignoredFindings); + self::assertSame([], $auditReport->errors()); + } + + public function testExpiredSuppressionIsAConfigurationError(): void + { + config(['warden.ignore_findings' => [[ + 'id' => 'accepted', + 'reason' => 'Old review', + 'expires_at' => '2025-01-01', + ]]]); + + $auditReport = (new SuppressionService())->apply($this->report([]), includeBaseline: false); + + self::assertSame('suppression_expired', $auditReport->errors()[0]->code); + self::assertSame(2, $auditReport->exitCode('never')); + } + + public function testBaselineSuppressesOnlyTheExactFingerprint(): void + { + $path = sys_get_temp_dir() . '/warden-suppression-' . bin2hex(random_bytes(8)) . '.json'; + $matching = new Finding('legacy', 'test', 'Legacy A', Severity::High, 'Description', package: 'one'); + $different = new Finding('legacy', 'test', 'Legacy B', Severity::High, 'Description', package: 'two'); + file_put_contents($path, json_encode([ + 'schema_version' => '1.0.0', + 'findings' => [[ + 'id' => $matching->id, + 'fingerprint' => $matching->fingerprint(), + 'reason' => 'Tracked in SEC-123', + 'expires_at' => '2099-01-01', + ]], + ], JSON_THROW_ON_ERROR)); + config(['warden.baseline.file' => $path]); + + try { + $auditReport = (new SuppressionService())->apply($this->report([$matching, $different])); + + self::assertSame($different->fingerprint(), $auditReport->findings()[0]->fingerprint()); + self::assertSame($matching->fingerprint(), $auditReport->ignoredFindings[0]->fingerprint()); + } finally { + unlink($path); + } + } + + /** @param list $findings */ + private function report(array $findings): AuditReport + { + return new AuditReport( + new AuditContext(), + [AuditResult::complete('test', $findings)], + scannedAt: CarbonImmutable::parse('2026-01-01T00:00:00Z'), + ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..4d02138 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,16 @@ +finding('rule.low', Severity::Low); + $critical = $this->finding('rule.critical', Severity::Critical); + $auditReport = new AuditReport( + new AuditContext(), + [AuditResult::complete('test', [$finding, $critical])], + scannedAt: CarbonImmutable::parse('2026-01-01T00:00:00Z'), + ); + + self::assertSame('rule.critical', $auditReport->findings()[0]->id); + self::assertSame($finding->fingerprint(), $this->finding('rule.low', Severity::Low)->fingerprint()); + } + + public function testExitCodesDistinguishFindingsErrorsAndThresholds(): void + { + $report = new AuditReport( + new AuditContext(), + [AuditResult::complete('test', [$this->finding('rule.medium', Severity::Medium)])], + ); + + self::assertSame(1, $report->exitCode('low')); + self::assertSame(0, $report->exitCode('high')); + self::assertSame(0, $report->exitCode('never')); + + $failed = new AuditReport(new AuditContext(), [AuditResult::failed('test', 'failed', 'No result')]); + self::assertSame(2, $failed->exitCode('never')); + } + + public function testNonBlockingFindingNeverFailsTheGate(): void + { + $finding = new Finding('warning', 'test', 'Warning', Severity::Critical, 'Description', blocking: false); + $auditReport = new AuditReport(new AuditContext(), [AuditResult::complete('test', [$finding])]); + + self::assertSame(0, $auditReport->exitCode('low')); + } + + private function finding(string $id, Severity $severity): Finding + { + return new Finding($id, 'test', 'Finding', $severity, 'Description', package: 'vendor/package', path: 'composer.lock'); + } +} From 0d52921a23bb7c368f99eb3112d586b199a27aa0 Mon Sep 17 00:00:00 2001 From: Nathan Langer <32520453+dgtlss@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:51:40 +0100 Subject: [PATCH 2/4] Add new audits and enhance configuration for Warden 2.0 - Introduced new audit types: `platform` and `source`, enhancing security checks. - Added `nikic/php-parser` and `symfony/finder` as dependencies in composer.json. - Updated README to reflect new initialization command and audit options. - Enhanced audit reporting to include blocking and advisory counts. - Improved configuration options for source paths and supply chain review. - Added tests for new functionalities and ensured existing tests cover recent changes. --- UPGRADE.md | 4 + composer.json | 4 + composer.lock | 116 ++-- docs/rules.md | 44 ++ readme.md | 45 +- resources/schemas/warden-report-2.0.0.json | 4 +- src/Commands/WardenAuditCommand.php | 3 +- src/Commands/WardenBaselineCommand.php | 1 + src/Commands/WardenInitCommand.php | 147 +++++ src/Enums/RuleDisposition.php | 12 + src/Providers/WardenServiceProvider.php | 2 + src/Reporters/ConsoleReporter.php | 2 +- src/Reporters/GitHubReporter.php | 4 +- src/Reporters/JunitReporter.php | 5 +- src/Reporters/SarifReporter.php | 10 +- src/Services/AuditRunner.php | 19 +- .../Audits/LaravelConfigAuditService.php | 60 +- src/Services/Audits/PlatformAuditService.php | 131 +++++ src/Services/Audits/SourceAuditService.php | 68 +++ src/Services/Audits/StorageAuditService.php | 33 ++ .../Audits/SupplyChainAuditService.php | 99 ++++ src/Services/RulePolicy.php | 121 ++++ src/Services/Source/PhpSourceAnalyzer.php | 535 ++++++++++++++++++ src/Services/Source/SourceFileDiscovery.php | 102 ++++ src/Services/Source/TextSourceAnalyzer.php | 122 ++++ src/ValueObjects/AuditContext.php | 7 + src/ValueObjects/AuditReport.php | 4 + src/ValueObjects/AuditResult.php | 6 + src/ValueObjects/Finding.php | 26 +- src/config/warden.php | 26 + src/stubs/github-workflow.yml | 35 ++ src/stubs/gitlab-ci.yml | 24 + tests/Commands/WardenAuditCommandTest.php | 10 + tests/Commands/WardenInitCommandTest.php | 69 +++ tests/Fixtures/competitor-capabilities.php | 32 ++ tests/Reporters/ReporterTest.php | 15 + .../Audits/LaravelConfigAuditServiceTest.php | 2 + .../Audits/PlatformAuditServiceTest.php | 48 ++ .../Audits/SourceAuditServiceTest.php | 267 +++++++++ .../Audits/SupplyChainAuditServiceTest.php | 32 ++ tests/Services/CompetitorCapabilityTest.php | 21 + tests/ValueObjects/AuditReportTest.php | 9 + 42 files changed, 2245 insertions(+), 81 deletions(-) create mode 100644 docs/rules.md create mode 100644 src/Commands/WardenInitCommand.php create mode 100644 src/Enums/RuleDisposition.php create mode 100644 src/Services/Audits/PlatformAuditService.php create mode 100644 src/Services/Audits/SourceAuditService.php create mode 100644 src/Services/RulePolicy.php create mode 100644 src/Services/Source/PhpSourceAnalyzer.php create mode 100644 src/Services/Source/SourceFileDiscovery.php create mode 100644 src/Services/Source/TextSourceAnalyzer.php create mode 100644 src/stubs/github-workflow.yml create mode 100644 src/stubs/gitlab-ci.yml create mode 100644 tests/Commands/WardenInitCommandTest.php create mode 100644 tests/Fixtures/competitor-capabilities.php create mode 100644 tests/Services/Audits/PlatformAuditServiceTest.php create mode 100644 tests/Services/Audits/SourceAuditServiceTest.php create mode 100644 tests/Services/CompetitorCapabilityTest.php diff --git a/UPGRADE.md b/UPGRADE.md index e7f3b52..85502a5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -24,6 +24,8 @@ Run Warden before the production artifact is rebuilt with `composer install --no | `warden:schedule` | Removed; schedule the CI pipeline instead | | `--output=jenkins` | `--format=junit` | +Warden 2.0 also adds `warden:init`, the default `source` and `platform` audits, and parser-backed application security findings. + The exit code contract is now strict: findings return `1`, while an incomplete or invalid audit returns `2` even when `--fail-on=never` is used. ## Configuration changes @@ -32,6 +34,8 @@ The exit code contract is now strict: findings return `1`, while an incomplete o - Replace wildcard `ignore_findings` rules with entries containing `id`, `reason`, and `expires_at`; add `fingerprint` when only one occurrence should be accepted. - Replace custom audit implementations with the typed `run(AuditContext): AuditResult` contract. - Configure notifications only under `warden.notifications`; the legacy webhook path is no longer dispatched. +- Add optional `rule_overrides` values using `enforced`, `advisory`, or `off`; malformed overrides fail before any scan starts. +- Review `audits.source`, `audits.supply_chain`, and `audits.platform` when merging published configuration. Republish the configuration or merge the new defaults manually: diff --git a/composer.json b/composer.json index 2bf2085..95fe20c 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,8 @@ "vulnerabilities", "audits", "ci", + "sast", + "secret-scanning", "supply-chain", "notifications", "CVE" @@ -37,6 +39,8 @@ "illuminate/http": "^12.0|^13.0", "illuminate/support": "^12.0|^13.0", "guzzlehttp/guzzle": "^7.0", + "nikic/php-parser": "^5.8", + "symfony/finder": "^7.2|^8.0", "symfony/process": "^7.2|^8.0" }, "extra": { diff --git a/composer.lock b/composer.lock index 51fe0c7..1fd57ab 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "db2350f3b26bb90f021689e584848eb1", + "content-hash": "b10cb46301c4f3f1ef9a3e60610588e3", "packages": [ { "name": "brick/math", @@ -2326,6 +2326,63 @@ }, "time": "2026-05-11T20:49:54+00:00" }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, { "name": "nunomaduro/termwind", "version": "v2.4.0", @@ -6477,63 +6534,6 @@ ], "time": "2025-08-01T08:46:24+00:00" }, - { - "name": "nikic/php-parser", - "version": "v5.8.0", - "source": { - "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } - ], - "description": "A PHP parser written in PHP", - "keywords": [ - "parser", - "php" - ], - "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" - }, - "time": "2026-07-04T14:30:18+00:00" - }, { "name": "nunomaduro/collision", "version": "v8.9.4", diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..108aecc --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,44 @@ +# Warden rule catalogue + +Every rule has a stable ID and a default disposition. `enforced` findings participate in `--fail-on`; `advisory` findings are reported without changing the security exit code. Any rule in this table may be changed to `enforced`, `advisory`, or `off` through `warden.rule_overrides`. + +## Source rules + +| Rule ID | Default | Purpose | +|---|---|---| +| `source.secrets.provider-credential` | enforced | Recognizable provider credentials and private-key headers | +| `source.secrets.suspicious-literal` | advisory | Literal values assigned to secret-named keys or variables | +| `source.php.sql-dynamic-raw` | enforced | Interpolation, concatenation, or dynamic formatting in Laravel raw SQL | +| `source.php.command-tainted-input` | enforced | Request input reaching shell execution functions | +| `source.php.unsafe-deserialization` | enforced | Request input reaching unrestricted `unserialize()` | +| `source.php.ssrf-tainted-url` | enforced | Request input selecting an HTTP client or cURL destination | +| `source.php.path-traversal` | enforced | Request input reaching include, filesystem, or Storage paths | +| `source.php.open-redirect` | enforced | Request input selecting an external redirect or Location header | +| `source.php.xss-tainted-output` | enforced | Request input reaching raw PHP output without recognized escaping | +| `source.php.tls-verification-disabled` | enforced | Laravel, Guzzle, cURL, or stream TLS verification disabled | +| `source.php.weak-cipher` | enforced | Deprecated or unsuitable constant cipher selection | +| `source.laravel.csrf-disabled` | enforced | Explicit removal of Laravel request-forgery middleware | +| `source.blade.unescaped-output` | advisory | Blade `{!! !!}` output requiring trust review | +| `source.blade.form-missing-csrf` | advisory | Mutable non-Livewire form without an obvious CSRF directive | +| `source.laravel.mass-assignment-disabled` | advisory | `$guarded = []` or global `Model::unguard()` | +| `source.php.debug-call` | advisory | Debug/output functions left in application source | +| `source.php.sensitive-log` | advisory | Secret-named or request data referenced by logging code | +| `source.php.weak-hash-context` | advisory | MD5 or SHA-1 appearing in a security-named context | +| `source.php.insecure-rng-context` | advisory | Predictable randomness appearing in a security-named context | + +## Configuration, platform, and supply-chain rules + +| Rule ID | Default | Purpose | +|---|---|---| +| `laravel.cors.wildcard-credentials` | enforced | Wildcard CORS combined with credentials | +| `laravel.cors.wildcard-origin` | advisory | Wildcard CORS without credentials | +| `laravel.debug-tool.enabled` | enforced | Telescope, Debugbar, or Clockwork enabled in production | +| `deployment.env.permissions` | advisory | World-readable or world-writable production `.env` | +| `deployment.path.world-writable` | advisory | World-writable Laravel runtime path | +| `platform.php.eol` | enforced | Unsupported PHP branch | +| `platform.php.security-only` | advisory | PHP in security-only or near-EOL support | +| `platform.laravel.eol` | enforced | Unsupported Laravel major | +| `platform.laravel.security-only` | advisory | Laravel in security-only or near-EOL support | +| `supply-chain.composer.recent-package` | advisory | Package inside the configured release-age window | +| `supply-chain.composer.recent-executable-package` | enforced | Recent package using Composer plugins or `autoload.files` | +| `supply-chain.composer.release-time-missing` | advisory | Package whose release age cannot be evaluated offline | \ No newline at end of file diff --git a/readme.md b/readme.md index a998b03..fb14882 100644 --- a/readme.md +++ b/readme.md @@ -25,12 +25,15 @@ php artisan warden:audit --profile=production --scope=production composer install --no-dev --no-interaction --optimize-autoloader ``` -Publishing the configuration is optional: +Initialize Warden safely and optionally generate a dedicated CI file: ```bash -php artisan vendor:publish --tag=warden-config +php artisan warden:init --ci=github +# or: --ci=gitlab|both|none ``` +`warden:init` never overwrites an existing `config/warden.php` or root GitLab pipeline. `--force` may replace only Warden-owned generated CI files. Publishing with `vendor:publish --tag=warden-config` remains available for manual setups. + ## CI usage The default command uses the CI profile, audits production dependencies, reports every finding, and fails on low severity or higher: @@ -52,7 +55,7 @@ php artisan warden:audit --profile=production php artisan warden:audit --scope=all # Select or skip audits -php artisan warden:audit --only=supply-chain,composer,laravel-config +php artisan warden:audit --only=supply-chain,composer,laravel-config,platform,source php artisan warden:audit --skip=npm,storage # Produce CI artifacts @@ -88,10 +91,42 @@ CI environment variables do not disable production checks. Use `--profile=produc - `composer`: Composer advisories, malware, and abandoned production packages from `composer.lock` - `npm`: npm advisories from `package-lock.json`, auto-detected without a flag - `laravel-config`: tracked `.env` detection and production application/session/tooling rules +- `platform`: offline PHP and Laravel support-window enforcement, including Composer's exact platform target +- `source`: parser-backed PHP taint analysis, Blade review, and redacted credential detection - `storage`: production-only operational warnings; these do not fail the security gate Yarn, pnpm, and Bun lockfiles are detected but are not yet parsed. Warden reports the limitation so the package-manager-native audit can be added as a separate CI step. +### Source security model + +Warden parses each selected PHP file once and distinguishes enforcement from review guidance: + +- Blocking rules require a high-confidence condition such as request-controlled data reaching a command, raw output, outbound URL, redirect, deserializer, or filesystem sink. Other blocking rules cover interpolated raw SQL, disabled TLS verification, provider-format credentials, weak constant ciphers, and explicit CSRF middleware removal. +- Advisory rules highlight unescaped Blade output, forms without an obvious CSRF directive, mass-assignment disabling, debug calls, sensitive logging, weak contextual hashing/randomness, and secret-like literals. + +Credentials are never copied into reports. Warden emits only the provider, location, and a redacted description; the secret contributes only a one-way hash to the stable fingerprint. + +Default PHP scan paths are `app`, `bootstrap`, `config`, and `routes`; Blade templates are read from `resources/views`. File paths, exclusions, and the 1 MiB file limit are configurable under `warden.audits.source`. A selected file that cannot be read or parsed makes the scan incomplete and exits `2`. + +### Rule policy + +Every configurable rule has a stable ID and a built-in disposition. Override one without suppressing individual occurrences: + +```php +'rule_overrides' => [ + 'source.blade.unescaped-output' => 'enforced', + 'source.php.debug-call' => 'off', +], +``` + +Allowed values are `enforced`, `advisory`, and `off`. Unknown rule IDs and invalid values are configuration errors. Advisory findings remain visible in every report but do not affect exit `1`; suppressions and baselines still apply to them. + +See the complete [rule catalogue](docs/rules.md) for stable IDs, default dispositions, and rationale. + +### Supply-chain review window + +Composer packages released within three days produce an advisory. A recent package becomes a blocking critical finding when it is a Composer plugin or registers `autoload.files`, because it can execute automatically. The window is offline, uses `composer.lock` timestamps, respects `--scope`, and is configurable with `warden.audits.supply_chain.minimum_release_age_days`. + ## Reviewed suppressions Suppressions are exact, documented, and expiring. Wildcards are not supported. @@ -126,12 +161,14 @@ This creates `warden-baseline.json`. Baseline generation refuses to write a file Warden supports: - `console`: readable terminal report -- `json`: versioned Warden schema with audits, findings, ignored findings, errors, and summary; the schema ships at `resources/schemas/warden-report-2.0.0.json` +- `json`: versioned Warden schema with audits, blocking/advisory counts, findings, ignored findings, errors, and summary; the schema ships at `resources/schemas/warden-report-2.0.0.json` - `github`: GitHub Actions workflow annotations - `gitlab`: GitLab dependency scanning report schema 15.2.4 - `sarif`: SARIF 2.1.0 for GitHub code scanning and compatible platforms - `junit`: portable JUnit XML for Jenkins and other CI systems +Advisory findings render as notices in GitHub and skipped tests in JUnit. SARIF and JSON preserve the `blocking` property. + `--output-file=-` writes to stdout. Relative file paths are resolved from the Laravel application root. ## Notifications diff --git a/resources/schemas/warden-report-2.0.0.json b/resources/schemas/warden-report-2.0.0.json index 20d8099..08f29a4 100644 --- a/resources/schemas/warden-report-2.0.0.json +++ b/resources/schemas/warden-report-2.0.0.json @@ -29,9 +29,11 @@ }, "summary": { "type": "object", - "required": ["total", "ignored", "errors", "severity"], + "required": ["total", "blocking", "advisory", "ignored", "errors", "severity"], "properties": { "total": { "type": "integer", "minimum": 0 }, + "blocking": { "type": "integer", "minimum": 0 }, + "advisory": { "type": "integer", "minimum": 0 }, "ignored": { "type": "integer", "minimum": 0 }, "errors": { "type": "integer", "minimum": 0 }, "severity": { diff --git a/src/Commands/WardenAuditCommand.php b/src/Commands/WardenAuditCommand.php index 9da04c6..3857094 100644 --- a/src/Commands/WardenAuditCommand.php +++ b/src/Commands/WardenAuditCommand.php @@ -54,12 +54,14 @@ public function handle(): int return $this->renderInvalidConfiguration($validationError, $profile, $scope, $format, $outputFile); } + $scannedAt = CarbonImmutable::now(); $auditContext = new AuditContext( profile: $profile, scope: $scope, timeout: (int) $timeout, only: $only, skip: $skip, + scannedAt: $scannedAt, ); $progress = $format === 'console' && $outputFile === '-' ? function (string $audit, string $status, ?float $duration): void { @@ -69,7 +71,6 @@ public function handle(): int } : null; - $scannedAt = CarbonImmutable::now(); $suppressionErrors = $this->suppressionService->errors(now: $scannedAt); $auditReport = $suppressionErrors === [] ? $this->suppressionService->apply($this->auditRunner->run($auditContext, $progress)) diff --git a/src/Commands/WardenBaselineCommand.php b/src/Commands/WardenBaselineCommand.php index 0c9cfeb..02bd788 100644 --- a/src/Commands/WardenBaselineCommand.php +++ b/src/Commands/WardenBaselineCommand.php @@ -90,6 +90,7 @@ public function handle(): int timeout: $timeout, only: $only, skip: $skip, + scannedAt: CarbonImmutable::now(), ); $suppressionErrors = $this->suppressionService->errors(includeBaseline: false); if ($suppressionErrors !== []) { diff --git a/src/Commands/WardenInitCommand.php b/src/Commands/WardenInitCommand.php new file mode 100644 index 0000000..3c1c9a4 --- /dev/null +++ b/src/Commands/WardenInitCommand.php @@ -0,0 +1,147 @@ +option('ci')); + if (!in_array($ci, ['github', 'gitlab', 'both', 'none'], true)) { + $this->error('Invalid --ci value. Expected github, gitlab, both, or none.'); + + return Command::FAILURE; + } + + if (!$this->publishConfiguration()) { + return Command::FAILURE; + } + + $targets = $ci === 'both' ? ['github', 'gitlab'] : ($ci === 'none' ? [] : [$ci]); + foreach ($targets as $target) { + if (!$this->{'generate' . ucfirst($target)}()) { + return Command::FAILURE; + } + } + + $this->info('Warden initialization complete.'); + + return Command::SUCCESS; + } + + private function publishConfiguration(): bool + { + $target = config_path('warden.php'); + if (is_link($target)) { + $this->error('Refusing to write config/warden.php through a symbolic link.'); + + return false; + } + + if (is_file($target)) { + $this->line('Preserved existing config/warden.php.'); + + return true; + } + + if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0755, true) && !is_dir(dirname($target))) { + $this->error('Unable to create the application config directory.'); + + return false; + } + + if (!copy(__DIR__ . '/../config/warden.php', $target)) { + $this->error('Unable to publish config/warden.php.'); + + return false; + } + + $this->info('Created config/warden.php.'); + + return true; + } + + private function generateGithub(): bool + { + $target = base_path('.github/workflows/warden.yml'); + + return $this->writeOwnedFile($target, $this->stub('github-workflow.yml'), '.github/workflows/warden.yml'); + } + + private function generateGitlab(): bool + { + $target = base_path('.gitlab/warden.yml'); + if (!$this->writeOwnedFile($target, $this->stub('gitlab-ci.yml'), '.gitlab/warden.yml')) { + return false; + } + + $root = base_path('.gitlab-ci.yml'); + if (is_link($root)) { + $this->error('Refusing to write .gitlab-ci.yml through a symbolic link.'); + + return false; + } + + if (!is_file($root)) { + if (file_put_contents($root, "include:\n - local: .gitlab/warden.yml\n") === false) { + $this->error('Unable to create .gitlab-ci.yml.'); + + return false; + } + + $this->info('Created .gitlab-ci.yml with the Warden include.'); + } else { + $this->line('Existing .gitlab-ci.yml was preserved. Add: include: [{ local: .gitlab/warden.yml }]'); + } + + return true; + } + + private function writeOwnedFile(string $target, string $contents, string $display): bool + { + if (is_link($target)) { + $this->error(sprintf('Refusing to write %s through a symbolic link.', $display)); + + return false; + } + + if (is_file($target) && !(bool) $this->option('force')) { + $this->error(sprintf('%s already exists; pass --force to replace this Warden-owned file.', $display)); + + return false; + } + + if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0755, true) && !is_dir(dirname($target))) { + $this->error(sprintf('Unable to create the directory for %s.', $display)); + + return false; + } + + if (file_put_contents($target, $contents) === false) { + $this->error(sprintf('Unable to write %s.', $display)); + + return false; + } + + $this->info(sprintf('Created %s.', $display)); + + return true; + } + + private function stub(string $name): string + { + $contents = file_get_contents(__DIR__ . '/../stubs/' . $name); + + return str_replace('{{PHP_VERSION}}', PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, is_string($contents) ? $contents : ''); + } +} diff --git a/src/Enums/RuleDisposition.php b/src/Enums/RuleDisposition.php new file mode 100644 index 0000000..491416f --- /dev/null +++ b/src/Enums/RuleDisposition.php @@ -0,0 +1,12 @@ +commands([ WardenAuditCommand::class, WardenBaselineCommand::class, + WardenInitCommand::class, WardenSyntaxCommand::class, ]); } diff --git a/src/Reporters/ConsoleReporter.php b/src/Reporters/ConsoleReporter.php index 0a97ad3..f49a346 100644 --- a/src/Reporters/ConsoleReporter.php +++ b/src/Reporters/ConsoleReporter.php @@ -48,7 +48,7 @@ public function format(AuditReport $auditReport): string $finding->id, $finding->title, $location, - $finding->blocking ? '' : ' (warning only)', + $finding->blocking ? '' : ' [ADVISORY — non-blocking]', ); $lines[] = ' ' . $finding->description; if ($finding->remediation !== null) { diff --git a/src/Reporters/GitHubReporter.php b/src/Reporters/GitHubReporter.php index 80ef73d..a1a946b 100644 --- a/src/Reporters/GitHubReporter.php +++ b/src/Reporters/GitHubReporter.php @@ -17,7 +17,9 @@ public function format(AuditReport $auditReport): string } foreach ($auditReport->findings() as $finding) { - $level = in_array($finding->severity->value, ['critical', 'high'], true) ? 'error' : 'warning'; + $level = $finding->blocking + ? (in_array($finding->severity->value, ['critical', 'high'], true) ? 'error' : 'warning') + : ('notice'); $properties = ['title=' . $this->key($finding->id)]; if ($finding->path !== null) { $properties[] = 'file=' . $this->key($finding->path); diff --git a/src/Reporters/JunitReporter.php b/src/Reporters/JunitReporter.php index e1041a5..9a4f1dc 100644 --- a/src/Reporters/JunitReporter.php +++ b/src/Reporters/JunitReporter.php @@ -37,7 +37,7 @@ public function format(AuditReport $auditReport): string ); } else { $cases[] = sprintf( - ' %s', + ' %s', $className, $name, $this->escape($finding->description), @@ -50,10 +50,11 @@ public function format(AuditReport $auditReport): string } return sprintf( - "\n\n%s\n\n", + "\n\n%s\n\n", count($cases), count(array_filter($auditReport->findings(), static fn ($finding): bool => $finding->blocking)), count($auditReport->errors()), + count(array_filter($auditReport->findings(), static fn ($finding): bool => !$finding->blocking)), implode("\n", $cases), ); } diff --git a/src/Reporters/SarifReporter.php b/src/Reporters/SarifReporter.php index 7847e4c..e4b2d4e 100644 --- a/src/Reporters/SarifReporter.php +++ b/src/Reporters/SarifReporter.php @@ -22,7 +22,10 @@ public function format(AuditReport $auditReport): string 'shortDescription' => ['text' => $finding->title], 'fullDescription' => ['text' => $finding->description], 'help' => ['text' => $finding->remediation ?? $finding->description], - 'properties' => ['security-severity' => (string) ($finding->severity->weight() * 2.5)], + 'properties' => [ + 'security-severity' => (string) ($finding->severity->weight() * 2.5), + 'blocking' => $finding->blocking, + ], ]; } @@ -50,13 +53,14 @@ private function result(Finding $finding): array { $result = [ 'ruleId' => $finding->id, - 'level' => match ($finding->severity->value) { + 'level' => $finding->blocking ? match ($finding->severity->value) { 'critical', 'high' => 'error', 'medium' => 'warning', default => 'note', - }, + } : 'note', 'message' => ['text' => $finding->title . ' — ' . $finding->description], 'partialFingerprints' => ['wardenFingerprint/v1' => $finding->fingerprint()], + 'properties' => ['blocking' => $finding->blocking], ]; if ($finding->path !== null) { diff --git a/src/Services/AuditRunner.php b/src/Services/AuditRunner.php index cb8d078..ea53597 100644 --- a/src/Services/AuditRunner.php +++ b/src/Services/AuditRunner.php @@ -4,14 +4,15 @@ namespace Dgtlss\Warden\Services; -use Carbon\CarbonImmutable; use Dgtlss\Warden\Contracts\AuditServiceInterface; use Dgtlss\Warden\Contracts\CustomAudit; use Dgtlss\Warden\Services\Audits\ComposerAuditService; use Dgtlss\Warden\Services\Audits\LaravelConfigAuditService; use Dgtlss\Warden\Services\Audits\NpmAuditService; +use Dgtlss\Warden\Services\Audits\PlatformAuditService; use Dgtlss\Warden\Services\Audits\StorageAuditService; use Dgtlss\Warden\Services\Audits\SupplyChainAuditService; +use Dgtlss\Warden\Services\Audits\SourceAuditService; use Dgtlss\Warden\ValueObjects\AuditContext; use Dgtlss\Warden\ValueObjects\AuditError; use Dgtlss\Warden\ValueObjects\AuditReport; @@ -23,6 +24,7 @@ class AuditRunner public function __construct( private readonly Container $container, private readonly AuditExecutor $auditExecutor, + private readonly RulePolicy $rulePolicy, ) { } @@ -31,20 +33,25 @@ public function __construct( */ public function run(AuditContext $auditContext, ?callable $onProgress = null): AuditReport { + $policyErrors = $this->rulePolicy->errors(); + if ($policyErrors !== []) { + return new AuditReport($auditContext, [], configurationErrors: $policyErrors, scannedAt: $auditContext->scanTime()); + } + [$services, $errors] = $this->services($auditContext); if ($errors !== []) { - return new AuditReport($auditContext, [], configurationErrors: $errors, scannedAt: CarbonImmutable::now()); + return new AuditReport($auditContext, [], configurationErrors: $errors, scannedAt: $auditContext->scanTime()); } - $results = $this->auditExecutor->execute($auditContext, $services, $onProgress); + $results = $this->rulePolicy->apply($this->auditExecutor->execute($auditContext, $services, $onProgress)); - return new AuditReport($auditContext, $results, configurationErrors: $errors, scannedAt: CarbonImmutable::now()); + return new AuditReport($auditContext, $results, configurationErrors: $errors, scannedAt: $auditContext->scanTime()); } /** @return list */ public function availableAuditIds(): array { - $ids = ['supply-chain', 'composer', 'npm', 'laravel-config', 'storage']; + $ids = ['supply-chain', 'composer', 'npm', 'laravel-config', 'platform', 'source', 'storage']; $customAudits = config('warden.custom_audits', []); if (is_array($customAudits)) { foreach ($customAudits as $customAudit) { @@ -76,6 +83,8 @@ private function services(AuditContext $auditContext): array $this->container->make(ComposerAuditService::class), $this->container->make(NpmAuditService::class), $this->container->make(LaravelConfigAuditService::class), + $this->container->make(PlatformAuditService::class), + $this->container->make(SourceAuditService::class), $this->container->make(StorageAuditService::class), ]; $errors = []; diff --git a/src/Services/Audits/LaravelConfigAuditService.php b/src/Services/Audits/LaravelConfigAuditService.php index 305bf56..6a5e02b 100644 --- a/src/Services/Audits/LaravelConfigAuditService.php +++ b/src/Services/Audits/LaravelConfigAuditService.php @@ -4,6 +4,7 @@ namespace Dgtlss\Warden\Services\Audits; +use Composer\InstalledVersions; use Dgtlss\Warden\Contracts\AuditServiceInterface; use Dgtlss\Warden\Enums\Severity; use Dgtlss\Warden\ValueObjects\AuditContext; @@ -31,6 +32,7 @@ public function run(AuditContext $auditContext): AuditResult ...$findings, ...$this->applicationFindings(), ...$this->sessionFindings(), + ...$this->corsFindings(), ...$this->toolingFindings(), ]); } @@ -142,9 +144,10 @@ private function sessionFindings(): array /** @return list */ private function toolingFindings(): array { + $findings = []; if (class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled') === true) { - return [new Finding( - id: 'laravel.telescope.enabled', + $findings[] = new Finding( + id: 'laravel.debug-tool.enabled', source: $this->getName(), title: 'Laravel Telescope is enabled in production', severity: Severity::High, @@ -152,10 +155,59 @@ private function toolingFindings(): array remediation: 'Disable Telescope or verify access authorization and data filtering before deployment.', package: 'laravel/telescope', path: 'config/telescope.php', - )]; + identity: 'laravel/telescope', + ); + } + + $tools = [ + ['Barryvdh\\Debugbar\\LaravelDebugbar', 'debugbar.enabled', 'barryvdh/laravel-debugbar', 'Laravel Debugbar'], + ['Clockwork\\Clockwork', 'clockwork.enable', 'itsgoingd/clockwork', 'Clockwork'], + ]; + foreach ($tools as [, $configKey, $package, $label]) { + if (InstalledVersions::isInstalled($package) && config()->get($configKey) === true) { + $findings[] = new Finding( + id: 'laravel.debug-tool.enabled', + source: $this->getName(), + title: sprintf('%s is enabled in production', $label), + severity: Severity::High, + description: sprintf('%s may expose requests, queries, exceptions, and application internals.', $label), + remediation: sprintf('Disable %s in the effective production configuration.', $label), + package: $package, + path: 'config', + identity: $package, + ); + } } - return []; + return $findings; + } + + /** @return list */ + private function corsFindings(): array + { + $origins = config('cors.allowed_origins', []); + $patterns = config('cors.allowed_origins_patterns', []); + $credentials = config('cors.supports_credentials') === true; + $wildcard = (is_array($origins) && in_array('*', $origins, true)) + || (is_array($patterns) && in_array('.*', $patterns, true)); + + if (!$wildcard) { + return []; + } + + return [new Finding( + id: $credentials ? 'laravel.cors.wildcard-credentials' : 'laravel.cors.wildcard-origin', + source: $this->getName(), + title: $credentials ? 'Credentialed CORS accepts every origin' : 'CORS accepts every origin', + severity: $credentials ? Severity::High : Severity::Low, + description: $credentials + ? 'Wildcard origins combined with credential support can expose authenticated responses cross-origin.' + : 'Every origin can call routes covered by the CORS configuration.', + remediation: 'Configure an explicit list of trusted production origins.', + path: 'config/cors.php', + blocking: $credentials, + identity: 'wildcard-origin', + )]; } private function validEncryptionKey(string $key, string $cipher): bool diff --git a/src/Services/Audits/PlatformAuditService.php b/src/Services/Audits/PlatformAuditService.php new file mode 100644 index 0000000..38525f6 --- /dev/null +++ b/src/Services/Audits/PlatformAuditService.php @@ -0,0 +1,131 @@ + */ + private const PHP_SUPPORT = [ + '8.3' => ['active' => '2025-12-31', 'security' => '2027-12-31'], + '8.4' => ['active' => '2026-12-31', 'security' => '2028-12-31'], + '8.5' => ['active' => '2027-12-31', 'security' => '2029-12-31'], + ]; + + /** @var array */ + private const LARAVEL_SUPPORT = [ + '12' => ['active' => '2026-08-13', 'security' => '2027-02-24'], + '13' => ['active' => '2027-09-30', 'security' => '2028-03-17'], + ]; + + public function getName(): string + { + return 'platform'; + } + + public function run(AuditContext $auditContext): AuditResult + { + $warningDays = config('warden.audits.platform.warning_days', 90); + if (!is_int($warningDays) || $warningDays < 0 || $warningDays > 365) { + return AuditResult::failed($this->getName(), 'invalid_configuration', 'Platform warning_days must be between 0 and 365.'); + } + + $phpVersions = [$this->phpVersion()]; + $platform = $this->composerPlatformPhp(); + if ($platform !== null) { + $phpVersions[] = $platform; + } + + $findings = []; + foreach (array_unique($phpVersions) as $version) { + array_push($findings, ...$this->supportFindings('php', $version, self::PHP_SUPPORT, $auditContext->scanTime(), $warningDays)); + } + + $laravel = $this->laravelVersion(); + if ($laravel !== null) { + array_push($findings, ...$this->supportFindings('laravel', $laravel, self::LARAVEL_SUPPORT, $auditContext->scanTime(), $warningDays)); + } + + return AuditResult::complete($this->getName(), $findings); + } + + protected function phpVersion(): string + { + return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION; + } + + protected function laravelVersion(): ?string + { + return InstalledVersions::isInstalled('laravel/framework') ? InstalledVersions::getPrettyVersion('laravel/framework') : null; + } + + /** + * @param array $calendar + * @return list + */ + private function supportFindings(string $platform, string $version, array $calendar, CarbonImmutable $now, int $warningDays): array + { + $majorMinor = $platform === 'laravel' + ? (string) ((int) ltrim($version, 'v')) + : implode('.', array_slice(explode('.', ltrim($version, 'v')), 0, 2)); + $support = $calendar[$majorMinor] ?? null; + $label = ucfirst($platform); + + if ($support === null || $now->startOfDay()->greaterThan(CarbonImmutable::parse($support['security'])->endOfDay())) { + return [new Finding( + id: sprintf('platform.%s.eol', $platform), + source: $this->getName(), + title: sprintf('%s %s is end of life', $label, $version), + severity: Severity::Critical, + description: sprintf('%s %s no longer receives upstream security fixes.', $label, $version), + remediation: sprintf('Upgrade to a security-supported %s release.', $platform), + identity: $majorMinor, + )]; + } + + $activeUntil = CarbonImmutable::parse($support['active'])->endOfDay(); + $securityUntil = CarbonImmutable::parse($support['security'])->endOfDay(); + if ($now->greaterThan($activeUntil) || $now->diffInDays($securityUntil, false) <= $warningDays) { + return [new Finding( + id: sprintf('platform.%s.security-only', $platform), + source: $this->getName(), + title: sprintf('%s %s is in security-only or near-EOL support', $label, $version), + severity: Severity::Low, + description: sprintf('Upstream security support ends on %s.', $securityUntil->format('Y-m-d')), + remediation: sprintf('Plan an upgrade before %s.', $securityUntil->format('Y-m-d')), + blocking: false, + identity: $majorMinor, + )]; + } + + return []; + } + + private function composerPlatformPhp(): ?string + { + $contents = @file_get_contents(base_path('composer.json')); + if (!is_string($contents)) { + return null; + } + + try { + $composer = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + $version = is_array($composer) ? ($composer['config']['platform']['php'] ?? null) : null; + + return is_string($version) && preg_match('/^\d+\.\d+(?:\.\d+)?$/', $version) === 1 ? $version : null; + } +} diff --git a/src/Services/Audits/SourceAuditService.php b/src/Services/Audits/SourceAuditService.php new file mode 100644 index 0000000..22c6978 --- /dev/null +++ b/src/Services/Audits/SourceAuditService.php @@ -0,0 +1,68 @@ +sourceFileDiscovery->discover(); + $errors = $files['errors']; + if ($this->timedOut($startedAt, $auditContext->timeout)) { + $errors[] = new AuditError($this->getName(), 'timeout', 'Source discovery exceeded the configured timeout.'); + + return new AuditResult($this->getName(), [], $errors); + } + + $findings = $this->textSourceAnalyzer->secrets($files['secrets']); + if ($this->timedOut($startedAt, $auditContext->timeout)) { + $errors[] = new AuditError($this->getName(), 'timeout', 'Secret analysis exceeded the configured timeout.'); + + return new AuditResult($this->getName(), $findings, $errors); + } + + array_push($findings, ...$this->textSourceAnalyzer->blade($files['blade'])); + + foreach ($files['php'] as $file) { + if ($this->timedOut($startedAt, $auditContext->timeout)) { + $errors[] = new AuditError($this->getName(), 'timeout', 'Source analysis exceeded the configured timeout.'); + break; + } + + $result = $this->phpSourceAnalyzer->analyze($file); + array_push($findings, ...$result['findings']); + array_push($errors, ...$result['errors']); + } + + return new AuditResult($this->getName(), $findings, $errors); + } + + /** @phpstan-impure */ + protected function timedOut(float $startedAt, int $timeout): bool + { + return (microtime(true) - $startedAt) >= $timeout; + } +} diff --git a/src/Services/Audits/StorageAuditService.php b/src/Services/Audits/StorageAuditService.php index 727f725..2100b2b 100644 --- a/src/Services/Audits/StorageAuditService.php +++ b/src/Services/Audits/StorageAuditService.php @@ -27,8 +27,41 @@ public function run(AuditContext $auditContext): AuditResult } $findings = []; + $envPath = base_path('.env'); + if (is_file($envPath)) { + $permissions = fileperms($envPath); + if (is_int($permissions) && (($permissions & 0004) !== 0 || ($permissions & 0002) !== 0)) { + $findings[] = new Finding( + id: 'deployment.env.permissions', + source: $this->getName(), + title: 'Environment file permissions are too broad', + severity: Severity::Medium, + description: sprintf('.env permissions are %s and permit world read or write access.', substr(sprintf('%o', $permissions), -4)), + remediation: 'Restrict .env to the deployment owner/group, normally mode 600 or 640.', + path: '.env', + blocking: false, + identity: 'env-permissions', + ); + } + } + foreach ($this->directories as $directory) { $path = base_path($directory); + $permissions = @fileperms($path); + if (is_int($permissions) && ($permissions & 0002) !== 0) { + $findings[] = new Finding( + id: 'deployment.path.world-writable', + source: $this->getName(), + title: sprintf('Deployment path is world-writable: %s', $directory), + severity: Severity::Medium, + description: 'Any local user may modify files used by the Laravel runtime.', + remediation: 'Grant write access only to the deployment user or service group.', + path: $directory, + blocking: false, + identity: $directory, + ); + } + if (!is_dir($path) || !is_writable($path)) { $findings[] = new Finding( id: 'deployment.storage.not-writable', diff --git a/src/Services/Audits/SupplyChainAuditService.php b/src/Services/Audits/SupplyChainAuditService.php index eff9610..03af7e7 100644 --- a/src/Services/Audits/SupplyChainAuditService.php +++ b/src/Services/Audits/SupplyChainAuditService.php @@ -22,6 +22,11 @@ public function getName(): string public function run(AuditContext $auditContext): AuditResult { + $minimumAgeDays = config('warden.audits.supply_chain.minimum_release_age_days', 3); + if (!is_int($minimumAgeDays) || $minimumAgeDays < 0 || $minimumAgeDays > 30) { + return AuditResult::failed($this->getName(), 'invalid_configuration', 'minimum_release_age_days must be between 0 and 30.'); + } + $composer = $this->readJson('composer.json'); if ($composer === null) { return AuditResult::failed($this->getName(), 'invalid_composer_json', 'composer.json is missing or invalid JSON.'); @@ -31,6 +36,7 @@ public function run(AuditContext $auditContext): AuditResult $findings = [ ...$composerFindings, ...$this->composerConfigurationFindings($composer), + ...$this->composerReleaseFindings($auditContext), ...$this->javascriptLockFindings(), ]; @@ -212,6 +218,99 @@ private function javascriptLockFindings(): array return []; } + /** @return list */ + private function composerReleaseFindings(AuditContext $auditContext): array + { + $minimumAgeDays = config('warden.audits.supply_chain.minimum_release_age_days', 3); + if ($minimumAgeDays === 0) { + return []; + } + + $lock = $this->readJson('composer.lock'); + if ($lock === null) { + return []; + } + + $sections = $auditContext->scope === 'production' ? ['packages'] : ['packages', 'packages-dev']; + $threshold = $auditContext->scanTime()->subDays($minimumAgeDays); + $findings = []; + + foreach ($sections as $section) { + $packages = $lock[$section] ?? []; + if (!is_array($packages)) { + continue; + } + + foreach ($packages as $package) { + if (!is_array($package) || !is_string($package['name'] ?? null)) { + continue; + } + + $name = $package['name']; + $version = is_string($package['version'] ?? null) ? $package['version'] : 'unknown'; + $releasedAt = is_string($package['time'] ?? null) ? $package['time'] : null; + if ($releasedAt === null) { + $findings[] = $this->releaseTimeMissingFinding($name, $version); + continue; + } + + try { + $release = \Carbon\CarbonImmutable::parse($releasedAt); + } catch (\Throwable) { + $release = null; + } + + if (!$release instanceof \Carbon\CarbonImmutable) { + $findings[] = $this->releaseTimeMissingFinding($name, $version); + continue; + } + + if ($release->lessThan($threshold)) { + continue; + } + + $autoload = is_array($package['autoload'] ?? null) ? $package['autoload'] : []; + $hasAutoloadFiles = is_array($autoload['files'] ?? null) && $autoload['files'] !== []; + $isPlugin = ($package['type'] ?? null) === 'composer-plugin'; + $executable = $hasAutoloadFiles || $isPlugin; + + $findings[] = new Finding( + id: $executable ? 'supply-chain.composer.recent-executable-package' : 'supply-chain.composer.recent-package', + source: $this->getName(), + title: $executable ? 'Recently released package can execute code automatically' : 'Composer package was released recently', + severity: $executable ? Severity::Critical : Severity::Medium, + description: sprintf('%s %s was released at %s%s.', $name, $version, $release->toISOString(), $executable ? ' and registers automatic execution behavior' : ''), + remediation: $executable + ? 'Verify the release provenance and source diff before allowing it into the deployment.' + : 'Delay deployment until the review window passes or explicitly accept the advisory.', + package: $name, + path: 'composer.lock', + blocking: $executable, + metadata: ['released_at' => $release->toISOString(), 'scope' => $section === 'packages-dev' ? 'development' : 'production'], + identity: $name . '@' . $version, + ); + } + } + + return $findings; + } + + private function releaseTimeMissingFinding(string $name, string $version): Finding + { + return new Finding( + id: 'supply-chain.composer.release-time-missing', + source: $this->getName(), + title: 'Composer package release time is unavailable', + severity: Severity::Low, + description: sprintf('%s %s cannot be evaluated by the release-age policy.', $name, $version), + remediation: 'Review the private package provenance and provide release metadata when possible.', + package: $name, + path: 'composer.lock', + blocking: false, + identity: $name . '@' . $version, + ); + } + /** @return array|null */ private function readJson(string $path): ?array { diff --git a/src/Services/RulePolicy.php b/src/Services/RulePolicy.php new file mode 100644 index 0000000..cbc5c7a --- /dev/null +++ b/src/Services/RulePolicy.php @@ -0,0 +1,121 @@ + */ + private const DEFAULTS = [ + 'source.secrets.provider-credential' => RuleDisposition::Enforced, + 'source.secrets.suspicious-literal' => RuleDisposition::Advisory, + 'source.php.sql-dynamic-raw' => RuleDisposition::Enforced, + 'source.php.command-tainted-input' => RuleDisposition::Enforced, + 'source.php.unsafe-deserialization' => RuleDisposition::Enforced, + 'source.php.ssrf-tainted-url' => RuleDisposition::Enforced, + 'source.php.path-traversal' => RuleDisposition::Enforced, + 'source.php.open-redirect' => RuleDisposition::Enforced, + 'source.php.xss-tainted-output' => RuleDisposition::Enforced, + 'source.php.tls-verification-disabled' => RuleDisposition::Enforced, + 'source.php.weak-cipher' => RuleDisposition::Enforced, + 'source.laravel.csrf-disabled' => RuleDisposition::Enforced, + 'source.blade.unescaped-output' => RuleDisposition::Advisory, + 'source.blade.form-missing-csrf' => RuleDisposition::Advisory, + 'source.laravel.mass-assignment-disabled' => RuleDisposition::Advisory, + 'source.php.debug-call' => RuleDisposition::Advisory, + 'source.php.sensitive-log' => RuleDisposition::Advisory, + 'source.php.weak-hash-context' => RuleDisposition::Advisory, + 'source.php.insecure-rng-context' => RuleDisposition::Advisory, + 'laravel.cors.wildcard-credentials' => RuleDisposition::Enforced, + 'laravel.cors.wildcard-origin' => RuleDisposition::Advisory, + 'laravel.debug-tool.enabled' => RuleDisposition::Enforced, + 'deployment.env.permissions' => RuleDisposition::Advisory, + 'deployment.path.world-writable' => RuleDisposition::Advisory, + 'platform.php.eol' => RuleDisposition::Enforced, + 'platform.php.security-only' => RuleDisposition::Advisory, + 'platform.laravel.eol' => RuleDisposition::Enforced, + 'platform.laravel.security-only' => RuleDisposition::Advisory, + 'supply-chain.composer.recent-package' => RuleDisposition::Advisory, + 'supply-chain.composer.recent-executable-package' => RuleDisposition::Enforced, + 'supply-chain.composer.release-time-missing' => RuleDisposition::Advisory, + ]; + + /** @return list */ + public function errors(): array + { + $overrides = config('warden.rule_overrides', []); + if (!is_array($overrides)) { + return [new AuditError('configuration', 'invalid_rule_overrides', 'warden.rule_overrides must be an array.')]; + } + + $errors = []; + foreach ($overrides as $id => $value) { + if (!is_string($id) || !isset(self::DEFAULTS[$id])) { + $errors[] = new AuditError('configuration', 'unknown_rule', sprintf('Unknown rule override "%s".', (string) $id)); + continue; + } + + if (!is_string($value) || RuleDisposition::tryFrom($value) === null) { + $errors[] = new AuditError('configuration', 'invalid_rule_disposition', sprintf('Rule "%s" must be enforced, advisory, or off.', $id)); + } + } + + return $errors; + } + + public function disposition(string $id): ?RuleDisposition + { + $default = self::DEFAULTS[$id] ?? null; + if ($default === null) { + return null; + } + + $overrides = config('warden.rule_overrides', []); + $override = is_array($overrides) ? ($overrides[$id] ?? null) : null; + + return is_string($override) ? (RuleDisposition::tryFrom($override) ?? $default) : $default; + } + + public function enabled(string $id): bool + { + return $this->disposition($id) !== RuleDisposition::Off; + } + + /** + * @param list $results + * @return list + */ + public function apply(array $results): array + { + $updated = []; + foreach ($results as $result) { + $findings = []; + foreach ($result->findings as $finding) { + $disposition = $this->disposition($finding->id); + if ($disposition === RuleDisposition::Off) { + continue; + } + + $findings[] = $disposition instanceof \Dgtlss\Warden\Enums\RuleDisposition + ? $finding->withBlocking($disposition === RuleDisposition::Enforced) + : $finding; + } + + $updated[] = $result->withFindings($findings); + } + + return $updated; + } + + /** @return list */ + public function ruleIds(): array + { + return array_keys(self::DEFAULTS); + } +} diff --git a/src/Services/Source/PhpSourceAnalyzer.php b/src/Services/Source/PhpSourceAnalyzer.php new file mode 100644 index 0000000..54f9098 --- /dev/null +++ b/src/Services/Source/PhpSourceAnalyzer.php @@ -0,0 +1,535 @@ +, errors: list} */ + public function analyze(SplFileInfo $file): array + { + $path = ltrim(str_replace(base_path(), '', $file->getRealPath()), DIRECTORY_SEPARATOR); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + + try { + $nodes = $parser->parse($file->getContents()); + } catch (Error $error) { + return [ + 'findings' => [], + 'errors' => [new AuditError('source', 'parse_error', sprintf('%s: %s', $path, $error->getMessage()))], + ]; + } + + if ($nodes === null) { + return ['findings' => [], 'errors' => []]; + } + + $securityNodeVisitor = new SecurityNodeVisitor($path, $this->rulePolicy); + $nodeTraverser = new NodeTraverser(); + $nodeTraverser->addVisitor($securityNodeVisitor); + $nodeTraverser->traverse($nodes); + + return ['findings' => $securityNodeVisitor->findings(), 'errors' => []]; + } +} + +/** @internal */ +final class SecurityNodeVisitor extends NodeVisitorAbstract +{ + /** @var list */ + private array $findings = []; + + /** @var list> */ + private array $taintScopes = [[]]; + + private readonly Standard $standard; + + public function __construct(private readonly string $path, private readonly RulePolicy $rulePolicy) + { + $this->standard = new Standard(); + } + + /** @return list */ + public function findings(): array + { + return $this->findings; + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof Node\FunctionLike) { + $this->taintScopes[] = []; + } + + if ($node instanceof Node\Expr\Assign && $node->var instanceof Node\Expr\Variable && is_string($node->var->name)) { + if ($this->isTainted($node->expr)) { + $this->taintScopes[$this->scopeIndex()][$node->var->name] = true; + } else { + unset($this->taintScopes[$this->scopeIndex()][$node->var->name]); + } + + if ($node->expr instanceof Node\Expr\FuncCall + && in_array($this->name($node->expr->name), ['rand', 'mt_rand', 'uniqid'], true) + && preg_match('/token|secret|reset|csrf|nonce|salt|password|otp|key/i', $node->var->name) === 1) { + $this->add('source.php.insecure-rng-context', 'Non-cryptographic randomness appears in a security context', Severity::Medium, 'A secret-named value is generated with a predictable randomness function.', 'Use random_bytes(), random_int(), or Str::random().', $node, false); + } + } + + if ($node instanceof Node\Expr\FuncCall) { + $this->inspectFunctionCall($node); + } elseif ($node instanceof Node\Expr\StaticCall) { + $this->inspectStaticCall($node); + } elseif ($node instanceof Node\Expr\MethodCall) { + $this->inspectMethodCall($node); + } elseif ($node instanceof Node\Expr\New_) { + $this->inspectNew($node); + } elseif ($node instanceof Node\Stmt\Echo_) { + $this->inspectEcho($node); + } elseif ($node instanceof Node\Expr\Print_) { + $this->addTaintedFinding('source.php.xss-tainted-output', $node->expr, $node, 'User-controlled input is emitted without escaping', Severity::High, 'Escape output with e()/htmlspecialchars() or return a framework response that encodes data safely.'); + } elseif ($node instanceof Node\Expr\Include_) { + $this->addTaintedFinding('source.php.path-traversal', $node->expr, $node, 'User-controlled path reaches include/require', Severity::High, 'Resolve the path against an allowlisted directory and verify containment before loading it.'); + } elseif ($node instanceof Node\Stmt\Property) { + $this->inspectProperty($node); + } + + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof Node\FunctionLike) { + array_pop($this->taintScopes); + } + + return null; + } + + private function inspectFunctionCall(Node\Expr\FuncCall $funcCall): void + { + $name = $this->name($funcCall->name); + $first = $this->argument($funcCall->args, 0); + + if (in_array($name, ['exec', 'shell_exec', 'system', 'passthru', 'proc_open', 'popen'], true) && $first instanceof Node\Expr) { + $this->addTaintedFinding('source.php.command-tainted-input', $first, $funcCall, 'User-controlled input reaches a shell command', Severity::Critical, 'Use Symfony Process argument arrays and never concatenate request data into a shell command.'); + } + + if ($name === 'unserialize' && $first instanceof Node\Expr && !$this->hasSafeAllowedClasses($funcCall)) { + $this->addTaintedFinding('source.php.unsafe-deserialization', $first, $funcCall, 'User-controlled input reaches unserialize()', Severity::High, 'Use JSON or pass allowed_classes => false after validating the payload.'); + } + + if (in_array($name, ['file_get_contents', 'fopen', 'readfile', 'file_put_contents'], true) && $first instanceof Node\Expr) { + $this->addTaintedFinding('source.php.path-traversal', $first, $funcCall, 'User-controlled path reaches a filesystem operation', Severity::High, 'Resolve the path beneath an allowlisted root and reject traversal before accessing it.'); + } + + if ($name === 'redirect' && $first instanceof Node\Expr) { + $this->addTaintedFinding('source.php.open-redirect', $first, $funcCall, 'User-controlled input determines a redirect target', Severity::Medium, 'Redirect to named internal routes or validate the target against an explicit host allowlist.'); + } + + if ($name === 'header' && $first instanceof Node\Expr && str_contains(strtolower($this->normalized($first)), 'location:')) { + $this->addTaintedFinding('source.php.open-redirect', $first, $funcCall, 'User-controlled input determines a Location header', Severity::Medium, 'Use a named internal route or validate an external destination against an allowlist.'); + } + + if ($name === 'stream_context_create' && $first instanceof Node\Expr\Array_ + && ($this->arrayContainsBoolean($first, ['verify_peer', 'verify_peer_name'], false) + || $this->arrayContainsBoolean($first, ['allow_self_signed'], true))) { + $this->add('source.php.tls-verification-disabled', 'TLS certificate verification is disabled', Severity::High, 'The stream context disables peer verification or permits self-signed certificates.', 'Enable peer and peer-name verification and use a trusted certificate authority.', $funcCall); + } + + $second = $this->argument($funcCall->args, 1); + $third = $this->argument($funcCall->args, 2); + if ($name === 'curl_setopt' && $second instanceof \PhpParser\Node\Expr && $third instanceof \PhpParser\Node\Expr) { + $option = strtoupper($this->name($second)); + if (in_array($option, ['CURLOPT_SSL_VERIFYPEER', 'CURLOPT_SSL_VERIFYHOST'], true) && $this->isFalseLike($third)) { + $this->add('source.php.tls-verification-disabled', 'TLS certificate verification is disabled', Severity::High, 'Disabling TLS verification permits man-in-the-middle attacks.', 'Remove the override and trust the correct certificate authority.', $funcCall); + } + + if ($option === 'CURLOPT_URL') { + $this->addTaintedFinding('source.php.ssrf-tainted-url', $third, $funcCall, 'User-controlled input determines an outbound URL', Severity::High, 'Validate scheme, host, port, DNS result, and redirect destinations against an allowlist.'); + } + } + + if (str_starts_with($name, 'mcrypt_')) { + $this->add('source.php.weak-cipher', 'Deprecated mcrypt cipher is used', Severity::High, 'mcrypt is obsolete and does not provide modern authenticated encryption.', 'Use Laravel encryption or libsodium.', $funcCall); + } + + if (in_array($name, ['openssl_encrypt', 'openssl_decrypt'], true) && $second instanceof \PhpParser\Node\Expr) { + $cipher = strtolower($this->stringValue($second) ?? ''); + if ($cipher !== '' && preg_match('/(?:^|[-_])(des|3des|rc2|rc4|ecb)(?:$|[-_])/', $cipher) === 1) { + $this->add('source.php.weak-cipher', 'Weak or unauthenticated cipher is used', Severity::High, sprintf('The configured cipher "%s" is unsuitable for protecting sensitive data.', $cipher), 'Use Laravel encryption or an authenticated AES-GCM/libsodium construction.', $funcCall); + } + } + + if (in_array($name, ['dd', 'dump', 'var_dump', 'phpinfo'], true)) { + $this->add('source.php.debug-call', 'Debug function remains in application source', Severity::Low, sprintf('%s() may disclose application data when executed.', $name), 'Remove the call or ensure it cannot execute outside local development.', $funcCall, false); + } + + if (in_array($name, ['md5', 'sha1'], true) && $this->hasSecurityContext($funcCall)) { + $this->add('source.php.weak-hash-context', 'Weak hash appears in a security context', Severity::Medium, sprintf('%s() is not suitable for passwords, signatures, or security tokens.', $name), 'Use password_hash, hash_hmac with SHA-256+, or a framework security primitive.', $funcCall, false); + } + + if (in_array($name, ['rand', 'mt_rand', 'uniqid'], true) && $this->hasSecurityContext($funcCall)) { + $this->add('source.php.insecure-rng-context', 'Non-cryptographic randomness appears in a security context', Severity::Medium, sprintf('%s() must not generate secrets or tokens.', $name), 'Use random_bytes(), random_int(), or Str::random().', $funcCall, false); + } + } + + private function inspectStaticCall(Node\Expr\StaticCall $staticCall): void + { + $class = strtolower($this->name($staticCall->class)); + $method = strtolower($this->name($staticCall->name)); + $first = $this->argument($staticCall->args, 0); + + if ($this->endsWith($class, 'db') && in_array($method, ['select', 'insert', 'update', 'delete', 'statement', 'raw'], true) && $first instanceof Node\Expr && $this->isDynamicString($first)) { + $this->add('source.php.sql-dynamic-raw', 'Dynamic data is interpolated into raw SQL', Severity::High, 'Raw SQL contains interpolation or concatenation instead of parameter bindings.', 'Use query builder methods or positional/named bindings.', $staticCall); + } + + if ($this->endsWith($class, 'http') && in_array($method, ['get', 'post', 'put', 'patch', 'delete', 'head', 'send'], true) && $first instanceof Node\Expr) { + $this->addTaintedFinding('source.php.ssrf-tainted-url', $first, $staticCall, 'User-controlled input determines an outbound URL', Severity::High, 'Validate scheme, host, port, DNS result, and redirects against an allowlist.'); + } + + if ($this->endsWith($class, 'storage') && in_array($method, ['get', 'put', 'move', 'copy', 'delete', 'download', 'readstream', 'writestream'], true) && $first instanceof Node\Expr) { + $this->addTaintedFinding('source.php.path-traversal', $first, $staticCall, 'User-controlled path reaches Laravel storage', Severity::High, 'Map external identifiers to server-owned paths instead of accepting a path from the request.'); + } + + if (($this->endsWith($class, 'redirect') || $this->endsWith($class, 'redirector')) && in_array($method, ['away', 'to'], true) && $first instanceof Node\Expr) { + $this->addTaintedFinding('source.php.open-redirect', $first, $staticCall, 'User-controlled input determines a redirect target', Severity::Medium, 'Use named internal routes or an explicit destination allowlist.'); + } + + if ($this->endsWith($class, 'model') && $method === 'unguard') { + $this->add('source.laravel.mass-assignment-disabled', 'Global mass-assignment protection is disabled', Severity::Medium, 'Model::unguard() permits unrestricted mass assignment for every model.', 'Scope unguarding narrowly to trusted seed/import code or use explicit fillable attributes.', $staticCall, false); + } + + if ($this->endsWith($class, 'log') && in_array($method, ['debug', 'info', 'notice', 'warning', 'error'], true) && $this->hasSecurityContext($staticCall)) { + $this->add('source.php.sensitive-log', 'Sensitive value may be written to logs', Severity::Medium, 'A log statement references request or secret-named data.', 'Log an opaque identifier and redact credentials, tokens, and personal data.', $staticCall, false); + } + + if ($this->endsWith($class, 'http') && $method === 'withoutverifying') { + $this->add('source.php.tls-verification-disabled', 'TLS certificate verification is disabled', Severity::High, 'Http::withoutVerifying() permits man-in-the-middle attacks.', 'Remove withoutVerifying() and configure the correct CA certificate.', $staticCall); + } + + if ($this->endsWith($class, 'http') && $method === 'withoptions' && $first instanceof Node\Expr\Array_ && $this->arrayBoolean($first, 'verify') === false) { + $this->add('source.php.tls-verification-disabled', 'TLS certificate verification is disabled', Severity::High, 'The Laravel HTTP client is configured with verify=false.', 'Remove the override and configure the correct CA certificate.', $staticCall); + } + } + + private function inspectMethodCall(Node\Expr\MethodCall $methodCall): void + { + $method = strtolower($this->name($methodCall->name)); + $first = $this->argument($methodCall->args, 0); + + if (in_array($method, ['whereraw', 'selectraw', 'orderbyraw', 'havingraw', 'groupbyraw', 'fromraw'], true) && $first instanceof Node\Expr && $this->isDynamicString($first)) { + $this->add('source.php.sql-dynamic-raw', 'Dynamic data is interpolated into raw SQL', Severity::High, 'A raw query-builder expression contains interpolation or concatenation.', 'Use the method binding argument or a structured query-builder API.', $methodCall); + } + + if (in_array($method, ['away', 'to'], true) && $first instanceof Node\Expr && ($method === 'away' || str_contains(strtolower($this->normalized($methodCall->var)), 'redirect'))) { + $this->addTaintedFinding('source.php.open-redirect', $first, $methodCall, 'User-controlled input determines a redirect target', Severity::Medium, 'Use a named route or validate an external destination against an allowlist.'); + } + + if (in_array($method, ['request', 'get', 'post', 'put', 'patch', 'delete', 'head'], true) + && preg_match('/client|guzzle|http/i', $this->normalized($methodCall->var)) === 1) { + $url = $method === 'request' ? $this->argument($methodCall->args, 1) : $first; + if ($url instanceof Node\Expr) { + $this->addTaintedFinding('source.php.ssrf-tainted-url', $url, $methodCall, 'User-controlled input determines an outbound URL', Severity::High, 'Validate scheme, host, port, DNS result, and redirects against an allowlist.'); + } + } + + if ($method === 'withoutverifying') { + $this->add('source.php.tls-verification-disabled', 'TLS certificate verification is disabled', Severity::High, 'withoutVerifying() permits man-in-the-middle attacks.', 'Remove the override and configure the correct CA certificate.', $methodCall); + } + + if ($method === 'withoptions' && $first instanceof Node\Expr\Array_ && $this->arrayBoolean($first, 'verify') === false) { + $this->add('source.php.tls-verification-disabled', 'TLS certificate verification is disabled', Severity::High, 'An HTTP client is configured with verify=false.', 'Remove the override and configure the correct CA certificate.', $methodCall); + } + + if ($method === 'withoutmiddleware' && $first instanceof Node\Expr && preg_match('/(?:VerifyCsrfToken|ValidateCsrfToken|PreventRequestForgery)/i', $this->normalized($first)) === 1) { + $this->add('source.laravel.csrf-disabled', 'Laravel request-forgery middleware is explicitly disabled', Severity::Critical, 'Application code removes Laravel CSRF/request-forgery protection.', 'Remove the exclusion or scope it to a narrowly reviewed webhook route with alternative verification.', $methodCall); + } + } + + private function inspectNew(Node\Expr\New_ $new): void + { + if (!$new->class instanceof Node\Name || !$this->endsWith(strtolower($new->class->toString()), 'client')) { + return; + } + + $first = $this->argument($new->args, 0); + if ($first instanceof Node\Expr\Array_ && $this->arrayBoolean($first, 'verify') === false) { + $this->add('source.php.tls-verification-disabled', 'TLS certificate verification is disabled', Severity::High, 'An HTTP client is constructed with verify=false.', 'Remove the override and configure the correct CA certificate.', $new); + } + } + + private function inspectEcho(Node\Stmt\Echo_ $echo): void + { + foreach ($echo->exprs as $expr) { + $this->addTaintedFinding('source.php.xss-tainted-output', $expr, $echo, 'User-controlled input is emitted without escaping', Severity::High, 'Escape output with e()/htmlspecialchars() or return a framework response that encodes data safely.'); + } + } + + private function inspectProperty(Node\Stmt\Property $property): void + { + foreach ($property->props as $prop) { + if ($prop->name->toString() === 'guarded' && $prop->default instanceof Node\Expr\Array_ && $prop->default->items === []) { + $this->add('source.laravel.mass-assignment-disabled', 'Model permits every attribute to be mass assigned', Severity::Medium, '$guarded = [] disables Laravel mass-assignment protection for the model.', 'Use explicit fillable attributes or guard sensitive columns.', $property, false); + } + } + } + + private function addTaintedFinding(string $id, Node\Expr $expr, Node $location, string $title, Severity $severity, string $remediation): void + { + if ($this->isTainted($expr)) { + $this->add($id, $title, $severity, 'Request-controlled data reaches a security-sensitive operation without a recognized validation boundary.', $remediation, $location); + } + } + + private function add(string $id, string $title, Severity $severity, string $description, string $remediation, Node $node, bool $blocking = true): void + { + if (!$this->rulePolicy->enabled($id)) { + return; + } + + $this->findings[] = new Finding( + id: $id, + source: 'source', + title: $title, + severity: $severity, + description: $description, + remediation: $remediation, + path: $this->path, + line: max(1, $node->getStartLine()), + blocking: $blocking, + identity: hash('sha256', $this->normalized($node)), + ); + } + + private function isTainted(Node\Expr $expr): bool + { + if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { + return in_array($expr->name, ['_GET', '_POST', '_REQUEST', '_COOKIE', '_FILES'], true) + || isset($this->taintScopes[$this->scopeIndex()][$expr->name]); + } + + if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->var instanceof Node\Expr\Variable && is_string($expr->var->name)) { + return in_array($expr->var->name, ['_GET', '_POST', '_REQUEST', '_COOKIE', '_FILES'], true) || $this->isTainted($expr->var); + } + + if ($expr instanceof Node\Expr\PropertyFetch && $expr->var instanceof Node\Expr\Variable && $expr->var->name === 'request') { + return true; + } + + if ($expr instanceof Node\Expr\MethodCall) { + $method = strtolower($this->name($expr->name)); + if (in_array($method, ['input', 'get', 'post', 'query', 'cookie', 'file', 'all', 'only', 'except'], true) + && (($expr->var instanceof Node\Expr\Variable && $expr->var->name === 'request') || $this->isRequestHelper($expr->var))) { + return true; + } + + return $this->isTainted($expr->var); + } + + if ($expr instanceof Node\Expr\FuncCall) { + $name = $this->name($expr->name); + if (in_array($name, ['request', 'input'], true)) { + return true; + } + + if (in_array($name, ['e', 'htmlspecialchars', 'htmlentities', 'escapeshellarg', 'escapeshellcmd', 'basename', 'realpath'], true)) { + return false; + } + + foreach ($expr->args as $arg) { + if ($arg instanceof Node\Arg && $this->isTainted($arg->value)) { + return true; + } + } + } + + if ($expr instanceof Node\Expr\BinaryOp) { + return $this->isTainted($expr->left) || $this->isTainted($expr->right); + } + + if ($expr instanceof Node\Expr\Ternary) { + return ($expr->if instanceof \PhpParser\Node\Expr && $this->isTainted($expr->if)) || $this->isTainted($expr->else) || $this->isTainted($expr->cond); + } + + if ($expr instanceof Node\Scalar\InterpolatedString) { + foreach ($expr->parts as $part) { + if ($part instanceof Node\Expr && $this->isTainted($part)) { + return true; + } + } + } + + return false; + } + + private function isRequestHelper(Node\Expr $expr): bool + { + return $expr instanceof Node\Expr\FuncCall && $this->name($expr->name) === 'request'; + } + + private function isDynamicString(Node\Expr $expr): bool + { + return $expr instanceof Node\Scalar\InterpolatedString + || ($expr instanceof Node\Expr\BinaryOp\Concat && ($this->containsVariable($expr->left) || $this->containsVariable($expr->right))) + || ($expr instanceof Node\Expr\FuncCall && $this->name($expr->name) === 'sprintf' && $this->containsVariable($expr)); + } + + private function containsVariable(Node $node): bool + { + if ($node instanceof Node\Expr\Variable || $node instanceof Node\Expr\PropertyFetch || $node instanceof Node\Expr\ArrayDimFetch) { + return true; + } + + foreach ($node->getSubNodeNames() as $name) { + $child = $node->$name; + if ($child instanceof Node && $this->containsVariable($child)) { + return true; + } + + if (is_array($child)) { + foreach ($child as $item) { + if ($item instanceof Node && $this->containsVariable($item)) { + return true; + } + } + } + } + + return false; + } + + private function hasSafeAllowedClasses(Node\Expr\FuncCall $funcCall): bool + { + $options = $this->argument($funcCall->args, 1); + return $options instanceof Node\Expr\Array_ && $this->arrayBoolean($options, 'allowed_classes') === false; + } + + private function arrayBoolean(Node\Expr\Array_ $array, string $key): ?bool + { + foreach ($array->items as $item) { + if ($this->stringValue($item->key) !== $key) { + continue; + } + + if ($item->value instanceof Node\Expr\ConstFetch) { + return match (strtolower($item->value->name->toString())) { + 'true' => true, + 'false' => false, + default => null, + }; + } + } + + return null; + } + + /** @param list $keys */ + private function arrayContainsBoolean(Node\Expr\Array_ $array, array $keys, bool $expected): bool + { + foreach ($array->items as $item) { + $key = $this->stringValue($item->key); + if ($key !== null && in_array($key, $keys, true) && $this->arrayItemBoolean($item->value) === $expected) { + return true; + } + + if ($item->value instanceof Node\Expr\Array_ && $this->arrayContainsBoolean($item->value, $keys, $expected)) { + return true; + } + } + + return false; + } + + private function arrayItemBoolean(Node\Expr $expr): ?bool + { + if (!$expr instanceof Node\Expr\ConstFetch) { + return null; + } + + return match (strtolower($expr->name->toString())) { + 'true' => true, + 'false' => false, + default => null, + }; + } + + private function isFalseLike(Node\Expr $expr): bool + { + return ($expr instanceof Node\Expr\ConstFetch && strtolower($expr->name->toString()) === 'false') + || ($expr instanceof Node\Scalar\Int_ && $expr->value === 0); + } + + private function hasSecurityContext(Node $node): bool + { + return preg_match('/password|secret|token|signature|hmac|api_?key|auth|nonce|salt|otp|csrf|reset/i', $this->normalized($node)) === 1; + } + + private function normalized(Node $node): string + { + $printed = $node instanceof Node\Expr ? $this->standard->prettyPrintExpr($node) : $this->standard->prettyPrint([$node]); + return trim((string) preg_replace('/\s+/', ' ', $printed)); + } + + private function stringValue(?Node $node): ?string + { + return $node instanceof Node\Scalar\String_ ? $node->value : null; + } + + private function name(Node|string|null $node): string + { + if (is_string($node)) { + return $node; + } + + if ($node instanceof Node\Name || $node instanceof Node\Identifier) { + return $node->toString(); + } + + if ($node instanceof Node\Expr\ClassConstFetch) { + return $this->name($node->class) . '::' . $this->name($node->name); + } + + if ($node instanceof Node\Expr\ConstFetch) { + return $node->name->toString(); + } + + return ''; + } + + private function endsWith(string $value, string $suffix): bool + { + return $value === $suffix || str_ends_with($value, '\\' . $suffix); + } + + private function scopeIndex(): int + { + return count($this->taintScopes) - 1; + } + + /** + * @param array $arguments + */ + private function argument(array $arguments, int $index): ?Node\Expr + { + $argument = $arguments[$index] ?? null; + + return $argument instanceof Node\Arg ? $argument->value : null; + } +} diff --git a/src/Services/Source/SourceFileDiscovery.php b/src/Services/Source/SourceFileDiscovery.php new file mode 100644 index 0000000..c9b5957 --- /dev/null +++ b/src/Services/Source/SourceFileDiscovery.php @@ -0,0 +1,102 @@ +, blade: list, secrets: list, errors: list} */ + public function discover(): array + { + $maxKb = config('warden.audits.source.max_file_size_kb', 1024); + $exclude = config('warden.audits.source.exclude', []); + $phpPaths = config('warden.audits.source.php_paths', []); + $bladePaths = config('warden.audits.source.blade_paths', []); + + if (!is_int($maxKb) || $maxKb < 1 || $maxKb > 10240 || !is_array($exclude) || !is_array($phpPaths) || !is_array($bladePaths)) { + return ['php' => [], 'blade' => [], 'secrets' => [], 'errors' => [new AuditError('source', 'invalid_configuration', 'Source paths, exclusions, and max_file_size_kb must be valid.')]]; + } + + if (!$this->validPaths($exclude) || !$this->validPaths($phpPaths) || !$this->validPaths($bladePaths)) { + return ['php' => [], 'blade' => [], 'secrets' => [], 'errors' => [new AuditError('source', 'invalid_configuration', 'Source paths must be non-empty paths relative to the application root.')]]; + } + + $excludedPaths = $this->stringList($exclude); + $php = $this->filesWithin($this->stringList($phpPaths), ['*.php'], $excludedPaths); + $blade = $this->filesWithin($this->stringList($bladePaths), ['*.blade.php'], $excludedPaths); + $secrets = $this->filesWithin(['.'], ['*.php', '*.blade.php', '*.js', '*.ts', '*.json', '*.yaml', '*.yml', '.env.example'], $excludedPaths); + + $errors = []; + $limit = $maxKb * 1024; + foreach (array_merge($php, $blade, $secrets) as $file) { + if (!$file->isReadable()) { + $errors[$file->getRelativePathname()] = new AuditError('source', 'unreadable_file', sprintf('Unable to read %s.', $file->getRelativePathname())); + } elseif ($file->getSize() > $limit) { + $errors[$file->getRelativePathname()] = new AuditError('source', 'file_too_large', sprintf('%s exceeds the configured source file limit.', $file->getRelativePathname())); + } + } + + $allowed = static fn (SplFileInfo $file): bool => $file->isReadable() && $file->getSize() <= $limit && $file->getFilename() !== '.env'; + + return [ + 'php' => array_values(array_filter($php, $allowed)), + 'blade' => array_values(array_filter($blade, $allowed)), + 'secrets' => array_values(array_filter($secrets, $allowed)), + 'errors' => array_values($errors), + ]; + } + + /** + * @param list $paths + * @param list $names + * @param list $exclude + * @return list + */ + private function filesWithin(array $paths, array $names, array $exclude): array + { + $existing = array_values(array_filter($paths, static fn (string $path): bool => is_dir(base_path($path)))); + if ($existing === []) { + return []; + } + + $finder = Finder::create()->files()->ignoreUnreadableDirs()->in(array_map(base_path(...), $existing)); + foreach ($names as $name) { + $finder->name($name); + } + + foreach ($exclude as $path) { + $finder->notPath($path); + } + + $files = iterator_to_array($finder, false); + usort($files, static fn (SplFileInfo $a, SplFileInfo $b): int => $a->getRealPath() <=> $b->getRealPath()); + + return $files; + } + + /** + * @param array $items + * @return list + */ + private function stringList(array $items): array + { + return array_values(array_filter($items, static fn (mixed $item): bool => is_string($item) && trim($item) !== '')); + } + + /** @param array $paths */ + private function validPaths(array $paths): bool + { + foreach ($paths as $path) { + if (!is_string($path) || trim($path) === '' || str_starts_with($path, DIRECTORY_SEPARATOR) || preg_match('#(^|[\\/])\.\.([\\/]|$)#', $path) === 1) { + return false; + } + } + + return true; + } +} diff --git a/src/Services/Source/TextSourceAnalyzer.php b/src/Services/Source/TextSourceAnalyzer.php new file mode 100644 index 0000000..1251b4d --- /dev/null +++ b/src/Services/Source/TextSourceAnalyzer.php @@ -0,0 +1,122 @@ + */ + private const SECRET_PATTERNS = [ + 'AWS access key' => '/\bAKIA[0-9A-Z]{16}\b/', + 'GitHub token' => '/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_\w{20,}\b/', + 'GitLab token' => '/\bgl(?:pat|cbt|dt|rt)-[A-Za-z0-9_.-]{20,}\b/', + 'Stripe secret key' => '/\bsk_(?:live|test)_[0-9A-Za-z]{24,}\b/', + 'Slack token' => '/\bxox[baprs]-[0-9A-Za-z-]{10,}\b/', + 'Google API key' => '/\bAIza[0-9A-Za-z_-]{35}\b/', + 'OpenAI API key' => '/\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}\b/', + 'Anthropic API key' => '/\bsk-ant-[A-Za-z0-9_-]{20,}\b/', + 'npm token' => '/\bnpm_[A-Za-z0-9]{36,}\b/', + 'private key' => '/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/', + ]; + + public function __construct(private readonly RulePolicy $rulePolicy) + { + } + + /** + * @param list $files + * @return list + */ + public function secrets(array $files): array + { + $findings = []; + foreach ($files as $file) { + $contents = $file->getContents(); + $path = $this->relativePath($file); + foreach (explode("\n", $contents) as $index => $line) { + foreach (self::SECRET_PATTERNS as $provider => $pattern) { + if (!$this->rulePolicy->enabled('source.secrets.provider-credential') || preg_match($pattern, $line, $match) !== 1) { + continue; + } + + $findings[] = new Finding( + id: 'source.secrets.provider-credential', + source: 'source', + title: sprintf('%s appears to be committed in source', $provider), + severity: Severity::Critical, + description: sprintf('A provider-format credential was detected at %s:%d. Its value has been redacted.', $path, $index + 1), + remediation: 'Revoke and rotate the credential, remove it from history, and load it from a secret store.', + path: $path, + line: $index + 1, + identity: hash('sha256', $match[0]), + ); + continue 2; + } + + if ($this->rulePolicy->enabled('source.secrets.suspicious-literal') && preg_match('/(?:password|secret|api_?key|access_?token|private_?key)["\']?\s*(?:=>|=)\s*["\']([^"\']{8,})["\']/i', $line, $match) === 1) { + $findings[] = new Finding( + id: 'source.secrets.suspicious-literal', + source: 'source', + title: 'Secret-like value is assigned as a literal', + severity: Severity::Medium, + description: sprintf('A secret-named value is assigned a literal at %s:%d. The value has been redacted.', $path, $index + 1), + remediation: 'Confirm the value is fake or move it to a deployment secret store.', + path: $path, + line: $index + 1, + blocking: false, + identity: hash('sha256', $match[1]), + ); + } + } + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + public function blade(array $files): array + { + $findings = []; + foreach ($files as $file) { + $contents = $file->getContents(); + $path = $this->relativePath($file); + foreach (explode("\n", $contents) as $index => $line) { + if ($this->rulePolicy->enabled('source.blade.unescaped-output') && preg_match('/\{!!\s*\$(?!slot\b|attributes\b)([^!]+)!!\}/', $line, $match) === 1) { + $findings[] = $this->advisory('source.blade.unescaped-output', 'Blade renders unescaped output', Severity::Medium, 'Unescaped Blade output may render attacker-controlled HTML.', 'Use escaped {{ }} output or sanitize trusted HTML explicitly.', $path, $index + 1, trim($match[1])); + } + } + + if ($this->rulePolicy->enabled('source.blade.form-missing-csrf') && preg_match_all('/]*method=["\'](?:post|put|patch|delete)["\'][^>]*>(.*?)<\/form>/is', $contents, $forms, PREG_OFFSET_CAPTURE) > 0) { + foreach ($forms[0] as [$form, $offset]) { + if (str_contains(strtolower($form), 'wire:submit') || preg_match('/@csrf|csrf_field\s*\(|csrf_token\s*\(/i', $form) === 1) { + continue; + } + + $line = substr_count(substr($contents, 0, $offset), "\n") + 1; + $findings[] = $this->advisory('source.blade.form-missing-csrf', 'Mutable Blade form has no CSRF directive', Severity::Medium, 'A non-Livewire mutable form does not contain an obvious CSRF token.', 'Add @csrf inside the form or document the alternative protection.', $path, $line, 'form:' . $line); + } + } + } + + return $findings; + } + + private function advisory(string $id, string $title, Severity $severity, string $description, string $remediation, string $path, int $line, string $identity): Finding + { + return new Finding($id, 'source', $title, $severity, $description, $remediation, path: $path, line: $line, blocking: false, identity: $identity); + } + + private function relativePath(SplFileInfo $file): string + { + return ltrim(str_replace(base_path(), '', $file->getRealPath()), DIRECTORY_SEPARATOR); + } +} diff --git a/src/ValueObjects/AuditContext.php b/src/ValueObjects/AuditContext.php index 25d54c1..e9f44a1 100644 --- a/src/ValueObjects/AuditContext.php +++ b/src/ValueObjects/AuditContext.php @@ -4,6 +4,7 @@ namespace Dgtlss\Warden\ValueObjects; +use Carbon\CarbonImmutable; use InvalidArgumentException; final readonly class AuditContext @@ -18,6 +19,7 @@ public function __construct( public int $timeout = 300, public array $only = [], public array $skip = [], + public ?CarbonImmutable $scannedAt = null, ) { if (!in_array($this->profile, ['ci', 'production', 'local'], true)) { throw new InvalidArgumentException(sprintf('Invalid audit profile "%s".', $this->profile)); @@ -43,4 +45,9 @@ public function includes(string $auditId): bool return !in_array($auditId, $this->skip, true) && ($this->only === [] || in_array($auditId, $this->only, true)); } + + public function scanTime(): CarbonImmutable + { + return $this->scannedAt ?? CarbonImmutable::now(); + } } diff --git a/src/ValueObjects/AuditReport.php b/src/ValueObjects/AuditReport.php index f956b95..2bc1b27 100644 --- a/src/ValueObjects/AuditReport.php +++ b/src/ValueObjects/AuditReport.php @@ -91,8 +91,10 @@ public function withFilteredFindings(array $audits, array $ignoredFindings, arra public function jsonSerialize(): array { $counts = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0]; + $blocking = 0; foreach ($this->findings() as $finding) { $counts[$finding->severity->value]++; + $blocking += $finding->blocking ? 1 : 0; } return [ @@ -106,6 +108,8 @@ public function jsonSerialize(): array ], 'summary' => [ 'total' => count($this->findings()), + 'blocking' => $blocking, + 'advisory' => count($this->findings()) - $blocking, 'ignored' => count($this->ignoredFindings), 'errors' => count($this->errors()), 'severity' => $counts, diff --git a/src/ValueObjects/AuditResult.php b/src/ValueObjects/AuditResult.php index 764a09b..d8e8d0f 100644 --- a/src/ValueObjects/AuditResult.php +++ b/src/ValueObjects/AuditResult.php @@ -36,6 +36,12 @@ public function withDuration(float $durationMs): self return new self($this->audit, $this->findings, $this->errors, $durationMs); } + /** @param list $findings */ + public function withFindings(array $findings): self + { + return new self($this->audit, $findings, $this->errors, $this->durationMs); + } + public function succeeded(): bool { return $this->errors === []; diff --git a/src/ValueObjects/Finding.php b/src/ValueObjects/Finding.php index dd8e5f5..341a1aa 100644 --- a/src/ValueObjects/Finding.php +++ b/src/ValueObjects/Finding.php @@ -24,6 +24,7 @@ public function __construct( public ?int $line = null, public bool $blocking = true, public array $metadata = [], + public ?string $identity = null, ) { if ($this->id === '' || $this->source === '' || $this->title === '' || $this->description === '') { throw new InvalidArgumentException('A finding requires a non-empty ID, source, title, and description.'); @@ -32,6 +33,10 @@ public function __construct( if ($this->line !== null && $this->line < 1) { throw new InvalidArgumentException('A finding line number must be positive.'); } + + if ($this->identity === '') { + throw new InvalidArgumentException('A finding identity cannot be empty.'); + } } public function fingerprint(): string @@ -41,10 +46,29 @@ public function fingerprint(): string $this->package ?? '', $this->reference ?? '', $this->path ?? '', - (string) ($this->line ?? ''), + $this->identity ?? (string) ($this->line ?? ''), ])); } + public function withBlocking(bool $blocking): self + { + return new self( + $this->id, + $this->source, + $this->title, + $this->severity, + $this->description, + $this->remediation, + $this->package, + $this->reference, + $this->path, + $this->line, + $blocking, + $this->metadata, + $this->identity, + ); + } + /** @return array */ public function jsonSerialize(): array { diff --git a/src/config/warden.php b/src/config/warden.php index 41cf61f..49f87dc 100644 --- a/src/config/warden.php +++ b/src/config/warden.php @@ -16,6 +16,27 @@ '.git', ], ], + 'source' => [ + 'php_paths' => ['app', 'bootstrap', 'config', 'routes'], + 'blade_paths' => ['resources/views'], + 'exclude' => [ + 'vendor', + 'node_modules', + 'storage', + 'bootstrap/cache', + 'tests', + 'database', + 'public/build', + '.git', + ], + 'max_file_size_kb' => 1024, + ], + 'supply_chain' => [ + 'minimum_release_age_days' => 3, + ], + 'platform' => [ + 'warning_days' => 90, + ], ], /* @@ -35,6 +56,11 @@ 'file' => env('WARDEN_BASELINE_FILE', 'warden-baseline.json'), ], + /* Override a built-in rule with enforced, advisory, or off. */ + 'rule_overrides' => [ + // 'source.blade.unescaped-output' => 'enforced', + ], + 'custom_audits' => [ // \App\Audits\MyCustomAudit::class, ], diff --git a/src/stubs/github-workflow.yml b/src/stubs/github-workflow.yml new file mode 100644 index 0000000..899c9b8 --- /dev/null +++ b/src/stubs/github-workflow.yml @@ -0,0 +1,35 @@ +name: Warden Security Audit + +on: + push: + branches: [main, master] + pull_request: + +permissions: + contents: read + +jobs: + warden: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@e8d4307400f9427dba7cb98e488d6ab85f1cec5f # immutable pin, reviewed 2026-07-10 + + - name: Set up PHP + uses: shivammathur/setup-php@93f351c169514bd9f4630bd31dd63fde23a79f9f # immutable pin, reviewed 2026-07-10 + with: + php-version: '{{PHP_VERSION}}' + tools: composer:v2 + coverage: none + + - name: Cache Composer downloads + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # immutable pin, reviewed 2026-07-10 + with: + path: ~/.composer/cache + key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }} + + - name: Install development dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run Warden + run: php artisan warden:audit --format=github diff --git a/src/stubs/gitlab-ci.yml b/src/stubs/gitlab-ci.yml new file mode 100644 index 0000000..54ec7a3 --- /dev/null +++ b/src/stubs/gitlab-ci.yml @@ -0,0 +1,24 @@ +stages: + - security + +warden: + stage: security + image: composer:2 + cache: + key: + files: + - composer.lock + paths: + - .composer-cache/ + variables: + COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache" + script: + - composer install --prefer-dist --no-progress --no-interaction + - php artisan warden:audit --format=gitlab --output-file=gl-dependency-scanning-report.json + artifacts: + when: always + reports: + dependency_scanning: gl-dependency-scanning-report.json + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' diff --git a/tests/Commands/WardenAuditCommandTest.php b/tests/Commands/WardenAuditCommandTest.php index 0bbe7b2..3c8b85c 100644 --- a/tests/Commands/WardenAuditCommandTest.php +++ b/tests/Commands/WardenAuditCommandTest.php @@ -98,6 +98,16 @@ public function testInvalidSuppressionIsRejectedBeforeExecution(): void self::assertSame(2, $exitCode); } + public function testInvalidRuleOverrideIsAConfigurationErrorBeforeScanning(): void + { + config(['warden.rule_overrides' => ['unknown.rule' => 'off']]); + + $exitCode = Artisan::call('warden:audit', ['--format' => 'json', '--only' => 'source']); + + self::assertSame(2, $exitCode); + self::assertStringContainsString('"code": "unknown_rule"', Artisan::output()); + } + private function bindReport(AuditReport $auditReport): void { $this->mock(AuditRunner::class, function (MockInterface $mock) use ($auditReport): void { diff --git a/tests/Commands/WardenInitCommandTest.php b/tests/Commands/WardenInitCommandTest.php new file mode 100644 index 0000000..b8530d9 --- /dev/null +++ b/tests/Commands/WardenInitCommandTest.php @@ -0,0 +1,69 @@ +originalBasePath = $this->app->basePath(); + $this->temporaryBasePath = sys_get_temp_dir() . '/warden-init-' . bin2hex(random_bytes(8)); + mkdir($this->temporaryBasePath, 0777, true); + $this->app->setBasePath($this->temporaryBasePath); + } + + protected function tearDown(): void + { + $this->app->setBasePath($this->originalBasePath); + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->temporaryBasePath, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof SplFileInfo) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + } + + rmdir($this->temporaryBasePath); + parent::tearDown(); + } + + public function testGithubInitializationIsPinnedAndNonDestructive(): void + { + self::assertSame(0, Artisan::call('warden:init', ['--ci' => 'github'])); + + $workflow = file_get_contents(base_path('.github/workflows/warden.yml')); + self::assertIsString($workflow); + self::assertMatchesRegularExpression('/actions\/checkout@[a-f0-9]{40}/', $workflow); + self::assertMatchesRegularExpression('/setup-php@[a-f0-9]{40}/', $workflow); + self::assertStringContainsString("php-version: '" . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . "'", $workflow); + self::assertFileExists(config_path('warden.php')); + + file_put_contents(config_path('warden.php'), ' true];'); + self::assertSame(1, Artisan::call('warden:init', ['--ci' => 'github'])); + self::assertSame(0, Artisan::call('warden:init', ['--ci' => 'github', '--force' => true])); + self::assertStringContainsString('sentinel', (string) file_get_contents(config_path('warden.php'))); + } + + public function testGitlabInitializationPreservesAnExistingRootPipeline(): void + { + file_put_contents(base_path('.gitlab-ci.yml'), "existing: true\n"); + + self::assertSame(0, Artisan::call('warden:init', ['--ci' => 'gitlab'])); + + self::assertSame("existing: true\n", file_get_contents(base_path('.gitlab-ci.yml'))); + self::assertFileExists(base_path('.gitlab/warden.yml')); + self::assertStringContainsString('dependency_scanning', (string) file_get_contents(base_path('.gitlab/warden.yml'))); + } +} diff --git a/tests/Fixtures/competitor-capabilities.php b/tests/Fixtures/competitor-capabilities.php new file mode 100644 index 0000000..f363e14 --- /dev/null +++ b/tests/Fixtures/competitor-capabilities.php @@ -0,0 +1,32 @@ + 'replaced', + 'npm-cve' => 'replaced', + 'environment' => 'enforced', + 'gitignore-env' => 'replaced', + 'file-permissions' => 'advisory', + 'hardcoded-secrets' => 'enforced', + 'sql-injection' => 'enforced', + 'mass-assignment' => 'advisory', + 'xss' => 'enforced', + 'csrf' => 'enforced', + 'open-redirect' => 'enforced', + 'command-injection' => 'enforced', + 'insecure-deserialization' => 'enforced', + 'debug-functions' => 'advisory', + 'sensitive-exposure' => 'advisory', + 'ssrf' => 'enforced', + 'tls-verification' => 'enforced', + 'cors' => 'enforced', + 'package-freshness' => 'advisory', + 'supply-chain-tooling' => 'intentionally-omitted', + 'path-traversal' => 'enforced', + 'weak-cryptography' => 'enforced', + 'insecure-rng' => 'advisory', + 'session-security' => 'enforced', + 'eol-versions' => 'enforced', + 'suspicious-autoload' => 'replaced', +]; diff --git a/tests/Reporters/ReporterTest.php b/tests/Reporters/ReporterTest.php index 229d648..a3f7643 100644 --- a/tests/Reporters/ReporterTest.php +++ b/tests/Reporters/ReporterTest.php @@ -27,6 +27,8 @@ public function testJsonContainsCompleteVersionedContract(): void self::assertSame('2.0.0', $decoded['schema_version']); self::assertSame('completed', $decoded['run']['status']); self::assertSame('reporter.high', $decoded['findings'][0]['id']); + self::assertSame(1, $decoded['summary']['blocking']); + self::assertSame(0, $decoded['summary']['advisory']); self::assertArrayHasKey('audits', $decoded); self::assertArrayHasKey('errors', $decoded); @@ -67,6 +69,19 @@ public function testJunitIsValidXmlAndRepresentsFindingsAsFailures(): void self::assertSame(1, $domDocument->getElementsByTagName('failure')->length); } + public function testAdvisoriesAreSarifNotesAndJunitSkippedCases(): void + { + $finding = new Finding('review.me', 'source', 'Review me', Severity::High, 'Advisory only.', blocking: false); + $auditReport = new AuditReport(new AuditContext(), [AuditResult::complete('source', [$finding])]); + $sarif = json_decode((new SarifReporter())->format($auditReport), true, 512, JSON_THROW_ON_ERROR); + $domDocument = new DOMDocument(); + $domDocument->loadXML((new JunitReporter())->format($auditReport)); + + self::assertSame('note', $sarif['runs'][0]['results'][0]['level']); + self::assertFalse($sarif['runs'][0]['results'][0]['properties']['blocking']); + self::assertSame(1, $domDocument->getElementsByTagName('skipped')->length); + } + private function report(): AuditReport { $finding = new Finding( diff --git a/tests/Services/Audits/LaravelConfigAuditServiceTest.php b/tests/Services/Audits/LaravelConfigAuditServiceTest.php index 3030d25..daae229 100644 --- a/tests/Services/Audits/LaravelConfigAuditServiceTest.php +++ b/tests/Services/Audits/LaravelConfigAuditServiceTest.php @@ -31,6 +31,8 @@ public function testSecureProductionConfigurationPasses(): void 'session.http_only' => true, 'session.same_site' => 'lax', 'telescope.enabled' => false, + 'cors.allowed_origins' => ['https://example.com'], + 'cors.supports_credentials' => false, ]); $auditResult = (new LaravelConfigAuditService())->run(new AuditContext(profile: 'production')); diff --git a/tests/Services/Audits/PlatformAuditServiceTest.php b/tests/Services/Audits/PlatformAuditServiceTest.php new file mode 100644 index 0000000..7354983 --- /dev/null +++ b/tests/Services/Audits/PlatformAuditServiceTest.php @@ -0,0 +1,48 @@ +run(new AuditContext(scannedAt: CarbonImmutable::parse('2027-01-01'))); + + self::assertSame(['platform.php.eol', 'platform.laravel.eol'], array_map(static fn ($finding): string => $finding->id, $auditResult->findings)); + self::assertTrue($auditResult->findings[0]->blocking); + + $supported = new class extends PlatformAuditService { + protected function phpVersion(): string { return '8.5.1'; } + + protected function laravelVersion(): string { return '13.0.0'; } + }; + $passed = $supported->run(new AuditContext(scannedAt: CarbonImmutable::parse('2026-07-10'))); + + self::assertSame([], $passed->findings); + } + + public function testSecurityOnlyPlatformIsAdvisory(): void + { + $service = new class extends PlatformAuditService { + protected function phpVersion(): string { return '8.3.20'; } + + protected function laravelVersion(): ?string { return null; } + }; + $auditResult = $service->run(new AuditContext(scannedAt: CarbonImmutable::parse('2026-07-10'))); + + self::assertSame('platform.php.security-only', $auditResult->findings[0]->id); + self::assertFalse($auditResult->findings[0]->blocking); + } +} diff --git a/tests/Services/Audits/SourceAuditServiceTest.php b/tests/Services/Audits/SourceAuditServiceTest.php new file mode 100644 index 0000000..425820f --- /dev/null +++ b/tests/Services/Audits/SourceAuditServiceTest.php @@ -0,0 +1,267 @@ +originalBasePath = $this->app->basePath(); + $this->temporaryBasePath = sys_get_temp_dir() . '/warden-source-' . bin2hex(random_bytes(8)); + mkdir($this->temporaryBasePath . '/app', 0777, true); + mkdir($this->temporaryBasePath . '/resources/views', 0777, true); + $this->app->setBasePath($this->temporaryBasePath); + } + + protected function tearDown(): void + { + $this->app->setBasePath($this->originalBasePath); + if (is_dir($this->temporaryBasePath)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->temporaryBasePath, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof SplFileInfo) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + } + + rmdir($this->temporaryBasePath); + } + + parent::tearDown(); + } + + public function testBlockingTaintAndConstantRulesAreDetected(): void + { + $this->write('app/DangerousController.php', <<<'PHP' +input('url'); + $path = $request->input('path'); + $command = $request->input('command'); + $id = $request->input('id'); + Http::get( + $url, + ); + Storage::get($path); + system($command); + DB::select("select * from users where id = $id"); + unserialize($request->input('payload')); + redirect($request->input('next')); + echo $request->input('name'); + Http::withoutVerifying(); + openssl_encrypt('data', 'des-ecb', 'key'); + app()->withoutMiddleware([PreventRequestForgery::class]); + } +} +PHP); + + $auditResult = $this->service()->run(new AuditContext()); + $ids = array_column(array_map(static fn ($finding): array => ['id' => $finding->id], $auditResult->findings), 'id'); + + self::assertContains('source.php.ssrf-tainted-url', $ids); + self::assertContains('source.php.path-traversal', $ids); + self::assertContains('source.php.command-tainted-input', $ids); + self::assertContains('source.php.sql-dynamic-raw', $ids); + self::assertContains('source.php.unsafe-deserialization', $ids); + self::assertContains('source.php.open-redirect', $ids); + self::assertContains('source.php.xss-tainted-output', $ids); + self::assertContains('source.php.tls-verification-disabled', $ids); + self::assertContains('source.php.weak-cipher', $ids); + self::assertContains('source.laravel.csrf-disabled', $ids); + self::assertSame([], $auditResult->errors); + } + + public function testAdvisoriesAreVisibleAndSanitizersAvoidBlockingFindings(): void + { + $this->write('app/Reviewed.php', <<<'PHP' +input('name')); + system(escapeshellarg($request->input('command'))); + $resetToken = uniqid(); + Log::info('token', [$request->input('token')]); + dd('local only'); + } +} +PHP); + $this->write('resources/views/form.blade.php', '
{!! $body !!}
'); + + $auditResult = $this->service()->run(new AuditContext()); + $byId = []; + foreach ($auditResult->findings as $finding) { + $byId[$finding->id] = $finding; + } + + self::assertArrayHasKey('source.laravel.mass-assignment-disabled', $byId); + self::assertArrayHasKey('source.php.insecure-rng-context', $byId); + self::assertArrayHasKey('source.php.sensitive-log', $byId); + self::assertArrayHasKey('source.php.debug-call', $byId); + self::assertArrayHasKey('source.blade.unescaped-output', $byId); + self::assertArrayHasKey('source.blade.form-missing-csrf', $byId); + self::assertFalse($byId['source.blade.unescaped-output']->blocking); + self::assertArrayNotHasKey('source.php.xss-tainted-output', $byId); + self::assertArrayNotHasKey('source.php.command-tainted-input', $byId); + } + + public function testSecretsAreRedactedAndFingerprintsSurviveLineMovement(): void + { + $secret = 'ghp_' . str_repeat('A', 36); + $source = " '" . $secret . "'];\n"; + $this->write('app/Secret.php', $source); + + $finding = $this->finding('source.secrets.provider-credential'); + self::assertStringNotContainsString($secret, json_encode($finding, JSON_THROW_ON_ERROR)); + + $auditResult = $this->service()->run(new AuditContext()); + $auditReport = new AuditReport(new AuditContext(), [$auditResult]); + foreach ([new ConsoleReporter(), new JsonReporter(), new GitHubReporter(), new GitLabReporter(), new SarifReporter(), new JunitReporter()] as $reporter) { + self::assertStringNotContainsString($secret, $reporter->format($auditReport)); + } + + $this->write('app/Secret.php', " '" . $secret . "'];\n"); + $second = $this->finding('source.secrets.provider-credential'); + + self::assertSame($finding->fingerprint(), $second->fingerprint()); + self::assertNotSame($finding->line, $second->line); + } + + public function testParseErrorsMakeTheAuditIncomplete(): void + { + $this->write('app/Broken.php', 'service()->run(new AuditContext()); + + self::assertSame('parse_error', $auditResult->errors[0]->code); + self::assertFalse($auditResult->succeeded()); + } + + public function testPhp85SyntaxIsAccepted(): void + { + $this->write('app/Pipeline.php', ' strtoupper(...);'); + + $auditResult = $this->service()->run(new AuditContext()); + + self::assertSame([], $auditResult->errors); + } + + public function testOversizedFilesAndTimeoutsAreIncompleteWhileExclusionsAreHonoured(): void + { + config([ + 'warden.audits.source.max_file_size_kb' => 1, + 'warden.audits.source.exclude' => ['Excluded.php'], + ]); + $this->write('app/Large.php', 'write('app/Excluded.php', 'service()->run(new AuditContext()); + self::assertContains('file_too_large', array_map(static fn ($error): string => $error->code, $auditResult->errors)); + self::assertNotContains('source.php.debug-call', array_map(static fn ($finding): string => $finding->id, $auditResult->findings)); + + config(['warden.audits.source.max_file_size_kb' => 1024]); + $service = new class( + $this->app->make(\Dgtlss\Warden\Services\Source\SourceFileDiscovery::class), + $this->app->make(\Dgtlss\Warden\Services\Source\PhpSourceAnalyzer::class), + $this->app->make(\Dgtlss\Warden\Services\Source\TextSourceAnalyzer::class), + ) extends SourceAuditService { + protected function timedOut(float $startedAt, int $timeout): bool { return true; } + }; + $timedOut = $service->run(new AuditContext()); + self::assertSame('timeout', $timedOut->errors[0]->code); + } + + public function testRuleOverridesCanPromoteDisableAndRejectRules(): void + { + config(['warden.rule_overrides' => [ + 'source.blade.unescaped-output' => 'enforced', + 'source.php.debug-call' => 'off', + ]]); + $rulePolicy = new RulePolicy(); + $result = $rulePolicy->apply([$this->resultWithAdvisories()])[0]; + $byId = []; + foreach ($result->findings as $finding) { + $byId[$finding->id] = $finding; + } + + self::assertTrue($byId['source.blade.unescaped-output']->blocking); + self::assertArrayNotHasKey('source.php.debug-call', $byId); + self::assertSame([], $rulePolicy->errors()); + + config(['warden.rule_overrides' => ['not-a-rule' => 'off']]); + self::assertSame('unknown_rule', (new RulePolicy())->errors()[0]->code); + } + + public function testSourcePathsCannotEscapeTheApplicationRoot(): void + { + config(['warden.audits.source.php_paths' => ['../outside']]); + + $auditResult = $this->service()->run(new AuditContext()); + + self::assertSame('invalid_configuration', $auditResult->errors[0]->code); + } + + private function resultWithAdvisories(): \Dgtlss\Warden\ValueObjects\AuditResult + { + $this->write('app/Debug.php', 'write('resources/views/output.blade.php', '{!! $body !!}'); + + return $this->service()->run(new AuditContext()); + } + + private function finding(string $id): \Dgtlss\Warden\ValueObjects\Finding + { + foreach ($this->service()->run(new AuditContext())->findings as $finding) { + if ($finding->id === $id) { + return $finding; + } + } + + self::fail(sprintf('Finding %s was not produced.', $id)); + } + + private function service(): SourceAuditService + { + return $this->app->make(SourceAuditService::class); + } + + private function write(string $relative, string $contents): void + { + $path = $this->temporaryBasePath . '/' . $relative; + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $contents); + } +} diff --git a/tests/Services/Audits/SupplyChainAuditServiceTest.php b/tests/Services/Audits/SupplyChainAuditServiceTest.php index 0cccbea..aec5254 100644 --- a/tests/Services/Audits/SupplyChainAuditServiceTest.php +++ b/tests/Services/Audits/SupplyChainAuditServiceTest.php @@ -4,6 +4,7 @@ namespace Dgtlss\Warden\Tests\Services\Audits; +use Carbon\CarbonImmutable; use Dgtlss\Warden\Services\Audits\SupplyChainAuditService; use Dgtlss\Warden\Tests\TestCase; use Dgtlss\Warden\ValueObjects\AuditContext; @@ -75,4 +76,35 @@ protected function createComposerValidationProcess(): Process self::assertSame('composer_validation_failed', $auditResult->errors[0]->code); } + + public function testRecentExecutableProductionPackageBlocksWhileOrdinaryAndDevPackagesAdvise(): void + { + file_put_contents(base_path('composer.json'), '{"name":"example/app"}'); + file_put_contents(base_path('composer.lock'), json_encode([ + 'packages' => [ + ['name' => 'vendor/runtime', 'version' => '1.0.0', 'time' => '2026-07-09T00:00:00Z'], + ['name' => 'vendor/plugin', 'version' => '2.0.0', 'time' => '2026-07-09T00:00:00Z', 'type' => 'composer-plugin'], + ], + 'packages-dev' => [ + ['name' => 'vendor/dev-tool', 'version' => '3.0.0', 'time' => '2026-07-09T00:00:00Z'], + ], + ], JSON_THROW_ON_ERROR)); + + $service = new class extends SupplyChainAuditService { + protected function createComposerValidationProcess(): Process + { + return new Process([PHP_BINARY, '-r', 'exit(0);']); + } + }; + + $auditResult = $service->run(new AuditContext(scope: 'production', scannedAt: CarbonImmutable::parse('2026-07-10'))); + $all = $service->run(new AuditContext(scope: 'all', scannedAt: CarbonImmutable::parse('2026-07-10'))); + + self::assertCount(2, $auditResult->findings); + self::assertCount(3, $all->findings); + self::assertSame('supply-chain.composer.recent-package', $auditResult->findings[0]->id); + self::assertFalse($auditResult->findings[0]->blocking); + self::assertSame('supply-chain.composer.recent-executable-package', $auditResult->findings[1]->id); + self::assertTrue($auditResult->findings[1]->blocking); + } } diff --git a/tests/Services/CompetitorCapabilityTest.php b/tests/Services/CompetitorCapabilityTest.php new file mode 100644 index 0000000..a7ff972 --- /dev/null +++ b/tests/Services/CompetitorCapabilityTest.php @@ -0,0 +1,21 @@ + $disposition) { + self::assertIsString($category); + self::assertContains($disposition, ['enforced', 'advisory', 'replaced', 'intentionally-omitted']); + } + } +} diff --git a/tests/ValueObjects/AuditReportTest.php b/tests/ValueObjects/AuditReportTest.php index 6c2a805..d0125e0 100644 --- a/tests/ValueObjects/AuditReportTest.php +++ b/tests/ValueObjects/AuditReportTest.php @@ -51,6 +51,15 @@ public function testNonBlockingFindingNeverFailsTheGate(): void self::assertSame(0, $auditReport->exitCode('low')); } + public function testExplicitIdentityKeepsFingerprintStableAcrossLineChanges(): void + { + $first = new Finding('source.rule', 'source', 'Finding', Severity::High, 'Description', path: 'app/Test.php', line: 10, identity: 'normalized-node'); + $second = new Finding('source.rule', 'source', 'Finding', Severity::High, 'Description', path: 'app/Test.php', line: 99, identity: 'normalized-node'); + + self::assertSame($first->fingerprint(), $second->fingerprint()); + self::assertArrayNotHasKey('identity', $first->jsonSerialize()); + } + private function finding(string $id, Severity $severity): Finding { return new Finding($id, 'test', 'Finding', $severity, 'Description', package: 'vendor/package', path: 'composer.lock'); From 35abf857afcdbf2da38728a5fdcc154e6c4004be Mon Sep 17 00:00:00 2001 From: Nathan Langer <32520453+dgtlss@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:30:37 +0100 Subject: [PATCH 3/4] Pretty console reporter and docs Revamp console output and polish docs. Updates: - Reworked ConsoleReporter to produce a rich, grouped, and colorized summary (durations, per-check counts, severity breakdowns, locations, remediation, etc.) with helper methods for grouping, wrapping, and formatting. - ReportFormatterFactory now accepts a decorated flag and passes it to ConsoleReporter. - WardenAuditCommand improved progress lines, escapes output, and forwards decoration state when building formatters. - README.md and UPGRADE.md refreshed with badges, intro, and clearer sections. - Tests updated (ReporterTest) to cover the new console grouping and per-check summaries. These changes improve CLI readability and ensure ANSI decoration is handled correctly. --- UPGRADE.md | 16 +- readme.md | 50 +++- src/Commands/WardenAuditCommand.php | 14 +- src/Reporters/ConsoleReporter.php | 376 +++++++++++++++++++++--- src/Services/ReportFormatterFactory.php | 4 +- tests/Reporters/ReporterTest.php | 85 ++++++ 6 files changed, 494 insertions(+), 51 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 85502a5..243e24e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,8 +1,20 @@ -# Upgrading from Warden 1.x to 2.0 +

Upgrading to Warden 2.0

+ +

A migration guide for Warden 1.x applications.

+ +

+ Tests + Latest Version on Packagist + Total Downloads + PHP Version + License +

+ +## Introduction Warden 2.0 is a CI-first major release. Review the following changes before updating pipeline configuration. -## Supported platforms +## Supported Platforms - PHP 8.3–8.5 - Laravel 12–13 diff --git a/readme.md b/readme.md index fb14882..73dda45 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,18 @@ -# Warden +

Warden

-Warden is a deterministic Laravel security gate for CI and deployment pipelines. It audits locked production dependencies, supply-chain configuration, and high-confidence Laravel production settings without becoming part of the deployed application. +

A deterministic security gate for Laravel CI and deployment pipelines.

+ +

+ Tests + Latest Version on Packagist + Total Downloads + PHP Version + License +

+ +## Introduction + +Warden audits locked production dependencies, supply-chain configuration, and high-confidence Laravel production settings without becoming part of the deployed application. ## Requirements @@ -17,6 +29,10 @@ Install Warden as a development dependency: composer require --dev dgtlss/warden ``` +Laravel discovers Warden automatically. If package discovery is disabled, register `Dgtlss\Warden\Providers\WardenServiceProvider::class` in your application's providers array. + +## Usage + Run Warden **before** pruning development dependencies from the production artifact: ```bash @@ -34,7 +50,7 @@ php artisan warden:init --ci=github `warden:init` never overwrites an existing `config/warden.php` or root GitLab pipeline. `--force` may replace only Warden-owned generated CI files. Publishing with `vendor:publish --tag=warden-config` remains available for manual setups. -## CI usage +### CI usage The default command uses the CI profile, audits production dependencies, reports every finding, and fails on low severity or higher: @@ -127,7 +143,7 @@ See the complete [rule catalogue](docs/rules.md) for stable IDs, default disposi Composer packages released within three days produce an advisory. A recent package becomes a blocking critical finding when it is a Composer plugin or registers `autoload.files`, because it can execute automatically. The window is offline, uses `composer.lock` timestamps, respects `--scope`, and is configurable with `warden.audits.supply_chain.minimum_release_age_days`. -## Reviewed suppressions +### Reviewed suppressions Suppressions are exact, documented, and expiring. Wildcards are not supported. @@ -144,7 +160,7 @@ Suppressions are exact, documented, and expiring. Wildcards are not supported. An expired or malformed suppression is a configuration error and exits `2`. -## Baselines +### Baselines Legacy applications can commit an explicit fingerprint baseline while continuing to fail on new findings: @@ -156,7 +172,7 @@ php artisan warden:baseline \ This creates `warden-baseline.json`. Baseline generation refuses to write a file if any audit is incomplete. -## Reports +### Reports Warden supports: @@ -171,7 +187,7 @@ Advisory findings render as notices in GitHub and skipped tests in JUnit. SARIF `--output-file=-` writes to stdout. Relative file paths are resolved from the Laravel application root. -## Notifications +### Notifications Notifications are opt-in and never affect the audit exit code: @@ -191,7 +207,7 @@ WARDEN_EMAIL_FROM=warden@example.com Each channel is dispatched once. Delivery failures are written to stderr after the report is produced. -## Custom audits +### Custom audits Custom audits receive the immutable audit context and return a typed result: @@ -229,7 +245,7 @@ final class PublicBucketAudit implements CustomAudit Register the class in `config/warden.php` under `custom_audits`. -## Development +## Testing ```bash composer install @@ -239,9 +255,21 @@ composer rector composer validate --strict ``` -See [contributing.md](contributing.md) for contribution guidelines. +## Changelog + +Please see [the releases page](https://github.com/dgtlss/warden/releases) for a list of changes. + +## Contributing + +Please see [contributing.md](contributing.md) for contribution guidelines. Upgrading from Warden 1.x? Read [UPGRADE.md](UPGRADE.md) before changing the dependency constraint. + +## Security Vulnerabilities + +Please report security vulnerabilities privately using [GitHub Security Advisories](https://github.com/dgtlss/warden/security/advisories/new). + +## License -Upgrading from Warden 1.x? Read [UPGRADE.md](UPGRADE.md) before changing the dependency constraint. +Warden is open-sourced software licensed under the [MIT license](LICENSE). ## License diff --git a/src/Commands/WardenAuditCommand.php b/src/Commands/WardenAuditCommand.php index 3857094..0bdf758 100644 --- a/src/Commands/WardenAuditCommand.php +++ b/src/Commands/WardenAuditCommand.php @@ -13,6 +13,7 @@ use Dgtlss\Warden\ValueObjects\AuditError; use Dgtlss\Warden\ValueObjects\AuditReport; use Illuminate\Console\Command; +use Symfony\Component\Console\Formatter\OutputFormatter; use Throwable; final class WardenAuditCommand extends Command @@ -66,7 +67,13 @@ public function handle(): int $progress = $format === 'console' && $outputFile === '-' ? function (string $audit, string $status, ?float $duration): void { if ($status !== 'running') { - $this->line(sprintf('%s %s (%sms)', $status === 'done' ? '✓' : '✗', $audit, number_format($duration ?? 0, 1))); + $symbol = $status === 'done' ? '✓' : '✗'; + $this->line(sprintf( + '%s %-20s %9s', + $symbol, + OutputFormatter::escape($audit), + number_format($duration ?? 0, 1) . 'ms', + )); } } : null; @@ -77,7 +84,10 @@ public function handle(): int : new AuditReport($auditContext, [], configurationErrors: $suppressionErrors, scannedAt: $scannedAt); try { - $rendered = $this->reportFormatterFactory->make($format)->format($auditReport); + $rendered = $this->reportFormatterFactory->make( + $format, + $format === 'console' && $outputFile === '-' && $this->output->isDecorated(), + )->format($auditReport); } catch (Throwable $throwable) { $this->writeError('Report generation failed: ' . $throwable->getMessage()); diff --git a/src/Reporters/ConsoleReporter.php b/src/Reporters/ConsoleReporter.php index f49a346..6b73a11 100644 --- a/src/Reporters/ConsoleReporter.php +++ b/src/Reporters/ConsoleReporter.php @@ -6,62 +6,370 @@ use Dgtlss\Warden\Contracts\ReportFormatter; use Dgtlss\Warden\ValueObjects\AuditReport; +use Dgtlss\Warden\ValueObjects\AuditResult; +use Dgtlss\Warden\ValueObjects\Finding; +use Symfony\Component\Console\Formatter\OutputFormatter; final class ConsoleReporter implements ReportFormatter { + private const WIDTH = 96; + + private const CHECK_TABLE_WIDTH = 112; + + private readonly OutputFormatter $outputFormatter; + + public function __construct(bool $decorated = false) + { + $this->outputFormatter = new OutputFormatter($decorated); + } + public function format(AuditReport $auditReport): string { + $findings = $auditReport->findings(); + $errors = $auditReport->errors(); + $duration = array_sum(array_map(static fn ($audit): float => $audit->durationMs, $auditReport->audits)); + $completed = count(array_filter($auditReport->audits, static fn ($audit): bool => $audit->succeeded())); + $failed = count($auditReport->audits) - $completed; + $blocking = count(array_filter($findings, static fn (Finding $finding): bool => $finding->blocking)); + $lines = [ - 'Warden 2.0 Security Audit', - sprintf('Profile: %s | Scope: %s', $auditReport->context->profile, $auditReport->context->scope), '', + 'WARDEN 2.0 SECURITY AUDIT', + sprintf( + '%s profile • %s dependencies • %s', + $this->escape(strtoupper($auditReport->context->profile)), + $this->escape($auditReport->context->scope), + $this->duration($duration), + ), + '', + sprintf( + '%d audit%s completed%s • %d active finding%s • %d ignored', + $completed, + $completed === 1 ? '' : 's', + $failed > 0 ? sprintf(' • %d failed', $failed) : '', + count($findings), + count($findings) === 1 ? '' : 's', + count($auditReport->ignoredFindings), + ), + sprintf( + '%s • %d blocking • %d advisory', + $this->severitySummary($findings), + $blocking, + count($findings) - $blocking, + ), ]; + if ($auditReport->audits !== []) { + $lines[] = ''; + $lines[] = 'CHECK RESULTS'; + $lines[] = '' . str_repeat('─', self::CHECK_TABLE_WIDTH) . ''; + $lines[] = '' . $this->escape(sprintf( + ' %-20s %-13s %-26s %-26s %-10s %9s', + 'CHECK', + 'RESULT', + 'SEVERITY', + 'DISPOSITION', + 'ERRORS', + 'TIME', + )) . ''; + foreach ($auditReport->audits as $audit) { + $lines[] = $this->auditSummary($audit); + } + } + + if ($errors !== []) { + $lines[] = ''; + $lines[] = 'SCAN INCOMPLETE ' . count($errors) . ' error' . (count($errors) === 1 ? '' : 's') . ''; + $lines[] = '' . str_repeat('─', 72) . ''; + foreach ($errors as $error) { + $lines[] = sprintf( + '%s %s', + $this->escape($this->auditLabel($error->audit)), + $this->escape($error->code), + ); + array_push($lines, ...$this->detail('Message', $error->message)); + $lines[] = ''; + } + } + + if ($findings === []) { + $lines[] = ''; + $lines[] = $errors === [] + ? '✓ No active security findings.' + : 'No findings were reported before the scan stopped.'; + + return $this->render($lines); + } + foreach ($auditReport->audits as $audit) { + if ($audit->findings === []) { + continue; + } + + $lines[] = ''; $lines[] = sprintf( - '[%s] %s (%sms, %d findings)', - $audit->succeeded() ? 'PASS' : 'FAIL', - $audit->audit, - number_format($audit->durationMs, 1), + '%s %d finding%s • %s%s', + $this->escape($this->auditLabel($audit->audit)), count($audit->findings), + count($audit->findings) === 1 ? '' : 's', + $this->escape($this->plainSeveritySummary($audit->findings)), + $audit->succeeded() ? '' : ' INCOMPLETE', ); + $lines[] = '' . str_repeat('━', 72) . ''; + + $groups = $this->groups($audit->findings); + foreach (['critical', 'high', 'medium', 'low'] as $severity) { + $section = array_values(array_filter($groups, static fn (array $group): bool => $group['severity'] === $severity)); + if ($section === []) { + continue; + } + + $sectionCount = array_sum(array_map(static fn (array $group): int => count($group['findings']), $section)); + $lines[] = sprintf('%s %d', $this->severityLabel($severity), $sectionCount); + + foreach ($section as $group) { + $count = count($group['findings']); + $suffix = $count > 1 ? sprintf(' · %d occurrences', $count) : ''; + $advisory = $group['blocking'] ? '' : ' ADVISORY'; + $lines[] = sprintf(' ● %s%s%s', $this->escape($group['title']), $suffix, $advisory); + $lines[] = sprintf(' %s', $this->escape($group['id'])); + + $descriptions = array_values(array_unique(array_map(static fn (Finding $finding): string => $finding->description, $group['findings']))); + if (count($descriptions) === 1) { + array_push($lines, ...$this->detail('Why', $descriptions[0])); + } + + $packages = array_values(array_unique(array_filter(array_map(static fn (Finding $finding): ?string => $finding->package, $group['findings'])))); + if ($packages !== []) { + array_push($lines, ...$this->detail('Package', implode(', ', $packages))); + } + + $locations = $this->locations($group['findings']); + if ($locations !== []) { + array_push($lines, ...$this->locationLines($locations)); + } + + $references = array_values(array_unique(array_filter(array_map(static fn (Finding $finding): ?string => $finding->reference, $group['findings'])))); + foreach ($references as $reference) { + array_push($lines, ...$this->detail('Reference', $reference)); + } + + if ($group['remediation'] !== null) { + array_push($lines, ...$this->detail('Fix', $group['remediation'], '')); + } + + $lines[] = ''; + } + } } - if ($auditReport->errors() !== []) { - $lines[] = ''; - $lines[] = sprintf('%d audit/configuration error(s):', count($auditReport->errors())); - foreach ($auditReport->errors() as $error) { - $lines[] = sprintf(' - [%s:%s] %s', $error->audit, $error->code, $error->message); + if ($auditReport->ignoredFindings !== []) { + $lines[] = sprintf( + '%d finding%s suppressed by reviewed policy or baseline.', + count($auditReport->ignoredFindings), + count($auditReport->ignoredFindings) === 1 ? '' : 's', + ); + } + + return $this->render($lines); + } + + /** + * @param list $findings + * @return list}> + */ + private function groups(array $findings): array + { + $groups = []; + foreach ($findings as $finding) { + $key = hash('sha256', implode('|', [ + $finding->severity->value, + $finding->blocking ? 'blocking' : 'advisory', + $finding->id, + $finding->title, + $finding->remediation ?? '', + ])); + if (!isset($groups[$key])) { + $groups[$key] = [ + 'severity' => $finding->severity->value, + 'blocking' => $finding->blocking, + 'id' => $finding->id, + 'title' => $finding->title, + 'remediation' => $finding->remediation, + 'findings' => [], + ]; } + + $groups[$key]['findings'][] = $finding; } - $lines[] = ''; - if ($auditReport->findings() === []) { - $lines[] = 'No active security findings.'; - } else { - $lines[] = sprintf('%d active finding(s):', count($auditReport->findings())); - foreach ($auditReport->findings() as $finding) { - $location = $finding->path === null ? '' : sprintf(' [%s%s]', $finding->path, $finding->line === null ? '' : ':' . $finding->line); - $lines[] = sprintf( - ' - %s %s: %s%s%s', - strtoupper($finding->severity->value), - $finding->id, - $finding->title, - $location, - $finding->blocking ? '' : ' [ADVISORY — non-blocking]', - ); - $lines[] = ' ' . $finding->description; - if ($finding->remediation !== null) { - $lines[] = ' Fix: ' . $finding->remediation; - } + return array_values($groups); + } + + /** + * @param list $findings + * @return list + */ + private function locations(array $findings): array + { + $locations = []; + foreach ($findings as $finding) { + if ($finding->path === null) { + continue; } + + $locations[] = $finding->path . ($finding->line === null ? '' : ':' . $finding->line); } - if ($auditReport->ignoredFindings !== []) { - $lines[] = ''; - $lines[] = sprintf('%d finding(s) suppressed by reviewed policy or baseline.', count($auditReport->ignoredFindings)); + return array_values(array_unique($locations)); + } + + /** + * @param list $locations + * @return list + */ + private function locationLines(array $locations): array + { + $lines = []; + foreach ($locations as $index => $location) { + $branch = $index === array_key_last($locations) ? '└─' : '├─'; + $label = $index === 0 ? ' Where ' : ' '; + $lines[] = sprintf('%s%s %s', $label, $branch, $this->escape($location)); + } + + return $lines; + } + + /** @return list */ + private function detail(string $label, string $text, string $style = ''): array + { + $prefix = ' ' . str_pad($label, 10) . ' '; + $wrapped = explode("\n", wordwrap($text, self::WIDTH - strlen($prefix), "\n", false)); + $lines = []; + foreach ($wrapped as $index => $line) { + $lines[] = sprintf('%s%s%s', $style, $index === 0 ? $prefix : str_repeat(' ', strlen($prefix)), $this->escape($line)); + } + + return $lines; + } + + /** @param list $findings */ + private function severitySummary(array $findings): string + { + $parts = []; + foreach (['critical', 'high', 'medium', 'low'] as $severity) { + $count = count(array_filter($findings, static fn (Finding $finding): bool => $finding->severity->value === $severity)); + if ($count > 0) { + $parts[] = sprintf('%s %d', $this->severityLabel($severity), $count); + } + } + + return $parts === [] ? 'Clean' : implode(' ', $parts); + } + + private function auditSummary(AuditResult $auditResult): string + { + $status = $auditResult->succeeded() ? '✓' : '✗'; + $label = str_pad($this->auditLabel($auditResult->audit), 20); + $findingCount = count($auditResult->findings); + $resultText = match (true) { + $findingCount > 0 => sprintf('%d finding%s', $findingCount, $findingCount === 1 ? '' : 's'), + !$auditResult->succeeded() => 'incomplete', + default => 'clean', + }; + $result = str_pad($resultText, 13); + $counts = $this->severityCounts($auditResult->findings); + $severity = $findingCount === 0 + ? str_repeat(' ', 26) + : sprintf('C %3d H %3d M %3d L %3d', $counts['critical'], $counts['high'], $counts['medium'], $counts['low']); + $blocking = count(array_filter($auditResult->findings, static fn (Finding $finding): bool => $finding->blocking)); + $disposition = $findingCount === 0 + ? str_repeat(' ', 26) + : sprintf('%3d blocking %3d advisory', $blocking, $findingCount - $blocking); + $errorCount = count($auditResult->errors); + $errors = str_pad($errorCount === 0 ? '' : sprintf('%d error%s', $errorCount, $errorCount === 1 ? '' : 's'), 10); + $time = str_pad($this->duration($auditResult->durationMs), 9, ' ', STR_PAD_LEFT); + $resultStyle = match (true) { + !$auditResult->succeeded() && $findingCount === 0 => 'red', + $findingCount === 0 => 'green', + default => 'default', + }; + + return sprintf( + '%s %s %s %s %s %s %s', + $status, + $this->escape($label), + $resultStyle, + $this->escape($result), + $this->escape($severity), + $this->escape($disposition), + $this->escape($errors), + $this->escape($time), + ); + } + + /** @param list $findings */ + private function plainSeveritySummary(array $findings): string + { + $labels = ['critical' => 'C', 'high' => 'H', 'medium' => 'M', 'low' => 'L']; + $counts = $this->severityCounts($findings); + $parts = []; + foreach ($labels as $severity => $label) { + $count = $counts[$severity]; + if ($count > 0) { + $parts[] = $label . ' ' . $count; + } } - return implode(PHP_EOL, $lines) . PHP_EOL; + return implode(' ', $parts); + } + + /** + * @param list $findings + * @return array{critical: int, high: int, medium: int, low: int} + */ + private function severityCounts(array $findings): array + { + $counts = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0]; + foreach ($findings as $finding) { + $counts[$finding->severity->value]++; + } + + return $counts; + } + + private function auditLabel(string $audit): string + { + return strtoupper(str_replace('-', ' ', $audit)); + } + + private function severityLabel(string $severity): string + { + $color = match ($severity) { + 'critical' => 'red', + 'high' => 'magenta', + 'medium' => 'yellow', + default => 'blue', + }; + + return sprintf('%s', $color, strtoupper($severity)); + } + + private function duration(float $milliseconds): string + { + return $milliseconds >= 1000 + ? number_format($milliseconds / 1000, 2) . 's' + : number_format($milliseconds, 1) . 'ms'; + } + + /** @param list $lines */ + private function render(array $lines): string + { + return $this->outputFormatter->format(implode(PHP_EOL, $lines) . PHP_EOL) ?? ''; + } + + private function escape(string $value): string + { + return OutputFormatter::escape($value); } } diff --git a/src/Services/ReportFormatterFactory.php b/src/Services/ReportFormatterFactory.php index 368481b..f7b98d8 100644 --- a/src/Services/ReportFormatterFactory.php +++ b/src/Services/ReportFormatterFactory.php @@ -15,10 +15,10 @@ final class ReportFormatterFactory { - public function make(string $format): ReportFormatter + public function make(string $format, bool $decorated = false): ReportFormatter { return match ($format) { - 'console' => new ConsoleReporter(), + 'console' => new ConsoleReporter($decorated), 'json' => new JsonReporter(), 'github' => new GitHubReporter(), 'gitlab' => new GitLabReporter(), diff --git a/tests/Reporters/ReporterTest.php b/tests/Reporters/ReporterTest.php index a3f7643..9038c00 100644 --- a/tests/Reporters/ReporterTest.php +++ b/tests/Reporters/ReporterTest.php @@ -7,6 +7,7 @@ use Carbon\CarbonImmutable; use DOMDocument; use Dgtlss\Warden\Enums\Severity; +use Dgtlss\Warden\Reporters\ConsoleReporter; use Dgtlss\Warden\Reporters\GitLabReporter; use Dgtlss\Warden\Reporters\JsonReporter; use Dgtlss\Warden\Reporters\JunitReporter; @@ -69,6 +70,90 @@ public function testJunitIsValidXmlAndRepresentsFindingsAsFailures(): void self::assertSame(1, $domDocument->getElementsByTagName('failure')->length); } + public function testConsoleReportGroupsRepeatedFindingsIntoReadableSections(): void + { + $first = new Finding( + 'source.secrets.provider-credential', + 'source', + 'Google API key appears to be committed in source', + Severity::Critical, + 'A credential was detected at app/First.php:10.', + 'Rotate the credential and move it to a secret store.', + path: 'app/First.php', + line: 10, + blocking: false, + identity: 'first', + ); + $second = new Finding( + 'source.secrets.provider-credential', + 'source', + 'Google API key appears to be committed in source', + Severity::Critical, + 'A credential was detected at app/Second.php:20.', + 'Rotate the credential and move it to a secret store.', + path: 'app/Second.php', + line: 20, + blocking: false, + identity: 'second', + ); + $auditReport = new AuditReport( + new AuditContext(), + [AuditResult::complete('source', [$first, $second])->withDuration(123.4)], + scannedAt: CarbonImmutable::parse('2026-01-01T00:00:00Z'), + ); + + $output = (new ConsoleReporter())->format($auditReport); + + self::assertStringContainsString('WARDEN 2.0 SECURITY AUDIT', $output); + self::assertStringContainsString('2 active findings', $output); + self::assertStringContainsString('CHECK RESULTS', $output); + self::assertStringContainsString('CHECK RESULT SEVERITY', $output); + self::assertStringContainsString('SOURCE 2 findings C 2 H 0 M 0 L 0 0 blocking 2 advisory', $output); + self::assertStringContainsString('SOURCE 2 findings • C 2', $output); + self::assertStringContainsString('CRITICAL 2', $output); + self::assertStringContainsString('· 2 occurrences', $output); + self::assertStringContainsString('ADVISORY', $output); + self::assertStringContainsString('app/First.php:10', $output); + self::assertStringContainsString('app/Second.php:20', $output); + self::assertSame(1, substr_count($output, 'Google API key appears to be committed in source')); + self::assertSame(1, substr_count($output, 'Rotate the credential and move it to a secret store.')); + self::assertStringNotContainsString('A credential was detected at app/First.php:10.', $output); + } + + public function testConsoleReportMakesPerCheckCountsAndFailuresExplicit(): void + { + $composerFinding = new Finding( + 'composer.advisory.example', + 'composer', + 'Vulnerable Composer dependency', + Severity::High, + 'A dependency is vulnerable.', + ); + $auditReport = new AuditReport(new AuditContext(), [ + AuditResult::complete('composer', [$composerFinding])->withDuration(334.1), + AuditResult::complete('npm')->withDuration(155.4), + AuditResult::failed('source', 'file_too_large', 'main.js is too large.')->withDuration(3426.2), + ]); + + $output = (new ConsoleReporter())->format($auditReport); + + self::assertStringContainsString('COMPOSER 1 finding C 0 H 1 M 0 L 0 1 blocking 0 advisory', $output); + self::assertStringContainsString('NPM clean', $output); + self::assertStringContainsString('SOURCE incomplete', $output); + self::assertStringContainsString('SOURCE file_too_large', $output); + self::assertStringContainsString('COMPOSER 1 finding • H 1', $output); + + $rows = array_values(array_filter( + explode(PHP_EOL, $output), + static fn (string $line): bool => str_starts_with($line, '✓ COMPOSER') + || str_starts_with($line, '✓ NPM') + || str_starts_with($line, '✗ SOURCE'), + )); + self::assertCount(3, $rows); + self::assertSame(strlen($rows[0]), strlen($rows[1])); + self::assertSame(strlen($rows[0]), strlen($rows[2])); + } + public function testAdvisoriesAreSarifNotesAndJunitSkippedCases(): void { $finding = new Finding('review.me', 'source', 'Review me', Severity::High, 'Advisory only.', blocking: false); From 43b1179a4f55a76c75bfec00c952bfbf501c288e Mon Sep 17 00:00:00 2001 From: Nathan Langer <32520453+dgtlss@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:41:15 +0100 Subject: [PATCH 4/4] Harden audits and split debug-tool rules Adds safer audit execution and reporting: separate Telescope, Debugbar, and Clockwork into distinct rule IDs, surface PHP syntax timeouts and command errors, reuse parser/process setup more efficiently, and make SARIF/report output handle missing line/version data cleanly. Also updates init and workflow handling plus related docs/tests. --- .github/workflows/tests.yml | 7 ++ docs/rules.md | 6 +- readme.md | 4 - src/Commands/WardenBaselineCommand.php | 2 +- src/Commands/WardenInitCommand.php | 26 +++- src/Commands/WardenSyntaxCommand.php | 4 + src/Reporters/SarifReporter.php | 4 +- src/Services/AuditRunner.php | 30 +++-- .../Audits/LaravelConfigAuditService.php | 21 ++-- src/Services/Audits/PhpSyntaxAuditService.php | 41 ++++++- src/Services/Audits/PlatformAuditService.php | 2 +- src/Services/Audits/SourceAuditService.php | 5 + src/Services/NotificationDispatcher.php | 3 +- src/Services/RulePolicy.php | 4 +- src/Services/Source/PhpSourceAnalyzer.php | 7 +- src/ValueObjects/AuditReport.php | 26 ++-- tests/Commands/WardenInitCommandTest.php | 10 ++ tests/Commands/WardenSyntaxCommandTest.php | 13 ++ tests/Reporters/ReporterTest.php | 18 +++ tests/Services/AuditRunnerTest.php | 25 ++++ .../Audits/LaravelConfigAuditServiceTest.php | 60 ++++++++++ .../Audits/PhpSyntaxAuditServiceTest.php | 113 ++++++++++++++++++ .../Audits/PlatformAuditServiceTest.php | 21 ++++ .../Audits/SourceAuditServiceTest.php | 43 +++++++ tests/Services/NotificationDispatcherTest.php | 60 ++++++++-- tests/Services/SuppressionServiceTest.php | 22 ++++ tests/ValueObjects/AuditReportTest.php | 10 ++ 27 files changed, 531 insertions(+), 56 deletions(-) create mode 100644 tests/Services/AuditRunnerTest.php create mode 100644 tests/Services/Audits/PhpSyntaxAuditServiceTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1935ff0..d5eed9a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main, master] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -24,6 +27,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: shivammathur/setup-php@v2 with: @@ -58,6 +63,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: shivammathur/setup-php@v2 with: php-version: '8.3' diff --git a/docs/rules.md b/docs/rules.md index 108aecc..1dbf83b 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -32,7 +32,9 @@ Every rule has a stable ID and a default disposition. `enforced` findings partic |---|---|---| | `laravel.cors.wildcard-credentials` | enforced | Wildcard CORS combined with credentials | | `laravel.cors.wildcard-origin` | advisory | Wildcard CORS without credentials | -| `laravel.debug-tool.enabled` | enforced | Telescope, Debugbar, or Clockwork enabled in production | +| `laravel.debug-tool.telescope-enabled` | enforced | Telescope enabled in production | +| `laravel.debug-tool.debugbar-enabled` | enforced | Laravel Debugbar enabled in production | +| `laravel.debug-tool.clockwork-enabled` | enforced | Clockwork enabled in production | | `deployment.env.permissions` | advisory | World-readable or world-writable production `.env` | | `deployment.path.world-writable` | advisory | World-writable Laravel runtime path | | `platform.php.eol` | enforced | Unsupported PHP branch | @@ -41,4 +43,4 @@ Every rule has a stable ID and a default disposition. `enforced` findings partic | `platform.laravel.security-only` | advisory | Laravel in security-only or near-EOL support | | `supply-chain.composer.recent-package` | advisory | Package inside the configured release-age window | | `supply-chain.composer.recent-executable-package` | enforced | Recent package using Composer plugins or `autoload.files` | -| `supply-chain.composer.release-time-missing` | advisory | Package whose release age cannot be evaluated offline | \ No newline at end of file +| `supply-chain.composer.release-time-missing` | advisory | Package whose release age cannot be evaluated offline | diff --git a/readme.md b/readme.md index 73dda45..6860109 100644 --- a/readme.md +++ b/readme.md @@ -270,7 +270,3 @@ Please report security vulnerabilities privately using [GitHub Security Advisori ## License Warden is open-sourced software licensed under the [MIT license](LICENSE). - -## License - -Warden is released under the [MIT License](LICENSE). diff --git a/src/Commands/WardenBaselineCommand.php b/src/Commands/WardenBaselineCommand.php index 02bd788..6431791 100644 --- a/src/Commands/WardenBaselineCommand.php +++ b/src/Commands/WardenBaselineCommand.php @@ -79,7 +79,7 @@ public function handle(): int $expiry = null; } - if (!$expiry instanceof \Carbon\CarbonImmutable || $expiry->format('Y-m-d') !== $expires || $expiry->endOfDay()->isPast()) { + if (!$expiry instanceof CarbonImmutable || $expiry->format('Y-m-d') !== $expires || $expiry->endOfDay()->isPast()) { $this->error('--expires must be a future date in YYYY-MM-DD format.'); return 2; } diff --git a/src/Commands/WardenInitCommand.php b/src/Commands/WardenInitCommand.php index 3c1c9a4..ef33fae 100644 --- a/src/Commands/WardenInitCommand.php +++ b/src/Commands/WardenInitCommand.php @@ -74,14 +74,27 @@ private function publishConfiguration(): bool private function generateGithub(): bool { $target = base_path('.github/workflows/warden.yml'); + $contents = $this->stub('github-workflow.yml'); + if ($contents === null) { + $this->error('Unable to read bundled stub github-workflow.yml.'); - return $this->writeOwnedFile($target, $this->stub('github-workflow.yml'), '.github/workflows/warden.yml'); + return false; + } + + return $this->writeOwnedFile($target, $contents, '.github/workflows/warden.yml'); } private function generateGitlab(): bool { $target = base_path('.gitlab/warden.yml'); - if (!$this->writeOwnedFile($target, $this->stub('gitlab-ci.yml'), '.gitlab/warden.yml')) { + $contents = $this->stub('gitlab-ci.yml'); + if ($contents === null) { + $this->error('Unable to read bundled stub gitlab-ci.yml.'); + + return false; + } + + if (!$this->writeOwnedFile($target, $contents, '.gitlab/warden.yml')) { return false; } @@ -138,10 +151,13 @@ private function writeOwnedFile(string $target, string $contents, string $displa return true; } - private function stub(string $name): string + private function stub(string $name): ?string { - $contents = file_get_contents(__DIR__ . '/../stubs/' . $name); + $contents = @file_get_contents(__DIR__ . '/../stubs/' . $name); + if (!is_string($contents)) { + return null; + } - return str_replace('{{PHP_VERSION}}', PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, is_string($contents) ? $contents : ''); + return str_replace('{{PHP_VERSION}}', PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, $contents); } } diff --git a/src/Commands/WardenSyntaxCommand.php b/src/Commands/WardenSyntaxCommand.php index 16dfcfb..ba378a9 100644 --- a/src/Commands/WardenSyntaxCommand.php +++ b/src/Commands/WardenSyntaxCommand.php @@ -27,6 +27,10 @@ public function handle(): int } if ($auditResult->errors !== []) { + foreach ($auditResult->errors as $error) { + $this->error(sprintf('%s: %s', $error->code, $error->message)); + } + return 2; } diff --git a/src/Reporters/SarifReporter.php b/src/Reporters/SarifReporter.php index e4b2d4e..c2827fe 100644 --- a/src/Reporters/SarifReporter.php +++ b/src/Reporters/SarifReporter.php @@ -67,9 +67,11 @@ private function result(Finding $finding): array $result['locations'] = [[ 'physicalLocation' => [ 'artifactLocation' => ['uri' => $finding->path], - 'region' => ['startLine' => $finding->line ?? 1], ], ]]; + if ($finding->line !== null) { + $result['locations'][0]['physicalLocation']['region'] = ['startLine' => $finding->line]; + } } return $result; diff --git a/src/Services/AuditRunner.php b/src/Services/AuditRunner.php index ea53597..9102709 100644 --- a/src/Services/AuditRunner.php +++ b/src/Services/AuditRunner.php @@ -78,16 +78,28 @@ public function availableAuditIds(): array */ private function services(AuditContext $auditContext): array { - $services = [ - $this->container->make(SupplyChainAuditService::class), - $this->container->make(ComposerAuditService::class), - $this->container->make(NpmAuditService::class), - $this->container->make(LaravelConfigAuditService::class), - $this->container->make(PlatformAuditService::class), - $this->container->make(SourceAuditService::class), - $this->container->make(StorageAuditService::class), - ]; + $services = []; $errors = []; + $builtInClasses = [ + SupplyChainAuditService::class, + ComposerAuditService::class, + NpmAuditService::class, + LaravelConfigAuditService::class, + PlatformAuditService::class, + SourceAuditService::class, + StorageAuditService::class, + ]; + foreach ($builtInClasses as $class) { + try { + $services[] = $this->container->make($class); + } catch (Throwable $throwable) { + $errors[] = new AuditError( + 'configuration', + 'builtin_audit_initialization_failed', + sprintf('%s: %s', $class, $throwable->getMessage()), + ); + } + } $customAudits = config('warden.custom_audits', []); if (!is_array($customAudits)) { diff --git a/src/Services/Audits/LaravelConfigAuditService.php b/src/Services/Audits/LaravelConfigAuditService.php index 6a5e02b..031d41d 100644 --- a/src/Services/Audits/LaravelConfigAuditService.php +++ b/src/Services/Audits/LaravelConfigAuditService.php @@ -4,7 +4,6 @@ namespace Dgtlss\Warden\Services\Audits; -use Composer\InstalledVersions; use Dgtlss\Warden\Contracts\AuditServiceInterface; use Dgtlss\Warden\Enums\Severity; use Dgtlss\Warden\ValueObjects\AuditContext; @@ -145,9 +144,9 @@ private function sessionFindings(): array private function toolingFindings(): array { $findings = []; - if (class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled') === true) { + if ($this->classAvailable('Laravel\\Telescope\\Telescope') && config('telescope.enabled') === true) { $findings[] = new Finding( - id: 'laravel.debug-tool.enabled', + id: 'laravel.debug-tool.telescope-enabled', source: $this->getName(), title: 'Laravel Telescope is enabled in production', severity: Severity::High, @@ -160,13 +159,13 @@ private function toolingFindings(): array } $tools = [ - ['Barryvdh\\Debugbar\\LaravelDebugbar', 'debugbar.enabled', 'barryvdh/laravel-debugbar', 'Laravel Debugbar'], - ['Clockwork\\Clockwork', 'clockwork.enable', 'itsgoingd/clockwork', 'Clockwork'], + ['Barryvdh\\Debugbar\\LaravelDebugbar', 'debugbar.enabled', 'laravel.debug-tool.debugbar-enabled', 'barryvdh/laravel-debugbar', 'Laravel Debugbar'], + ['Clockwork\\Clockwork', 'clockwork.enable', 'laravel.debug-tool.clockwork-enabled', 'itsgoingd/clockwork', 'Clockwork'], ]; - foreach ($tools as [, $configKey, $package, $label]) { - if (InstalledVersions::isInstalled($package) && config()->get($configKey) === true) { + foreach ($tools as [$class, $configKey, $id, $package, $label]) { + if ($this->classAvailable($class) && config()->get($configKey) === true) { $findings[] = new Finding( - id: 'laravel.debug-tool.enabled', + id: $id, source: $this->getName(), title: sprintf('%s is enabled in production', $label), severity: Severity::High, @@ -182,6 +181,12 @@ private function toolingFindings(): array return $findings; } + /** @param class-string|string $class */ + private function classAvailable(string $class): bool + { + return class_exists($class); + } + /** @return list */ private function corsFindings(): array { diff --git a/src/Services/Audits/PhpSyntaxAuditService.php b/src/Services/Audits/PhpSyntaxAuditService.php index 7b3df5d..0fda05e 100644 --- a/src/Services/Audits/PhpSyntaxAuditService.php +++ b/src/Services/Audits/PhpSyntaxAuditService.php @@ -7,11 +7,13 @@ use Dgtlss\Warden\Contracts\AuditServiceInterface; use Dgtlss\Warden\Enums\Severity; use Dgtlss\Warden\ValueObjects\AuditContext; +use Dgtlss\Warden\ValueObjects\AuditError; use Dgtlss\Warden\ValueObjects\AuditResult; use Dgtlss\Warden\ValueObjects\Finding; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; +use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; class PhpSyntaxAuditService implements AuditServiceInterface @@ -23,15 +25,34 @@ public function getName(): string public function run(AuditContext $auditContext): AuditResult { + $startedAt = microtime(true); $findings = []; foreach ($this->phpFiles() as $path) { - $process = new Process([PHP_BINARY, '-l', $path], base_path(), null, null, $auditContext->timeout); - $process->run(); + $relativePath = $this->relativePath($path); + $remaining = $auditContext->timeout - (microtime(true) - $startedAt); + if ($remaining <= 0) { + return new AuditResult($this->getName(), $findings, [new AuditError( + $this->getName(), + 'timeout', + sprintf('PHP syntax analysis exceeded the configured timeout before linting %s.', $relativePath), + )]); + } + + $process = $this->createProcess($path, $remaining); + try { + $process->run(); + } catch (ProcessTimedOutException) { + return new AuditResult($this->getName(), $findings, [new AuditError( + $this->getName(), + 'timeout', + sprintf('PHP syntax analysis exceeded the configured timeout while linting %s.', $relativePath), + )]); + } + if ($process->isSuccessful()) { continue; } - $relativePath = ltrim(str_replace(base_path(), '', $path), DIRECTORY_SEPARATOR); $findings[] = new Finding( id: 'quality.php.syntax', source: $this->getName(), @@ -46,6 +67,11 @@ public function run(AuditContext $auditContext): AuditResult return AuditResult::complete($this->getName(), $findings); } + protected function createProcess(string $path, float $timeout): Process + { + return new Process([PHP_BINARY, '-l', $path], base_path(), null, null, $timeout); + } + /** @return list */ private function phpFiles(): array { @@ -81,12 +107,19 @@ private function phpFiles(): array /** @param list $excluded */ private function isExcluded(string $path, array $excluded): bool { + $normalisedPath = str_replace('\\', '/', $path); foreach ($excluded as $directory) { - if ($path === $directory || str_starts_with($path, rtrim($directory, '/') . DIRECTORY_SEPARATOR)) { + $normalisedDirectory = rtrim(str_replace('\\', '/', $directory), '/'); + if ($normalisedPath === $normalisedDirectory || str_starts_with($normalisedPath, $normalisedDirectory . '/')) { return true; } } return false; } + + private function relativePath(string $path): string + { + return ltrim(str_replace(base_path(), '', $path), DIRECTORY_SEPARATOR); + } } diff --git a/src/Services/Audits/PlatformAuditService.php b/src/Services/Audits/PlatformAuditService.php index 38525f6..7a7a507 100644 --- a/src/Services/Audits/PlatformAuditService.php +++ b/src/Services/Audits/PlatformAuditService.php @@ -111,7 +111,7 @@ private function supportFindings(string $platform, string $version, array $calen return []; } - private function composerPlatformPhp(): ?string + protected function composerPlatformPhp(): ?string { $contents = @file_get_contents(base_path('composer.json')); if (!is_string($contents)) { diff --git a/src/Services/Audits/SourceAuditService.php b/src/Services/Audits/SourceAuditService.php index 22c6978..b15bcac 100644 --- a/src/Services/Audits/SourceAuditService.php +++ b/src/Services/Audits/SourceAuditService.php @@ -45,6 +45,11 @@ public function run(AuditContext $auditContext): AuditResult } array_push($findings, ...$this->textSourceAnalyzer->blade($files['blade'])); + if ($this->timedOut($startedAt, $auditContext->timeout)) { + $errors[] = new AuditError($this->getName(), 'timeout', 'Source analysis exceeded the configured timeout.'); + + return new AuditResult($this->getName(), $findings, $errors); + } foreach ($files['php'] as $file) { if ($this->timedOut($startedAt, $auditContext->timeout)) { diff --git a/src/Services/NotificationDispatcher.php b/src/Services/NotificationDispatcher.php index fb714a5..f4ecb34 100644 --- a/src/Services/NotificationDispatcher.php +++ b/src/Services/NotificationDispatcher.php @@ -10,6 +10,7 @@ use Dgtlss\Warden\Notifications\Channels\SlackChannel; use Dgtlss\Warden\Notifications\Channels\TeamsChannel; use Dgtlss\Warden\ValueObjects\AuditReport; +use Dgtlss\Warden\ValueObjects\Finding; use Throwable; final class NotificationDispatcher @@ -25,7 +26,7 @@ public function send(AuditReport $auditReport): array try { $notificationChannel->send(array_map( - static fn ($finding): array => [ + static fn (Finding $finding): array => [ 'id' => $finding->id, 'fingerprint' => $finding->fingerprint(), 'source' => $finding->source, diff --git a/src/Services/RulePolicy.php b/src/Services/RulePolicy.php index cbc5c7a..55417bf 100644 --- a/src/Services/RulePolicy.php +++ b/src/Services/RulePolicy.php @@ -34,7 +34,9 @@ final class RulePolicy 'source.php.insecure-rng-context' => RuleDisposition::Advisory, 'laravel.cors.wildcard-credentials' => RuleDisposition::Enforced, 'laravel.cors.wildcard-origin' => RuleDisposition::Advisory, - 'laravel.debug-tool.enabled' => RuleDisposition::Enforced, + 'laravel.debug-tool.telescope-enabled' => RuleDisposition::Enforced, + 'laravel.debug-tool.debugbar-enabled' => RuleDisposition::Enforced, + 'laravel.debug-tool.clockwork-enabled' => RuleDisposition::Enforced, 'deployment.env.permissions' => RuleDisposition::Advisory, 'deployment.path.world-writable' => RuleDisposition::Advisory, 'platform.php.eol' => RuleDisposition::Enforced, diff --git a/src/Services/Source/PhpSourceAnalyzer.php b/src/Services/Source/PhpSourceAnalyzer.php index 54f9098..33d5cb7 100644 --- a/src/Services/Source/PhpSourceAnalyzer.php +++ b/src/Services/Source/PhpSourceAnalyzer.php @@ -12,24 +12,27 @@ use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; +use PhpParser\Parser; use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard; use Symfony\Component\Finder\SplFileInfo; final class PhpSourceAnalyzer { + private readonly Parser $parser; + public function __construct(private readonly RulePolicy $rulePolicy) { + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); } /** @return array{findings: list, errors: list} */ public function analyze(SplFileInfo $file): array { $path = ltrim(str_replace(base_path(), '', $file->getRealPath()), DIRECTORY_SEPARATOR); - $parser = (new ParserFactory())->createForNewestSupportedVersion(); try { - $nodes = $parser->parse($file->getContents()); + $nodes = $this->parser->parse($file->getContents()); } catch (Error $error) { return [ 'findings' => [], diff --git a/src/ValueObjects/AuditReport.php b/src/ValueObjects/AuditReport.php index 2bc1b27..7a9a606 100644 --- a/src/ValueObjects/AuditReport.php +++ b/src/ValueObjects/AuditReport.php @@ -90,9 +90,11 @@ public function withFilteredFindings(array $audits, array $ignoredFindings, arra /** @return array */ public function jsonSerialize(): array { + $findings = $this->findings(); + $errors = $this->errors(); $counts = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0]; $blocking = 0; - foreach ($this->findings() as $finding) { + foreach ($findings as $finding) { $counts[$finding->severity->value]++; $blocking += $finding->blocking ? 1 : 0; } @@ -101,23 +103,23 @@ public function jsonSerialize(): array 'schema_version' => '2.0.0', 'warden_version' => $this->wardenVersion(), 'run' => [ - 'status' => $this->errors() === [] ? 'completed' : 'failed', + 'status' => $errors === [] ? 'completed' : 'failed', 'profile' => $this->context->profile, 'scope' => $this->context->scope, 'scanned_at' => ($this->scannedAt ?? CarbonImmutable::now())->toISOString(), ], 'summary' => [ - 'total' => count($this->findings()), + 'total' => count($findings), 'blocking' => $blocking, - 'advisory' => count($this->findings()) - $blocking, + 'advisory' => count($findings) - $blocking, 'ignored' => count($this->ignoredFindings), - 'errors' => count($this->errors()), + 'errors' => count($errors), 'severity' => $counts, ], 'audits' => $this->audits, - 'findings' => $this->findings(), + 'findings' => $findings, 'ignored_findings' => $this->ignoredFindings, - 'errors' => $this->errors(), + 'errors' => $errors, ]; } @@ -127,8 +129,14 @@ private function wardenVersion(): string return InstalledVersions::getPrettyVersion('dgtlss/warden') ?? 'unknown'; } - $root = InstalledVersions::getRootPackage(); + return $this->prettyVersion(InstalledVersions::getRootPackage()); + } + + /** @param array $package */ + private function prettyVersion(array $package): string + { + $prettyVersion = $package['pretty_version'] ?? null; - return $root['pretty_version']; + return is_string($prettyVersion) && $prettyVersion !== '' ? $prettyVersion : 'unknown'; } } diff --git a/tests/Commands/WardenInitCommandTest.php b/tests/Commands/WardenInitCommandTest.php index b8530d9..0d5c679 100644 --- a/tests/Commands/WardenInitCommandTest.php +++ b/tests/Commands/WardenInitCommandTest.php @@ -4,10 +4,12 @@ namespace Dgtlss\Warden\Tests\Commands; +use Dgtlss\Warden\Commands\WardenInitCommand; use Dgtlss\Warden\Tests\TestCase; use Illuminate\Support\Facades\Artisan; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use ReflectionMethod; use SplFileInfo; final class WardenInitCommandTest extends TestCase @@ -66,4 +68,12 @@ public function testGitlabInitializationPreservesAnExistingRootPipeline(): void self::assertFileExists(base_path('.gitlab/warden.yml')); self::assertStringContainsString('dependency_scanning', (string) file_get_contents(base_path('.gitlab/warden.yml'))); } + + public function testMissingBundledStubDoesNotProduceEmptyContents(): void + { + $reflectionMethod = new ReflectionMethod(WardenInitCommand::class, 'stub'); + + self::assertNull($reflectionMethod->invoke($this->app->make(WardenInitCommand::class), 'missing.yml')); + self::assertFileDoesNotExist(base_path('.github/workflows/warden.yml')); + } } diff --git a/tests/Commands/WardenSyntaxCommandTest.php b/tests/Commands/WardenSyntaxCommandTest.php index 64bf8b3..8180646 100644 --- a/tests/Commands/WardenSyntaxCommandTest.php +++ b/tests/Commands/WardenSyntaxCommandTest.php @@ -7,6 +7,7 @@ use Dgtlss\Warden\Enums\Severity; use Dgtlss\Warden\Services\Audits\PhpSyntaxAuditService; use Dgtlss\Warden\Tests\TestCase; +use Dgtlss\Warden\ValueObjects\AuditError; use Dgtlss\Warden\ValueObjects\AuditResult; use Dgtlss\Warden\ValueObjects\Finding; use Mockery\MockInterface; @@ -35,4 +36,16 @@ public function testSyntaxFindingExitsOne(): void ->expectsOutputToContain('app/Broken.php: Parse error') ->assertExitCode(1); } + + public function testAuditErrorIsDisplayedAndExitsTwo(): void + { + $auditError = new AuditError('php-syntax', 'timeout', 'PHP syntax analysis timed out.'); + $this->mock(PhpSyntaxAuditService::class, function (MockInterface $mock) use ($auditError): void { + $mock->shouldReceive('run')->once()->andReturn(new AuditResult('php-syntax', errors: [$auditError])); + }); + + $this->artisan('warden:syntax') + ->expectsOutputToContain('timeout: PHP syntax analysis timed out.') + ->assertExitCode(2); + } } diff --git a/tests/Reporters/ReporterTest.php b/tests/Reporters/ReporterTest.php index 9038c00..795fa32 100644 --- a/tests/Reporters/ReporterTest.php +++ b/tests/Reporters/ReporterTest.php @@ -167,6 +167,24 @@ public function testAdvisoriesAreSarifNotesAndJunitSkippedCases(): void self::assertSame(1, $domDocument->getElementsByTagName('skipped')->length); } + public function testSarifOmitsRegionWhenFindingHasNoLine(): void + { + $sarif = json_decode((new SarifReporter())->format($this->report()), true, 512, JSON_THROW_ON_ERROR); + $physicalLocation = $sarif['runs'][0]['results'][0]['locations'][0]['physicalLocation']; + + self::assertSame('composer.lock', $physicalLocation['artifactLocation']['uri']); + self::assertArrayNotHasKey('region', $physicalLocation); + } + + public function testGitlabTimestampsMatchTheDeclaredSchemaVersion(): void + { + $gitlab = json_decode((new GitLabReporter())->format($this->report()), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame('15.2.4', $gitlab['version']); + self::assertSame('2026-01-01T00:00:00', $gitlab['scan']['start_time']); + self::assertSame('2026-01-01T00:00:00', $gitlab['scan']['end_time']); + } + private function report(): AuditReport { $finding = new Finding( diff --git a/tests/Services/AuditRunnerTest.php b/tests/Services/AuditRunnerTest.php new file mode 100644 index 0000000..870b465 --- /dev/null +++ b/tests/Services/AuditRunnerTest.php @@ -0,0 +1,25 @@ +app->bind(SupplyChainAuditService::class, static fn (): never => throw new RuntimeException('fixture construction failed')); + + $auditReport = $this->app->make(AuditRunner::class)->run(new AuditContext()); + + self::assertSame('builtin_audit_initialization_failed', $auditReport->errors()[0]->code); + self::assertStringContainsString(SupplyChainAuditService::class, $auditReport->errors()[0]->message); + self::assertSame(2, $auditReport->exitCode('never')); + } +} diff --git a/tests/Services/Audits/LaravelConfigAuditServiceTest.php b/tests/Services/Audits/LaravelConfigAuditServiceTest.php index daae229..bd456d6 100644 --- a/tests/Services/Audits/LaravelConfigAuditServiceTest.php +++ b/tests/Services/Audits/LaravelConfigAuditServiceTest.php @@ -10,6 +10,26 @@ final class LaravelConfigAuditServiceTest extends TestCase { + private string $originalBasePath; + + private string $temporaryBasePath; + + protected function setUp(): void + { + parent::setUp(); + $this->originalBasePath = $this->app->basePath(); + $this->temporaryBasePath = sys_get_temp_dir() . '/warden-config-' . bin2hex(random_bytes(8)); + mkdir($this->temporaryBasePath, 0777, true); + $this->app->setBasePath($this->temporaryBasePath); + } + + protected function tearDown(): void + { + $this->app->setBasePath($this->originalBasePath); + rmdir($this->temporaryBasePath); + parent::tearDown(); + } + public function testCiProfileDoesNotRequireAnEnvironmentFileOrProductionConfiguration(): void { config(['app.debug' => true, 'app.key' => null]); @@ -62,4 +82,44 @@ public function testInsecureProductionConfigurationProducesHighConfidenceRules() self::assertContains('laravel.session.http-only', $ids); self::assertContains('laravel.session.same-site', $ids); } + + public function testDebugToolsHaveDistinctStableRuleIds(): void + { + foreach ([ + 'Laravel\\Telescope\\Telescope', + 'Barryvdh\\Debugbar\\LaravelDebugbar', + 'Clockwork\\Clockwork', + ] as $class) { + if (!class_exists($class)) { + class_alias(DebugToolFixture::class, $class); + } + } + + config([ + 'app.debug' => false, + 'app.key' => 'base64:' . base64_encode(random_bytes(32)), + 'app.cipher' => 'AES-256-CBC', + 'app.url' => 'https://example.com', + 'session.secure' => true, + 'session.http_only' => true, + 'session.same_site' => 'lax', + 'cors.allowed_origins' => ['https://example.com'], + 'cors.supports_credentials' => false, + 'telescope.enabled' => true, + 'debugbar.enabled' => true, + 'clockwork.enable' => true, + ]); + + $auditResult = (new LaravelConfigAuditService())->run(new AuditContext(profile: 'production')); + + self::assertSame([ + 'laravel.debug-tool.telescope-enabled', + 'laravel.debug-tool.debugbar-enabled', + 'laravel.debug-tool.clockwork-enabled', + ], array_map(static fn ($finding): string => $finding->id, $auditResult->findings)); + } +} + +final class DebugToolFixture +{ } diff --git a/tests/Services/Audits/PhpSyntaxAuditServiceTest.php b/tests/Services/Audits/PhpSyntaxAuditServiceTest.php new file mode 100644 index 0000000..6592f7a --- /dev/null +++ b/tests/Services/Audits/PhpSyntaxAuditServiceTest.php @@ -0,0 +1,113 @@ +originalBasePath = $this->app->basePath(); + $this->temporaryBasePath = sys_get_temp_dir() . '/warden-syntax-' . bin2hex(random_bytes(8)); + mkdir($this->temporaryBasePath, 0777, true); + $this->app->setBasePath($this->temporaryBasePath); + } + + protected function tearDown(): void + { + $this->app->setBasePath($this->originalBasePath); + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->temporaryBasePath, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof SplFileInfo) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + } + + rmdir($this->temporaryBasePath); + parent::tearDown(); + } + + public function testTimeoutRetainsEarlierSyntaxFindingsAndReturnsAnAuditError(): void + { + $this->write('app/A-Broken.php', 'write('app/B-Slow.php', 'processes++; + if ($this->processes === 1) { + return parent::createProcess($path, $timeout); + } + + return new Process([PHP_BINARY, '-r', 'usleep(50000);'], base_path(), null, null, 0.001); + } + }; + + $auditResult = $service->run(new AuditContext(timeout: 1)); + + self::assertCount(1, $auditResult->findings); + self::assertSame('quality.php.syntax', $auditResult->findings[0]->id); + self::assertSame('timeout', $auditResult->errors[0]->code); + self::assertFalse($auditResult->succeeded()); + } + + public function testExclusionsAcceptEitherPathSeparator(): void + { + config(['warden.audits.php_syntax.exclude' => ['bootstrap\\cache']]); + $this->write('bootstrap/cache/Broken.php', 'run(new AuditContext()); + + self::assertSame([], $auditResult->findings); + self::assertSame([], $auditResult->errors); + } + + public function testEachProcessReceivesOnlyTheRemainingAuditBudget(): void + { + $this->write('app/A.php', 'write('app/B.php', ' */ + public array $timeouts = []; + + protected function createProcess(string $path, float $timeout): Process + { + $this->timeouts[] = $timeout; + + return new Process([PHP_BINARY, '-r', 'usleep(20000);'], base_path(), null, null, $timeout); + } + }; + + $auditResult = $service->run(new AuditContext(timeout: 1)); + + self::assertSame([], $auditResult->errors); + self::assertCount(2, $service->timeouts); + self::assertLessThan($service->timeouts[0], $service->timeouts[1]); + } + + private function write(string $relative, string $contents): void + { + $path = $this->temporaryBasePath . '/' . $relative; + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $contents); + } +} diff --git a/tests/Services/Audits/PlatformAuditServiceTest.php b/tests/Services/Audits/PlatformAuditServiceTest.php index 7354983..d59fd8c 100644 --- a/tests/Services/Audits/PlatformAuditServiceTest.php +++ b/tests/Services/Audits/PlatformAuditServiceTest.php @@ -17,6 +17,8 @@ public function testUnsupportedPlatformsBlockAndSupportedPlatformsPass(): void protected function phpVersion(): string { return '8.2.30'; } protected function laravelVersion(): string { return '11.0.0'; } + + protected function composerPlatformPhp(): ?string { return null; } }; $auditResult = $unsupported->run(new AuditContext(scannedAt: CarbonImmutable::parse('2027-01-01'))); @@ -27,6 +29,8 @@ protected function laravelVersion(): string { return '11.0.0'; } protected function phpVersion(): string { return '8.5.1'; } protected function laravelVersion(): string { return '13.0.0'; } + + protected function composerPlatformPhp(): ?string { return null; } }; $passed = $supported->run(new AuditContext(scannedAt: CarbonImmutable::parse('2026-07-10'))); @@ -39,10 +43,27 @@ public function testSecurityOnlyPlatformIsAdvisory(): void protected function phpVersion(): string { return '8.3.20'; } protected function laravelVersion(): ?string { return null; } + + protected function composerPlatformPhp(): ?string { return null; } }; $auditResult = $service->run(new AuditContext(scannedAt: CarbonImmutable::parse('2026-07-10'))); self::assertSame('platform.php.security-only', $auditResult->findings[0]->id); self::assertFalse($auditResult->findings[0]->blocking); } + + public function testComposerPlatformPhpIsAuditedIndependently(): void + { + $service = new class extends PlatformAuditService { + protected function phpVersion(): string { return '8.5.1'; } + + protected function laravelVersion(): ?string { return null; } + + protected function composerPlatformPhp(): string { return '8.2.30'; } + }; + + $auditResult = $service->run(new AuditContext(scannedAt: CarbonImmutable::parse('2026-07-10'))); + + self::assertSame(['platform.php.eol'], array_map(static fn ($finding): string => $finding->id, $auditResult->findings)); + } } diff --git a/tests/Services/Audits/SourceAuditServiceTest.php b/tests/Services/Audits/SourceAuditServiceTest.php index 425820f..652f116 100644 --- a/tests/Services/Audits/SourceAuditServiceTest.php +++ b/tests/Services/Audits/SourceAuditServiceTest.php @@ -6,6 +6,7 @@ use Dgtlss\Warden\Services\Audits\SourceAuditService; use Dgtlss\Warden\Services\RulePolicy; +use Dgtlss\Warden\Services\Source\PhpSourceAnalyzer; use Dgtlss\Warden\Reporters\ConsoleReporter; use Dgtlss\Warden\Reporters\GitHubReporter; use Dgtlss\Warden\Reporters\GitLabReporter; @@ -17,7 +18,9 @@ use Dgtlss\Warden\ValueObjects\AuditReport; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use ReflectionProperty; use SplFileInfo; +use Symfony\Component\Finder\SplFileInfo as FinderFileInfo; final class SourceAuditServiceTest extends TestCase { @@ -201,6 +204,32 @@ protected function timedOut(float $startedAt, int $timeout): bool { return true; self::assertSame('timeout', $timedOut->errors[0]->code); } + public function testTimeoutAfterBladeAnalysisSkipsPhpAnalysis(): void + { + $this->write('resources/views/output.blade.php', '{!! $body !!}'); + $this->write('app/Broken.php', 'app->make(\Dgtlss\Warden\Services\Source\SourceFileDiscovery::class), + $this->app->make(\Dgtlss\Warden\Services\Source\PhpSourceAnalyzer::class), + $this->app->make(\Dgtlss\Warden\Services\Source\TextSourceAnalyzer::class), + ) extends SourceAuditService { + private int $checks = 0; + + protected function timedOut(float $startedAt, int $timeout): bool + { + $this->checks++; + + return $this->checks >= 3; + } + }; + + $auditResult = $service->run(new AuditContext()); + + self::assertSame('timeout', $auditResult->errors[0]->code); + self::assertContains('source.blade.unescaped-output', array_map(static fn ($finding): string => $finding->id, $auditResult->findings)); + self::assertNotContains('parse_error', array_map(static fn ($error): string => $error->code, $auditResult->errors)); + } + public function testRuleOverridesCanPromoteDisableAndRejectRules(): void { config(['warden.rule_overrides' => [ @@ -231,6 +260,20 @@ public function testSourcePathsCannotEscapeTheApplicationRoot(): void self::assertSame('invalid_configuration', $auditResult->errors[0]->code); } + public function testPhpParserInstanceIsReusedAcrossFiles(): void + { + $this->write('app/First.php', 'write('app/Second.php', 'app->make(PhpSourceAnalyzer::class); + $reflectionProperty = new ReflectionProperty(PhpSourceAnalyzer::class, 'parser'); + $parser = $reflectionProperty->getValue($phpSourceAnalyzer); + + $phpSourceAnalyzer->analyze(new FinderFileInfo($this->temporaryBasePath . '/app/First.php', 'app', 'app/First.php')); + $phpSourceAnalyzer->analyze(new FinderFileInfo($this->temporaryBasePath . '/app/Second.php', 'app', 'app/Second.php')); + + self::assertSame($parser, $reflectionProperty->getValue($phpSourceAnalyzer)); + } + private function resultWithAdvisories(): \Dgtlss\Warden\ValueObjects\AuditResult { $this->write('app/Debug.php', ' 'https://example.com/slack', - 'warden.notifications.discord.webhook_url' => null, - 'warden.notifications.teams.webhook_url' => null, - 'warden.notifications.email.recipients' => null, - ]); - $finding = new Finding('test', 'test', 'Finding', Severity::High, 'Description'); - $auditReport = new AuditReport(new AuditContext(), [AuditResult::complete('test', [$finding])]); + $this->configureChannels(slack: 'https://example.com/slack'); + + self::assertSame([], (new NotificationDispatcher())->send($this->report())); + Http::assertSentCount(1); + } + + public function testChannelFailureReturnsANonGatingWarning(): void + { + Http::fake(['https://example.com/slack' => Http::response([], 500)]); + $this->configureChannels(slack: 'https://example.com/slack'); + + $warnings = (new NotificationDispatcher())->send($this->report()); + + self::assertCount(1, $warnings); + self::assertStringContainsString('Slack notification failed:', $warnings[0]); + } + + public function testMultipleConfiguredChannelsEachDispatchOnce(): void + { + Http::fake(); + $this->configureChannels( + slack: 'https://example.com/slack', + discord: 'https://example.com/discord', + teams: 'https://example.com/teams', + ); + + self::assertSame([], (new NotificationDispatcher())->send($this->report())); + Http::assertSentCount(3); + } + + public function testEmptyFindingReportStillDispatchesWhenRequested(): void + { + Http::fake(); + $this->configureChannels(slack: 'https://example.com/slack'); + $auditReport = new AuditReport(new AuditContext(), [AuditResult::complete('test')]); self::assertSame([], (new NotificationDispatcher())->send($auditReport)); Http::assertSentCount(1); } + + private function report(): AuditReport + { + $finding = new Finding('test', 'test', 'Finding', Severity::High, 'Description'); + + return new AuditReport(new AuditContext(), [AuditResult::complete('test', [$finding])]); + } + + private function configureChannels(?string $slack = null, ?string $discord = null, ?string $teams = null): void + { + config([ + 'warden.notifications.slack.webhook_url' => $slack, + 'warden.notifications.discord.webhook_url' => $discord, + 'warden.notifications.teams.webhook_url' => $teams, + 'warden.notifications.email.recipients' => null, + ]); + } } diff --git a/tests/Services/SuppressionServiceTest.php b/tests/Services/SuppressionServiceTest.php index dd3b6c5..3ac0433 100644 --- a/tests/Services/SuppressionServiceTest.php +++ b/tests/Services/SuppressionServiceTest.php @@ -74,6 +74,28 @@ public function testBaselineSuppressesOnlyTheExactFingerprint(): void } } + public function testDebugToolSuppressionDoesNotHideOtherTools(): void + { + config(['warden.ignore_findings' => [[ + 'id' => 'laravel.debug-tool.telescope-enabled', + 'reason' => 'Telescope access is restricted under SEC-123', + 'expires_at' => '2099-01-01', + ]]]); + $findings = [ + new Finding('laravel.debug-tool.telescope-enabled', 'laravel-config', 'Telescope', Severity::High, 'Description'), + new Finding('laravel.debug-tool.debugbar-enabled', 'laravel-config', 'Debugbar', Severity::High, 'Description'), + new Finding('laravel.debug-tool.clockwork-enabled', 'laravel-config', 'Clockwork', Severity::High, 'Description'), + ]; + + $auditReport = (new SuppressionService())->apply($this->report($findings), includeBaseline: false); + + self::assertSame([ + 'laravel.debug-tool.clockwork-enabled', + 'laravel.debug-tool.debugbar-enabled', + ], array_map(static fn ($finding): string => $finding->id, $auditReport->findings())); + self::assertSame('laravel.debug-tool.telescope-enabled', $auditReport->ignoredFindings[0]->id); + } + /** @param list $findings */ private function report(array $findings): AuditReport { diff --git a/tests/ValueObjects/AuditReportTest.php b/tests/ValueObjects/AuditReportTest.php index d0125e0..fe7cfc5 100644 --- a/tests/ValueObjects/AuditReportTest.php +++ b/tests/ValueObjects/AuditReportTest.php @@ -11,6 +11,7 @@ use Dgtlss\Warden\ValueObjects\AuditResult; use Dgtlss\Warden\ValueObjects\Finding; use PHPUnit\Framework\TestCase; +use ReflectionMethod; final class AuditReportTest extends TestCase { @@ -60,6 +61,15 @@ public function testExplicitIdentityKeepsFingerprintStableAcrossLineChanges(): v self::assertArrayNotHasKey('identity', $first->jsonSerialize()); } + public function testMissingComposerRootPrettyVersionFallsBackToUnknown(): void + { + $reflectionMethod = new ReflectionMethod(AuditReport::class, 'prettyVersion'); + $auditReport = new AuditReport(new AuditContext(), []); + + self::assertSame('unknown', $reflectionMethod->invoke($auditReport, [])); + self::assertSame('unknown', $reflectionMethod->invoke($auditReport, ['pretty_version' => null])); + } + private function finding(string $id, Severity $severity): Finding { return new Finding($id, 'test', 'Finding', $severity, 'Description', package: 'vendor/package', path: 'composer.lock');