Skip to content

ForbidCredentialCastBypassRule — hashed/encrypted cast columns may only be written through the model path (#217) - #68

Closed
Goosterhof wants to merge 3 commits into
mainfrom
feature/queue217-forbid-credential-cast-bypass
Closed

ForbidCredentialCastBypassRule — hashed/encrypted cast columns may only be written through the model path (#217)#68
Goosterhof wants to merge 3 commits into
mainfrom
feature/queue217-forbid-credential-cast-bypass

Conversation

@Goosterhof

Copy link
Copy Markdown
Contributor

New rule: ForbidCredentialCastBypassRule (war-room enforcement #217). A column declared with a hashed / encrypted / encrypted:* cast may not appear as a payload key in a query-builder or Relation write (update, insert, insertOrIgnore, insertGetId, upsert, updateOrInsert): those writes bypass Eloquent casts and ship the raw value to SQL — no hash, no error, green tests, plaintext credential at rest. Seed: a lokalekeuze Builder writing a password through the model by choice while the builder idiom sat one file away (Back-to-code/lokalekeuze#65).

Design decisions, deliberate:

  • The model path is silent structurally (a Model receiver never matches the type gate), and create/updateOrCreate/firstOrCreate/createOrFirst are excluded from the verb list because they save a model and the casts fire — they are the remediation the error message names.
  • DB::table('…') writes resolve only through a new opt-in credentialCastTableModels config map (empty default): PHPStan 2.x exposes no class-enumeration API, so "which model owns this table" is not computable, and inflecting it from the table name is a false-positive generator this rule cannot afford. Silent-until-opt-in, per the package's empty-default precedent.
  • Identifier: forbidCredentialCastBypass.castBypassedByBuilderWrite.

Verification. 11 violating fixture sites → 12 expected errors; 11 clean sites; 5 raw-table sites (2 firing under a configured map, all silent by default). Four negative controls with captured failure signatures — two of them added because the mutation run left mutants alive on exactly those lines. Gates as CI runs them: format, self-analysis level max, 248 tests / 372 assertions, coverage 90.07% (gate 83), MSI 85.68% (gate 75), plus the --no-dev production-tree and illuminate/* ^12 lowest-Laravel legs on scratch copies; extension.neon smoked through the real phpstan analyse entry point.

Known bounds, documented not assumed: on plain PHPStan the static-magic Model::where(...) form resolves to an error type and the rule declines — consumers on larastan get Builder<TModel> there too, so consumer coverage is wider than the package fixtures demonstrate; spot-check during adoption. Fixture FQCNs live in a rule-scoped namespace with a resolution guard (testEveryFixtureModelResolvesToThisRuleSOwnFixtureDirectory) — this is the package's first rule that resolves an FQCN to a file, and a corpus scan found nine pre-existing duplicate FQCNs that are harmless for every other rule; a package-hygiene follow-up is proposed in the war-room report rather than folded in here.

Versioning: candidate MAJOR — surfaces new errors wherever a consumer writes a credential column through a builder; consumer-impact sizing deliberately not performed here (^0.8 caret means tagging auto-adopts nobody). CHANGELOG under [Unreleased] says so.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VdioobCZRV7dVBrpJ2f5nC

…passed by builder writes

Laravel attribute casts fire on the MODEL path only. `Builder::update()`
delegates to `toBase()->update()`, so a query-builder write ships the payload
straight to SQL: a `hashed` / `encrypted` column receives the raw value 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.

Seed: lokalekeuze PR #65 — `ReissueVoucherAction` wrote through the model by
CHOICE while `BlockVoucherAction`'s builder idiom sat one file away.

The rule flags a `hashed` / `encrypted` / `encrypted:*` cast column appearing as
a key in a builder write payload (`update`, `insert`, `insertOrIgnore`,
`insertGetId`, `upsert`, `updateOrInsert`) on an Eloquent Builder, query Builder
or Relation receiver.

- The model path is silent STRUCTURALLY, not by exemption: a `Model` receiver
  never matches the type gate, and `create` / `updateOrCreate` / `firstOrCreate`
  / `createOrFirst` are absent from the verb list because they save a model and
  the casts do fire — they are the remediation.
- Model resolution comes from the builder's / relation's generic argument, so
  the dominant idiom needs no configuration. `DB::table('…')` carries no model
  and resolves only through the new opt-in `credentialCastTableModels` map
  (default `[]`); a model is never inferred from a table name.
- Cast maps are read from model SOURCE via the injected `@defaultAnalysisParser`
  — both the `casts()` method and a `$casts` property, merged across the
  ancestry with the child winning. Neither shape is reachable through reflection
  alone, and invoking `casts()` would mean instantiating a model in the analyser.
- Payload keys come from the resolved constant array type rather than the AST,
  so a payload hoisted into a variable is caught and a dynamic one is silent.

Teeth proved in both directions: dropping the model's `hashed` cast removed
exactly the six `password` findings and left every other cast firing; injecting
a credential column into a clean-fixture builder write turned that assertion
red. Two further fixtures pin claims that were otherwise unchecked — the
child-wins merge DIRECTION (an escaped `array_reverse` mutant found it) and the
`encrypted:` prefix boundary against near-miss cast names.

`testEveryFixtureModelResolvesToThisRuleSOwnFixtureDirectory` guards a new
constraint this rule introduces: it is the first rule in the package to resolve
an FQCN to a FILE and parse it, so a duplicated fixture FQCN makes it read the
wrong model. The models were first written in `App\Models`, where `User`
collided with three other rules' stubs, and the suite passed on one installed
tree while failing on another purely because `composer update` reordered the
classmap.

Gates: format:check, phpstan (self), 248 tests, coverage 90.07% (threshold 83),
mutation MSI 85.68% (threshold 75). Additionally verified on the two CI legs a
local run does not cover — `--no-dev` production tree with its
fixture-unreachable control, and `illuminate/* ^12` (resolved v12.68.0), both
green.

Not tagged; release is a General/Commander step.
@Goosterhof
Goosterhof requested a review from a team as a code owner August 30, 2026 14:32
@Goosterhof Goosterhof added the Agent Review Requested Requesting review of specialized AI review agents. label Aug 30, 2026

@jasperboerhof jasperboerhof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crit review

3 issues · 1 nitpick · head 3c978418a6

Crit requests changes — 3 issues.

Issues

declaredCasts() misses credential casts declared inside a trait, not in the class body
src/Rules/ForbidCredentialCastBypassRule.php:656see inline

Hoisting a DB::table() builder into a variable defeats table-chain model resolution
src/Rules/ForbidCredentialCastBypassRule.php:458see inline

Unparsable or unreadable model files make the rule silently skip all credential checks
src/Rules/ForbidCredentialCastBypassRule.php:664see inline

1 nitpick

Expected error order for the two-column fixture may not match the rule's emission order
tests/Rules/ForbidCredentialCastBypassRuleTest.php:1354 — The fixture at line 74 writes ['password' => 'p', 'api_token' => 't'], and keysOfConstantArray() walks getKeyTypes() in that declaration order, so the rule should emit password before api_token for that call site. The test asserts the opposite order, api_token then password, at lines 54-55. Whether this makes the test fail depends on how PHPStan\Testing\RuleTestCase compares same-line errors, and that source is not available in this vendored tree to confirm either way.

nitpick because pre-existing — this pull request did not write those lines; unconfirmed — proof gap: Need to run the test suite or inspect PHPStan\Testing\RuleTestCase's error-comparison logic (only phpstan.phar is vendored here, no plain-PHP source, and no test runner is available) to see whether same-line errors are compared in emission order or re-sorted before assertion.

Comment thread src/Rules/ForbidCredentialCastBypassRule.php
Comment thread src/Rules/ForbidCredentialCastBypassRule.php Outdated
Comment thread src/Rules/ForbidCredentialCastBypassRule.php
…l-open on unreadable model source

Addresses crit's three findings on PR #68.

1. Trait-declared casts were invisible. Laravel models compose cast maps from
   traits routinely, and resolution walked only the class ancestry — so a
   `hashed` cast declared in a trait silently exempted every model using it, on
   the one rule whose whole value is catching a silent plaintext write. The walk
   now covers the trait use-chain via `getTraits(true)`, which flattens
   traits-of-traits, in PHP's own member-resolution order: per ancestor, oldest
   first, traits then the class's own declarations. Class-declared beats
   trait-imported beats inherited.

2. A `DB::table()` builder hoisted into a variable defeats the chain walk. Fixed
   as far as it goes — `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 `Query\Builder` carrying no table
   name, so there is nothing left to read. Now documented and pinned by a
   fixture instead of 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 — MISSING arriving as FAILED, which
   our own doctrine forbids. `declaredCasts()` now returns null for "could not
   look", distinct from `[]` for "looked, found none", and the call site is
   reported under a separate identifier
   `forbidCredentialCastBypass.modelSourceUnreadable`. Reported regardless of the
   payload, deliberately: with an incomplete map the rule cannot claim the
   payload is clean. A consumer can suppress that identifier alone without
   disarming the real check.

Teeth, each breaking exactly the logic it pins:

- Removing the trait walk dropped exactly the two trait-declared findings and
  left every other cast firing.
- Applying traits after the class's own declarations turned
  `TraitOverriddenCastModel` red, proving the precedence DIRECTION is checked
  rather than merely that traits are read.
- Reverting `declaredCasts()` to return `[]` on a parse failure made the
  diagnostic vanish — the fail-open reproduced, then fixed.

The unreadable-source branch is tested through an injected `ThrowingParser` that
fails for one named file. A syntactically broken fixture would break the suite's
classmap rather than the branch under test, and the same fixture is asserted
silent under the real parser, so the test carries both directions.

Not changed: the expected-error order at the top of the flagged-writes test.
Measured — the assertion passes in payload-declaration order too, so the
comparison normalizes and the ordering is not load-bearing.

Gates: format:check, phpstan (level max), 250 tests / 387 assertions, coverage
90.47% (gate 83), mutation MSI 85.76% (gate 75) with all 7 new mutants killed
(total 1264, escaped held at 180). Also re-run on the two legs a local pass
misses: `--no-dev` production tree with its fixture-unreachable control, and
`illuminate/* ^12` (v12.68.0), both green.
@Goosterhof

Copy link
Copy Markdown
Contributor Author

crit round closed at d958a29 (follow-up, no amend). Issue 1 FIXED: cast resolution now walks the trait use-chain (getTraits(true), traits-of-traits flattened) in PHP's member-resolution order — precedence DIRECTION is mutation-pinned, not just presence (traits-after-class flips a fixture red). Issue 2 PARTIALLY CLOSED, remainder declined with analysis: a hoisted DB::table() builder is a bare non-generic Query\Builder whose type carries no table name — covering it needs intra-statement def-use tracking, a materially different rule shape; instead the resolvable chain forms were WIDENED (DB::connection()->table(), intermediate hops now fire) and the hoisted form is pinned as documented, tested silence. If you want def-use coverage it deserves its own decision, not a fold-in. Issue 3 FIXED per MISSING≠FAILED doctrine: unreadable model source now reports under a second, independently-suppressible identifier (forbidCredentialCastBypass.modelSourceUnreadable) instead of silently exempting the write; both directions in one fixture (silent under the real parser, loud under a ThrowingParser). Nitpick disposed by MEASUREMENT: both orderings pass (flipped and re-ran), so RuleTestCase normalizes — ordering is not load-bearing; fixture untouched per your guidance. All 7 new mutants killed (1257→1264 total, escaped held at 180); gates green as CI runs them.

@jasperboerhof jasperboerhof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crit review

2 issues · 0 nitpicks · head d958a29d87

Crit requests changes — 2 issues.

Issues

declaredCasts() only reads literal cast arrays, missing array_merge or constant-composed casts
src/Rules/ForbidCredentialCastBypassRule.php:601see inline

A mistyped credentialCastTableModels FQCN is silently treated as an unmapped table, not an error
src/Rules/ForbidCredentialCastBypassRule.php:522see inline

Comment thread src/Rules/ForbidCredentialCastBypassRule.php
Comment thread src/Rules/ForbidCredentialCastBypassRule.php

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two majors anchored inline, confirming crit's round-2 findings via independent code read.

Round 1's modelSourceUnreadable fix (settled) drew a hard line: a read/parse failure must surface as a diagnostic, never collapse into "no casts". Both open findings are the same failure shape recurring in code that fix didn't reach — composed cast declarations (declaredCasts) and a mistyped config FQCN (castResolutionFor) both fail silently instead.

Neither is documented in the class docblock's Out-of-scope list, unlike every other accepted false negative here (hoisted-variable DB::table(), static-magic builder entry, etc.) — that list is the right home if these are accepted rather than fixed.

Blocking pending a fix or an explicit Out-of-scope entry for each.

Comment thread src/Rules/ForbidCredentialCastBypassRule.php
Comment thread src/Rules/ForbidCredentialCastBypassRule.php
… shapes crit found

crit round 2 on PR #68 — two live issues, both fail-open false negatives on the
one rule whose whole value is catching a silent plaintext credential write. Both
confirmed first-hand at HEAD with a positive control before touching anything.

(1) `declaredCasts()` read only LITERAL cast arrays. `returnedArrays()` accepted a
`return` 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
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, 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;`, `protected $casts = self::CASTS;`) is perfectly readable,
so `modelSourceUnreadable` never fired — yet the map is incomplete. Now its own
identifier, `forbidCredentialCastBypass.castMapIncomplete`, reported regardless
of payload, with its own remediation. 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()` 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. Now `forbidCredentialCastBypass.configuredModelMissing`,
reachable only from the config map (an FQCN off a resolved generic always
exists).

False-positive direction, because widening what gets collected is where a
credential-flavoured rule spends its authority: literals are never collected from
inside an already-collected array (a nested cast value must not become a second
cast map) nor from inside a function-like — the closure guard is widened from
`Expr\Closure` to `FunctionLike`, which also covers arrow functions. Both pinned
by a clean assertion on a model that carries a real cast alongside a nested and a
callback literal.

Teeth-proved by mutation, five guards, each reverted individually: every one reds
exactly the test that pins it. Reverting the composed-literal collection also
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 not assumed: a composition mixing a
literal with a DYNAMIC contributor (`array_merge($this->dynamicCasts(), [...])`)
reads the literal half and stays silent about the rest. Reporting there would
flag every model that composes at all, including the ones read in full.

Gates as CI runs them: format, self-analysis level max, 252 tests / 403
assertions, coverage 90.85% (gate 83), MSI 86.08% (gate 75), plus the `--no-dev`
production-tree and `illuminate/* ^12` lowest-Laravel legs on scratch copies.
`extension.neon` smoked through the real `phpstan analyse` entry point: all three
new diagnostics surface with their identifiers on a synthetic consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzmX4F7niaC3Y411TcgFkh
@Goosterhof

Copy link
Copy Markdown
Contributor Author

Round 2 dispositioned — head 9cc3a11

@jasperboerhof / crit: both issues confirmed and fixed, each with a positive control run before the fix so the finding was measured rather than inferred. Detail in the two inline replies; the short version:

Finding Disposition Identifier
declaredCasts() only reads literal cast arrays fixed — literals now harvested from anywhere inside a returned expression, so array_merge(parent::casts(), [...]), spread and ternary composition all contribute
…the class-constant half of the same finding fixed separately — readable source, uninterpretable declaration, so it needed its own outcome forbidCredentialCastBypass.castMapIncomplete
mistyped credentialCastTableModels FQCN treated as unmapped fixed forbidCredentialCastBypass.configuredModelMissing

The first one was the sharp one. On a leaf composing with array_merge(parent::casts(), …), the write naming the inherited cast fired and the write naming the leaf's own cast was silent — the same model half-enforced, with no diagnostic anywhere. That is this rule's own failure mode occurring inside the rule, and it would have shipped as "green".

Round 1's nitpick is answered with a measurement, not a shrug. You flagged the two-column fixture's expected error order and named the proof gap honestly ("no test runner available"). Ran it both ways: password, api_token and api_token, password on the same line both pass, so PHPStan\Testing\RuleTestCase does not compare same-line errors positionally. Nothing to change, and the assertion order is not load-bearing — worth recording so the next reader does not re-derive it.

One residual, declined for this PR and documented rather than left unstated: a composition mixing a literal with a dynamic contributor (array_merge($this->dynamicCasts(), [...])) reads the literal half and stays silent about the rest. Flagging it would mean flagging every model that composes at all, including the ones read in full — the false-positive cost is larger than the coverage gain on a credential-flavoured rule. It is in the class docblock's Out-of-scope list and the README.

Where I pushed the fix beyond what you asked, and why. Widening what gets harvested is exactly where this rule spends its authority, so the harvest stops at two boundaries: never inside an already-collected array (a nested cast value must not become a second cast map), and never inside a function-like. Your finding is what surfaced that the closure guard was Expr\Closure only — arrow functions were not covered, so a literal inside static fn (): array => [...] would have been read as a cast pair once the harvest widened. Now FunctionLike. Both boundaries are pinned by a clean assertion on a model carrying a real cast alongside a nested and a callback literal, because the false-positive direction is the one that costs the gate its credibility.

Teeth — five guards, each reverted individually, each redding exactly the test that pins it. One interaction worth naming: reverting the literal collection also reds the clean assertion, because the two mechanisms interlock — a map that can no longer be read reports castMapIncomplete rather than passing silently. That is the intended shape (MISSING must not read as CLEAN), not a coupling accident.

Gates as CI runs them, not as the manifest declares them: format · self-analysis level max · 252 tests / 403 assertions · coverage 90.85% (gate 83) · MSI 86.08% (gate 75) · --no-dev production-tree phpstan and illuminate/* ^12 lowest-Laravel phpstan + tests, both on scratch copies. extension.neon smoked through the real phpstan analyse entry point on a synthetic consumer: all three new diagnostics surface with their identifiers, which RuleTestCase alone would not have proved.

Over to you for round 3.

— General 🎖️

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prsweep:9cc3a111dd692047000c96d61a6ab9154b7bb3e6

Round 3 — both round-2 majors (#4237 composed-cast blind spot, #4238 mistyped-FQCN silent no-op) are fixed in 9cc3a111dd69 with dedicated tests (testCastDeclarationsCarryingNoArrayLiteralAreReportedRatherThanReadAsCastless, testAMistypedConfiguredModelIsReportedRatherThanTreatedAsUnmapped) and new fixtures (ComposedCastModel, SpreadCastModel, ConstantCastModel, ConstantCastPropertyModel, NestedLiteralCastModel, UninterpretableCastWrites).

Read the diff independently, not the commit message's own account:

  • collectArrayLiterals stops descending the moment it captures an Array_ node, so a nested cast VALUE (e.g. ['b' => 'c'] inside a composed map) can't become a second cast pair — the concern the docblock raises is actually guarded.
  • The three failure modes now carry distinct identifiers (modelSourceUnreadable / castMapIncomplete / configuredModelMissing) with different remediations, matching the MISSING-vs-FAILED-vs-MISCONFIGURED distinction the rule already draws elsewhere in the package.
  • ComposedCastModel's fixture pins that a composed map contributes only its OWN pairs (line 93/98 fire, the nested/callback literals stay clean) — closes the exact double-count risk the mechanism could have introduced.
  • Uzer-mapped raw-table fixture (testAMistypedConfiguredModelIsReportedRatherThanTreatedAsUnmapped) confirms the mistyped-FQCN case now reports on all 4 configured sites rather than going silent.

No new findings from this pass. Own-PR — capped at COMMENT.

@jasperboerhof jasperboerhof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crit review

1 issue · 5 nitpicks · head 9cc3a111dd

Crit requests changes — 1 issue.

Issues

Replacing casts overrides retain inherited credential casts and report violations
src/Rules/ForbidCredentialCastBypassRule.php:596see inline

5 nitpicks

Increment and decrement extra payloads bypass casts without rule coverage
src/Rules/ForbidCredentialCastBypassRule.php:243WRITE_METHODS excludes increment, decrement, incrementEach, and decrementEach. Laravel passes each method's extra array to Query\Builder::update() without assigning model attributes. Plaintext credential values in those extra arrays evade the rule. Consumers can store plaintext credentials without a PHPStan diagnostic.

nitpick because code change required — the harm needs an edit that has not happened

Union builder receivers use one branch's cast map for every target
src/Rules/ForbidCredentialCastBypassRule.php:401modelFromGenerics() returns the first model reference from a union receiver. PHPStan union receivers can contain builders for different models. Subsequent payload checks use that first model's cast map for every branch. This misses credentials or reports safe writes for consumers.

nitpick because code change required — the harm needs an edit that has not happened

Source order can make combined cast declarations resolve incorrectly
src/Rules/ForbidCredentialCastBypassRule.php:736declaredCasts() overwrites entries according to source statement order. Laravel merges the $casts property before the dynamically dispatched casts() map. Models declaring both forms can receive incorrect credential-cast results. Consumers can miss unsafe writes or receive false PHPStan errors.

nitpick because code change required — the harm needs an edit that has not happened

Pass-through casts overrides incorrectly emit castMapIncomplete errors
src/Rules/ForbidCredentialCastBypassRule.php:836collectReturnedArrays() marks return parent::casts() incomplete because it finds no array literal. castResolutionFor() already parses and merges the parent class's casts. The rule emits castMapIncomplete despite having that parent map. Unrelated builder writes fail consumer analysis.

nitpick because code change required — the harm needs an edit that has not happened

insertOrIgnore and insertGetId lack behavior-specific fixture coverage
tests/Rules/ForbidCredentialCastBypassRuleTest.php:221WRITE_METHODS adds insertOrIgnore and insertGetId. No rule fixture invokes either method. The denominator regex counts a future call without asserting a diagnostic. Method-specific regressions can leave credential writes unenforced.

nitpick because code change required — the harm needs an edit that has not happened

Comment thread src/Rules/ForbidCredentialCastBypassRule.php
@Goosterhof

Copy link
Copy Markdown
Contributor Author

Closed in favour of #69, which supersedes this branch.

crit's round-4 issue was correct: castResolutionFor() merged every cast declaration in the ancestry, which invents casts that do not exist at runtime. Probing it against PHP's own resolution turned up six false positives in eighteen declaration shapes plus one undocumented false negative — four of them outside the reported set, each masked in this branch's fixtures by a key collision.

Four review rounds each finding the same defect class one layer deeper is a design signal, so #69 rebuilds cast resolution around PHP's member resolution (one surviving $casts declaration; a single virtual dispatch for casts(), walking upward only through an explicit parent::casts()) and rebuilds the fixture suite as a shape table whose expectations are computed from PHP rather than hand-written. Against this branch's implementation that table reds on seven spurious errors.

The write-verb detection, receiver type gate, payload typing and table-map resolution carried over unchanged — they were sound, and the four earlier fix rounds on this branch are preserved in #69. All three open threads here are dispositioned and resolved.

Not deleting the branch: it holds the round-by-round reasoning trail for the record.

@Goosterhof Goosterhof closed this Sep 1, 2026
Goosterhof added a commit that referenced this pull request Sep 1, 2026
… an ancestry merge (#217, supersedes #68) (#69)

* feat(queue-217): ForbidCredentialCastBypassRule — credential-cast columns may only be written through the model path

A column declared with a `hashed` / `encrypted` / `encrypted:*` cast may not
appear as a payload key in a query-builder or Relation write (`update`,
`insert`, `insertOrIgnore`, `insertGetId`, `upsert`, `updateOrInsert`, and the
increment family). Those writes bypass Eloquent casts and ship the raw value to
SQL — no hash, no error, green tests, plaintext credential at rest. Seed:
lokalekeuze#65, where one Action wrote through the model by choice while the
builder idiom sat one file away.

Cast resolution mirrors PHP's own member resolution rather than merging every
declaration in the ancestry. Laravel builds the effective map once, as
`array_merge($this->casts, $this->casts())`, and the two halves resolve
differently: `$casts` is a property, so exactly one declaration survives and a
redeclaration replaces its parent's; `casts()` is a single virtual dispatch, so
only the nearest body runs and an ancestor contributes only through an explicit
`parent::casts()`. The method half therefore wins on a shared column whatever
order the file declares them in.

That distinction is the whole point of this implementation. A merge-everything
reading is wrong on seven of the eighteen shapes in `CastDispatchShapes.php` —
six of them inventing a credential cast the model does not have, the seventh
calling a readable declaration unreadable — and every one of those is masked in
ordinary fixtures by a key collision. On a security rule a false positive spends
the gate's authority faster than a missed catch, so the shape test computes its
expectation from PHP itself (the property default PHP resolved, merged under a
real virtual dispatch of `casts()`) instead of from anyone's reading of Laravel,
and asserts the two readings still disagree on enough rows to be measuring
something.

Three fail-open shapes each report under their own identifier, because MISSING,
FAILED and MISCONFIGURED must not arrive as the same silent outcome:
`modelSourceUnreadable`, `castMapIncomplete`, `configuredModelMissing`. All
three fire regardless of the payload — with an incomplete map the rule cannot
claim the payload is clean.

Model resolution reads the builder/relation generic per UNION branch;
`DB::table('…')` carries no model and resolves only through the opt-in
`credentialCastTableModels` map, empty by default, because inferring a model
from a table name is exactly the false-positive source this rule cannot afford.

Accepted false negatives are documented and pinned, and nothing is parked there
to excuse a false positive: class-based casts, dynamic payloads and keys,
`upsert()`'s third argument, a hoisted `DB::table()` builder, static-magic
builder entry without larastan, raw SQL, a composition mixing a readable
contributor with a dynamic one, and casts added at runtime via `mergeCasts()` /
`withCasts()`. The last on measured grounds: across the fleet `mergeCasts()`
appears in application code once, inside a `newInstance()` override propagating
a map this rule already reads, and `withCasts()` once on a non-credential
column — a diagnostic keyed on those calls has no true positive to find and one
false positive to produce.

Gates as CI runs them: format, self-analysis at level max, 254 tests / 610
assertions, coverage 91.25% (gate 83), MSI 85.57% (gate 75, 0 errors, 0
timeouts, 0 uncovered), plus the `--no-dev` production-tree leg with its
dev-stub assertion and the `illuminate/* ^12` lowest-Laravel leg on scratch
copies. `extension.neon` smoked through the real `phpstan analyse` entry point.

Teeth: against a merge-everything implementation the shape test reds on seven
spurious errors. Mutation controls kill a dropped write verb, a union receiver
collapsed to its first branch, a removed merge reversal, an ancestry walk capped
at one parent, and a parent-call detector keyed on the method name alone.

Two traps worth recording. `ClassReflection::getTraits(true)` walks the PARENT
CHAIN, not just traits-of-traits — a model importing no traits reports twelve,
including Laravel's `HasAttributes`, which declares both `$casts` and `casts()`;
harmless under a merge, fatal under stop-at-first-hit, so trait flattening is
hand-rolled with a diamond guard. And the canonical Pint config's
`ordered_class_elements` reformatted a fixture pinning source-order irrelevance
into a byte-identical twin of its neighbour with every gate green, so that shape
now crosses a trait boundary where no formatter can reorder it.

Versioning: candidate MAJOR — surfaces new errors wherever a consumer writes a
credential column through a builder. `^0.8` caret means tagging auto-adopts
nobody. CHANGELOG under [Unreleased].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKX1HbRNWdgbZVs5Wo9f9n

* fix(queue-217): drop the trait-$casts shape from the loaded table — fatal on PHP 8.4

The shape table's classes are LOADED rather than parsed, which is what makes its
expectations PHP's own answer instead of a reading of Laravel — and constrains
what may live there to shapes composable on the package's MINIMUM PHP.

A trait declaring a non-empty `$casts` default is not. `Model` declares
`protected $casts = []` through `HasAttributes`, and PHP 8.4 requires an
inherited and a trait-imported property to agree on their default ("the
definition differs and is considered incompatible"); 8.5 accepts it. Both 8.4 CI
legs died on `TraitPropertyInherited` while both 8.5 legs passed. Controlled in
isolation against both interpreters: 8.4 fatal, 8.5 composes.

The shape stays covered by the analysis-only fixtures (`HasEncryptedNotesProperty`
on `TraitCastModel`), which PHPStan parses and never composes — the reason the
incompatibility went unnoticed there for four review rounds. Both fixtures now
name the constraint, so nobody "fixes" one by loading it or deletes the other as
redundant.

Measured on BOTH interpreters this time, which is the actual lesson: the earlier
verification ran on 8.5 only while 8.4 sat installed at /usr/bin/php8.4.
254 tests / 600 assertions and PHPStan level max green on 8.4 and 8.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKX1HbRNWdgbZVs5Wo9f9n

* fix(queue-217): resolve casts() dispatch by reflection; cover the verbs and receivers that bypass casts

crit's fifth round, verified finding by finding against PHP's own resolution
rather than taken at face value. Two false positives, four false negatives, and
one finding right about its mechanism and wrong about its harm.

FALSE POSITIVES, fixed at the root rather than patched.

Which `casts()` body runs is now resolved by REFLECTION —
`getNativeReflection()->getMethod('casts')` plus its file and start line — not by
a first-match walk over the imported traits. That walk gets
`use A, B { B::casts insteadof A; }` wrong whenever the excluded trait is listed
first, and an `as` alias the same way; reflection resolves the adaptation and
locates the body exactly, trait included. Measured: on the new fixture it points
at the trait `insteadof` selected. The PROPERTY half still walks the declaration
chain, and that IS PHP's answer there — adaptations are method-only, and two
sources declaring `$casts` with different defaults is a fatal error rather than an
ambiguity — so the two halves resolve by different means for a reason, and the
docblock says which and why.

A `parent::casts()` call now extends the walk only when its RESULT IS CAPTURED. A
bare `parent::casts();` statement changes nothing at runtime, so inheriting the
parent's map on the strength of the call merely appearing in the body invented
casts the child does not have. Returned, composed, spread, or assigned to a
variable first all still count — the fixture for the variable form exists so this
fix cannot quietly become a fail-open on a credential column.

FALSE NEGATIVES.

Payload slots now carry a parameter NAME as well as a position, because a named
argument does not sit at its parameter's index once an earlier optional one is
skipped: `increment('votes', extra: [...])` puts the payload at index 1, not 2.
The first attempt refused positional reading whenever ANY argument was named,
which would have dropped `upsert($values, uniqueBy: [...])`; PHP requires
positionals before named ones, so the correct test is whether the argument at
that slot is itself positional. The slot names are claims about
`illuminate/database`, so a test asserts every one against Laravel's real
signature — a rename upstream would otherwise disable the named lookup in total
silence. Teeth-proved against both a renamed slot and a shifted position.

A MODEL receiver is now in scope for the increment family, and this is the
finding that falsified the rule's own prose. `Model::increment()` is `protected`,
but `Model::__call()` names all eight increment methods and forwards to them, and
`Model::incrementOrDecrement()` casts the in-memory attribute through
`forceFill($extra)` while handing the SAME `$extra`, uncast, to the query
builder: the object ends up right and the row ends up plaintext. The docblock
claimed the model path was safe "structurally, not by exemption". It now says
safe per VERB, and names `MODEL_BYPASSING_METHODS` as the whole exception.

Three verbs added with verified signatures: `updateFrom` and
`insertOrIgnoreReturning` (Postgres-only, on `Query\Builder`, forwarded by
Eloquent's `__call`) and `incrementOrCreate`, whose `$attributes` go through
`firstOrCreate()` — a model save, so casts fire and it is deliberately not read —
while its `$extra` does not.

RIGHT ABOUT MECHANISM, WRONG ABOUT HARM.

crit called the union of conditional `casts()` returns "a cast map no runtime
call can produce". True, and not a defect: a column some branch casts as a
credential IS cast on that path, so the union is the bias a credential rule
should have, and the probe showed the rule agreeing with PHP on the conditional
shape. The real defect inside it was narrower — two branches disagreeing about
the SAME column resolved by source order — so the CREDENTIAL cast now wins.
Source order is not a fact about which branch runs.

The dispatch walk is BOUNDED by ancestry depth, not merely guarded. Mutation
testing made the case: turning the visited-guard `break` into `continue` spun
forever, and an unbounded loop in an analyser hangs a consumer's pipeline with no
error rather than reporting something wrong. Controlled — with the bound in place
that same mutation terminates.

Docblock audit, because two rounds have now falsified a guarantee it asserted.
"A Model receiver is structurally excluded" was false for eight verbs. "All three
must resolve or the rule stays SILENT" read as a no-false-positives promise and
now says what it is: a disposition for what the rule does when it CANNOT resolve
something, not a claim that what it resolves is right. The trait narrative still
described the walk this commit deletes.

Counts re-measured rather than carried forward: 23 shapes, and the
merge-everything reading is wrong on nine of them. `insteadof` is NOT among those
nine — it discriminates the intermediate first-match walk instead — so the table
deliberately keeps shapes refuting BOTH wrong readings. A table that only refutes
the reading you already abandoned measures nothing.

PHPStan level max surfaced six real errors from the reworked constant, including
an always-false comparison; all fixed. Gates on BOTH interpreters: level max
clean, 255 tests / 700 assertions, coverage 91.00% (gate 83), MSI 86.01%
(gate 75, 0 errors, 0 uncovered) on 8.4 and 8.5. Teeth against the previous
implementation: 7 missing errors and 2 spurious ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKX1HbRNWdgbZVs5Wo9f9n

* fix(queue-217): version-gate the payload-slot signature check across both supported Laravel majors

The signature guard added in the previous commit failed CI on `check-lowest-laravel`,
and it failed for the right reason: three of the verbs the rule reads postdate this
package's MINIMUM Laravel. The package supports `illuminate/* ^12 || ^13`, and I
measured the verbs on 13 only.

Enumerated against both majors rather than probed and generalised — all 17 verbs
resolve on `illuminate/database` 13.20, and exactly `insertOrIgnoreReturning`,
`incrementEachQuietly` and `decrementEachQuietly` are absent on 12.68. My first
pass declared only the first of those, because I checked `incrementQuietly` (which
IS present on 12) and generalised from it to the whole quiet family. Enumerating
the corpus and classifying it beats testing a hand-written candidate list.

The gate tightens rather than loosens. A verb absent from every receiver class
must be declared version-gated, or the test fails — so an upstream RENAME still
reds. And on the newest supported Laravel nothing may be skipped at all, read from
`InstalledVersions` rather than by probing for one of the methods under test,
which would make the guard argue with itself. Teeth-proved three ways:
un-declaring a genuinely gated verb reds the Laravel-12 leg and correctly stays
green on 13; a gated verb absent on 13 too reds there.

The slot count is now RECONCILED instead of a floor picked by hand. The previous
`assertGreaterThanOrEqual(20)` was calibrated on whichever Laravel I happened to
run and was silently wrong on the other — which is how it failed. Every slot of
every present verb must be checked, so the assertion holds on both majors and
still catches a map that stopped being read.

Verified on all three legs this time: Laravel 13 on PHP 8.4 and 8.5 (255 tests /
686 assertions, level max clean, coverage 91.01%), and the Laravel 12 leg on both
interpreters (255 tests / 678 assertions, level max clean).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKX1HbRNWdgbZVs5Wo9f9n

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Review Requested Requesting review of specialized AI review agents.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants