mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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
182 lines
8.6 KiB
JSON
182 lines
8.6 KiB
JSON
{
|
|
"schema_version": 1,
|
|
"experiment": "collection-read-obligation-v1",
|
|
"date": "2026-09-20",
|
|
"task": "pre-push-safety-net 14.8",
|
|
"claim_scope": "One operation added to the shipped obligation vocabulary, with an argument-shape condition, measured on two repositories. Detection only. No capability qualified.",
|
|
"question": "Can the commonest collection read carry the access-control obligation without claiming every array search and jQuery selector?",
|
|
"why_it_was_missing": "`find` was absent from a table holding findOne, findById, findAll and findByPk, so NodeGoat's documented insecure direct object reference raised no obligation at all: its module reads with allocationsCol.find(searchCriteria()). The name is shared with Array.prototype.find and jQuery's `.find`, so the name alone cannot carry an obligation.",
|
|
"discriminator": {
|
|
"rule": "the obligation is claimed only where the first argument has the shape of a query document",
|
|
"claimed": {
|
|
"no arguments": "col.find() reads the whole collection, the obligation at its widest",
|
|
"BLOCK": "an object literal, which is how jssrc2cpg gives { owner: 1 }",
|
|
"CALL that is not an operator": "a builder, col.find(searchCriteria())"
|
|
},
|
|
"not_claimed": {
|
|
"METHOD_REF": "a predicate passed to an array search, inline or a function expression",
|
|
"LITERAL": "a jQuery selector",
|
|
"CALL that is an operator": "a selector built at run time, <operator>.formatString or <operator>.addition",
|
|
"IDENTIFIER": "deliberately not claimed. col.find(query) and arr.find(pred) are the same shape, and neither repository measured has one, so this is a stated false negative rather than an unmeasured risk."
|
|
},
|
|
"where_it_is_declared": "query_document_arg on the operation fact; the arbiter that can see argument shapes decides it"
|
|
},
|
|
"false_positive_measurement": {
|
|
"subject": "wp-statistics assets, a WordPress plugin that ships jQuery",
|
|
"graph": {
|
|
"files": 60,
|
|
"methods": 4552
|
|
},
|
|
"find_calls": 88,
|
|
"by_shape": {
|
|
"LITERAL": 70,
|
|
"METHOD_REF": 13,
|
|
"CALL (operator, a run-time selector)": 5,
|
|
"BLOCK": 0,
|
|
"no-args": 0
|
|
},
|
|
"operations_matched_in_the_whole_graph": 0,
|
|
"note": "Zero is a measured zero, not an empty graph: the census on the same run reports 60 files and 4,552 methods, and the files holding those 88 calls are in it."
|
|
},
|
|
"recall_measurement": {
|
|
"subject": "NodeGoat",
|
|
"find_calls": 3,
|
|
"by_shape": {
|
|
"BLOCK": 2,
|
|
"CALL (a builder)": 1
|
|
},
|
|
"operations_matched": 3,
|
|
"handlers_now_raising_an_obligation": 14,
|
|
"handlers_reaching_a_verdict": 7,
|
|
"newly_raised": {
|
|
"app/routes/allocations.js:displayAllocations": "The documented insecure direct object reference. It takes userId from req.params on an authenticated route, with the fix commented out, and raised nothing before this change at any point in the pipeline.",
|
|
"app/routes/benefits.js:displayBenefits and updateBenefits": "the admin page whose isAdmin guard is commented out in the route table",
|
|
"app/routes/memos.js:displayMemos": "reads every memo in the collection"
|
|
},
|
|
"verdicts": {
|
|
"none": 8,
|
|
"model_corroborated": 7
|
|
},
|
|
"reported_without_a_judge": 0,
|
|
"per_handler": {
|
|
"app/routes/benefits.js:displayBenefits": {
|
|
"rows": 2,
|
|
"carried": 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"
|
|
},
|
|
"app/routes/benefits.js:updateBenefits": {
|
|
"rows": 2,
|
|
"carried": 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"
|
|
},
|
|
"app/routes/allocations.js:displayAllocations": {
|
|
"rows": 1,
|
|
"carried": 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"
|
|
},
|
|
"app/routes/memos.js:addMemos": {
|
|
"rows": 1,
|
|
"carried": 1,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
},
|
|
"app/routes/memos.js:displayMemos": {
|
|
"rows": 1,
|
|
"carried": 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"
|
|
},
|
|
"app/routes/research.js:displayResearch": {
|
|
"rows": 0,
|
|
"carried": 0,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
},
|
|
"app/routes/contributions.js:displayContributions": {
|
|
"rows": 1,
|
|
"carried": 1,
|
|
"verdict": "model_corroborated",
|
|
"witness": "displayContributions line 17 calls contributionsDAO.getByUserId(userId, (error, contrib) => { if (error) return next(error); contr, whose operation at app/data/contributions-dao.js:57: obligated operation with no discharging guard, and no guarded sibling"
|
|
},
|
|
"app/routes/contributions.js:handleContributionsUpdate": {
|
|
"rows": 1,
|
|
"carried": 1,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
},
|
|
"app/routes/session.js:displayLoginPage": {
|
|
"rows": 4,
|
|
"carried": 4,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
},
|
|
"app/routes/session.js:handleLoginRequest": {
|
|
"rows": 4,
|
|
"carried": 4,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
},
|
|
"app/routes/session.js:displaySignupPage": {
|
|
"rows": 4,
|
|
"carried": 4,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
},
|
|
"app/routes/session.js:handleSignup": {
|
|
"rows": 4,
|
|
"carried": 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 3 sibling handler(s) reaching the same module are guarded (<lambda>16, <lambda>2, <lambda>6); the obligation was carried across a module boundary, so those siblings need not share this route's trust context"
|
|
},
|
|
"app/routes/session.js:displayWelcomePage": {
|
|
"rows": 4,
|
|
"carried": 4,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
},
|
|
"app/routes/profile.js:displayProfile": {
|
|
"rows": 1,
|
|
"carried": 1,
|
|
"verdict": "model_corroborated",
|
|
"witness": "displayProfile line 20 calls profile.getByUserId(parseInt(userId), (err, doc) => { if (err) return next(err); doc.userId = us, whose operation at app/data/profile-dao.js:95: obligated operation with no discharging guard, and no guarded sibling"
|
|
},
|
|
"app/routes/profile.js:handleProfileUpdate": {
|
|
"rows": 1,
|
|
"carried": 1,
|
|
"verdict": "none",
|
|
"witness": ""
|
|
}
|
|
}
|
|
},
|
|
"census_after": {
|
|
"nodegoat": {
|
|
"access_control": 0,
|
|
"config_secrets": 1,
|
|
"injection": 3,
|
|
"output_encoding": 1,
|
|
"path": 0,
|
|
"prototype": 0,
|
|
"untrusted_destination": 2
|
|
},
|
|
"vampi": {
|
|
"access_control": 2,
|
|
"config_secrets": 1,
|
|
"deserialization": 0,
|
|
"injection": 1,
|
|
"output_encoding": 0,
|
|
"path": 0,
|
|
"untrusted_destination": 0
|
|
},
|
|
"note": "Unchanged on both. Every new obligation is corroborated, and the corroborated band needs a judge the census does not configure."
|
|
},
|
|
"limits": [
|
|
"A query document passed as a bare variable is not claimed, which is a false negative this change chooses over an unmeasured false positive.",
|
|
"One jQuery repository measured. A bundle that calls find with an object literal for something other than a collection would be claimed.",
|
|
"The obligation being raised is not a finding: all seven NodeGoat verdicts are corroborated and none is reported without a judge.",
|
|
"NodeGoat and wp-statistics were both used to develop this analyser."
|
|
]
|
|
}
|