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
+35
View File
@@ -436,3 +436,38 @@ def test_an_identity_constrained_handler_is_not_reported(tmp_path: Path) -> None
cpg = CpgResult(cpg_path=tmp_path / "cpg.bin", run=lambda q, p: rows)
answer = verdict(cpg, spec, function="displayMine", file="app/routes/notes.js")
assert answer is None, f"a handler that passes the caller's own identity was reported: {answer}"
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_only_a_collection_read_is_an_obligated_find(tmp_path: Path) -> None:
"""`find` is a collection read, an array search and a jQuery selector. Only the first is obligated.
The name was absent from the operation table for that reason, which left the family's obligation missing at
the commonest read there is: NodeGoat's documented insecure direct object reference reads with
`allocationsCol.find(searchCriteria())`. Measured on one WordPress plugin's shipped assets, 83 of 88 calls
named `find` pass a selector or a predicate, and five pass a selector built at run time. Each exclusion
below is one of those measured classes.
"""
from openultrasast.model.specs import dominance_specs
spec = dominance_specs(language="javascript")["access_control"]
assert "find" in spec.document_shape, "the shape requirement is no longer declared for this call"
source = tmp_path / "shapes.js"
source.write_text(
"function shapes(db, arr, $el, pred, query) {\n"
' const col = db.collection("notes");\n'
" const read = col.find({ owner: 1 });\n"
" const wide = col.find();\n"
" const built = col.find(criteria());\n"
" const search = arr.find(x => x.id === 1);\n"
' const selector = $el.find(".cls");\n'
' const interpolated = $el.find(`li[data-key="${pred}"]`);\n'
" const concatenated = $el.find('li[data-key=\"' + pred + '\"]');\n"
" return [read, wide, built, search, selector, interpolated, concatenated];\n"
"}\n"
)
rows = _dominance_rows(tmp_path, function="shapes", file="shapes.js")
lines = sorted(int(str(row["opLine"])) for row in rows)
# 3 the object literal, 4 the whole collection, 5 the builder call.
assert lines == [3, 4, 5], f"the wrong find shapes were claimed: {[(r['opLine'], r['operation']) for r in rows]}"