From dbcf80a3a4f18dfa75cdd123baa1e1f28766f794 Mon Sep 17 00:00:00 2001 From: alexkrivonos Date: Mon, 14 Sep 2026 13:05:14 +0200 Subject: [PATCH 1/2] docs: update changelog and readme with recent security and config changes --- .env.example | 123 +--------------- CHANGELOG.md | 39 ++++- README.md | 46 +++++- src/Actions/ActionGuardAction.php | 9 +- src/Bridge/BusinessCoreGuardAdapter.php | 28 +--- src/Results/CheckResolution.php | 15 +- src/Support/ResolutionUrlSanitizer.php | 33 +++++ tests/Feature/ActionGuardActionTest.php | 70 +++++++++ .../ResolutionLinkXssSecurityTest.php | 52 +++++++ .../FilamentLivewireIntegrationTest.php | 134 ++++++++++++++++++ tests/TestCase.php | 34 ++++- tests/Unit/ActionGuardServiceProviderTest.php | 26 ++++ tests/Unit/Support/ActionGuardAuditTest.php | 17 +++ tests/Unit/Traits/HasActionGuardsTest.php | 97 +++++++++++++ 14 files changed, 566 insertions(+), 157 deletions(-) create mode 100644 src/Support/ResolutionUrlSanitizer.php create mode 100644 tests/Feature/Workbench/FilamentLivewireIntegrationTest.php create mode 100644 tests/Unit/ActionGuardServiceProviderTest.php diff --git a/.env.example b/.env.example index 27e98eb..a0c62c0 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,6 @@ -# ============================================================================== -# FILAMENT ACTIONGUARD - ENTERPRISE ENVIRONMENT CONFIGURATION -# ============================================================================== -# Local-development example only. Do not copy it to production unchanged. -# ============================================================================== +# Filament ActionGuard local Workbench configuration. +# Local development only; review every value before using it elsewhere. -# ------------------------------------------------------------------------------ -# 1. APPLICATION & GENERAL SECURITY -# ------------------------------------------------------------------------------ APP_NAME="Filament ActionGuard" APP_ENV=local APP_KEY= @@ -15,133 +9,30 @@ APP_URL=http://localhost:8000 APP_TIMEZONE=UTC APP_LOCALE=en APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US -APP_MAINTENANCE_DRIVER=cache -# ------------------------------------------------------------------------------ -# 2. LOGGING & AUDIT TRAIL -# ------------------------------------------------------------------------------ LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -LOG_DAILY_DAYS=14 -# ------------------------------------------------------------------------------ -# 3. DATABASE CONFIGURATION (Multi-Engine Resilience) -# ------------------------------------------------------------------------------ -# Default: SQLite for zero-latency local development & testbench DB_CONNECTION=sqlite DB_DATABASE="${APP_BASE_PATH:-.}/workbench/database.sqlite" DB_FOREIGN_KEYS=true -# Enterprise PostgreSQL Profile (Supports pgvector for Semantic Search) -# DB_CONNECTION=pgsql -# DB_HOST=127.0.0.1 -# DB_PORT=5432 -# DB_DATABASE=actionguard_enterprise -# DB_USERNAME=actionguard_app -# DB_PASSWORD=your_secure_password -# DB_SSLMODE=prefer - -# Enterprise MySQL/MariaDB Profile (Strict Mode Compliant) -# DB_CONNECTION=mysql -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=actionguard_enterprise -# DB_USERNAME=actionguard_app -# DB_PASSWORD=your_secure_password - -# ------------------------------------------------------------------------------ -# 4. SESSION & COOKIE SETTINGS -# ------------------------------------------------------------------------------ SESSION_DRIVER=file -SESSION_LIFETIME=120 -SESSION_ENCRYPT=true -# Laravel 13 default: json serialization prevents PHP deserialization gadget chain attacks -SESSION_SERIALIZATION=json -SESSION_PATH=/ -SESSION_DOMAIN=null -# Set true in every HTTPS deployment. -SESSION_SECURE_COOKIE=false -SESSION_HTTP_ONLY=true -SESSION_SAME_SITE=lax - -# ------------------------------------------------------------------------------ -# 5. CACHING & APPLICATION ACCELERATION (Laravel 13 Standards) -# ------------------------------------------------------------------------------ CACHE_STORE=file -# Laravel 13 uses hyphenated prefix standard: {app-name}-cache- -CACHE_PREFIX="actionguard-cache-" -# Laravel 13 security: restricts arbitrary cached object unserialization -CACHE_SERIALIZABLE_CLASSES=false - -# ------------------------------------------------------------------------------ -# 6. QUEUE & ASYNC TASK PROCESSING (Laravel 13 Queue Routing) -# ------------------------------------------------------------------------------ QUEUE_CONNECTION=sync -QUEUE_FAILED_DRIVER=database-uuids - -# ------------------------------------------------------------------------------ -# 7. REDIS CONFIGURATION (Clustering & TLS Support) -# ------------------------------------------------------------------------------ -REDIS_CLIENT=phpredis -REDIS_SCHEME=tcp -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 -REDIS_DB=0 -REDIS_CACHE_DB=1 -REDIS_PREFIX="actionguard-database-" -# ------------------------------------------------------------------------------ -# 8. FILAMENT 5 PANEL CONFIGURATION -# ------------------------------------------------------------------------------ -FILAMENT_FILESYSTEM_DISK=public -FILAMENT_SERVE_ASSETS_FROM_VITE=false -FILAMENT_BROADCASTING_ENABLED=false - -# ------------------------------------------------------------------------------ -# 9. MAIL & NOTIFICATION DELIVERY -# ------------------------------------------------------------------------------ -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=1025 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="no-reply@actionguard.example.com" -MAIL_FROM_NAME="${APP_NAME}" - -# ------------------------------------------------------------------------------ -# 10. OBJECT STORAGE (AWS S3 / S3-Compatible Storage) -# ------------------------------------------------------------------------------ -FILESYSTEM_DISK=local -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=eu-central-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -# ------------------------------------------------------------------------------ -# 11. ACTIONGUARD POLICY SETTINGS -# ------------------------------------------------------------------------------ -# Enable or disable ActionGuard evaluation globally. Keep true in production. +# Keep ActionGuard and fail-closed behavior enabled in production. ACTIONGUARD_ENABLED=true - -# Security posture: Fail-closed (true) blocks actions if a check throws an unhandled exception ACTIONGUARD_FAIL_CLOSED=true -# Disabled by default. Enable only for a controlled, logged maintenance task. +# Enable only for a controlled and documented maintenance callback. ACTIONGUARD_ALLOW_BYPASS=false -# Dispatch user-facing Filament danger notifications on state invariant violations ACTIONGUARD_NOTIFICATIONS_ENABLED=true -# Enable data-minimised structured audit events; route the channel to your SIEM. -ACTIONGUARD_AUDIT_TRAIL=true +# Audit output is opt-in and data-minimised. Configure a suitable channel when enabled. +ACTIONGUARD_AUDIT_TRAIL=false ACTIONGUARD_AUDIT_CHANNEL=stack -# Relative and HTTPS resolution links are allowed. HTTP is blocked by default. +# Relative paths and HTTPS are allowed. HTTP requires this explicit opt-in. ACTIONGUARD_ALLOW_INSECURE_RESOLUTION_URLS=false diff --git a/CHANGELOG.md b/CHANGELOG.md index e415322..495aaa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to `filament-actionguard` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Publishable runtime configuration for enablement, fail-closed behavior, + notifications, guarded bypasses, audit logging, and resolution URL policy. +- Data-minimised structured audit events for action evaluation, invariant + blocking, and explicitly enabled bypass usage. +- Locked and prefer-lowest dependency verification in CI. +- Privacy responsibility and AI-generated artwork disclosures. +- Central resolution URL sanitization shared by Community and Enterprise checks. +- Filament Livewire integration coverage for table actions, page header actions, + modal halting, model validation errors, and notification configuration. + +### Changed + +- Composer now resolves stable dependencies by default and locks against the + minimum supported PHP 8.3 platform. +- ActionGuard bypasses are disabled by default and scoped to their callback. +- Resolution links allow HTTPS and relative URLs by default; HTTP requires an + explicit compatibility opt-in. +- Security policy now documents supported versions and response expectations. +- The local environment example now contains only Workbench and ActionGuard + settings and keeps audit logging disabled by default. + +### Security + +- Check exceptions are reported server-side with correlation IDs instead of + exposing internal exception messages in the Filament interface. +- Explicit checks are validated against `ActionGuardCheckContract`, and rich + action labels are stripped of HTML before use as modal headings. +- Direct `CheckResolution` URLs now use the same scheme allow-list and malformed + URL rejection as Enterprise resolutions. + ## [1.0.0] - 2026-09-11 ### Added @@ -20,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RequiredFieldCheck`: Validates presence and non-null status of critical model attributes. - `NotEmptyCheck`: Validates strings, arrays, and collections with precise numeric zero (`0`, `'0'`, `0.00`) preservation. - `ConditionCheck`: Custom boolean and closure-based rule evaluations with contextual failure messages. - - `RelationshipCheck`: Ensures required relationships exist and meet minimum count constraints. + - `RelationshipCheck`: Ensures required relationships resolve to a model or non-empty collection. - `MediaCheck`: Verifies attachments via Spatie MediaLibrary collections or standard URL/path attributes. - `CallbackCheck`: Flexible closure-driven check returning custom `CheckResult` instances with explicit severity and descriptions. @@ -55,6 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Developer Experience & Tooling** - Root `artisan` CLI bridge to Orchestra Testbench and Workbench environment. - - Comprehensive test suite with 103 Pest tests and 234 assertions covering architecture, unit checks, traits, enterprise bridge hardening, and demo product scenarios. + - Comprehensive test suite with 132 Pest tests and 308 assertions covering architecture, unit checks, traits, enterprise bridge hardening, Livewire integration, security, and demo product scenarios. [1.0.0]: https://github.com/allgorithm/filament-actionguard/releases/tag/v1.0.0 +[Unreleased]: https://github.com/allgorithm/filament-actionguard/compare/v1.0.0...HEAD diff --git a/README.md b/README.md index affc0a2..8dc3384 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Latest Version on Packagist PHP 8.3 - 8.5 Filament v5 - Pest Tests + Pest Tests PHPStan Level 8 License MIT

