Repository navigation
Restore custom taint map from cache; make taint analysis deterministic - #11946
Conversation
fe1ad60 to
5db2f38
Compare
5db2f38 to
3859653
Compare
|
Good catch on the forked-scan taint merge — that gap is now fixed: bugbot run |
|
bugbot run |
3859653 to
abf3977
Compare
|
Re-worked the forked-scan taint handling per the discussion: instead of a post-scan merge (which, as noted, can't reconcile two workers that assigned the same bit to different taints), new custom taints are now registered in the parent's single authoritative registry. A worker sends the taint name over its Amp task channel and the parent replies with the canonical bit ( bugbot run |
…le process Custom taint types (registered via Codebase::getOrRegisterTaint, e.g. from `@psalm-taint-sink`/`@psalm-taint-source` docblocks or plugins) are assigned a bit lazily, in the order they are first encountered while scanning, and that bit is baked into the cached file/classlike storage (a param's `sinks`, a function's `taint_source_types`, etc.). On a cache hit the defining docblocks are not re-parsed, so getOrRegisterTaint never runs and Codebase::$custom_taints is empty (or ordered differently). The taint bits baked into the reused storage then no longer correspond to the taint names registered this run, so cached sinks and sources silently stop matching and no taint issues are reported -- unless the cache is disabled with `--no-cache`, which forces a full re-scan. Persist the custom taint name->bit map next to the cache (ProjectCacheProvider) and restore it before anything is scanned (ProjectAnalyzer), so the bits baked into reused storage stay valid. The map is saved after every run that populated the cache, not just full-project runs, since individual-file and diff runs also write taint bits into the storage cache. Scanning -- and therefore docblock taint registration -- happens in forked worker processes, which each assign bits from an independent counter. Two brand-new custom taints first seen by different workers would otherwise get the same bit (or one taint two different bits), corrupting the merged storage. Register new taints in the parent's single authoritative registry instead: a worker sends the taint name over its task channel and the parent (via Pool's new message pump) replies with the canonical bit, so every worker agrees. This also leaves the parent map complete, so it can be persisted directly. Also make the array_map()/array_filter() return-type providers deterministic: they named their synthetic offset/method-call variables with mt_rand(), and those names become part of taint-graph node ids, so the taint graph (and its dump) differed from run to run. Derive the discriminator from the call position instead -- the `$fake_var_discriminator` "known value" hook that already existed but was unused by default. Co-Authored-By: Claude Opus 4.8
abf3977 to
4d09331
Compare
|
bugbot review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 4d09331. Configure here.
…rminism Restore custom taint map from cache; make taint analysis deterministic
Problem
Custom taint types (registered via
Codebase::getOrRegisterTaint()— e.g. from@psalm-taint-sink/@psalm-taint-sourcedocblocks or a plugin) are assigned a bit lazily, in the order they are first encountered while scanning ($id = 1 << $this->taint_count++). That bit is then baked into the cachedFileStorage/ClassLikeStorage(a parameter'ssinks, a function'staint_source_types, etc.).On a cache hit the defining docblocks are not re-parsed, so
getOrRegisterTaint()never runs andCodebase::$custom_taintsends up empty (or ordered differently than the run that wrote the cache). The taint bits baked into the reused storage then no longer correspond to the taint names registered in the current run, so cached sinks and sources silently stop matching and no taint issues are reported — unless caching is disabled with--no-cache, which forces a full re-scan.This is easy to hit with a project that defines a custom taint on a vendored/rarely-changed class (a Mongo/NoSQL sink, say): the sink lives in a file that is always served from cache, so after the first run its taint bit is effectively dead and the vulnerability stops being reported.
Fix
Persist the custom taint
name → bitmap next to the cache (ProjectCacheProvider::save/loadCustomTaints()) and restore it before anything is scanned (ProjectAnalyzer), so the bits baked into reused storage stay valid across runs. The map only ever grows, so previously-cached storage remains consistent. It is saved after every run that populated the cache (not just full-project runs), because individual-file and diff runs also write taint bits into the storage cache. With--no-cachethere is no cache provider, so nothing is persisted and each run re-registers from scratch as before.Single-process taint registration
Scanning — and therefore docblock taint registration — runs in forked worker processes, and each worker assigns bits from its own counter. So two brand-new custom taints first discovered by different workers would both get the first free bit (or one taint would get two different bits depending on discovery order), corrupting the merged storage and the findings built from it. (Thanks to the Cursor Bugbot review for flagging this.)
Rather than a best-effort post-scan merge, new taints are now registered in the parent's single authoritative registry: a worker sends the taint name over its task channel and the parent replies with the canonical bit (
Codebase::registerTaintFromWorker(), driven by a small message pump added toPool::run). Every worker therefore agrees on the bit for a given name, and the parent map is complete so it can be persisted directly.Determinism
array_map()/array_filter()named their synthetic offset / method-call variables withmt_rand(), and those names become part of taint-graph node ids, so the taint graph varied between runs. They now derive a deterministic discriminator from the call position — the$fake_var_discriminator"known value" hook that already existed but was unused by default. (This doesn't change issue counts — the random id is internally consistent within a run — but it makes the taint graph byte-reproducible.)Tests
tests/Cache/CustomTaintCacheTest.php—export/importround-trip preserves bits and appends new taints; import is a no-op once taints are registered; a forked worker registers taints through the parent registry; two workers with opposite discovery order agree on every taint's bit (the collision case); and custom taint bits survive cache reuse across runs.tests/TaintDeterminismTest.php— the samearray_map()/array_filter()code analysed twice yields an identical taint graph (fails againstmt_rand()).Existing
TaintTest,ReturnTypeProvider/ArrayMapTest,ArrayFilterTestand cache tests still pass. Validated end-to-end on a large real project that forks for scanning: cold and warm runs report the same taints and persist a complete, stable taint map.🤖 Generated with Claude Code