Static analysis tool for Odoo addons: SQL injection, N+1 queries, ORM anti-patterns, manifest dependencies, and more.
Project description
odoo-review
Static analysis tool for Odoo addons. Combines Bandit security scanning with Odoo-specific AST checks for SQL injection, N+1 queries, ORM anti-patterns, manifest dependency issues, XML/view-layer problems, and more.
Installation
pip install odoo-review
# with precise line numbers for XML findings (recommended):
pip install "odoo-review[xml]"
# or from source:
pip install -e .
XML line numbers: the XML/view checks (OR070–OR079) run with or without
lxml, butlxmlis needed to report the exact line of a finding (it is already standard in any Odoo environment). Without it the checks still fire, reporting line 0.
Usage
# Scan a single addon
odoo-review ./my_addon
# Scan all addons in a directory
odoo-review ./addons --all
# Verbose output (show code snippets + suggestions)
odoo-review ./my_addon --verbose
# JSON output (for CI/CD)
odoo-review ./my_addon --format json -o report.json
# Skip Bandit (faster, Odoo checks only)
odoo-review ./my_addon --no-bandit
# Force a target Odoo series (otherwise auto-detected from __manifest__.py)
odoo-review ./my_addon --odoo-version 17
# Fail CI if any HIGH or CRITICAL finding exists
odoo-review ./my_addon --fail-on HIGH
Odoo version awareness
Some rules depend on the target Odoo series. The version is resolved as:
--odoo-version Nif passed (explicit override), else- auto-detected from the addon's
__manifest__.pyversionkey, else - unknown — version-aware rules fall back to conservative defaults.
When the version is known it sharpens results, e.g.:
- OR020 —
sudo(True)is reportedHIGH("removed in Odoo 13") on v13+, but downgraded toMEDIUM(legacy-but-valid) on v12 and earlier. - OR042 — with an explicit
--odoo-version, a manifest whose version prefix does not match the target series is flagged as a mismatch.
Suppressing findings
There are two ways to silence findings you have reviewed and accepted. Both are
applied as a final filter, so they cover Bandit results (B###) as well as the
built-in OR### rules.
Inline — # noqa
A trailing comment on the offending line:
self.env.cr.execute(query) # noqa: OR001 # one rule
self.env.cr.execute(query) # noqa: OR001,B608 # several rules
self.sudo() # noqa # everything on this line
# noqa must be a real comment — a "# noqa" inside a string does not count.
Trailing prose is fine: # noqa: OR026 — intentional, validated input.
Project-wide — .odoo-review
Drop a .odoo-review INI file at your repo root (discovered by walking up from
each addon, like Bandit's .bandit):
[odoo-review]
target-version = 17 ; sets the Odoo series (overrides manifest auto-detect)
disable = OR025, OR044 ; never report these rules
select = OR001, OR002, OR026 ; if set, report ONLY these rules
severity = OR021:INFO, OR010:CRITICAL ; remap a rule's severity
exclude = legacy/*, scratch/* ; glob paths to skip (relative to addon)
The same keys are also accepted in pyproject.toml (needs a TOML reader —
stdlib tomllib on Python 3.11+, or the tomli backport):
[tool.odoo-review]
target-version = 17
disable = ["OR025", "OR044"]
severity = { OR021 = "INFO" }
exclude = ["legacy/*"]
A Muted: N finding(s) suppressed … line in the report (and a suppressed
field in JSON output) shows how many findings were filtered.
Use as a pre-commit hook
In any Odoo addon repo, add to .pre-commit-config.yaml:
repos:
- repo: https://github.com/fahrizaardhi/odoo-review
rev: v0.2.0
hooks:
- id: odoo-review
# optional: args: [--no-bandit, --fail-on, MEDIUM]
Then pre-commit install. On each commit, the changed .py/.csv files are
mapped to their addon and scanned; HIGH/CRITICAL findings block the commit
(tune via args).
GitHub Actions Integration
- name: Run odoo-review
run: |
pip install odoo-review
odoo-review ./addons --all --format json -o review.json --fail-on HIGH
- name: Upload review report
uses: actions/upload-artifact@v4
with:
name: odoo-review-report
path: review.json
Rules
Security
| Rule | Severity | Description |
|---|---|---|
| OR001 | CRITICAL | f-string used directly in cr.execute() — SQL injection |
| OR002 | HIGH | %-format, .format(), or string concat in cr.execute() — SQL injection |
| OR020 | HIGH | sudo(True) forces superuser context (legacy API) |
| OR021 | LOW | bare sudo() escalates privileges — confirm it is justified |
| OR026 | CRITICAL | eval() / exec() / compile() builtin usage — use safe_eval instead |
| OR050 | HIGH | new model (_name) with no ir.model.access rule — only superuser can use it |
Performance (N+1)
| Rule | Severity | Description |
|---|---|---|
| OR010 | HIGH | search() / search_count() / browse() / read() called inside a loop |
| OR011 | MEDIUM | write() / create() / unlink() / copy() inside a loop (should batch) |
| OR012 | MEDIUM | self.env['Model'] registry lookup inside a loop |
ORM Best Practices
| Rule | Severity | Description |
|---|---|---|
| OR022 | MEDIUM | Hardcoded integer ID passed to browse() — use env.ref() |
| OR023 | LOW | Model class missing _description |
| OR024 | INFO | _name + _inherit without _description |
| OR025 | LOW | _compute_* method missing @api.depends() |
Manifest & Dependencies
| Rule | Severity | Description |
|---|---|---|
| OR040 | MEDIUM | Missing required manifest keys (name, version, depends) |
| OR041 | INFO | 'base' in depends — implicit, can be removed |
| OR042 | LOW | Version not following XX.0.X.X.X convention |
| OR043 | MEDIUM | auto_install=True with installable=False |
| OR044 | INFO | Missing license key |
| OR045 | MEDIUM | Python package listed in depends (should be external_dependencies) |
| OR046 | HIGH | Module lists itself in depends (circular) |
| OR047 | HIGH | __manifest__.py missing or unparseable |
Deprecations (version-aware)
Severity scales with the target series: HIGH once removed, INFO while still supported, MEDIUM when the version is unknown.
| Rule | Severity | Description |
|---|---|---|
| OR060 | scaled | @api.multi / @api.one — removed in Odoo 13 |
| OR061 | MEDIUM | explicit cr.commit() in addon code — breaks transaction handling |
| OR062 | scaled | legacy API: from openerp, osv.osv, _columns, fields.function |
| OR063 | scaled | self.pool / self.pool.get() — old API, use self.env |
XML / View layer
Every *.xml file is parsed (views, QWeb templates, data, actions). The
deprecation rules are version-aware; attrs/states/<tree> are only flagged
on the versions where they actually changed, so older code stays quiet.
| Rule | Severity | Description |
|---|---|---|
| OR070 | scaled | attrs="..." attribute — deprecated in Odoo 17, removed in 18 (HIGH on v18+, INFO on v17) |
| OR071 | scaled | states="..." attribute — same deprecation path as OR070 |
| OR072 | INFO | <tree> renamed to <list> in Odoo 17 |
| OR073 | MEDIUM/HIGH | t-raw QWeb directive — unescaped HTML / XSS; removed in 17 (use t-out) |
| OR074 | HIGH | duplicate XML record id within the addon — the second one overwrites the first |
| OR075 | HIGH | ir.actions.act_window record / <act_window/> with no res_model |
| OR079 | HIGH | malformed XML — Odoo would refuse to load it |
Scan scope:
__pycache__,.git,node_modules,static,migrations, andtestsdirectories are skipped by the AST checks, the XML checks, and Bandit. Test code intentionally contains anti-patterns, so scanning it only adds noise.
Bandit (via integration)
All Bandit rules run automatically unless --no-bandit is passed.
Rules OR001/OR002/OR026 are excluded from Bandit to avoid duplicates with our Odoo-aware versions.
Output Example
======================================================================
📦 Addon: sale_attachment_mandatory
Files : 3 Python file(s) scanned
======================================================================
💀 CRITICAL (1)
──────────────────────────────────────────────────────────
OR001 models/sale_order.py:42
[OR001] Potential SQL injection: f-string used as SQL query
🔴 HIGH (2)
──────────────────────────────────────────────────────────
OR010 models/sale_order.py:67
[OR010] .search() called inside a loop — N+1 query risk.
Summary: Critical: 1 | High: 2 | Medium: 3 | Total: 6
Extending with Custom Rules
# my_checker.py
import ast
from pathlib import Path
from typing import Generator
from odoo_review.checkers import BaseChecker
from odoo_review.models import Finding, Severity, Category
class MyCustomChecker(BaseChecker):
def check_file(self, filepath: Path, tree: ast.Module, source: str) -> Generator[Finding, None, None]:
for node in ast.walk(tree):
if isinstance(node, ast.Call):
# ... your logic
yield Finding(
rule_id="CX001",
severity=Severity.MEDIUM,
category=Category.BEST_PRACTICE,
message="[CX001] Custom rule triggered",
filepath=str(filepath),
line=node.lineno,
)
Then expose it through the odoo_review.checkers entry-point group in your own
package's pyproject.toml — no need to edit odoo-review itself:
# pyproject.toml of your plugin package
[project.entry-points."odoo_review.checkers"]
my_custom = "my_package.my_checker:MyCustomChecker"
After pip install-ing your package, odoo-review auto-discovers the checker and
runs it alongside the built-ins. The entry point may point to a BaseChecker
subclass (instantiated automatically) or to an already-instantiated checker.
A plugin that fails to import or instantiate is skipped without aborting the scan.
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file odoo_review-0.3.0.tar.gz.
File metadata
- Download URL: odoo_review-0.3.0.tar.gz
- Upload date:
- Size: 42.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ed7900c66cb6580c0548f6cb89cd010f3632c2f387ce8046e2e2c5075bfe6b74
|
|
| MD5 |
7d2cacbb81d1161dc9d01097079dc2f8
|
|
| BLAKE2b-256 |
324dfaa77c13e5c08daa99b5fe876b385c9f2c35c84abb4896f069e085e4c606
|
Provenance
The following attestation bundles were made for odoo_review-0.3.0.tar.gz:
Publisher:
publish.yml on fahrizaardhi/odoo-review
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
odoo_review-0.3.0.tar.gz -
Subject digest:
ed7900c66cb6580c0548f6cb89cd010f3632c2f387ce8046e2e2c5075bfe6b74 - Sigstore transparency entry: 1702528051
- Sigstore integration time:
-
Permalink:
fahrizaardhi/odoo-review@3a50b478ef8d0ddebaa21ee664c027ad1acd0897 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/fahrizaardhi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@3a50b478ef8d0ddebaa21ee664c027ad1acd0897 -
Trigger Event:
push
-
Statement type:
File details
Details for the file odoo_review-0.3.0-py3-none-any.whl.
File metadata
- Download URL: odoo_review-0.3.0-py3-none-any.whl
- Upload date:
- Size: 40.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b6c73a312f6c0c40d77c95767ac72f7541fd9173be0563bb923ae8efc167fffc
|
|
| MD5 |
69c08276e5686eea1b3ad0127b11585b
|
|
| BLAKE2b-256 |
fa29fa87141fafd5a15896b74d9e2fc96516b121b179707d7d398f541fa58239
|
Provenance
The following attestation bundles were made for odoo_review-0.3.0-py3-none-any.whl:
Publisher:
publish.yml on fahrizaardhi/odoo-review
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
odoo_review-0.3.0-py3-none-any.whl -
Subject digest:
b6c73a312f6c0c40d77c95767ac72f7541fd9173be0563bb923ae8efc167fffc - Sigstore transparency entry: 1702528071
- Sigstore integration time:
-
Permalink:
fahrizaardhi/odoo-review@3a50b478ef8d0ddebaa21ee664c027ad1acd0897 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/fahrizaardhi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@3a50b478ef8d0ddebaa21ee664c027ad1acd0897 -
Trigger Event:
push
-
Statement type: