ldc-arcadia-api/storage/auditor-findings.json

306 lines
17 KiB
JSON

{
"findings": [
{
"id": "F-2026-0001",
"rule_id": "AUD-SEC-008",
"title": "Rate limiter key spoofing via untrusted HTTP_X_FORWARDED_FOR header",
"domain": "security",
"severity": "high",
"confidence": "confirmed",
"status": "fixed",
"summary": "AppServiceProvider binds a singleton 'clientIp' reading directly from request()->server('HTTP_X_FORWARDED_FOR'), allowing callers to bypass rate limiters.",
"why_it_matters": "An attacker can bypass API and Web rate limiting (60 req/min for api.v1, 120 req/min for web) by rotating arbitrary IP addresses in the X-Forwarded-For header, neutralizing protection against DoS and brute-force attacks.",
"evidence": [
{
"type": "file",
"reference": "src/Infra/Providers/AppServiceProvider.php",
"line": 28,
"end_line": 30,
"detail": "$this->app->singleton('clientIp', fn() => request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp());"
},
{
"type": "file",
"reference": "src/Infra/Providers/AppServiceProvider.php",
"line": 84,
"end_line": 90,
"detail": "RateLimiter::for('api.v1') and RateLimiter::for('web') partition limits by resolve('clientIp')."
}
],
"affected_resources": [
"src/Infra/Providers/AppServiceProvider.php",
"bootstrap/middlewares.php"
],
"symbol": "Infra\\Providers\\AppServiceProvider::register",
"recommendation": "Remove the custom 'clientIp' singleton and resolve rate limit keys via $request->ip() inside the RateLimiter callback.",
"remediation": "Replaced resolve('clientIp') with $request->ip() in RateLimiter::for('web') and RateLimiter::for('api.v1'), removed the singleton('clientIp') binding in register(), and added Tests\\Feature\\Api\\V1\\RateLimitingTest.",
"verification_notes": "Fixed in AppServiceProvider.php and verified with RateLimitingTest. Rate limit partition now resolves trusted IP address via $request->ip().",
"metadata": {
"priority": "p0",
"subsystem": "CFG"
}
},
{
"id": "F-2026-0002",
"rule_id": "AUD-CON-003",
"title": "BrandCache reset deletes global key before querying items to invalidate",
"domain": "conventions",
"severity": "medium",
"confidence": "confirmed",
"status": "fixed",
"summary": "BrandCache::reset() deletes KEY_ALL before calling getAll(), which causes getAll() to immediately return null and leaves individual brand cache entries intact.",
"why_it_matters": "When BrandCreated or BrandRenamed domain events fire, BrandCache::reset() fails to invalidate individual brand cache entries (brand:id:*, brand:code:*, brand:arcadia:*), serving stale brand data for up to 24 hours.",
"evidence": [
{
"type": "file",
"reference": "src/Infra/Cache/BrandCache.php",
"line": 127,
"end_line": 143,
"detail": "$this->cacheStore->delete(self::KEY_ALL) runs before $all = $this->getAll(); getAll() attempts to read self::KEY_ALL which was just deleted."
}
],
"affected_resources": [
"src/Infra/Cache/BrandCache.php",
"src/Infra/Listeners/InvalidateBrandCache.php"
],
"symbol": "Infra\\Cache\\BrandCache::reset",
"recommendation": "Retrieve $all = $this->getAll() before deleting self::KEY_ALL so individual brand keys can be successfully resolved and deleted.",
"remediation": "In BrandCache::reset(), moved $all = $this->getAll() before $this->cacheStore->delete(self::KEY_ALL).",
"verification_notes": "Fixed in BrandCache.php and verified with BrandCacheTest.",
"metadata": {
"priority": "p1",
"subsystem": "CFG"
}
},
{
"id": "F-2026-0003",
"rule_id": "AUD-PER-011",
"title": "N+1 database queries in INCO Sheet entity hydration due to un-cached ArcadiaBrand lookup",
"domain": "performance",
"severity": "medium",
"confidence": "confirmed",
"status": "fixed",
"summary": "EloquentIncoSheetDirectory::findAll and search trigger an un-cached SQL query to arcadia_brands for every row in the result set via BrandResolver::resolve.",
"why_it_matters": "Listing 50 or 100 INCO sheets dispatches 50 to 100 extra individual database queries to arcadia_brands to check if the brand was renamed, causing query amplification on high-traffic listing endpoints.",
"evidence": [
{
"type": "file",
"reference": "src/Infra/Adapters/Directories/EloquentIncoSheetDirectory.php",
"line": 76,
"end_line": 78,
"detail": "Loop calls toDomain($row) -> brandFrom($model) -> brandResolver->resolve($arcadiaId)."
},
{
"type": "file",
"reference": "src/Domain/Services/BrandResolver.php",
"line": 33,
"end_line": 35,
"detail": "$this->arcadiaBrandDirectory->find($arcadiaBrandCode) executes an un-cached database query on every row iteration."
}
],
"affected_resources": [
"src/Infra/Adapters/Directories/EloquentIncoSheetDirectory.php",
"src/Domain/Services/BrandResolver.php",
"src/Infra/Adapters/Directories/EloquentArcadiaBrandDirectory.php",
"src/Infra/Providers/DomainServiceProvider.php"
],
"symbol": "Infra\\Adapters\\Directories\\EloquentIncoSheetDirectory::findAll",
"recommendation": "Cache ArcadiaBrandDirectory::find lookups or avoid querying the external directory on every item when the domain brand entity is already cached and valid.",
"remediation": "Added in-memory memoization in EloquentArcadiaBrandDirectory and registered ArcadiaBrandDirectory as scoped in DomainServiceProvider. Verified with EloquentArcadiaBrandDirectoryTest.",
"verification_notes": "Tested in-memory cache behavior in EloquentArcadiaBrandDirectoryTest. Second find() lookup makes 0 DB queries.",
"metadata": {
"priority": "p1",
"impact": {
"resource": "database",
"mechanism": "Eliminates duplicate queries per brand code against arcadia_brands table during listing hydration.",
"amplification": "Avoids up to 100 queries per paginated request."
},
"subsystem": "DB"
}
},
{
"id": "F-2026-0004",
"rule_id": "AUD-DB-002",
"title": "Destructive dropIfExists in migration up() methods",
"domain": "database",
"severity": "medium",
"confidence": "confirmed",
"status": "fixed",
"summary": "Several migration up() methods invoke dropIfExists() before creating tables, creating risk of silent data loss if run against an existing database.",
"why_it_matters": "If migrations are executed against an existing database environment, calling dropIfExists() silently destroys table data without warning.",
"evidence": [
{
"type": "file",
"reference": "database/migrations/2026_08_11_000000_create_employees_table.php",
"line": 19,
"end_line": 19,
"detail": "$schema->dropIfExists('employees'); called inside up()"
},
{
"type": "file",
"reference": "database/migrations/2026_08_14_000001_create_inco_sheets_table.php",
"line": 19,
"end_line": 19,
"detail": "$schema->dropIfExists('inco_sheets'); called inside up()"
},
{
"type": "file",
"reference": "database/migrations/2026_08_14_000002_create_inco_compos_table.php",
"line": 19,
"end_line": 19,
"detail": "$schema->dropIfExists('inco_compos'); called inside up()"
},
{
"type": "file",
"reference": "database/migrations/2026_08_20_114010_create_arcadia_brands_table.php",
"line": 19,
"end_line": 19,
"detail": "$schema->dropIfExists('arcadia_brands'); called inside up()"
}
],
"affected_resources": [
"database/migrations/2026_08_11_000000_create_employees_table.php",
"database/migrations/2026_08_14_000001_create_inco_sheets_table.php",
"database/migrations/2026_08_14_000002_create_inco_compos_table.php",
"database/migrations/2026_08_20_114010_create_arcadia_brands_table.php"
],
"recommendation": "Remove dropIfExists calls from up() methods and keep table dropping exclusively in down() methods.",
"remediation": "Guarded $schema->dropIfExists(...) with if (app()->environment('local', 'testing')) in all four migration files.",
"verification_notes": "Tested in local environment; migrations safely rebuild secondary DB tables in dev/test while protecting production.",
"metadata": {
"priority": "p1",
"subsystem": "DB"
}
},
{
"id": "F-2026-0005",
"rule_id": "AUD-CON-004",
"title": "Development dependency driftingly/rector-laravel declared in production require",
"domain": "conventions",
"severity": "low",
"confidence": "confirmed",
"status": "fixed",
"summary": "driftingly/rector-laravel is declared in the 'require' block instead of 'require-dev' in composer.json.",
"why_it_matters": "Rector is an AST analysis and refactoring developer tool. Installing it in production builds needlessly increases vendor deployment size and package attack surface.",
"evidence": [
{
"type": "file",
"reference": "composer.json",
"line": 12,
"end_line": 12,
"detail": "\"driftingly/rector-laravel\": \"^2.5\" is in 'require'"
}
],
"affected_resources": [
"composer.json"
],
"recommendation": "Move driftingly/rector-laravel to require-dev in composer.json.",
"remediation": "Moved driftingly/rector-laravel from require to require-dev in composer.json and updated composer.lock.",
"verification_notes": "Verified composer.json require-dev section and lockfile.",
"metadata": {
"priority": "p3",
"subsystem": "DEP"
}
},
{
"id": "F-2026-0006",
"rule_id": "AUD-CON-005",
"title": "Config auth.php references non-existent App\\Models\\User class and unused auth tables",
"domain": "conventions",
"severity": "low",
"confidence": "confirmed",
"status": "fixed",
"summary": "config/auth.php imports and references App\\Models\\User practical class which does not exist in this DDD/Clean Architecture application.",
"why_it_matters": "The project authenticates via custom HMAC and api_clients table. Referencing non-existent classes and creating unused Laravel boilerplate tables (users, password_reset_tokens, personal_access_tokens) creates confusion and runtime errors if default guards are triggered.",
"evidence": [
{
"type": "file",
"reference": "config/auth.php",
"line": 5,
"end_line": 69,
"detail": "use App\\Models\\User; and 'model' => env('AUTH_MODEL', User::class)"
},
{
"type": "file",
"reference": "database/factories/UserFactory.php",
"line": 12,
"end_line": 14,
"detail": "Factory defines User factory for non-existent model"
}
],
"affected_resources": [
"config/auth.php",
"database/factories/UserFactory.php"
],
"symbol": "config/auth.php",
"recommendation": "Update config/auth.php to point to ApiClient or remove the unused Eloquent user provider, and delete unused UserFactory.",
"remediation": "Replaced User::class with Infra\\Database\\Models\\ApiClient::class in config/auth.php and deleted unused UserFactory.php.",
"verification_notes": "Verified config/auth.php now references valid ApiClient model.",
"metadata": {
"priority": "p2",
"subsystem": "CFG"
}
},
{
"id": "F-2026-0007",
"rule_id": "AUD-QUE-001",
"title": "Queued listener SendClientSecurityAlertMail missing retry and timeout bounds",
"domain": "conventions",
"severity": "low",
"confidence": "confirmed",
"status": "open",
"summary": "SendClientSecurityAlertMail implements ShouldQueue without defining explicit $tries or $timeout properties.",
"why_it_matters": "If the mail transport hangs or fails, the job relies on queue worker defaults without explicit bounded retries or execution timeout.",
"evidence": [
{
"type": "file",
"reference": "src/Infra/Listeners/SendClientSecurityAlertMail.php",
"line": 19,
"end_line": 23,
"detail": "Class implements ShouldQueue and uses Queueable, but defines no $tries or $timeout properties."
}
],
"affected_resources": [
"src/Infra/Listeners/SendClientSecurityAlertMail.php"
],
"symbol": "Infra\\Listeners\\SendClientSecurityAlertMail",
"recommendation": "Declare explicit $tries and $timeout properties on SendClientSecurityAlertMail.",
"remediation": "Add public int $tries = 3; and public int $timeout = 30; to SendClientSecurityAlertMail.",
"verification_notes": "Inspected SendClientSecurityAlertMail class definition.",
"metadata": {
"priority": "p3",
"subsystem": "CMD"
}
},
{
"id": "F-2026-0008",
"rule_id": "AUD-SEC-010",
"title": "Non-atomic nonce validation in CachedNonceValidator allows race conditions in replay protection",
"domain": "security",
"severity": "low",
"confidence": "confirmed",
"status": "fixed",
"summary": "CachedNonceValidator checks if a nonce is consumed via has() and then consumes it via set(), which is non-atomic and susceptible to race conditions under concurrent requests.",
"why_it_matters": "In high-concurrency scenarios, simultaneous replayed requests sharing the same nonce could pass the has() check before set() executes.",
"evidence": [
{
"type": "file",
"reference": "src/Infra/Adapters/Services/CachedNonceValidator.php",
"line": 27,
"end_line": 46,
"detail": "$this->isKeyConsumed($key) (calling has()) followed by $this->consumeKey($key) (calling set())."
}
],
"affected_resources": [
"src/Infra/Adapters/Services/CachedNonceValidator.php"
],
"symbol": "Infra\\Adapters\\Services\\CachedNonceValidator::validateAndConsume",
"recommendation": "Use an atomic add operation (e.g. Cache::add() or Redis SETNX) which atomically tests and sets the key.",
"remediation": "Refactored CachedNonceValidator to use CacheRepository::add() for single-step atomic test-and-set nonce consumption.",
"verification_notes": "Verified with CachedNonceValidatorTest asserting atomic add() call.",
"metadata": {
"priority": "p2",
"subsystem": "CFG"
}
}
]
}