dgtlss / warden
A deterministic Laravel security gate for CI and deployment pipelines
Requires
- php: >=8.3 <8.6
- guzzlehttp/guzzle: ^7.0
- 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
- nikic/php-parser: ^5.8
- symfony/finder: ^7.2|^8.0
- symfony/process: ^7.2|^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- opis/json-schema: ^2.4
- orchestra/testbench: ^10.9|^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5|^12.0
- rector/rector: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v2.2.0
- v2.1.0
- v2.0.x-dev
- v2.0.0
- 1.5.3
- 1.5.1
- 1.5.0
- 1.4.1
- 1.4.0
- 1.3.5
- 1.3.2
- 1.3.1
- 1.3.0
- 1.2.8
- 1.2.7
- 1.2.6
- 1.2.5
- 1.2.4
- 1.2.3
- 1.2.2
- 1.2.1
- 1.1.0
- 1.0.1
- 1.0.0
- dev-fix/respect-notification-preferences
- dev-fix/report-and-scan-integrity
- dev-release/v2.1.0
- dev-feat/finding-only-notifications
- dev-fix/custom-laravel-paths
- dev-fix/source-secret-context
- dev-v/2.0-dev
- dev-26-false-positive-laravelhorizon-classified-as-a-development-package
- dev-nl/ux-pre-flight
- dev-cursor/development-environment-setup-8bf3
- dev-cursor/readme-documentation-structure-ebf8
- dev-cursor/warden-code-refinements-b113
- dev-cursor/phpstan-analysis-issues-6efe
- dev-cursor/phpstan-analysis-issues-f0b8
- dev-v2/core-improvements
- dev-Code-Quality-Improvements-Testing
- dev-10-requestproposition-add-config-for-name-of-the-application
This package is auto-updated.
Last update: 2026-10-13 18:58:48 UTC
README
A deterministic security gate for Laravel CI and deployment pipelines.
Introduction
Warden audits locked production dependencies, supply-chain configuration, and high-confidence Laravel production settings without becoming part of the deployed application.
Requirements
- PHP 8.3–8.5
- Laravel 12 or 13
- Composer 2
- npm only when a
package-lock.jsonis present
Installation
Install Warden as a development dependency:
composer require --dev dgtlss/warden
Laravel discovers Warden automatically. If package discovery is disabled, register Dgtlss\Warden\Providers\WardenServiceProvider::class in your application's providers array.
Usage
Run Warden before pruning development dependencies from the production artifact:
composer install --no-interaction php artisan warden:audit --profile=production --scope=production composer install --no-dev --no-interaction --optimize-autoloader
Initialize Warden safely and optionally generate a dedicated CI file:
php artisan warden:init --ci=github
# or: --ci=gitlab|both|none
warden:init never overwrites an existing config/warden.php or root GitLab pipeline. --force may replace only Warden-owned generated CI files. Publishing with vendor:publish --tag=warden-config remains available for manual setups.
CI usage
The default command uses the CI profile, audits production dependencies, reports every finding, and fails on low severity or higher:
php artisan warden:audit
Common examples:
# Gate only on high and critical findings while still reporting everything php artisan warden:audit --fail-on=high # Validate effective production configuration php artisan warden:audit --profile=production # Audit production and development dependencies php artisan warden:audit --scope=all # Select or skip audits php artisan warden:audit --only=supply-chain,composer,laravel-config,platform,source php artisan warden:audit --skip=npm,storage # 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
Machine formats never mix progress or diagnostic prose into stdout. Scanner failures are included in the report and exit with code 2.
Exit codes
| 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 |
Profiles
| 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 |
CI environment variables do not disable production checks. Use --profile=production when the pipeline has loaded the intended deployment configuration.
Built-in audits
supply-chain: lockfile presence/synchronization, secure Composer repositories, plugin allow-listing, and JavaScript lockfile supportcomposer: Composer advisories, malware, and abandoned production packages fromcomposer.locknpm: npm advisories frompackage-lock.jsonornpm-shrinkwrap.json, auto-detected without a flag; shrinkwrap takes precedence when both exist and report locations identify the selected lockfilelaravel-config: tracked.envdetection and production application/session/tooling rulesplatform: offline PHP and Laravel support-window enforcement, including Composer's exact platform targetsource: parser-backed PHP taint analysis, Blade review, and redacted credential detectionstorage: production-only operational warnings; these do not fail the security gate
Deployment checks use Laravel's configured storage, bootstrap, and environment file paths, including custom environment filenames. Findings show paths relative to the application root when possible and absolute paths for external locations. The Git tracking check inspects the configured environment file only when it is inside the application repository.
Yarn, pnpm, and Bun lockfiles are detected but are not yet parsed. Warden reports the limitation so the package-manager-native audit can be added as a separate CI step.
Source security model
Warden parses each selected PHP file once and distinguishes enforcement from review guidance:
- Blocking rules require a high-confidence condition such as request-controlled data reaching a command, raw output, outbound URL, redirect, deserializer, or filesystem sink. Other blocking rules cover interpolated raw SQL, disabled TLS verification, provider-format credentials, weak constant ciphers, and explicit CSRF middleware removal.
- Advisory rules highlight unescaped Blade output, forms without an obvious CSRF directive, mass-assignment disabling, debug calls, sensitive logging, weak contextual hashing/randomness, and secret-like literals.
Credentials are never copied into reports. Warden emits only the provider, location, and a redacted description; the secret contributes only a one-way hash to the stable fingerprint.
Default PHP scan paths are app, bootstrap, config, and routes; Blade templates are read from resources/views. File paths, exclusions, and the 1 MiB file limit are configurable under warden.audits.source. A selected file that cannot be read or parsed makes the scan incomplete and exits 2. Credential discovery includes dotfiles matching the supported extensions, .env.example, and .github files. Configured exclusions and VCS-directory exclusions still apply; .env contents are never scanned. Every provider credential and distinct secret-like literal on a line is examined.
Unreadable directories also make the scan incomplete. Console and explicitly selected json-v2.1 audit records expose coverage.scanned, coverage.skipped, and coverage.errors: scanned counts unique files for which all selected analysis passes ran (including parse attempts); skipped counts known eligible files rejected by size/readability or left unfinished at timeout. Excluded/unsupported files are outside this count. An unreadable directory can hide an unknown number of files, so incomplete-run counts are lower bounds, never a claim of full coverage.
Sanitization is sink-specific: HTML escaping only clears HTML-output taint; escapeshellarg only clears shell-argument taint. escapeshellcmd, basename, and realpath do not establish validation boundaries. Imported class aliases, typed Laravel Request parameters, and named arguments are recognized for existing rules. Name resolution preserves fingerprints for unchanged source, so existing fingerprint suppressions and baselines keep matching. Relative source paths such as ./app and app/. select the same files as app. Analysis remains local and heuristic, not interprocedural or a proof that application input is safe.
Rule policy
Every configurable rule has a stable ID and a built-in disposition. Override one without suppressing individual occurrences:
'rule_overrides' => [ 'source.blade.unescaped-output' => 'enforced', 'source.php.debug-call' => 'off', ],
Allowed values are enforced, advisory, and off. Unknown rule IDs and invalid values are configuration errors. Advisory findings remain visible in every report but do not affect exit 1; suppressions and baselines still apply to them.
See the complete rule catalogue for stable IDs, default dispositions, and rationale.
Supply-chain review window
Composer packages released within three days produce an advisory. A recent package becomes a blocking critical finding when it is a Composer plugin or registers autoload.files, because it can execute automatically. The window is offline, uses composer.lock timestamps, respects --scope, and is configurable with warden.audits.supply_chain.minimum_release_age_days.
Reviewed suppressions
Suppressions are exact, documented, and expiring. Wildcards are not supported.
'ignore_findings' => [ [ 'id' => 'composer.advisory.ghsa-example', // Optional: paste the exact 64-character lowercase fingerprint from a report. // Omit the key entirely to suppress every occurrence of this rule. 'reason' => 'Compensating control reviewed in SEC-123', 'expires_at' => '2099-12-31', ], ],
An expired or malformed suppression is a configuration error and exits 2 before scanning. A supplied fingerprint must be a 64-character lowercase SHA-256 hash; null, false, arrays, numbers, empty strings, and malformed hashes are rejected rather than becoming rule-wide suppressions. Expiry must be an actual calendar date in YYYY-MM-DD format; it remains valid through the end of that day. Baselines always require a fingerprint.
Baselines
Legacy applications can commit an explicit fingerprint baseline while continuing to fail on new findings:
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.
Reports
Warden supports:
console: readable terminal reportjson: the unchanged 2.1-release JSON contract, with audits, blocking/advisory counts, findings, ignored findings, errors, and summary; validates againstresources/schemas/warden-report-2.0.0.jsonjson-v2.1: explicitly selected extended JSON with gate and per-audit coverage fields; validates againstresources/schemas/warden-report-2.1.0.jsongithub: GitHub Actions workflow annotationsgitlab: GitLab dependency scanning report schema 15.2.4sarif: SARIF 2.1.0 for GitHub code scanning and compatible platformsjunit: portable JUnit XML for Jenkins and other CI systems
Advisory findings render as notices in GitHub and skipped tests in JUnit. Blocking findings below --fail-on (including all findings with never) are also non-gating in these outputs. JUnit errors still represent incomplete scans. json-v2.1 adds gate.fail_on, gate.exit_code, and audits[].coverage (null when unavailable); SARIF adds runs[].properties.wardenGate while preserving finding severity and disposition. Default json, new JsonReporter(), and direct json_encode($report) / json_encode($auditResult) preserve schema 2.0.0 without gate or coverage fields. Use new JsonReporter(extended: true) for schema 2.1.0. Schema versions are independent of the package version. Programmatic reporters default to low; call $report->withGate('high') to choose another threshold.
--output-file=- writes to stdout. Relative file paths are resolved from the Laravel application root. Equivalent destinations (including dot segments, symlinks, and existing hard links) are rejected with exit 2 before scanning or writing any report; the error goes to stderr. Repeat --report=format:path to emit additional files from one scan, with the same timestamp, suppressions, and gate:
php artisan warden:audit --fail-on=high --format=github \ --report=json-v2.1:warden.json --report=sarif:warden.sarif --report=junit:warden.xml
Generated GitHub and GitLab configurations explicitly select json-v2.1 for warden.json. The GitHub summary reports an unavailable gate for legacy JSON instead of guessing an exit code. Generated GitHub workflows retain reports for 14 days, emit annotations, and write a concise job summary even after gate failure. SARIF upload is opt-in: after confirming code-scanning availability and policy, set repository variable WARDEN_CODE_SCANNING=true and add security-events: write to the generated job's permissions. Upload failure is non-gating; annotations and artifacts remain available. Fork PRs skip upload. Warden initialization does not change repository permissions, settings, or secrets.
Generated GitLab includes use the built-in .pre stage without replacing an existing stages list. Newly created root pipelines override Warden to the regular test stage so standalone pipelines run. Existing root pipelines remain untouched and must include .gitlab/warden.yml; a pipeline containing only .pre/.post jobs also needs a regular-stage job. The Composer Alpine image installs Node/npm only when an npm lockfile exists. JUnit, SARIF, JSON and the GitLab report are retained even on gate failure.
Notifications
Notifications are opt-in and never affect the audit exit code:
php artisan warden:audit --notify
To skip messages for completed scans when no active findings remain, use --notify-on-finding instead of --notify (or combine both flags):
php artisan warden:audit --fail-on=never --notify-on-finding --only=composer
To apply this filter to every --notify run, set warden.notifications.only_on_findings to true or WARDEN_NOTIFY_ONLY_ON_FINDINGS=true. The setting defaults to false and does not enable notifications by itself. The flag and configured filter combine with OR: --notify cannot override a finding-only preference, and suppressed findings do not count as active findings.
warden.notifications.on_errors controls incomplete reports. Missing or null (the default) respects existing finding-only preferences. Only the boolean true explicitly opts into additional error-only alerts; boolean false silences all incomplete reports, including those containing active findings. Existing published 2.1 configs need no new key and do not gain additional alerts.
Delivery requires --notify or --notify-on-finding and a configured channel in every case:
| Scan result | Finding-only filter | on_errors missing / null |
on_errors=true |
on_errors=false |
|---|---|---|---|---|
| Completed, active findings | Either | Send | Send | Send |
| Completed, no active findings | Off | Send | Send | Send |
| Completed, no active findings | On | Skip | Skip | Skip |
| Incomplete, active findings | Either | Send | Send | Skip |
| Incomplete, no active findings | Off | Send | Send | Skip |
| Incomplete, no active findings | On | Skip | Send (explicit opt-in) | Skip |
Reports containing only ignored findings use the no-active-findings rows. To opt into error-only alerts, deliberately add 'on_errors' => true to the notifications array of your published config. This does not enable notification delivery on its own.
Every delivered incomplete report explicitly describes the failure and never claims all-clear. Completed emails retain the existing warden::mail.enhanced-report view, including application overrides, recipients, and sender settings. Incomplete emails use explicit plain text because legacy findings-only templates cannot safely represent scan errors; suppress these with on_errors=false if that format is unsuitable. Built-in dispatch uses ReportNotificationChannel::sendReport; the legacy NotificationChannel::send(array) API remains available. Teams report payloads are limited to 28,000 encoded JSON bytes; long reports retain status and counts and mark truncation. Delivery filtering does not change reports or exit codes, including exit 2 for incomplete audits.
Configure any combination of:
WARDEN_SLACK_WEBHOOK_URL= WARDEN_DISCORD_WEBHOOK_URL= WARDEN_TEAMS_WEBHOOK_URL= WARDEN_EMAIL_RECIPIENTS=security@example.com WARDEN_EMAIL_FROM=warden@example.com
Each channel is dispatched once. Delivery failures are written to stderr after the report is produced.
Custom audits
Custom audits receive the immutable audit context and return a typed result:
use Dgtlss\Warden\Contracts\CustomAudit; use Dgtlss\Warden\Enums\Severity; use Dgtlss\Warden\ValueObjects\AuditContext; use Dgtlss\Warden\ValueObjects\AuditResult; use Dgtlss\Warden\ValueObjects\Finding; final class PublicBucketAudit implements CustomAudit { 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 run(AuditContext $context): AuditResult { $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); } }
Register the class in config/warden.php under custom_audits.
Testing
composer install
composer test
composer phpstan
composer rector
composer validate --strict
Changelog
Please see the releases page for a list of changes.
Contributing
Please see contributing.md for contribution guidelines. Upgrading from Warden 1.x? Read UPGRADE.md before changing the dependency constraint.
Security Vulnerabilities
Please report security vulnerabilities privately using GitHub Security Advisories.
License
Warden is open-sourced software licensed under the MIT license.