diff --git a/.kiro/specs/pre-push-safety-net/status.md b/.kiro/specs/pre-push-safety-net/status.md index af68413..e420702 100644 --- a/.kiro/specs/pre-push-safety-net/status.md +++ b/.kiro/specs/pre-push-safety-net/status.md @@ -174,6 +174,15 @@ of a query document. Measured on a plugin that ships jQuery, all 88 of its `find 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. +What a session read discharges is now decided by where its value goes. A destructuring spells +`session.userId` away, so the arbiter walks back from the names the query is given; two NodeGoat +handlers that were correct code stop being asked about, while the memo page that reads every memo +and merely renders the session's user stays reported. The sibling scope was decided by +measurement and left at the file: on this subject the file scope entails two handlers, both +public routes and both wrong, and an application-wide scope entails four true defects and the +same two wrong ones. Neither scope can tell a public route from a protected one, so the carried +rung stays capped and that question stays with the judge. + 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 @@ -186,4 +195,5 @@ Evidence: [family detection census](../../../benchmarks/measurements/2026-09-19- [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), -[collection read obligation](../../../benchmarks/measurements/2026-09-20-collection-read-obligation.json). +[collection read obligation](../../../benchmarks/measurements/2026-09-20-collection-read-obligation.json), +[sibling scope and identity](../../../benchmarks/measurements/2026-09-20-sibling-scope-and-identity.json). diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index fc03dc8..3543d32 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -510,11 +510,14 @@ Preserve all completed task evidence below. - _Boundary: contributor-scan dominance, Ranker Scope Contract_ - _Depends: 14.1_ - _Requirements: 6.2, 6.3, 8.1_ -- [ ] 14.7 Decide the sibling scope, and see identity that a destructuring spelled away +- [x] 14.7 Decide the sibling scope, and see identity that a destructuring spelled away - **Named by the 14.4 measurement.** Two of NodeGoat's three corroborated claims are correct code: `const { userId } = req.session` followed by `dao.getByUserId(userId, ...)` is identity-constrained, and the destructuring erases the `session.userId` spelling the token list holds, so the guard is invisible at the call. A judge would be asked about handlers that are not defects. - The contradiction that would make NodeGoat's IDOR entailed is cross-file: `allocations` passes a URL parameter while `contributions` and `profile` pass session identity. The sibling set is one file, and widening it is a decision, not an implementation detail: measured, a route file mixes public with authenticated handlers, so application-wide siblings would contradict every login and signup handler there is. - Record the measured cost of each candidate before choosing, and do not widen the scope to make a finding appear. - Done when one record states the candidates and the choice, a destructured identity read discharges the obligation it actually discharges, and no public route is reported. + - _Verified 2026-09-20:_ 1,415 tests passed, sixteen skipped; thirteen Joern-gated query tests pass in the image; Ruff/format/mypy passed. A destructured identity read now discharges what it reaches, through a bounded three-hop walk back over the method's own assignments from the names the operation is GIVEN. `req.session` joins the identity sources and is only safe because of that requirement: what discharges an object-level obligation is that the value PASSED to the operation comes from the authenticated context, not that a session is mentioned nearby. On NodeGoat `displayContributions` and `displayProfile` go silent, which is correct code no longer put to a judge, and VAmPI is unchanged. + - _Two exclusions the measurement forced, each caught by a page whose whole purpose is the leak it demonstrates._ Taking identifiers from the whole argument subtree credited NodeGoat's memo listing with a discharge, because `memosDAO.getAllMemos((err, docs) => ... userId ...)` reads every memo and only RENDERS the session's user; a callback is not a constraint, so method references are excluded. Then the session read still dominated the query and the dominating-guard clause credited that instead; an assignment is a read of the authenticated context rather than a check applied to it, so operator calls are excluded from that clause, which is the same exclusion the sink matcher needed. The leak stays reported. + - _The sibling scope is decided by measurement, and the choice is to keep the file._ On NodeGoat the region's own file entails two handlers, both public routes and both false, and no true defect at all. Every handler in the application entails six: four true, and the same two public routes. Both scopes carry the same false positives, because neither can tell a public route from a protected one — which is a semantic question and already the corroborated band's residual. With the carried rung capped at corroborated the scope changes no verdict, only the witness, so widening it would buy a repository-wide walk per question and no precision. What would change the decision is a discriminator for a public route; with one, the wider scope is worth four true entailments on this subject. Evidence: `benchmarks/measurements/2026-09-20-sibling-scope-and-identity.json`. - _Boundary: contributor-scan dominance, shipped obligation facts_ - _Depends: 14.4_ - _Requirements: 6.2, 6.3, 8.1_ diff --git a/benchmarks/measurements/2026-09-20-sibling-scope-and-identity.json b/benchmarks/measurements/2026-09-20-sibling-scope-and-identity.json new file mode 100644 index 0000000..960c45b --- /dev/null +++ b/benchmarks/measurements/2026-09-20-sibling-scope-and-identity.json @@ -0,0 +1,172 @@ +{ + "schema_version": 1, + "experiment": "sibling-scope-and-destructured-identity-v1", + "date": "2026-09-20", + "task": "pre-push-safety-net 14.7", + "claim_scope": "A decision about the sibling scope, and one repair to what counts as an identity constraint. Measured on NodeGoat and VAmPI. Detection only. No capability qualified.", + "part_one_identity_through_destructuring": { + "problem": "`const { userId } = req.session` lowers to `_tmp_1 = req.session` then `userId = _tmp_1.userId`, so nothing in the graph spells `session.userId` by the time the value reaches `dao.getByUserId(userId, ...)`. Two of NodeGoat's three corroborated claims were correct code.", + "repair": "A bounded walk, three hops, back over the method's own assignments from the names the operation is GIVEN. What discharges an object-level obligation is that the value passed to the operation comes from the authenticated context, not that the session is mentioned nearby. `req.session` joins the identity sources, and is only safe because of that requirement.", + "two_exclusions_the_measurement_forced": { + "a_callback_is_not_a_constraint": "Taking identifiers from the whole argument subtree credited NodeGoat's memo listing with a discharge: `memosDAO.getAllMemos((err, docs) => ... userId ...)` reads every memo in the collection and only RENDERS the session's user. That leak is the point of the page. Argument names now exclude method references.", + "an_assignment_is_not_a_dominating_guard": "The session read dominates the query, so the dominating-guard clause credited it and the memo listing went quiet again. An assignment is a READ of the authenticated context, not a check applied to it, so operator calls are excluded from that clause -- the same exclusion the sink matcher needed. What a session read does discharge is decided by the walk alone." + }, + "measured_on_nodegoat": { + "handlers_asked": 15, + "handlers_with_an_obligation": 9, + "corroborated_before": 7, + "corroborated_after": 6, + "now_silent_and_correct": { + "displayContributions": "destructures req.session and passes it to the query", + "displayProfile": "same, through parseInt" + }, + "still_reported_and_genuine": { + "displayAllocations": "the documented insecure direct object reference, userId from req.params", + "displayBenefits and updateBenefits": "the admin pages whose isAdmin guard is commented out", + "displayMemos": "reads every memo in the collection and renders the session's user" + }, + "newly_corroborated_and_honest": { + "handleLoginRequest": "was quiet only because a callback's text matched a discharger token; login is public by design and is now an open question rather than an accidental pass", + "handleSignup": "public by design, the same open question" + }, + "per_handler": { + "app/routes/benefits.js:displayBenefits": { + "rows": 2, + "verdict": "model_corroborated", + "witness": "displayBenefits line 15 calls benefitsDAO.getAllNonAdminUsers((error, users) => { if (error) return next(error); return res., whose operation at app/data/benefits-dao.js:16: obligated operation with no discharging guard, and no guarded sibling", + "before_identity_walk": "model_corroborated" + }, + "app/routes/benefits.js:updateBenefits": { + "rows": 2, + "verdict": "model_corroborated", + "witness": "updateBenefits line 39 calls benefitsDAO.getAllNonAdminUsers((error, users) => { if (error) return next(error); cons, whose operation at app/data/benefits-dao.js:16: obligated operation with no discharging guard, and no guarded sibling", + "before_identity_walk": "model_corroborated" + }, + "app/routes/allocations.js:displayAllocations": { + "rows": 1, + "verdict": "model_corroborated", + "witness": "displayAllocations line 23 calls allocationsDAO.getByUserIdAndThreshold(userId, threshold, (err, allocations) => { if (err) return next(err);, whose operation at app/data/allocations-dao.js:86: obligated operation with no discharging guard, and no guarded sibling", + "before_identity_walk": "model_corroborated" + }, + "app/routes/memos.js:addMemos": { + "rows": 1, + "verdict": "none", + "witness": "", + "before_identity_walk": "none" + }, + "app/routes/memos.js:displayMemos": { + "rows": 1, + "verdict": "model_corroborated", + "witness": "displayMemos line 25 calls memosDAO.getAllMemos((err, docs) => { if (err) return next(err); return res.render(\"memos\", {, whose operation at app/data/memos-dao.js:28: obligated operation with no discharging guard, and no guarded sibling", + "before_identity_walk": "model_corroborated" + }, + "app/routes/research.js:displayResearch": { + "rows": 0, + "verdict": "none", + "witness": "", + "before_identity_walk": "none" + }, + "app/routes/contributions.js:displayContributions": { + "rows": 1, + "verdict": "none", + "witness": "", + "before_identity_walk": "model_corroborated" + }, + "app/routes/contributions.js:handleContributionsUpdate": { + "rows": 1, + "verdict": "none", + "witness": "", + "before_identity_walk": "none" + }, + "app/routes/session.js:displayLoginPage": { + "rows": 4, + "verdict": "none", + "witness": "", + "before_identity_walk": "none" + }, + "app/routes/session.js:handleLoginRequest": { + "rows": 4, + "verdict": "model_corroborated", + "witness": "handleLoginRequest line 58 calls userDAO.validateLogin(userName, password, (err, user) => { const errorMessage = \"Invalid username and/or pas, whose operation at app/data/user-dao.js:91: no discharging guard, while 2 sibling handler(s) reaching the same module are guarded (16, 2); the obligation was carried across a module boundary, so those siblings need not share this route's trust context", + "before_identity_walk": "none" + }, + "app/routes/session.js:displaySignupPage": { + "rows": 4, + "verdict": "none", + "witness": "", + "before_identity_walk": "none" + }, + "app/routes/session.js:handleSignup": { + "rows": 4, + "verdict": "model_corroborated", + "witness": "handleSignup line 208 calls userDAO.getUserByUserName(userName, (err, user) => { if (err) return next(err); if (us, whose operation at app/data/user-dao.js:104: no discharging guard, while 2 sibling handler(s) reaching the same module are guarded (16, 2); the obligation was carried across a module boundary, so those siblings need not share this route's trust context", + "before_identity_walk": "model_corroborated" + }, + "app/routes/session.js:displayWelcomePage": { + "rows": 4, + "verdict": "none", + "witness": "", + "before_identity_walk": "none" + }, + "app/routes/profile.js:displayProfile": { + "rows": 1, + "verdict": "none", + "witness": "", + "before_identity_walk": "model_corroborated" + }, + "app/routes/profile.js:handleProfileUpdate": { + "rows": 1, + "verdict": "none", + "witness": "", + "before_identity_walk": "none" + } + } + }, + "measured_on_vampi": { + "access_control_findings": 2, + "unchanged": true + } + }, + "part_two_the_sibling_scope_decision": { + "question": "Should a carried obligation compare against siblings beyond the region's own file?", + "candidates_measured_on_nodegoat": [ + { + "id": "a", + "scope": "the region's own file (committed)", + "entailed": 2, + "true_defects": 0, + "public_route_false_positives": 2, + "which": [ + "handleLoginRequest", + "handleSignup" + ] + }, + { + "id": "b", + "scope": "every handler in the application", + "entailed": 6, + "true_defects": 4, + "public_route_false_positives": 2, + "which_true": [ + "displayAllocations", + "displayBenefits", + "updateBenefits", + "displayMemos" + ], + "which_false": [ + "handleLoginRequest", + "handleSignup" + ] + } + ], + "decision": "Keep the region's own file, and keep the rung capped at corroborated for a carried obligation. Both scopes carry the SAME two public-route false positives, because neither can tell a public route from a protected one -- that is a semantic question and it is already the corroborated band's residual. With the cap in place the scope changes no verdict at all, only the witness text, so widening it would buy a repository-wide walk per question and no precision.", + "what_would_change_the_decision": "A discriminator for a public route. With one, scope (b) is worth four true entailments on this subject; without one it reports every login and signup handler there is. The file scope on its own entails nothing true, so it is not the thing holding the family back.", + "note_on_the_file_scope_being_kept": "It is kept because widening buys nothing today, not because it is right. It entails only the two false positives, which is the clearest argument yet for the cap that suppresses them." + }, + "limits": [ + "The walk is three hops over one method's assignments, not a dataflow query: a value that reaches the operation through a helper is not followed.", + "`req.session` as an identity source is only sound because the value must reach the operation's arguments; loosening that would clear every handler that touches a session.", + "Public versus protected is not decided here and cannot be, which is why the corroborated band keeps it.", + "One application shape, on a subject used to develop this analyser." + ] +} diff --git a/src/openultrasast/cpg/queries/dominance.sc b/src/openultrasast/cpg/queries/dominance.sc index e958a72..34fb0ef 100644 --- a/src/openultrasast/cpg/queries/dominance.sc +++ b/src/openultrasast/cpg/queries/dominance.sc @@ -285,6 +285,39 @@ ujson.Obj("kind" -> "context_summary") :: (rows ++ carriedRows ++ boundaries) } + // ---- The identity a destructuring spelled away ------------------------------------------------------- + // + // `const { userId } = req.session` lowers to `_tmp_1 = req.session` and then `userId = _tmp_1.userId`, so by + // the time the value reaches `dao.getByUserId(userId, ...)` nothing in the graph spells `session.userId` and + // the constraint the handler really applies is invisible. Measured on NodeGoat: two of three corroborated + // claims were this, which is correct code being put to a judge. + // + // Resolved by walking the method's OWN assignments back from the names the operation is given, rather than + // by accepting the token anywhere in the handler. What discharges an object-level obligation is that the + // value PASSED to the operation comes from the authenticated context; a handler that reads the session for + // logging and keys its query on a path parameter is the bug this family exists for, and it stays unguarded. + // Bounded at three hops, which is two more than the lowering needs and far short of a dataflow query. + def definingText( + method: io.shiftleft.codepropertygraph.generated.nodes.Method, + names: Set[String], + hops: Int + ): List[String] = { + val assignments = method.ast.isCall.nameExact(".assignment").l + var seen = names + var frontier = names + var texts = List.empty[String] + var level = 0 + while (level < hops && frontier.nonEmpty) { + val defining = assignments.filter(a => frontier.exists(n => a.code.startsWith(n + " ="))) + texts = texts ++ defining.map(_.code) + val next = defining.flatMap(_.argument.argumentIndexGt(1).ast.isIdentifier.name.l).toSet -- seen + seen = seen ++ next + frontier = next + level += 1 + } + texts.distinct + } + // `via` is the call that carried the obligation here, absent when the operation is in the region's own file. def rowFor(op: io.shiftleft.codepropertygraph.generated.nodes.Call, via: Option[io.shiftleft.codepropertygraph.generated.nodes.Call]): ujson.Obj = { val method = op.method @@ -299,7 +332,14 @@ val inCondition = method.controlStructure.condition.code.l.filter(c => mentions(c, applicable)) // 3. dominating guard: a call naming a discharger that actually dominates the operation. - val dominating = op.dominatedBy.isCall.code.l.filter(c => mentions(c, applicable)) + // + // A CALL, so operators are excluded, and that exclusion is load-bearing rather than tidy. `const { userId } + // = req.session` lowers to an assignment, and an assignment that dominates the query is a READ of the + // authenticated context, not a check applied to it. Counting it discharged NodeGoat's memo listing, which + // reads every memo in the collection and only renders the session's user -- the leak that page exists to + // demonstrate. What a session read does discharge is decided by clause 5, where the value has to reach the + // operation's own arguments. + val dominating = op.dominatedBy.isCall.filterNot(_.name.startsWith(" mentions(c, applicable)) // 4. a guard at the CARRIER. When the obligation crossed a module boundary the check that discharges it is // overwhelmingly on this side of it: the handler establishes who is asking and passes the result down. @@ -307,10 +347,20 @@ val atCarrier = via.toList.flatMap { call => call.argument.code.l.filter(c => mentions(c, applicable)) ++ call.method.controlStructure.condition.code.l.filter(c => mentions(c, applicable)) ++ - call.dominatedBy.isCall.code.l.filter(c => mentions(c, applicable)) + call.dominatedBy.isCall.filterNot(_.name.startsWith(" mentions(c, applicable)) } - val guards = (inArguments ++ inCondition ++ dominating ++ atCarrier).distinct.map(_.take(120)) + // 5. identity that reaches the operation's arguments through a local, which is the shape a destructuring + // leaves behind. Asked at the site the arguments are written: the carrier when there is one. + val site = via.getOrElse(op) + // A CALLBACK is not a constraint, so its body's names are not the operation's arguments. NodeGoat's memo + // listing is the measurement: `memosDAO.getAllMemos((err, docs) => ... userId ...)` reads every memo in the + // collection and only RENDERS the session's user, and taking identifiers from the whole argument subtree + // credited that render with discharging the obligation. The leak is the point of that page. + val argumentNames = site.argument.argumentIndexGt(0).filterNot(_.label == "METHOD_REF").ast.isIdentifier.name.l.toSet + val throughLocals = definingText(site.method, argumentNames, 3).filter(c => mentions(c, applicable)) + + val guards = (inArguments ++ inCondition ++ dominating ++ atCarrier ++ throughLocals).distinct.map(_.take(120)) val attributed = via.map(call => attributedName(call.method)).getOrElse(attributedName(method)) ujson.Obj( diff --git a/src/openultrasast/ruleset/obligations/javascript.toml b/src/openultrasast/ruleset/obligations/javascript.toml index f554dfe..700a7dd 100644 --- a/src/openultrasast/ruleset/obligations/javascript.toml +++ b/src/openultrasast/ruleset/obligations/javascript.toml @@ -75,10 +75,15 @@ kind = "path_guard" calls = ["authenticate", "requireAuth", "ensureAuthenticated", "isAuthenticated", "verifyToken", "checkAuth", "protect", "authorize", "requireUser", "getServerSession", "auth", "withAuth", "jwt.verify", "passport.authenticate"] decorators = ["UseGuards", "Authorized", "Roles"] +# `req.session` is the authenticated context itself, and it is listed because a destructuring spells the +# dotted forms away: `const { userId } = req.session` lowers to a temporary, so nothing in the graph says +# `session.userId` by the time the value reaches the query. The token is only safe because the arbiter +# requires the value to REACH the operation's arguments -- a handler that reads the session for logging and +# keys its query on a path parameter is the bug this family exists for, and it stays unguarded. [[discharger]] id = "identity-from-context" kind = "identity_constraint" -identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "getServerSession("] +identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "req.session", "request.session", "getServerSession("] constraint_params = ["userId", "user_id", "ownerId", "owner_id", "authorId", "author_id", "accountId", "tenantId", "createdBy", "user", "owner"] request_sources = ["req.body", "req.query", "req.params", "request.body", "request.query", "req.headers", "req.cookies", "searchParams", "formData("] diff --git a/src/openultrasast/ruleset/obligations/typescript.toml b/src/openultrasast/ruleset/obligations/typescript.toml index 1cf6d41..d7e0757 100644 --- a/src/openultrasast/ruleset/obligations/typescript.toml +++ b/src/openultrasast/ruleset/obligations/typescript.toml @@ -75,10 +75,15 @@ kind = "path_guard" calls = ["authenticate", "requireAuth", "ensureAuthenticated", "isAuthenticated", "verifyToken", "checkAuth", "protect", "authorize", "requireUser", "getServerSession", "auth", "withAuth", "jwt.verify", "passport.authenticate"] decorators = ["UseGuards", "Authorized", "Roles"] +# `req.session` is the authenticated context itself, and it is listed because a destructuring spells the +# dotted forms away: `const { userId } = req.session` lowers to a temporary, so nothing in the graph says +# `session.userId` by the time the value reaches the query. The token is only safe because the arbiter +# requires the value to REACH the operation's arguments -- a handler that reads the session for logging and +# keys its query on a path parameter is the bug this family exists for, and it stays unguarded. [[discharger]] id = "identity-from-context" kind = "identity_constraint" -identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "getServerSession("] +identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "req.session", "request.session", "getServerSession("] constraint_params = ["userId", "user_id", "ownerId", "owner_id", "authorId", "author_id", "accountId", "tenantId", "createdBy", "user", "owner"] request_sources = ["req.body", "req.query", "req.params", "request.body", "request.query", "req.headers", "req.cookies", "searchParams", "formData("] diff --git a/tests/test_cpg_queries_compile.py b/tests/test_cpg_queries_compile.py index 54c990a..d17878a 100644 --- a/tests/test_cpg_queries_compile.py +++ b/tests/test_cpg_queries_compile.py @@ -324,6 +324,16 @@ def _route_and_dao(tmp_path: Path) -> Path: " this.displayMine = (req, res) => {\n" " return notesDAO.getOwned(req.user.id, (err, notes) => res.json(notes));\n" " };\n" + "\n" + " this.displayDestructured = (req, res) => {\n" + " const { userId } = req.session;\n" + " return notesDAO.getOwned(userId, (err, notes) => res.json(notes));\n" + " };\n" + "\n" + " this.displayEverything = (req, res) => {\n" + " const { userId } = req.session;\n" + " return notesDAO.getAll((err, notes) => res.json({ userId, notes }));\n" + " };\n" "}\n" "\n" "module.exports = NotesHandler;\n" @@ -339,6 +349,10 @@ def _route_and_dao(tmp_path: Path) -> Path: " this.getOwned = (userId, callback) => {\n" " notes.findOne({ owner: userId }, callback);\n" " };\n" + "\n" + " this.getAll = (callback) => {\n" + " notes.find({}).toArray(callback);\n" + " };\n" "}\n" "\n" "module.exports.NotesDAO = NotesDAO;\n" @@ -471,3 +485,34 @@ def test_only_a_collection_read_is_an_obligated_find(tmp_path: Path) -> None: 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]}" + + +@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine") +def test_a_destructured_identity_discharges_what_it_reaches(tmp_path: Path) -> None: + """Reading the session discharges the query it constrains, and nothing else. + + `const { userId } = req.session` lowers to a temporary, so nothing in the graph spells `session.userId` by + the time the value reaches the query, and two of NodeGoat's corroborated claims were correct code put to a + judge for that reason. The second half is the measurement that keeps it honest: NodeGoat's memo listing + reads the same session and RENDERS it while its query reads every memo in the collection, which is the leak + that page exists to demonstrate. So the value has to reach the operation's own arguments. + """ + from openultrasast.cpg.backend import CpgResult + from openultrasast.model.dominance import verdict + from openultrasast.model.ladder import Rung + from openultrasast.model.specs import dominance_specs + + root = _route_and_dao(tmp_path) + spec = dominance_specs(language="javascript")["access_control"] + + def answer(function: str): # type: ignore[no-untyped-def] + rows = _dominance_rows(root, function=function, file="app/routes/notes.js") + cpg = CpgResult(cpg_path=tmp_path / "cpg.bin", run=lambda q, p: rows) + return verdict(cpg, spec, function=function, file="app/routes/notes.js") + + constrained = answer("displayDestructured") + assert constrained is None, f"a query constrained by the session's own user was reported: {constrained}" + + leaked = answer("displayEverything") + assert leaked is not None, "a query that reads the whole collection was not reported" + assert leaked.rung is Rung.CORROBORATED, f"{leaked.rung} from {leaked.witness}"