@@ -62,7 +62,13 @@ ActionGuard introduces a robust **Two-Phase Invariant Defense System**: - `MediaCheck`: Checks media collections (Spatie MediaLibrary or file upload paths). - `CallbackCheck`: Flexible check with full `CheckResult` control. - ๐ŸŒ **Fully Localized (i18n):** Complete English and German translations included out-of-the-box. -- ๐Ÿ’Ž **Bulletproof Quality:** PHPStan **Level 8** (0 errors), 100% PSR-12 code style, and 103 comprehensive Pest tests. +- ๐Ÿ’Ž **Bulletproof Quality:** PHPStan **Level 8** (0 errors), 100% PSR-12 code style, and 132 comprehensive Pest tests. + +## Compatibility + +| ActionGuard | PHP | Laravel | Filament | +| :--- | :--- | :--- | :--- | +| `1.x` | `8.3โ€“8.5` | `11.28โ€“13.x` (through Filament) | `5.x` | --- @@ -207,15 +213,23 @@ ActionGuardAction::make('publish') | `RequiredFieldCheck` | Attribute is not `null` | `RequiredFieldCheck::make('title')` | | `NotEmptyCheck` | Value is not empty string/whitespace/empty array/empty collection (safely preserves `0`, `'0'`, `0.00`) | `NotEmptyCheck::make('price')` | | `ConditionCheck` | Boolean callback closure | `ConditionCheck::make('min_stock', fn ($record) => $record->stock > 0, 'Stock must be greater than zero')` | -| `RelationshipCheck` | Eloquent relationship exists and is loaded | `RelationshipCheck::make('category')` | +| `RelationshipCheck` | Eloquent relationship resolves to a model or non-empty collection; it may lazy-load the relation | `RelationshipCheck::make('category')` | | `MediaCheck` | Spatie MediaLibrary or array of image URLs | `MediaCheck::make('image_url')` | | `CallbackCheck` | Evaluates custom logic returning `CheckResult` | `CallbackCheck::make('vat_id', fn ($record) => ...)` | --- -## โš™๏ธ Fluent Customization & Zero-Config +## โš™๏ธ Secure defaults & optional configuration + +ActionGuard works with secure defaults without publishing a configuration file. +To review or override its operational safeguards, publish the package config: + +```bash +php artisan vendor:publish --tag="filament-actionguard-config" +``` -ActionGuard is designed as **Zero-Configuration by Default**. It requires no separate configuration file. You can customize the modal appearance, heading, and width directly on the action using Filament's fluent API: +You can customize the modal appearance, heading, and width directly on the +action using Filament's fluent API: ```php ActionGuardAction::make('publish') @@ -224,6 +238,26 @@ ActionGuardAction::make('publish') ->checks([...]); ``` +The operational defaults and environment variables are: + +| Setting | Environment variable | Default | +| :--- | :--- | :---: | +| `enabled` | `ACTIONGUARD_ENABLED` | `true` | +| `fail_closed` | `ACTIONGUARD_FAIL_CLOSED` | `true` | +| `allow_bypass` | `ACTIONGUARD_ALLOW_BYPASS` | `false` | +| `notifications` | `ACTIONGUARD_NOTIFICATIONS_ENABLED` | `true` | +| `audit.enabled` | `ACTIONGUARD_AUDIT_TRAIL` | `false` | +| `audit.channel` | `ACTIONGUARD_AUDIT_CHANNEL` | `null` | +| `allow_insecure_resolution_urls` | `ACTIONGUARD_ALLOW_INSECURE_RESOLUTION_URLS` | `false` | + +Resolution links accept local absolute paths and HTTPS URLs. HTTP is available +only through its explicit compatibility switch; executable, protocol-relative, +malformed, and control-character URLs are discarded. + +ActionGuard evaluates checks when the modal is rendered, when confirmation is +prepared, and immediately before execution. Checks must therefore be +side-effect-free and safe to run repeatedly. The final evaluation is decisive. + --- ## ๐Ÿงช Testing & Quality Assurance @@ -231,7 +265,7 @@ ActionGuardAction::make('publish') ActionGuard is built with strict quality standards: ```bash -# Run automated test suite (103 tests, 234 assertions) +# Run automated test suite (132 tests, 308 assertions) composer test # Run code style fixer and static analysis (PHPStan Level 8) diff --git a/src/Actions/ActionGuardAction.php b/src/Actions/ActionGuardAction.php index 73686dd..2a7d21e 100644 --- a/src/Actions/ActionGuardAction.php +++ b/src/Actions/ActionGuardAction.php @@ -31,9 +31,12 @@ protected function setUp(): void $this->modalDescription(null); $this->modalWidth('lg'); - $rawLabel = $this->getLabel(); - $label = $rawLabel instanceof Htmlable ? strip_tags($rawLabel->toHtml()) : (string) ($rawLabel ?? ''); - $this->modalHeading(__('filament-actionguard::ui.modal.heading', ['label' => $label])); + $this->modalHeading(function (): string { + $rawLabel = $this->getLabel(); + $label = $rawLabel instanceof Htmlable ? strip_tags($rawLabel->toHtml()) : (string) ($rawLabel ?? ''); + + return __('filament-actionguard::ui.modal.heading', ['label' => $label]); + }); $this->modalContent(function (ActionGuardAction $action, ?Model $record) { return $action->evaluateAndRender($record); }); diff --git a/src/Bridge/BusinessCoreGuardAdapter.php b/src/Bridge/BusinessCoreGuardAdapter.php index 98f12e6..a8ccbf6 100644 --- a/src/Bridge/BusinessCoreGuardAdapter.php +++ b/src/Bridge/BusinessCoreGuardAdapter.php @@ -9,6 +9,7 @@ use Allgorithm\FilamentActionGuard\Results\CheckResolution; use Allgorithm\FilamentActionGuard\Results\CheckResult; use Allgorithm\FilamentActionGuard\Results\CheckStatus; +use Allgorithm\FilamentActionGuard\Support\ResolutionUrlSanitizer; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; @@ -106,7 +107,7 @@ protected function mapCoreResult(mixed $coreResult): CheckResult $resolution = $coreResult->resolution; } elseif (is_object($coreResult->resolution) && property_exists($coreResult->resolution, 'label')) { $action = property_exists($coreResult->resolution, 'action') - ? $this->sanitizeResolutionUrl($coreResult->resolution->action) + ? ResolutionUrlSanitizer::sanitize($coreResult->resolution->action) : null; $resolution = new CheckResolution( label: (string) $coreResult->resolution->label, @@ -137,29 +138,4 @@ protected function makeOperationContext(Model $record, string $correlationId): o $correlationId, ); } - - protected function sanitizeResolutionUrl(mixed $url): ?string - { - if (! is_string($url)) { - return null; - } - - $url = trim($url); - if ($url === '' || preg_match('/[\x00-\x20\x7F\\\\]/', $url) === 1 || str_starts_with($url, '//')) { - return null; - } - - if (str_starts_with($url, '/')) { - return $url; - } - - $scheme = parse_url($url, PHP_URL_SCHEME); - - $allowedSchemes = ['https']; - if (config('filament-actionguard.allow_insecure_resolution_urls', false)) { - $allowedSchemes[] = 'http'; - } - - return in_array(strtolower((string) $scheme), $allowedSchemes, true) ? $url : null; - } } diff --git a/src/Results/CheckResolution.php b/src/Results/CheckResolution.php index ec81cf1..b9d7cda 100644 --- a/src/Results/CheckResolution.php +++ b/src/Results/CheckResolution.php @@ -4,10 +4,19 @@ namespace Allgorithm\FilamentActionGuard\Results; +use Allgorithm\FilamentActionGuard\Support\ResolutionUrlSanitizer; + final readonly class CheckResolution { + public string $label; + + public ?string $url; + public function __construct( - public string $label, - public ?string $url = null, - ) {} + string $label, + ?string $url = null, + ) { + $this->label = $label; + $this->url = ResolutionUrlSanitizer::sanitize($url); + } } diff --git a/src/Support/ResolutionUrlSanitizer.php b/src/Support/ResolutionUrlSanitizer.php new file mode 100644 index 0000000..019d5e5 --- /dev/null +++ b/src/Support/ResolutionUrlSanitizer.php @@ -0,0 +1,33 @@ +and($result->checks[0]->message)->toContain('Reference:'); }); +it('allows an action to continue after a technical error only with explicit fail-open configuration', function () { + config()->set('filament-actionguard.fail_closed', false); + $executed = false; + $record = new class extends Model {}; + $action = ActionGuardAction::make('publish') + ->checks([ + new class implements ActionGuardCheckContract + { + public function evaluate(Model $record): CheckResult + { + throw new RuntimeException('technical outage'); + } + }, + ]) + ->action(function () use (&$executed): void { + $executed = true; + }) + ->record($record); + + $action->callBefore(); + $action->call(['record' => $record]); + + expect($executed)->toBeTrue(); +}); + it('renders preflight modal view with correct result payload', function () { $action = ActionGuardAction::make('publish') ->checks([ @@ -181,3 +207,47 @@ public function evaluate(Model $record): CheckResult expect($data['passed'])->toBeTrue() ->and($data['result']->summary['passed'])->toBe(1); }); + +it('rejects invalid explicit checks before evaluation', function (mixed $invalidCheck) { + expect(fn () => ActionGuardAction::make('publish')->checks([$invalidCheck])) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'object' => fn () => new stdClass, + 'existing incompatible class string' => stdClass::class, + 'unknown class string' => 'Tests\\MissingActionGuardCheck', + 'scalar' => 42, +]); + +it('strips HTML from a late-bound Htmlable action label in the modal heading', function () { + $action = ActionGuardAction::make('publish') + ->label(new HtmlString('Publish safely')); + + expect($action->getModalHeading()) + ->toBe('Publish safelyalert(1)') + ->not->toContain('') + ->not->toContain('