Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Zen for Python 3 is compatible with:
### WSGI
* ✅ [Django](docs/django.md)
* ✅ [Flask](docs/flask.md) ^2.2.4

#### WSGI Servers
* ✅ [Gunicorn](docs/gunicorn.md)
* ✅ [uWSGI](docs/uwsgi.md)
Expand Down Expand Up @@ -64,6 +65,9 @@ Zen instruments the following AI SDKs to track which models are used and how man
* ✅ [`boto3`](https://pypi.org/project/boto3) (AWS Bedrock)
* ✅ [`groq`](https://pypi.org/project/groq)

### Applications
* ✅ [Odoo](docs/odoo.md) 16–19

Zen is compatible with Python 3.8-3.14 and can run on Windows, Linux, and Mac OS X.

## Reporting to your Aikido Security dashboard
Expand Down
1 change: 1 addition & 0 deletions aikido_zen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def protect(mode="daemon", token=""):
# Import sources
import aikido_zen.sources.django
import aikido_zen.sources.flask
import aikido_zen.sources.odoo
import aikido_zen.sources.quart
import aikido_zen.sources.starlette
import aikido_zen.sources.fastapi
Expand Down
2 changes: 1 addition & 1 deletion aikido_zen/context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
UINPUT_SOURCES = ["body", "cookies", "query", "headers", "xml", "route_params"]
current_context = contextvars.ContextVar("current_context", default=None)

WSGI_SOURCES = ["django", "flask"]
WSGI_SOURCES = ["django", "flask", "odoo"]
ASGI_SOURCES = ["quart", "django_async", "starlette"]


Expand Down
21 changes: 21 additions & 0 deletions aikido_zen/init_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import builtins
from unittest.mock import patch

import pytest
Expand Down Expand Up @@ -31,3 +32,23 @@ def test_protect_does_not_start_without_gil(monkeypatch):

test_uds_file_access.assert_not_called()
start_background_process.assert_not_called()


def test_protect_registers_the_odoo_source(monkeypatch):
imported_modules = []
original_import = builtins.__import__

def track_import(name, *args, **kwargs):
imported_modules.append(name)
return original_import(name, *args, **kwargs)

monkeypatch.setattr("aikido_zen.aikido_disabled_flag_active", lambda: False)
monkeypatch.setattr("aikido_zen.python_version_not_supported", lambda: False)
monkeypatch.setattr("aikido_zen.gil_not_enabled", lambda: False)
monkeypatch.setattr("aikido_zen.test_uds_file_access", lambda: True)
monkeypatch.setattr("aikido_zen.check_gevent", lambda: False)
monkeypatch.setattr(builtins, "__import__", track_import)

protect(mode="daemon_disabled")

assert "aikido_zen.sources.odoo" in imported_modules
5 changes: 5 additions & 0 deletions aikido_zen/sources/odoo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from aikido_zen.sinks import on_import
from .lifecycle import patch


on_import("odoo.http")(patch)
26 changes: 26 additions & 0 deletions aikido_zen/sources/odoo/body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
_FORM_MIMETYPES = {
"application/x-www-form-urlencoded",
"multipart/form-data",
}
_JSON_ROUTE_TYPES = {"json", "jsonrpc", "json2"}


def extract_body(request, routing_type):
httprequest = request.httprequest

if routing_type in _JSON_ROUTE_TYPES:
if routing_type == "json2" and not httprequest.content_length:
return None
return request.get_json_data()

mimetype = (getattr(httprequest, "mimetype", "") or "").lower()
if mimetype == "application/json" or mimetype.endswith("+json"):
return request.get_json_data()

if mimetype in _FORM_MIMETYPES:
form = httprequest.form
if hasattr(form, "lists"):
return {key: list(values) for key, values in form.lists()}
return dict(form)

return httprequest.get_data(cache=True)
83 changes: 83 additions & 0 deletions aikido_zen/sources/odoo/body_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import json
from io import BytesIO

import pytest
from werkzeug.test import EnvironBuilder
from werkzeug.wrappers import Request as WerkzeugRequest

from .body import extract_body


class OdooRequest:
def __init__(self, httprequest):
self.httprequest = httprequest

def get_json_data(self):
return json.loads(self.httprequest.get_data(as_text=True))


def test_extract_form_preserves_duplicate_values_without_reading_uploads():
environ = EnvironBuilder(
method="POST",
data={
"tag": ["first", "second"],
"upload": (BytesIO(b"file contents"), "example.txt"),
},
).get_environ()
request = OdooRequest(WerkzeugRequest(environ))

body = extract_body(request, "http")

assert body == {"tag": ["first", "second"]}
assert request.httprequest.form.getlist("tag") == ["first", "second"]
assert request.httprequest.files["upload"].stream.tell() == 0


@pytest.mark.parametrize("routing_type", ["json", "jsonrpc", "json2"])
def test_extract_json_keeps_the_full_envelope_and_cached_request_data(routing_type):
payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {"command": "echo test", "context": {"lang": "en_US"}},
"id": 7,
}
encoded_payload = json.dumps(payload).encode()
environ = EnvironBuilder(
method="POST",
data=encoded_payload,
content_type="application/json",
).get_environ()
request = OdooRequest(WerkzeugRequest(environ))

assert extract_body(request, routing_type) == payload
assert request.httprequest.get_data(cache=True) == encoded_payload


def test_extract_unstructured_body_uses_the_cached_request_data():
payload = b"plain text body"
environ = EnvironBuilder(
method="POST",
data=payload,
content_type="text/plain",
).get_environ()
request = OdooRequest(WerkzeugRequest(environ))

assert extract_body(request, "http") == payload
assert request.httprequest.get_data(cache=True) == payload


def test_malformed_json_remains_available_for_odoo_to_parse():
payload = b'{"invalid":'
environ = EnvironBuilder(
method="POST",
data=payload,
content_type="application/json",
).get_environ()
request = OdooRequest(WerkzeugRequest(environ))

with pytest.raises(json.JSONDecodeError):
extract_body(request, "json")
with pytest.raises(json.JSONDecodeError):
request.get_json_data()

assert request.httprequest.get_data(cache=True) == payload
Loading
Loading