diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 8b9e560..9ed2f6d 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -44,7 +44,7 @@ jobs: - uses: ramsey/composer-install@v3 with: working-directory: backend - - run: vendor/bin/phpstan analyse --no-progress + - run: vendor/bin/phpstan analyse --no-progress --memory-limit=512M pest: name: Pest diff --git a/backend/.env.example b/backend/.env.example index 4dd7dbb..b7cf5a5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -89,3 +89,19 @@ AWS_USE_PATH_STYLE_ENDPOINT=false # Nome exposto ao front-end via Vite (build de assets do backend, se houver). VITE_APP_NAME="${APP_NAME}" + +# Origens com permissão de CORS (separadas por vírgula). Nunca "*" fora de local. +CORS_ALLOWED_ORIGINS=http://localhost:3000 + +# Limites de rate limit por rota sensível — tentativas por janela de decaimento em minutos. +RATE_LIMIT_LOGIN_ATTEMPTS=5 +RATE_LIMIT_LOGIN_DECAY_MINUTES=1 +RATE_LIMIT_PASSWORD_RESET_ATTEMPTS=5 +RATE_LIMIT_PASSWORD_RESET_DECAY_MINUTES=1 +RATE_LIMIT_CHECKOUT_ATTEMPTS=10 +RATE_LIMIT_CHECKOUT_DECAY_MINUTES=1 +RATE_LIMIT_WEBHOOKS_ATTEMPTS=60 +RATE_LIMIT_WEBHOOKS_DECAY_MINUTES=1 + +# DSN do Sentry. Vazio = integração desligada (padrão). +SENTRY_LARAVEL_DSN= diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 0029276..60342a4 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -27,6 +27,10 @@ Ver `docs/produto.md` na raiz do monorepo para a responsabilidade de cada um. - Toda mutação de Model com regra de negócio por trás passa por uma `Action`, nunca `->save()`/`->update()` direto de um Controller. - Toda entidade de edição carrega `edition_id`; usuários, perfis de palestrante e fornecedores são globais. - Sem Filament — o admin é o Nuxt em `../front-admin`, consumindo esta API. +- Validação sempre por `FormRequest` ou `Data` object — nunca `$request->all()` em um controller. `tests/Arch/NoRawRequestAllTest.php` garante isso. +- Rota sensível (login, recuperação de senha, checkout, webhook) usa `throttle:` com um dos limiters nomeados em `App\Providers\SecurityServiceProvider` (`login`, `password-reset`, `checkout`, `webhooks`), configurável via `.env` (`RATE_LIMIT_*`). +- `Log::info/error/...` nunca precisa esconder segredo na mão — `App\Logging\RedactSensitiveDataProcessor` redige `password`/`token`/`api_key`/`secret`/`authorization`/cartão automaticamente em todo log (JSON estruturado, ver `config/logging.php`). +- Segredo nunca vai pro repositório — ver `docs/adr/0006-politica-de-segredos.md`. ## Comandos diff --git a/backend/app/Http/Controllers/HealthController.php b/backend/app/Http/Controllers/HealthController.php new file mode 100644 index 0000000..1b8b937 --- /dev/null +++ b/backend/app/Http/Controllers/HealthController.php @@ -0,0 +1,45 @@ + $this->probe(function (): bool { + DB::connection()->getPdo(); + + return true; + }), + 'redis' => $this->probe(fn (): bool => Redis::connection()->ping() !== false), + ]; + + $healthy = !in_array(false, $checks, true); + + return response()->json([ + 'status' => $healthy ? 'ok' : 'degraded', + 'checks' => $checks, + ], $healthy ? 200 : 503); + } + + /** + * @param Closure(): bool $probe + */ + private function probe(Closure $probe): bool + { + try { + return $probe(); + } catch (Throwable) { + return false; + } + } +} diff --git a/backend/app/Http/Middleware/SecurityHeaders.php b/backend/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..27656bb --- /dev/null +++ b/backend/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,45 @@ +headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()'); + $response->headers->set('Content-Security-Policy', $this->contentSecurityPolicy($request)); + + if ($request->secure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } + + private function contentSecurityPolicy(Request $request): string + { + // A UI do Scramble (/docs/*) carrega Stoplight Elements do unpkg e + // usa script/style inline — precisa de uma CSP mais frouxa que o + // resto da aplicação, que não serve HTML nenhum. + if ($request->is('docs/*')) { + return "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; " + ."style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; " + ."connect-src 'self'; frame-ancestors 'none'"; + } + + return "default-src 'self'; frame-ancestors 'none'; base-uri 'self'"; + } +} diff --git a/backend/app/Logging/JsonFormatterTap.php b/backend/app/Logging/JsonFormatterTap.php new file mode 100644 index 0000000..e71a94f --- /dev/null +++ b/backend/app/Logging/JsonFormatterTap.php @@ -0,0 +1,33 @@ +getLogger(); + + if (!$monolog instanceof MonologLogger) { + return; + } + + foreach ($monolog->getHandlers() as $handler) { + if ($handler instanceof FormattableHandlerInterface) { + $handler->setFormatter(new JsonFormatter); + } + + if ($handler instanceof ProcessableHandlerInterface) { + $handler->pushProcessor(new RedactSensitiveDataProcessor); + } + } + } +} diff --git a/backend/app/Logging/RedactSensitiveDataProcessor.php b/backend/app/Logging/RedactSensitiveDataProcessor.php new file mode 100644 index 0000000..93134bb --- /dev/null +++ b/backend/app/Logging/RedactSensitiveDataProcessor.php @@ -0,0 +1,41 @@ +with(context: $this->redact($record->context)); + } + + /** + * @param array $data + * @return array + */ + private function redact(array $data): array + { + $redacted = []; + + foreach ($data as $key => $value) { + $redacted[$key] = match (true) { + is_string($key) && in_array(mb_strtolower($key), self::SENSITIVE_KEYS, true) => '[REDACTED]', + is_array($value) => $this->redact($value), + default => $value, + }; + } + + return $redacted; + } +} diff --git a/backend/app/Providers/SecurityServiceProvider.php b/backend/app/Providers/SecurityServiceProvider.php new file mode 100644 index 0000000..6381c78 --- /dev/null +++ b/backend/app/Providers/SecurityServiceProvider.php @@ -0,0 +1,43 @@ +registerRateLimiters(); + } + + private function registerRateLimiters(): void + { + $this->registerLimiter('login', fn (Request $request): string => $request->ip().'|'.$request->input('email')); + $this->registerLimiter('password-reset', fn (Request $request): string => $request->ip().'|'.$request->input('email')); + $this->registerLimiter('checkout', fn (Request $request): string => (string) $request->ip()); + $this->registerLimiter('webhooks', fn (Request $request): string => (string) $request->ip()); + } + + /** + * @param callable(Request): string $signatureFor + */ + private function registerLimiter(string $name, callable $signatureFor): void + { + $key = str_replace('-', '_', $name); + $config = config("security.rate_limits.{$key}"); + + RateLimiter::for( + $name, + fn (Request $request) => Limit::perMinutes($config['decay_minutes'], $config['attempts']) + ->by($signatureFor($request)), + ); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index b8baa5d..8840cfd 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -4,6 +4,7 @@ use App\Http\Middleware\AssignRequestId; use App\Http\Middleware\EnsureIdempotencyKey; +use App\Http\Middleware\SecurityHeaders; use App\Support\Api\ApiError; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -15,13 +16,14 @@ ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', - health: '/up', then: function (): void { require __DIR__.'/../routes/api.php'; + require __DIR__.'/../routes/health.php'; }, ) ->withMiddleware(function (Middleware $middleware): void { $middleware->append(AssignRequestId::class); + $middleware->append(SecurityHeaders::class); $middleware->alias([ 'idempotent' => EnsureIdempotencyKey::class, diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php index 6141e3a..0e19b0f 100644 --- a/backend/bootstrap/providers.php +++ b/backend/bootstrap/providers.php @@ -4,8 +4,10 @@ use App\Providers\AppServiceProvider; use App\Providers\HorizonServiceProvider; +use App\Providers\SecurityServiceProvider; return [ AppServiceProvider::class, HorizonServiceProvider::class, + SecurityServiceProvider::class, ]; diff --git a/backend/composer.json b/backend/composer.json index 89d4fc7..be57ca2 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -32,7 +32,8 @@ "phpeste/suppliers": "^1.0.0", "phpeste/tasks": "^1.0.0", "phpeste/ticketing": "^1.0.0", - "predis/predis": "^3.0" + "predis/predis": "^3.0", + "sentry/sentry-laravel": "^4.29" }, "require-dev": { "driftingly/rector-laravel": "^2.6", @@ -81,7 +82,7 @@ "./vendor/bin/pint" ], "phpstan": [ - "./vendor/bin/phpstan analyse" + "./vendor/bin/phpstan analyse --memory-limit=512M" ], "rector": [ "./vendor/bin/rector process --dry-run" diff --git a/backend/composer.lock b/backend/composer.lock index d7d4bd5..d4b27e9 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c91562751a7f44f182b78246e62dfc29", + "content-hash": "01289237eeedf27d35be345cf6e6c8d0", "packages": [ { "name": "brick/math", @@ -1876,6 +1876,66 @@ }, "time": "2026-03-23T14:50:10+00:00" }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, { "name": "justinrainbow/json-schema", "version": "6.14.0", @@ -3765,6 +3825,84 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "phpeste/audit", "version": "1.0.0", @@ -5336,6 +5474,195 @@ }, "time": "2023-09-03T09:24:00+00:00" }, + { + "name": "sentry/sentry", + "version": "4.35.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "8247f5411fd98d98183366f001278ce3591eda20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/8247f5411fd98d98183366f001278ce3591eda20", + "reference": "8247f5411fd98d98183366f001278ce3591eda20", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "carthage-software/mago": "1.30.0", + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/guzzle": "^7.0|^8.0", + "guzzlehttp/promises": "^2.0.3|^3.0.0", + "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.1", + "open-telemetry/context": "^1.1", + "open-telemetry/exporter-otlp": "^1.1", + "open-telemetry/gen-otlp-protobuf": "^1.2.1", + "open-telemetry/sdk": "^1.1", + "open-telemetry/sem-conv": "^1.27", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6" + }, + "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.35.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-10-29T16:02:44+00:00" + }, + { + "name": "sentry/sentry-laravel", + "version": "4.29.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-laravel.git", + "reference": "4b3deab3d8818490220e1da8e2d7cca971f74410" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b3deab3d8818490220e1da8e2d7cca971f74410", + "reference": "4b3deab3d8818490220e1da8e2d7cca971f74410", + "shasum": "" + }, + "require": { + "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", + "nyholm/psr7": "^1.0", + "php": "^7.2 | ^8.0", + "sentry/sentry": "^4.34.0", + "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.11", + "guzzlehttp/guzzle": "^7.2", + "laravel/ai": "^0.1.5 | ^1.0", + "laravel/folio": "^1.1", + "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", + "laravel/octane": "^2.15", + "laravel/pennant": "^1.0", + "livewire/livewire": "^2.0 | ^3.0 | ^4.0", + "mockery/mockery": "^1.3", + "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0", + "phpstan/phpstan": "^1.10 | ^2.2", + "phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Sentry": "Sentry\\Laravel\\Facade" + }, + "providers": [ + "Sentry\\Laravel\\ServiceProvider", + "Sentry\\Laravel\\Tracing\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-0": { + "Sentry\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "Laravel SDK for Sentry (https://sentry.io)", + "homepage": "https://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "laravel", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-laravel/issues", + "source": "https://github.com/getsentry/sentry-laravel/tree/4.29.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-10-08T12:31:48+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.3", @@ -6457,6 +6784,77 @@ ], "time": "2026-10-28T17:58:38+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v8.1.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "68cc43038d234c69d510c62f37bd0058b40d548a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/68cc43038d234c69d510c62f37bd0058b40d548a", + "reference": "68cc43038d234c69d510c62f37bd0058b40d548a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.1.10" + }, + "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-10-18T14:56:39+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.37.0", @@ -7754,6 +8152,93 @@ ], "time": "2026-10-25T14:40:25+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.1.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "cfa8f3ac693fec234a91d8745b45ed9a81a0b799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/cfa8f3ac693fec234a91d8745b45ed9a81a0b799", + "reference": "cfa8f3ac693fec234a91d8745b45ed9a81a0b799", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.9" + }, + "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-10-07T08:21:34+00:00" + }, { "name": "symfony/routing", "version": "v8.1.11", @@ -9068,66 +9553,6 @@ }, "time": "2026-01-28T22:20:33+00:00" }, - { - "name": "jean85/pretty-package-versions", - "version": "2.1.1", - "source": { - "type": "git", - "url": "https://github.com/Jean85/pretty-package-versions.git", - "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", - "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2.1.0", - "php": "^7.4|^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.2", - "jean85/composer-provided-replaced-stub-package": "^1.0", - "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^7.5|^8.5|^9.6", - "rector/rector": "^2.0", - "vimeo/psalm": "^4.3 || ^5.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Jean85\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alessandro Lai", - "email": "alessandro.lai85@gmail.com" - } - ], - "description": "A library to get pretty versions strings of installed dependencies", - "keywords": [ - "composer", - "package", - "release", - "versions" - ], - "support": { - "issues": "https://github.com/Jean85/pretty-package-versions/issues", - "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" - }, - "time": "2025-03-19T14:43:43+00:00" - }, { "name": "larastan/larastan", "version": "v3.14.0", diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..491ac9d --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,38 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + // Lista separada por vírgula em CORS_ALLOWED_ORIGINS — nunca '*' fora de + // local. Em dev, front-admin (Nuxt) roda em localhost:3000 por padrão. + 'allowed_origins' => array_filter(explode(',', (string) env('CORS_ALLOWED_ORIGINS', 'http://localhost:3000'))), + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => ['X-Request-Id'], + + 'max_age' => 0, + + 'supports_credentials' => false, + +]; diff --git a/backend/config/logging.php b/backend/config/logging.php index 5a57677..5c2213e 100644 --- a/backend/config/logging.php +++ b/backend/config/logging.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Logging\JsonFormatterTap; use Monolog\Handler\NullHandler; use Monolog\Handler\StreamHandler; use Monolog\Handler\SyslogUdpHandler; @@ -65,6 +66,8 @@ 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, + // JSON estruturado + redação de password/token/secret — ver docs/adr. + 'tap' => [JsonFormatterTap::class], ], 'daily' => [ @@ -113,6 +116,7 @@ ], 'formatter' => env('LOG_STDERR_FORMATTER'), 'processors' => [PsrLogMessageProcessor::class], + 'tap' => [JsonFormatterTap::class], ], 'syslog' => [ diff --git a/backend/config/security.php b/backend/config/security.php new file mode 100644 index 0000000..1e00917 --- /dev/null +++ b/backend/config/security.php @@ -0,0 +1,24 @@ + [ + 'login' => [ + 'attempts' => (int) env('RATE_LIMIT_LOGIN_ATTEMPTS', 5), + 'decay_minutes' => (int) env('RATE_LIMIT_LOGIN_DECAY_MINUTES', 1), + ], + 'password_reset' => [ + 'attempts' => (int) env('RATE_LIMIT_PASSWORD_RESET_ATTEMPTS', 5), + 'decay_minutes' => (int) env('RATE_LIMIT_PASSWORD_RESET_DECAY_MINUTES', 1), + ], + 'checkout' => [ + 'attempts' => (int) env('RATE_LIMIT_CHECKOUT_ATTEMPTS', 10), + 'decay_minutes' => (int) env('RATE_LIMIT_CHECKOUT_DECAY_MINUTES', 1), + ], + 'webhooks' => [ + 'attempts' => (int) env('RATE_LIMIT_WEBHOOKS_ATTEMPTS', 60), + 'decay_minutes' => (int) env('RATE_LIMIT_WEBHOOKS_DECAY_MINUTES', 1), + ], + ], +]; diff --git a/backend/config/sentry.php b/backend/config/sentry.php new file mode 100644 index 0000000..89ef80a --- /dev/null +++ b/backend/config/sentry.php @@ -0,0 +1,178 @@ + env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')), + + // @see https://spotlightjs.com/ + // 'spotlight' => env('SENTRY_SPOTLIGHT', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger + // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` + + // The release version of your application + // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) + 'release' => env('SENTRY_RELEASE'), + + // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`) + 'environment' => env('SENTRY_ENVIRONMENT'), + + // Override the organization ID used for trace continuation checks. + 'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate + 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate + 'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate + 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), + + // Only continue incoming traces when the organization IDs are compatible with this SDK instance. + 'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs + 'enable_logs' => env('SENTRY_ENABLE_LOGS', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics + 'enable_metrics' => env('SENTRY_ENABLE_METRICS', true), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold + 'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'), + + // The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel + 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii + 'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false), + + // Configures which data is collected automatically, `null` keeps collecting data based on `send_default_pii` + 'data_collection' => null, + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions + // 'ignore_exceptions' => [], + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions + 'ignore_transactions' => [ + // Ignore Laravel's default health URL + '/up', + ], + + // Breadcrumb specific configuration + 'breadcrumbs' => [ + // Capture Laravel logs as breadcrumbs + 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as breadcrumbs + 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), + + // Capture Livewire components like routes as breadcrumbs + 'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true), + + // Capture SQL queries as breadcrumbs + 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query breadcrumbs, ignored when `data_collection` is set + // This option will be migrated to `data_collection.database_query_data` in the next major version + 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + + // Capture queue job information as breadcrumbs + 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + + // Capture command information as breadcrumbs + 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + + // Capture HTTP client request information as breadcrumbs + 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + ], + + // Performance monitoring specific configuration + 'tracing' => [ + // Trace queue jobs as their own transactions (this enables tracing for queue jobs) + 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), + + // Capture queue jobs as spans when executed on the sync driver + 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), + + // Capture SQL queries as spans + 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query spans, ignored when `data_collection` is set + // This option will be migrated to `data_collection.database_query_data` in the next major version + 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + + // Capture where the SQL query originated from on the SQL query spans + 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + + // Define a threshold in milliseconds for SQL queries to resolve their origin + 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + + // Capture views rendered as spans + 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + + // Capture Livewire components as spans + 'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true), + + // Capture HTTP client requests as spans + 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture where the HTTP client request originated from on the HTTP client request spans + 'http_client_requests_origin' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ORIGIN_ENABLED', true), + + // Define a threshold in milliseconds for HTTP client requests to resolve their origin + 'http_client_requests_origin_threshold_ms' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ORIGIN_THRESHOLD_MS', 250), + + // Capture Laravel cache events (hits, writes etc.) as spans + 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + + // Capture Redis operations as spans (this enables Redis events in Laravel) + 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + + // Capture where the Redis command originated from on the Redis command spans + 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + + // Enable tracing for requests without a matching route (404's) + 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + + // Configures if the performance trace should continue after the response has been sent to the user until the application terminates + // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example + 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + + // Capture AI agent interactions as spans (requires laravel/ai) + 'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true), + + // Capture AI invoke_agent spans + 'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true), + + // Capture AI chat spans + 'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true), + + // Capture AI execute_tool spans + 'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true), + + // Capture AI embeddings spans + 'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true), + + // Capture AI evaluate spans for classifications (requires laravel/ai 1.0+) + 'gen_ai_evaluate' => env('SENTRY_TRACE_GEN_AI_EVALUATE_ENABLED', true), + + // Enable the tracing integrations supplied by Sentry (recommended) + 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), + ], + +]; diff --git a/backend/routes/health.php b/backend/routes/health.php new file mode 100644 index 0000000..3a53127 --- /dev/null +++ b/backend/routes/health.php @@ -0,0 +1,10 @@ +name('health'); diff --git a/backend/tests/Arch/NoRawRequestAllTest.php b/backend/tests/Arch/NoRawRequestAllTest.php new file mode 100644 index 0000000..59456f5 --- /dev/null +++ b/backend/tests/Arch/NoRawRequestAllTest.php @@ -0,0 +1,34 @@ +all() — sempre FormRequest ou Data object', function (): void { + $controllerDirs = [ + __DIR__.'/../../app/Http/Controllers', + ...(glob(__DIR__.'/../../app-modules/*/src/Http/Controllers', GLOB_ONLYDIR) ?: []), + ]; + + $offenders = []; + + foreach ($controllerDirs as $dir) { + if (!is_dir($dir)) { + continue; + } + + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $contents = file_get_contents($file->getPathname()); + + if ($contents !== false && preg_match('/\$request->all\(\)|request\(\)->all\(\)/', $contents) === 1) { + $offenders[] = $file->getPathname(); + } + } + } + + expect($offenders)->toBe([]); +}); diff --git a/backend/tests/Feature/Api/CorsTest.php b/backend/tests/Feature/Api/CorsTest.php new file mode 100644 index 0000000..fb145fe --- /dev/null +++ b/backend/tests/Feature/Api/CorsTest.php @@ -0,0 +1,26 @@ +withHeaders([ + 'Origin' => 'http://localhost:3000', + 'Access-Control-Request-Method' => 'GET', + ])->options('/api/v1/ping') + ->assertHeader('Access-Control-Allow-Origin', 'http://localhost:3000'); +}); + +it('não libera CORS pra origem fora da lista', function (): void { + $response = $this->withHeaders([ + 'Origin' => 'https://site-malicioso.example', + 'Access-Control-Request-Method' => 'GET', + ])->options('/api/v1/ping'); + + expect($response->headers->get('Access-Control-Allow-Origin'))->not->toBe('https://site-malicioso.example'); +}); + +it('expõe X-Request-Id pro front conseguir ler via CORS', function (): void { + $this->withHeaders(['Origin' => 'http://localhost:3000']) + ->getJson('/api/v1/ping') + ->assertHeader('Access-Control-Expose-Headers', 'X-Request-Id'); +}); diff --git a/backend/tests/Feature/Api/RateLimitTest.php b/backend/tests/Feature/Api/RateLimitTest.php new file mode 100644 index 0000000..20f9d41 --- /dev/null +++ b/backend/tests/Feature/Api/RateLimitTest.php @@ -0,0 +1,32 @@ +post('/__test/login', fn () => response()->json(['ok' => true])); +}); + +it('responde 429 depois de estourar o limite do limiter de login', function (): void { + $attempts = (int) config('security.rate_limits.login.attempts'); + + for ($i = 0; $i < $attempts; $i++) { + $this->postJson('/__test/login', ['email' => 'ada@example.com'])->assertOk(); + } + + $this->postJson('/__test/login', ['email' => 'ada@example.com']) + ->assertStatus(429); +}); + +it('não compartilha o limite entre e-mails diferentes', function (): void { + $attempts = (int) config('security.rate_limits.login.attempts'); + + for ($i = 0; $i < $attempts; $i++) { + $this->postJson('/__test/login', ['email' => 'ada@example.com'])->assertOk(); + } + + $this->postJson('/__test/login', ['email' => 'ada@example.com'])->assertStatus(429); + $this->postJson('/__test/login', ['email' => 'outra@example.com'])->assertOk(); +}); diff --git a/backend/tests/Feature/Api/SecurityHeadersTest.php b/backend/tests/Feature/Api/SecurityHeadersTest.php new file mode 100644 index 0000000..f64690e --- /dev/null +++ b/backend/tests/Feature/Api/SecurityHeadersTest.php @@ -0,0 +1,29 @@ +getJson('/api/v1/ping') + ->assertHeader('X-Content-Type-Options', 'nosniff') + ->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin') + ->assertHeader('Permissions-Policy') + ->assertHeader('Content-Security-Policy'); +}); + +it('usa uma CSP mais frouxa (unpkg) só pra UI da documentação', function (): void { + $response = $this->getJson('/api/v1/ping'); + $docsCsp = $this->getJson('/docs/api.json')->headers->get('Content-Security-Policy'); + + expect($response->headers->get('Content-Security-Policy'))->not->toContain('unpkg.com') + ->and($docsCsp)->toContain('unpkg.com'); +}); + +it('não envia Strict-Transport-Security fora de HTTPS', function (): void { + $this->getJson('/api/v1/ping') + ->assertHeaderMissing('Strict-Transport-Security'); +}); + +it('envia Strict-Transport-Security quando a requisição é HTTPS', function (): void { + $this->get('https://localhost/api/v1/ping', ['Accept' => 'application/json']) + ->assertHeader('Strict-Transport-Security'); +}); diff --git a/backend/tests/Feature/HealthTest.php b/backend/tests/Feature/HealthTest.php new file mode 100644 index 0000000..c07897d --- /dev/null +++ b/backend/tests/Feature/HealthTest.php @@ -0,0 +1,30 @@ +ping')->andReturn(true); + + $this->get('/up') + ->assertOk() + ->assertJson(['status' => 'ok', 'checks' => ['database' => true, 'redis' => true]]); +}); + +it('responde 503 quando o banco cai', function (): void { + Redis::shouldReceive('connection->ping')->andReturn(true); + DB::shouldReceive('connection->getPdo')->andThrow(new RuntimeException('conexão recusada')); + + $this->get('/up') + ->assertStatus(503) + ->assertJson(['status' => 'degraded', 'checks' => ['database' => false, 'redis' => true]]); +}); + +it('responde 503 quando o redis cai', function (): void { + Redis::shouldReceive('connection->ping')->andThrow(new RuntimeException('conexão recusada')); + + $this->get('/up') + ->assertStatus(503) + ->assertJson(['status' => 'degraded', 'checks' => ['database' => true, 'redis' => false]]); +}); diff --git a/backend/tests/Feature/Logging/StructuredLoggingTest.php b/backend/tests/Feature/Logging/StructuredLoggingTest.php new file mode 100644 index 0000000..75187fa --- /dev/null +++ b/backend/tests/Feature/Logging/StructuredLoggingTest.php @@ -0,0 +1,25 @@ + 'test-request-id']); + Log::info('tentativa de login', [ + 'email' => 'ada@example.com', + 'password' => 'supersecreto123', + ]); + + $lastLine = mb_trim((string) collect(file($logPath))->last()); + $decoded = json_decode($lastLine, true); + + expect($decoded)->not->toBeNull() + ->and($decoded['context']['request_id'])->toBe('test-request-id') + ->and($decoded['context']['password'])->toBe('[REDACTED]') + ->and($lastLine)->not->toContain('supersecreto123'); +}); diff --git a/backend/tests/Unit/Logging/RedactSensitiveDataProcessorTest.php b/backend/tests/Unit/Logging/RedactSensitiveDataProcessorTest.php new file mode 100644 index 0000000..59e8e65 --- /dev/null +++ b/backend/tests/Unit/Logging/RedactSensitiveDataProcessorTest.php @@ -0,0 +1,43 @@ + 'ada@example.com', + 'password' => 'supersecreto123', + 'gateway' => ['api_key' => 'sk_live_abc', 'amount' => 1_990], + ])); + + expect($result->context)->toBe([ + 'email' => 'ada@example.com', + 'password' => '[REDACTED]', + 'gateway' => ['api_key' => '[REDACTED]', 'amount' => 1_990], + ]); +}); + +it('não mexe em contexto sem dado sensível', function (): void { + $processor = new RedactSensitiveDataProcessor; + + $result = $processor(makeTestLogRecord(['request_id' => 'abc-123', 'status' => 'ok'])); + + expect($result->context)->toBe(['request_id' => 'abc-123', 'status' => 'ok']); +}); diff --git a/docs/adr/0006-politica-de-segredos.md b/docs/adr/0006-politica-de-segredos.md new file mode 100644 index 0000000..d80de41 --- /dev/null +++ b/docs/adr/0006-politica-de-segredos.md @@ -0,0 +1,19 @@ +# 0006 · Política de segredos + +**Status:** aceito + +## Contexto + +O backend guarda dados pessoais de participantes e, eventualmente, credenciais de gateway de pagamento por edição. Nada disso pode vazar por commit, log ou configuração mal feita. + +## Decisão + +- Nenhum segredo (chave de API, senha, DSN com credencial, token) vive no repositório. `.env` é por ambiente, nunca commitado (`backend/.gitignore` já cobre `.env*`), `.env.example` documenta cada variável sem valor real. +- Credencial de gateway por edição (quando o módulo Payments existir) é criptografada em banco — nunca texto plano, nunca devolvida pela API depois de salva (já previsto em `docs/produto.md`). +- Log nunca contém segredo: `App\Logging\RedactSensitiveDataProcessor` redige `password`, `token`, `api_key`, `secret`, `authorization`, `card_number`, `cvv`/`cvc` (e variantes aninhadas) em todo `Log::info/error/...`, automaticamente — quem loga não precisa lembrar de redigir na mão. +- Sentry (observabilidade de erro) é opt-in: `SENTRY_LARAVEL_DSN` vazio por padrão desliga a integração inteira, sem código condicional espalhado. + +## Consequências + +- Rotacionar uma chave vazada é trocar variável de ambiente, nunca reescrever histórico de git. +- Um novo tipo de dado sensível (ex.: CPF) deve entrar na lista de `RedactSensitiveDataProcessor` se algum dia for logado — o teste `tests/Unit/Logging/RedactSensitiveDataProcessorTest.php` é onde isso se documenta. diff --git a/docs/api.md b/docs/api.md index 455d966..84d8d18 100644 --- a/docs/api.md +++ b/docs/api.md @@ -44,6 +44,14 @@ Route::post('/orders', StoreOrderController::class)->middleware('idempotent'); Sem o header `Idempotency-Key`, a requisição recebe 400. Com o header, a resposta da primeira chamada fica em cache por 24h; uma segunda chamada com a mesma chave recebe a resposta cacheada em vez de repetir o efeito colateral — é isso que impede dois ingressos quando o gateway reenvia o mesmo webhook. +## Rate limiting + +Rota sensível usa `throttle:` com um limiter nomeado em `App\Providers\SecurityServiceProvider`: `login`, `password-reset` (por IP + e-mail), `checkout`, `webhooks` (por IP). Limites configuráveis via `.env` (`RATE_LIMIT_LOGIN_ATTEMPTS`, `RATE_LIMIT_LOGIN_DECAY_MINUTES`, etc.) — estourar o limite responde 429. + +## CORS + +`config/cors.php` libera só as origens em `CORS_ALLOWED_ORIGINS` (lista separada por vírgula no `.env`) — nunca `*` fora de `local`. `X-Request-Id` está em `exposed_headers`, então o front consegue ler o header da resposta mesmo em requisição cross-origin. + ## Paginação - **Cursor** (`cursorPaginate()`): listas públicas ou potencialmente grandes (programação, propostas do CFP). Não permite pular página, mas escala sem `OFFSET`.