Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

### Added

- `ForbidCredentialCastBypassRule` — new rule (war-room enforcement queue #217) forbidding a `hashed` / `encrypted` / `encrypted:*` cast column from appearing as a key in a **query-builder** write payload: `Model::query()->…->update([...])`, `->insert(...)`, `->insertOrIgnore(...)`, `->insertGetId(...)`, `->upsert(...)`, `->updateOrInsert(...)`, a relation-derived builder write, or a `DB::table('…')` write on a mapped table. Registered in `extension.neon` with one new parameter, `credentialCastTableModels` (default `[]`). Identifier: `forbidCredentialCastBypass.castBypassedByBuilderWrite`. Doctrine: war-room §Architectural Principles #1 (Explicit over implicit) + #10; ISO 27001 A.5.33 and AVG on the compliance territories. Seed: lokalekeuze PR #65.

**The bug class.** Attribute casts fire on the MODEL path only — `setAttribute()` runs the cast when you assign `$model->password = $plain` and `save()`. `Illuminate\Database\Eloquent\Builder::update()` delegates to `toBase()->update()`, which ships the array straight to SQL: the credential lands in the column verbatim, with no hash, no encryption, no exception, and a **green test suite** (a test that reads the column back gets exactly what it wrote). The failure is silent at every layer and is normally discovered by reading the database. In the seed, `ReissueVoucherAction` wrote through the model by CHOICE while `BlockVoucherAction`'s builder idiom sat one file away — nothing but author preference separated the safe site from the unsafe one.

**Model path stays silent STRUCTURALLY, not by exemption.** The receiver type must be an Eloquent `Builder`, a query `Builder`, or a `Relation`; a `Model` receiver never matches, so `$model->update([...])`, `$model->password = …; $model->save()` and `Model::create([...])` are silent because of what they are, not because they are listed. `create` / `updateOrCreate` / `firstOrCreate` / `createOrFirst` are deliberately absent from the write-verb list even on a builder receiver — they instantiate and `save()` a model, so casts fire and flagging them would criminalize the remediation.

**Model resolution — generic first, config only for raw tables.** An Eloquent `Builder<TModel>` or `Relation<TRelatedModel, …>` yields the model from its generic argument (measured on plain PHPStan: `Model::query()->where(…)` resolves to `Builder<App\Models\User>`), so the dominant idiom needs no configuration. `DB::table('users')` resolves to a bare `Illuminate\Database\Query\Builder` carrying **no** model, so it is resolved only by walking the chain back to the `table('…')` string literal and looking it up in `credentialCastTableModels`. That map is **empty by default**, so raw-table writes are silent until a consumer opts in — inferring a model from a table name by inflection is exactly the false-positive source a credential-flavoured rule cannot afford, and queue #217 names the per-territory Level-1 arch test as the interim for what this rule cannot see.

**Cast maps are read from model SOURCE.** Neither declaration shape is reachable through reflection alone: `protected function casts(): array` needs a method body, and invoking it would mean instantiating an Eloquent model inside the analyser. The rule injects PHPStan's own `@defaultAnalysisParser` (cached — a model file is parsed once per run), locates the class by resolved `namespacedName`, and reads `'column' => 'cast'` string pairs from **both** the `casts()` method's `return [...]` statements and a `$casts` property default, merged across the ancestry with the child winning (a cast declared on an abstract base still counts at the leaf). Payload keys come from the resolved **constant array type** rather than the AST literal, so `$p = ['password' => …]; $q->update($p);` is caught and a dynamic payload is silent.

**Deliberate misses, each a false NEGATIVE and never a false positive:** class-based casts (`AsEncryptedArrayObject::class` and friends appear as `::class` constant fetches, not the string values matched here); dynamic payloads and computed keys; `upsert()`'s third argument (an update-COLUMN list whose names are values, and every column named there already appears in the row payload that is read); `Model::where(…)->update([...])` static-magic entry on plain PHPStan, where `__callStatic` is untypeable and the receiver resolves to an error type (consumers running larastan get `Builder<TModel>` there and the rule fires normally — `Model::query()->…` resolves either way); and raw SQL, which has no payload array.

**Teeth proved in both directions.** Flipping `'password' => 'hashed'` to `'string'` on the model fixture dropped exactly the six `password` findings and left every other cast firing; injecting `'password'` into a clean-fixture builder write turned the green assertion red at that line. A denominator test asserts each fixture still carries its write sites, so the two zero-expectation assertions cannot pass on an empty or unparsed file. The `extension.neon` wiring was additionally smoked through the real `phpstan analyse` entry point, not only `RuleTestCase`.

**Review round 1 (crit, PR #68 — three issues, all fixed in a follow-up commit).** (1) **Trait-declared casts were invisible.** Laravel models compose cast maps from traits routinely, and the resolution walked only the class ancestry — so a `hashed` cast declared in a trait silently exempted every model using it, on a rule whose whole purpose is catching a silent plaintext write. The walk now covers the trait use-chain via `getTraits(true)` (flattening traits-of-traits) in PHP's own member-resolution order: per ancestor, oldest first, traits then the class's own declarations, so class-declared beats trait-imported beats inherited. (2) **`DB::table()` hoisted into a variable** defeats the chain walk. Fixed as far as it can be — `DB::connection('…')->table('…')` and arbitrary intermediate hops now resolve — but the hoisted form is **not resolvable in principle**: the variable's type is a bare `Illuminate\Database\Query\Builder` which carries no table name, so there is nothing to read. Now documented and pinned by a fixture rather than left as an unstated gap. (3) **An unparsable or unlocatable model source made the rule fail OPEN** — it returned the same empty cast set as a model that genuinely declares none, so every write to that model passed silently. `declaredCasts()` now returns `null` for "could not look" as distinct from `[]` for "looked, found none", and the call site is reported under a **separate identifier `forbidCredentialCastBypass.modelSourceUnreadable`** — regardless of the payload, because with an incomplete map the rule cannot claim the payload is clean. Each fix is teeth-proved in both directions, including a fixture pinning the trait-vs-class precedence DIRECTION and a test that injects a parser failing on exactly one model file (a syntactically broken fixture would break the suite's classmap rather than the branch under test).

**Review round 2 (crit, PR #68 — two issues, both fail-open false negatives, both fixed).** (1) **`declaredCasts()` read only LITERAL cast arrays.** `returnedArrays()` collected a `return` statement only when its expression *was* an `Expr\Array_`, so `return array_merge(parent::casts(), ['password' => 'hashed']);` — Laravel's own documented way to extend a parent's cast map — contributed **nothing**. Measured with a positive control before the fix: on a leaf composing that way, the write naming the *inherited* `passphrase` fired and the write naming the leaf's own `composed_secret` was **silent**, so the same model was half-enforced with no diagnostic anywhere. Array literals are now collected from anywhere inside a returned expression (never from *inside* an already-collected array, so a nested-array cast value stays a value), which covers `array_merge`, a ternary over two literals, and the spread form. (2) **A `casts()` return or `$casts` default carrying no array literal AT ALL** (`return self::CASTS;`, `return $this->buildCasts();`, `protected $casts = self::CASTS;`) is read, so `modelSourceUnreadable` never fired — yet the map is incomplete. That is now its own identifier, **`forbidCredentialCastBypass.castMapIncomplete`**, reported regardless of payload with a remediation of its own (restate the credential columns literally). An abstract `casts()` with no body is NOT flagged — no return statement, nothing uninterpretable. (3) **A mistyped `credentialCastTableModels` FQCN was answered with the "table not mapped" result.** `hasClass()` returning false returned the same empty cast set as an absent mapping, so a typo or a stale rename permanently and silently disarmed the rule for that table — the exact fail-open shape the unreadable identifier exists to prevent, arriving through configuration rather than source. Now **`forbidCredentialCastBypass.configuredModelMissing`**, reachable only from the config map (an FQCN taken from a resolved generic always exists). All three teeth-proved by mutation: reverting each guard individually reds exactly the test that pins it, and reverting the composed-literal collection additionally reds the *clean* assertion — because the two mechanisms interlock, a map that can no longer be read reports `castMapIncomplete` rather than passing. New accepted false negative, documented: a composition mixing a literal with a *dynamic* contributor reads the literal half and stays silent about the rest.

**Versioning: candidate MAJOR** — it surfaces new errors in code that previously passed wherever a consumer writes a credential column through a builder. Per the pre-1.0 caret convention `^0.8` excludes the next minor, so tagging auto-adopts nobody; each consumer adopts on its own pin-bump PR. **NOT tagged** (release is a General/Commander step).

- `ForbidUntimedHttpClientRule` — new rule enforcing war-room **Architectural Principle #8** (explicit timeouts on outbound HTTP) at analysis time, the AST-aware successor to the per-territory `ExternalHttpTimeoutTest` named-list Pest tests (kendo / emmie). The named-list tests detect wrong-shape on *enrolled* classes but are blind to **omission** — a new untimed call nobody adds to the list; this rule closes the omission gap for the tractable call shapes. Registered in `extension.neon` (no parameters). Identifier: `forbidUntimedHttpClient.missingTimeout`. Doctrine: war-room §Architectural Principles #8. Seed: war-room enforcement queue #58 (spike branch `spike/wr-queue58-untimed-http-client`). **Review follow-up (bus #57 findings):** the `withOptions()` check is TYPE-aware, not AST-literal — a constant array type provably lacking `'timeout'` still fires (including through a variable holding a literal array, a widening over the inline-`Array_`-only first cut), while a computed/helper-built options expression (not a constant array type) is POSSIBLY timed and the chain DECLINES (the Major: flagging it was a false positive). A chain member outside the known `PendingRequest` builder surface — a Macroable extension (`Http::github()`, root or intermediate) or `when()`/`unless()` with their opaque closures — likewise declines (the Minor: a macro may return a pre-timed request); a genuine builder missing from the list costs only a false negative.

**Detection (type-anchored, two entry points):** fires on a terminal send verb (`get`/`post`/`put`/`patch`/`delete`/`head`/`send`) reached without an explicit request timeout, where the entry point is either (1) the `Http` facade (`Illuminate\Support\Facades\Http` static-call root), or (2) an **injected `Illuminate\Http\Client\Factory`** receiver (`$this->http->…->get()`, anchored by TYPE so the property alias is irrelevant — the dominant fleet idiom, established by field survey of kendo/emmie/ublgenie/BIO). A timeout counts when the visible chain contains `->timeout(...)` **or** `->withOptions([... 'timeout' => ...])`; `connectTimeout()` alone does NOT (it bounds the handshake, not the response).
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev
| `ForbidUntimedHttpClientRule` | War-room §Explicit HTTP timeouts (#8) | `forbidUntimedHttpClient.missingTimeout` (type-aware; flags an `Http` facade OR injected `Illuminate\Http\Client\Factory` chain that reaches a send verb with no explicit `->timeout()` / `withOptions(['timeout'])`. Conservative — fires only on fully-visible single-expression chains; declines split/helper-built chains + Guzzle/SDK surfaces to hold FP at zero. COMPLEMENTS, does not replace, the per-territory `ExternalHttpTimeoutTest`. on `main`, `[Unreleased]`) |
| `EnforceAuditModelProtectionsRule` | ADR-0001 §Append-only | `enforceAuditModelProtections.hasFactoryForbidden` / `.softDeletesForbidden` / `.updatedAtNotDisabled` (denylist-inversion; discovers audit models by shape — `auditModelNameSuffixes` default `AuditLog` OR `auditModelNamespacePrefixes` default `App\Models\Audit` — and flags `HasFactory` / `SoftDeletes` / missing `const UPDATED_AT = null`. shipped v0.7.0) |
| `EnforceActionResultDtoRule` | ADR-0020 + ADR-0011 | `enforceActionResultDto.arrayReturnFromExecute` (signature-only; flags an `array` / `?array` / `array\|Dto` union / `iterable` native return type on `App\Actions\*` `execute()`. Phpdoc-only `@return array{...}` is a deliberate miss; no `list<T>` carve-out. Seed kendo PR #1653. shipped v0.8.0) |
| `ForbidCredentialCastBypassRule` | War-room §Explicit over implicit (#1) + §Rotation-invariant credential handling (#10) | `forbidCredentialCastBypass.castBypassedByBuilderWrite` / `.modelSourceUnreadable` / `.castMapIncomplete` / `.configuredModelMissing` (flags a `hashed` / `encrypted` / `encrypted:*` cast column appearing as a key in a QUERY-BUILDER write payload — `update`/`insert`/`insertOrIgnore`/`insertGetId`/`upsert`/`updateOrInsert` on an Eloquent `Builder`, query `Builder` or `Relation`. Casts fire on the model path only, so a builder write stores the credential in plaintext with a green suite. The model path and the model-routing builder verbs (`create`/`updateOrCreate`/`firstOrCreate`/`createOrFirst`) are silent STRUCTURALLY — the receiver type gate, not an exemption list. Model comes from the builder/relation generic; `DB::table('…')` carries none and resolves only via the opt-in `credentialCastTableModels` map, default `[]`. Cast maps read from model SOURCE via the injected `@defaultAnalysisParser` — both `casts()` and `$casts`, merged across the ancestry AND the trait use-chain (class-declared beats trait-imported beats inherited). Composed maps are read as well — the literal inside `array_merge(parent::casts(), …)` and an array spread both contribute. THREE fail-open shapes are each reported under their own identifier rather than reading as castless: `.modelSourceUnreadable` (source cannot be located/parsed), `.castMapIncomplete` (read, but a declaration carries no array literal at all — `return self::CASTS;`), `.configuredModelMissing` (`credentialCastTableModels` names a nonexistent class). Payload keys read from the CONSTANT ARRAY TYPE, so a hoisted variable is caught. Seed lokalekeuze PR #65. on `main`, `[Unreleased]`) |
| `ConnectionTransactionReturnTypeExtension` | (type extension, no rule) | — (resolves `ConnectionInterface::transaction()` to the closure's return type. **Load-bearing on `illuminate/* ^12` only** — it annotates `@return mixed`; Laravel 13 annotates `@template TReturn`/`@return TReturn` and infers the same type unaided. Its teeth are measured by the `check-lowest-laravel` CI job, not by the unit suite on a Laravel-13 tree. WR-0855, WR-0860) |

Phase 2 expands the rule set: `EnforceAuditSnapshotOnRetryRule` (ADR-0001 §Snapshot-on-Retry Safety) was the first Phase 2 addition, promoted from cross-territory Pest arch tests (emmie PR #187, entreezuil PR #139, ublgenie PR #166, kendo PR #1029). `EnforceResourceDataValidatorOptInRule` (ADR-0009 §EAGER_LOAD validator opt-in) is the second Phase 2 addition, promoted from kendo PR #1084 under war-room enforcement queue #55. `EnforceFormRequestToDtoRule` (ADR-0012) is the third Phase 2 addition, promoted from entreezuil's `tests/Arch/FormRequestsTest.php` under the same queue #55 (instance 2). `EnforceExplicitHydrationRule` (ADR-0019) is the next Phase 2 candidate.
Expand Down Expand Up @@ -135,6 +136,7 @@ SemVer per ADR-0021:
### War-room Architectural Principle rules (no published ADR)

- **Explicit over implicit** — package distributes `ForbidAbortHelperRule` (bans `abort()` / `abort_if()` / `abort_unless()`; shipped), `EnforceCurrentUserAttributeRule` (flags `Request::user()` / `Auth::user()` / `auth()->user()` in `App\Http\Controllers`, steering to the `#[CurrentUser]` container attribute per Architectural Principle #9; shipped v0.4.0), `ForbidHttpExceptionInActionsRule` (type-aware sibling of `ForbidAbortHelperRule` — bans throwing the `Symfony\…\HttpException` family from `App\Actions\*`; HTTP status concerns belong to the HTTP layer per Principles #1 + #3; `ValidationException` deliberately out of scope; shipped v0.5.0), `ForbidResourceWrappedInJsonResponseRule` (bans wrapping a `JsonResource` in `response()->json()` / `new JsonResponse()` inside controllers per Principle #1 + ADR-0009; shipped v0.5.0), and `ForbidRawExceptionMessageInResponseRule` (bans a raw `Throwable::getMessage()` — or the `Throwable` itself — reaching a client-facing response sink per Principle #1 + information-disclosure hardening for the ISO 27001 / AVG / NEN 7510 consumers; default sink `Laravel\Mcp\Response::error`, configurable via `rawExceptionMessageSinks`; server-side logging never flags; `// @leak-safe:` exemption; shipped v0.8.0). These enforce war-room §Architectural Principles (some also touching numbered ADRs) — each rule's docblock "Doctrine source" line names its authority.
- **Rotation-invariant credential handling (#10) + Explicit over implicit (#1)** — package distributes `ForbidCredentialCastBypassRule` (flags a `hashed` / `encrypted` / `encrypted:*` cast column named as a key in a query-builder write payload, where the cast never fires and the raw credential reaches SQL; the model path is structurally silent because a `Model` receiver never matches the builder/relation type gate. `DB::table()` resolution is opt-in via `credentialCastTableModels` — a model is never inferred from a table name. ISO 27001 A.5.33 / AVG relevance on the compliance consumers. Seed lokalekeuze PR #65, war-room enforcement queue #217; on `main`, `[Unreleased]`).
- **Explicit HTTP timeouts (#8)** — package distributes `ForbidUntimedHttpClientRule` (flags an `Http` facade / injected `Illuminate\Http\Client\Factory` chain reaching a send verb without an explicit request timeout, per Architectural Principle #8; the AST-aware, omission-closing successor to the per-territory `ExternalHttpTimeoutTest` named-list Pest tests — conservative single-expression detection, declines split/helper-built chains + Guzzle/SDK surfaces; COMPLEMENTS the named-lists rather than replacing them; on `main`, `[Unreleased]`). Seed: war-room enforcement queue #58.

### War-room internal ADRs
Expand Down
Loading
Loading