diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index ea3cd94..d5eed9a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,43 +2,74 @@ name: Tests
on:
push:
- branches: [ main, master ]
+ branches: [main, master]
pull_request:
- branches: [ main, master ]
+ branches: [main, master]
+
+permissions:
+ contents: read
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
+ with:
+ persist-credentials: false
- - 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
+ with:
+ persist-credentials: false
+ - 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..243e24e
--- /dev/null
+++ b/UPGRADE.md
@@ -0,0 +1,60 @@
+
Upgrading to Warden 2.0
+
+A migration guide for Warden 1.x applications.
+
+
+
+
+
+
+
+
+
+## Introduction
+
+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` |
+
+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
+
+- 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.
+- 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:
+
+```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..95fe20c 100644
--- a/composer.json
+++ b/composer.json
@@ -1,36 +1,47 @@
{
"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",
+ "sast",
+ "secret-scanning",
+ "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"
+ "nikic/php-parser": "^5.8",
+ "symfony/finder": "^7.2|^8.0",
+ "symfony/process": "^7.2|^8.0"
},
"extra": {
"laravel": {
@@ -43,9 +54,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..1fd57ab 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": "b10cb46301c4f3f1ef9a3e60610588e3",
"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,66 @@
],
"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-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"
+ }
},
- "time": "2026-02-13T03:05:33+00:00"
+ "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",
@@ -3015,20 +3079,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 +3151,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 +3210,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 +3230,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 +3310,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 +3330,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 +3379,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 +3399,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 +3425,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -3384,7 +3450,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 +3461,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 +3531,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 +3551,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 +3617,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 +3637,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 +3664,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -3627,7 +3697,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 +3708,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 +3765,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 +3785,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 +3846,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 +3866,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 +3955,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 +3975,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 +4035,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 +4055,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 +4121,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 +4141,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 +4204,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 +4224,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 +4286,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 +4306,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 +4373,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 +4393,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 +4458,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 +4478,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 +4543,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 +4563,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 +4627,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 +4647,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 +4678,7 @@
"bootstrap.php"
],
"psr-4": {
- "Symfony\\Polyfill\\Php83\\": ""
+ "Symfony\\Polyfill\\Php84\\": ""
},
"classmap": [
"Resources/stubs"
@@ -4642,7 +4698,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 +4707,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 +4727,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 +4758,7 @@
"bootstrap.php"
],
"psr-4": {
- "Symfony\\Polyfill\\Php84\\": ""
+ "Symfony\\Polyfill\\Php85\\": ""
},
"classmap": [
"Resources/stubs"
@@ -4722,7 +4778,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 +4787,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 +4807,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 +4838,7 @@
"bootstrap.php"
],
"psr-4": {
- "Symfony\\Polyfill\\Php85\\": ""
+ "Symfony\\Polyfill\\Php86\\": ""
},
"classmap": [
"Resources/stubs"
@@ -4802,7 +4858,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 +4867,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 +4887,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 +4950,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 +4970,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 +5015,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 +5035,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 +5095,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 +5115,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 +5146,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -5131,7 +5182,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 +5202,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 +5272,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 +5292,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 +5365,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 +5385,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 +5411,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -5396,7 +5447,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 +5467,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 +5525,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 +5545,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 +5612,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 +5632,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 +5691,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 +5759,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 +5771,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 +5821,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 +5845,7 @@
"type": "tidelift"
}
],
- "time": "2024-11-21T01:49:47+00:00"
+ "time": "2026-04-26T05:33:54+00:00"
}
],
"packages-dev": [
@@ -6062,16 +6113,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 +6148,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 +6232,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 +6240,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 +6320,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 +6358,9 @@
"providers": [
"Laravel\\Tinker\\TinkerServiceProvider"
]
+ },
+ "branch-alias": {
+ "dev-master": "3.x-dev"
}
},
"autoload": {
@@ -6333,9 +6387,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",
@@ -6480,83 +6534,25 @@
],
"time": "2025-08-01T08:46:24+00:00"
},
- {
- "name": "nikic/php-parser",
- "version": "v5.7.0",
- "source": {
- "type": "git",
- "url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
- "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
- "shasum": ""
- },
- "require": {
- "ext-ctype": "*",
- "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.7.0"
- },
- "time": "2025-12-06T11:56:16+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/docs/rules.md b/docs/rules.md
new file mode 100644
index 0000000..1dbf83b
--- /dev/null
+++ b/docs/rules.md
@@ -0,0 +1,46 @@
+# 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.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 |
+| `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 |
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..6860109 100644
--- a/readme.md
+++ b/readme.md
@@ -1,620 +1,272 @@
-# Warden
+Warden
-[](https://packagist.org/packages/dgtlss/warden)
-[](https://packagist.org/packages/dgtlss/warden)
-[](https://packagist.org/packages/dgtlss/warden)
-[](https://packagist.org/packages/dgtlss/warden)
-
+A deterministic security gate for Laravel CI and deployment pipelines.
-**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.
+
+
+
+
+
+
+
-## 🚀 Key Features
+## Introduction
-### ✅ 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
+Warden audits locked production dependencies, supply-chain configuration, and high-confidence Laravel production settings without becoming part of the deployed application.
-### ✅ 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
+## Requirements
-### ✅ 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
+- PHP 8.3–8.5
+- Laravel 12 or 13
+- Composer 2
+- npm only when a `package-lock.json` is present
-Perfect for continuous security monitoring and DevOps pipelines.
+## Installation
----
+Install Warden as a development dependency:
-## 📋 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)
+```bash
+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.
-## 🚀 Installation
+## Usage
-To install Warden, use Composer:
+Run Warden **before** pruning development dependencies from the production artifact:
```bash
-composer require dgtlss/warden
+composer install --no-interaction
+php artisan warden:audit --profile=production --scope=production
+composer install --no-dev --no-interaction --optimize-autoloader
```
-Publish configuration:
+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
```
-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.
-
----
+`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.
-## ⚡ Quick Start
+### CI usage
-Dive into Warden's powerful security auditing capabilities with these simple commands:
+The default command uses the CI profile, audits production dependencies, reports every finding, and fails on low severity or higher:
-### Basic Security Audit
-Run a comprehensive security scan of your Laravel application:
```bash
php artisan warden:audit
```
-### With NPM Dependencies
-Include JavaScript vulnerabilities in your audit:
-```bash
-php artisan warden:audit --npm
-```
+Common examples:
-### JSON Output for CI/CD
-Generate machine-readable reports for automated pipelines:
```bash
-php artisan warden:audit --output=json --severity=high
-```
+# Gate only on high and critical findings while still reporting everything
+php artisan warden:audit --fail-on=high
-### No Notifications
-Run audits without sending notifications (useful for CI or local checks):
-```bash
-php artisan warden:audit --no-notify
-```
-> **Note:** `--silent` still works for backward compatibility.
+# Validate effective production configuration
+php artisan warden:audit --profile=production
----
+# Audit production and development dependencies
+php artisan warden:audit --scope=all
-## 📌 Command Reference
+# Select or skip audits
+php artisan warden:audit --only=supply-chain,composer,laravel-config,platform,source
+php artisan warden:audit --skip=npm,storage
-Quick reference for all commands and options.
+# 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
+```
-| 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 |
+Machine formats never mix progress or diagnostic prose into stdout. Scanner failures are included in the report and exit with code `2`.
----
+### Exit codes
-## ⚙️ Configuration
+| 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 |
-### Environment Variables
+### Profiles
-Add these to your `.env` file:
+| 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 |
-#### 🔔 Notifications
-```env
-# Slack (recommended - rich formatting)
-WARDEN_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
+CI environment variables do not disable production checks. Use `--profile=production` when the pipeline has loaded the intended deployment configuration.
-# Discord
-WARDEN_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR/WEBHOOK
+### Built-in audits
-# Microsoft Teams
-WARDEN_TEAMS_WEBHOOK_URL=https://outlook.office.com/webhook/YOUR/WEBHOOK
+- `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
+- `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
-# Email
-WARDEN_EMAIL_RECIPIENTS=security@company.com,admin@company.com
-WARDEN_EMAIL_FROM=security@company.com
-WARDEN_EMAIL_FROM_NAME="Security Team"
+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.
-# Legacy webhook (backward compatibility)
-WARDEN_WEBHOOK_URL=https://your-webhook-url.com
-```
+### Source security model
-#### ⚡ Performance
-```env
-WARDEN_CACHE_ENABLED=true
-WARDEN_CACHE_DURATION=3600 # Cache for 1 hour
-WARDEN_PARALLEL_EXECUTION=true # Enable parallel audits
-```
+Warden parses each selected PHP file once and distinguishes enforcement from review guidance:
-#### 🔬 PHP Syntax Audit
-```env
-WARDEN_PHP_SYNTAX_AUDIT_ENABLED=false # Enable via warden:syntax or config
-```
+- 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.
-#### ⏰ Scheduling
-```env
-WARDEN_SCHEDULE_ENABLED=false
-WARDEN_SCHEDULE_FREQUENCY=daily # hourly|daily|weekly|monthly
-WARDEN_SCHEDULE_TIME=03:00
-WARDEN_SCHEDULE_TIMEZONE=UTC
-```
+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`.
-### Ignoring Accepted Findings
+### Rule policy
-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`.
+Every configurable rule has a stable ID and a built-in disposition. Override one without suppressing individual occurrences:
```php
-'ignore_findings' => [
- ['source' => 'debug-mode', 'package' => 'laravel/horizon'],
- ['source' => 'debug-mode', 'title' => 'Testing routes*'],
+'rule_overrides' => [
+ 'source.blade.unescaped-output' => 'enforced',
+ 'source.php.debug-call' => 'off',
],
```
-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
+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.
-### 4. **Storage & Permissions**
-- Audits Laravel storage directories (`storage/`, `bootstrap/cache/`)
-- Ensures proper write permissions
-- Identifies missing or misconfigured paths
+See the complete [rule catalogue](docs/rules.md) for stable IDs, default dispositions, and rationale.
-### 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
+### Supply-chain review window
-### 6. **PHP Syntax Analysis**
-- Code syntax validation across your application
-- Configurable directory exclusions
-- Integration with existing audit workflow
+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
-## 💡 Usage Examples
+Suppressions are exact, documented, and expiring. Wildcards are not supported.
-### 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
+```php
+'ignore_findings' => [
+ [
+ 'id' => 'composer.advisory.ghsa-example',
+ 'fingerprint' => 'optional-fingerprint-for-one-occurrence',
+ 'reason' => 'Compensating control reviewed in SEC-123',
+ 'expires_at' => '2099-12-31',
+ ],
+],
```
-### Output Formats
-
-```bash
-# JSON for processing
-php artisan warden:audit --output=json > security-report.json
+An expired or malformed suppression is a configuration error and exits `2`.
-# GitHub Actions annotations
-php artisan warden:audit --output=github
+### Baselines
-# GitLab CI dependency scanning
-php artisan warden:audit --output=gitlab > gl-dependency-scanning-report.json
-
-# Jenkins format
-php artisan warden:audit --output=jenkins
-```
-
-### 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, 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
-```env
-WARDEN_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
-```
+Advisory findings render as notices in GitHub and skipped tests in JUnit. SARIF and JSON preserve the `blocking` property.
-### ✅ Discord
-- Rich embeds with color coding
-- Grouped findings by source
-- Custom branding
+`--output-file=-` writes to stdout. Relative file paths are resolved from the Laravel application root.
-```env
-WARDEN_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR/WEBHOOK
-```
+### Notifications
-### ✅ 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 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 getDescription(): string
+ public function run(AuditContext $context): AuditResult
{
- return 'Checks for weak database passwords';
- }
-
- public function shouldRun(): bool
- {
- 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
-
-Add to `config/warden.php`:
-
-```php
-'custom_audits' => [
- \App\Audits\DatabasePasswordAudit::class,
- \App\Audits\ApiKeySecurityAudit::class,
- // Add more custom audits
-],
-```
+Register the class in `config/warden.php` under `custom_audits`.
----
+## Testing
-## ⏰ 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:
-
-```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
-```
-
-### 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 install
+composer test
+composer phpstan
+composer rector
+composer validate --strict
```
-**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)
-
----
+## Changelog
-## 💝 Support Development
+Please see [the releases page](https://github.com/dgtlss/warden/releases) for a list of changes.
-If you find Warden useful for your organization's security needs, please consider [supporting its development](https://github.com/sponsors/dgtlss).
+## 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
-**Made with ❤️ for the Laravel community**
+Please report security vulnerabilities privately using [GitHub Security Advisories](https://github.com/dgtlss/warden/security/advisories/new).
-[⭐ Star on GitHub](https://github.com/dgtlss/warden) | [📦 Packagist](https://packagist.org/packages/dgtlss/warden) | [🐦 Follow Updates](https://twitter.com/nlangerdev)
+## License
-
+Warden is open-sourced software licensed 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..08f29a4
--- /dev/null
+++ b/resources/schemas/warden-report-2.0.0.json
@@ -0,0 +1,138 @@
+{
+ "$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", "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": {
+ "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..0bdf758 100644
--- a/src/Commands/WardenAuditCommand.php
+++ b/src/Commands/WardenAuditCommand.php
@@ -1,917 +1,227 @@
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);
+ }
+
+ $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 {
+ if ($status !== 'running') {
+ $symbol = $status === 'done' ? '✓>' : '✗>';
+ $this->line(sprintf(
+ '%s %-20s> %9s>',
+ $symbol,
+ OutputFormatter::escape($audit),
+ number_format($duration ?? 0, 1) . 'ms',
+ ));
}
-
- continue;
}
+ : null;
- if (!$isMachineOutput) {
- $this->info(sprintf('Running %s audit...', $auditName));
- }
+ $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 === 'console' && $outputFile === '-' && $this->output->isDecorated(),
+ )->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..6431791
--- /dev/null
+++ b/src/Commands/WardenBaselineCommand.php
@@ -0,0 +1,162 @@
+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 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,
+ scannedAt: CarbonImmutable::now(),
+ );
+ $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/WardenInitCommand.php b/src/Commands/WardenInitCommand.php
new file mode 100644
index 0000000..ef33fae
--- /dev/null
+++ b/src/Commands/WardenInitCommand.php
@@ -0,0 +1,163 @@
+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');
+ $contents = $this->stub('github-workflow.yml');
+ if ($contents === null) {
+ $this->error('Unable to read bundled stub github-workflow.yml.');
+
+ return false;
+ }
+
+ return $this->writeOwnedFile($target, $contents, '.github/workflows/warden.yml');
+ }
+
+ private function generateGitlab(): bool
+ {
+ $target = base_path('.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;
+ }
+
+ $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);
+ if (!is_string($contents)) {
+ return null;
+ }
+
+ return str_replace('{{PHP_VERSION}}', PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, $contents);
+ }
+}
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..ba378a9 100644
--- a/src/Commands/WardenSyntaxCommand.php
+++ b/src/Commands/WardenSyntaxCommand.php
@@ -1,69 +1,45 @@
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 ($auditResult->errors !== []) {
+ foreach ($auditResult->errors as $error) {
+ $this->error(sprintf('%s: %s', $error->code, $error->message));
+ }
- if (collect($findings)->contains('severity', 'error')) {
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..e249710 100644
--- a/src/Providers/WardenServiceProvider.php
+++ b/src/Providers/WardenServiceProvider.php
@@ -1,86 +1,37 @@
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,
+ WardenInitCommand::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..6b73a11
--- /dev/null
+++ b/src/Reporters/ConsoleReporter.php
@@ -0,0 +1,375 @@
+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(
+ '%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> %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->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;
+ }
+
+ 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);
+ }
+
+ 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(' ', $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/Reporters/GitHubReporter.php b/src/Reporters/GitHubReporter.php
new file mode 100644
index 0000000..a1a946b
--- /dev/null
+++ b/src/Reporters/GitHubReporter.php
@@ -0,0 +1,51 @@
+errors() as $auditError) {
+ $lines[] = sprintf('::error title=%s::%s', $this->key($auditError->code), $this->message($auditError->message));
+ }
+
+ foreach ($auditReport->findings() as $finding) {
+ $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);
+ }
+
+ 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()),
+ count(array_filter($auditReport->findings(), static fn ($finding): bool => !$finding->blocking)),
+ 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..c2827fe
--- /dev/null
+++ b/src/Reporters/SarifReporter.php
@@ -0,0 +1,79 @@
+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),
+ 'blocking' => $finding->blocking,
+ ],
+ ];
+ }
+
+ $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' => $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) {
+ $result['locations'] = [[
+ 'physicalLocation' => [
+ 'artifactLocation' => ['uri' => $finding->path],
+ ],
+ ]];
+ if ($finding->line !== null) {
+ $result['locations'][0]['physicalLocation']['region'] = ['startLine' => $finding->line];
+ }
+ }
+
+ 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..9102709
--- /dev/null
+++ b/src/Services/AuditRunner.php
@@ -0,0 +1,151 @@
+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: $auditContext->scanTime());
+ }
+
+ $results = $this->rulePolicy->apply($this->auditExecutor->execute($auditContext, $services, $onProgress));
+
+ return new AuditReport($auditContext, $results, configurationErrors: $errors, scannedAt: $auditContext->scanTime());
+ }
+
+ /** @return list */
+ public function availableAuditIds(): array
+ {
+ $ids = ['supply-chain', 'composer', 'npm', 'laravel-config', 'platform', 'source', '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 = [];
+ $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)) {
+ $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..031d41d
--- /dev/null
+++ b/src/Services/Audits/LaravelConfigAuditService.php
@@ -0,0 +1,239 @@
+trackedEnvironmentFindings();
+ if ($auditContext->profile !== 'production') {
+ return AuditResult::complete($this->getName(), $findings);
+ }
+
+ return AuditResult::complete($this->getName(), [
+ ...$findings,
+ ...$this->applicationFindings(),
+ ...$this->sessionFindings(),
+ ...$this->corsFindings(),
+ ...$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
+ {
+ $findings = [];
+ if ($this->classAvailable('Laravel\\Telescope\\Telescope') && config('telescope.enabled') === true) {
+ $findings[] = new Finding(
+ id: 'laravel.debug-tool.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',
+ identity: 'laravel/telescope',
+ );
+ }
+
+ $tools = [
+ ['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 [$class, $configKey, $id, $package, $label]) {
+ if ($this->classAvailable($class) && config()->get($configKey) === true) {
+ $findings[] = new Finding(
+ id: $id,
+ 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 $findings;
+ }
+
+ /** @param class-string|string $class */
+ private function classAvailable(string $class): bool
+ {
+ return class_exists($class);
+ }
+
+ /** @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
+ {
+ 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..0fda05e 100644
--- a/src/Services/Audits/PhpSyntaxAuditService.php
+++ b/src/Services/Audits/PhpSyntaxAuditService.php
@@ -1,96 +1,125 @@
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(),
- ]);
+ $startedAt = microtime(true);
+ $findings = [];
+ foreach ($this->phpFiles() as $path) {
+ $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;
+ }
+
+ $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 createProcess(string $path, float $timeout): Process
+ {
+ return new Process([PHP_BINARY, '-l', $path], base_path(), null, null, $timeout);
}
- 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),
+ );
+
+ /** @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;
+ }
- $pathsToPrune = collect($excludedDirs)
- ->map(fn ($dir) => sprintf("-path './%s' -prune", $dir))
- ->implode(' -o ');
+ $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,
- ];
+ $normalisedPath = str_replace('\\', '/', $path);
+ foreach ($excluded as $directory) {
+ $normalisedDirectory = rtrim(str_replace('\\', '/', $directory), '/');
+ if ($normalisedPath === $normalisedDirectory || str_starts_with($normalisedPath, $normalisedDirectory . '/')) {
+ return true;
}
}
- return $errors;
+ return false;
+ }
+
+ private function relativePath(string $path): string
+ {
+ return ltrim(str_replace(base_path(), '', $path), DIRECTORY_SEPARATOR);
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Audits/PlatformAuditService.php b/src/Services/Audits/PlatformAuditService.php
new file mode 100644
index 0000000..7a7a507
--- /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 [];
+ }
+
+ protected 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..b15bcac
--- /dev/null
+++ b/src/Services/Audits/SourceAuditService.php
@@ -0,0 +1,73 @@
+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']));
+ 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)) {
+ $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 fcf5e93..2100b2b 100644
--- a/src/Services/Audits/StorageAuditService.php
+++ b/src/Services/Audits/StorageAuditService.php
@@ -1,50 +1,81 @@
- */
- 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 = [];
+ $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);
-
- if (!file_exists($path)) {
- $this->addFinding([
- 'package' => 'storage',
- 'title' => 'Missing directory: ' . $directory,
- 'severity' => 'high',
- 'cve' => null,
- 'affected_versions' => null
- ]);
- continue;
+ $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_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..03af7e7
--- /dev/null
+++ b/src/Services/Audits/SupplyChainAuditService.php
@@ -0,0 +1,330 @@
+ 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.');
+ }
+
+ [$composerFindings, $composerErrors] = $this->composerValidation();
+ $findings = [
+ ...$composerFindings,
+ ...$this->composerConfigurationFindings($composer),
+ ...$this->composerReleaseFindings($auditContext),
+ ...$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 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
+ {
+ $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..f4ecb34
--- /dev/null
+++ b/src/Services/NotificationDispatcher.php
@@ -0,0 +1,56 @@
+ 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 $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..f7b98d8
--- /dev/null
+++ b/src/Services/ReportFormatterFactory.php
@@ -0,0 +1,30 @@
+ new ConsoleReporter($decorated),
+ '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/RulePolicy.php b/src/Services/RulePolicy.php
new file mode 100644
index 0000000..55417bf
--- /dev/null
+++ b/src/Services/RulePolicy.php
@@ -0,0 +1,123 @@
+ */
+ 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.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,
+ '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..33d5cb7
--- /dev/null
+++ b/src/Services/Source/PhpSourceAnalyzer.php
@@ -0,0 +1,538 @@
+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);
+
+ try {
+ $nodes = $this->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('/