feat(access-control): model the collection read, by argument shape rather than by name

NodeGoat's documented insecure direct object reference reads with
allocationsCol.find(searchCriteria()), and `find` was absent from an operation table holding
findOne, findById, findAll and findByPk. The obligation was missing at the commonest read there
is, so that defect raised nothing at any point in the pipeline.

It was absent for a good reason, and the reason is the argument, not the name. Measured on
wp-statistics, a WordPress plugin that ships jQuery: its assets hold 88 calls named find, of
which 70 pass a jQuery selector, 13 pass a predicate to Array.prototype.find and 5 pass a
selector built at run time by a template literal or a concatenation. None is a collection read.

So the obligation is claimed only where the first argument has the shape of a query document.
The operation fact states which argument with query_document_arg, and the arbiter that can see
argument shapes decides it: no arguments at all, an object literal, or a builder call are
claimed; a method reference, a literal, an operator call and a bare identifier are not. Each
exclusion is one of the measured classes above rather than a guess.

Zero of those 88 calls became an obligated operation, and the zero is measured rather than
empty: the same run's census reports 60 files and 4,552 methods, and the files holding those
calls are in it.

On NodeGoat all three find calls are collection reads and all three are claimed, which raises
the obligation at four more handlers -- the allocations handler that takes userId from req.params
on an authenticated route, the benefits pages whose isAdmin guard is commented out in the route
table, and the memo listing. Seven of fifteen handlers now reach a verdict, all corroborated,
none reported without a judge, and the census is unchanged on both subjects.

A bare identifier is deliberately not claimed: col.find(query) and arr.find(pred) are the same
shape and neither repository measured holds one, so this is a stated false negative rather than
an unmeasured false-positive risk.

1,415 tests pass, sixteen skipped; twelve Joern-gated query tests pass in the image.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-20 12:10:27 +02:00
co-authored by Claude Opus 5
parent 102a21fc4e
commit c0427e6ceb
10 changed files with 322 additions and 5 deletions
+9 -1
View File
@@ -167,6 +167,13 @@ corroborated and none is reported without a judge, which is the honest rung: ent
obligation reported a signup handler that is public by design, because its authenticated
siblings share the same data module. VAmPI is unchanged.
The commonest collection read now carries the obligation too, so NodeGoat's documented insecure
direct object reference raises one where it never had. `find` had been left out because the name
is shared with array searches and jQuery selectors, so it is claimed only in the argument shape
of a query document. Measured on a plugin that ships jQuery, all 88 of its `find` calls stay
unclaimed. Seven NodeGoat handlers now reach a verdict, all corroborated; the census is
unchanged, because the corroborated band needs a judge it does not configure.
Closing the second gap surfaced four defects, three of which were false positives it would
otherwise have shipped: a permissive-value set shared across settings whose booleans have
opposite polarity, a dotted setting matched on its trailing segment alone, a flow query asked of
@@ -178,4 +185,5 @@ permissive setting; task 14.6 carries it.
Evidence: [family detection census](../../../benchmarks/measurements/2026-09-19-family-detection-census.json),
[untrusted destination vocabulary](../../../benchmarks/measurements/2026-09-19-untrusted-destination-vocabulary.json),
[output encoding family](../../../benchmarks/measurements/2026-09-19-output-encoding-family.json),
[access control across the module split](../../../benchmarks/measurements/2026-09-20-access-control-module-split.json).
[access control across the module split](../../../benchmarks/measurements/2026-09-20-access-control-module-split.json),
[collection read obligation](../../../benchmarks/measurements/2026-09-20-collection-read-obligation.json).
+5 -1
View File
@@ -518,10 +518,14 @@ Preserve all completed task evidence below.
- _Boundary: contributor-scan dominance, shipped obligation facts_
- _Depends: 14.4_
- _Requirements: 6.2, 6.3, 8.1_
- [ ] 14.8 Model the collection read an application actually writes
- [x] 14.8 Model the collection read an application actually writes
- **Named by the 14.4 measurement.** NodeGoat's documented insecure direct object reference reads with `allocationsCol.find(searchCriteria())`, and `find` is absent from an operation vocabulary holding `findOne`, `findAll` and `findById`. It is the commonest read there is, so the family's obligation is missing at exactly the shape it exists for.
- It is absent for a reason: `Array.prototype.find` is everywhere in JavaScript and jQuery's `.find` is everywhere in a vendored bundle. A collection read takes a query document where those take a closure or a selector, so the discriminator is the argument's shape rather than the name.
- Done when a collection read raises the obligation, an array search and a selector do not, and the false-positive count is measured on a repository with vendored bundles rather than argued from the shape alone.
- _Verified 2026-09-20:_ 1,415 tests passed, sixteen skipped; twelve Joern-gated query tests pass in the image; Ruff/format/mypy passed. The obligation is claimed where the first argument has the shape of a query document, which the operation fact states as `query_document_arg` and the arbiter decides: no arguments at all, an object literal, or a builder call. Not claimed: a method reference, a literal, an operator call, and a bare identifier.
- _The false-positive count is measured, on wp-statistics, a WordPress plugin that ships jQuery._ Its assets hold 88 calls named `find` — 70 jQuery selectors, 13 array predicates and 5 selectors built at run time by a template literal or a concatenation, and no collection read at all. Zero became an obligated operation. The zero is a measured one and not an empty graph: the same run's census reports 60 files and 4,552 methods, and the files holding those 88 calls are in it.
- On NodeGoat all three `find` calls are collection reads and all three are claimed, which raises the obligation at four more handlers including `app/routes/allocations.js:displayAllocations` — the documented insecure direct object reference, which had raised nothing at any point in the pipeline before. Seven of fifteen handlers now reach a verdict, all corroborated, none reported without a judge, and the census is unchanged on both subjects. Evidence: `benchmarks/measurements/2026-09-20-collection-read-obligation.json`.
- _A bare identifier is deliberately not claimed._ `col.find(query)` and `arr.find(pred)` are the same shape and neither repository measured holds one, so this is a stated false negative rather than an unmeasured false-positive risk.
- _Boundary: shipped obligation facts, contributor-scan dominance_
- _Depends: 14.4_
- _Requirements: 6.2, 8.1_