precision: read the named false positives, and the misses that named themselves
Browse filesSix false-positive causes the README had written down, closed by cause rather
than by rule, because the six had three causes between them.
Four rules matched a variable's NAME and read that as evidence about its value.
semantics.py grew a local value trace -- what a scope binds, what each name was
assigned, whether an expression is certainly an integer, whether its root came
from outside the file -- and float-comparison, path-join and decimal-for-money
moved onto it. onchange-is-not-validation had a different fault: the regex read
400 characters past the decorator and could not see where the method ended, so
all three of its false positives were a short onchange followed by an action
method that raises. Scoped to the decorated function by an AST pass.
Docstrings are emptied before matching, quotes and geometry preserved. A
suppression marker that names a worded code now covers only rules sharing one
of those words; an opaque code keeps the line as before, and the gap that
leaves is named in _marker_is_about. check_compliance runs semantic checkers
too, having silently lost every rule that moved to AST.
sudo-bypasses-record-rules now checks four clauses instead of one. The fourth,
`self = self.sudo()`, came out of the harvest: it is the miss the README names
by hand, and it is an on-target catch. The company_id exemption became the
fields the model's own ir.rule is written about, which is the narrowing 3.40
called a worklist entry and could not do without guessing.
Recall, re-measured over three ecosystems and 1,164 fix commits: 6% on OpenSPP
(was 4%), 6% on OCA (was 2%), 5% on odoo core. Nine more rules graded from
recall --aim, seven of them unproven on the evidence of firing nowhere in
820,000 lines -- which needed the third tree, because four rules that looked
identical on two trees fire on core.
Two new classifications, 84 findings read by hand. sudo is 0 true of 24. odoo
core is the first sample rather than census here, 60 findings across the four
rules making 70% of its output: ondelete 13/15, stored-compute 11/15, assert
3/15, sudo 0/15. The spread is the finding -- precision depends on what kind of
code is under review, and a framework's asserts have no outside to come from.
Seven checker defects fell out of reading: sudo(False), sudo(flag=False) and
sudo(self.env.su) read as escalation; display_name treated as maybe-stored when
the framework declares it unstored; _auto = False models treated as having a
foreign key; a statement-span tie that lost a marker; and scan_source matching
prose in docstrings.
244 of 244 hand-classified true positives still found, checked by re-running
against the same checkouts rather than by reading the diff.
- ARCHITECTURE.md +421 -8
- README.md +240 -76
- bin/context.py +43 -4
- bin/layers.py +168 -16
- bin/semantics.py +936 -63
- classification-odoo-core-2026-09.json +598 -0
- classification-sudo-2026-09.json +287 -0
- corpus/build_corpus.py +131 -56
- corpus/corpus_meta.json +4 -4
- corpus/rules/odoo.json +23 -27
- tests/test_layers.py +91 -0
- tests/test_semantics.py +418 -9
|
@@ -2170,12 +2170,12 @@ framework's answer to precision is `float_round`/`float_compare`, not `Decimal`.
|
|
| 2170 |
rule fires on the idiom and names a fix that cannot be taken. Three findings do not
|
| 2171 |
settle a checker either way, so it is recorded rather than removed.
|
| 2172 |
|
| 2173 |
-
**Two defects the classification found that no test would have.** `scan_source`
|
| 2174 |
`#` comments before matching and not string literals, so `eval()` named in a docstring
|
| 2175 |
-
|
| 2176 |
-
asking which rule it
|
| 2177 |
datetime finding four lines above it — the mute button of §3.42 reached from the other
|
| 2178 |
-
side. Both
|
| 2179 |
|
| 2180 |
**What the id prefix means now.** `python.decimal-for-money-not-float` carries
|
| 2181 |
`stack: odoo`. The prefix is **provenance** — where the rule came from and which
|
|
@@ -2192,6 +2192,413 @@ stopped being retrieved at all against 27 — that probe is the generic validati
|
|
| 2192 |
question every constraint rule here answers. Reworded to the one only it answers.
|
| 2193 |
**27 of 27 retrievable, 25 in the top 3.**
|
| 2194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2195 |
## 4. Performance traps, all found by measurement
|
| 2196 |
|
| 2197 |
Within four instrumented runs the report showed the 7B at ~10 tok/s against a
|
|
@@ -2265,11 +2672,17 @@ in a corpus; it does not close the reasoning gap.
|
|
| 2265 |
|
| 2266 |
- 1 of 27 rules has no automated check at all. It returns **no verdict**, never a
|
| 2267 |
false pass, and `nexa coverage` prints the reason.
|
| 2268 |
-
-
|
| 2269 |
catches textual signatures, not meaning. Code wrong in a way no pattern describes
|
| 2270 |
-
passes silently. The other
|
| 2271 |
-
|
| 2272 |
-
string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2273 |
- Semantic analysis is **Python only**, which is the whole subject now. An answer may
|
| 2274 |
still contain a JavaScript block, Odoo's web client being OWL; that block gets
|
| 2275 |
delimiter balance and nothing deeper, and the layer reports UNKNOWN rather than
|
|
|
|
| 2170 |
rule fires on the idiom and names a fix that cannot be taken. Three findings do not
|
| 2171 |
settle a checker either way, so it is recorded rather than removed.
|
| 2172 |
|
| 2173 |
+
**Two defects the classification found that no test would have.** `scan_source` stripped
|
| 2174 |
`#` comments before matching and not string literals, so `eval()` named in a docstring
|
| 2175 |
+
read as code. And suppression read any marker within a simple statement without
|
| 2176 |
+
asking which rule it named, so a `# nosemgrep: odoo-sudo-without-context` silenced a
|
| 2177 |
datetime finding four lines above it — the mute button of §3.42 reached from the other
|
| 2178 |
+
side. Both were named in the README first, and both are closed in §3.47.
|
| 2179 |
|
| 2180 |
**What the id prefix means now.** `python.decimal-for-money-not-float` carries
|
| 2181 |
`stack: odoo`. The prefix is **provenance** — where the rule came from and which
|
|
|
|
| 2192 |
question every constraint rule here answers. Reworded to the one only it answers.
|
| 2193 |
**27 of 27 retrievable, 25 in the top 3.**
|
| 2194 |
|
| 2195 |
+
### 3.47 Reading the value instead of the name, and the rest of the sudo sentence
|
| 2196 |
+
|
| 2197 |
+
**Decided 2026-09-21.** Six false-positive causes the README had named were closed in
|
| 2198 |
+
one pass, and the pass was ordered by cause rather than by rule, because the six had
|
| 2199 |
+
three causes between them.
|
| 2200 |
+
|
| 2201 |
+
**The shared cause.** `float-comparison-needs-float-utils` (2 true, 5 false),
|
| 2202 |
+
`path-join-does-not-contain-a-path` (0 of 4) and `decimal-for-money-not-float` (0 of 3)
|
| 2203 |
+
each matched a variable's *name* and treated that as evidence about its *value*. All
|
| 2204 |
+
five float falses compared an integer to zero — `search_count`, a `read_group`
|
| 2205 |
+
`<field>_count`, `cr.fetchone()[0]` of a `SELECT COUNT(*)`, a `fields.Integer` — and
|
| 2206 |
+
three of the five plus both true positives were spelled `total == 0`. No name could ever
|
| 2207 |
+
have separated them. The assignment can.
|
| 2208 |
+
|
| 2209 |
+
So `semantics.py` grew a local value trace: which names a scope binds, what each was
|
| 2210 |
+
assigned, whether an expression is certainly a whole number, whether its root came from
|
| 2211 |
+
outside the file. One file, one function, no imports followed, no types inferred, and
|
| 2212 |
+
the limit is written into the limitations section rather than discovered later. Three
|
| 2213 |
+
regexes became AST passes on top of it. Every answer the trace gives that *suppresses* a
|
| 2214 |
+
finding must be certain — a name assigned twice is an integer only if both assignments
|
| 2215 |
+
are — because a suppressing guess is the direction that hides.
|
| 2216 |
+
|
| 2217 |
+
**The window that crossed a function.** `onchange-is-not-validation` was 4 true, 3
|
| 2218 |
+
false, and the README's reading of the three ("the write is to a Many2one") was wrong.
|
| 2219 |
+
Each is a four-line onchange that assigns a field, followed by an action method that
|
| 2220 |
+
raises. The regex read `[\s\S]{0,400}` past the decorator; `_onchange_variable_id` is
|
| 2221 |
+
200 characters long and the `UserError` it matched belongs to `action_remap`. A regex
|
| 2222 |
+
has no way not to do that. Scoped to the decorated function by an AST pass, the three
|
| 2223 |
+
are silent, and three new findings surfaced and were read: a phone-number validation
|
| 2224 |
+
living only in an onchange with no constraint anywhere in the file; a
|
| 2225 |
+
`_check_date_collected` onchange raising `exceptions.ValidationError`, a dotted spelling
|
| 2226 |
+
the regex's `\w*` never matched; and one that clears a field and returns the warning
|
| 2227 |
+
dict. The finding anchors on the decorator, because that is where the rule is and where
|
| 2228 |
+
a marker about it gets written — a `FunctionDef`'s own `lineno` is the `def`, one line
|
| 2229 |
+
down, and reporting there lost a suppression a test already relied on.
|
| 2230 |
+
|
| 2231 |
+
**Two infrastructure defects, fixed once.** Docstrings are now emptied before any
|
| 2232 |
+
pattern sees the file — docstrings only, since `_SQL_COLUMNS` reads triple-quoted SQL
|
| 2233 |
+
on purpose — with quotes, newlines and length preserved so no reported line or column
|
| 2234 |
+
moves. And a suppression marker that *names* a code with words in it
|
| 2235 |
+
(`odoo-sudo-without-context`) now covers only rules sharing one of those words, vendor
|
| 2236 |
+
and language words excepted; a code with no words (`B608`, `E501`) or prose after the
|
| 2237 |
+
colon cannot be compared with anything and covers its line exactly as before. The
|
| 2238 |
+
direction of error is stated in `_marker_is_about`: a code the function cannot relate
|
| 2239 |
+
to a rule leaves the finding reported.
|
| 2240 |
+
|
| 2241 |
+
Fixing that surfaced a third defect nothing had reached before. Every statement span
|
| 2242 |
+
borrows the comment line above it, so consecutive statements overlap by one line, and a
|
| 2243 |
+
finding on the first line of one statement is also inside the borrowed line of the next.
|
| 2244 |
+
Two spans of equal width tied, and the winner was whichever `ast.walk` reached first.
|
| 2245 |
+
On `spp_api_v2/wizards/show_secret_wizard.py` the `return` on line 38 won the marker on
|
| 2246 |
+
line 36 away from the write on 37. A span whose statement *covers* the line now beats
|
| 2247 |
+
one that only borrows it.
|
| 2248 |
+
|
| 2249 |
+
**The rest of the sudo sentence.** The rule says three things — scope `sudo()` to the
|
| 2250 |
+
narrowest operation, re-apply company filtering by hand, never pass a sudo recordset
|
| 2251 |
+
onward — and the checker tested only `.sudo().search()`. §3.30's harvest put `.sudo(` at
|
| 2252 |
+
70 missed against 6 caught, 47 of the 70 in files where a checker fired elsewhere; the
|
| 2253 |
+
README's own named miss, *"remove sudo() when importing record"*, is a `create`. Counted
|
| 2254 |
+
on the OpenSPP tree, the query clause has 116 call sites and the unchecked one 136.
|
| 2255 |
+
`sudo_search_drops_a_real_rule` became `sudo_drops_record_rules` and checks all three:
|
| 2256 |
+
queries, touches (`create`, `write`, `unlink`, `copy`, `read`), and a sudo recordset
|
| 2257 |
+
bound to a name and then returned or passed as an argument — the bare name only, since
|
| 2258 |
+
telling `return p.filtered(...)` from `return p.mapped('name')` means knowing which
|
| 2259 |
+
recordset methods return recordsets, and a new clause on a critical rule should
|
| 2260 |
+
under-report.
|
| 2261 |
+
|
| 2262 |
+
A query decides *which* records exist for you and a touch acts on ones you already hold.
|
| 2263 |
+
That line is where the measurement fell, so it is where the code divides.
|
| 2264 |
+
|
| 2265 |
+
The record-rule trigger of §3.40 gates every clause, and it needed one more thing to do
|
| 2266 |
+
so: `Plan.sudo().create(...)` names no model, so the trigger read UNKNOWN and could not
|
| 2267 |
+
suppress. The receiver is now followed through its assignment — `Plan =
|
| 2268 |
+
self.env["spp.case.plan"]` a few lines up — and 12 of the 15 unresolved receivers in the
|
| 2269 |
+
file that produced them resolve. `check_compliance` also runs semantic checkers now; it
|
| 2270 |
+
ran patterns only, so every rule that moved to an AST pass had silently left the
|
| 2271 |
+
compliance check of generated answers, including the onchange rule that function's own
|
| 2272 |
+
docstring gives as its reason for existing.
|
| 2273 |
+
|
| 2274 |
+
**The `company_id` exemption became the rule's own fields.** The old test for "this call
|
| 2275 |
+
already re-applied the scoping" was the literal word `company_id`, from the rule's
|
| 2276 |
+
correct sample, and §3.40 recorded the cost: 14 of 20 surviving findings were scoped by
|
| 2277 |
+
a group, a partner or an area and read as unscoped. It called a narrowing a worklist
|
| 2278 |
+
entry because "deciding in general that a domain is already scoped means evaluating it,
|
| 2279 |
+
and a narrowing that guesses would cost the 6 true positives this keeps."
|
| 2280 |
+
|
| 2281 |
+
There is a version that does not guess. `context` already parses every `ir.rule` in the
|
| 2282 |
+
tree to decide whether one filters; it now also records **which fields each domain is
|
| 2283 |
+
written about**, and the checker compares the call against those instead of against one
|
| 2284 |
+
hardcoded word. Not one OpenSPP case model has a company rule at all — they are governed
|
| 2285 |
+
by `[('case_worker_id', '=', user.id)]` and `[('case_id.case_worker_id', '=', user.id)]`
|
| 2286 |
+
— so a create naming `case_worker_id` or `case_id` lands inside the caller's own
|
| 2287 |
+
visibility, and that is the rule's advice followed in the only spelling that model has.
|
| 2288 |
+
`company_id` stays as the fallback where the rules could not be read, so nothing exempt
|
| 2289 |
+
before stops being exempt now.
|
| 2290 |
+
|
| 2291 |
+
Two details cost a true positive each before they were right. The comparison reads the
|
| 2292 |
+
call's **arguments**, not the unparsed call: the receiver carries the model name, and
|
| 2293 |
+
`spp.group.membership` contains the word `group`, which is one of that model's own rule
|
| 2294 |
+
fields — it exempted an unscoped sweep of every membership in the database, classified
|
| 2295 |
+
true by hand. And an argument that is a bare name is followed to what it was assigned,
|
| 2296 |
+
because `create(vals)` otherwise hides every field it sets behind four letters. All six
|
| 2297 |
+
true positives survive, checked explicitly.
|
| 2298 |
+
|
| 2299 |
+
**What the re-run says**, against the artifacts of §3.43 and §3.46:
|
| 2300 |
+
|
| 2301 |
+
| | OpenSPP before | after | OCA before | after |
|
| 2302 |
+
|---|---|---|---|---|
|
| 2303 |
+
| `float-comparison` | 7 | **2** | 1 | **3** |
|
| 2304 |
+
| `onchange-is-not-validation` | 7 | 7, all three falses replaced | — | — |
|
| 2305 |
+
| `decimal-for-money` | 2 | **0** | 1 | **0** |
|
| 2306 |
+
| `path-join` | 1 | **0** | 3 | **0** |
|
| 2307 |
+
| `eval-and-pickle` (docstring) | 1 | **0** | — | — |
|
| 2308 |
+
| `datetimes` wrongly suppressed | 1 | **0** | — | — |
|
| 2309 |
+
| `sudo` deciding / advisory / decided | 2 / 0 / 62 | **2 / 11 / 117** | 7 / 0 / 0 | **6 / 10 / 0** |
|
| 2310 |
+
| Rate per thousand lines | 0.9 | **0.8** | 3.5 | **3.4** |
|
| 2311 |
+
|
| 2312 |
+
The OCA float number went *up*, and that is the old `satisfied_pattern` clearing a
|
| 2313 |
+
whole file on a `float_compare` anywhere in it. The three are `Monetary` and `Float`
|
| 2314 |
+
fields compared with `!= 0` in files that call `float_compare` somewhere else, each read.
|
| 2315 |
+
|
| 2316 |
+
**What the classification says, and what was done about it.** All 23 live sudo findings
|
| 2317 |
+
were read by hand against their own files, in `classification-sudo-2026-09.json`.
|
| 2318 |
+
**0 true, 23 false.** The touch and escape clauses have not met a real one in 260,897
|
| 2319 |
+
lines. Every finding they made is code where the caller is an administrator by
|
| 2320 |
+
construction — a demo-data seeder, an accounting wizard behind
|
| 2321 |
+
`group_account_manager` — or a framework model with no tenant boundary: an
|
| 2322 |
+
`ir.actions.act_window`, an `ir.sequence.date_range`, a postgres tuning row.
|
| 2323 |
+
|
| 2324 |
+
So the two new clauses emit `unproven` **per finding**. That grade already exists for
|
| 2325 |
+
exactly this position — "a real defect nothing measured here has met yet, so it is
|
| 2326 |
+
reported and not trusted to stop a build" — and `scan_source` now derives a hit's
|
| 2327 |
+
`advisory` flag from the hit's own grade as well as its rule's, which is the same
|
| 2328 |
+
derivation the corpus builder already does one level up. The query clause keeps the
|
| 2329 |
+
rule's `defect` and its 6 true positives, because a grade travels with the finding.
|
| 2330 |
+
|
| 2331 |
+
Not deleted, and not by path. Skipping `*_demo` modules would be a heuristic invented to
|
| 2332 |
+
make a number smaller; grading a clause on what it was measured at is the thing this
|
| 2333 |
+
project does everywhere else. `touches-a-record-already-held` is 7 of the 23 and
|
| 2334 |
+
`scoped-to-a-record-already-held` an eighth of the same shape — a recordset the caller
|
| 2335 |
+
obtained under its own rules, then written under sudo. That is not nothing, because
|
| 2336 |
+
`ir.rule` governs write permission as well as visibility, but it is a different claim
|
| 2337 |
+
from the one the rule's `why` paragraph makes. Whether it is a second rule or a
|
| 2338 |
+
narrowing wants a tree where it fires on production code, and neither of these two is
|
| 2339 |
+
that tree.
|
| 2340 |
+
|
| 2341 |
+
### 3.48 A fourth sudo clause, taken from the missed list
|
| 2342 |
+
|
| 2343 |
+
**Decided 2026-09-21.** The harvest is supposed to be a worklist, so it was used as one.
|
| 2344 |
+
|
| 2345 |
+
`--harvest` over the OCA trees puts `.sudo(` at the top again, and one row in it is the
|
| 2346 |
+
miss the README had been naming by hand since §3.30: server-tools `cbefaa511485`,
|
| 2347 |
+
*"[FIX] excel_import_export: remove sudo() when importing record"*. The diff deletes one
|
| 2348 |
+
line.
|
| 2349 |
+
|
| 2350 |
+
```python
|
| 2351 |
+
self = self.sudo()
|
| 2352 |
+
```
|
| 2353 |
+
|
| 2354 |
+
Nothing below it mentions sudo. Every clause in §3.47 looks for `.sudo().<method>()` or
|
| 2355 |
+
a sudo recordset handed to something, and this is neither: it **replaces the receiver
|
| 2356 |
+
the whole method is written against**, so each subsequent `self.env[...]` inherits the
|
| 2357 |
+
elevated environment while naming nothing. The commit says what it cost — *"finding
|
| 2358 |
+
multiple records across all companies during import when they have same record name.
|
| 2359 |
+
Previously, importing with sudo could bypass multi-company record rules"* — which is the
|
| 2360 |
+
rule's own `why` paragraph, written by somebody who had just been bitten by it.
|
| 2361 |
+
|
| 2362 |
+
Three sites in 260,000 lines, so the clause is narrow as well as real. Run against that
|
| 2363 |
+
commit's own before and after images the finding sits on line 283, is gone afterwards,
|
| 2364 |
+
and 283 is one of the two lines the fix touched: an on-target catch by recall's own
|
| 2365 |
+
definition, which is why this clause carries no grade of its own and keeps the rule's
|
| 2366 |
+
`defect` where the touch clause takes `unproven`.
|
| 2367 |
+
|
| 2368 |
+
The same commit **adds** `ModelData = self.sudo().env["ir.model.data"]`, a deliberate
|
| 2369 |
+
and narrowly scoped elevation. That is the evidence for not also flagging `.sudo().env[`,
|
| 2370 |
+
and it is worth stating: the author of the fix reached for the shape one clause would
|
| 2371 |
+
have reported, in the commit that fixed the thing another clause did report.
|
| 2372 |
+
|
| 2373 |
+
### 3.49 What the re-measurement says, and the grades that came out of it
|
| 2374 |
+
|
| 2375 |
+
**Measured 2026-09-21**, over 795 fix commits across both ecosystems.
|
| 2376 |
+
|
| 2377 |
+
| | OpenSPP before | after | OCA before | after |
|
| 2378 |
+
|---|---|---|---|---|
|
| 2379 |
+
| Fix commits examined | 406 | 409 | 385 | 386 |
|
| 2380 |
+
| …touching a covered construct | 160 | **173** | 56 | **64** |
|
| 2381 |
+
| Caught | 6 | **11** | 1 | **4** |
|
| 2382 |
+
| Recall against in-scope fixes | 4% | **6%** | 2% | **6%** |
|
| 2383 |
+
|
| 2384 |
+
Both ecosystems land on the same number from different directions, which is worth more
|
| 2385 |
+
than either figure alone. The in-scope denominators rose too, so the improvement is not
|
| 2386 |
+
an artefact of measuring less.
|
| 2387 |
+
|
| 2388 |
+
`.sudo(` remains the largest cluster on OpenSPP but a different shape: **67 missed, 9
|
| 2389 |
+
caught, 52 spoke, 15 silent**, against 70/6/47/23 before. The silent column is what the
|
| 2390 |
+
new clauses ate. `noupdate` — 22 missed, 0 caught, 9 spoke, 13 silent — is now the
|
| 2391 |
+
cluster with the most silence against the least coverage, and is next.
|
| 2392 |
+
|
| 2393 |
+
**Four rules earned a grade.** `recall --aim` placed every finding a checker made in a
|
| 2394 |
+
file a fix touched against the method the fix edited, and the placements decide:
|
| 2395 |
+
|
| 2396 |
+
| rule | on-target | same-symbol | other | caught | grade |
|
| 2397 |
+
|---|---|---|---|---|---|
|
| 2398 |
+
| `sql-needs-query-parameters` | 3 | 5 | 12 | 1 | **defect** |
|
| 2399 |
+
| `naive-and-aware-datetimes-do-not-compare` | 2 | 5 | 16 | 2 | **defect** |
|
| 2400 |
+
| `check-company-on-relations` | 1 | 3 | 10 | 1 | **construct** |
|
| 2401 |
+
| `constrains-must-list-every-field-read` | 1 | 2 | 22 | 0 | **construct** |
|
| 2402 |
+
|
| 2403 |
+
The two `construct` grades are the same reading `ondelete` and `stored-compute` already
|
| 2404 |
+
have: the rule recognises the declaration and is about the method rather than the line
|
| 2405 |
+
anybody fixes.
|
| 2406 |
+
|
| 2407 |
+
### 3.50 A third ecosystem, and the two things it changed
|
| 2408 |
+
|
| 2409 |
+
**Measured 2026-09-21.** Every recall number this project had came from two trees, and
|
| 2410 |
+
the README's own caveat was that two repositories in one ecosystem is a narrow sample.
|
| 2411 |
+
So the pass was run again against **odoo core 18.0** — 559,663 lines, 190,217 commits,
|
| 2412 |
+
4,368 `.sudo(` call sites — with the recall half capped at 400 fix commits.
|
| 2413 |
+
|
| 2414 |
+
| | OpenSPP | OCA | odoo core | all three |
|
| 2415 |
+
|---|---|---|---|---|
|
| 2416 |
+
| Fix commits examined | 409 | 386 | 369 | **1,164** |
|
| 2417 |
+
| …touching a covered construct | 173 | 64 | 97 | **334** |
|
| 2418 |
+
| Caught | 11 | 4 | 5 | **20** |
|
| 2419 |
+
| Recall | 6% | 6% | 5% | **6%** |
|
| 2420 |
+
| Live findings per 1000 lines | 0.8 | 3.5 | **5.0** | — |
|
| 2421 |
+
|
| 2422 |
+
Three ecosystems landing on the same recall figure from different directions is the
|
| 2423 |
+
first evidence this project has that ~6% is a property of the corpus rather than of a
|
| 2424 |
+
sample. The precision rate on core is the highest of the three and is **not classified**;
|
| 2425 |
+
core is the reference implementation, so a rate there is a statement about the rules
|
| 2426 |
+
rather than about somebody's house style, and reading those 2,784 findings is the
|
| 2427 |
+
largest single piece of work this repository now has outstanding.
|
| 2428 |
+
|
| 2429 |
+
**It caught a checker defect that two trees could not.** `self = self.sudo(False)`
|
| 2430 |
+
turns record rules back **on**. It is how a method that must run under the caller's own
|
| 2431 |
+
rights says so, and odoo core does it 60 times — including mail's
|
| 2432 |
+
`_check_attachments_access`, whose docstring reads *"This method relies on access
|
| 2433 |
+
rules/rights and therefore it should not be called from a sudo env."* Every sudo clause
|
| 2434 |
+
read `.sudo` and ignored its argument, so the checker reported the safeguard as the
|
| 2435 |
+
defect. Neither OpenSPP nor OCA contains a single `sudo(False)`, so no amount of work on
|
| 2436 |
+
those two trees would have found it. `_escalates()` now reads the argument, and a
|
| 2437 |
+
non-literal argument still counts as an escalation because `sudo(flag)` can be either.
|
| 2438 |
+
|
| 2439 |
+
**It stopped a grading mistake.** Measured on OpenSPP and OCA alone, eleven rules had
|
| 2440 |
+
fired nowhere at all, and `unproven` — "a real defect nothing measured here has met yet"
|
| 2441 |
+
— is literally accurate for a rule like that. Four of the eleven fire on odoo core:
|
| 2442 |
+
`bare-except-catches-the-exit` 28 times, `mutable-default-argument` 15,
|
| 2443 |
+
`eval-and-pickle-execute-their-input` 4 and `subprocess-shell-true-is-injection` 4.
|
| 2444 |
+
Grading off two trees would have quietly downgraded four working rules on the strength
|
| 2445 |
+
of not having looked far enough.
|
| 2446 |
+
|
| 2447 |
+
The seven that fire nowhere across all three — 820,000 lines and 1,164 fix commits —
|
| 2448 |
+
are graded `unproven` on that evidence, which is more than the one rule already carrying
|
| 2449 |
+
the grade had. Two of the seven, `decimal-for-money-not-float` and `path-join-does-not-
|
| 2450 |
+
contain-a-path`, are silent because §3.47 narrowed them rather than because nothing was
|
| 2451 |
+
there; both readings are in the README and the grade records the second.
|
| 2452 |
+
|
| 2453 |
+
Sixteen rules are now graded and eleven are `unmeasured`, which remains the accurate
|
| 2454 |
+
word for them rather than a placeholder.
|
| 2455 |
+
|
| 2456 |
+
**One thing found, measured, and then not acted on.** Odoo core documents intentional
|
| 2457 |
+
elevation with its own comment convention — `# sudo: discuss.channel - guests don't
|
| 2458 |
+
have access for creating mail.message` — and uses it 136 times. The first reading was
|
| 2459 |
+
that this is the same artefact as a `# nosemgrep` and that recognising it would
|
| 2460 |
+
materially improve the rate on core.
|
| 2461 |
+
|
| 2462 |
+
The second half of that is simply false, and counting says so: of the **538** sudo
|
| 2463 |
+
findings on core, **6** carry a `# sudo:` note within three lines. One percent. The 136
|
| 2464 |
+
notes sit overwhelmingly on elevations no clause here fires on — the record-rule trigger
|
| 2465 |
+
has already dropped those, which is the trigger working. So the practical case for
|
| 2466 |
+
reading the convention evaporates, and what is left is only the argument of principle.
|
| 2467 |
+
|
| 2468 |
+
That argument is worth writing down anyway, because it is the one that decides whether
|
| 2469 |
+
any future marker gets added. A `# nosemgrep` records that *an analyser made a specific
|
| 2470 |
+
claim and a human rejected that claim*. A `# sudo:` records that *the author, while
|
| 2471 |
+
writing the line, noted why they wanted it*. Those are different kinds of evidence: the
|
| 2472 |
+
first is a response to a challenge, the second is the intention of the person who chose
|
| 2473 |
+
the code. This corpus exists for defects that **look correct in review** — the headline
|
| 2474 |
+
measurement is that 17% of OpenSPP's fixes were silent until the data was visibly wrong
|
| 2475 |
+
— so an author's stated intention is close to the thing the tool is meant to second-guess,
|
| 2476 |
+
not evidence against the finding. Treating it as a suppression would also be the
|
| 2477 |
+
nexa-specific mute button §3.42 refuses to build, reached through a third party's
|
| 2478 |
+
comment style.
|
| 2479 |
+
|
| 2480 |
+
The convention stays unread, and the reason is the principle rather than the number.
|
| 2481 |
+
|
| 2482 |
+
There is a general rule in that, and it is worth stating because this file is otherwise
|
| 2483 |
+
an argument for measuring everything. A question that turns on **interpretation** does
|
| 2484 |
+
not become settled by being measured. The 1% above is a fact about two checkouts on one
|
| 2485 |
+
day; the thing actually in dispute is what a comment is evidence *of*, and no rerun
|
| 2486 |
+
answers that. Pinning a number to it would produce a reference that drifts on every tree
|
| 2487 |
+
and every rerun while the disagreement underneath it stays exactly where it was. Measure
|
| 2488 |
+
to check a claim already made — that is how the 1% corrected an overclaim in this very
|
| 2489 |
+
section — but do not manufacture a measurement in place of a decision.
|
| 2490 |
+
|
| 2491 |
+
### 3.51 Reading odoo core, and the first classification that is a sample
|
| 2492 |
+
|
| 2493 |
+
**Measured 2026-09-21.** §3.50 left core's findings unclassified and called reading them
|
| 2494 |
+
the largest outstanding piece of work. Reading all 2,784 is not a session's work, and
|
| 2495 |
+
pretending otherwise would have produced a worse artifact than admitting it.
|
| 2496 |
+
|
| 2497 |
+
So this is the first classification here that is a **sample rather than a census**: 15
|
| 2498 |
+
findings of the largest rule — `stored-compute-needs-complete-depends`, 1,051 of the
|
| 2499 |
+
2,784 — drawn with a fixed seed and read by hand. The draw is reproducible from the
|
| 2500 |
+
artifact's own description, without the script that made it. **11 true, 4 false**, which
|
| 2501 |
+
is 73% with a 95% Wilson interval of roughly 48% to 89%. The interval is wide and it is
|
| 2502 |
+
the honest number for n=15: it says the rule is not mostly noise on core and it does not
|
| 2503 |
+
say much more.
|
| 2504 |
+
|
| 2505 |
+
**All four false verdicts have one cause.** `odoo/models.py` adds `display_name` to
|
| 2506 |
+
every model as `fields.Char(automatic=True, compute='_compute_display_name',
|
| 2507 |
+
search=...)` with **no store**. An override that omits a dependency therefore cannot
|
| 2508 |
+
leave a stale row, because there is no row. The checker read "no declaration in this
|
| 2509 |
+
file" as unknown storedness and kept the finding, which is the right reading for an
|
| 2510 |
+
ordinary field declared in another module and the wrong one here: the framework's own
|
| 2511 |
+
declaration is knowable and says not stored. 121 of the rule's 1,051 findings on core
|
| 2512 |
+
were this shape.
|
| 2513 |
+
|
| 2514 |
+
The narrowing is free, and that was checked rather than assumed. Ten hand-verified true
|
| 2515 |
+
positives compute `display_name` — every one of them in OpenSPP — and every one declares
|
| 2516 |
+
`store=True` on it **in its own file**, so in-file storedness reads True and they are
|
| 2517 |
+
untouched. Re-run: 244 of 244 hand-classified true positives across all five artifacts
|
| 2518 |
+
still found, none lost.
|
| 2519 |
+
|
| 2520 |
+
**The bigger thing the sample found, and did not act on.** 837 of odoo core's 1,360
|
| 2521 |
+
stored computes are `readonly=False`: the "compute a smart default, then let the user own
|
| 2522 |
+
it" pattern, where a deliberately narrow `@api.depends` protects a user's edit from being
|
| 2523 |
+
clobbered. That is a plausible reading of most of this rule's volume on core, and it is
|
| 2524 |
+
not a safe narrowing. Three of the 47 resolvable hand-verified true positives on OCA and
|
| 2525 |
+
OpenSPP are themselves `readonly=False`, and nine of the eleven true verdicts in this
|
| 2526 |
+
very sample are too. The pattern is a consideration inside each verdict, not a rule, and
|
| 2527 |
+
the number that would have justified treating it as one is the number that refutes it.
|
| 2528 |
+
|
| 2529 |
+
**What is still unread.** 1,612 live findings on core. The next three by volume are read
|
| 2530 |
+
in §3.52.
|
| 2531 |
+
|
| 2532 |
+
### 3.52 The other three rules on core, and what precision turned out to depend on
|
| 2533 |
+
|
| 2534 |
+
**Measured 2026-09-21.** Same method as §3.51 and the same seed: 15 findings each of
|
| 2535 |
+
`sudo-bypasses-record-rules`, `assert-is-not-validation` and `ondelete-is-a-decision`,
|
| 2536 |
+
drawn and read by hand. With §3.51 that is 60 of core's findings across the four rules
|
| 2537 |
+
making 70% of them, in `classification-odoo-core-2026-09.json`.
|
| 2538 |
+
|
| 2539 |
+
| rule | core | previously | 95% Wilson |
|
| 2540 |
+
|---|---|---|---|
|
| 2541 |
+
| `ondelete-is-a-decision` | **13/15** | 12/12 OCA, 14/14 OpenSPP | 60–95% |
|
| 2542 |
+
| `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA | 48–89% |
|
| 2543 |
+
| `assert-is-not-validation` | **3/15** | 6/10 OCA | 7–45% |
|
| 2544 |
+
| `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP | 0–20% |
|
| 2545 |
+
|
| 2546 |
+
**The finding is that precision depends on what kind of code is under review, and this
|
| 2547 |
+
is the first tree here that made that legible.** Two rules hold up on core. Two collapse,
|
| 2548 |
+
and both collapse for the same reason rather than because the checkers are broken.
|
| 2549 |
+
|
| 2550 |
+
`assert-is-not-validation`'s own true class, set when it was classified on OCA, is *the
|
| 2551 |
+
asserted value came from outside the module*. A framework's asserts are overwhelmingly
|
| 2552 |
+
invariants between its own components — the registry asserting its cache-name
|
| 2553 |
+
convention, a field descriptor asserting its own configuration, the query builder
|
| 2554 |
+
asserting an alias it generated itself. There is no outside. Twelve of the fifteen are
|
| 2555 |
+
that shape.
|
| 2556 |
+
|
| 2557 |
+
`sudo-bypasses-record-rules` is 0 of 15 and every one is a deliberate framework
|
| 2558 |
+
elevation: a public route resolving an access token before any identity exists, an
|
| 2559 |
+
`@api.ondelete` guard that has to see records the caller cannot or the guard is
|
| 2560 |
+
bypassable, a cross-company sync whose entire purpose is to cross companies. The rule
|
| 2561 |
+
describes an application leaking between tenants. Core is the thing that implements
|
| 2562 |
+
tenancy.
|
| 2563 |
+
|
| 2564 |
+
Neither is a reason to change a grade. A precision number is about a subject, and the
|
| 2565 |
+
subject here is one this corpus was never aimed at — nobody reviews odoo core with it.
|
| 2566 |
+
It is a reason to say so in the README, which is done, and it is the strongest argument
|
| 2567 |
+
yet that a single precision figure for "the corpus" means less than the same figure per
|
| 2568 |
+
kind of codebase.
|
| 2569 |
+
|
| 2570 |
+
**Three checker defects, all found by reading rather than by testing.**
|
| 2571 |
+
|
| 2572 |
+
`_auto = False` marks a model odoo builds no table for: a SQL view. There is no foreign
|
| 2573 |
+
key, so `ondelete` is not a decision anybody can make about it. Two of the fifteen
|
| 2574 |
+
ondelete findings were report models of exactly that kind, and the exemption joins
|
| 2575 |
+
TransientModel in `_transient_spans`. None of the 26 hand-verified true positives for
|
| 2576 |
+
this rule sits on such a model, so it is free.
|
| 2577 |
+
|
| 2578 |
+
The other two are both `sudo` de-escalation the checker read as escalation, the same
|
| 2579 |
+
class as the `sudo(False)` defect in §3.50 and missed by the same reading. Odoo's
|
| 2580 |
+
signature is `sudo(self, flag=True)`, so **`sudo(flag=False)`** is de-escalation by
|
| 2581 |
+
keyword and `_escalates` was only reading positional arguments. And **`x.sudo(self.env.su)`**
|
| 2582 |
+
sets su to whatever the environment already has, so it cannot raise privilege above
|
| 2583 |
+
ambient; core writes it beside the comment *"Unsudo the invoice after creation if not
|
| 2584 |
+
already sudoed"*. `sudo(self.env.su or field.compute_sudo)` is deliberately not exempt,
|
| 2585 |
+
because the `or` can turn it on.
|
| 2586 |
+
|
| 2587 |
+
Rates after all three: **4.7 per 1000 lines** on core, 0.8 on OpenSPP, 3.3 on OCA, with
|
| 2588 |
+
244 of 244 hand-classified true positives still found.
|
| 2589 |
+
|
| 2590 |
+
**One finding worth acting on outside this project.** Core's
|
| 2591 |
+
`ir.model.access.group_names_with_access` interpolates its `access_mode` argument
|
| 2592 |
+
straight into SQL as `perm_{access_mode}`, and the only thing constraining it to four
|
| 2593 |
+
literals is an `assert`. The method is public and RPC-callable. Odoo does not run under
|
| 2594 |
+
`python -O`, so this is latent rather than live, and the assert also turns a bad
|
| 2595 |
+
argument into a 500 rather than a clean error — but it is exactly the harm the rule
|
| 2596 |
+
describes, found in the reference implementation.
|
| 2597 |
+
|
| 2598 |
+
**Still unread on core:** roughly 1,590 findings, led by `naive-and-aware-datetimes`
|
| 2599 |
+
(167), `constrains-must-list-every-field-read` (151) and
|
| 2600 |
+
`depends-must-not-name-unread-fields` (134).
|
| 2601 |
+
|
| 2602 |
## 4. Performance traps, all found by measurement
|
| 2603 |
|
| 2604 |
Within four instrumented runs the report showed the 7B at ~10 tok/s against a
|
|
|
|
| 2672 |
|
| 2673 |
- 1 of 27 rules has no automated check at all. It returns **no verdict**, never a
|
| 2674 |
false pass, and `nexa coverage` prints the reason.
|
| 2675 |
+
- 12 of the 26 checked rules are decided by a **regex over generated code**. That
|
| 2676 |
catches textual signatures, not meaning. Code wrong in a way no pattern describes
|
| 2677 |
+
passes silently. The other 14 are decided by an AST pass (§3.12, §3.47). The regexes
|
| 2678 |
+
no longer see docstrings, which are emptied before matching; they still see every
|
| 2679 |
+
other string literal, so a violation quoted inside a triple-quoted SQL string or a
|
| 2680 |
+
help text would still be reported.
|
| 2681 |
+
- The value trace behind four of those AST checkers (§3.47) reads **one file and one
|
| 2682 |
+
function**. It follows no import and infers no type. A float that arrives as a
|
| 2683 |
+
parameter is still reported as a float comparison; an integer returned by a helper
|
| 2684 |
+
in another module is too. A traversal or a money string that reaches `os.path.join`
|
| 2685 |
+
or `float()` through a helper in another file is not reported at all.
|
| 2686 |
- Semantic analysis is **Python only**, which is the whole subject now. An answer may
|
| 2687 |
still contain a JavaScript block, Odoo's web client being OWL; that block gets
|
| 2688 |
delimiter balance and nothing deeper, and the layer reports UNKNOWN rather than
|
|
@@ -130,10 +130,13 @@ publishing a classification.
|
|
| 130 |
**Not a general linter.** 27 rules. It will not replace `pylint-odoo`, and after the
|
| 131 |
wrapper above it does not have to.
|
| 132 |
|
| 133 |
-
**Not a bug finder.** Against real Odoo bugfixes the
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
**Odoo only, and that is the whole of it.** There was a TypeScript corpus and a
|
| 139 |
JavaScript one. They got their first field evidence in September 2026 and it was not
|
|
@@ -219,33 +222,45 @@ which a regex cannot.
|
|
| 219 |
|
| 220 |
### What is wrong with the other half, named
|
| 221 |
|
| 222 |
-
**Three rules fired and were never right.** `decimal-for-money-not-float`
|
| 223 |
this one has a *reason* behind it rather than a sample size: Odoo's `Monetary` field **is
|
| 224 |
a float** — the ORM defines it that way — and the framework's answer to the precision
|
| 225 |
problem is `float_round` and `float_compare` against the currency's rounding, not
|
| 226 |
-
`Decimal`. Inside an Odoo module the rule
|
| 227 |
-
be taken.
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
**Three more never fired at all**, and silence is not a pass: it says 260,000 lines
|
| 236 |
contain none of what they look for, which is also what a broken checker says. Two of the
|
| 237 |
three are the two ruff already covers, which is the more likely explanation.
|
| 238 |
|
| 239 |
-
**A checker defect, found by classifying rather than by testing.** Two of the 50
|
| 240 |
-
same fault: `scan_source`
|
| 241 |
string literals, so `eval()` named in a docstring arguing *against* using it, and a
|
| 242 |
-
`datetime.now()` inside a doctest example, both read as code.
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
**A suppression that should not have fired.** One finding — a consent-receipt timestamp
|
| 245 |
-
—
|
| 246 |
-
marker four lines below it. Suppression
|
| 247 |
-
asking which rule it
|
| 248 |
-
reached from the other direction
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
---
|
| 251 |
|
|
@@ -528,8 +543,8 @@ The axes are separate because they answer different questions. `severity` is how
|
|
| 528 |
bad it is if this happens; `likelihood` is how likely a finding is the bug, graded
|
| 529 |
from what `nexa recall --aim` measured against real fix commits rather than from
|
| 530 |
judgement. A rule can be severe and rarely the point — a Python uniqueness check
|
| 531 |
-
really does race, and across
|
| 532 |
-
field could not say so.
|
| 533 |
|
| 534 |
Everything else is **derived** and must never be hand-edited:
|
| 535 |
|
|
@@ -547,13 +562,13 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
|
|
| 547 |
|---|---|
|
| 548 |
| Rules | 27 — 19 Odoo ORM, 8 plain-Python, one stack |
|
| 549 |
| Critical | 12 |
|
| 550 |
-
| Machine-checkable | 26 (96%) —
|
| 551 |
| Traceable to upstream docs | 25 (93%), each audited for topic fit |
|
| 552 |
| Pattern false positives | 0 cross-hits across the corpus |
|
| 553 |
| Recall under meaning-preserving rewrite | 79/79 |
|
| 554 |
-
| Satisfied patterns bypassable by a comment | 0 of
|
| 555 |
| Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
|
| 556 |
-
| Hit rate on 33,960 lines of reviewed OCA code | 3.
|
| 557 |
| Those 120 hits, classified by hand | **86 true positives**, 21 false, 13 undecidable from one file |
|
| 558 |
| Hit rate on 226,937 lines of OpenSPP (Odoo 19) | 0.9 per 1000 lines, +69 another tool had already flagged, +71 advisory |
|
| 559 |
| Every one of those findings, classified by hand | **157 true positives**, 137 false, 6 undecidable |
|
|
@@ -675,6 +690,18 @@ again the only thing that makes the rest of it safe.
|
|
| 675 |
`classification-python-2026-09.json`. The rate went up and the precision went down, and
|
| 676 |
that is the trade the section above prices.
|
| 677 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
### The same treatment on a second ecosystem
|
| 679 |
|
| 680 |
A precision number from one ecosystem is a precision number for that ecosystem. The two
|
|
@@ -747,21 +774,51 @@ are the subject of
|
|
| 747 |
|
| 748 |
### What is still wrong, named
|
| 749 |
|
| 750 |
-
`float-comparison-needs-float-utils`
|
| 751 |
-
*integer* to zero — `search_count`, a `read_group` `_count`, `SELECT COUNT(*)`
|
| 752 |
-
regex
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
[the section above](#what-is-wrong-with-the-other-half-named).
|
| 764 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
What the first run found:
|
| 766 |
|
| 767 |
- **`onchange-is-not-validation`** fired on all 16 `@api.onchange` declarations that
|
|
@@ -801,16 +858,22 @@ and again after. A finding that was there before, is gone after, **and sat on a
|
|
| 801 |
line the fix actually changed** is the corpus noticing the bug that was really
|
| 802 |
fixed.
|
| 803 |
|
| 804 |
-
Across
|
| 805 |
the corpus covers:
|
| 806 |
|
| 807 |
-
| | |
|
| 808 |
-
|---|---|
|
| 809 |
-
| Caught | **
|
| 810 |
-
| Missed | 55 |
|
| 811 |
-
|
|
|
|
|
| 812 |
|
| 813 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
Without it, any refactor that moves code counts as a catch. The first version of
|
| 815 |
this tool did not have it and reported **12**. Reading the commit subjects killed
|
| 816 |
the result: *"fix image url in locales with thousand separator"*, *"poor
|
|
@@ -820,18 +883,25 @@ because a dereference moved.
|
|
| 820 |
|
| 821 |
**What this means, stated plainly.** Nexa is precise and narrow. It is right
|
| 822 |
about what it describes, and it describes very little of what actually goes
|
| 823 |
-
wrong.
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
- *"
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
*
|
| 831 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
understates recall against validation bugs specifically. Two repositories in one
|
| 833 |
-
ecosystem is also a narrow sample. The number is a floor
|
| 834 |
-
|
| 835 |
|
| 836 |
The missed list is the more useful output. Every entry is a bug somebody
|
| 837 |
actually fixed that the corpus had nothing to say about, which makes it a
|
|
@@ -869,32 +939,126 @@ clusters the fixes the corpus did not flag on the line of the fix by the constru
|
|
| 869 |
each diff touched, so the reading order is the writing order. Against **OpenSPP 2
|
| 870 |
(Odoo 19)** — a second ecosystem on a newer series than the OCA repositories:
|
| 871 |
|
| 872 |
-
| | |
|
| 873 |
-
|---|---|
|
| 874 |
-
| Fix commits examined | 406 |
|
| 875 |
-
| …touching a construct the corpus covers | 160 |
|
| 876 |
-
| Caught | 6 |
|
| 877 |
-
| Recall against in-scope fixes | 4% |
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
|
| 882 |
```
|
| 883 |
-
|
| 884 |
-
.sudo(
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
|
|
|
|
|
|
| 888 |
```
|
| 889 |
|
| 890 |
**`spoke` is the column that changed the reading.** A miss is not automatically a
|
| 891 |
blind spot: a checker firing somewhere else in the same file means the rule exists
|
| 892 |
-
and is aimed wrong, which wants a sharper rule rather than a new one. Ninety-
|
| 893 |
-
the
|
| 894 |
-
(nothing caught here)`, which reads as a missing rule — and the checker
|
| 895 |
-
firing on sixteen of those files while the fixes went elsewhere.
|
| 896 |
-
|
| 897 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 898 |
`odoo.depends-must-not-name-unread-fields`. Two of the remaining rules in this corpus
|
| 899 |
came from the crosswalk below.
|
| 900 |
|
|
|
|
| 130 |
**Not a general linter.** 27 rules. It will not replace `pylint-odoo`, and after the
|
| 131 |
wrapper above it does not have to.
|
| 132 |
|
| 133 |
+
**Not a bug finder.** Against real Odoo bugfixes the corpus catches 20 of 334 — about
|
| 134 |
+
**6%**, measured across **three** ecosystems and 1,164 fix commits: OpenSPP 6%, the two
|
| 135 |
+
OCA repositories 6%, and odoo core itself 5%. Most bugs are domain logic ("finished
|
| 136 |
+
plans should not be the current plan") and no rule corpus finds those. Measured and
|
| 137 |
+
published here because a precision number without a recall number beside it is the more
|
| 138 |
+
flattering half of the picture. It was 2–4% on two trees before the pass in §3.47; the
|
| 139 |
+
way it moved was reading the missed list, not writing more rules.
|
| 140 |
|
| 141 |
**Odoo only, and that is the whole of it.** There was a TypeScript corpus and a
|
| 142 |
JavaScript one. They got their first field evidence in September 2026 and it was not
|
|
|
|
| 222 |
|
| 223 |
### What is wrong with the other half, named
|
| 224 |
|
| 225 |
+
**Three rules fired and were never right.** `decimal-for-money-not-float` was 0 of 3, and
|
| 226 |
this one has a *reason* behind it rather than a sample size: Odoo's `Monetary` field **is
|
| 227 |
a float** — the ORM defines it that way — and the framework's answer to the precision
|
| 228 |
problem is `float_round` and `float_compare` against the currency's rounding, not
|
| 229 |
+
`Decimal`. Inside an Odoo module the rule fired on the idiom and named a fix that cannot
|
| 230 |
+
be taken. Three findings settle nothing, so it was narrowed rather than culled, on the
|
| 231 |
+
line the argument itself draws: it now fires only where the value is still a string
|
| 232 |
+
when it arrives, which is the one place `Decimal(value)` is available. `float(self.amount)`
|
| 233 |
+
is the ORM's own type and is not reported. Re-run on both trees: **0 findings**, from 3.
|
| 234 |
+
|
| 235 |
+
`path-join-does-not-contain-a-path` was 0 of 4 — all four join a configured directory to
|
| 236 |
+
a name this module generated, and the pattern matched the *word* `filename` rather than
|
| 237 |
+
anything about where the value came from. It now asks where the value came from instead,
|
| 238 |
+
and reports only a component that arrived from outside the file. Re-run: **0 findings**,
|
| 239 |
+
from 4. That is less than the regex reported, on purpose; everything the regex reported
|
| 240 |
+
was false. `eval-and-pickle-execute-their-input` is 0 of 1, and that one is below.
|
| 241 |
|
| 242 |
**Three more never fired at all**, and silence is not a pass: it says 260,000 lines
|
| 243 |
contain none of what they look for, which is also what a broken checker says. Two of the
|
| 244 |
three are the two ruff already covers, which is the more likely explanation.
|
| 245 |
|
| 246 |
+
**A checker defect, found by classifying rather than by testing.** Two of the 50 were the
|
| 247 |
+
same fault: `scan_source` stripped `#` comments before matching and did **not** strip
|
| 248 |
string literals, so `eval()` named in a docstring arguing *against* using it, and a
|
| 249 |
+
`datetime.now()` inside a doctest example, both read as code. Docstrings are now emptied
|
| 250 |
+
before any pattern sees the file — docstrings only, since a triple-quoted SQL query is
|
| 251 |
+
data the checkers have a legitimate interest in — with the quotes, the newlines and the
|
| 252 |
+
length left in place so no line or column moves. Both findings are gone on the re-run.
|
| 253 |
|
| 254 |
**A suppression that should not have fired.** One finding — a consent-receipt timestamp
|
| 255 |
+
— sat inside a `return` statement carrying a `# nosemgrep: odoo-sudo-without-context`
|
| 256 |
+
marker four lines below it. Suppression read any marker within the statement without
|
| 257 |
+
asking which rule it named. That is the mute button this project says it will not build,
|
| 258 |
+
reached from the other direction. A marker that *says* what it is about — a code with
|
| 259 |
+
words in it — now covers only rules that share one of those words, so a marker about
|
| 260 |
+
sudo no longer speaks for a finding about datetimes, while the 141 OpenSPP sudo sites
|
| 261 |
+
carrying that same marker still read as decided. A code with no words in it, `B608` or
|
| 262 |
+
`E501`, cannot be compared with anything and covers its line as before; that gap is
|
| 263 |
+
named in `layers._marker_is_about` rather than papered over.
|
| 264 |
|
| 265 |
---
|
| 266 |
|
|
|
|
| 543 |
bad it is if this happens; `likelihood` is how likely a finding is the bug, graded
|
| 544 |
from what `nexa recall --aim` measured against real fix commits rather than from
|
| 545 |
judgement. A rule can be severe and rarely the point — a Python uniqueness check
|
| 546 |
+
really does race, and across 237 in-scope fixes in two ecosystems not one of them
|
| 547 |
+
was about that — and one field could not say so.
|
| 548 |
|
| 549 |
Everything else is **derived** and must never be hand-edited:
|
| 550 |
|
|
|
|
| 562 |
|---|---|
|
| 563 |
| Rules | 27 — 19 Odoo ORM, 8 plain-Python, one stack |
|
| 564 |
| Critical | 12 |
|
| 565 |
+
| Machine-checkable | 26 (96%) — 12 by pattern, 14 by AST |
|
| 566 |
| Traceable to upstream docs | 25 (93%), each audited for topic fit |
|
| 567 |
| Pattern false positives | 0 cross-hits across the corpus |
|
| 568 |
| Recall under meaning-preserving rewrite | 79/79 |
|
| 569 |
+
| Satisfied patterns bypassable by a comment | 0 of 3 |
|
| 570 |
| Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
|
| 571 |
+
| Hit rate on 33,960 lines of reviewed OCA code | 3.3 per 1000 lines |
|
| 572 |
| Those 120 hits, classified by hand | **86 true positives**, 21 false, 13 undecidable from one file |
|
| 573 |
| Hit rate on 226,937 lines of OpenSPP (Odoo 19) | 0.9 per 1000 lines, +69 another tool had already flagged, +71 advisory |
|
| 574 |
| Every one of those findings, classified by hand | **157 true positives**, 137 false, 6 undecidable |
|
|
|
|
| 690 |
`classification-python-2026-09.json`. The rate went up and the precision went down, and
|
| 691 |
that is the trade the section above prices.
|
| 692 |
|
| 693 |
+
**Re-measured again on 2026-09-21, after the six false-positive causes named above were
|
| 694 |
+
closed: 112 hits, 3.3 per 1000 lines.** Four known false positives are gone — three
|
| 695 |
+
`path-join` and one `decimal-for-money` — and three new `float-comparison` findings
|
| 696 |
+
arrived, each read by hand, from a `satisfied_pattern` that had been clearing whole
|
| 697 |
+
files on a `float_compare` written somewhere else in them.
|
| 698 |
+
|
| 699 |
+
The check that makes all of this safe is the same one every time, and it is worth
|
| 700 |
+
stating as a number rather than a promise: across all five classification artifacts,
|
| 701 |
+
**244 hand-classified true positives, 244 still found, none lost.** That is asserted by
|
| 702 |
+
re-running the checkers against the same two checkouts and matching every `true` verdict
|
| 703 |
+
back to a live finding, not by reading the diff and hoping.
|
| 704 |
+
|
| 705 |
### The same treatment on a second ecosystem
|
| 706 |
|
| 707 |
A precision number from one ecosystem is a precision number for that ecosystem. The two
|
|
|
|
| 774 |
|
| 775 |
### What is still wrong, named
|
| 776 |
|
| 777 |
+
`float-comparison-needs-float-utils` was **2 true, 5 false**. All five compared an
|
| 778 |
+
*integer* to zero — `search_count`, a `read_group` `_count`, `SELECT COUNT(*)`, a
|
| 779 |
+
`fields.Integer` — and the regex could not see it because it matched the variable's
|
| 780 |
+
name, not its value. Three of the five and both true positives were spelled `total == 0`.
|
| 781 |
+
The checker now follows the assignment, and the five are gone. It also stopped clearing
|
| 782 |
+
the whole file on a `float_compare` anywhere in it, which the old `satisfied_pattern`
|
| 783 |
+
did: on OCA that surfaces three `Monetary` and `Float` fields compared with `!= 0` in
|
| 784 |
+
files that use `float_compare` elsewhere, each read by hand. **OpenSPP 7 → 2, OCA 1 → 3.**
|
| 785 |
+
|
| 786 |
+
`onchange-is-not-validation` was **4 true, 3 false**, and the three were not what they
|
| 787 |
+
looked like. Each is a four-line onchange that assigns a field, followed by an action
|
| 788 |
+
method that raises — and the regex read 400 characters past the decorator with no way to
|
| 789 |
+
know where the method ended. `_onchange_variable_id` sets a target model; the
|
| 790 |
+
`UserError` 200 characters below it belongs to `action_remap`. Scoped to the decorated
|
| 791 |
+
function, the three are gone. Three new ones arrived in their place and were read: a
|
| 792 |
+
phone-number validation living only in an onchange with no constraint in the file, a
|
| 793 |
+
`_check_date_collected` onchange raising `exceptions.ValidationError` — a spelling the
|
| 794 |
+
regex's `\w*` never matched — and one that clears a field and returns the warning dict.
|
| 795 |
+
Count unchanged at 7; set changed entirely.
|
| 796 |
+
|
| 797 |
+
The composed-in Python half's four are fixed and measured in
|
| 798 |
[the section above](#what-is-wrong-with-the-other-half-named).
|
| 799 |
|
| 800 |
+
`sudo-bypasses-record-rules` now checks four clauses rather than one: a **query** under
|
| 801 |
+
sudo (search, search_count, search_read, read_group), a **touch** (create, write,
|
| 802 |
+
unlink, copy, read), a sudo recordset bound to a name and **passed on**, and a name
|
| 803 |
+
**rebound** to its own sudo form. The query clause is the one that was always checked
|
| 804 |
+
and it keeps its 6 hand-classified true positives.
|
| 805 |
+
|
| 806 |
+
Every live finding the new clauses made across both trees was read by hand — **0 true,
|
| 807 |
+
24 false**, in `classification-sudo-2026-09.json`. Each is code where the caller is an
|
| 808 |
+
administrator by construction, a demo-data seeder or a wizard behind
|
| 809 |
+
`group_account_manager`, or a framework model with no tenant boundary at all. So the
|
| 810 |
+
touch and escape clauses report at likelihood `unproven`, which makes their findings
|
| 811 |
+
advisory: shown, counted apart, gating nothing. The grade is carried by the finding and
|
| 812 |
+
not by the rule, so the query clause keeps `defect`. A clause the rule's own sentence
|
| 813 |
+
asks for is worth having under measurement; a clause measured at zero is not worth
|
| 814 |
+
failing a build on.
|
| 815 |
+
|
| 816 |
+
The rebind clause is the exception and keeps `defect`, because it has the one thing the
|
| 817 |
+
others lack: an incident. It turns the miss this README names by hand —
|
| 818 |
+
*"remove sudo() when importing record"* — into an on-target catch, which is the same
|
| 819 |
+
evidence every other `defect` grade in the corpus rests on. It also produces one false
|
| 820 |
+
positive at HEAD, and both facts are in the artifact.
|
| 821 |
+
|
| 822 |
What the first run found:
|
| 823 |
|
| 824 |
- **`onchange-is-not-validation`** fired on all 16 `@api.onchange` declarations that
|
|
|
|
| 858 |
line the fix actually changed** is the corpus noticing the bug that was really
|
| 859 |
fixed.
|
| 860 |
|
| 861 |
+
Across 386 fix commits in those two repositories, 64 of which touched a construct
|
| 862 |
the corpus covers:
|
| 863 |
|
| 864 |
+
| | 2026-09-16 | 2026-09-21 |
|
| 865 |
+
|---|---|---|
|
| 866 |
+
| Caught | 1 | **4** |
|
| 867 |
+
| Missed | 55 | 60 |
|
| 868 |
+
| Recall against in-scope fixes | 2% | **6%** |
|
| 869 |
+
| Rules that caught anything | 1 of 14 | **3 of 27** |
|
| 870 |
|
| 871 |
+
The right-hand column is after the pass in §3.47 of ARCHITECTURE. Three of the
|
| 872 |
+
four catches are `sudo-bypasses-record-rules`, which grew the two clauses its own
|
| 873 |
+
sentence always asked for; the in-scope denominator went up as well, from 56 to
|
| 874 |
+
64, because the corpus now covers more constructs than it did.
|
| 875 |
+
|
| 876 |
+
That condition, *on a line the fix changed*, is the entire measurement.
|
| 877 |
Without it, any refactor that moves code counts as a catch. The first version of
|
| 878 |
this tool did not have it and reported **12**. Reading the commit subjects killed
|
| 879 |
the result: *"fix image url in locales with thousand separator"*, *"poor
|
|
|
|
| 883 |
|
| 884 |
**What this means, stated plainly.** Nexa is precise and narrow. It is right
|
| 885 |
about what it describes, and it describes very little of what actually goes
|
| 886 |
+
wrong. Three bugs squarely inside its own stated territory were named here as
|
| 887 |
+
misses, and one of them is now a catch:
|
| 888 |
+
|
| 889 |
+
- *"remove sudo() when importing record"* — **caught, 2026-09-21.** The line the
|
| 890 |
+
fix deletes is `self = self.sudo()`, which rebinds the method's own receiver so
|
| 891 |
+
that everything below it runs elevated without naming sudo again. Every clause
|
| 892 |
+
looking for `.sudo().method()` was blind to it. The commit says what it cost:
|
| 893 |
+
*"finding multiple records across all companies during import when they have
|
| 894 |
+
same record name."*
|
| 895 |
+
- *"prevent removal of x2many values from inaccessible companies"* — still
|
| 896 |
+
missed. A multi-company leak, which `check-company-on-relations` is about.
|
| 897 |
+
- *"log computed fields stored in db as expected"* — still missed. A stored
|
| 898 |
+
compute.
|
| 899 |
+
|
| 900 |
+
**What this does not mean.** The denominator is generous. Many of those 64
|
| 901 |
+
commits touched a covered construct while fixing something else entirely, so 6%
|
| 902 |
understates recall against validation bugs specifically. Two repositories in one
|
| 903 |
+
ecosystem is also a narrow sample. The number is still a floor; it is a higher
|
| 904 |
+
one than it was.
|
| 905 |
|
| 906 |
The missed list is the more useful output. Every entry is a bug somebody
|
| 907 |
actually fixed that the corpus had nothing to say about, which makes it a
|
|
|
|
| 939 |
each diff touched, so the reading order is the writing order. Against **OpenSPP 2
|
| 940 |
(Odoo 19)** — a second ecosystem on a newer series than the OCA repositories:
|
| 941 |
|
| 942 |
+
| | 2026-09-16 | 2026-09-21 |
|
| 943 |
+
|---|---|---|
|
| 944 |
+
| Fix commits examined | 406 | 409 |
|
| 945 |
+
| …touching a construct the corpus covers | 160 | 173 |
|
| 946 |
+
| Caught | 6 | **11** |
|
| 947 |
+
| Recall against in-scope fixes | 4% | **6%** |
|
| 948 |
+
|
| 949 |
+
**A third ecosystem, and it agrees.** The criticism this section used to carry was that
|
| 950 |
+
two repositories in one ecosystem is a narrow sample. So the same pass was run against
|
| 951 |
+
**odoo core 18.0** — 559,663 lines, 190,000 commits — capped at 400 fix commits:
|
| 952 |
+
|
| 953 |
+
| | OpenSPP | OCA | odoo core | all three |
|
| 954 |
+
|---|---|---|---|---|
|
| 955 |
+
| Fix commits examined | 409 | 386 | 369 | **1,164** |
|
| 956 |
+
| …touching a covered construct | 173 | 64 | 97 | **334** |
|
| 957 |
+
| Caught | 11 | 4 | 5 | **20** |
|
| 958 |
+
| Recall against in-scope fixes | 6% | 6% | 5% | **6%** |
|
| 959 |
+
|
| 960 |
+
Three ecosystems arriving at the same number from different directions is worth more
|
| 961 |
+
than any one of them. It was 2% and 4% on two trees before this pass. The floor is
|
| 962 |
+
still a floor; it is a better-founded one.
|
| 963 |
+
|
| 964 |
+
Precision on core is higher than on either other tree: **2,663 findings at 4.8 per 1000
|
| 965 |
+
lines** after the narrowing below. Odoo core is the reference implementation, so a rate
|
| 966 |
+
there is a statement about the rules and not about somebody's house style.
|
| 967 |
+
|
| 968 |
+
**The first classification here that is a sample rather than a census.** Reading all
|
| 969 |
+
2,784 findings is not a session's work, and 2,784 is twenty-five times any set read here
|
| 970 |
+
before. So 15 findings of the largest rule — `stored-compute-needs-complete-depends`,
|
| 971 |
+
1,051 of the total — were drawn with a fixed seed and read by hand:
|
| 972 |
+
**11 true, 4 false**, in `classification-odoo-core-2026-09.json`. That is 73%, with a
|
| 973 |
+
95% Wilson interval of roughly 48% to 89%. The interval is wide, and it is the honest
|
| 974 |
+
number for fifteen: it says the rule is not mostly noise on core and not much more. The
|
| 975 |
+
draw reproduces from the artifact's own description, without the script that made it.
|
| 976 |
+
|
| 977 |
+
All four false verdicts have one cause. `odoo/models.py` gives every model
|
| 978 |
+
`display_name = fields.Char(automatic=True, compute='_compute_display_name', ...)` with
|
| 979 |
+
**no store**, so an override that omits a dependency cannot leave a stale row: there is
|
| 980 |
+
no row. The checker read "not declared in this file" as unknown and kept the finding,
|
| 981 |
+
which is right for a field declared in another module and wrong for one the framework
|
| 982 |
+
declares itself. **121 of that rule's 1,051 findings on core were this shape.** The
|
| 983 |
+
narrowing is free and that was checked, not assumed: all ten hand-verified true
|
| 984 |
+
positives that compute `display_name` declare `store=True` on it in their own file, so
|
| 985 |
+
they are untouched.
|
| 986 |
+
|
| 987 |
+
The larger thing the sample turned up was deliberately **not** acted on. 837 of core's
|
| 988 |
+
1,360 stored computes are `readonly=False` — the "compute a smart default, then let the
|
| 989 |
+
user own it" pattern, where a narrow `@api.depends` is often protecting a user's edit
|
| 990 |
+
rather than forgetting a dependency. That would explain most of this rule's volume on
|
| 991 |
+
core, and it is not a safe narrowing: three of the 47 resolvable hand-verified true
|
| 992 |
+
positives are themselves `readonly=False`, and so are nine of the eleven true verdicts
|
| 993 |
+
in this sample. The number big enough to justify the rule is the number that refutes it.
|
| 994 |
+
|
| 995 |
+
The next three rules by volume were read the same way, 15 each, so 60 of core's findings
|
| 996 |
+
are now classified across the four rules that make 70% of them:
|
| 997 |
+
|
| 998 |
+
| rule | core | previously measured |
|
| 999 |
+
|---|---|---|
|
| 1000 |
+
| `ondelete-is-a-decision` | **13/15** | 12/12 OCA, 14/14 OpenSPP |
|
| 1001 |
+
| `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA |
|
| 1002 |
+
| `assert-is-not-validation` | **3/15** | 6/10 OCA |
|
| 1003 |
+
| `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP |
|
| 1004 |
+
|
| 1005 |
+
**Precision turns out to depend on what kind of code is under review**, and this is the
|
| 1006 |
+
first tree here that made that legible. Two rules hold up. Two collapse, and not because
|
| 1007 |
+
the checkers broke. `assert-is-not-validation`'s own true class is *the asserted value
|
| 1008 |
+
came from outside the module*, and a framework's asserts are invariants between its own
|
| 1009 |
+
components — the registry, the field descriptors, the query builder — where there is no
|
| 1010 |
+
outside. `sudo-bypasses-record-rules` describes an application leaking between tenants,
|
| 1011 |
+
and core is the thing that implements tenancy: every finding is a public route resolving
|
| 1012 |
+
a token before identity exists, a delete guard that must see what the caller cannot, or
|
| 1013 |
+
a cross-company sync whose whole purpose is to cross companies.
|
| 1014 |
+
|
| 1015 |
+
Neither moves a grade. A precision number is about a subject, and nobody reviews odoo
|
| 1016 |
+
core with this. It is the strongest argument so far that one precision figure for "the
|
| 1017 |
+
corpus" says less than the same figure per kind of codebase.
|
| 1018 |
+
|
| 1019 |
+
Reading them turned up three more checker defects, all of the same family as the
|
| 1020 |
+
`sudo(False)` one above: a model with `_auto = False` is a SQL view with no foreign key,
|
| 1021 |
+
so `ondelete` is not a decision about it; `sudo(flag=False)` is de-escalation by
|
| 1022 |
+
keyword; and `x.sudo(self.env.su)` restores the ambient level and cannot raise
|
| 1023 |
+
privilege. Core is now **2,650 findings at 4.7 per 1000 lines**.
|
| 1024 |
+
|
| 1025 |
+
One finding is worth acting on outside this project. Core's
|
| 1026 |
+
`ir.model.access.group_names_with_access` interpolates its `access_mode` argument
|
| 1027 |
+
straight into SQL as `perm_{access_mode}`, and the only thing constraining it to four
|
| 1028 |
+
literals is an `assert`. The method is public and RPC-callable. Odoo does not run under
|
| 1029 |
+
`python -O`, so it is latent rather than live — but it is exactly the harm the rule
|
| 1030 |
+
describes, in the reference implementation.
|
| 1031 |
+
|
| 1032 |
+
Still unread: roughly 1,590 findings on core, led by `naive-and-aware-datetimes` (167),
|
| 1033 |
+
`constrains-must-list-every-field-read` (151) and `depends-must-not-name-unread-fields`
|
| 1034 |
+
(134).
|
| 1035 |
+
|
| 1036 |
+
Where the OpenSPP clustering stands now:
|
| 1037 |
|
| 1038 |
```
|
| 1039 |
+
missed caught spoke silent
|
| 1040 |
+
.sudo( 67 9 52 15
|
| 1041 |
+
ValidationError 40 2 27 13
|
| 1042 |
+
Many2one 26 1 14 12
|
| 1043 |
+
api.depends 23 0 14 9
|
| 1044 |
+
noupdate 22 0 9 13
|
| 1045 |
+
api.constrains 16 0 10 6
|
| 1046 |
```
|
| 1047 |
|
| 1048 |
**`spoke` is the column that changed the reading.** A miss is not automatically a
|
| 1049 |
blind spot: a checker firing somewhere else in the same file means the rule exists
|
| 1050 |
+
and is aimed wrong, which wants a sharper rule rather than a new one. Ninety-seven
|
| 1051 |
+
of the 162 misses are that. The first version of this table reported `21x
|
| 1052 |
+
api.depends (nothing caught here)`, which reads as a missing rule — and the checker
|
| 1053 |
+
was in fact firing on sixteen of those files while the fixes went elsewhere.
|
| 1054 |
+
|
| 1055 |
+
`.sudo(` stayed the largest cluster and was worked twice. Its silent column fell
|
| 1056 |
+
from 23 to 15 and its caught column rose from 6 to 9, because the rule grew the two
|
| 1057 |
+
clauses its own sentence asked for and then a third, `self = self.sudo()`, taken
|
| 1058 |
+
from a miss in the OCA list. `noupdate` is now the cluster with the most silence
|
| 1059 |
+
against the least coverage, and it is next.
|
| 1060 |
+
|
| 1061 |
+
Reading the diffs behind the seven that were silent produced one rule,
|
| 1062 |
`odoo.depends-must-not-name-unread-fields`. Two of the remaining rules in this corpus
|
| 1063 |
came from the crosswalk below.
|
| 1064 |
|
|
@@ -49,6 +49,13 @@ _RULE_DOMAIN_RE = re.compile(
|
|
| 49 |
# real filter, because deciding that a domain is vacuous in general means
|
| 50 |
# evaluating it.
|
| 51 |
_VACUOUS_DOMAIN = re.compile(r"^\[\s*\(\s*1\s*,\s*[\"']=[\"']\s*,\s*1\s*\)\s*\]$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
_NAME_RE = re.compile(r"[\"']name[\"']\s*:\s*[\"']([^\"']+)[\"']")
|
| 53 |
_MODEL_RE = re.compile(r"^\s*_(?:name|inherit)\s*=\s*[\"']([a-z0-9_.]+)[\"']", re.M)
|
| 54 |
_ODOO_IMPORT_RE = re.compile(r"^\s*from\s+odoo[\w.]*\s+import\s+(.+)$", re.M)
|
|
@@ -263,7 +270,7 @@ def tree_facts(paths, complete=True):
|
|
| 263 |
|
| 264 |
declared, deleted = set(), set()
|
| 265 |
unlink_tokens = set()
|
| 266 |
-
ruled =
|
| 267 |
multi_company = False
|
| 268 |
seen = 0
|
| 269 |
# Every series the manifests in this walk name. Kept as a set because
|
|
@@ -297,7 +304,8 @@ def tree_facts(paths, complete=True):
|
|
| 297 |
semantics.MULTI_COMPANY_MARKERS):
|
| 298 |
multi_company = True
|
| 299 |
if "ir.rule" in body:
|
| 300 |
-
|
|
|
|
| 301 |
|
| 302 |
# Access tokens are matched FORWARDS, from each declared model to the token
|
| 303 |
# it would have. Parsing `model_sale_order_line` back into a dotted name is
|
|
@@ -312,6 +320,7 @@ def tree_facts(paths, complete=True):
|
|
| 312 |
return {"walked": True, "complete": complete, "files": seen,
|
| 313 |
"models_declared": declared, "models_deleted": deleted,
|
| 314 |
"models_record_ruled": ruled,
|
|
|
|
| 315 |
"multi_company": multi_company,
|
| 316 |
"odoo_series": pinned.pop() if len(pinned) == 1 else None}
|
| 317 |
|
|
@@ -342,7 +351,7 @@ def _models_with_a_filtering_rule(body):
|
|
| 342 |
pattern silently lost them; and a model whose rules are all unreadable is a
|
| 343 |
model nobody can show is protected, which is a finding worth making anyway.
|
| 344 |
"""
|
| 345 |
-
found =
|
| 346 |
for block in _IR_RULE_RE.findall(body):
|
| 347 |
ref = _RULE_MODEL_RE.search(block)
|
| 348 |
if not ref:
|
|
@@ -354,7 +363,15 @@ def _models_with_a_filtering_rule(body):
|
|
| 354 |
text = " ".join(domain.group(1).split()) if domain else ""
|
| 355 |
if text and _VACUOUS_DOMAIN.match(text):
|
| 356 |
continue
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
return found
|
| 359 |
|
| 360 |
|
|
@@ -390,6 +407,28 @@ def model_is_record_ruled(facts, model):
|
|
| 390 |
return ABSENT if model in facts.get("models_declared", ()) else UNKNOWN
|
| 391 |
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
def _models_granted_unlink(body):
|
| 394 |
"""The `model_id:id` tokens an ir.model.access.csv grants perm_unlink on."""
|
| 395 |
rows = list(csv.reader(io.StringIO(body)))
|
|
|
|
| 49 |
# real filter, because deciding that a domain is vacuous in general means
|
| 50 |
# evaluating it.
|
| 51 |
_VACUOUS_DOMAIN = re.compile(r"^\[\s*\(\s*1\s*,\s*[\"']=[\"']\s*,\s*1\s*\)\s*\]$")
|
| 52 |
+
|
| 53 |
+
# The left operand of every leaf in a domain: the fields the rule filters ON.
|
| 54 |
+
# A domain is a flat list of triples once the operators are ignored, and the
|
| 55 |
+
# first element of each is a field path. Read for one purpose only -- deciding
|
| 56 |
+
# whether a call under sudo names the field the rule it bypasses is about --
|
| 57 |
+
# and a field this fails to read simply is not offered as an exemption.
|
| 58 |
+
_RULE_FIELD_RE = re.compile(r"[\(\[]\s*[\"']([a-z_][a-z0-9_.]*)[\"']\s*,")
|
| 59 |
_NAME_RE = re.compile(r"[\"']name[\"']\s*:\s*[\"']([^\"']+)[\"']")
|
| 60 |
_MODEL_RE = re.compile(r"^\s*_(?:name|inherit)\s*=\s*[\"']([a-z0-9_.]+)[\"']", re.M)
|
| 61 |
_ODOO_IMPORT_RE = re.compile(r"^\s*from\s+odoo[\w.]*\s+import\s+(.+)$", re.M)
|
|
|
|
| 270 |
|
| 271 |
declared, deleted = set(), set()
|
| 272 |
unlink_tokens = set()
|
| 273 |
+
ruled = {}
|
| 274 |
multi_company = False
|
| 275 |
seen = 0
|
| 276 |
# Every series the manifests in this walk name. Kept as a set because
|
|
|
|
| 304 |
semantics.MULTI_COMPANY_MARKERS):
|
| 305 |
multi_company = True
|
| 306 |
if "ir.rule" in body:
|
| 307 |
+
for model, fields in _models_with_a_filtering_rule(body).items():
|
| 308 |
+
ruled.setdefault(model, set()).update(fields)
|
| 309 |
|
| 310 |
# Access tokens are matched FORWARDS, from each declared model to the token
|
| 311 |
# it would have. Parsing `model_sale_order_line` back into a dotted name is
|
|
|
|
| 320 |
return {"walked": True, "complete": complete, "files": seen,
|
| 321 |
"models_declared": declared, "models_deleted": deleted,
|
| 322 |
"models_record_ruled": ruled,
|
| 323 |
+
"models_rule_fields": {m: tuple(sorted(f)) for m, f in ruled.items()},
|
| 324 |
"multi_company": multi_company,
|
| 325 |
"odoo_series": pinned.pop() if len(pinned) == 1 else None}
|
| 326 |
|
|
|
|
| 351 |
pattern silently lost them; and a model whose rules are all unreadable is a
|
| 352 |
model nobody can show is protected, which is a finding worth making anyway.
|
| 353 |
"""
|
| 354 |
+
found = {}
|
| 355 |
for block in _IR_RULE_RE.findall(body):
|
| 356 |
ref = _RULE_MODEL_RE.search(block)
|
| 357 |
if not ref:
|
|
|
|
| 363 |
text = " ".join(domain.group(1).split()) if domain else ""
|
| 364 |
if text and _VACUOUS_DOMAIN.match(text):
|
| 365 |
continue
|
| 366 |
+
model = token[len("model_"):].replace("_", ".")
|
| 367 |
+
fields = found.setdefault(model, set())
|
| 368 |
+
for path in _RULE_FIELD_RE.findall(text):
|
| 369 |
+
# Both the path and the relation it starts from. A rule reading
|
| 370 |
+
# `case_id.case_worker_id` is satisfied by a create that names
|
| 371 |
+
# `case_id`, because the case it points at is one the caller
|
| 372 |
+
# can already see and the rule follows the link to find out.
|
| 373 |
+
fields.add(path)
|
| 374 |
+
fields.add(path.split(".", 1)[0])
|
| 375 |
return found
|
| 376 |
|
| 377 |
|
|
|
|
| 407 |
return ABSENT if model in facts.get("models_declared", ()) else UNKNOWN
|
| 408 |
|
| 409 |
|
| 410 |
+
def model_rule_fields(facts, model):
|
| 411 |
+
"""The fields this model's own filtering rules are written about.
|
| 412 |
+
|
| 413 |
+
`[('case_worker_id', '=', user.id)]` yields `case_worker_id`, and
|
| 414 |
+
`[('case_id.case_worker_id', '=', user.id)]` yields both the path and
|
| 415 |
+
`case_id`, the relation it is reached through.
|
| 416 |
+
|
| 417 |
+
This exists so a call under sudo can be compared against the rule it
|
| 418 |
+
would actually bypass, instead of against the word `company_id`. A
|
| 419 |
+
per-user rule is as real as a multi-company one -- OpenSPP's case models
|
| 420 |
+
are governed entirely by `case_worker_id` and `case_id.case_worker_id`,
|
| 421 |
+
and not one of them has a company rule at all -- so a checker that knows
|
| 422 |
+
only the multi-company spelling cannot see when the advice was followed.
|
| 423 |
+
|
| 424 |
+
Empty for a model nobody declared a readable rule for, which offers no
|
| 425 |
+
exemption and leaves the finding standing.
|
| 426 |
+
"""
|
| 427 |
+
if not facts or not facts.get("walked") or not model:
|
| 428 |
+
return ()
|
| 429 |
+
return tuple(facts.get("models_rule_fields", {}).get(model, ()))
|
| 430 |
+
|
| 431 |
+
|
| 432 |
def _models_granted_unlink(body):
|
| 433 |
"""The `model_id:id` tokens an ir.model.access.csv grants perm_unlink on."""
|
| 434 |
rows = list(csv.reader(io.StringIO(body)))
|
|
@@ -246,6 +246,14 @@ def check_compliance(text, rules):
|
|
| 246 |
is exactly what the 7B did. This checks the generated code for each rule's
|
| 247 |
violation signature instead.
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
Kept as a module-level function with the original signature because pipeline
|
| 250 |
and corpus both call it directly; the domain layer wraps it.
|
| 251 |
"""
|
|
@@ -255,13 +263,25 @@ def check_compliance(text, rules):
|
|
| 255 |
views = {}
|
| 256 |
violations, checked = [], []
|
| 257 |
for r in rules:
|
| 258 |
-
|
|
|
|
| 259 |
continue
|
| 260 |
checked.append(r["id"])
|
| 261 |
kind = comment_kind(r.get("stack"))
|
| 262 |
if kind not in views:
|
| 263 |
views[kind] = semantics.strip_comments(code, kind)
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
violations.append((r["id"], r["severity"], r["title"]))
|
| 266 |
return violations, checked
|
| 267 |
|
|
@@ -276,6 +296,98 @@ SUPPRESSION_MARKERS = ("noqa", "nosemgrep", "nosec", "pylint: disable",
|
|
| 276 |
"pylint:disable", "type: ignore", "eslint-disable",
|
| 277 |
"@ts-ignore", "@ts-expect-error")
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
# Statement kinds a marker is allowed to reach across. Every one of these is a
|
| 280 |
# SIMPLE statement: it has no body of its own, so its line span is the span of
|
| 281 |
# one expression and nothing else can hide inside it. Compound statements are
|
|
@@ -285,8 +397,28 @@ _SIMPLE_STMTS = (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Expr,
|
|
| 285 |
ast.Return, ast.Raise, ast.Assert, ast.Delete)
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
def _simple_statement_spans(body):
|
| 289 |
-
"""(start, end) for every simple statement
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
A finding anchors on one line, and a suppression marker written about that
|
| 292 |
finding may sit on another line of the SAME expression. Measured on
|
|
@@ -319,7 +451,7 @@ def _simple_statement_spans(body):
|
|
| 319 |
# 36 of OpenSPP's 105 sit there, above a statement that fits on
|
| 320 |
# one line and has no room for anything else. Reading the same
|
| 321 |
# convention the marker was written under is not obeying it.
|
| 322 |
-
spans.append((max(1, start - 1), end))
|
| 323 |
return spans
|
| 324 |
|
| 325 |
|
|
@@ -405,24 +537,33 @@ def scan_source(body, rules, stack, errors=None, facts=None, gaps=None):
|
|
| 405 |
# call will read as unsuppressed when a person had in fact marked it.
|
| 406 |
spans = _simple_statement_spans(body) if comment_kind(stack) == "python" else []
|
| 407 |
|
| 408 |
-
def
|
| 409 |
-
|
|
|
|
| 410 |
|
| 411 |
-
def marked(line):
|
| 412 |
text = rows[line - 1] if 0 < line <= len(rows) else ""
|
| 413 |
-
if
|
| 414 |
return True
|
| 415 |
# The smallest simple statement containing this line, if any: a marker
|
| 416 |
# written inside one expression covers that expression and stops there.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
best = None
|
| 418 |
-
for start, end in spans:
|
| 419 |
-
if start <= line <= end
|
| 420 |
-
|
| 421 |
-
|
|
|
|
|
|
|
| 422 |
if best is None:
|
| 423 |
return False
|
| 424 |
-
|
| 425 |
-
|
|
|
|
| 426 |
|
| 427 |
# One parse for the whole file, before any semantic checker runs, so that
|
| 428 |
# "nothing to say" and "could not read it" stop being the same output. Only
|
|
@@ -463,7 +604,7 @@ def scan_source(body, rules, stack, errors=None, facts=None, gaps=None):
|
|
| 463 |
continue
|
| 464 |
seen.add(line)
|
| 465 |
out.append(SourceHit(rule["id"], rule["severity"], line,
|
| 466 |
-
line_at(body, line), marked(line),
|
| 467 |
bool(rule.get("advisory")),
|
| 468 |
rule.get("likelihood", "unmeasured")))
|
| 469 |
elif rule.get("semantic_check"):
|
|
@@ -479,8 +620,19 @@ def scan_source(body, rules, stack, errors=None, facts=None, gaps=None):
|
|
| 479 |
for line, message, grade in found:
|
| 480 |
# A grade means a trigger fired on THIS finding and earned it a
|
| 481 |
# likelihood of its own. Absent, the rule's grade stands.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
out.append(SourceHit(rule["id"], rule["severity"], line, message,
|
| 483 |
-
marked(line
|
|
|
|
|
|
|
| 484 |
grade or rule.get("likelihood",
|
| 485 |
"unmeasured")))
|
| 486 |
if unparsed is not None and gaps is not None:
|
|
|
|
| 246 |
is exactly what the 7B did. This checks the generated code for each rule's
|
| 247 |
violation signature instead.
|
| 248 |
|
| 249 |
+
Semantic checkers count here too. They did not, and the omission was
|
| 250 |
+
invisible because it looked like coverage: a rule whose checker moved from
|
| 251 |
+
a regex to an AST pass silently stopped being checked against generated
|
| 252 |
+
code, and this function's own motivating example -- a model citing
|
| 253 |
+
'onchange is not validation' and then writing an onchange -- is one of
|
| 254 |
+
the rules that moved. A checker that cannot see the answer is not a
|
| 255 |
+
stricter checker.
|
| 256 |
+
|
| 257 |
Kept as a module-level function with the original signature because pipeline
|
| 258 |
and corpus both call it directly; the domain layer wraps it.
|
| 259 |
"""
|
|
|
|
| 263 |
views = {}
|
| 264 |
violations, checked = [], []
|
| 265 |
for r in rules:
|
| 266 |
+
pattern, check = r.get("violation_pattern"), r.get("semantic_check")
|
| 267 |
+
if not pattern and not check:
|
| 268 |
continue
|
| 269 |
checked.append(r["id"])
|
| 270 |
kind = comment_kind(r.get("stack"))
|
| 271 |
if kind not in views:
|
| 272 |
views[kind] = semantics.strip_comments(code, kind)
|
| 273 |
+
hit = False
|
| 274 |
+
if check:
|
| 275 |
+
# An answer whose code does not parse is a syntactic finding and
|
| 276 |
+
# is reported as one; here it simply produces no semantic hit,
|
| 277 |
+
# exactly as a pattern that does not match produces none.
|
| 278 |
+
try:
|
| 279 |
+
hit = bool(semantics.run_check(check, code))
|
| 280 |
+
except (SyntaxError, ValueError, RecursionError):
|
| 281 |
+
hit = False
|
| 282 |
+
if not hit and pattern:
|
| 283 |
+
hit = fires(r, views[kind], stripped=True)
|
| 284 |
+
if hit:
|
| 285 |
violations.append((r["id"], r["severity"], r["title"]))
|
| 286 |
return violations, checked
|
| 287 |
|
|
|
|
| 296 |
"pylint:disable", "type: ignore", "eslint-disable",
|
| 297 |
"@ts-ignore", "@ts-expect-error")
|
| 298 |
|
| 299 |
+
# What a marker names, if it names anything: `# noqa: E501`,
|
| 300 |
+
# `# nosemgrep: odoo-sudo-without-context`, `# pylint: disable=no-member`,
|
| 301 |
+
# `# type: ignore[arg-type]`, `# nosec B608`. A marker with nothing after it
|
| 302 |
+
# is bare and is about whatever was on that line.
|
| 303 |
+
_MARKER_CODES = re.compile(
|
| 304 |
+
r"(?:" + "|".join(re.escape(m) for m in SUPPRESSION_MARKERS) + r")"
|
| 305 |
+
r"\s*[:=\[]?\s*([A-Za-z][A-Za-z0-9_.\-]*(?:\s*[,\s]\s*[A-Za-z][A-Za-z0-9_.\-]*)*)?")
|
| 306 |
+
|
| 307 |
+
# A code that SAYS what it is about: two or more parts joined by -, _ or .,
|
| 308 |
+
# with a real word among them. `odoo-sudo-without-context` and `no-member`
|
| 309 |
+
# qualify; `B608`, `E501` and `W0212` do not, and neither does the prose
|
| 310 |
+
# people write after a colon -- `# noqa: decided, TICKET-9`. Only a code in
|
| 311 |
+
# the first group can be compared with a rule id at all, so only that group
|
| 312 |
+
# is narrowed; everything else keeps the behaviour it had.
|
| 313 |
+
_SPEAKING_CODE = re.compile(r"^(?=[^A-Za-z]*[A-Za-z]{3})"
|
| 314 |
+
r"[A-Za-z0-9]+(?:[-_.][A-Za-z0-9]+)+$")
|
| 315 |
+
|
| 316 |
+
# Words that identify a vendor or a language rather than a defect. A code and
|
| 317 |
+
# a rule id sharing only these has not said they are about the same thing.
|
| 318 |
+
_UNDISTINGUISHING = frozenset([
|
| 319 |
+
"odoo", "python", "py", "js", "ts", "oca", "openspp", "rule", "rules",
|
| 320 |
+
"the", "a", "an", "and", "or", "of", "in", "on", "to", "for", "is", "are",
|
| 321 |
+
"be", "not", "no", "it", "its", "this", "that", "with", "without", "do",
|
| 322 |
+
"does", "must", "should", "needs", "need", "use", "used", "using", "by",
|
| 323 |
+
"from", "at", "as", "all", "any", "every", "one", "two", "disable",
|
| 324 |
+
"ignore", "expect", "error", "warning", "check", "checks", "bad",
|
| 325 |
+
])
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _tokens(text):
|
| 329 |
+
return {t for t in re.split(r"[^A-Za-z0-9]+", text.lower())
|
| 330 |
+
if t and t not in _UNDISTINGUISHING}
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _marker_scope(text):
|
| 334 |
+
"""(carries a marker, codes it names). An empty code set means bare.
|
| 335 |
+
|
| 336 |
+
The first thing after a marker decides. `# nosec B608` names an opaque
|
| 337 |
+
analyser code, `# noqa: decided, TICKET-9` is somebody talking -- neither
|
| 338 |
+
can be compared with a rule id, so both read as bare and cover the line
|
| 339 |
+
the way they always did. A marker whose first code is legible narrows to
|
| 340 |
+
what it names.
|
| 341 |
+
"""
|
| 342 |
+
if not any(marker in text for marker in SUPPRESSION_MARKERS):
|
| 343 |
+
return False, set()
|
| 344 |
+
codes = set()
|
| 345 |
+
for match in _MARKER_CODES.finditer(text):
|
| 346 |
+
named = match.group(1)
|
| 347 |
+
if not named:
|
| 348 |
+
return True, set() # bare: about whatever is on the line
|
| 349 |
+
parts = [p for p in re.split(r"[,\s]+", named.strip()) if p]
|
| 350 |
+
if not parts or not _SPEAKING_CODE.match(parts[0]):
|
| 351 |
+
return True, set()
|
| 352 |
+
codes.update(p for p in parts if _SPEAKING_CODE.match(p))
|
| 353 |
+
return True, codes
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _marker_is_about(rule, codes):
|
| 357 |
+
"""Does a marker naming these codes speak about this rule?
|
| 358 |
+
|
| 359 |
+
A marker that names nothing is about the line and covers anything on it,
|
| 360 |
+
which is how 105 of OpenSPP's 106 `.sudo().search(` findings were read.
|
| 361 |
+
A marker that DOES name something has said what it is about, and reading
|
| 362 |
+
it as covering everything is the mute button this project will not build
|
| 363 |
+
-- reached from the other direction. Classifying 50 findings by hand found
|
| 364 |
+
one: a consent-receipt timestamp silenced by a
|
| 365 |
+
`# nosemgrep: odoo-sudo-without-context` four lines below it, which is a
|
| 366 |
+
marker about sudo suppressing a finding about datetimes.
|
| 367 |
+
|
| 368 |
+
Matched on shared words rather than a mapping table, because no table
|
| 369 |
+
exists between somebody else's rule ids and ours, and `crosswalk` builds
|
| 370 |
+
one only for the analyser it was pointed at. Vendor and language words do
|
| 371 |
+
not count, so `odoo-sudo-without-context` and
|
| 372 |
+
`odoo.sudo-bypasses-record-rules` meet on `sudo` and not on `odoo`.
|
| 373 |
+
|
| 374 |
+
The direction of error is stated: a code this cannot relate to a rule
|
| 375 |
+
leaves the finding REPORTED, which is the side that shows too much rather
|
| 376 |
+
than the side that hides.
|
| 377 |
+
|
| 378 |
+
What is left open, and named rather than quietly accepted: an opaque code
|
| 379 |
+
still covers the line. `# noqa: E501` is a decision about line length and
|
| 380 |
+
reads here as a decision about whatever else is on that line, because
|
| 381 |
+
E501 carries no words to compare. Closing it means a table of another
|
| 382 |
+
analyser's codes, which is what `crosswalk` builds for one analyser at a
|
| 383 |
+
time and is not something this function can assume it has.
|
| 384 |
+
"""
|
| 385 |
+
if not codes:
|
| 386 |
+
return True
|
| 387 |
+
subject = _tokens(rule.get("id", "").split(".", 1)[-1])
|
| 388 |
+
subject.update(_tokens(" ".join(rule.get("tags", []) or [])))
|
| 389 |
+
return any(_tokens(code) & subject for code in codes)
|
| 390 |
+
|
| 391 |
# Statement kinds a marker is allowed to reach across. Every one of these is a
|
| 392 |
# SIMPLE statement: it has no body of its own, so its line span is the span of
|
| 393 |
# one expression and nothing else can hide inside it. Compound statements are
|
|
|
|
| 397 |
ast.Return, ast.Raise, ast.Assert, ast.Delete)
|
| 398 |
|
| 399 |
|
| 400 |
+
def _advisory_grades():
|
| 401 |
+
"""The likelihoods that make a finding advisory, from the one list.
|
| 402 |
+
|
| 403 |
+
Imported lazily because corpus imports this module, and the grades a
|
| 404 |
+
finding can carry must be one list rather than one here and another in
|
| 405 |
+
the validator -- the same reason evidence hashing lives in one place.
|
| 406 |
+
"""
|
| 407 |
+
import corpus # local: corpus imports layers, so not at module level
|
| 408 |
+
return corpus.ADVISORY_LIKELIHOODS
|
| 409 |
+
|
| 410 |
+
|
| 411 |
def _simple_statement_spans(body):
|
| 412 |
+
"""(start, end, own_start) for every simple statement, widest last.
|
| 413 |
+
|
| 414 |
+
`start` reaches one line above the statement to take in a marker written
|
| 415 |
+
there; `own_start` is where the statement itself begins. Both are needed
|
| 416 |
+
because the borrowed line makes two statements overlap, and a finding on
|
| 417 |
+
the first line of one is inside the borrowed line of the one below it. On
|
| 418 |
+
OpenSPP that mattered exactly once and read as a missing suppression:
|
| 419 |
+
`self.client_id.sudo().write(...)` on line 37 carries its nosemgrep on 36
|
| 420 |
+
and is followed by a `return` on 38, whose own span borrows 37. Two spans
|
| 421 |
+
of equal width contained the line and the wrong one was picked.
|
| 422 |
|
| 423 |
A finding anchors on one line, and a suppression marker written about that
|
| 424 |
finding may sit on another line of the SAME expression. Measured on
|
|
|
|
| 451 |
# 36 of OpenSPP's 105 sit there, above a statement that fits on
|
| 452 |
# one line and has no room for anything else. Reading the same
|
| 453 |
# convention the marker was written under is not obeying it.
|
| 454 |
+
spans.append((max(1, start - 1), end, start))
|
| 455 |
return spans
|
| 456 |
|
| 457 |
|
|
|
|
| 537 |
# call will read as unsuppressed when a person had in fact marked it.
|
| 538 |
spans = _simple_statement_spans(body) if comment_kind(stack) == "python" else []
|
| 539 |
|
| 540 |
+
def _speaks_to(text, rule):
|
| 541 |
+
carries, codes = _marker_scope(text)
|
| 542 |
+
return carries and _marker_is_about(rule, codes)
|
| 543 |
|
| 544 |
+
def marked(line, rule):
|
| 545 |
text = rows[line - 1] if 0 < line <= len(rows) else ""
|
| 546 |
+
if _speaks_to(text, rule):
|
| 547 |
return True
|
| 548 |
# The smallest simple statement containing this line, if any: a marker
|
| 549 |
# written inside one expression covers that expression and stops there.
|
| 550 |
+
#
|
| 551 |
+
# A statement that really covers the line beats one that only reaches
|
| 552 |
+
# it through the comment line it borrowed from above. Without that,
|
| 553 |
+
# two spans of equal width tie and the loser is whichever ast.walk
|
| 554 |
+
# happened to reach first.
|
| 555 |
best = None
|
| 556 |
+
for start, end, own in spans:
|
| 557 |
+
if not start <= line <= end:
|
| 558 |
+
continue
|
| 559 |
+
rank = (0 if own <= line else 1, end - start)
|
| 560 |
+
if best is None or rank < best[0]:
|
| 561 |
+
best = (rank, start, end)
|
| 562 |
if best is None:
|
| 563 |
return False
|
| 564 |
+
_, start, end = best
|
| 565 |
+
return any(_speaks_to(rows[n - 1], rule)
|
| 566 |
+
for n in range(start, end + 1) if 0 < n <= len(rows))
|
| 567 |
|
| 568 |
# One parse for the whole file, before any semantic checker runs, so that
|
| 569 |
# "nothing to say" and "could not read it" stop being the same output. Only
|
|
|
|
| 604 |
continue
|
| 605 |
seen.add(line)
|
| 606 |
out.append(SourceHit(rule["id"], rule["severity"], line,
|
| 607 |
+
line_at(body, line), marked(line, rule),
|
| 608 |
bool(rule.get("advisory")),
|
| 609 |
rule.get("likelihood", "unmeasured")))
|
| 610 |
elif rule.get("semantic_check"):
|
|
|
|
| 620 |
for line, message, grade in found:
|
| 621 |
# A grade means a trigger fired on THIS finding and earned it a
|
| 622 |
# likelihood of its own. Absent, the rule's grade stands.
|
| 623 |
+
#
|
| 624 |
+
# Advisory follows the grade, per hit as well as per rule. The
|
| 625 |
+
# corpus already derives a rule's `advisory` flag from its
|
| 626 |
+
# likelihood, and a finding that earned `unproven` on its own
|
| 627 |
+
# evidence has earned the same treatment: reported, out of the
|
| 628 |
+
# rate, gating nothing. Without this a clause measured at zero
|
| 629 |
+
# true positives would gate a build on the strength of the
|
| 630 |
+
# clause beside it, which is the rule's grade answering for a
|
| 631 |
+
# finding it was not measured on.
|
| 632 |
out.append(SourceHit(rule["id"], rule["severity"], line, message,
|
| 633 |
+
marked(line, rule),
|
| 634 |
+
bool(rule.get("advisory"))
|
| 635 |
+
or grade in _advisory_grades(),
|
| 636 |
grade or rule.get("likelihood",
|
| 637 |
"unmeasured")))
|
| 638 |
if unparsed is not None and gaps is not None:
|
|
@@ -398,6 +398,15 @@ def _declared_covers(read, declared):
|
|
| 398 |
return any(d == read or d.startswith(read + ".") for d in declared)
|
| 399 |
|
| 400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
def depends_covers_compute_reads(code, gaps=None):
|
| 402 |
"""odoo.stored-compute-needs-complete-depends
|
| 403 |
|
|
@@ -467,6 +476,24 @@ def depends_covers_compute_reads(code, gaps=None):
|
|
| 467 |
continue
|
| 468 |
registered = set(declared)
|
| 469 |
produced = _assigned_attrs(fn)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
missing = {}
|
| 471 |
for path, lineno in _field_reads(fn, _aliases(fn)):
|
| 472 |
while len(path) > 1 and path[-1] in ORM_ATTRS:
|
|
@@ -885,21 +912,40 @@ def _is_true(node):
|
|
| 885 |
return isinstance(node, ast.Constant) and node.value is True
|
| 886 |
|
| 887 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
def _transient_spans(tree):
|
| 889 |
-
"""Line ranges of every
|
| 890 |
|
| 891 |
A wizard's record is vacuumed, so the failure both ondelete-is-a-decision
|
| 892 |
and stored-compute-needs-complete-depends describe -- a value written once
|
| 893 |
-
and read wrong much later -- has no time in which to happen.
|
| 894 |
-
|
| 895 |
-
|
|
|
|
|
|
|
| 896 |
|
| 897 |
check_company_missing_on_company_owned_relation already had this exemption
|
| 898 |
and iterates classes directly; these two do not, hence the spans.
|
| 899 |
"""
|
| 900 |
return [(n.lineno, getattr(n, "end_lineno", n.lineno))
|
| 901 |
for n in ast.walk(tree)
|
| 902 |
-
if isinstance(n, ast.ClassDef) and _is_transient(n)]
|
| 903 |
|
| 904 |
|
| 905 |
def _within(spans, lineno):
|
|
@@ -1837,78 +1883,822 @@ def declares_multi_company(code):
|
|
| 1837 |
return any(marker in view for marker in MULTI_COMPANY_MARKERS)
|
| 1838 |
|
| 1839 |
|
| 1840 |
-
|
| 1841 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1842 |
|
| 1843 |
-
|
| 1844 |
-
|
| 1845 |
-
|
| 1846 |
-
|
| 1847 |
-
|
| 1848 |
-
|
| 1849 |
-
|
| 1850 |
-
|
| 1851 |
-
|
| 1852 |
-
|
| 1853 |
-
|
| 1854 |
-
|
| 1855 |
-
|
| 1856 |
-
|
| 1857 |
-
|
| 1858 |
-
|
| 1859 |
-
|
| 1860 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1861 |
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 1867 |
-
|
| 1868 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1869 |
"""
|
| 1870 |
tree, _ = parse_python(code)
|
| 1871 |
if tree is None:
|
| 1872 |
return []
|
| 1873 |
|
| 1874 |
-
|
|
|
|
| 1875 |
for node in ast.walk(tree):
|
| 1876 |
-
if not isinstance(node, ast.
|
| 1877 |
-
continue
|
| 1878 |
-
if not isinstance(node.func, ast.Attribute):
|
| 1879 |
continue
|
| 1880 |
-
if
|
| 1881 |
-
continue
|
| 1882 |
-
inner = node.func.value
|
| 1883 |
-
if not (isinstance(inner, ast.Call) and isinstance(inner.func, ast.Attribute)
|
| 1884 |
-
and inner.func.attr == "sudo"):
|
| 1885 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1886 |
|
| 1887 |
-
# The rule's own correct sample re-applies company filtering inside the
|
| 1888 |
-
# domain, so a domain naming company_id is the rule satisfied. Read off
|
| 1889 |
-
# the call's own arguments rather than the file, because a company_id
|
| 1890 |
-
# anywhere else in a module said nothing about THIS search.
|
| 1891 |
-
try:
|
| 1892 |
-
call_src = ast.unparse(node)
|
| 1893 |
-
except Exception: # pragma: no cover
|
| 1894 |
-
call_src = ""
|
| 1895 |
-
if "company_id" in call_src:
|
| 1896 |
-
continue
|
| 1897 |
|
| 1898 |
-
|
| 1899 |
-
|
| 1900 |
-
continue
|
| 1901 |
|
| 1902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1903 |
findings.append((
|
| 1904 |
node.lineno,
|
| 1905 |
-
"
|
| 1906 |
-
"
|
| 1907 |
-
"
|
| 1908 |
None))
|
| 1909 |
return findings
|
| 1910 |
|
| 1911 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1912 |
def _env_model_of(node):
|
| 1913 |
"""The model name at the head of a chained call, or None.
|
| 1914 |
|
|
@@ -1932,7 +2722,11 @@ def _env_model_of(node):
|
|
| 1932 |
|
| 1933 |
CHECKS = {
|
| 1934 |
"handler_order_shadows_a_subclass": handler_order_shadows_a_subclass,
|
| 1935 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1936 |
"check_company_missing_on_company_owned_relation":
|
| 1937 |
check_company_missing_on_company_owned_relation,
|
| 1938 |
"commit_inside_a_loop": commit_inside_a_loop,
|
|
@@ -1954,7 +2748,7 @@ CONTEXT_CHECKS = frozenset([
|
|
| 1954 |
"check_company_missing_on_company_owned_relation",
|
| 1955 |
# Reads models_record_ruled: whether sudo drops anything depends on whether
|
| 1956 |
# the tree declares a rule that filters.
|
| 1957 |
-
"
|
| 1958 |
# Reads odoo_series: whether a _sql_constraints block is a constraint at
|
| 1959 |
# all depends on which Odoo is going to load it.
|
| 1960 |
"prefer_sql_constraint_applies",
|
|
@@ -2167,8 +2961,81 @@ def unresolvable_imports(tree):
|
|
| 2167 |
|
| 2168 |
# ------------------------------------------------------- comment stripping
|
| 2169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2170 |
def strip_comments(code, kind="python"):
|
| 2171 |
-
"""Code with its comments removed,
|
| 2172 |
|
| 2173 |
WHY: a violation_pattern and a satisfied_pattern are regexes, and a regex
|
| 2174 |
cannot tell code from prose. The adversarial counterexample probe put a
|
|
@@ -2182,12 +3049,18 @@ def strip_comments(code, kind="python"):
|
|
| 2182 |
as a live violation, which is the false positive that teaches people to
|
| 2183 |
ignore a checker.
|
| 2184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2185 |
Newlines inside removed comments are preserved so that multi-line patterns
|
| 2186 |
still see the line structure they were written against.
|
| 2187 |
"""
|
| 2188 |
out = []
|
| 2189 |
i, n = 0, len(code)
|
| 2190 |
triple = ("'''", '"""')
|
|
|
|
| 2191 |
|
| 2192 |
while i < n:
|
| 2193 |
ch = code[i]
|
|
@@ -2196,7 +3069,7 @@ def strip_comments(code, kind="python"):
|
|
| 2196 |
marker = code[i:i + 3]
|
| 2197 |
end = code.find(marker, i + 3)
|
| 2198 |
end = n if end < 0 else end + 3
|
| 2199 |
-
out.append(code[i:end])
|
| 2200 |
i = end
|
| 2201 |
continue
|
| 2202 |
|
|
@@ -2212,7 +3085,7 @@ def strip_comments(code, kind="python"):
|
|
| 2212 |
if code[j] == "\n" and kind == "python":
|
| 2213 |
break # unterminated single-quoted string
|
| 2214 |
j += 1
|
| 2215 |
-
out.append(code[i:j])
|
| 2216 |
i = j
|
| 2217 |
continue
|
| 2218 |
|
|
|
|
| 398 |
return any(d == read or d.startswith(read + ".") for d in declared)
|
| 399 |
|
| 400 |
|
| 401 |
+
# Fields the ORM adds to every model itself, with a declaration this corpus
|
| 402 |
+
# can read and a default of NOT stored. A model that wants one of these in the
|
| 403 |
+
# database says so in its own file, and then the ordinary storedness test sees
|
| 404 |
+
# it. Only display_name is here: the other automatic fields -- id, create_uid,
|
| 405 |
+
# create_date, write_uid, write_date -- are never computed by module code, so
|
| 406 |
+
# listing them would be describing a shape nobody writes.
|
| 407 |
+
FRAMEWORK_UNSTORED = frozenset(["display_name"])
|
| 408 |
+
|
| 409 |
+
|
| 410 |
def depends_covers_compute_reads(code, gaps=None):
|
| 411 |
"""odoo.stored-compute-needs-complete-depends
|
| 412 |
|
|
|
|
| 476 |
continue
|
| 477 |
registered = set(declared)
|
| 478 |
produced = _assigned_attrs(fn)
|
| 479 |
+
if produced and produced <= FRAMEWORK_UNSTORED and \
|
| 480 |
+
storedness.get(fn.name) is None:
|
| 481 |
+
# A field the framework adds to every model, which this file does
|
| 482 |
+
# not redeclare. "Unknown storedness" is the right reading for an
|
| 483 |
+
# ordinary field declared in another module; it is the wrong one
|
| 484 |
+
# here, because the framework's own declaration is visible and
|
| 485 |
+
# says not stored. odoo/models.py adds display_name as
|
| 486 |
+
# `fields.Char(automatic=True, compute='_compute_display_name',
|
| 487 |
+
# search=...)` with no store, so an override that omits a
|
| 488 |
+
# dependency cannot leave a stale row: there is no row.
|
| 489 |
+
#
|
| 490 |
+
# 121 of the 1051 findings this rule made on odoo core were
|
| 491 |
+
# _compute_display_name overrides of exactly that shape. The
|
| 492 |
+
# narrowing costs nothing measurable: all 10 hand-verified true
|
| 493 |
+
# positives that compute display_name -- every one of them in
|
| 494 |
+
# OpenSPP -- declare `store=True` on it in their own file, so
|
| 495 |
+
# storedness reads True for them and they are untouched.
|
| 496 |
+
continue
|
| 497 |
missing = {}
|
| 498 |
for path, lineno in _field_reads(fn, _aliases(fn)):
|
| 499 |
while len(path) > 1 and path[-1] in ORM_ATTRS:
|
|
|
|
| 912 |
return isinstance(node, ast.Constant) and node.value is True
|
| 913 |
|
| 914 |
|
| 915 |
+
def _is_sql_view(cls):
|
| 916 |
+
"""True for a model declaring `_auto = False`: a SQL view, not a table.
|
| 917 |
+
|
| 918 |
+
Odoo builds no table for one and therefore no foreign key, so `ondelete`
|
| 919 |
+
is not a decision anybody can make about it -- there is no constraint for
|
| 920 |
+
a value to be chosen for. Two of the 15 sampled ondelete findings on odoo
|
| 921 |
+
core were report models of exactly this kind, hr.contract.history and
|
| 922 |
+
purchase.bill.line.match.
|
| 923 |
+
"""
|
| 924 |
+
for node in cls.body:
|
| 925 |
+
if not isinstance(node, ast.Assign):
|
| 926 |
+
continue
|
| 927 |
+
if any(isinstance(t, ast.Name) and t.id == "_auto" for t in node.targets):
|
| 928 |
+
return isinstance(node.value, ast.Constant) and node.value.value is False
|
| 929 |
+
return False
|
| 930 |
+
|
| 931 |
+
|
| 932 |
def _transient_spans(tree):
|
| 933 |
+
"""Line ranges of every class this rule family does not apply to.
|
| 934 |
|
| 935 |
A wizard's record is vacuumed, so the failure both ondelete-is-a-decision
|
| 936 |
and stored-compute-needs-complete-depends describe -- a value written once
|
| 937 |
+
and read wrong much later -- has no time in which to happen. A `_auto =
|
| 938 |
+
False` model has no table at all, which rules out the same failure for a
|
| 939 |
+
different reason: see _is_sql_view. Returned as spans because the
|
| 940 |
+
declarations and methods they gate are found by walking the whole tree,
|
| 941 |
+
which loses the class on the way down.
|
| 942 |
|
| 943 |
check_company_missing_on_company_owned_relation already had this exemption
|
| 944 |
and iterates classes directly; these two do not, hence the spans.
|
| 945 |
"""
|
| 946 |
return [(n.lineno, getattr(n, "end_lineno", n.lineno))
|
| 947 |
for n in ast.walk(tree)
|
| 948 |
+
if isinstance(n, ast.ClassDef) and (_is_transient(n) or _is_sql_view(n))]
|
| 949 |
|
| 950 |
|
| 951 |
def _within(spans, lineno):
|
|
|
|
| 1883 |
return any(marker in view for marker in MULTI_COMPANY_MARKERS)
|
| 1884 |
|
| 1885 |
|
| 1886 |
+
# ------------------------------------------- where a value came from
|
| 1887 |
+
#
|
| 1888 |
+
# Four rules were classified false for the same reason: each matched a
|
| 1889 |
+
# variable's NAME and called that evidence about its value. `total == 0` is a
|
| 1890 |
+
# float comparison in one function and an exact integer test in the next;
|
| 1891 |
+
# `filename` says nothing about whether a caller chose it. Telling those apart
|
| 1892 |
+
# needs the assignment, which is what this section reads.
|
| 1893 |
+
#
|
| 1894 |
+
# It is a local trace and nothing more. One file, one function, no imports
|
| 1895 |
+
# followed and no types inferred -- which is enough for the shapes that were
|
| 1896 |
+
# actually misread, and the limit is stated here rather than discovered later.
|
| 1897 |
|
| 1898 |
+
def _function_of(tree):
|
| 1899 |
+
"""Maps every node to the function that encloses it, innermost winning.
|
| 1900 |
+
|
| 1901 |
+
Lambdas count. `filtered(lambda x: x.qty == 0)` is where one of the
|
| 1902 |
+
misreadings lived, and a lambda binds its arguments exactly as a def does.
|
| 1903 |
+
"""
|
| 1904 |
+
owner = {}
|
| 1905 |
+
scopes = [n for n in ast.walk(tree)
|
| 1906 |
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda))]
|
| 1907 |
+
# Outermost first, so an inner scope overwrites the outer one's claim.
|
| 1908 |
+
scopes.sort(key=lambda n: (getattr(n, "lineno", 0),
|
| 1909 |
+
getattr(n, "col_offset", 0)))
|
| 1910 |
+
for fn in scopes:
|
| 1911 |
+
for node in ast.walk(fn):
|
| 1912 |
+
owner[id(node)] = fn
|
| 1913 |
+
return owner
|
| 1914 |
+
|
| 1915 |
+
|
| 1916 |
+
def _final_name(node):
|
| 1917 |
+
"""The last identifier in a Name or Attribute, or None for anything else."""
|
| 1918 |
+
if isinstance(node, ast.Name):
|
| 1919 |
+
return node.id
|
| 1920 |
+
if isinstance(node, ast.Attribute):
|
| 1921 |
+
return node.attr
|
| 1922 |
+
return None
|
| 1923 |
+
|
| 1924 |
+
|
| 1925 |
+
def _chain_root(node):
|
| 1926 |
+
"""The leftmost Name in a chain of attributes, calls and subscripts."""
|
| 1927 |
+
while True:
|
| 1928 |
+
if isinstance(node, ast.Attribute):
|
| 1929 |
+
node = node.value
|
| 1930 |
+
elif isinstance(node, ast.Subscript):
|
| 1931 |
+
node = node.value
|
| 1932 |
+
elif isinstance(node, ast.Call):
|
| 1933 |
+
node = node.func
|
| 1934 |
+
else:
|
| 1935 |
+
break
|
| 1936 |
+
return node.id if isinstance(node, ast.Name) else None
|
| 1937 |
|
| 1938 |
+
|
| 1939 |
+
def _targets(node):
|
| 1940 |
+
"""Every Name a statement binds, whatever the binding form."""
|
| 1941 |
+
found = []
|
| 1942 |
+
for sub in ast.walk(node):
|
| 1943 |
+
if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store, ast.Del)):
|
| 1944 |
+
found.append(sub.id)
|
| 1945 |
+
return found
|
| 1946 |
+
|
| 1947 |
+
|
| 1948 |
+
def _bound_in(fn):
|
| 1949 |
+
"""Names this scope binds: parameters, assignments, loops, withs, imports."""
|
| 1950 |
+
if fn is None:
|
| 1951 |
+
return set()
|
| 1952 |
+
names = set()
|
| 1953 |
+
args = getattr(fn, "args", None)
|
| 1954 |
+
if args is not None:
|
| 1955 |
+
for group in (args.args, args.posonlyargs, args.kwonlyargs):
|
| 1956 |
+
names.update(a.arg for a in group or [])
|
| 1957 |
+
for extra in (args.vararg, args.kwarg):
|
| 1958 |
+
if extra is not None:
|
| 1959 |
+
names.add(extra.arg)
|
| 1960 |
+
for node in ast.walk(fn):
|
| 1961 |
+
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.For,
|
| 1962 |
+
ast.AsyncFor, ast.comprehension, ast.NamedExpr)):
|
| 1963 |
+
names.update(_targets(node))
|
| 1964 |
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
| 1965 |
+
for item in node.items:
|
| 1966 |
+
if item.optional_vars is not None:
|
| 1967 |
+
names.update(_targets(item.optional_vars))
|
| 1968 |
+
elif isinstance(node, ast.ExceptHandler) and node.name:
|
| 1969 |
+
names.add(node.name)
|
| 1970 |
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
| 1971 |
+
names.update((a.asname or a.name).split(".")[0] for a in node.names)
|
| 1972 |
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
| 1973 |
+
if node is not fn:
|
| 1974 |
+
names.add(node.name)
|
| 1975 |
+
return names
|
| 1976 |
+
|
| 1977 |
+
|
| 1978 |
+
def _bound_at_module(tree):
|
| 1979 |
+
"""Names the file itself binds at any level outside a function body."""
|
| 1980 |
+
names = set()
|
| 1981 |
+
for node in ast.walk(tree):
|
| 1982 |
+
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
| 1983 |
+
names.update(_targets(node))
|
| 1984 |
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
| 1985 |
+
names.update((a.asname or a.name).split(".")[0] for a in node.names)
|
| 1986 |
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
| 1987 |
+
names.add(node.name)
|
| 1988 |
+
return names
|
| 1989 |
+
|
| 1990 |
+
|
| 1991 |
+
# Reads that are external by their own name, whatever the surrounding file
|
| 1992 |
+
# does with them. `request` is werkzeug's, reached through odoo.http; the
|
| 1993 |
+
# context is whatever the caller put there; both parsers turn somebody else's
|
| 1994 |
+
# bytes into objects.
|
| 1995 |
+
EXTERNAL_ROOTS = frozenset(["request"])
|
| 1996 |
+
EXTERNAL_CALLS = frozenset(["loads", "literal_eval", "parse_qs", "parse_qsl"])
|
| 1997 |
+
CONTEXT_READS = ("_context", "context")
|
| 1998 |
+
|
| 1999 |
+
|
| 2000 |
+
def _is_external(node, fn, tree):
|
| 2001 |
+
"""True when this expression's value came from outside the file.
|
| 2002 |
+
|
| 2003 |
+
Two ways to qualify. Either the expression names a source that is external
|
| 2004 |
+
by construction -- `request`, a parsed payload, the context a caller
|
| 2005 |
+
handed in -- or its root name is one this file never binds, which is the
|
| 2006 |
+
same answer arrived at by elimination: a name that is not a parameter, not
|
| 2007 |
+
assigned, not a loop or with target, not imported and not a builtin is a
|
| 2008 |
+
value from somewhere this file cannot see.
|
| 2009 |
+
|
| 2010 |
+
The elimination half is what makes the rules' own 'wrong' samples fire,
|
| 2011 |
+
because a sample is a fragment and its inputs are free names by
|
| 2012 |
+
definition. It is also what keeps them quiet on the four findings that
|
| 2013 |
+
were classified false: `file_config`, `filename`, `full_path` and
|
| 2014 |
+
`raw_value` are all bound a few lines above the join.
|
| 2015 |
+
"""
|
| 2016 |
+
for sub in ast.walk(node):
|
| 2017 |
+
if isinstance(sub, ast.Attribute) and sub.attr in CONTEXT_READS:
|
| 2018 |
+
return True
|
| 2019 |
+
if isinstance(sub, ast.Call):
|
| 2020 |
+
attr = _final_name(sub.func)
|
| 2021 |
+
if attr in EXTERNAL_CALLS:
|
| 2022 |
+
return True
|
| 2023 |
+
root = _chain_root(node)
|
| 2024 |
+
if root is None:
|
| 2025 |
+
return False
|
| 2026 |
+
if root in EXTERNAL_ROOTS:
|
| 2027 |
+
return True
|
| 2028 |
+
if root in ("self", "cls"):
|
| 2029 |
+
return False
|
| 2030 |
+
if root in dir(builtins):
|
| 2031 |
+
return False
|
| 2032 |
+
return root not in _bound_in(fn) and root not in _bound_at_module(tree)
|
| 2033 |
+
|
| 2034 |
+
|
| 2035 |
+
# Calls whose result is a whole number, so `== 0` on it is exact and the
|
| 2036 |
+
# float rule has nothing to say. search_count and len are counts by
|
| 2037 |
+
# definition; `.count()` is one on a recordset, a string and a list alike.
|
| 2038 |
+
INT_VALUED_CALLS = frozenset(["search_count", "count", "len", "bit_length",
|
| 2039 |
+
"index", "ord"])
|
| 2040 |
+
_COUNT_KEY = re.compile(r"(?:^__count$|_count$)")
|
| 2041 |
+
|
| 2042 |
+
|
| 2043 |
+
def _integer_fields(tree):
|
| 2044 |
+
"""Field names the file declares as fields.Integer."""
|
| 2045 |
+
names = set()
|
| 2046 |
+
for node in ast.walk(tree):
|
| 2047 |
+
if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
|
| 2048 |
+
continue
|
| 2049 |
+
func = node.value.func
|
| 2050 |
+
if isinstance(func, ast.Attribute) and func.attr == "Integer":
|
| 2051 |
+
names.update(_targets(node))
|
| 2052 |
+
return names
|
| 2053 |
+
|
| 2054 |
+
|
| 2055 |
+
def _counts_rows(fn):
|
| 2056 |
+
"""True when this scope runs a COUNT query, so a fetched cell is an int."""
|
| 2057 |
+
if fn is None:
|
| 2058 |
+
return False
|
| 2059 |
+
for node in ast.walk(fn):
|
| 2060 |
+
if not (isinstance(node, ast.Call) and _final_name(node.func) == "execute"):
|
| 2061 |
+
continue
|
| 2062 |
+
for arg in node.args[:1]:
|
| 2063 |
+
try:
|
| 2064 |
+
text = ast.unparse(arg)
|
| 2065 |
+
except Exception: # pragma: no cover
|
| 2066 |
+
continue
|
| 2067 |
+
if "count(" in text.lower():
|
| 2068 |
+
return True
|
| 2069 |
+
return False
|
| 2070 |
+
|
| 2071 |
+
|
| 2072 |
+
def _assigned_values(fn, name):
|
| 2073 |
+
"""Values assigned to `name` in this scope, or None if it is bound some
|
| 2074 |
+
way the trace cannot read -- a loop target, a with, an unpacking."""
|
| 2075 |
+
if fn is None:
|
| 2076 |
+
return None
|
| 2077 |
+
values = []
|
| 2078 |
+
for node in ast.walk(fn):
|
| 2079 |
+
if isinstance(node, ast.Assign):
|
| 2080 |
+
for target in node.targets:
|
| 2081 |
+
if isinstance(target, ast.Name) and target.id == name:
|
| 2082 |
+
values.append(node.value)
|
| 2083 |
+
elif name in _targets(target):
|
| 2084 |
+
return None # unpacked: no single value
|
| 2085 |
+
elif isinstance(node, ast.AnnAssign):
|
| 2086 |
+
if isinstance(node.target, ast.Name) and node.target.id == name \
|
| 2087 |
+
and node.value is not None:
|
| 2088 |
+
values.append(node.value)
|
| 2089 |
+
elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
|
| 2090 |
+
if name in _targets(node.target):
|
| 2091 |
+
return None
|
| 2092 |
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
| 2093 |
+
for item in node.items:
|
| 2094 |
+
if item.optional_vars is not None \
|
| 2095 |
+
and name in _targets(item.optional_vars):
|
| 2096 |
+
return None
|
| 2097 |
+
elif isinstance(node, ast.AugAssign):
|
| 2098 |
+
if isinstance(node.target, ast.Name) and node.target.id == name:
|
| 2099 |
+
values.append(node.value)
|
| 2100 |
+
return values or None
|
| 2101 |
+
|
| 2102 |
+
|
| 2103 |
+
def _int_valued(node, fn, tree, depth=0):
|
| 2104 |
+
"""True when this expression is certainly a whole number.
|
| 2105 |
+
|
| 2106 |
+
Certainly, not probably: every answer here SUPPRESSES a finding, so the
|
| 2107 |
+
unreadable cases must come back False and leave the finding standing. A
|
| 2108 |
+
name assigned twice is only an integer if both assignments are.
|
| 2109 |
+
"""
|
| 2110 |
+
if depth > 4: # pragma: no cover
|
| 2111 |
+
return False
|
| 2112 |
+
if isinstance(node, ast.Constant):
|
| 2113 |
+
return isinstance(node.value, int) and not isinstance(node.value, bool)
|
| 2114 |
+
if isinstance(node, ast.Call):
|
| 2115 |
+
if _final_name(node.func) in INT_VALUED_CALLS:
|
| 2116 |
+
return True
|
| 2117 |
+
return False
|
| 2118 |
+
if isinstance(node, ast.Subscript):
|
| 2119 |
+
# read_group hands back rows keyed '<field>_count', and the key is
|
| 2120 |
+
# written as an f-string as often as a literal.
|
| 2121 |
+
key = node.slice
|
| 2122 |
+
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
| 2123 |
+
if _COUNT_KEY.search(key.value):
|
| 2124 |
+
return True
|
| 2125 |
+
if isinstance(key, ast.JoinedStr) and key.values:
|
| 2126 |
+
tail = key.values[-1]
|
| 2127 |
+
if isinstance(tail, ast.Constant) and isinstance(tail.value, str) \
|
| 2128 |
+
and _COUNT_KEY.search(tail.value):
|
| 2129 |
+
return True
|
| 2130 |
+
# cr.fetchone()[0] of a SELECT COUNT(*) is the third spelling of a
|
| 2131 |
+
# count, and the query is the only place that says so.
|
| 2132 |
+
inner = node.value
|
| 2133 |
+
if isinstance(inner, ast.Call) and _final_name(inner.func) in (
|
| 2134 |
+
"fetchone", "fetchall") and _counts_rows(fn):
|
| 2135 |
+
return True
|
| 2136 |
+
return False
|
| 2137 |
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub,
|
| 2138 |
+
ast.Mult, ast.Mod,
|
| 2139 |
+
ast.FloorDiv)):
|
| 2140 |
+
return (_int_valued(node.left, fn, tree, depth + 1)
|
| 2141 |
+
and _int_valued(node.right, fn, tree, depth + 1))
|
| 2142 |
+
if isinstance(node, ast.Attribute):
|
| 2143 |
+
return node.attr in _integer_fields(tree)
|
| 2144 |
+
if isinstance(node, ast.Name):
|
| 2145 |
+
if node.id in _integer_fields(tree):
|
| 2146 |
+
return True
|
| 2147 |
+
values = _assigned_values(fn, node.id)
|
| 2148 |
+
if not values:
|
| 2149 |
+
return False
|
| 2150 |
+
return all(_int_valued(v, fn, tree, depth + 1) for v in values)
|
| 2151 |
+
return False
|
| 2152 |
+
|
| 2153 |
+
|
| 2154 |
+
# ------------------------------------------------ the rewritten checkers
|
| 2155 |
+
|
| 2156 |
+
_MONEY_NAME = re.compile(r"price|amount|qty|quantity|total|balance", re.I)
|
| 2157 |
+
_RESOLVES_THE_PATH = frozenset(["commonpath", "commonprefix", "relpath",
|
| 2158 |
+
"is_relative_to", "realpath", "abspath"])
|
| 2159 |
+
|
| 2160 |
+
|
| 2161 |
+
def float_equality_against_zero(code):
|
| 2162 |
+
"""odoo.float-comparison-needs-float-utils
|
| 2163 |
+
|
| 2164 |
+
`== 0` or `!= 0` on a value whose name says it is money or a quantity,
|
| 2165 |
+
where the assignment does not say it is a whole number.
|
| 2166 |
+
|
| 2167 |
+
The regex this replaces asked only the first half. Classified by hand it
|
| 2168 |
+
was 2 true and 5 false, and every one of the five compared an INTEGER to
|
| 2169 |
+
zero, where `==` is exact and the rule has nothing to say:
|
| 2170 |
+
`search_count(...)`, a read_group `<field>_count`, `cr.fetchone()[0]` of a
|
| 2171 |
+
SELECT COUNT(*), and `x.qty` where the same file declares qty as
|
| 2172 |
+
fields.Integer. All five were named `total`, `group_total` or
|
| 2173 |
+
`total_population`, and so were both true positives -- `total =
|
| 2174 |
+
sum(sorted_amounts)` is the same spelling with a float behind it. The name
|
| 2175 |
+
could never have separated them.
|
| 2176 |
+
|
| 2177 |
+
What it cannot see, and so still reports: a float that arrives as a
|
| 2178 |
+
parameter, and an integer from a helper in another file. Both leave the
|
| 2179 |
+
finding standing, which is the direction a suppressing trace has to err in.
|
| 2180 |
"""
|
| 2181 |
tree, _ = parse_python(code)
|
| 2182 |
if tree is None:
|
| 2183 |
return []
|
| 2184 |
|
| 2185 |
+
owner = _function_of(tree)
|
| 2186 |
+
findings, seen = [], set()
|
| 2187 |
for node in ast.walk(tree):
|
| 2188 |
+
if not isinstance(node, ast.Compare) or len(node.ops) != 1:
|
|
|
|
|
|
|
| 2189 |
continue
|
| 2190 |
+
if not isinstance(node.ops[0], (ast.Eq, ast.NotEq)):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2191 |
continue
|
| 2192 |
+
left, right = node.left, node.comparators[0]
|
| 2193 |
+
for value, other in ((left, right), (right, left)):
|
| 2194 |
+
if not (isinstance(other, ast.Constant)
|
| 2195 |
+
and isinstance(other.value, (int, float))
|
| 2196 |
+
and not isinstance(other.value, bool)
|
| 2197 |
+
and other.value == 0):
|
| 2198 |
+
continue
|
| 2199 |
+
name = _final_name(value)
|
| 2200 |
+
if not name or not _MONEY_NAME.search(name):
|
| 2201 |
+
continue
|
| 2202 |
+
if _int_valued(value, owner.get(id(value)), tree):
|
| 2203 |
+
break
|
| 2204 |
+
if node.lineno in seen:
|
| 2205 |
+
break
|
| 2206 |
+
seen.add(node.lineno)
|
| 2207 |
+
findings.append((
|
| 2208 |
+
node.lineno,
|
| 2209 |
+
"'{0}' is compared with == against zero -- binary floats do "
|
| 2210 |
+
"not land on it exactly, so use float_is_zero with the "
|
| 2211 |
+
"field's precision".format(name),
|
| 2212 |
+
None))
|
| 2213 |
+
break
|
| 2214 |
+
return findings
|
| 2215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2216 |
|
| 2217 |
+
def path_join_takes_an_external_name(code):
|
| 2218 |
+
"""python.path-join-does-not-contain-a-path
|
|
|
|
| 2219 |
|
| 2220 |
+
os.path.join where one component came from outside the file, and the
|
| 2221 |
+
result is not resolved and compared before it is used.
|
| 2222 |
+
|
| 2223 |
+
The regex this replaces matched component NAMES -- `filename`, `form`,
|
| 2224 |
+
`params` -- and was 0 true, 4 false. All four join a configured directory
|
| 2225 |
+
to a name the module itself produced: a timestamped backup filename, a
|
| 2226 |
+
literal from a list declared in the same function, a module path out of
|
| 2227 |
+
get_module_path. Not one was a fragment a caller could point out of the
|
| 2228 |
+
directory, which is the only thing the rule is about.
|
| 2229 |
+
|
| 2230 |
+
So the question moved from what the component is called to where its value
|
| 2231 |
+
came from, and 'outside the file' is the answer _is_external gives. The
|
| 2232 |
+
rule's own wrong sample joins `request.args['name']` and still fires.
|
| 2233 |
+
|
| 2234 |
+
This reports less than the regex did, on purpose: the regex's entire
|
| 2235 |
+
output on 294,000 lines was false, and a checker that cries wolf gets
|
| 2236 |
+
switched off. A traversal reached through a helper in another module is
|
| 2237 |
+
outside what a local trace can see and is not reported.
|
| 2238 |
+
"""
|
| 2239 |
+
tree, _ = parse_python(code)
|
| 2240 |
+
if tree is None:
|
| 2241 |
+
return []
|
| 2242 |
+
|
| 2243 |
+
owner = _function_of(tree)
|
| 2244 |
+
findings = []
|
| 2245 |
+
for node in ast.walk(tree):
|
| 2246 |
+
if not (isinstance(node, ast.Call) and _final_name(node.func) == "join"):
|
| 2247 |
+
continue
|
| 2248 |
+
# os.path.join, not str.join and not pathlib's.
|
| 2249 |
+
parent = node.func.value if isinstance(node.func, ast.Attribute) else None
|
| 2250 |
+
if not (isinstance(parent, ast.Attribute) and parent.attr == "path"):
|
| 2251 |
+
continue
|
| 2252 |
+
fn = owner.get(id(node))
|
| 2253 |
+
# The fix is to resolve and compare, and it is written beside the
|
| 2254 |
+
# join. Scoped to the enclosing function rather than the file: a
|
| 2255 |
+
# realpath somewhere else in a 900-line model says nothing about this
|
| 2256 |
+
# call. A join at module scope -- which is what a rule's own sample
|
| 2257 |
+
# is -- is scoped to the module for the same reason.
|
| 2258 |
+
scope = fn if fn is not None else tree
|
| 2259 |
+
if any(_final_name(c.func) in _RESOLVES_THE_PATH
|
| 2260 |
+
for c in ast.walk(scope) if isinstance(c, ast.Call)):
|
| 2261 |
+
continue
|
| 2262 |
+
for arg in node.args[1:]:
|
| 2263 |
+
if _is_external(arg, fn, tree):
|
| 2264 |
+
try:
|
| 2265 |
+
shown = ast.unparse(arg)
|
| 2266 |
+
except Exception: # pragma: no cover
|
| 2267 |
+
shown = "the component"
|
| 2268 |
+
findings.append((
|
| 2269 |
+
node.lineno,
|
| 2270 |
+
"os.path.join is handed {0}, which came from outside this "
|
| 2271 |
+
"file -- an absolute component discards the base directory "
|
| 2272 |
+
"entirely and '..' walks out of it, so resolve both and "
|
| 2273 |
+
"compare before opening anything".format(shown),
|
| 2274 |
+
None))
|
| 2275 |
+
break
|
| 2276 |
+
return findings
|
| 2277 |
+
|
| 2278 |
+
|
| 2279 |
+
def float_coercion_of_external_money(code):
|
| 2280 |
+
"""python.decimal-for-money-not-float
|
| 2281 |
+
|
| 2282 |
+
float() applied to a money value that arrived from outside the file.
|
| 2283 |
+
|
| 2284 |
+
The regex this replaces matched the NAME either side of the coercion and
|
| 2285 |
+
was 0 true, 3 false, with a reason behind it rather than a sample size:
|
| 2286 |
+
Odoo's Monetary field IS a float -- the ORM defines it that way -- and the
|
| 2287 |
+
framework's answer to the precision problem is float_round and
|
| 2288 |
+
float_compare against the currency's rounding, not Decimal. Inside an Odoo
|
| 2289 |
+
module the rule fired on the idiom and named a fix that cannot be taken.
|
| 2290 |
+
|
| 2291 |
+
Three findings do not settle a rule either way, so it is narrowed rather
|
| 2292 |
+
than culled, and the narrowing is the line the argument actually draws.
|
| 2293 |
+
`float(self.amount or 0.0)` is the ORM's own type and is not reported.
|
| 2294 |
+
`float(result)`, where result came back from a service in this file, is
|
| 2295 |
+
not reported. `float(payload['price'])`, where the string came from a
|
| 2296 |
+
caller and Decimal(payload['price']) IS available, still is -- which is
|
| 2297 |
+
the rule's own wrong sample and the one shape its remedy fits.
|
| 2298 |
+
"""
|
| 2299 |
+
tree, _ = parse_python(code)
|
| 2300 |
+
if tree is None:
|
| 2301 |
+
return []
|
| 2302 |
+
|
| 2303 |
+
owner = _function_of(tree)
|
| 2304 |
+
findings = []
|
| 2305 |
+
for node in ast.walk(tree):
|
| 2306 |
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
| 2307 |
+
and node.func.id == "float" and node.args):
|
| 2308 |
+
continue
|
| 2309 |
+
value = node.args[0]
|
| 2310 |
+
name = _final_name(value) or _chain_root(value) or ""
|
| 2311 |
+
target = _MONEY_NAME.search(name)
|
| 2312 |
+
if not target:
|
| 2313 |
+
# The coerced expression may say nothing, while what it is
|
| 2314 |
+
# assigned to does: `price = float(payload['p'])`.
|
| 2315 |
+
holder = owner.get(id(node))
|
| 2316 |
+
for stmt in ast.walk(holder if holder is not None else tree):
|
| 2317 |
+
if isinstance(stmt, ast.Assign) and stmt.value is node:
|
| 2318 |
+
target = any(_MONEY_NAME.search(t)
|
| 2319 |
+
for t in _targets(stmt) if t)
|
| 2320 |
+
break
|
| 2321 |
+
if not target:
|
| 2322 |
+
continue
|
| 2323 |
+
if not _is_external(value, owner.get(id(node)), tree):
|
| 2324 |
+
continue
|
| 2325 |
findings.append((
|
| 2326 |
node.lineno,
|
| 2327 |
+
"a money value from outside this file is routed through float() "
|
| 2328 |
+
"-- the binary float is already wrong before Decimal can be given "
|
| 2329 |
+
"it, so build Decimal from the original string",
|
| 2330 |
None))
|
| 2331 |
return findings
|
| 2332 |
|
| 2333 |
|
| 2334 |
+
def onchange_tries_to_enforce(code):
|
| 2335 |
+
"""odoo.onchange-is-not-validation
|
| 2336 |
+
|
| 2337 |
+
An @api.onchange method that RAISES or returns a warning -- it is trying
|
| 2338 |
+
to enforce an invariant the web client is the only thing that will apply.
|
| 2339 |
+
|
| 2340 |
+
The regex this replaces read 400 characters after the decorator and could
|
| 2341 |
+
not see where the method ended. All three of its false positives are the
|
| 2342 |
+
same failure: a four-line onchange that assigns one field, followed by an
|
| 2343 |
+
action method that raises. `_onchange_variable_id` sets a target model and
|
| 2344 |
+
the UserError 200 characters below it belongs to `action_remap`. The
|
| 2345 |
+
window crossed the function boundary, and a regex has no way not to.
|
| 2346 |
+
|
| 2347 |
+
Scoped to the decorated function, that shape is silent, and the four true
|
| 2348 |
+
positives -- an onchange that really does raise ValidationError, and ones
|
| 2349 |
+
that return the warning dict -- all stand.
|
| 2350 |
+
|
| 2351 |
+
A constraint anywhere in the file clears it, exactly as the
|
| 2352 |
+
satisfied_pattern did: an onchange sitting beside a real @api.constrains
|
| 2353 |
+
is the faster feedback the rule explicitly allows. _sql_constraints counts
|
| 2354 |
+
here, because it IS a constraint on 16 through 18; the 19 hole is closed
|
| 2355 |
+
where the series is legible, in prefer_sql_constraint_applies.
|
| 2356 |
+
"""
|
| 2357 |
+
tree, _ = parse_python(code)
|
| 2358 |
+
if tree is None:
|
| 2359 |
+
return []
|
| 2360 |
+
|
| 2361 |
+
backed = False
|
| 2362 |
+
for node in ast.walk(tree):
|
| 2363 |
+
if isinstance(node, ast.Name) and node.id == "_sql_constraints":
|
| 2364 |
+
backed = True
|
| 2365 |
+
elif isinstance(node, ast.Attribute) and node.attr in ("constrains",
|
| 2366 |
+
"Constraint"):
|
| 2367 |
+
backed = True
|
| 2368 |
+
if backed:
|
| 2369 |
+
return []
|
| 2370 |
+
|
| 2371 |
+
findings = []
|
| 2372 |
+
for fn in _functions(tree):
|
| 2373 |
+
if _decorator_args(fn, "api", "onchange") is None:
|
| 2374 |
+
continue
|
| 2375 |
+
# Anchored on the decorator, not on the def below it. That is where
|
| 2376 |
+
# the rule is, where a reader looking the finding up will look, and
|
| 2377 |
+
# where the marker goes when somebody has already decided about it --
|
| 2378 |
+
# a FunctionDef's own lineno is the `def`, one line past all three.
|
| 2379 |
+
at = fn.lineno
|
| 2380 |
+
for dec in fn.decorator_list:
|
| 2381 |
+
func = dec.func if isinstance(dec, ast.Call) else dec
|
| 2382 |
+
if isinstance(func, ast.Attribute) and func.attr == "onchange":
|
| 2383 |
+
at = dec.lineno
|
| 2384 |
+
break
|
| 2385 |
+
for node in ast.walk(fn):
|
| 2386 |
+
spot = None
|
| 2387 |
+
if isinstance(node, ast.Raise):
|
| 2388 |
+
exc = node.exc
|
| 2389 |
+
name = _final_name(exc.func) if isinstance(exc, ast.Call) \
|
| 2390 |
+
else _final_name(exc)
|
| 2391 |
+
if name and ("Error" in name or "Warning" in name):
|
| 2392 |
+
spot = node.lineno
|
| 2393 |
+
elif isinstance(node, ast.Dict):
|
| 2394 |
+
for key in node.keys:
|
| 2395 |
+
if isinstance(key, ast.Constant) and key.value == "warning":
|
| 2396 |
+
spot = node.lineno
|
| 2397 |
+
if spot is None:
|
| 2398 |
+
continue
|
| 2399 |
+
findings.append((
|
| 2400 |
+
at,
|
| 2401 |
+
"@api.onchange '{0}' refuses a value, and onchange runs in "
|
| 2402 |
+
"the web client form only -- an import, an RPC call, a cron "
|
| 2403 |
+
"or a direct create() never triggers it, so the record enters "
|
| 2404 |
+
"the database unchallenged".format(fn.name),
|
| 2405 |
+
None))
|
| 2406 |
+
break
|
| 2407 |
+
return findings
|
| 2408 |
+
|
| 2409 |
+
|
| 2410 |
+
# A query decides WHICH records you get to see, and dropping ir.rule widens
|
| 2411 |
+
# that set. Every one of the 6 hand-classified true positives is one of these.
|
| 2412 |
+
SUDO_QUERIES = ("search", "search_count", "search_read", "read_group")
|
| 2413 |
+
# These act on a recordset the caller already holds. Dropping ir.rule still
|
| 2414 |
+
# matters -- it governs write and read permission as well as visibility -- but
|
| 2415 |
+
# it widens what you may DO with a record rather than which records exist for
|
| 2416 |
+
# you, and nothing measured here has yet found a real one.
|
| 2417 |
+
SUDO_TOUCHES = ("create", "write", "unlink", "copy", "read")
|
| 2418 |
+
|
| 2419 |
+
# The grade the write and escape clauses carry, per finding rather than per
|
| 2420 |
+
# rule. `unproven` is the corpus's word for a real defect nothing measured
|
| 2421 |
+
# here has met yet, and that is exactly their position: the read clause has
|
| 2422 |
+
# 6 hand-classified true positives across two trees, and these two have 0 in
|
| 2423 |
+
# 261,000 lines. Every one of the 23 they found was read, and every one was
|
| 2424 |
+
# an administrator by construction -- a demo-data seeder, an ACL-gated
|
| 2425 |
+
# accounting wizard -- or a framework model with no tenant boundary at all.
|
| 2426 |
+
#
|
| 2427 |
+
# Grading them rather than deleting them is the same call §3.46 made about
|
| 2428 |
+
# decimal-for-money: a clause the rule's own sentence asks for, measured at
|
| 2429 |
+
# zero, is reported and not trusted to stop a build. It costs the read
|
| 2430 |
+
# clause nothing, because a grade travels with the finding and not the rule.
|
| 2431 |
+
UNPROVEN = "unproven"
|
| 2432 |
+
|
| 2433 |
+
|
| 2434 |
+
def _escalates(call):
|
| 2435 |
+
"""True when this `.sudo(...)` call turns record rules OFF.
|
| 2436 |
+
|
| 2437 |
+
`sudo(False)` turns them back ON. It is how a method that must run under
|
| 2438 |
+
the caller's own rights says so, and odoo core does it 60 times --
|
| 2439 |
+
`self = self.sudo(False)` in mail's `_check_attachments_access`, whose
|
| 2440 |
+
own docstring reads "This method relies on access rules/rights and
|
| 2441 |
+
therefore it should not be called from a sudo env."
|
| 2442 |
+
|
| 2443 |
+
Reading it as an escalation reports the fix as the defect, which is worse
|
| 2444 |
+
than missing the defect: it is the one kind of finding that teaches a
|
| 2445 |
+
reader the checker cannot be trusted. An argument that is not a literal
|
| 2446 |
+
is unreadable here and counts as escalation, because `sudo(flag)` with a
|
| 2447 |
+
computed flag can be either and the finding should stand.
|
| 2448 |
+
"""
|
| 2449 |
+
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)
|
| 2450 |
+
and call.func.attr == "sudo"):
|
| 2451 |
+
return False
|
| 2452 |
+
given = list(call.args) + [k.value for k in call.keywords if k.arg == "flag"]
|
| 2453 |
+
for arg in given:
|
| 2454 |
+
if isinstance(arg, ast.Constant) and not arg.value:
|
| 2455 |
+
return False
|
| 2456 |
+
# `x.sudo(self.env.su)` sets su to whatever the environment already
|
| 2457 |
+
# has, so it cannot raise privilege above ambient -- it is how a
|
| 2458 |
+
# method hands a record back at the caller's own level. odoo core
|
| 2459 |
+
# writes it four times and once with the comment "Unsudo the invoice
|
| 2460 |
+
# after creation if not already sudoed". `sudo(self.env.su or
|
| 2461 |
+
# field.compute_sudo)` is NOT this: the `or` can turn it on, so only
|
| 2462 |
+
# the bare attribute is read as ambient.
|
| 2463 |
+
if isinstance(arg, ast.Attribute) and arg.attr == "su":
|
| 2464 |
+
return False
|
| 2465 |
+
return True
|
| 2466 |
+
|
| 2467 |
+
|
| 2468 |
+
def _under_sudo(node):
|
| 2469 |
+
"""The receiver of `<x>.sudo().<method>(...)`, or None."""
|
| 2470 |
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
| 2471 |
+
return None
|
| 2472 |
+
inner = node.func.value
|
| 2473 |
+
if isinstance(inner, ast.Call) and _escalates(inner):
|
| 2474 |
+
return inner.func.value
|
| 2475 |
+
return None
|
| 2476 |
+
|
| 2477 |
+
|
| 2478 |
+
def sudo_drops_record_rules(code, facts=None):
|
| 2479 |
+
"""odoo.sudo-bypasses-record-rules
|
| 2480 |
+
|
| 2481 |
+
Three clauses, because the rule has three: scope sudo to the narrowest
|
| 2482 |
+
operation, re-apply company filtering by hand, and never pass a sudo
|
| 2483 |
+
recordset onward.
|
| 2484 |
+
|
| 2485 |
+
1. a read under sudo -- search, search_read, read_group, read
|
| 2486 |
+
2. a write under sudo -- create, write, unlink, copy
|
| 2487 |
+
3. a sudo recordset bound to a name and then handed to something else
|
| 2488 |
+
|
| 2489 |
+
Only the first was checked. The `--harvest` clustering says what that
|
| 2490 |
+
cost: `.sudo(` is the largest miss cluster in the corpus's recall
|
| 2491 |
+
measurement at 70 missed against 6 caught, and 47 of those 70 are files
|
| 2492 |
+
where a checker fired somewhere else -- a rule aimed wrong rather than a
|
| 2493 |
+
rule that is missing. Counted on the OpenSPP tree, the clause that was
|
| 2494 |
+
checked has 116 call sites and the clause that was not has 136, so the
|
| 2495 |
+
unchecked half was the larger one. One of the misses the README names,
|
| 2496 |
+
*"remove sudo() when importing record"*, is a create the search-only
|
| 2497 |
+
checker could not see at all.
|
| 2498 |
+
|
| 2499 |
+
ir.rule governs read, write, create and unlink alike, so the same
|
| 2500 |
+
record-rule trigger gates every clause: where the tree declares no rule
|
| 2501 |
+
that filters this model, sudo drops nothing and there is no finding. The
|
| 2502 |
+
trigger only ever SUPPRESSES. A model the walk could not name, a tree that
|
| 2503 |
+
was not walked, a partial walk -- each reads UNKNOWN and the finding
|
| 2504 |
+
stands, because "no rule was found" and "nobody looked" must not be the
|
| 2505 |
+
same answer on a critical rule. `nexa check` on one file therefore behaves
|
| 2506 |
+
exactly as it did before triggers existed.
|
| 2507 |
+
|
| 2508 |
+
Clause 3 fires at the binding, not at the use. `partners =
|
| 2509 |
+
self.env['res.partner'].sudo()` is a recordset carrying an environment
|
| 2510 |
+
with su=True, and every call made on it downstream inherits that -- which
|
| 2511 |
+
is the whole reason the rule's sentence says not to pass one on. Reported
|
| 2512 |
+
where the escape happens, once, rather than at each later read.
|
| 2513 |
+
|
| 2514 |
+
What it still over-reports, measured and not yet fixed: 14 of the 20
|
| 2515 |
+
surviving read findings constrain the search to records the caller had
|
| 2516 |
+
already identified -- a group id, a partner id, an area -- which is the
|
| 2517 |
+
rule's advice followed in a spelling the rule does not recognise, since it
|
| 2518 |
+
names only company_id. Deciding in general that a domain is already scoped
|
| 2519 |
+
means evaluating it, and a narrowing that guesses would cost the 6 true
|
| 2520 |
+
positives this keeps.
|
| 2521 |
+
"""
|
| 2522 |
+
tree, _ = parse_python(code)
|
| 2523 |
+
if tree is None:
|
| 2524 |
+
return []
|
| 2525 |
+
|
| 2526 |
+
def rescoped(node, model):
|
| 2527 |
+
# Did this call re-apply the scoping the rule it bypasses is about?
|
| 2528 |
+
#
|
| 2529 |
+
# The old test was the literal word `company_id`, from the rule's own
|
| 2530 |
+
# correct sample. That is one spelling of one kind of rule, and the
|
| 2531 |
+
# docstring recorded what it cost: 14 of 20 surviving read findings
|
| 2532 |
+
# were scoped by a group, a partner or an area instead, and read as
|
| 2533 |
+
# unscoped. Every OpenSPP case model is governed by `case_worker_id`
|
| 2534 |
+
# or `case_id.case_worker_id` and carries no company rule at all.
|
| 2535 |
+
#
|
| 2536 |
+
# So the call is now compared against the fields the model's OWN
|
| 2537 |
+
# ir.rule filters on, which is not a guess about the domain -- it is
|
| 2538 |
+
# the domain, read off the tree that declares it. A create naming
|
| 2539 |
+
# `case_id` lands inside the caller's visibility because that is the
|
| 2540 |
+
# link the rule follows. company_id stays as the fallback for a model
|
| 2541 |
+
# whose rules this could not read, so nothing that was exempt before
|
| 2542 |
+
# stops being exempt now.
|
| 2543 |
+
# The ARGUMENTS only. Unparsing the whole call includes the receiver,
|
| 2544 |
+
# and the model name is part of it: `spp.group.membership` contains
|
| 2545 |
+
# the word `group`, which is one of that model's own rule fields, so
|
| 2546 |
+
# reading the whole chain exempted an unscoped sweep of every
|
| 2547 |
+
# membership in the database -- a finding classified true by hand.
|
| 2548 |
+
#
|
| 2549 |
+
# An argument that is a bare name is followed to what it was assigned,
|
| 2550 |
+
# because `create(vals)` otherwise hides every field it sets behind
|
| 2551 |
+
# four letters. The trace is the same local one the other checkers
|
| 2552 |
+
# use, and a name it cannot resolve contributes nothing.
|
| 2553 |
+
pieces = []
|
| 2554 |
+
for arg in list(node.args) + [k.value for k in node.keywords]:
|
| 2555 |
+
try:
|
| 2556 |
+
pieces.append(ast.unparse(arg))
|
| 2557 |
+
except Exception: # pragma: no cover
|
| 2558 |
+
continue
|
| 2559 |
+
if isinstance(arg, ast.Name):
|
| 2560 |
+
for value in _assigned_values(owner.get(id(node)), arg.id) or ():
|
| 2561 |
+
try:
|
| 2562 |
+
pieces.append(ast.unparse(value))
|
| 2563 |
+
except Exception: # pragma: no cover
|
| 2564 |
+
pass
|
| 2565 |
+
call_src = " ".join(pieces)
|
| 2566 |
+
fields = context.model_rule_fields(facts, model) if model else ()
|
| 2567 |
+
for name in fields or ("company_id",):
|
| 2568 |
+
if re.search(r"\b" + re.escape(name.split(".", 1)[0]) + r"\b", call_src):
|
| 2569 |
+
return True
|
| 2570 |
+
return "company_id" in call_src
|
| 2571 |
+
|
| 2572 |
+
owner = _function_of(tree)
|
| 2573 |
+
|
| 2574 |
+
def model_of(receiver):
|
| 2575 |
+
# `self.env['x'].sudo()` names its model in the chain. `Plan.sudo()`
|
| 2576 |
+
# does not, and every one of the write clause's first findings on
|
| 2577 |
+
# OpenSPP's demo generators was that shape -- `Plan =
|
| 2578 |
+
# self.env['spp.case.plan']` a few lines up, then fifteen writes on
|
| 2579 |
+
# the name. Unresolved, the trigger reads UNKNOWN and cannot suppress;
|
| 2580 |
+
# followed through the assignment, it can answer. Every assignment to
|
| 2581 |
+
# the name must agree, or the answer is still None.
|
| 2582 |
+
model = _env_model_of(receiver)
|
| 2583 |
+
if model is not None or not isinstance(receiver, ast.Name):
|
| 2584 |
+
return model
|
| 2585 |
+
values = _assigned_values(owner.get(id(receiver)), receiver.id)
|
| 2586 |
+
if not values:
|
| 2587 |
+
values = [v for n in ast.walk(tree) if isinstance(n, ast.Assign)
|
| 2588 |
+
for t in n.targets if isinstance(t, ast.Name)
|
| 2589 |
+
and t.id == receiver.id for v in (n.value,)]
|
| 2590 |
+
found = {_env_model_of(v) for v in values}
|
| 2591 |
+
return found.pop() if len(found) == 1 and None not in found else None
|
| 2592 |
+
|
| 2593 |
+
def ruled_out(receiver):
|
| 2594 |
+
model = model_of(receiver)
|
| 2595 |
+
return context.model_is_record_ruled(facts, model) == context.ABSENT, model
|
| 2596 |
+
|
| 2597 |
+
findings, seen = [], set()
|
| 2598 |
+
|
| 2599 |
+
def report(line, text, grade=None):
|
| 2600 |
+
if line not in seen:
|
| 2601 |
+
seen.add(line)
|
| 2602 |
+
findings.append((line, text, grade))
|
| 2603 |
+
|
| 2604 |
+
for node in ast.walk(tree):
|
| 2605 |
+
receiver = _under_sudo(node)
|
| 2606 |
+
if receiver is None:
|
| 2607 |
+
continue
|
| 2608 |
+
method = node.func.attr
|
| 2609 |
+
if method not in SUDO_QUERIES and method not in SUDO_TOUCHES:
|
| 2610 |
+
continue
|
| 2611 |
+
absent, model = ruled_out(receiver)
|
| 2612 |
+
if absent:
|
| 2613 |
+
continue
|
| 2614 |
+
if rescoped(node, model):
|
| 2615 |
+
continue
|
| 2616 |
+
where = " on {0}".format(model) if model else ""
|
| 2617 |
+
if method in SUDO_QUERIES:
|
| 2618 |
+
report(node.lineno,
|
| 2619 |
+
"sudo().{0}(){1} runs with record rules off -- ir.rule "
|
| 2620 |
+
"filtering, including multi-company isolation, does not "
|
| 2621 |
+
"apply and the result looks correct in single-company "
|
| 2622 |
+
"testing".format(method, where))
|
| 2623 |
+
else:
|
| 2624 |
+
report(node.lineno,
|
| 2625 |
+
"sudo().{0}(){1} acts with record rules off -- ir.rule "
|
| 2626 |
+
"governs write, create, unlink and read as well as "
|
| 2627 |
+
"visibility, so this reaches a record the caller's own "
|
| 2628 |
+
"rules would not let it touch".format(method, where),
|
| 2629 |
+
UNPROVEN)
|
| 2630 |
+
|
| 2631 |
+
# Clause 3: a sudo recordset given a name, then handed on.
|
| 2632 |
+
#
|
| 2633 |
+
# The bare name only, as an argument or as the whole returned value.
|
| 2634 |
+
# `return partners.mapped('name')` hands back a list of strings and the
|
| 2635 |
+
# elevated environment does not travel with it, while
|
| 2636 |
+
# `return partners.filtered(...)` does -- telling those apart means
|
| 2637 |
+
# knowing which recordset methods return recordsets, so neither is
|
| 2638 |
+
# reported. Clause 3 under-reports by construction, which is the right
|
| 2639 |
+
# direction for a new clause on a critical rule: the search clause's
|
| 2640 |
+
# first version fired 106 times for 6 real ones, and a flood is how a
|
| 2641 |
+
# checker gets switched off.
|
| 2642 |
+
for fn in _functions(tree):
|
| 2643 |
+
escapes = set()
|
| 2644 |
+
for node in ast.walk(fn):
|
| 2645 |
+
if isinstance(node, ast.Call):
|
| 2646 |
+
if _under_sudo(node) is None:
|
| 2647 |
+
escapes.update(a.id for a in node.args
|
| 2648 |
+
if isinstance(a, ast.Name))
|
| 2649 |
+
escapes.update(k.value.id for k in node.keywords
|
| 2650 |
+
if isinstance(k.value, ast.Name))
|
| 2651 |
+
elif isinstance(node, ast.Return) and isinstance(node.value, ast.Name):
|
| 2652 |
+
escapes.add(node.value.id)
|
| 2653 |
+
for node in ast.walk(fn):
|
| 2654 |
+
if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
|
| 2655 |
+
continue
|
| 2656 |
+
call = node.value
|
| 2657 |
+
if not _escalates(call):
|
| 2658 |
+
continue
|
| 2659 |
+
bound = [t for t in _targets(node)]
|
| 2660 |
+
|
| 2661 |
+
# `self = self.sudo()` rebinds the method's own receiver, and
|
| 2662 |
+
# every ORM call below it in the method inherits the elevated
|
| 2663 |
+
# environment without naming sudo anywhere. It is the escape
|
| 2664 |
+
# clause at its widest -- the recordset is not passed on, it
|
| 2665 |
+
# replaces the thing the whole method is written against.
|
| 2666 |
+
#
|
| 2667 |
+
# This is the miss the README names by hand. OCA server-tools
|
| 2668 |
+
# cbefaa511485, "[FIX] excel_import_export: remove sudo() when
|
| 2669 |
+
# importing record", deletes exactly this line and says what it
|
| 2670 |
+
# cost: "finding multiple records across all companies during
|
| 2671 |
+
# import when they have same record name. Previously, importing
|
| 2672 |
+
# with sudo could bypass multi-company record rules."
|
| 2673 |
+
#
|
| 2674 |
+
# Three sites in 260,000 lines, so it is narrow as well as real.
|
| 2675 |
+
# The same commit ADDS `self.sudo().env["ir.model.data"]`, a
|
| 2676 |
+
# deliberate and narrow elevation, which is the evidence for not
|
| 2677 |
+
# also flagging that shape.
|
| 2678 |
+
rebind = isinstance(call.func.value, ast.Name) \
|
| 2679 |
+
and call.func.value.id in bound
|
| 2680 |
+
if rebind:
|
| 2681 |
+
report(node.lineno,
|
| 2682 |
+
"'{0}' is rebound to its own sudo form, so every ORM "
|
| 2683 |
+
"call below this line in the method runs with record "
|
| 2684 |
+
"rules off -- including ones that name no sudo at all"
|
| 2685 |
+
.format(call.func.value.id))
|
| 2686 |
+
continue
|
| 2687 |
+
|
| 2688 |
+
if not any(name in escapes for name in bound):
|
| 2689 |
+
continue
|
| 2690 |
+
absent, model = ruled_out(call.func.value)
|
| 2691 |
+
if absent:
|
| 2692 |
+
continue
|
| 2693 |
+
where = " of {0}".format(model) if model else ""
|
| 2694 |
+
report(node.lineno,
|
| 2695 |
+
"this sudo recordset{0} is passed on -- the elevated "
|
| 2696 |
+
"environment travels with it, so every read and write made "
|
| 2697 |
+
"on it downstream also runs with record rules off"
|
| 2698 |
+
.format(where), UNPROVEN)
|
| 2699 |
+
return sorted(findings)
|
| 2700 |
+
|
| 2701 |
+
|
| 2702 |
def _env_model_of(node):
|
| 2703 |
"""The model name at the head of a chained call, or None.
|
| 2704 |
|
|
|
|
| 2722 |
|
| 2723 |
CHECKS = {
|
| 2724 |
"handler_order_shadows_a_subclass": handler_order_shadows_a_subclass,
|
| 2725 |
+
"sudo_drops_record_rules": sudo_drops_record_rules,
|
| 2726 |
+
"float_equality_against_zero": float_equality_against_zero,
|
| 2727 |
+
"path_join_takes_an_external_name": path_join_takes_an_external_name,
|
| 2728 |
+
"float_coercion_of_external_money": float_coercion_of_external_money,
|
| 2729 |
+
"onchange_tries_to_enforce": onchange_tries_to_enforce,
|
| 2730 |
"check_company_missing_on_company_owned_relation":
|
| 2731 |
check_company_missing_on_company_owned_relation,
|
| 2732 |
"commit_inside_a_loop": commit_inside_a_loop,
|
|
|
|
| 2748 |
"check_company_missing_on_company_owned_relation",
|
| 2749 |
# Reads models_record_ruled: whether sudo drops anything depends on whether
|
| 2750 |
# the tree declares a rule that filters.
|
| 2751 |
+
"sudo_drops_record_rules",
|
| 2752 |
# Reads odoo_series: whether a _sql_constraints block is a constraint at
|
| 2753 |
# all depends on which Odoo is going to load it.
|
| 2754 |
"prefer_sql_constraint_applies",
|
|
|
|
| 2961 |
|
| 2962 |
# ------------------------------------------------------- comment stripping
|
| 2963 |
|
| 2964 |
+
def _docstring_starts(code):
|
| 2965 |
+
"""Character offsets where a docstring literal opens.
|
| 2966 |
+
|
| 2967 |
+
A docstring is prose, and a regex cannot tell prose from code -- which is
|
| 2968 |
+
the whole reason comments are stripped before matching. String literals
|
| 2969 |
+
were left standing, and classifying 50 findings by hand found the cost:
|
| 2970 |
+
two of them were `eval()` named in a docstring in a sentence arguing
|
| 2971 |
+
AGAINST using it, and a `datetime.now()` inside a doctest example. Both
|
| 2972 |
+
read as live code.
|
| 2973 |
+
|
| 2974 |
+
Only docstrings, not every string. A triple-quoted SQL query is data the
|
| 2975 |
+
checkers have a legitimate interest in, and `_SQL_COLUMNS` reads one. The
|
| 2976 |
+
statement-level bare string an AST calls a docstring is the one place the
|
| 2977 |
+
file is guaranteed to be talking ABOUT code rather than in it.
|
| 2978 |
+
|
| 2979 |
+
Offsets, not spans: the scanner already knows how to find a literal's end,
|
| 2980 |
+
and asking it to do that is one way of measuring the string rather than
|
| 2981 |
+
two. `col_offset` is a byte offset, which parts company with a character
|
| 2982 |
+
offset on a line holding non-ASCII -- so the result is checked against the
|
| 2983 |
+
quote it claims to point at and dropped when it does not, leaving that
|
| 2984 |
+
docstring intact rather than blanking the wrong run of characters.
|
| 2985 |
+
"""
|
| 2986 |
+
tree, _ = parse_python(code)
|
| 2987 |
+
if tree is None:
|
| 2988 |
+
return frozenset()
|
| 2989 |
+
|
| 2990 |
+
line_start, offset = [], 0
|
| 2991 |
+
for row in code.splitlines(True):
|
| 2992 |
+
line_start.append(offset)
|
| 2993 |
+
offset += len(row)
|
| 2994 |
+
|
| 2995 |
+
starts = set()
|
| 2996 |
+
holders = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
| 2997 |
+
for node in ast.walk(tree):
|
| 2998 |
+
if not isinstance(node, holders):
|
| 2999 |
+
continue
|
| 3000 |
+
body = getattr(node, "body", None)
|
| 3001 |
+
if not body:
|
| 3002 |
+
continue
|
| 3003 |
+
first = body[0]
|
| 3004 |
+
if not (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant)
|
| 3005 |
+
and isinstance(first.value.value, str)):
|
| 3006 |
+
continue
|
| 3007 |
+
lineno = getattr(first.value, "lineno", 0)
|
| 3008 |
+
if not 0 < lineno <= len(line_start):
|
| 3009 |
+
continue
|
| 3010 |
+
at = line_start[lineno - 1] + first.value.col_offset
|
| 3011 |
+
# The guard the docstring above describes. A prefix of anything but
|
| 3012 |
+
# indentation is possible -- `def f(): "doc"` -- but it is ASCII in
|
| 3013 |
+
# every spelling that reaches here, so a miss means the offset is
|
| 3014 |
+
# wrong and the safe answer is to leave the literal alone.
|
| 3015 |
+
if at < len(code) and code[at] in ("'", '"'):
|
| 3016 |
+
starts.add(at)
|
| 3017 |
+
return frozenset(starts)
|
| 3018 |
+
|
| 3019 |
+
|
| 3020 |
+
def _blanked(text):
|
| 3021 |
+
"""A string literal with its contents emptied, quotes and shape intact.
|
| 3022 |
+
|
| 3023 |
+
Same length and the same newlines, so the line AND column a finding
|
| 3024 |
+
reports are the ones a person reading the file will see, and so a
|
| 3025 |
+
docstring that is the whole body of a function still leaves that function
|
| 3026 |
+
syntactically intact for anything that parses the stripped view.
|
| 3027 |
+
"""
|
| 3028 |
+
if len(text) < 2:
|
| 3029 |
+
return text
|
| 3030 |
+
quote = 3 if text[:3] in ("'''", '"""') else 1
|
| 3031 |
+
head, body, tail = text[:quote], text[quote:-quote or None], text[-quote:]
|
| 3032 |
+
if len(text) < quote * 2:
|
| 3033 |
+
return text
|
| 3034 |
+
return head + "".join("\n" if c == "\n" else " " for c in body) + tail
|
| 3035 |
+
|
| 3036 |
+
|
| 3037 |
def strip_comments(code, kind="python"):
|
| 3038 |
+
"""Code with its comments and docstrings removed, strings left intact.
|
| 3039 |
|
| 3040 |
WHY: a violation_pattern and a satisfied_pattern are regexes, and a regex
|
| 3041 |
cannot tell code from prose. The adversarial counterexample probe put a
|
|
|
|
| 3049 |
as a live violation, which is the false positive that teaches people to
|
| 3050 |
ignore a checker.
|
| 3051 |
|
| 3052 |
+
A docstring is the same argument wearing a different quote. It was left
|
| 3053 |
+
standing here until classifying 50 findings by hand turned up two that were
|
| 3054 |
+
prose read as code, so docstrings are now emptied and left in place -- see
|
| 3055 |
+
_docstring_starts for why only docstrings and not every literal.
|
| 3056 |
+
|
| 3057 |
Newlines inside removed comments are preserved so that multi-line patterns
|
| 3058 |
still see the line structure they were written against.
|
| 3059 |
"""
|
| 3060 |
out = []
|
| 3061 |
i, n = 0, len(code)
|
| 3062 |
triple = ("'''", '"""')
|
| 3063 |
+
docstrings = _docstring_starts(code) if kind == "python" else frozenset()
|
| 3064 |
|
| 3065 |
while i < n:
|
| 3066 |
ch = code[i]
|
|
|
|
| 3069 |
marker = code[i:i + 3]
|
| 3070 |
end = code.find(marker, i + 3)
|
| 3071 |
end = n if end < 0 else end + 3
|
| 3072 |
+
out.append(_blanked(code[i:end]) if i in docstrings else code[i:end])
|
| 3073 |
i = end
|
| 3074 |
continue
|
| 3075 |
|
|
|
|
| 3085 |
if code[j] == "\n" and kind == "python":
|
| 3086 |
break # unterminated single-quoted string
|
| 3087 |
j += 1
|
| 3088 |
+
out.append(_blanked(code[i:j]) if i in docstrings else code[i:j])
|
| 3089 |
i = j
|
| 3090 |
continue
|
| 3091 |
|
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"generated_utc": "2026-09-21T22:37:59+00:00",
|
| 3 |
+
"stack": "odoo",
|
| 4 |
+
"checkouts": {
|
| 5 |
+
"odoo": "3c3e3b3d17cbd98584c7685e607d9085712adfe0"
|
| 6 |
+
},
|
| 7 |
+
"subject": "odoo/odoo 18.0, the framework itself",
|
| 8 |
+
"files": 5899,
|
| 9 |
+
"lines": 559663,
|
| 10 |
+
"scope": "A SAMPLE, not a census, and the first classification here that is one. odoo core produced 2,784 live findings, twenty-five times the size of any set read here before. 15 findings of each of the four largest rules were drawn with a fixed seed and read by hand against the file each sits in: 60 of the 2,784, covering the rules that make 70% of them. The draw reproduces from this description alone: take the live findings for the rule, drop the advisory and suppressed ones, sort by (path, line), and call random.Random('20260921:' + rule_id).sample(rows, 15).",
|
| 11 |
+
"totals": {
|
| 12 |
+
"true": 27,
|
| 13 |
+
"false": 33
|
| 14 |
+
},
|
| 15 |
+
"by_rule": {
|
| 16 |
+
"odoo.stored-compute-needs-complete-depends": {
|
| 17 |
+
"true": 11,
|
| 18 |
+
"false": 4,
|
| 19 |
+
"precision": "11/15"
|
| 20 |
+
},
|
| 21 |
+
"python.assert-is-not-validation": {
|
| 22 |
+
"true": 3,
|
| 23 |
+
"false": 12,
|
| 24 |
+
"precision": "3/15"
|
| 25 |
+
},
|
| 26 |
+
"odoo.ondelete-is-a-decision": {
|
| 27 |
+
"true": 13,
|
| 28 |
+
"false": 2,
|
| 29 |
+
"precision": "13/15"
|
| 30 |
+
},
|
| 31 |
+
"odoo.sudo-bypasses-record-rules": {
|
| 32 |
+
"true": 0,
|
| 33 |
+
"false": 15,
|
| 34 |
+
"precision": "0/15"
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
"false_classes": {
|
| 38 |
+
"framework-internal-invariant": 8,
|
| 39 |
+
"domain-scopes-it-already": 5,
|
| 40 |
+
"framework-unstored-magic-field": 4,
|
| 41 |
+
"private-helper-contract": 3,
|
| 42 |
+
"sql-view-has-no-foreign-key": 2,
|
| 43 |
+
"authentication-by-token": 2,
|
| 44 |
+
"cross-company-by-design": 2,
|
| 45 |
+
"self-check-on-module-constants": 1,
|
| 46 |
+
"global-uniqueness-by-design": 1,
|
| 47 |
+
"de-escalation": 1,
|
| 48 |
+
"public-website-read": 1,
|
| 49 |
+
"system-configuration-read": 1,
|
| 50 |
+
"cross-user-by-design": 1,
|
| 51 |
+
"guard-must-see-everything": 1
|
| 52 |
+
},
|
| 53 |
+
"intervals": "n=15 per rule, so every rate here carries a wide band. 95% Wilson intervals: stored-compute 11/15 is 48-89%, ondelete 13/15 is 60-95%, assert 3/15 is 7-45%, sudo 0/15 is 0-20%. They separate the rules into three groups and do not support finer claims than that.",
|
| 54 |
+
"hits": [
|
| 55 |
+
{
|
| 56 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 57 |
+
"path": "addons/account/models/account_move.py",
|
| 58 |
+
"line": 198,
|
| 59 |
+
"verdict": "true",
|
| 60 |
+
"class": null,
|
| 61 |
+
"why": "the construct and the harm the rule names are both present",
|
| 62 |
+
"still_reported_after_this_pass": true
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 66 |
+
"path": "addons/account/models/account_move.py",
|
| 67 |
+
"line": 290,
|
| 68 |
+
"verdict": "true",
|
| 69 |
+
"class": null,
|
| 70 |
+
"why": "the construct and the harm the rule names are both present",
|
| 71 |
+
"still_reported_after_this_pass": true
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 75 |
+
"path": "addons/account/models/account_report.py",
|
| 76 |
+
"line": 877,
|
| 77 |
+
"verdict": "true",
|
| 78 |
+
"class": null,
|
| 79 |
+
"why": "the construct and the harm the rule names are both present",
|
| 80 |
+
"still_reported_after_this_pass": true
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 84 |
+
"path": "addons/hr_contract/report/hr_contract_history.py",
|
| 85 |
+
"line": 25,
|
| 86 |
+
"verdict": "false",
|
| 87 |
+
"class": "sql-view-has-no-foreign-key",
|
| 88 |
+
"why": "hr.contract.history sets _auto = False, so there is no table, no foreign key and no ondelete to decide",
|
| 89 |
+
"still_reported_after_this_pass": false
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 93 |
+
"path": "addons/hr_expense/models/hr_expense_sheet.py",
|
| 94 |
+
"line": 192,
|
| 95 |
+
"verdict": "true",
|
| 96 |
+
"class": null,
|
| 97 |
+
"why": "the construct and the harm the rule names are both present",
|
| 98 |
+
"still_reported_after_this_pass": true
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 102 |
+
"path": "addons/hr_holidays/models/hr_leave.py",
|
| 103 |
+
"line": 163,
|
| 104 |
+
"verdict": "true",
|
| 105 |
+
"class": null,
|
| 106 |
+
"why": "the construct and the harm the rule names are both present",
|
| 107 |
+
"still_reported_after_this_pass": true
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 111 |
+
"path": "addons/l10n_it_edi_ndd/models/account_move.py",
|
| 112 |
+
"line": 17,
|
| 113 |
+
"verdict": "true",
|
| 114 |
+
"class": null,
|
| 115 |
+
"why": "the construct and the harm the rule names are both present",
|
| 116 |
+
"still_reported_after_this_pass": true
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 120 |
+
"path": "addons/mrp/models/stock_move.py",
|
| 121 |
+
"line": 230,
|
| 122 |
+
"verdict": "true",
|
| 123 |
+
"class": null,
|
| 124 |
+
"why": "the construct and the harm the rule names are both present",
|
| 125 |
+
"still_reported_after_this_pass": true
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 129 |
+
"path": "addons/purchase/models/purchase_bill_line_match.py",
|
| 130 |
+
"line": 16,
|
| 131 |
+
"verdict": "false",
|
| 132 |
+
"class": "sql-view-has-no-foreign-key",
|
| 133 |
+
"why": "purchase.bill.line.match sets _auto = False, same reason",
|
| 134 |
+
"still_reported_after_this_pass": false
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 138 |
+
"path": "addons/resource/models/resource_calendar.py",
|
| 139 |
+
"line": 85,
|
| 140 |
+
"verdict": "true",
|
| 141 |
+
"class": null,
|
| 142 |
+
"why": "the construct and the harm the rule names are both present",
|
| 143 |
+
"still_reported_after_this_pass": true
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 147 |
+
"path": "addons/sale_crm/models/sale_order.py",
|
| 148 |
+
"line": 10,
|
| 149 |
+
"verdict": "true",
|
| 150 |
+
"class": null,
|
| 151 |
+
"why": "the construct and the harm the rule names are both present",
|
| 152 |
+
"still_reported_after_this_pass": true
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 156 |
+
"path": "addons/stock/models/stock_warehouse.py",
|
| 157 |
+
"line": 75,
|
| 158 |
+
"verdict": "true",
|
| 159 |
+
"class": null,
|
| 160 |
+
"why": "the construct and the harm the rule names are both present",
|
| 161 |
+
"still_reported_after_this_pass": true
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 165 |
+
"path": "addons/stock_account/models/account_move.py",
|
| 166 |
+
"line": 10,
|
| 167 |
+
"verdict": "true",
|
| 168 |
+
"class": null,
|
| 169 |
+
"why": "the construct and the harm the rule names are both present",
|
| 170 |
+
"still_reported_after_this_pass": true
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 174 |
+
"path": "addons/stock_landed_costs/models/stock_landed_cost.py",
|
| 175 |
+
"line": 389,
|
| 176 |
+
"verdict": "true",
|
| 177 |
+
"class": null,
|
| 178 |
+
"why": "the construct and the harm the rule names are both present",
|
| 179 |
+
"still_reported_after_this_pass": true
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"rule": "odoo.ondelete-is-a-decision",
|
| 183 |
+
"path": "odoo/addons/test_access_rights/models.py",
|
| 184 |
+
"line": 13,
|
| 185 |
+
"verdict": "true",
|
| 186 |
+
"class": null,
|
| 187 |
+
"why": "the construct and the harm the rule names are both present",
|
| 188 |
+
"still_reported_after_this_pass": true
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 192 |
+
"path": "addons/account/models/account_move_line.py",
|
| 193 |
+
"line": 886,
|
| 194 |
+
"verdict": "true",
|
| 195 |
+
"class": null,
|
| 196 |
+
"why": "tax_ids is store=True and reads display_type, itself a store=True readonly=False field that can be edited without product_id changing",
|
| 197 |
+
"still_reported_after_this_pass": true
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 201 |
+
"path": "addons/account/models/account_tax.py",
|
| 202 |
+
"line": 4686,
|
| 203 |
+
"verdict": "true",
|
| 204 |
+
"class": null,
|
| 205 |
+
"why": "declares account_id and reads account_id.internal_group, so editing an account's type leaves use_in_tax_closing on its old answer",
|
| 206 |
+
"still_reported_after_this_pass": true
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 210 |
+
"path": "addons/hr_attendance/models/hr_attendance.py",
|
| 211 |
+
"line": 200,
|
| 212 |
+
"verdict": "false",
|
| 213 |
+
"class": "framework-unstored-magic-field",
|
| 214 |
+
"why": "display_name is added by odoo/models.py with no store and this file does not redeclare it",
|
| 215 |
+
"still_reported_after_this_pass": false
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 219 |
+
"path": "addons/hr_recruitment/models/calendar.py",
|
| 220 |
+
"line": 85,
|
| 221 |
+
"verdict": "true",
|
| 222 |
+
"class": null,
|
| 223 |
+
"why": "declares applicant_id and reads applicant_id.candidate_id",
|
| 224 |
+
"still_reported_after_this_pass": true
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 228 |
+
"path": "addons/l10n_in/models/account_move_line.py",
|
| 229 |
+
"line": 12,
|
| 230 |
+
"verdict": "true",
|
| 231 |
+
"class": null,
|
| 232 |
+
"why": "reads move_id.country_code and parent_state, declares neither, on a store=True HSN code a tax report is built from",
|
| 233 |
+
"still_reported_after_this_pass": true
|
| 234 |
+
},
|
| 235 |
+
{
|
| 236 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 237 |
+
"path": "addons/l10n_jo_edi_extended/models/account_move.py",
|
| 238 |
+
"line": 28,
|
| 239 |
+
"verdict": "true",
|
| 240 |
+
"class": null,
|
| 241 |
+
"why": "reads commercial_partner_id, declares only partner_id.country_code",
|
| 242 |
+
"still_reported_after_this_pass": true
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 246 |
+
"path": "addons/l10n_tw_edi_ecpay/models/account_move.py",
|
| 247 |
+
"line": 229,
|
| 248 |
+
"verdict": "true",
|
| 249 |
+
"class": null,
|
| 250 |
+
"why": "declares partner_id and reads partner_id.vat",
|
| 251 |
+
"still_reported_after_this_pass": true
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 255 |
+
"path": "addons/lunch/models/lunch_supplier.py",
|
| 256 |
+
"line": 127,
|
| 257 |
+
"verdict": "false",
|
| 258 |
+
"class": "framework-unstored-magic-field",
|
| 259 |
+
"why": "display_name again, not redeclared here",
|
| 260 |
+
"still_reported_after_this_pass": false
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 264 |
+
"path": "addons/mrp/models/mrp_production.py",
|
| 265 |
+
"line": 436,
|
| 266 |
+
"verdict": "true",
|
| 267 |
+
"class": null,
|
| 268 |
+
"why": "declares bom_id and reads bom_id.product_qty",
|
| 269 |
+
"still_reported_after_this_pass": true
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 273 |
+
"path": "addons/mrp_subcontracting/models/stock_picking.py",
|
| 274 |
+
"line": 44,
|
| 275 |
+
"verdict": "true",
|
| 276 |
+
"class": null,
|
| 277 |
+
"why": "reads picking_type_id.warehouse_id, declares only picking_type_id",
|
| 278 |
+
"still_reported_after_this_pass": true
|
| 279 |
+
},
|
| 280 |
+
{
|
| 281 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 282 |
+
"path": "addons/sale/models/sale_order_line.py",
|
| 283 |
+
"line": 503,
|
| 284 |
+
"verdict": "true",
|
| 285 |
+
"class": null,
|
| 286 |
+
"why": "reads product_packaging_id.qty, declares product_packaging_qty",
|
| 287 |
+
"still_reported_after_this_pass": true
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 291 |
+
"path": "addons/sale_stock/models/sale_order_line.py",
|
| 292 |
+
"line": 39,
|
| 293 |
+
"verdict": "true",
|
| 294 |
+
"class": null,
|
| 295 |
+
"why": "reads order_id.partner_shipping_id, declared nowhere",
|
| 296 |
+
"still_reported_after_this_pass": true
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 300 |
+
"path": "addons/stock/models/stock_picking.py",
|
| 301 |
+
"line": 720,
|
| 302 |
+
"verdict": "true",
|
| 303 |
+
"class": null,
|
| 304 |
+
"why": "reads group_id, declared nowhere, on a store=True move_type",
|
| 305 |
+
"still_reported_after_this_pass": true
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 309 |
+
"path": "addons/stock/models/stock_quant.py",
|
| 310 |
+
"line": 595,
|
| 311 |
+
"verdict": "false",
|
| 312 |
+
"class": "framework-unstored-magic-field",
|
| 313 |
+
"why": "display_name again, not redeclared here",
|
| 314 |
+
"still_reported_after_this_pass": false
|
| 315 |
+
},
|
| 316 |
+
{
|
| 317 |
+
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 318 |
+
"path": "odoo/addons/base/models/res_bank.py",
|
| 319 |
+
"line": 121,
|
| 320 |
+
"verdict": "false",
|
| 321 |
+
"class": "framework-unstored-magic-field",
|
| 322 |
+
"why": "display_name again, not redeclared here",
|
| 323 |
+
"still_reported_after_this_pass": false
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 327 |
+
"path": "addons/account/models/account_move.py",
|
| 328 |
+
"line": 3730,
|
| 329 |
+
"verdict": "false",
|
| 330 |
+
"class": "domain-scopes-it-already",
|
| 331 |
+
"why": "the domain pins journal_id and id, and journals are company-scoped, so the search cannot reach another company's moves",
|
| 332 |
+
"still_reported_after_this_pass": true
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 336 |
+
"path": "addons/calendar/controllers/main.py",
|
| 337 |
+
"line": 107,
|
| 338 |
+
"verdict": "false",
|
| 339 |
+
"class": "authentication-by-token",
|
| 340 |
+
"why": "a public route resolving an access_token before any identity exists; record rules cannot apply to a visitor",
|
| 341 |
+
"still_reported_after_this_pass": true
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 345 |
+
"path": "addons/event_sale/models/sale_order.py",
|
| 346 |
+
"line": 21,
|
| 347 |
+
"verdict": "false",
|
| 348 |
+
"class": "domain-scopes-it-already",
|
| 349 |
+
"why": "registrations of the caller's own sale orders, by id",
|
| 350 |
+
"still_reported_after_this_pass": true
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 354 |
+
"path": "addons/l10n_my_edi_pos/models/myinvois_document.py",
|
| 355 |
+
"line": 213,
|
| 356 |
+
"verdict": "false",
|
| 357 |
+
"class": "domain-scopes-it-already",
|
| 358 |
+
"why": "the same sequence-reference lookup as 1, pinned by id and name",
|
| 359 |
+
"still_reported_after_this_pass": true
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 363 |
+
"path": "addons/payment/models/payment_transaction.py",
|
| 364 |
+
"line": 361,
|
| 365 |
+
"verdict": "false",
|
| 366 |
+
"class": "global-uniqueness-by-design",
|
| 367 |
+
"why": "payment references must be unique across the database, so the prefix scan has to see rows the caller cannot",
|
| 368 |
+
"still_reported_after_this_pass": true
|
| 369 |
+
},
|
| 370 |
+
{
|
| 371 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 372 |
+
"path": "addons/point_of_sale/models/account_journal.py",
|
| 373 |
+
"line": 16,
|
| 374 |
+
"verdict": "false",
|
| 375 |
+
"class": "domain-scopes-it-already",
|
| 376 |
+
"why": "payment methods of the caller's own journals, by id",
|
| 377 |
+
"still_reported_after_this_pass": true
|
| 378 |
+
},
|
| 379 |
+
{
|
| 380 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 381 |
+
"path": "addons/point_of_sale/models/account_tax.py",
|
| 382 |
+
"line": 22,
|
| 383 |
+
"verdict": "false",
|
| 384 |
+
"class": "domain-scopes-it-already",
|
| 385 |
+
"why": "pinned to ids the caller already filtered out of self",
|
| 386 |
+
"still_reported_after_this_pass": true
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 390 |
+
"path": "addons/sale/wizard/sale_make_invoice_advance.py",
|
| 391 |
+
"line": 208,
|
| 392 |
+
"verdict": "false",
|
| 393 |
+
"class": "de-escalation",
|
| 394 |
+
"why": "invoice.sudo(self.env.su) restores the ambient level; the comment reads 'Unsudo the invoice after creation if not already sudoed'. A checker defect, fixed",
|
| 395 |
+
"still_reported_after_this_pass": false
|
| 396 |
+
},
|
| 397 |
+
{
|
| 398 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 399 |
+
"path": "addons/sale_management/models/sale_order_template.py",
|
| 400 |
+
"line": 146,
|
| 401 |
+
"verdict": "false",
|
| 402 |
+
"class": "cross-company-by-design",
|
| 403 |
+
"why": "archiving a shared template must clear it from every company that points at it, or the references dangle",
|
| 404 |
+
"still_reported_after_this_pass": true
|
| 405 |
+
},
|
| 406 |
+
{
|
| 407 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 408 |
+
"path": "addons/survey/controllers/survey_session_manage.py",
|
| 409 |
+
"line": 26,
|
| 410 |
+
"verdict": "false",
|
| 411 |
+
"class": "authentication-by-token",
|
| 412 |
+
"why": "a public survey session code resolved before identity",
|
| 413 |
+
"still_reported_after_this_pass": true
|
| 414 |
+
},
|
| 415 |
+
{
|
| 416 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 417 |
+
"path": "addons/website_event_track/controllers/event_track.py",
|
| 418 |
+
"line": 368,
|
| 419 |
+
"verdict": "false",
|
| 420 |
+
"class": "public-website-read",
|
| 421 |
+
"why": "a website controller rendering a published track to anonymous visitors",
|
| 422 |
+
"still_reported_after_this_pass": true
|
| 423 |
+
},
|
| 424 |
+
{
|
| 425 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 426 |
+
"path": "odoo/addons/base/models/ir_mail_server.py",
|
| 427 |
+
"line": 792,
|
| 428 |
+
"verdict": "false",
|
| 429 |
+
"class": "system-configuration-read",
|
| 430 |
+
"why": "listing outgoing mail servers to pick one; sending mail does not require read access to ir.mail_server",
|
| 431 |
+
"still_reported_after_this_pass": true
|
| 432 |
+
},
|
| 433 |
+
{
|
| 434 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 435 |
+
"path": "odoo/addons/base/models/ir_ui_view.py",
|
| 436 |
+
"line": 549,
|
| 437 |
+
"verdict": "false",
|
| 438 |
+
"class": "cross-user-by-design",
|
| 439 |
+
"why": "drops other users' view customisations when the view changes, which the comment beside it states as the intent",
|
| 440 |
+
"still_reported_after_this_pass": true
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 444 |
+
"path": "odoo/addons/base/models/res_partner.py",
|
| 445 |
+
"line": 667,
|
| 446 |
+
"verdict": "false",
|
| 447 |
+
"class": "cross-company-by-design",
|
| 448 |
+
"why": "iterating every company is what _company_dependent_commercial_sync is for",
|
| 449 |
+
"still_reported_after_this_pass": true
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 453 |
+
"path": "odoo/addons/base/models/res_partner.py",
|
| 454 |
+
"line": 827,
|
| 455 |
+
"verdict": "false",
|
| 456 |
+
"class": "guard-must-see-everything",
|
| 457 |
+
"why": "an @api.ondelete guard checking whether any user links to the partner; a scoped search would let the guard be bypassed",
|
| 458 |
+
"still_reported_after_this_pass": true
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"rule": "python.assert-is-not-validation",
|
| 462 |
+
"path": "addons/base_import_module/models/ir_module.py",
|
| 463 |
+
"line": 111,
|
| 464 |
+
"verdict": "true",
|
| 465 |
+
"class": null,
|
| 466 |
+
"why": "guards 'installable' read out of the manifest of a module being imported from an uploaded zip; under -O the gate disappears and a module marked not installable installs",
|
| 467 |
+
"still_reported_after_this_pass": true
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"rule": "python.assert-is-not-validation",
|
| 471 |
+
"path": "addons/delivery/models/sale_order.py",
|
| 472 |
+
"line": 100,
|
| 473 |
+
"verdict": "false",
|
| 474 |
+
"class": "private-helper-contract",
|
| 475 |
+
"why": "_get_pickup_locations is private and the docstring states the contract its own module's callers keep",
|
| 476 |
+
"still_reported_after_this_pass": true
|
| 477 |
+
},
|
| 478 |
+
{
|
| 479 |
+
"rule": "python.assert-is-not-validation",
|
| 480 |
+
"path": "addons/mail/tools/discuss.py",
|
| 481 |
+
"line": 147,
|
| 482 |
+
"verdict": "false",
|
| 483 |
+
"class": "private-helper-contract",
|
| 484 |
+
"why": "_add_values is a private Store helper; the key comes from callers inside the module",
|
| 485 |
+
"still_reported_after_this_pass": true
|
| 486 |
+
},
|
| 487 |
+
{
|
| 488 |
+
"rule": "python.assert-is-not-validation",
|
| 489 |
+
"path": "odoo/_monkeypatches/num2words.py",
|
| 490 |
+
"line": 391,
|
| 491 |
+
"verdict": "false",
|
| 492 |
+
"class": "self-check-on-module-constants",
|
| 493 |
+
"why": "asserts two hardcoded lists are the same length; no caller can make it false",
|
| 494 |
+
"still_reported_after_this_pass": true
|
| 495 |
+
},
|
| 496 |
+
{
|
| 497 |
+
"rule": "python.assert-is-not-validation",
|
| 498 |
+
"path": "odoo/addons/base/models/ir_mail_server.py",
|
| 499 |
+
"line": 666,
|
| 500 |
+
"verdict": "true",
|
| 501 |
+
"class": null,
|
| 502 |
+
"why": "the recipient list is derived from addresses handed to a public send_email, and the assert is what stops a send with no valid recipient",
|
| 503 |
+
"still_reported_after_this_pass": true
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"rule": "python.assert-is-not-validation",
|
| 507 |
+
"path": "odoo/addons/base/models/ir_model.py",
|
| 508 |
+
"line": 2098,
|
| 509 |
+
"verdict": "true",
|
| 510 |
+
"class": null,
|
| 511 |
+
"why": "access_mode is interpolated into SQL as perm_{access_mode} on a public, RPC-callable method, and this assert is the only thing constraining it to four literals",
|
| 512 |
+
"still_reported_after_this_pass": true
|
| 513 |
+
},
|
| 514 |
+
{
|
| 515 |
+
"rule": "python.assert-is-not-validation",
|
| 516 |
+
"path": "odoo/fields.py",
|
| 517 |
+
"line": 2653,
|
| 518 |
+
"verdict": "false",
|
| 519 |
+
"class": "framework-internal-invariant",
|
| 520 |
+
"why": "a field descriptor asserting its own configuration",
|
| 521 |
+
"still_reported_after_this_pass": true
|
| 522 |
+
},
|
| 523 |
+
{
|
| 524 |
+
"rule": "python.assert-is-not-validation",
|
| 525 |
+
"path": "odoo/fields.py",
|
| 526 |
+
"line": 2899,
|
| 527 |
+
"verdict": "false",
|
| 528 |
+
"class": "framework-internal-invariant",
|
| 529 |
+
"why": "registry setup asserting a Selection has a selection; developer error at boot",
|
| 530 |
+
"still_reported_after_this_pass": true
|
| 531 |
+
},
|
| 532 |
+
{
|
| 533 |
+
"rule": "python.assert-is-not-validation",
|
| 534 |
+
"path": "odoo/models.py",
|
| 535 |
+
"line": 241,
|
| 536 |
+
"verdict": "false",
|
| 537 |
+
"class": "framework-internal-invariant",
|
| 538 |
+
"why": "asserts a model class is imported from odoo.addons; developer error at class definition",
|
| 539 |
+
"still_reported_after_this_pass": true
|
| 540 |
+
},
|
| 541 |
+
{
|
| 542 |
+
"rule": "python.assert-is-not-validation",
|
| 543 |
+
"path": "odoo/modules/registry.py",
|
| 544 |
+
"line": 783,
|
| 545 |
+
"verdict": "false",
|
| 546 |
+
"class": "framework-internal-invariant",
|
| 547 |
+
"why": "Registry.clear_cache asserting its own cache-name convention",
|
| 548 |
+
"still_reported_after_this_pass": true
|
| 549 |
+
},
|
| 550 |
+
{
|
| 551 |
+
"rule": "python.assert-is-not-validation",
|
| 552 |
+
"path": "odoo/modules/registry.py",
|
| 553 |
+
"line": 875,
|
| 554 |
+
"verdict": "false",
|
| 555 |
+
"class": "framework-internal-invariant",
|
| 556 |
+
"why": "asserts the cursor is not the read-only replica",
|
| 557 |
+
"still_reported_after_this_pass": true
|
| 558 |
+
},
|
| 559 |
+
{
|
| 560 |
+
"rule": "python.assert-is-not-validation",
|
| 561 |
+
"path": "odoo/tools/pycompat.py",
|
| 562 |
+
"line": 15,
|
| 563 |
+
"verdict": "false",
|
| 564 |
+
"class": "private-helper-contract",
|
| 565 |
+
"why": "a deprecated compatibility shim asserting the stream type its callers pass",
|
| 566 |
+
"still_reported_after_this_pass": true
|
| 567 |
+
},
|
| 568 |
+
{
|
| 569 |
+
"rule": "python.assert-is-not-validation",
|
| 570 |
+
"path": "odoo/tools/query.py",
|
| 571 |
+
"line": 124,
|
| 572 |
+
"verdict": "false",
|
| 573 |
+
"class": "framework-internal-invariant",
|
| 574 |
+
"why": "the Query builder asserting an alias it generated itself is known",
|
| 575 |
+
"still_reported_after_this_pass": true
|
| 576 |
+
},
|
| 577 |
+
{
|
| 578 |
+
"rule": "python.assert-is-not-validation",
|
| 579 |
+
"path": "odoo/tools/sql.py",
|
| 580 |
+
"line": 668,
|
| 581 |
+
"verdict": "false",
|
| 582 |
+
"class": "framework-internal-invariant",
|
| 583 |
+
"why": "asserts the named fields are integers; the names reach SQL only through SQL.identifier, which escapes them",
|
| 584 |
+
"still_reported_after_this_pass": true
|
| 585 |
+
},
|
| 586 |
+
{
|
| 587 |
+
"rule": "python.assert-is-not-validation",
|
| 588 |
+
"path": "odoo/tools/translate.py",
|
| 589 |
+
"line": 633,
|
| 590 |
+
"verdict": "false",
|
| 591 |
+
"class": "framework-internal-invariant",
|
| 592 |
+
"why": "a translation helper asserting its own argument convention",
|
| 593 |
+
"still_reported_after_this_pass": true
|
| 594 |
+
}
|
| 595 |
+
],
|
| 596 |
+
"note": "The headline is that precision depends on what KIND of code is under review, and this is the first tree here that made the difference legible. assert-is-not-validation measured 6 true / 4 false on OCA addons and 3 true / 12 false here. The rule's own true class is 'the asserted value came from outside the module', and a framework's asserts are overwhelmingly invariants between its own components -- the registry, the field descriptors, the query builder -- where there is no outside. Same rule, same code, different subject.\n\nsudo-bypasses-record-rules measured 0 of 15 on core against 6 true on OpenSPP. Every core finding is a deliberate framework elevation: a public route resolving a token before any identity exists, a delete guard that has to see records the caller cannot, a cross-company sync whose entire purpose is to cross companies. The rule is about an application leaking between tenants, and core is the thing that implements tenancy.",
|
| 597 |
+
"worklist": "One finding in the assert sample is worth acting on outside this project: odoo core's ir.model.access.group_names_with_access interpolates its access_mode argument straight into SQL as perm_{access_mode}, and the only thing constraining it to four literals is an assert. The method is public and RPC-callable. Odoo does not run under python -O, so this is latent rather than live, and the assert also turns a bad argument into a 500 rather than a clean error.\n\nStill unread on core: roughly 1,590 findings, led by naive-and-aware-datetimes (167), constrains-must-list-every-field-read (151) and depends-must-not-name-unread-fields (134)."
|
| 598 |
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"generated_utc": "2026-09-21T12:02:21+00:00",
|
| 3 |
+
"stack": "odoo",
|
| 4 |
+
"checkouts": {
|
| 5 |
+
"OpenSPP2-19.0": "cfa0dc8ee3432ab2dd095a4882a861cc9dfba039",
|
| 6 |
+
"server-tools": "60cd54e5ee8f6538a14266af8a7b3d1da0af08ec",
|
| 7 |
+
"account-financial-tools": "e4c1b86aa0a61d9f484c1a7e9830a63f5e6a2233"
|
| 8 |
+
},
|
| 9 |
+
"scope": "every live finding odoo.sudo-bypasses-record-rules makes on both trees after the rule grew the two clauses its own sentence asks for -- a write or read on a record under sudo, and a sudo recordset passed onward -- read by hand against the file it sits in. Findings on a line already carrying another analyser's suppression marker are excluded, as they are from every rate in this project. The six query-clause findings on OCA were classified in classification-oca-2026-09.json and are not re-litigated here.",
|
| 10 |
+
"lines": 260897,
|
| 11 |
+
"totals": {
|
| 12 |
+
"false": 24
|
| 13 |
+
},
|
| 14 |
+
"by_clause": {
|
| 15 |
+
"query": {
|
| 16 |
+
"true": 0,
|
| 17 |
+
"false": 3
|
| 18 |
+
},
|
| 19 |
+
"touch": {
|
| 20 |
+
"true": 0,
|
| 21 |
+
"false": 21
|
| 22 |
+
},
|
| 23 |
+
"rebind": {
|
| 24 |
+
"true_in_history": 1,
|
| 25 |
+
"false_at_head": 1,
|
| 26 |
+
"note": "server-tools cbefaa511485 is an on-target recall catch; the one live finding at HEAD is false"
|
| 27 |
+
}
|
| 28 |
+
},
|
| 29 |
+
"false_classes": {
|
| 30 |
+
"touches-a-record-already-held": 7,
|
| 31 |
+
"demo-seeder": 4,
|
| 32 |
+
"framework-metadata-read": 2,
|
| 33 |
+
"writes-the-callers-own-company": 2,
|
| 34 |
+
"configuration-model-create": 2,
|
| 35 |
+
"global-uniqueness-by-design": 1,
|
| 36 |
+
"authentication-before-identity": 1,
|
| 37 |
+
"framework-metadata-create": 1,
|
| 38 |
+
"dba-configuration-model": 1,
|
| 39 |
+
"rebind-over-metadata-only": 1,
|
| 40 |
+
"company-refiltered-outside-the-call": 1,
|
| 41 |
+
"scoped-to-a-record-already-held": 1
|
| 42 |
+
},
|
| 43 |
+
"hits": [
|
| 44 |
+
{
|
| 45 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 46 |
+
"path": "account-financial-tools/account_asset_management/models/account_asset.py",
|
| 47 |
+
"line": 1233,
|
| 48 |
+
"clause": "touch",
|
| 49 |
+
"likelihood": "unproven",
|
| 50 |
+
"verdict": "false",
|
| 51 |
+
"class": "company-refiltered-outside-the-call",
|
| 52 |
+
"why": "triggers is recomputes filtered on company_id.id in companies.ids one line above, which is the rule's own advice followed where the checker reads only the call"
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 56 |
+
"path": "account-financial-tools/account_asset_management/models/account_move.py",
|
| 57 |
+
"line": 157,
|
| 58 |
+
"clause": "touch",
|
| 59 |
+
"likelihood": "unproven",
|
| 60 |
+
"verdict": "false",
|
| 61 |
+
"class": "framework-metadata-read",
|
| 62 |
+
"why": "reads an ir.actions.act_window fetched by env.ref; no tenant boundary"
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 66 |
+
"path": "account-financial-tools/account_journal_general_sequence/wizards/account_move_renumber_wizard.py",
|
| 67 |
+
"line": 85,
|
| 68 |
+
"clause": "touch",
|
| 69 |
+
"likelihood": "unproven",
|
| 70 |
+
"verdict": "false",
|
| 71 |
+
"class": "scoped-to-a-record-already-held",
|
| 72 |
+
"why": "the date ranges belong to the one sequence the wizard holds, and the wizard is ACL-gated to accounting managers by its own comment and its access rules"
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 76 |
+
"path": "account-financial-tools/account_lock_date_update/wizards/account_update_lock_date.py",
|
| 77 |
+
"line": 82,
|
| 78 |
+
"clause": "touch",
|
| 79 |
+
"likelihood": "unproven",
|
| 80 |
+
"verdict": "false",
|
| 81 |
+
"class": "writes-the-callers-own-company",
|
| 82 |
+
"why": "writes lock dates onto self.company_id after _check_execute_allowed has required group_account_manager"
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 86 |
+
"path": "account-financial-tools/account_lock_to_date/wizards/account_update_lock_to_date.py",
|
| 87 |
+
"line": 61,
|
| 88 |
+
"clause": "touch",
|
| 89 |
+
"likelihood": "unproven",
|
| 90 |
+
"verdict": "false",
|
| 91 |
+
"class": "writes-the-callers-own-company",
|
| 92 |
+
"why": "the same wizard for the to-date variant, behind the same group check"
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 96 |
+
"path": "account-financial-tools/account_move_line_purchase_info/models/purchase_order.py",
|
| 97 |
+
"line": 30,
|
| 98 |
+
"clause": "touch",
|
| 99 |
+
"likelihood": "unproven",
|
| 100 |
+
"verdict": "false",
|
| 101 |
+
"class": "touches-a-record-already-held",
|
| 102 |
+
"why": "self.sudo().read() prefetches one field on the records the caller already has, and the result is discarded"
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 106 |
+
"path": "account-financial-tools/account_move_name_sequence/models/account_journal.py",
|
| 107 |
+
"line": 111,
|
| 108 |
+
"clause": "touch",
|
| 109 |
+
"likelihood": "unproven",
|
| 110 |
+
"verdict": "false",
|
| 111 |
+
"class": "configuration-model-create",
|
| 112 |
+
"why": "creates the ir.sequence a journal needs, from vals prepared off that journal, after a non-sudo search found none"
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 116 |
+
"path": "account-financial-tools/account_move_name_sequence/models/ir_sequence.py",
|
| 117 |
+
"line": 51,
|
| 118 |
+
"clause": "touch",
|
| 119 |
+
"likelihood": "unproven",
|
| 120 |
+
"verdict": "false",
|
| 121 |
+
"class": "configuration-model-create",
|
| 122 |
+
"why": "a date range for the sequence the caller holds, keyed by self.id"
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 126 |
+
"path": "endpoint_route_handler/models/endpoint_route_handler.py",
|
| 127 |
+
"line": 65,
|
| 128 |
+
"clause": "query",
|
| 129 |
+
"likelihood": "defect",
|
| 130 |
+
"verdict": "false",
|
| 131 |
+
"class": "global-uniqueness-by-design",
|
| 132 |
+
"why": "a route is registered once for the whole database, so the clash check has to see every company's rows; scoping it would break the thing it exists to do"
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 136 |
+
"path": "fastapi/models/fastapi_endpoint_demo.py",
|
| 137 |
+
"line": 89,
|
| 138 |
+
"clause": "query",
|
| 139 |
+
"likelihood": "defect",
|
| 140 |
+
"verdict": "false",
|
| 141 |
+
"class": "authentication-before-identity",
|
| 142 |
+
"why": "an api key is resolved to a user before any identity exists, so there is no caller whose record rules could have applied"
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 146 |
+
"path": "server-tools/auditlog/models/rule.py",
|
| 147 |
+
"line": 827,
|
| 148 |
+
"clause": "touch",
|
| 149 |
+
"likelihood": "unproven",
|
| 150 |
+
"verdict": "false",
|
| 151 |
+
"class": "framework-metadata-create",
|
| 152 |
+
"why": "creates the ir.actions.act_window that surfaces an audit log; a window action has no tenant boundary"
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 156 |
+
"path": "server-tools/database_autovacuum_tuning/models/database_autovacuum_tuning.py",
|
| 157 |
+
"line": 34,
|
| 158 |
+
"clause": "touch",
|
| 159 |
+
"likelihood": "unproven",
|
| 160 |
+
"verdict": "false",
|
| 161 |
+
"class": "dba-configuration-model",
|
| 162 |
+
"why": "a postgres tuning row keyed by schema and table name, on a model with no tenant boundary at all"
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 166 |
+
"path": "server-tools/tracking_manager/models/ir_model.py",
|
| 167 |
+
"line": 58,
|
| 168 |
+
"clause": "query",
|
| 169 |
+
"likelihood": "defect",
|
| 170 |
+
"verdict": "false",
|
| 171 |
+
"class": "rebind-over-metadata-only",
|
| 172 |
+
"why": "`self = self.sudo()` is the construct exactly, and what the method then reads is ir.model.fields and ir.model -- framework metadata with no tenant boundary. The latent half of the rule is real here (anything added below that line is silently elevated too) but nothing below it today crosses a boundary"
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 176 |
+
"path": "spp_case_demo/models/generate_cases.py",
|
| 177 |
+
"line": 233,
|
| 178 |
+
"clause": "touch",
|
| 179 |
+
"likelihood": "unproven",
|
| 180 |
+
"verdict": "false",
|
| 181 |
+
"class": "demo-seeder",
|
| 182 |
+
"why": "a demo wizard creating an intervention under the plan it created a few lines earlier; the run is an administrator seeding a database and has no caller to isolate from"
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 186 |
+
"path": "spp_case_demo/models/generate_cases.py",
|
| 187 |
+
"line": 252,
|
| 188 |
+
"clause": "touch",
|
| 189 |
+
"likelihood": "unproven",
|
| 190 |
+
"verdict": "false",
|
| 191 |
+
"class": "touches-a-record-already-held",
|
| 192 |
+
"why": "intervention came from a non-sudo search two lines above, so the caller could already see it and sudo only elevates the write"
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 196 |
+
"path": "spp_case_demo/models/generate_cases.py",
|
| 197 |
+
"line": 308,
|
| 198 |
+
"clause": "touch",
|
| 199 |
+
"likelihood": "unproven",
|
| 200 |
+
"verdict": "false",
|
| 201 |
+
"class": "touches-a-record-already-held",
|
| 202 |
+
"why": "case was created by this same wizard in this same call"
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 206 |
+
"path": "spp_case_demo/models/generate_cases.py",
|
| 207 |
+
"line": 411,
|
| 208 |
+
"clause": "touch",
|
| 209 |
+
"likelihood": "unproven",
|
| 210 |
+
"verdict": "false",
|
| 211 |
+
"class": "demo-seeder",
|
| 212 |
+
"why": "interventions attached to the plan the wizard just created"
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 216 |
+
"path": "spp_case_demo/models/generate_cases.py",
|
| 217 |
+
"line": 466,
|
| 218 |
+
"clause": "touch",
|
| 219 |
+
"likelihood": "unproven",
|
| 220 |
+
"verdict": "false",
|
| 221 |
+
"class": "touches-a-record-already-held",
|
| 222 |
+
"why": "case was created by this same wizard in this same call"
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 226 |
+
"path": "spp_case_demo/models/generate_cases.py",
|
| 227 |
+
"line": 556,
|
| 228 |
+
"clause": "touch",
|
| 229 |
+
"likelihood": "unproven",
|
| 230 |
+
"verdict": "false",
|
| 231 |
+
"class": "demo-seeder",
|
| 232 |
+
"why": "creates a referral service after a non-sudo search failed to find one; spp.service carries no rule the create could land outside of"
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 236 |
+
"path": "spp_grm_demo/models/generate_tickets.py",
|
| 237 |
+
"line": 558,
|
| 238 |
+
"clause": "touch",
|
| 239 |
+
"likelihood": "unproven",
|
| 240 |
+
"verdict": "false",
|
| 241 |
+
"class": "touches-a-record-already-held",
|
| 242 |
+
"why": "ticket was created by this same wizard in this same call"
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 246 |
+
"path": "spp_grm_demo/models/generate_tickets.py",
|
| 247 |
+
"line": 781,
|
| 248 |
+
"clause": "touch",
|
| 249 |
+
"likelihood": "unproven",
|
| 250 |
+
"verdict": "false",
|
| 251 |
+
"class": "touches-a-record-already-held",
|
| 252 |
+
"why": "ticket was created by this same wizard in this same call"
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 256 |
+
"path": "spp_grm_demo/models/generate_tickets.py",
|
| 257 |
+
"line": 799,
|
| 258 |
+
"clause": "touch",
|
| 259 |
+
"likelihood": "unproven",
|
| 260 |
+
"verdict": "false",
|
| 261 |
+
"class": "touches-a-record-already-held",
|
| 262 |
+
"why": "assigns the ticket it created to a user drawn from a non-sudo search"
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 266 |
+
"path": "spp_mis_demo_v2/models/mis_demo_generator.py",
|
| 267 |
+
"line": 3147,
|
| 268 |
+
"clause": "touch",
|
| 269 |
+
"likelihood": "unproven",
|
| 270 |
+
"verdict": "false",
|
| 271 |
+
"class": "demo-seeder",
|
| 272 |
+
"why": "an exception fallback in a demo wizard stamping the approval flags that action_apply failed to set on the record it is iterating"
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
"rule": "odoo.sudo-bypasses-record-rules",
|
| 276 |
+
"path": "spp_mis_demo_v2/models/mis_demo_generator.py",
|
| 277 |
+
"line": 3890,
|
| 278 |
+
"clause": "touch",
|
| 279 |
+
"likelihood": "unproven",
|
| 280 |
+
"verdict": "false",
|
| 281 |
+
"class": "framework-metadata-read",
|
| 282 |
+
"why": "reads an ir.actions.act_window fetched by env.ref to return it as the wizard's next action; a window action has no tenant boundary and is routinely unreadable to an ordinary user"
|
| 283 |
+
}
|
| 284 |
+
],
|
| 285 |
+
"note": "0 true of 24. The query clause -- search, search_count, search_read, read_group -- keeps the 6 true positives classified in classification-openspp-rest-2026-09.json and is unchanged by this pass. The touch clause (create, write, unlink, copy, read) and the escape clause (a sudo recordset bound to a name and passed on) are new, and in 260,897 lines neither has met a real one at HEAD. Every finding they made is code where the caller is an administrator by construction -- a demo-data seeder, an ACL-gated accounting wizard -- or a framework model with no tenant boundary. So those two clauses emit likelihood 'unproven' per finding, which makes them advisory: reported, out of the rate, gating nothing. The grade travels with the finding, so the query clause keeps the rule's 'defect'.",
|
| 286 |
+
"worklist": "touches-a-record-already-held is 7 of the 23, and scoped-to-a-record-already-held is an eighth of the same shape. One predicate: a recordset the caller obtained under its own rules, then written under sudo. It is not nothing -- ir.rule governs write permission as well as visibility -- but it is a different claim from the one the rule's why paragraph makes, which is about a search returning another company's rows. Deciding whether that is a second rule or a narrowing wants a tree where it fires on production code, and neither of these two is that tree."
|
| 287 |
+
}
|
|
@@ -59,9 +59,10 @@ def _onchange_qty(self):
|
|
| 59 |
# Odoo 16 only. From 19 this attribute is ignored and creates nothing:
|
| 60 |
# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]""",
|
| 61 |
"tags": ["onchange", "constraints", "bypass"],
|
| 62 |
-
# onchange is allowed as UX garnish, but only alongside a real enforcement
|
| 63 |
-
|
| 64 |
-
|
|
|
|
| 65 |
},
|
| 66 |
{
|
| 67 |
"id": "odoo.constrains-must-list-every-field-read",
|
|
@@ -638,19 +639,23 @@ ENRICH = {
|
|
| 638 |
# field from a provider and narrowing a domain are what onchange is for,
|
| 639 |
# and flagging those fired on all 16 onchange declarations in two OCA
|
| 640 |
# repositories, none of which was validating anything.
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
#
|
| 645 |
-
#
|
| 646 |
-
#
|
| 647 |
-
#
|
| 648 |
-
#
|
| 649 |
-
#
|
| 650 |
-
#
|
| 651 |
-
#
|
| 652 |
-
#
|
| 653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
"source": ODOO_ONCHANGE,
|
| 655 |
},
|
| 656 |
"odoo.constrains-ignores-dotted-names": {
|
|
@@ -671,10 +676,16 @@ ENRICH = {
|
|
| 671 |
},
|
| 672 |
"odoo.float-comparison-needs-float-utils": {
|
| 673 |
# Equality only. Ordering comparisons against zero (qty > 0) have bounded
|
| 674 |
-
# error and are usually fine; '== 0' is the trap, and this also
|
| 675 |
-
#
|
| 676 |
-
|
| 677 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
"source": ODOO_CONSTRAINTS,
|
| 679 |
},
|
| 680 |
"odoo.prefer-sql-constraint": {
|
|
@@ -728,7 +739,7 @@ ENRICH = {
|
|
| 728 |
# were the defect: the other 100 were on models the tree gives no
|
| 729 |
# filtering rule, where sudo drops nothing. The AST pass asks the tree
|
| 730 |
# the second half of the question.
|
| 731 |
-
"semantic_check": "
|
| 732 |
# Record rules are a security concept, not an ORM one. The ORM reference
|
| 733 |
# does not mention sudo at all.
|
| 734 |
"source": ODOO_SECURITY,
|
|
@@ -825,12 +836,13 @@ ENRICH = {
|
|
| 825 |
"source": PY_ERRORS,
|
| 826 |
},
|
| 827 |
"python.eval-and-pickle-execute-their-input": {
|
| 828 |
-
# 0 true, 1 false. The one finding
|
| 829 |
-
# method's DOCSTRING, in a sentence arguing against using it
|
| 830 |
-
#
|
| 831 |
-
#
|
| 832 |
-
#
|
| 833 |
-
#
|
|
|
|
| 834 |
"violation_pattern": r"\beval\(|\bexec\(|pickle\.loads?\(",
|
| 835 |
"source": PY_PICKLE,
|
| 836 |
},
|
|
@@ -842,43 +854,37 @@ ENRICH = {
|
|
| 842 |
"source": PY_SUBPROCESS,
|
| 843 |
},
|
| 844 |
"python.path-join-does-not-contain-a-path": {
|
| 845 |
-
# 0 true
|
| 846 |
-
#
|
| 847 |
-
#
|
| 848 |
-
#
|
| 849 |
-
# the alternation and says nothing about where the value came from
|
| 850 |
-
#
|
| 851 |
-
# reported here rather than acted on.
|
| 852 |
#
|
| 853 |
-
#
|
| 854 |
-
#
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
# Cleared by resolving and comparing, which is the fix. Without this the
|
| 859 |
-
# rule's own correct sample matches its own violation pattern: the fix
|
| 860 |
-
# still joins, it just checks the result afterwards.
|
| 861 |
-
"satisfied_pattern": r"commonpath|commonprefix|relpath|is_relative_to",
|
| 862 |
"source": PY_OSPATH,
|
| 863 |
},
|
| 864 |
"python.decimal-for-money-not-float": {
|
| 865 |
-
# 0 true, 3 false, and this one
|
| 866 |
# sample size. Odoo's Monetary field IS a float -- the ORM defines it
|
| 867 |
# that way -- and the framework's answer to the precision problem is
|
| 868 |
# float_round and float_compare against the currency's rounding, not
|
| 869 |
-
# Decimal. So inside an Odoo module the
|
| 870 |
-
# remedy cannot be taken.
|
| 871 |
-
# three findings do not settle it either way; it is written down in the
|
| 872 |
-
# README as the composition's open question rather than acted on here.
|
| 873 |
#
|
| 874 |
-
#
|
| 875 |
-
#
|
| 876 |
-
#
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
|
|
|
| 882 |
"source": PY_DECIMAL,
|
| 883 |
},
|
| 884 |
"python.naive-and-aware-datetimes-do-not-compare": {
|
|
@@ -969,6 +975,75 @@ LIKELIHOOD = {
|
|
| 969 |
# construct is a real defect; nothing measured here has met one. See the
|
| 970 |
# checker's docstring for how the rule came about and which half was wrong.
|
| 971 |
"odoo.except-order-follows-the-hierarchy": "unproven",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
}
|
| 973 |
|
| 974 |
# --------------------------------------------------------------------- PROBES
|
|
|
|
| 59 |
# Odoo 16 only. From 19 this attribute is ignored and creates nothing:
|
| 60 |
# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]""",
|
| 61 |
"tags": ["onchange", "constraints", "bypass"],
|
| 62 |
+
# onchange is allowed as UX garnish, but only alongside a real enforcement
|
| 63 |
+
# layer. Both halves of that -- what counts as trying to enforce, and what
|
| 64 |
+
# counts as a real layer -- are in onchange_tries_to_enforce, which is where
|
| 65 |
+
# they can be scoped to the decorated function.
|
| 66 |
},
|
| 67 |
{
|
| 68 |
"id": "odoo.constrains-must-list-every-field-read",
|
|
|
|
| 639 |
# field from a provider and narrowing a domain are what onchange is for,
|
| 640 |
# and flagging those fired on all 16 onchange declarations in two OCA
|
| 641 |
# repositories, none of which was validating anything.
|
| 642 |
+
#
|
| 643 |
+
# Was a regex reading 400 characters after the decorator, which is not
|
| 644 |
+
# a function and could not see where one ended. All three of its false
|
| 645 |
+
# positives were the same failure: a short onchange that assigns a
|
| 646 |
+
# field, followed by an action method that raises, with the window
|
| 647 |
+
# crossing the boundary between them. The AST pass scopes the question
|
| 648 |
+
# to the decorated function.
|
| 649 |
+
#
|
| 650 |
+
# The satisfied_pattern moved inside the checker with the same three
|
| 651 |
+
# spellings, including the legacy one. Dropping _sql_constraints was
|
| 652 |
+
# tried and reverted: it IS a real constraint on 16 through 18, and the
|
| 653 |
+
# first field test after the change produced a false positive on OCA's
|
| 654 |
+
# account_loan -- branch 18.0, a genuine _sql_constraints block, an
|
| 655 |
+
# onchange properly backed by it. The 19 hole is left open on purpose
|
| 656 |
+
# and closed where the series IS legible: prefer_sql_constraint_applies
|
| 657 |
+
# reads odoo_series off the tree facts and only tightens when it says 19+.
|
| 658 |
+
"semantic_check": "onchange_tries_to_enforce",
|
| 659 |
"source": ODOO_ONCHANGE,
|
| 660 |
},
|
| 661 |
"odoo.constrains-ignores-dotted-names": {
|
|
|
|
| 676 |
},
|
| 677 |
"odoo.float-comparison-needs-float-utils": {
|
| 678 |
# Equality only. Ordering comparisons against zero (qty > 0) have bounded
|
| 679 |
+
# error and are usually fine; '== 0' is the trap, and this also keeps the
|
| 680 |
+
# checker off CHECK(qty > 0), where float_compare does not apply.
|
| 681 |
+
#
|
| 682 |
+
# Was a regex on the variable's NAME, at 2 true and 5 false. Every one
|
| 683 |
+
# of the five compared an integer to zero -- a search_count, a
|
| 684 |
+
# read_group `<field>_count`, a SELECT COUNT(*), an fields.Integer --
|
| 685 |
+
# where == is exact and the rule has nothing to say. Three of the five
|
| 686 |
+
# and both true positives were spelled `total == 0`, so the name could
|
| 687 |
+
# never have told them apart; the assignment can, and does.
|
| 688 |
+
"semantic_check": "float_equality_against_zero",
|
| 689 |
"source": ODOO_CONSTRAINTS,
|
| 690 |
},
|
| 691 |
"odoo.prefer-sql-constraint": {
|
|
|
|
| 739 |
# were the defect: the other 100 were on models the tree gives no
|
| 740 |
# filtering rule, where sudo drops nothing. The AST pass asks the tree
|
| 741 |
# the second half of the question.
|
| 742 |
+
"semantic_check": "sudo_drops_record_rules",
|
| 743 |
# Record rules are a security concept, not an ORM one. The ORM reference
|
| 744 |
# does not mention sudo at all.
|
| 745 |
"source": ODOO_SECURITY,
|
|
|
|
| 836 |
"source": PY_ERRORS,
|
| 837 |
},
|
| 838 |
"python.eval-and-pickle-execute-their-input": {
|
| 839 |
+
# 0 true, 1 false. The one finding was the word eval() written in a
|
| 840 |
+
# method's DOCSTRING, in a sentence arguing against using it -- not
|
| 841 |
+
# this rule's fault but a defect in what patterns were matched
|
| 842 |
+
# against, since strip_comments removed # comments and left string
|
| 843 |
+
# literals standing. Docstrings are now emptied before matching, so
|
| 844 |
+
# that finding does not recur. One sample decides nothing about the
|
| 845 |
+
# rule itself, which stands as written.
|
| 846 |
"violation_pattern": r"\beval\(|\bexec\(|pickle\.loads?\(",
|
| 847 |
"source": PY_PICKLE,
|
| 848 |
},
|
|
|
|
| 854 |
"source": PY_SUBPROCESS,
|
| 855 |
},
|
| 856 |
"python.path-join-does-not-contain-a-path": {
|
| 857 |
+
# Was a regex on component NAMES, at 0 true and 4 false. All four join
|
| 858 |
+
# a configured directory to a name this module generated -- a
|
| 859 |
+
# timestamped backup filename, a literal from a list declared in the
|
| 860 |
+
# same function, a module path from get_module_path. `filename` was in
|
| 861 |
+
# the alternation and says nothing about where the value came from,
|
| 862 |
+
# which is the only thing the rule is about.
|
|
|
|
| 863 |
#
|
| 864 |
+
# The AST pass asks the origin instead: it fires where a component came
|
| 865 |
+
# from outside the file and the result is not resolved and compared
|
| 866 |
+
# first. That reports less, deliberately -- the regex's entire output
|
| 867 |
+
# on 294,000 lines was false.
|
| 868 |
+
"semantic_check": "path_join_takes_an_external_name",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
"source": PY_OSPATH,
|
| 870 |
},
|
| 871 |
"python.decimal-for-money-not-float": {
|
| 872 |
+
# 0 true, 3 false, and this one had a reason behind it rather than a
|
| 873 |
# sample size. Odoo's Monetary field IS a float -- the ORM defines it
|
| 874 |
# that way -- and the framework's answer to the precision problem is
|
| 875 |
# float_round and float_compare against the currency's rounding, not
|
| 876 |
+
# Decimal. So inside an Odoo module the old pattern fired on the idiom
|
| 877 |
+
# and named a remedy that cannot be taken.
|
|
|
|
|
|
|
| 878 |
#
|
| 879 |
+
# Three findings do not settle a rule either way, so it is narrowed
|
| 880 |
+
# rather than culled, on the line the argument actually draws: the
|
| 881 |
+
# remedy exists only where the value is still a string when it
|
| 882 |
+
# arrives. float(self.amount) is the ORM's own type and is not
|
| 883 |
+
# reported; float(payload['price']), where Decimal(payload['price'])
|
| 884 |
+
# is available, is. Money, not arithmetic, as before -- a bare float(
|
| 885 |
+
# caught `float(successes) / total` inside a Wilson score interval,
|
| 886 |
+
# which is exactly what binary floating point is for.
|
| 887 |
+
"semantic_check": "float_coercion_of_external_money",
|
| 888 |
"source": PY_DECIMAL,
|
| 889 |
},
|
| 890 |
"python.naive-and-aware-datetimes-do-not-compare": {
|
|
|
|
| 975 |
# construct is a real defect; nothing measured here has met one. See the
|
| 976 |
# checker's docstring for how the rule came about and which half was wrong.
|
| 977 |
"odoo.except-order-follows-the-hierarchy": "unproven",
|
| 978 |
+
|
| 979 |
+
# ---- graded 2026-09-21, from `recall --aim` over both ecosystems -------
|
| 980 |
+
#
|
| 981 |
+
# 795 fix commits read across OpenSPP and the two OCA trees, 237 of them
|
| 982 |
+
# touching a covered construct, and every finding a checker made in a file
|
| 983 |
+
# one of those fixes touched placed against the method the fix edited.
|
| 984 |
+
# The four below are the ones the placements decide; the rest stay
|
| 985 |
+
# `unmeasured`, which is what they are.
|
| 986 |
+
|
| 987 |
+
# 3 on-target of 20 placements, and one commit caught outright. The same
|
| 988 |
+
# ratio that graded sudo a defect, on a smaller sample: when this rule
|
| 989 |
+
# fires on a line somebody was fixing, the formatted SQL was the bug.
|
| 990 |
+
"odoo.sql-needs-query-parameters": "defect",
|
| 991 |
+
|
| 992 |
+
# 2 on-target of 23 placements and two commits caught, on top of the 20
|
| 993 |
+
# hand-classified true positives in classification-python-2026-09.json.
|
| 994 |
+
# An Odoo Datetime is naive UTC by contract, so a naive LOCAL datetime
|
| 995 |
+
# reaching one is wrong by the server's offset -- and OpenSPP's history
|
| 996 |
+
# carries two fixes of exactly that, which is what a defect grade means.
|
| 997 |
+
"python.naive-and-aware-datetimes-do-not-compare": "defect",
|
| 998 |
+
|
| 999 |
+
# 1 on-target of 14, 3 in the right method describing something else, and
|
| 1000 |
+
# one commit caught. The same shape as ondelete's grade and a touch better
|
| 1001 |
+
# aimed: the rule recognises a company-crossing relation and is about the
|
| 1002 |
+
# declaration rather than the line anybody fixes.
|
| 1003 |
+
"odoo.check-company-on-relations": "construct",
|
| 1004 |
+
|
| 1005 |
+
# 1 on-target of 25, 2 in the right method. Its twin
|
| 1006 |
+
# stored-compute-needs-complete-depends measured 1 of 29 and is graded the
|
| 1007 |
+
# same way for the same reason: critical if it happens -- a constraint that
|
| 1008 |
+
# never fires is the silent-bad-data case -- and measured to be about the
|
| 1009 |
+
# method rather than the line.
|
| 1010 |
+
"odoo.constrains-must-list-every-field-read": "construct",
|
| 1011 |
+
|
| 1012 |
+
# ---- the ones that fire nowhere, graded 2026-09-21 --------------------
|
| 1013 |
+
#
|
| 1014 |
+
# Same standard except-order-follows-the-hierarchy was held to, on more
|
| 1015 |
+
# evidence than it had: zero findings across THREE ecosystems -- OpenSPP,
|
| 1016 |
+
# two OCA trees and odoo core itself, 820,000 lines -- and no incident
|
| 1017 |
+
# behind any of them in 1,164 fix commits. `unproven` is the corpus's word
|
| 1018 |
+
# for a real defect nothing measured here has met, and these are it.
|
| 1019 |
+
#
|
| 1020 |
+
# The third tree is what made this safe to do. Measured on OpenSPP and OCA
|
| 1021 |
+
# alone, eleven rules looked like this. Four of them --
|
| 1022 |
+
# bare-except-catches-the-exit, mutable-default-argument,
|
| 1023 |
+
# eval-and-pickle-execute-their-input and subprocess-shell-true-is-injection
|
| 1024 |
+
# -- fire on odoo core, at 28, 15, 4 and 4. Grading them off two trees
|
| 1025 |
+
# would have downgraded four working rules on the strength of not looking
|
| 1026 |
+
# far enough, which is the mistake this grade exists to avoid making
|
| 1027 |
+
# quietly.
|
| 1028 |
+
#
|
| 1029 |
+
# Advisory means reported, out of the rate, gating nothing. For a rule that
|
| 1030 |
+
# has never fired that changes no output today; it changes what happens the
|
| 1031 |
+
# first time one does, and says plainly that nobody has seen it work yet.
|
| 1032 |
+
"odoo.check-constraint-cannot-cross-tables": "unproven",
|
| 1033 |
+
"odoo.constrains-ignores-dotted-names": "unproven",
|
| 1034 |
+
"odoo.no-monkey-patching": "unproven",
|
| 1035 |
+
"odoo.noupdate-on-user-editable-data": "unproven",
|
| 1036 |
+
"odoo.schema-change-needs-migration": "unproven",
|
| 1037 |
+
|
| 1038 |
+
# These two are silent because they were narrowed in 3.47, not because
|
| 1039 |
+
# nothing was there: as regexes they fired 4 and 3 times across the OCA and
|
| 1040 |
+
# OpenSPP trees and every one of those was classified false. Origin-based,
|
| 1041 |
+
# they find nothing in 820,000 lines. Both readings are in the README; the
|
| 1042 |
+
# grade records the second one without deleting the rules, because the
|
| 1043 |
+
# defects they describe are real and a third of this corpus was written
|
| 1044 |
+
# from constructs nobody had met yet either.
|
| 1045 |
+
"python.decimal-for-money-not-float": "unproven",
|
| 1046 |
+
"python.path-join-does-not-contain-a-path": "unproven",
|
| 1047 |
}
|
| 1048 |
|
| 1049 |
# --------------------------------------------------------------------- PROBES
|
|
@@ -1,12 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"built_utc": "2026-09-
|
| 3 |
"rules": 27,
|
| 4 |
"machine_checkable": 26,
|
| 5 |
-
"by_pattern":
|
| 6 |
-
"by_semantic_check":
|
| 7 |
"checked_against": {
|
| 8 |
"odoo": "18.0",
|
| 9 |
"python": "3.12"
|
| 10 |
},
|
| 11 |
-
"corpus_sha256": "
|
| 12 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"built_utc": "2026-09-22T08:16:07.718107+00:00",
|
| 3 |
"rules": 27,
|
| 4 |
"machine_checkable": 26,
|
| 5 |
+
"by_pattern": 12,
|
| 6 |
+
"by_semantic_check": 14,
|
| 7 |
"checked_against": {
|
| 8 |
"odoo": "18.0",
|
| 9 |
"python": "3.12"
|
| 10 |
},
|
| 11 |
+
"corpus_sha256": "8d3d07faba2e1a3f0192b95f67972f9543a8d22325da110e518adb3da0d9d997"
|
| 12 |
}
|
|
@@ -23,9 +23,8 @@
|
|
| 23 |
"constraints",
|
| 24 |
"bypass"
|
| 25 |
],
|
| 26 |
-
"violation_pattern": "@api\\.onchange[\\s\\S]{0,400}?(?:raise\\s+\\w*(?:Validation|User)Error|['\\\"]warning['\\\"]\\s*:)",
|
| 27 |
-
"satisfied_pattern": "@api\\.constrains|_sql_constraints|models\\.Constraint",
|
| 28 |
"stack": "odoo",
|
|
|
|
| 29 |
"source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/08_compute_onchange.html",
|
| 30 |
"likelihood": "unmeasured",
|
| 31 |
"advisory": false,
|
|
@@ -50,7 +49,7 @@
|
|
| 50 |
],
|
| 51 |
"stack": "odoo",
|
| 52 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 53 |
-
"likelihood": "
|
| 54 |
"advisory": false,
|
| 55 |
"probe": "my constraint does not run when only one of the two dates is written",
|
| 56 |
"semantic_check": "constrains_lists_every_field_read"
|
|
@@ -76,8 +75,8 @@
|
|
| 76 |
"stack": "odoo",
|
| 77 |
"violation_pattern": "@api\\.constrains\\([^)]*['\\\"][a-z_]+\\.[a-z_]+['\\\"]",
|
| 78 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 79 |
-
"likelihood": "
|
| 80 |
-
"advisory":
|
| 81 |
"probe": "constrains on partner_id.country_id never fires and raises no error"
|
| 82 |
},
|
| 83 |
{
|
|
@@ -99,8 +98,7 @@
|
|
| 99 |
"intermittent"
|
| 100 |
],
|
| 101 |
"stack": "odoo",
|
| 102 |
-
"
|
| 103 |
-
"satisfied_pattern": "float_is_zero|float_compare",
|
| 104 |
"source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/10_constraints.html",
|
| 105 |
"likelihood": "unmeasured",
|
| 106 |
"advisory": false,
|
|
@@ -182,8 +180,8 @@
|
|
| 182 |
"violation_pattern": "CHECK\\s*\\([^)]*(?:SELECT|[a-z_]+_id\\.[a-z_]+)",
|
| 183 |
"stack": "odoo",
|
| 184 |
"source": "https://www.postgresql.org/docs/current/ddl-constraints.html",
|
| 185 |
-
"likelihood": "
|
| 186 |
-
"advisory":
|
| 187 |
"probe": "my CHECK constraint will not install because it reads the product stock"
|
| 188 |
},
|
| 189 |
{
|
|
@@ -207,7 +205,7 @@
|
|
| 207 |
"stack": "odoo",
|
| 208 |
"semantic_check": "check_company_missing_on_company_owned_relation",
|
| 209 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 210 |
-
"likelihood": "
|
| 211 |
"advisory": false,
|
| 212 |
"probe": "a user in one company can select another company's journal on a document"
|
| 213 |
},
|
|
@@ -298,8 +296,8 @@
|
|
| 298 |
"data-loss"
|
| 299 |
],
|
| 300 |
"stack": "odoo",
|
| 301 |
-
"likelihood": "
|
| 302 |
-
"advisory":
|
| 303 |
"probe": "I made an existing field required and the module upgrade fails on live data",
|
| 304 |
"no_check_reason": "The violation lives in the diff between two module versions plus the presence of a migrations/ directory. Nothing in a single generated snippet reveals it."
|
| 305 |
},
|
|
@@ -321,7 +319,7 @@
|
|
| 321 |
"record-rules"
|
| 322 |
],
|
| 323 |
"stack": "odoo",
|
| 324 |
-
"semantic_check": "
|
| 325 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html",
|
| 326 |
"likelihood": "defect",
|
| 327 |
"advisory": false,
|
|
@@ -352,8 +350,8 @@
|
|
| 352 |
],
|
| 353 |
"stack": "odoo",
|
| 354 |
"violation_pattern": "from\\s+odoo\\.addons[\\s\\S]{0,300}?\\n[A-Z]\\w+\\.\\w+\\s*=\\s*\\w+",
|
| 355 |
-
"likelihood": "
|
| 356 |
-
"advisory":
|
| 357 |
"probe": "change what a core sale.order method does without editing the sale module"
|
| 358 |
},
|
| 359 |
{
|
|
@@ -376,8 +374,8 @@
|
|
| 376 |
"violation_pattern": "<odoo>\\s*<record",
|
| 377 |
"satisfied_pattern": "noupdate",
|
| 378 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html",
|
| 379 |
-
"likelihood": "
|
| 380 |
-
"advisory":
|
| 381 |
"probe": "the customer's configuration resets itself every time we upgrade the module"
|
| 382 |
},
|
| 383 |
{
|
|
@@ -425,7 +423,7 @@
|
|
| 425 |
],
|
| 426 |
"stack": "odoo",
|
| 427 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 428 |
-
"likelihood": "
|
| 429 |
"advisory": false,
|
| 430 |
"probe": "a raw query breaks when a partner name contains an apostrophe",
|
| 431 |
"semantic_check": "sql_built_by_string_formatting"
|
|
@@ -620,11 +618,10 @@
|
|
| 620 |
"input"
|
| 621 |
],
|
| 622 |
"stack": "odoo",
|
| 623 |
-
"
|
| 624 |
-
"satisfied_pattern": "commonpath|commonprefix|relpath|is_relative_to",
|
| 625 |
"source": "https://docs.python.org/3/library/os.path.html#os.path.join",
|
| 626 |
-
"likelihood": "
|
| 627 |
-
"advisory":
|
| 628 |
"probe": "open a file the user named inside my uploads directory"
|
| 629 |
},
|
| 630 |
{
|
|
@@ -646,11 +643,10 @@
|
|
| 646 |
"decimal"
|
| 647 |
],
|
| 648 |
"stack": "odoo",
|
| 649 |
-
"
|
| 650 |
-
"satisfied_pattern": "Decimal",
|
| 651 |
"source": "https://docs.python.org/3/library/decimal.html",
|
| 652 |
-
"likelihood": "
|
| 653 |
-
"advisory":
|
| 654 |
"probe": "totals are off by a fraction of a penny after adding prices up"
|
| 655 |
},
|
| 656 |
{
|
|
@@ -671,7 +667,7 @@
|
|
| 671 |
"stack": "odoo",
|
| 672 |
"violation_pattern": "datetime\\.now\\(\\s*\\)|utcnow\\(\\s*\\)",
|
| 673 |
"source": "https://docs.python.org/3/library/datetime.html",
|
| 674 |
-
"likelihood": "
|
| 675 |
"advisory": false,
|
| 676 |
"probe": "TypeError comparing offset-naive and offset-aware datetimes"
|
| 677 |
}
|
|
|
|
| 23 |
"constraints",
|
| 24 |
"bypass"
|
| 25 |
],
|
|
|
|
|
|
|
| 26 |
"stack": "odoo",
|
| 27 |
+
"semantic_check": "onchange_tries_to_enforce",
|
| 28 |
"source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/08_compute_onchange.html",
|
| 29 |
"likelihood": "unmeasured",
|
| 30 |
"advisory": false,
|
|
|
|
| 49 |
],
|
| 50 |
"stack": "odoo",
|
| 51 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 52 |
+
"likelihood": "construct",
|
| 53 |
"advisory": false,
|
| 54 |
"probe": "my constraint does not run when only one of the two dates is written",
|
| 55 |
"semantic_check": "constrains_lists_every_field_read"
|
|
|
|
| 75 |
"stack": "odoo",
|
| 76 |
"violation_pattern": "@api\\.constrains\\([^)]*['\\\"][a-z_]+\\.[a-z_]+['\\\"]",
|
| 77 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 78 |
+
"likelihood": "unproven",
|
| 79 |
+
"advisory": true,
|
| 80 |
"probe": "constrains on partner_id.country_id never fires and raises no error"
|
| 81 |
},
|
| 82 |
{
|
|
|
|
| 98 |
"intermittent"
|
| 99 |
],
|
| 100 |
"stack": "odoo",
|
| 101 |
+
"semantic_check": "float_equality_against_zero",
|
|
|
|
| 102 |
"source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/10_constraints.html",
|
| 103 |
"likelihood": "unmeasured",
|
| 104 |
"advisory": false,
|
|
|
|
| 180 |
"violation_pattern": "CHECK\\s*\\([^)]*(?:SELECT|[a-z_]+_id\\.[a-z_]+)",
|
| 181 |
"stack": "odoo",
|
| 182 |
"source": "https://www.postgresql.org/docs/current/ddl-constraints.html",
|
| 183 |
+
"likelihood": "unproven",
|
| 184 |
+
"advisory": true,
|
| 185 |
"probe": "my CHECK constraint will not install because it reads the product stock"
|
| 186 |
},
|
| 187 |
{
|
|
|
|
| 205 |
"stack": "odoo",
|
| 206 |
"semantic_check": "check_company_missing_on_company_owned_relation",
|
| 207 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 208 |
+
"likelihood": "construct",
|
| 209 |
"advisory": false,
|
| 210 |
"probe": "a user in one company can select another company's journal on a document"
|
| 211 |
},
|
|
|
|
| 296 |
"data-loss"
|
| 297 |
],
|
| 298 |
"stack": "odoo",
|
| 299 |
+
"likelihood": "unproven",
|
| 300 |
+
"advisory": true,
|
| 301 |
"probe": "I made an existing field required and the module upgrade fails on live data",
|
| 302 |
"no_check_reason": "The violation lives in the diff between two module versions plus the presence of a migrations/ directory. Nothing in a single generated snippet reveals it."
|
| 303 |
},
|
|
|
|
| 319 |
"record-rules"
|
| 320 |
],
|
| 321 |
"stack": "odoo",
|
| 322 |
+
"semantic_check": "sudo_drops_record_rules",
|
| 323 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html",
|
| 324 |
"likelihood": "defect",
|
| 325 |
"advisory": false,
|
|
|
|
| 350 |
],
|
| 351 |
"stack": "odoo",
|
| 352 |
"violation_pattern": "from\\s+odoo\\.addons[\\s\\S]{0,300}?\\n[A-Z]\\w+\\.\\w+\\s*=\\s*\\w+",
|
| 353 |
+
"likelihood": "unproven",
|
| 354 |
+
"advisory": true,
|
| 355 |
"probe": "change what a core sale.order method does without editing the sale module"
|
| 356 |
},
|
| 357 |
{
|
|
|
|
| 374 |
"violation_pattern": "<odoo>\\s*<record",
|
| 375 |
"satisfied_pattern": "noupdate",
|
| 376 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html",
|
| 377 |
+
"likelihood": "unproven",
|
| 378 |
+
"advisory": true,
|
| 379 |
"probe": "the customer's configuration resets itself every time we upgrade the module"
|
| 380 |
},
|
| 381 |
{
|
|
|
|
| 423 |
],
|
| 424 |
"stack": "odoo",
|
| 425 |
"source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
|
| 426 |
+
"likelihood": "defect",
|
| 427 |
"advisory": false,
|
| 428 |
"probe": "a raw query breaks when a partner name contains an apostrophe",
|
| 429 |
"semantic_check": "sql_built_by_string_formatting"
|
|
|
|
| 618 |
"input"
|
| 619 |
],
|
| 620 |
"stack": "odoo",
|
| 621 |
+
"semantic_check": "path_join_takes_an_external_name",
|
|
|
|
| 622 |
"source": "https://docs.python.org/3/library/os.path.html#os.path.join",
|
| 623 |
+
"likelihood": "unproven",
|
| 624 |
+
"advisory": true,
|
| 625 |
"probe": "open a file the user named inside my uploads directory"
|
| 626 |
},
|
| 627 |
{
|
|
|
|
| 643 |
"decimal"
|
| 644 |
],
|
| 645 |
"stack": "odoo",
|
| 646 |
+
"semantic_check": "float_coercion_of_external_money",
|
|
|
|
| 647 |
"source": "https://docs.python.org/3/library/decimal.html",
|
| 648 |
+
"likelihood": "unproven",
|
| 649 |
+
"advisory": true,
|
| 650 |
"probe": "totals are off by a fraction of a penny after adding prices up"
|
| 651 |
},
|
| 652 |
{
|
|
|
|
| 667 |
"stack": "odoo",
|
| 668 |
"violation_pattern": "datetime\\.now\\(\\s*\\)|utcnow\\(\\s*\\)",
|
| 669 |
"source": "https://docs.python.org/3/library/datetime.html",
|
| 670 |
+
"likelihood": "defect",
|
| 671 |
"advisory": false,
|
| 672 |
"probe": "TypeError comparing offset-naive and offset-aware datetimes"
|
| 673 |
}
|
|
@@ -379,3 +379,94 @@ def test_version_bounds_narrow_each_other_and_alternatives_do_not():
|
|
| 379 |
assert not layers._version_applies(">=16.0", "15.0")
|
| 380 |
# A series nothing can parse still applies the rule, rather than dropping it.
|
| 381 |
assert layers._version_applies(">=16.0", "saas~16.3")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
assert not layers._version_applies(">=16.0", "15.0")
|
| 380 |
# A series nothing can parse still applies the rule, rather than dropping it.
|
| 381 |
assert layers._version_applies(">=16.0", "saas~16.3")
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ------------------------------------------- what a marker is about
|
| 385 |
+
|
| 386 |
+
def test_a_marker_naming_another_rule_does_not_silence_this_one():
|
| 387 |
+
"""Classifying 50 findings by hand turned up one suppression that should
|
| 388 |
+
not have fired: a consent-receipt timestamp inside a return statement
|
| 389 |
+
carrying `# nosemgrep: odoo-sudo-without-context` four lines below it. A
|
| 390 |
+
marker about sudo was silencing a finding about datetimes, which is the
|
| 391 |
+
mute button this project says it will not build, reached from the other
|
| 392 |
+
direction."""
|
| 393 |
+
import corpus
|
| 394 |
+
rules = [r for r in corpus.load_rules()
|
| 395 |
+
if r["id"] == "python.naive-and-aware-datetimes-do-not-compare"]
|
| 396 |
+
body = (
|
| 397 |
+
"def receipt(self):\n"
|
| 398 |
+
" return self._build(\n"
|
| 399 |
+
" issued=datetime.now(),\n"
|
| 400 |
+
" who=self.env.user.id,\n"
|
| 401 |
+
" ) # nosemgrep: odoo-sudo-without-context\n"
|
| 402 |
+
)
|
| 403 |
+
hits = layers.scan_source(body, rules, "odoo", errors=[])
|
| 404 |
+
assert len(hits) == 1
|
| 405 |
+
assert not hits[0].suppressed, "a marker about sudo does not speak for datetimes"
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def test_a_marker_naming_this_rule_still_silences_it():
|
| 409 |
+
"""The other side of the same line, and the measured one: 105 of OpenSPP's
|
| 410 |
+
106 `.sudo().search(` findings carry a nosemgrep marker about that exact
|
| 411 |
+
construct. `odoo-sudo-without-context` and `odoo.sudo-bypasses-record-rules`
|
| 412 |
+
meet on `sudo` -- and not on `odoo`, which names a vendor rather than a
|
| 413 |
+
defect."""
|
| 414 |
+
import corpus
|
| 415 |
+
rules = [r for r in corpus.load_rules()
|
| 416 |
+
if r["id"] == "odoo.sudo-bypasses-record-rules"]
|
| 417 |
+
body = (
|
| 418 |
+
"def f(self):\n"
|
| 419 |
+
" return self.env['res.partner'].sudo().search(\n"
|
| 420 |
+
" [('active', '=', True)],\n"
|
| 421 |
+
" ) # nosemgrep: odoo-sudo-without-context\n"
|
| 422 |
+
)
|
| 423 |
+
hits = layers.scan_source(body, rules, "odoo", errors=[])
|
| 424 |
+
assert len(hits) == 1
|
| 425 |
+
assert hits[0].suppressed
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def test_an_opaque_code_covers_the_line_as_it_always_did():
|
| 429 |
+
"""`B608` carries no words, so it cannot be compared with a rule id at
|
| 430 |
+
all. Narrowing on a comparison that cannot be made would drop a decision
|
| 431 |
+
somebody really did record -- two of the six sql-needs-query-parameters
|
| 432 |
+
findings on OpenSPP sit on lines carrying nothing but `# nosec B608`. The
|
| 433 |
+
gap this leaves is named in _marker_is_about."""
|
| 434 |
+
carries, codes = layers._marker_scope(" x = 1 # nosec B608")
|
| 435 |
+
assert carries and codes == set()
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def test_prose_after_a_marker_is_not_read_as_a_rule_name():
|
| 439 |
+
"""People write sentences there. `# noqa: decided, TICKET-9` is a person
|
| 440 |
+
talking, not an analyser code, and reading `decided` as a rule id would
|
| 441 |
+
silently unsuppress a line somebody had settled."""
|
| 442 |
+
for text in (" x = 1 # noqa: decided, TICKET-9",
|
| 443 |
+
" x = 1 # noqa: this guard is advisory, see TICKET-41"):
|
| 444 |
+
carries, codes = layers._marker_scope(text)
|
| 445 |
+
assert carries and codes == set(), text
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def test_a_statement_that_covers_the_line_beats_one_that_borrows_it():
|
| 449 |
+
"""Every span reaches one line above its statement to take in a marker
|
| 450 |
+
written there, so two statements on consecutive lines overlap. A finding
|
| 451 |
+
on the first line of one is also inside the borrowed line of the one
|
| 452 |
+
below, both spans are one line wide, and the tie used to go to whichever
|
| 453 |
+
ast.walk reached first.
|
| 454 |
+
|
| 455 |
+
Found on OpenSPP the first time the write clause of the sudo rule fired:
|
| 456 |
+
spp_api_v2/wizards/show_secret_wizard.py carries its nosemgrep on line 36
|
| 457 |
+
about the write on 37, and the `return` on 38 borrows 37 for itself. The
|
| 458 |
+
marker read as absent and a decision somebody had recorded was re-raised.
|
| 459 |
+
"""
|
| 460 |
+
import corpus
|
| 461 |
+
rule = [r for r in corpus.load_rules()
|
| 462 |
+
if r["id"] == "odoo.sudo-bypasses-record-rules"][0]
|
| 463 |
+
body = (
|
| 464 |
+
"def action_close(self):\n"
|
| 465 |
+
" if self.client_id:\n"
|
| 466 |
+
" # nosemgrep: odoo-sudo-without-context -- wizard ACL controls access\n"
|
| 467 |
+
" self.client_id.sudo().write({'client_secret': False})\n"
|
| 468 |
+
" return {'type': 'ir.actions.act_window_close'}\n"
|
| 469 |
+
)
|
| 470 |
+
hits = layers.scan_source(body, [rule], "odoo", errors=[])
|
| 471 |
+
assert len(hits) == 1 and hits[0].line == 4
|
| 472 |
+
assert hits[0].suppressed, "the marker one line above its own statement was lost"
|
|
@@ -941,15 +941,424 @@ def test_the_corpus_does_not_advise_a_declaration_odoo_19_ignores():
|
|
| 941 |
|
| 942 |
|
| 943 |
def test_the_onchange_guard_knows_every_spelling_of_a_constraint():
|
| 944 |
-
"""
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
next field test reported OCA's account_loan, branch 18.0, with a real
|
| 948 |
_sql_constraints block and an onchange properly backed by it.
|
| 949 |
|
| 950 |
The 19 case is closed where the series is legible instead, in
|
| 951 |
-
prefer_sql_constraint_applies.
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 941 |
|
| 942 |
|
| 943 |
def test_the_onchange_guard_knows_every_spelling_of_a_constraint():
|
| 944 |
+
"""The guard has to accept the legacy attribute: that IS a constraint on 16
|
| 945 |
+
through 18. Narrowing it to models.Constraint alone was tried and reverted --
|
| 946 |
+
the next field test reported OCA's account_loan, branch 18.0, with a real
|
|
|
|
| 947 |
_sql_constraints block and an onchange properly backed by it.
|
| 948 |
|
| 949 |
The 19 case is closed where the series is legible instead, in
|
| 950 |
+
prefer_sql_constraint_applies.
|
| 951 |
+
|
| 952 |
+
Asserted against the checker rather than against a satisfied_pattern,
|
| 953 |
+
which is where the guard moved when the rule became an AST pass. The
|
| 954 |
+
three spellings are the same three."""
|
| 955 |
+
body = ('class M(models.Model):\n'
|
| 956 |
+
' @api.onchange("qty")\n'
|
| 957 |
+
' def _onchange_qty(self):\n'
|
| 958 |
+
' if self.qty <= 0:\n'
|
| 959 |
+
' raise ValidationError("no")\n')
|
| 960 |
+
assert semantics.run_check("onchange_tries_to_enforce", body), \
|
| 961 |
+
"an unbacked onchange that raises is the violation"
|
| 962 |
+
|
| 963 |
+
for spelling in (' @api.constrains("qty")\n def _check(self):\n pass\n',
|
| 964 |
+
' _sql_constraints = [("q", "CHECK(qty > 0)", "no")]\n',
|
| 965 |
+
' _qty = models.Constraint("CHECK(qty > 0)", "no")\n'):
|
| 966 |
+
backed = body + spelling
|
| 967 |
+
assert not semantics.run_check("onchange_tries_to_enforce", backed), spelling
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
# ------------------------------------------------ the value-origin trace
|
| 971 |
+
#
|
| 972 |
+
# Four rules were classified false for one reason: each read a variable's NAME
|
| 973 |
+
# and treated that as evidence about its value. Every case below is a finding
|
| 974 |
+
# somebody read by hand and recorded a verdict for, in
|
| 975 |
+
# classification-{oca,openspp,python}-2026-09.json.
|
| 976 |
+
|
| 977 |
+
INT_ZERO_CASES = [
|
| 978 |
+
("search_count", """
|
| 979 |
+
def f(self):
|
| 980 |
+
total_population = self.env["res.partner"].search_count([])
|
| 981 |
+
if total_population == 0:
|
| 982 |
+
return 0
|
| 983 |
+
"""),
|
| 984 |
+
("read_group _count, keyed by an f-string", """
|
| 985 |
+
def f(self, field_name, rows):
|
| 986 |
+
for row in rows:
|
| 987 |
+
group_total = row[f"{field_name}_count"]
|
| 988 |
+
if group_total == 0:
|
| 989 |
+
continue
|
| 990 |
+
"""),
|
| 991 |
+
("SELECT COUNT(*) through a cursor", """
|
| 992 |
+
def f(cr, table, where):
|
| 993 |
+
cr.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}")
|
| 994 |
+
total = cr.fetchone()[0]
|
| 995 |
+
if total == 0:
|
| 996 |
+
return []
|
| 997 |
+
"""),
|
| 998 |
+
("a field the same file declares as fields.Integer", """
|
| 999 |
+
class L(models.Model):
|
| 1000 |
+
qty = fields.Integer(string="Quantity")
|
| 1001 |
+
|
| 1002 |
+
def f(self):
|
| 1003 |
+
self.line_ids.filtered(lambda x: x.qty == 0).unlink()
|
| 1004 |
+
"""),
|
| 1005 |
+
]
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
@pytest.mark.parametrize("label,body", INT_ZERO_CASES,
|
| 1009 |
+
ids=[c[0] for c in INT_ZERO_CASES])
|
| 1010 |
+
def test_an_integer_compared_to_zero_is_not_a_float_comparison(label, body):
|
| 1011 |
+
"""All five false positives on this rule compared an INTEGER to zero, where
|
| 1012 |
+
== is exact and the rule has nothing to say. Three of them and both true
|
| 1013 |
+
positives were spelled `total == 0`, so the name could never have separated
|
| 1014 |
+
them."""
|
| 1015 |
+
assert semantics.run_check("float_equality_against_zero", body) == [], label
|
| 1016 |
+
|
| 1017 |
+
|
| 1018 |
+
def test_a_float_compared_to_zero_still_fires():
|
| 1019 |
+
"""The guard suppresses; it must not suppress everything. `sum()` over
|
| 1020 |
+
amounts is the true positive that shares its spelling with three of the
|
| 1021 |
+
false ones."""
|
| 1022 |
+
body = """
|
| 1023 |
+
def f(sorted_amounts):
|
| 1024 |
+
total = sum(sorted_amounts)
|
| 1025 |
+
if total == 0:
|
| 1026 |
+
return 0.0
|
| 1027 |
+
"""
|
| 1028 |
+
assert semantics.run_check("float_equality_against_zero", body)
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
def test_a_locally_built_filename_is_not_a_traversal():
|
| 1032 |
+
"""0 true, 4 false. All four join a configured directory to a name the
|
| 1033 |
+
module itself produced, and the pattern matched the word `filename`."""
|
| 1034 |
+
body = """
|
| 1035 |
+
def f(self, data_path):
|
| 1036 |
+
for file_config in [{"filename": "a.csv"}]:
|
| 1037 |
+
file_path = os.path.join(data_path, file_config["filename"])
|
| 1038 |
+
"""
|
| 1039 |
+
assert semantics.run_check("path_join_takes_an_external_name", body) == []
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def test_a_component_from_outside_the_file_still_fires():
|
| 1043 |
+
body = "full = os.path.join(UPLOAD_DIR, request.args['name'])\n"
|
| 1044 |
+
assert semantics.run_check("path_join_takes_an_external_name", body)
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
def test_resolving_the_path_clears_the_join_in_that_function_only():
|
| 1048 |
+
"""The fix is to resolve and compare, and it is written beside the join. A
|
| 1049 |
+
realpath in a DIFFERENT function says nothing about this one."""
|
| 1050 |
+
fixed = """
|
| 1051 |
+
def f(base):
|
| 1052 |
+
full = os.path.realpath(os.path.join(base, request.args['name']))
|
| 1053 |
+
if os.path.commonpath([base, full]) != base:
|
| 1054 |
+
raise ValueError("escapes")
|
| 1055 |
+
"""
|
| 1056 |
+
assert semantics.run_check("path_join_takes_an_external_name", fixed) == []
|
| 1057 |
+
elsewhere = fixed + """
|
| 1058 |
+
|
| 1059 |
+
def g(base):
|
| 1060 |
+
return open(os.path.join(base, request.args['other']), 'rb')
|
| 1061 |
+
"""
|
| 1062 |
+
assert semantics.run_check("path_join_takes_an_external_name", elsewhere)
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
def test_the_orm_monetary_idiom_is_not_a_decimal_violation():
|
| 1066 |
+
"""Odoo's Monetary field IS a float -- the ORM defines it that way -- so
|
| 1067 |
+
inside a module the rule fired on the idiom and named a remedy that cannot
|
| 1068 |
+
be taken. Both shapes below were classified false."""
|
| 1069 |
+
for body in ("""
|
| 1070 |
+
class E(models.Model):
|
| 1071 |
+
def f(self):
|
| 1072 |
+
ctx = {"base_amount": float(self.amount or 0.0)}
|
| 1073 |
+
""", """
|
| 1074 |
+
def f(self):
|
| 1075 |
+
result = self.env["spp.cel.service"].evaluate_expression(self.e, {})
|
| 1076 |
+
amount = float(result)
|
| 1077 |
+
"""):
|
| 1078 |
+
assert semantics.run_check("float_coercion_of_external_money", body) == [], body
|
| 1079 |
+
|
| 1080 |
+
|
| 1081 |
+
def test_a_money_string_from_outside_still_fires():
|
| 1082 |
+
"""Where the value arrives as a string, Decimal(value) IS available and the
|
| 1083 |
+
rule's remedy can be taken. This is the rule's own wrong sample."""
|
| 1084 |
+
body = "price = float(payload['price'])\n"
|
| 1085 |
+
assert semantics.run_check("float_coercion_of_external_money", body)
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
def test_an_onchange_does_not_answer_for_the_method_below_it():
|
| 1089 |
+
"""All three false positives on this rule were the same failure: a short
|
| 1090 |
+
onchange that assigns a field, followed by an action method that raises,
|
| 1091 |
+
with the regex's 400-character window crossing between them.
|
| 1092 |
+
spp_studio/wizard/variable_remap_wizard.py is the shape, verbatim."""
|
| 1093 |
+
body = """
|
| 1094 |
+
class W(models.TransientModel):
|
| 1095 |
+
@api.onchange("variable_id")
|
| 1096 |
+
def _onchange_variable_id(self):
|
| 1097 |
+
if self.variable_id and self.variable_id.source_model:
|
| 1098 |
+
self.target_model = self.variable_id.source_model
|
| 1099 |
+
|
| 1100 |
+
def action_remap(self):
|
| 1101 |
+
self.ensure_one()
|
| 1102 |
+
if not self.new_field_id:
|
| 1103 |
+
raise UserError(_("Please select a field to map to."))
|
| 1104 |
+
"""
|
| 1105 |
+
assert semantics.run_check("onchange_tries_to_enforce", body) == []
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
def test_the_onchange_finding_anchors_on_the_decorator():
|
| 1109 |
+
"""Where the rule is, and where a marker about it gets written. A
|
| 1110 |
+
FunctionDef's own lineno is the `def`, one line further down."""
|
| 1111 |
+
body = ('class M(models.Model):\n'
|
| 1112 |
+
' @api.onchange("qty")\n'
|
| 1113 |
+
' def _onchange_qty(self):\n'
|
| 1114 |
+
' raise ValidationError("no")\n')
|
| 1115 |
+
found = semantics.run_check("onchange_tries_to_enforce", body)
|
| 1116 |
+
assert [line for line, _, _ in found] == [2]
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
# ------------------------------------------------- sudo, all three clauses
|
| 1120 |
+
|
| 1121 |
+
@pytest.mark.parametrize("clause,body", [
|
| 1122 |
+
("read", "def f(self):\n"
|
| 1123 |
+
" return self.env['res.partner'].sudo().search([])\n"),
|
| 1124 |
+
("create", "def f(self, vals):\n"
|
| 1125 |
+
" return self.env['res.partner'].sudo().create(vals)\n"),
|
| 1126 |
+
("write", "def f(self):\n"
|
| 1127 |
+
" self.env['res.partner'].sudo().write({'name': 'x'})\n"),
|
| 1128 |
+
("unlink", "def f(self):\n"
|
| 1129 |
+
" self.env['res.partner'].sudo().unlink()\n"),
|
| 1130 |
+
("handed to a call", "def f(self):\n"
|
| 1131 |
+
" p = self.env['res.partner'].sudo()\n"
|
| 1132 |
+
" return self._handle(p)\n"),
|
| 1133 |
+
("returned", "def f(self):\n"
|
| 1134 |
+
" p = self.env['res.partner'].sudo()\n"
|
| 1135 |
+
" return p\n"),
|
| 1136 |
+
])
|
| 1137 |
+
def test_sudo_covers_the_three_clauses_its_rule_states(clause, body):
|
| 1138 |
+
"""The rule says three things -- scope it narrowly, re-apply company
|
| 1139 |
+
filtering, never pass a sudo recordset onward -- and only the read was
|
| 1140 |
+
checked. `.sudo(` is the largest cluster in the recall harvest at 70
|
| 1141 |
+
missed against 6 caught, and on the OpenSPP tree the unchecked write
|
| 1142 |
+
clause has 136 call sites against the read clause's 116."""
|
| 1143 |
+
assert semantics.run_check("sudo_drops_record_rules", body), clause
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
@pytest.mark.parametrize("why,body", [
|
| 1147 |
+
("the call re-applies company scoping by hand",
|
| 1148 |
+
"def f(self):\n"
|
| 1149 |
+
" return self.env['res.partner'].sudo().search("
|
| 1150 |
+
"[('company_id', 'in', self.env.company.ids)])\n"),
|
| 1151 |
+
("the recordset never leaves the function",
|
| 1152 |
+
"def f(self):\n"
|
| 1153 |
+
" p = self.env['res.partner'].sudo()\n"
|
| 1154 |
+
" return p.mapped('name')\n"),
|
| 1155 |
+
])
|
| 1156 |
+
def test_sudo_stays_quiet_where_the_rule_is_already_followed(why, body):
|
| 1157 |
+
assert semantics.run_check("sudo_drops_record_rules", body) == [], why
|
| 1158 |
+
|
| 1159 |
+
|
| 1160 |
+
# ------------------------------------------------------------- docstrings
|
| 1161 |
+
|
| 1162 |
+
def test_a_docstring_is_prose_and_is_not_matched_as_code():
|
| 1163 |
+
"""Two of the 50 findings the Python composition added were this: `eval()`
|
| 1164 |
+
named in a docstring in a sentence arguing against using it, and a
|
| 1165 |
+
`datetime.now()` inside a doctest example."""
|
| 1166 |
+
src = ('def f():\n'
|
| 1167 |
+
' """Never use eval() here.\n'
|
| 1168 |
+
'\n'
|
| 1169 |
+
' >>> datetime.now()\n'
|
| 1170 |
+
' """\n'
|
| 1171 |
+
' return 1\n')
|
| 1172 |
+
view = semantics.strip_comments(src)
|
| 1173 |
+
assert "eval(" not in view
|
| 1174 |
+
assert "datetime.now()" not in view
|
| 1175 |
+
|
| 1176 |
+
|
| 1177 |
+
def test_a_real_string_literal_survives_and_the_geometry_does_too():
|
| 1178 |
+
"""Only docstrings. A triple-quoted SQL query is data the checkers have a
|
| 1179 |
+
legitimate interest in, and a line or column that moved would misreport
|
| 1180 |
+
every finding below it."""
|
| 1181 |
+
src = 'Q = """SELECT COUNT(*) FROM t"""\n\ndef f():\n """doc"""\n return Q\n'
|
| 1182 |
+
view = semantics.strip_comments(src)
|
| 1183 |
+
assert "SELECT COUNT(*)" in view
|
| 1184 |
+
assert "doc" not in view
|
| 1185 |
+
assert len(view) == len(src)
|
| 1186 |
+
assert view.count("\n") == src.count("\n")
|
| 1187 |
+
|
| 1188 |
+
|
| 1189 |
+
def test_rebinding_self_to_sudo_elevates_the_rest_of_the_method():
|
| 1190 |
+
"""The shape behind the one miss the README names by hand.
|
| 1191 |
+
|
| 1192 |
+
OCA server-tools cbefaa511485, "[FIX] excel_import_export: remove sudo()
|
| 1193 |
+
when importing record", deletes a single line -- `self = self.sudo()` --
|
| 1194 |
+
and says what it cost: "finding multiple records across all companies
|
| 1195 |
+
during import when they have same record name. Previously, importing with
|
| 1196 |
+
sudo could bypass multi-company record rules."
|
| 1197 |
+
|
| 1198 |
+
Nothing below that line names sudo, so every clause that looks for
|
| 1199 |
+
`.sudo().<method>()` is blind to it, and the recordset is never passed
|
| 1200 |
+
anywhere either. It replaces the thing the whole method is written
|
| 1201 |
+
against, which is the escape clause at its widest.
|
| 1202 |
+
|
| 1203 |
+
Run against that commit's own before and after images, the finding sits on
|
| 1204 |
+
line 283, is gone afterwards, and 283 is one of the two lines the fix
|
| 1205 |
+
changed -- an on-target catch by recall's own definition.
|
| 1206 |
+
"""
|
| 1207 |
+
before = ("class X(models.AbstractModel):\n"
|
| 1208 |
+
" def import_xlsx(self, res_id, template):\n"
|
| 1209 |
+
" self = self.sudo()\n"
|
| 1210 |
+
" record = self.env[template.res_model].browse(res_id)\n"
|
| 1211 |
+
" return record\n")
|
| 1212 |
+
after = before.replace(" self = self.sudo()\n", "")
|
| 1213 |
+
found = semantics.run_check("sudo_drops_record_rules", before)
|
| 1214 |
+
assert [line for line, _, _ in found] == [3]
|
| 1215 |
+
assert not semantics.run_check("sudo_drops_record_rules", after)
|
| 1216 |
+
|
| 1217 |
+
|
| 1218 |
+
def test_a_rebind_keeps_the_rules_own_grade():
|
| 1219 |
+
"""The touch and escape clauses report `unproven` because nothing measured
|
| 1220 |
+
has met a real one. This clause has: a fix commit that deletes the line and
|
| 1221 |
+
explains the multi-company leak it caused. So it carries no grade of its
|
| 1222 |
+
own and keeps the rule's `defect`."""
|
| 1223 |
+
body = ("def f(self):\n"
|
| 1224 |
+
" self = self.sudo()\n"
|
| 1225 |
+
" return self.env['res.partner'].search([])\n")
|
| 1226 |
+
grades = {grade for _, _, grade in
|
| 1227 |
+
semantics.run_check("sudo_drops_record_rules", body)}
|
| 1228 |
+
assert None in grades, "a rebind must not be downgraded to unproven"
|
| 1229 |
+
|
| 1230 |
+
|
| 1231 |
+
def test_a_sudo_recordset_bound_to_a_fresh_name_is_not_a_rebind():
|
| 1232 |
+
"""`p = self.env['x'].sudo()` binds a new name and elevates nothing that
|
| 1233 |
+
was not already written against it. Only rebinding a name to its OWN sudo
|
| 1234 |
+
form silently elevates the code below."""
|
| 1235 |
+
body = ("def f(self):\n"
|
| 1236 |
+
" p = self.env['res.partner'].sudo()\n"
|
| 1237 |
+
" return p.mapped('name')\n")
|
| 1238 |
+
assert semantics.run_check("sudo_drops_record_rules", body) == []
|
| 1239 |
+
|
| 1240 |
+
|
| 1241 |
+
def test_sudo_false_is_de_escalation_and_is_never_a_finding():
|
| 1242 |
+
"""`sudo(False)` turns record rules back ON.
|
| 1243 |
+
|
| 1244 |
+
It is how a method that must run under the caller's own rights says so.
|
| 1245 |
+
Odoo core does it 60 times, including mail's `_check_attachments_access`,
|
| 1246 |
+
whose docstring reads "This method relies on access rules/rights and
|
| 1247 |
+
therefore it should not be called from a sudo env." Reading it as an
|
| 1248 |
+
escalation reports the safeguard as the defect.
|
| 1249 |
+
|
| 1250 |
+
Neither OpenSPP nor the OCA trees contain a single `sudo(False)`, so this
|
| 1251 |
+
was invisible until the corpus was pointed at odoo core.
|
| 1252 |
+
"""
|
| 1253 |
+
for body in ("class A(models.Model):\n"
|
| 1254 |
+
" def f(self, toks):\n"
|
| 1255 |
+
" self = self.sudo(False)\n"
|
| 1256 |
+
" return self.env['x'].search([])\n",
|
| 1257 |
+
"def f(self):\n"
|
| 1258 |
+
" return self.env['res.partner'].sudo(False).search([])\n",
|
| 1259 |
+
"def f(self, vals):\n"
|
| 1260 |
+
" return self.env['res.partner'].sudo(False).create(vals)\n"):
|
| 1261 |
+
assert semantics.run_check("sudo_drops_record_rules", body) == [], body
|
| 1262 |
+
|
| 1263 |
+
|
| 1264 |
+
def test_a_computed_sudo_flag_still_counts_as_escalation():
|
| 1265 |
+
"""`sudo(flag)` can be either, and an unreadable argument must leave the
|
| 1266 |
+
finding standing rather than suppress it -- the same direction every other
|
| 1267 |
+
trigger on this rule errs in."""
|
| 1268 |
+
body = ("def f(self, flag):\n"
|
| 1269 |
+
" self = self.sudo(flag)\n"
|
| 1270 |
+
" return self.env['x'].search([])\n")
|
| 1271 |
+
assert semantics.run_check("sudo_drops_record_rules", body)
|
| 1272 |
+
|
| 1273 |
+
|
| 1274 |
+
def test_an_unstored_framework_field_is_not_a_stored_compute():
|
| 1275 |
+
"""odoo/models.py adds display_name to every model as
|
| 1276 |
+
`fields.Char(automatic=True, compute='_compute_display_name', search=...)`
|
| 1277 |
+
with no store. An override that omits a dependency therefore cannot leave
|
| 1278 |
+
a stale row, because there is no row.
|
| 1279 |
+
|
| 1280 |
+
The checker read "no declaration in this file" as unknown storedness and
|
| 1281 |
+
kept the finding, which is right for an ordinary field declared in another
|
| 1282 |
+
module and wrong here: the framework's declaration is knowable and says
|
| 1283 |
+
not stored. 121 of the 1,051 findings this rule made on odoo core were
|
| 1284 |
+
this shape.
|
| 1285 |
+
"""
|
| 1286 |
+
body = ("class M(models.Model):\n"
|
| 1287 |
+
" _inherit = 'res.partner'\n"
|
| 1288 |
+
"\n"
|
| 1289 |
+
" @api.depends('phone')\n"
|
| 1290 |
+
" def _compute_display_name(self):\n"
|
| 1291 |
+
" for rec in self:\n"
|
| 1292 |
+
" rec.display_name = rec.name + rec.phone\n")
|
| 1293 |
+
assert semantics.run_check("depends_covers_compute_reads", body) == []
|
| 1294 |
+
|
| 1295 |
+
|
| 1296 |
+
def test_a_model_that_stores_display_name_itself_is_still_checked():
|
| 1297 |
+
"""The narrowing is about the framework's default, not about the name. A
|
| 1298 |
+
model that declares `display_name = fields.Char(..., store=True)` in its
|
| 1299 |
+
own file has opted into a stored row, and all 10 hand-verified true
|
| 1300 |
+
positives that compute display_name -- every one of them in OpenSPP -- do
|
| 1301 |
+
exactly that. Losing those is what this test exists to prevent.
|
| 1302 |
+
"""
|
| 1303 |
+
body = ("class M(models.Model):\n"
|
| 1304 |
+
" _name = 'a.b'\n"
|
| 1305 |
+
"\n"
|
| 1306 |
+
" display_name = fields.Char(compute='_compute_display_name', store=True)\n"
|
| 1307 |
+
"\n"
|
| 1308 |
+
" @api.depends('phone')\n"
|
| 1309 |
+
" def _compute_display_name(self):\n"
|
| 1310 |
+
" for rec in self:\n"
|
| 1311 |
+
" rec.display_name = rec.name + rec.phone\n")
|
| 1312 |
+
found = semantics.run_check("depends_covers_compute_reads", body)
|
| 1313 |
+
assert any("'name'" in message for _, message, _ in found), found
|
| 1314 |
+
|
| 1315 |
+
|
| 1316 |
+
def test_a_sql_view_model_has_no_foreign_key_to_decide_about():
|
| 1317 |
+
"""`_auto = False` means odoo builds no table for the model, so there is
|
| 1318 |
+
no foreign key, and `ondelete` is not a decision anybody can make about
|
| 1319 |
+
it. Two of the 15 sampled ondelete findings on odoo core were report
|
| 1320 |
+
models of exactly this kind: hr.contract.history and
|
| 1321 |
+
purchase.bill.line.match.
|
| 1322 |
+
|
| 1323 |
+
None of the 26 hand-verified true positives for this rule sits on an
|
| 1324 |
+
_auto = False model, which is what makes the exemption free.
|
| 1325 |
+
"""
|
| 1326 |
+
view = ("class R(models.Model):\n"
|
| 1327 |
+
" _name = 'a.report'\n"
|
| 1328 |
+
" _auto = False\n"
|
| 1329 |
+
"\n"
|
| 1330 |
+
" employee_id = fields.Many2one('hr.employee', readonly=True)\n"
|
| 1331 |
+
"\n"
|
| 1332 |
+
" def f(self):\n"
|
| 1333 |
+
" return self.employee_id.name\n")
|
| 1334 |
+
assert semantics.run_check("ondelete_leaves_a_dangling_read", view) == []
|
| 1335 |
+
|
| 1336 |
+
table = view.replace(" _auto = False\n", "")
|
| 1337 |
+
assert semantics.run_check("ondelete_leaves_a_dangling_read", table), \
|
| 1338 |
+
"a real table with the same declaration is still the rule's subject"
|
| 1339 |
+
|
| 1340 |
+
|
| 1341 |
+
def test_sudo_de_escalation_is_recognised_by_keyword_too():
|
| 1342 |
+
"""odoo's signature is `sudo(self, flag=True)`, so `sudo(flag=False)` is
|
| 1343 |
+
the same de-escalation as `sudo(False)`. Reading only positional
|
| 1344 |
+
arguments missed it, and core writes it that way twice."""
|
| 1345 |
+
body = "def f(self):\n return self.env['res.partner'].sudo(flag=False).search([])\n"
|
| 1346 |
+
assert semantics.run_check("sudo_drops_record_rules", body) == []
|
| 1347 |
+
|
| 1348 |
+
|
| 1349 |
+
def test_restoring_the_ambient_level_is_not_an_escalation():
|
| 1350 |
+
"""`x.sudo(self.env.su)` sets su to whatever the environment already has,
|
| 1351 |
+
so it cannot raise privilege above ambient. It is how a method hands a
|
| 1352 |
+
record back at the caller's own level, and odoo core writes it beside the
|
| 1353 |
+
comment "Unsudo the invoice after creation if not already sudoed".
|
| 1354 |
+
|
| 1355 |
+
`sudo(self.env.su or field.compute_sudo)` is not the same thing: the `or`
|
| 1356 |
+
can turn it on, so only the bare attribute reads as ambient.
|
| 1357 |
+
"""
|
| 1358 |
+
ambient = "def f(self, invoice):\n invoice = invoice.sudo(self.env.su)\n return invoice\n"
|
| 1359 |
+
assert semantics.run_check("sudo_drops_record_rules", ambient) == []
|
| 1360 |
+
|
| 1361 |
+
may_raise = ("def f(self, rec):\n"
|
| 1362 |
+
" rec = rec.sudo(self.env.su or self.compute_sudo)\n"
|
| 1363 |
+
" return rec\n")
|
| 1364 |
+
assert semantics.run_check("sudo_drops_record_rules", may_raise)
|