mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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:
co-authored by
Claude Opus 5
parent
102a21fc4e
commit
c0427e6ceb
@@ -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).
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"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."
|
||||
]
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
file: String = "",
|
||||
operationRequires: String = "",
|
||||
dischargersByKind: String = "",
|
||||
documentShape: String = "",
|
||||
requests: String = "",
|
||||
requestsFile: String = "",
|
||||
contextEvidence: String = ""
|
||||
@@ -68,6 +69,7 @@
|
||||
fileS: String,
|
||||
requiresS: String,
|
||||
byKindS: String,
|
||||
shapeS: String,
|
||||
contextS: String = ""
|
||||
): List[ujson.Obj] = {
|
||||
|
||||
@@ -122,6 +124,38 @@
|
||||
// first repository measured came from exactly that, including three handlers that do call an
|
||||
// authorization guard. A qualified token means the qualification matters, so the receiver must appear
|
||||
// too; the desugared chain still carries it.
|
||||
// Calls whose obligation depends on the ARGUMENT rather than the name. `find` is the commonest collection
|
||||
// read there is, and also `Array.prototype.find` and jQuery's `.find`, so the name alone cannot carry it.
|
||||
//
|
||||
// Measured on one WordPress plugin's shipped assets, where 88 calls are named `find`:
|
||||
//
|
||||
// 70 a jQuery selector `this.notice.find('.processed')` LITERAL
|
||||
// 13 an array predicate `datasets.find(ds => ds.label === x)` METHOD_REF
|
||||
// 5 a selector built at run time ``el.find(`option[value="${v}"]`)`` CALL, an operator
|
||||
// 0 a collection read
|
||||
//
|
||||
// and on NodeGoat, where all three `find` calls are collection reads: two object literals and one builder
|
||||
// call. So the shapes separate cleanly, and each exclusion is a measured class rather than a guess:
|
||||
// a method reference is a predicate, a literal is a selector, and an operator call is a string being built.
|
||||
// A bare IDENTIFIER is deliberately NOT claimed -- `col.find(query)` and `arr.find(pred)` are the same shape
|
||||
// and neither repository measured has one, so it is a stated false negative rather than an unmeasured risk.
|
||||
val shapeNames = split(shapeS)
|
||||
|
||||
def isQueryDocument(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean = {
|
||||
val real = c.argument.argumentIndexGt(0).l
|
||||
real.headOption match {
|
||||
case None => true // `col.find()` reads the whole collection, which is the obligation at its widest
|
||||
case Some(node) =>
|
||||
node.label match {
|
||||
case "METHOD_REF" => false
|
||||
case "LITERAL" => false
|
||||
case "IDENTIFIER" => false
|
||||
case "CALL" => !node.asInstanceOf[io.shiftleft.codepropertygraph.generated.nodes.Call].name.startsWith("<operator")
|
||||
case _ => true // an object literal, which jssrc2cpg gives as a BLOCK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def matchesOperation(name: String, code: String): Boolean =
|
||||
opNames.exists { n =>
|
||||
if (name == n) true
|
||||
@@ -131,11 +165,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
def isOperation(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean =
|
||||
matchesOperation(c.name, c.code) && (!shapeNames.contains(c.name) || isQueryDocument(c))
|
||||
|
||||
// Scoped to the region's own file. `delete_user` exists in both the API handler and the model of the
|
||||
// repository this was measured on, and unscoped rows attributed the model's unguarded query to the
|
||||
// handler. The sibling comparison is a statement about a FILE contradicting itself, which is what the
|
||||
// rung's justification says, so the file is also the right scope for it.
|
||||
val allOperations = cpg.call.filter(c => matchesOperation(c.name, c.code)).l
|
||||
val allOperations = cpg.call.filter(isOperation).l
|
||||
val opCalls = if (fileS.isEmpty) allOperations else allOperations.filter(_.method.filename.endsWith(fileS))
|
||||
|
||||
// ---- The module boundary an application puts between its route and its query --------------------------
|
||||
@@ -310,6 +347,7 @@
|
||||
field("file"),
|
||||
field("operationRequires"),
|
||||
field("dischargersByKind"),
|
||||
if (field("documentShape").nonEmpty) field("documentShape") else documentShape,
|
||||
field("contextEvidence")
|
||||
): _*
|
||||
)
|
||||
@@ -322,7 +360,7 @@
|
||||
val census = ujson.Arr(ujson.Obj("methods" -> cpg.method.size.toString, "files" -> cpg.file.size.toString, "file_names" -> ujson.Arr.from(cpg.file.name.l)))
|
||||
println(ujson.write(ujson.Obj.from(answers.toSeq :+ ("__census__" -> census))))
|
||||
} else {
|
||||
println(ujson.write(ujson.Arr(rowsFor(operations, dischargers, function, file, operationRequires, dischargersByKind, contextEvidence): _*)))
|
||||
println(ujson.write(ujson.Arr(rowsFor(operations, dischargers, function, file, operationRequires, dischargersByKind, documentShape, contextEvidence): _*)))
|
||||
}
|
||||
println("---OUSAST-CPG-END---")
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ def request_params(spec: DominanceSpec, *, function: str = "", file: str = "") -
|
||||
"dischargers": spec.dischargers,
|
||||
"function": function,
|
||||
"file": file,
|
||||
# The calls whose obligation depends on the argument's shape rather than on the name alone.
|
||||
"documentShape": ",".join(spec.document_shape),
|
||||
"operationRequires": json.dumps({k: list(v) for k, v in spec.requirements.items()}, sort_keys=True) if spec.requirements else "",
|
||||
"dischargersByKind": json.dumps({k: list(v) for k, v in spec.dischargers_by_kind.items()}, sort_keys=True)
|
||||
if spec.dischargers_by_kind
|
||||
|
||||
@@ -154,6 +154,10 @@ class DominanceSpec:
|
||||
dischargers: tuple[str, ...]
|
||||
requirements: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
dischargers_by_kind: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
# Operations whose obligation depends on the ARGUMENT, not only the name. `find` is a collection read, an
|
||||
# array search and a jQuery selector, and only the first is obligated; the facts say which argument has to
|
||||
# look like a query document and the query decides whether it does.
|
||||
document_shape: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -318,6 +322,7 @@ def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> M
|
||||
dischargers=dischargers,
|
||||
requirements=requirements,
|
||||
dischargers_by_kind=by_kind,
|
||||
document_shape=tuple(sorted({call for fact in guarded if fact.query_document_arg for call in fact.calls})),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,23 @@ calls = ["findOne", "findById", "findMany", "findFirst", "findUnique", "findAll"
|
||||
requires = ["identity_constraint", "ownership_check"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
# CWE-639's commonest read, and the one the family was missing. NodeGoat's insecure direct object reference
|
||||
# reads with `allocationsCol.find(searchCriteria())`, and `find` was absent from a table holding `findOne`,
|
||||
# `findById` and `findAll` -- the obligation missing at exactly the shape it exists for.
|
||||
#
|
||||
# It was absent for a reason, and the reason is the argument, not the name. Measured on one WordPress plugin's
|
||||
# shipped assets, 88 calls are named `find`: 70 pass a jQuery selector, 13 pass a predicate to
|
||||
# `Array.prototype.find`, and 5 pass a template literal or a concatenation, which is a selector built at
|
||||
# runtime. None is a collection read. So this operation is claimed only where the first argument has the shape
|
||||
# of a query document, which `query_document_arg` says and the arbiter decides.
|
||||
id = "collection-read"
|
||||
kind = "protected_read"
|
||||
calls = ["find"]
|
||||
query_document_arg = 1
|
||||
requires = ["identity_constraint", "ownership_check"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
id = "orm-write"
|
||||
kind = "protected_write"
|
||||
|
||||
@@ -9,6 +9,23 @@ calls = ["findOne", "findById", "findMany", "findFirst", "findUnique", "findAll"
|
||||
requires = ["identity_constraint", "ownership_check"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
# CWE-639's commonest read, and the one the family was missing. NodeGoat's insecure direct object reference
|
||||
# reads with `allocationsCol.find(searchCriteria())`, and `find` was absent from a table holding `findOne`,
|
||||
# `findById` and `findAll` -- the obligation missing at exactly the shape it exists for.
|
||||
#
|
||||
# It was absent for a reason, and the reason is the argument, not the name. Measured on one WordPress plugin's
|
||||
# shipped assets, 88 calls are named `find`: 70 pass a jQuery selector, 13 pass a predicate to
|
||||
# `Array.prototype.find`, and 5 pass a template literal or a concatenation, which is a selector built at
|
||||
# runtime. None is a collection read. So this operation is claimed only where the first argument has the shape
|
||||
# of a query document, which `query_document_arg` says and the arbiter decides.
|
||||
id = "collection-read"
|
||||
kind = "protected_read"
|
||||
calls = ["find"]
|
||||
query_document_arg = 1
|
||||
requires = ["identity_constraint", "ownership_check"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
id = "orm-write"
|
||||
kind = "protected_write"
|
||||
|
||||
@@ -18,7 +18,7 @@ SENSITIVITIES = ("low", "medium", "high")
|
||||
DEFAULT_OBLIGATION_FACTS_DIR = Path(__file__).resolve().parents[2] / "ruleset" / "obligations"
|
||||
_LANGUAGE_ALIASES = {"c_cpp": "c", "cpp": "c", "python_web": "python", "node": "javascript", "ts": "typescript"}
|
||||
|
||||
_OPERATION_FIELDS = frozenset({"id", "kind", "calls", "resource_arg", "requires", "sensitivity"})
|
||||
_OPERATION_FIELDS = frozenset({"id", "kind", "calls", "resource_arg", "requires", "sensitivity", "query_document_arg"})
|
||||
_DISCHARGER_FIELDS = frozenset(
|
||||
{"id", "kind", "calls", "decorators", "identity_sources", "constraint_params", "request_sources", "permissive_values"}
|
||||
)
|
||||
@@ -37,6 +37,12 @@ class OperationFact:
|
||||
resource_arg: int | None # argument or receiver position naming the resource (model, table, path); None = receiver
|
||||
requires: tuple[str, ...] # DISCHARGER_KINDS that discharge this operation
|
||||
sensitivity: str # SENSITIVITIES
|
||||
# A call whose NAME is shared with something ordinary, obligated only in the argument shape of a data
|
||||
# operation. `find` is the commonest collection read there is and also `Array.prototype.find` and jQuery's
|
||||
# `.find`, so the name alone cannot carry the obligation: measured on one plugin's assets, 83 of 88 `find`
|
||||
# calls were a selector or a predicate. The position is 1-based over the real arguments; the arbiter that
|
||||
# can see argument shapes decides it, and a language whose calls are unambiguous never sets this.
|
||||
query_document_arg: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -96,6 +102,9 @@ def load_obligation_facts(directory: Path | None = None) -> ObligationFacts:
|
||||
resource_arg = row.get("resource_arg")
|
||||
if resource_arg is not None and not isinstance(resource_arg, int):
|
||||
raise ObligationFactsError(f"{path}: operation {row.get('id')!r} resource_arg must be an integer")
|
||||
document_arg = row.get("query_document_arg")
|
||||
if document_arg is not None and (not isinstance(document_arg, int) or document_arg < 1):
|
||||
raise ObligationFactsError(f"{path}: operation {row.get('id')!r} query_document_arg must be a positive integer")
|
||||
operations.append(
|
||||
OperationFact(
|
||||
id=str(row["id"]),
|
||||
@@ -105,6 +114,7 @@ def load_obligation_facts(directory: Path | None = None) -> ObligationFacts:
|
||||
resource_arg=resource_arg,
|
||||
requires=requires,
|
||||
sensitivity=sensitivity,
|
||||
query_document_arg=document_arg,
|
||||
)
|
||||
)
|
||||
for row in _rows(payload.get("discharger"), path):
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
Reference in New Issue
Block a user