diff --git a/requirements.txt b/requirements.txt index 69ac974db53..181d5381d9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ astor dataclasses odoorpc openupgradelib +sentry_sdk>=2.0.0 diff --git a/sentry/README.rst b/sentry/README.rst new file mode 100644 index 00000000000..f83d92968a4 --- /dev/null +++ b/sentry/README.rst @@ -0,0 +1,194 @@ +====== +Sentry +====== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:fb1e0787f353def76b80f3aae5948d0b7a9b01d35f2f738a5b96fa28588bab0e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/sentry + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-sentry + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows painless `Sentry `__ integration +with Odoo. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +The module can be installed just like any other Odoo module, by adding +the module's directory to Odoo *addons_path*. In order for the module to +correctly wrap the Odoo WSGI application, it also needs to be loaded as +a server-wide module. This can be done with the ``server_wide_modules`` +parameter in your Odoo config file or with the ``--load`` command-line +parameter. + +This module additionally requires the sentry-sdk Python package to be +available on the system. It can be installed using pip: + +:: + + pip install sentry-sdk + +Configuration +============= + +The following additional configuration options can be added to your Odoo +configuration file: + +[TABLE] + +Other `client +arguments `__ +can be configured by prepending the argument name with *sentry\_* in +your Odoo config file. Currently supported additional client arguments +are: +``with_locals, max_breadcrumbs, release, environment, server_name, shutdown_timeout, in_app_include, in_app_exclude, default_integrations, dist, sample_rate, send_default_pii, http_proxy, https_proxy, request_bodies, debug, attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, auto_enabling_integrations``. + +Example Odoo configuration +-------------------------- + +Below is an example of Odoo configuration file with *Odoo Sentry* +options: + +:: + + [options] + sentry_dsn = https://:@sentry.example.com/ + sentry_enabled = true + sentry_logging_level = warn + sentry_exclude_loggers = werkzeug + sentry_ignore_exceptions = odoo.exceptions.AccessDenied, + odoo.exceptions.AccessError,odoo.exceptions.MissingError, + odoo.exceptions.RedirectWarning,odoo.exceptions.UserError, + odoo.exceptions.ValidationError,odoo.exceptions.Warning, + odoo.exceptions.except_orm + sentry_include_context = true + sentry_environment = production + sentry_release = 1.3.2 + sentry_odoo_dir = /home/odoo/odoo/ + +Usage +===== + +Once configured and installed, the module will report any logging event +at and above the configured Sentry logging level, no additional actions +are necessary. + +|Try me on Runbot| + +.. |Try me on Runbot| image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :target: https://runbot.odoo-community.org/runbot/149/14.0 + +Known issues / Roadmap +====================== + +- **No database separation** -- This module functions by intercepting + all Odoo logging records in a running Odoo process. This means that + once installed in one database, it will intercept and report errors + for all Odoo databases, which are used on that Odoo server. +- **Frontend integration** -- In the future, it would be nice to add + Odoo client-side error reporting to this module as well, by + integrating `raven-js `__. + Additionally, `Sentry user feedback + form `__ could be + integrated into the Odoo client error dialog window to allow users + shortly describe what they were doing when things went wrong. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Mohammed Barsi +* Versada +* Nicolas JEUDY +* Vauxoo + +Contributors +------------ + +- Mohammed Barsi +- Andrius Preimantas +- Naglis Jonaitis +- Atte Isopuro +- Florian Mounier +- Jon Ashton +- Mark Schuit +- Atchuthan + +Other credits +------------- + +- Vauxoo + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-barsi| image:: https://github.com/barsi.png?size=40px + :target: https://github.com/barsi + :alt: barsi +.. |maintainer-naglis| image:: https://github.com/naglis.png?size=40px + :target: https://github.com/naglis + :alt: naglis +.. |maintainer-versada| image:: https://github.com/versada.png?size=40px + :target: https://github.com/versada + :alt: versada +.. |maintainer-moylop260| image:: https://github.com/moylop260.png?size=40px + :target: https://github.com/moylop260 + :alt: moylop260 +.. |maintainer-fernandahf| image:: https://github.com/fernandahf.png?size=40px + :target: https://github.com/fernandahf + :alt: fernandahf + +Current `maintainers `__: + +|maintainer-barsi| |maintainer-naglis| |maintainer-versada| |maintainer-moylop260| |maintainer-fernandahf| + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sentry/__init__.py b/sentry/__init__.py new file mode 100644 index 00000000000..7001103db4d --- /dev/null +++ b/sentry/__init__.py @@ -0,0 +1 @@ +from .hooks import post_load diff --git a/sentry/__manifest__.py b/sentry/__manifest__.py new file mode 100644 index 00000000000..3cbd41ba3eb --- /dev/null +++ b/sentry/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Sentry", + "summary": "Report Odoo errors to Sentry", + "version": "18.0.1.0.0", + "category": "Extra Tools", + "website": "https://github.com/OCA/server-tools", + "author": "Mohammed Barsi," + "Versada," + "Nicolas JEUDY," + "Odoo Community Association (OCA)," + "Vauxoo", + "maintainers": ["barsi", "naglis", "versada", "moylop260", "fernandahf"], + "license": "AGPL-3", + "application": False, + "installable": True, + "external_dependencies": { + "python": [ + "sentry_sdk>=2.0.0", + ] + }, + "depends": [ + "base", + ], + "post_load": "post_load", +} diff --git a/sentry/const.py b/sentry/const.py new file mode 100644 index 00000000000..7772c26a8f5 --- /dev/null +++ b/sentry/const.py @@ -0,0 +1,128 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import collections +import logging + +from sentry_sdk import HttpTransport +from sentry_sdk.consts import DEFAULT_OPTIONS +from sentry_sdk.integrations.logging import LoggingIntegration + +import odoo.loglevels + + +def split_multiple(string, delimiter=",", strip_chars=None): + """Splits :param:`string` and strips :param:`strip_chars` from values.""" + if not string: + return [] + return [v.strip(strip_chars) for v in string.split(delimiter)] + + +def to_int_if_defined(value): + if value == "" or value is None: + return + return int(value) + + +def to_float_if_defined(value): + if value == "" or value is None: + return + return float(value) + + +SentryOption = collections.namedtuple("SentryOption", ["key", "default", "converter"]) + +# Mapping of Odoo logging level -> Python stdlib logging library log level. +LOG_LEVEL_MAP = { + getattr(odoo.loglevels, f"LOG_{x}"): getattr(logging, x) + for x in ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET") +} +DEFAULT_LOG_LEVEL = "warn" + +ODOO_USER_EXCEPTIONS = [ + "odoo.exceptions.AccessDenied", + "odoo.exceptions.AccessError", + "odoo.exceptions.DeferredException", + "odoo.exceptions.MissingError", + "odoo.exceptions.RedirectWarning", + "odoo.exceptions.UserError", + "odoo.exceptions.ValidationError", + "odoo.exceptions.Warning", + "odoo.exceptions.except_orm", +] +DEFAULT_IGNORED_EXCEPTIONS = ",".join(ODOO_USER_EXCEPTIONS) + +EXCLUDE_LOGGERS = ("werkzeug",) +DEFAULT_EXCLUDE_LOGGERS = ",".join(EXCLUDE_LOGGERS) + +DEFAULT_ENVIRONMENT = "develop" + +DEFAULT_TRANSPORT = "threaded" + + +def select_transport(name=DEFAULT_TRANSPORT): + return { + "threaded": HttpTransport, + }.get(name, HttpTransport) + + +def get_sentry_logging(level=DEFAULT_LOG_LEVEL): + if level not in LOG_LEVEL_MAP: + level = DEFAULT_LOG_LEVEL + + return LoggingIntegration( + # Gather warnings into breadcrumbs regardless of actual logging level + level=logging.WARNING, + event_level=LOG_LEVEL_MAP[level], + ) + + +def get_sentry_options(): + return [ + SentryOption("dsn", "", str.strip), + SentryOption("transport", DEFAULT_OPTIONS["transport"], select_transport), + SentryOption("logging_level", DEFAULT_LOG_LEVEL, get_sentry_logging), + SentryOption( + "include_local_variables", DEFAULT_OPTIONS["include_local_variables"], None + ), + SentryOption( + "max_breadcrumbs", DEFAULT_OPTIONS["max_breadcrumbs"], to_int_if_defined + ), + SentryOption("release", DEFAULT_OPTIONS["release"], None), + SentryOption("environment", DEFAULT_OPTIONS["environment"], None), + SentryOption("server_name", DEFAULT_OPTIONS["server_name"], None), + SentryOption("shutdown_timeout", DEFAULT_OPTIONS["shutdown_timeout"], None), + SentryOption("integrations", DEFAULT_OPTIONS["integrations"], None), + SentryOption( + "in_app_include", DEFAULT_OPTIONS["in_app_include"], split_multiple + ), + SentryOption( + "in_app_exclude", DEFAULT_OPTIONS["in_app_exclude"], split_multiple + ), + SentryOption( + "default_integrations", DEFAULT_OPTIONS["default_integrations"], None + ), + SentryOption("dist", DEFAULT_OPTIONS["dist"], None), + SentryOption( + "sample_rate", DEFAULT_OPTIONS["sample_rate"], to_float_if_defined + ), + SentryOption("send_default_pii", DEFAULT_OPTIONS["send_default_pii"], None), + SentryOption("http_proxy", DEFAULT_OPTIONS["http_proxy"], None), + SentryOption("https_proxy", DEFAULT_OPTIONS["https_proxy"], None), + SentryOption("ignore_exceptions", DEFAULT_IGNORED_EXCEPTIONS, split_multiple), + SentryOption( + "max_request_body_size", DEFAULT_OPTIONS["max_request_body_size"], None + ), + SentryOption("attach_stacktrace", DEFAULT_OPTIONS["attach_stacktrace"], None), + SentryOption("ca_certs", DEFAULT_OPTIONS["ca_certs"], None), + SentryOption("propagate_traces", DEFAULT_OPTIONS["propagate_traces"], None), + SentryOption( + "traces_sample_rate", + DEFAULT_OPTIONS["traces_sample_rate"], + to_float_if_defined, + ), + SentryOption( + "auto_enabling_integrations", + DEFAULT_OPTIONS["auto_enabling_integrations"], + None, + ), + ] diff --git a/sentry/generalutils.py b/sentry/generalutils.py new file mode 100644 index 00000000000..e13d13cc899 --- /dev/null +++ b/sentry/generalutils.py @@ -0,0 +1,62 @@ +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + # Python < 3.3 + from collections.abc import Mapping # pragma: no cover + + +def string_types(): + """Taken from https://git.io/JIv5J""" + + return (str,) + + +def is_namedtuple(value): + """https://stackoverflow.com/a/2166841/1843746 + But modified to handle subclasses of namedtuples. + Taken from https://git.io/JIsfY + """ + if not isinstance(value, tuple): + return False + f = getattr(type(value), "_fields", None) + if not isinstance(f, tuple): + return False + return all(isinstance(n, str) for n in f) + + +def iteritems(d, **kw): + """Override iteritems for support multiple versions python. + Taken from https://git.io/JIvMi + """ + return iter(d.items(**kw)) + + +def varmap(func, var, context=None, name=None): + """Executes ``func(key_name, value)`` on all values + recurisively discovering dict and list scoped + values. Taken from https://git.io/JIvMN + """ + if context is None: + context = {} + objid = id(var) + if objid in context: + return func(name, "<...>") + context[objid] = 1 + + if isinstance(var, list | tuple) and not is_namedtuple(var): + ret = [varmap(func, f, context, name) for f in var] + else: + ret = func(name, var) + if isinstance(ret, Mapping): + ret = {k: varmap(func, v, context, k) for k, v in iteritems(var)} + del context[objid] + return ret + + +def get_environ(environ): + """Returns our whitelisted environment variables. + Taken from https://git.io/JIsf2 + """ + for key in ("REMOTE_ADDR", "SERVER_NAME", "SERVER_PORT"): + if key in environ: + yield key, environ[key] diff --git a/sentry/hooks.py b/sentry/hooks.py new file mode 100644 index 00000000000..ee39501953b --- /dev/null +++ b/sentry/hooks.py @@ -0,0 +1,154 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import warnings +from collections import abc + +import odoo.http +from odoo.service.server import server +from odoo.tools import config as odoo_config + +from . import const +from .logutils import ( + InvalidGitRepository, + SanitizeOdooCookiesProcessor, + fetch_git_sha, + get_extra_context, +) + +_logger = logging.getLogger(__name__) +HAS_SENTRY_SDK = True +try: + import sentry_sdk + from sentry_sdk.integrations.logging import ignore_logger + from sentry_sdk.integrations.threading import ThreadingIntegration + from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +except ImportError: # pragma: no cover + HAS_SENTRY_SDK = False # pragma: no cover + _logger.debug( + "Cannot import 'sentry-sdk'.\ + Please make sure it is installed." + ) # pragma: no cover + + +def before_send(event, hint): + """Prevent the capture of any exceptions in + the DEFAULT_IGNORED_EXCEPTIONS list + -- or -- + Add context to event if include_context is True + and sanitize sensitive data""" + + exc_info = hint.get("exc_info") + if exc_info is None and "log_record" in hint: + # Odoo handles UserErrors by logging the raw exception rather + # than a message string in odoo/http.py + try: + module_name = hint["log_record"].msg.__module__ + class_name = hint["log_record"].msg.__class__.__name__ + qualified_name = module_name + "." + class_name + except AttributeError: + qualified_name = "not found" + + if qualified_name in const.DEFAULT_IGNORED_EXCEPTIONS: + return None + + if event.setdefault("tags", {})["include_context"]: + cxtest = get_extra_context(odoo.http.request) + info_request = ["tags", "user", "extra", "request"] + + for item in info_request: + info_item = event.setdefault(item, {}) + info_item.update(cxtest.setdefault(item, {})) + + raven_processor = SanitizeOdooCookiesProcessor() + raven_processor.process(event) + + return event + + +def get_odoo_commit(odoo_dir): + """Attempts to get Odoo git commit from :param:`odoo_dir`.""" + if not odoo_dir: + return + try: + return fetch_git_sha(odoo_dir) + except InvalidGitRepository: + _logger.debug("Odoo directory: '%s' not a valid git repository", odoo_dir) + + +def initialize_sentry(config): + """Setup an instance of :class:`sentry_sdk.Client`. + :param config: Sentry configuration + :param client: class used to instantiate the sentry_sdk client. + """ + enabled = config.get("sentry_enabled", False) + if not (HAS_SENTRY_SDK and enabled): + return + _logger.info("Initializing sentry...") + if config.get("sentry_odoo_dir") and config.get("sentry_release"): + _logger.debug( + "Both sentry_odoo_dir and \ + sentry_release defined, choosing sentry_release" + ) + if config.get("sentry_transport"): + warnings.warn( + "`sentry_transport` has been deprecated. " + "Its not neccesary send it, will use `HttpTranport` by default.", + DeprecationWarning, + stacklevel=1, + ) + options = {} + for option in const.get_sentry_options(): + value = config.get(f"sentry_{option.key}", option.default) + if isinstance(option.converter, abc.Callable): + value = option.converter(value) + options[option.key] = value + + exclude_loggers = const.split_multiple( + config.get("sentry_exclude_loggers", const.DEFAULT_EXCLUDE_LOGGERS) + ) + + if not options.get("release"): + options["release"] = config.get( + "sentry_release", get_odoo_commit(config.get("sentry_odoo_dir")) + ) + + # Change name `ignore_exceptions` (with raven) + # to `ignore_errors' (sentry_sdk) + options["ignore_errors"] = options["ignore_exceptions"] + del options["ignore_exceptions"] + + options["before_send"] = before_send + + options["integrations"] = [ + options["logging_level"], + ThreadingIntegration(propagate_hub=True), + ] + # Remove logging_level, since in sentry_sdk is include in 'integrations' + del options["logging_level"] + + client = sentry_sdk.init(**options) + + sentry_sdk.set_tag("include_context", config.get("sentry_include_context", True)) + + if exclude_loggers: + for item in exclude_loggers: + ignore_logger(item) + + # The server app is already registered so patch it here + if server: + server.app = SentryWsgiMiddleware(server.app) + + # Patch the wsgi server in case of further registration + odoo.http.Application = SentryWsgiMiddleware(odoo.http.Application) + + with sentry_sdk.new_scope() as scope: + scope.set_extra("debug", False) + sentry_sdk.capture_message("Starting Odoo Server", "info") + + return client + + +def post_load(): + initialize_sentry(odoo_config) diff --git a/sentry/i18n/ca.po b/sentry/i18n/ca.po new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sentry/i18n/it.po b/sentry/i18n/it.po new file mode 100644 index 00000000000..73388557f6d --- /dev/null +++ b/sentry/i18n/it.po @@ -0,0 +1,14 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" diff --git a/sentry/i18n/sentry.pot b/sentry/i18n/sentry.pot new file mode 100644 index 00000000000..716a0702d88 --- /dev/null +++ b/sentry/i18n/sentry.pot @@ -0,0 +1,13 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/sentry/i18n/zh_CN.po b/sentry/i18n/zh_CN.po new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sentry/logutils.py b/sentry/logutils.py new file mode 100644 index 00000000000..b0b0958f3f1 --- /dev/null +++ b/sentry/logutils.py @@ -0,0 +1,117 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os.path +import urllib.parse + +from werkzeug import datastructures + +from .generalutils import get_environ +from .processor import SanitizePasswordsProcessor + + +def get_request_info(request): + """ + Returns context data extracted from :param:`request`. + + Heavily based on flask integration for Sentry: https://git.io/vP4i9. + """ + urlparts = urllib.parse.urlsplit(request.url) + return { + "url": f"{urlparts.scheme}://{urlparts.netloc}{urlparts.path}", + "query_string": urlparts.query, + "method": request.method, + "headers": dict(datastructures.EnvironHeaders(request.environ)), + "env": dict(get_environ(request.environ)), + } + + +def get_extra_context(request): + """ + Extracts additional context from the current request (if such is set). + """ + try: + session = getattr(request, "session", {}) + except RuntimeError: + ctx = {} + else: + ctx = { + "tags": { + "database": session.get("db", None), + }, + "user": { + "email": session.get("login", None), + "id": session.get("uid", None), + }, + "extra": { + "context": session.get("context", {}), + }, + } + if request.httprequest: + ctx.update({"request": get_request_info(request.httprequest)}) + return ctx + + +class SanitizeOdooCookiesProcessor(SanitizePasswordsProcessor): + """Custom :class:`raven.processors.Processor`. + Allows to sanitize sensitive Odoo cookies, namely the "session_id" cookie. + """ + + KEYS = frozenset( + [ + "session_id", + ] + ) + + +class InvalidGitRepository(Exception): + pass + + +def fetch_git_sha(path, head=None): + """>>> fetch_git_sha(os.path.dirname(__file__)) + Taken from https://git.io/JITmC + """ + if not head: + head_path = os.path.join(path, ".git", "HEAD") + if not os.path.exists(head_path): + raise InvalidGitRepository( + f"Cannot identify HEAD for git repository at {path}" + ) + + with open(head_path) as fp: + head = str(fp.read()).strip() + + if head.startswith("ref: "): + head = head[5:] + revision_file = os.path.join(path, ".git", *head.split("/")) + else: + return head + else: + revision_file = os.path.join(path, ".git", "refs", "heads", head) + + if not os.path.exists(revision_file): + if not os.path.exists(os.path.join(path, ".git")): + raise InvalidGitRepository( + f"{path} does not seem to be the root of a git repository" + ) + + # Check for our .git/packed-refs' file since a `git gc` may have run + # https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery + packed_file = os.path.join(path, ".git", "packed-refs") + if os.path.exists(packed_file): + with open(packed_file) as fh: + for line in fh: + line = line.rstrip() + if line and line[:1] not in ("#", "^"): + try: + revision, ref = line.split(" ", 1) + except ValueError: + continue + if ref == head: + return str(revision) + + raise InvalidGitRepository(f"Unable to find ref to head {head} in repository") + + with open(revision_file) as fh: + return str(fh.read()).strip() diff --git a/sentry/processor.py b/sentry/processor.py new file mode 100644 index 00000000000..e8298f7b22f --- /dev/null +++ b/sentry/processor.py @@ -0,0 +1,134 @@ +"""Custom class of raven.core.processors taken of https://git.io/JITko +This is a custom class of processor to filter and sanitize +passwords and keys from request data, it does not exist in +sentry-sdk. +""" + +import re + +from .generalutils import string_types, varmap + + +class SanitizeKeysProcessor: + """Class from raven for sanitize keys, cookies, etc + Asterisk out things that correspond to a configurable set of keys.""" + + MASK = "*" * 8 + + def process(self, data, **kwargs): + if "exception" in data: + if "values" in data["exception"]: + for value in data["exception"].get("values", []): + if "stacktrace" in value: + self.filter_stacktrace(value["stacktrace"]) + + if "request" in data: + self.filter_http(data["request"]) + + if "extra" in data: + data["extra"] = self.filter_extra(data["extra"]) + + if "level" in data: + data["level"] = self.filter_level(data["level"]) + + return data + + @property + def sanitize_keys(self): + pass + + def sanitize(self, item, value): + if value is None: + return + + if not item: # key can be a NoneType + return value + + # Just in case we have bytes here, we want to make them into text + # properly without failing so we can perform our check. + if isinstance(item, bytes): + item = item.decode("utf-8", "replace") + else: + item = str(item) + + item = item.lower() + for key in self.sanitize_keys: + if key in item: + # store mask as a fixed length for security + return self.MASK + return value + + def filter_stacktrace(self, data): + for frame in data.get("frames", []): + if "vars" not in frame: + continue + frame["vars"] = varmap(self.sanitize, frame["vars"]) + + def filter_http(self, data): + for n in ("data", "cookies", "headers", "env", "query_string"): + if n not in data: + continue + + # data could be provided as bytes and if it's python3 + if isinstance(data[n], bytes): + data[n] = data[n].decode("utf-8", "replace") + + if isinstance(data[n], string_types()) and "=" in data[n]: + # at this point we've assumed it's a standard HTTP query + # or cookie + if n == "cookies": + delimiter = ";" + else: + delimiter = "&" + + data[n] = self._sanitize_keyvals(data[n], delimiter) + else: + data[n] = varmap(self.sanitize, data[n]) + if n == "headers" and "Cookie" in data[n]: + data[n]["Cookie"] = self._sanitize_keyvals(data[n]["Cookie"], ";") + + def filter_extra(self, data): + return varmap(self.sanitize, data) + + def filter_level(self, data): + return re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data) + + def _sanitize_keyvals(self, keyvals, delimiter): + sanitized_keyvals = [] + for keyval in keyvals.split(delimiter): + keyval = keyval.split("=") + if len(keyval) == 2: + sanitized_keyvals.append((keyval[0], self.sanitize(*keyval))) + else: + sanitized_keyvals.append(keyval) + + return delimiter.join("=".join(keyval) for keyval in sanitized_keyvals) + + +class SanitizePasswordsProcessor(SanitizeKeysProcessor): + """Asterisk out things that look like passwords, credit card numbers, + and API keys in frames, http, and basic extra data.""" + + KEYS = frozenset( + [ + "password", + "secret", + "passwd", + "authorization", + "api_key", + "apikey", + "sentry_dsn", + "access_token", + ] + ) + VALUES_RE = re.compile(r"^(?:\d[ -]*?){13,16}$") + + @property + def sanitize_keys(self): + return self.KEYS + + def sanitize(self, item, value): + value = super().sanitize(item, value) + if isinstance(value, string_types()) and self.VALUES_RE.match(value): + return self.MASK + return value diff --git a/sentry/pyproject.toml b/sentry/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sentry/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sentry/readme/CONFIGURE.md b/sentry/readme/CONFIGURE.md new file mode 100644 index 00000000000..92ee130add5 --- /dev/null +++ b/sentry/readme/CONFIGURE.md @@ -0,0 +1,30 @@ +The following additional configuration options can be added to your Odoo +configuration file: + +[TABLE] + +Other [client +arguments](https://docs.sentry.io/platforms/python/configuration/) can +be configured by prepending the argument name with *sentry\_* in your +Odoo config file. Currently supported additional client arguments are: +`with_locals, max_breadcrumbs, release, environment, server_name, shutdown_timeout, in_app_include, in_app_exclude, default_integrations, dist, sample_rate, send_default_pii, http_proxy, https_proxy, request_bodies, debug, attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, auto_enabling_integrations`. + +## Example Odoo configuration + +Below is an example of Odoo configuration file with *Odoo Sentry* +options: + + [options] + sentry_dsn = https://:@sentry.example.com/ + sentry_enabled = true + sentry_logging_level = warn + sentry_exclude_loggers = werkzeug + sentry_ignore_exceptions = odoo.exceptions.AccessDenied, + odoo.exceptions.AccessError,odoo.exceptions.MissingError, + odoo.exceptions.RedirectWarning,odoo.exceptions.UserError, + odoo.exceptions.ValidationError,odoo.exceptions.Warning, + odoo.exceptions.except_orm + sentry_include_context = true + sentry_environment = production + sentry_release = 1.3.2 + sentry_odoo_dir = /home/odoo/odoo/ diff --git a/sentry/readme/CONTRIBUTORS.md b/sentry/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..b9cb90dd7e4 --- /dev/null +++ b/sentry/readme/CONTRIBUTORS.md @@ -0,0 +1,8 @@ +- Mohammed Barsi \<\> +- Andrius Preimantas \<\> +- Naglis Jonaitis \<\> +- Atte Isopuro \<\> +- Florian Mounier \<\> +- Jon Ashton \<\> +- Mark Schuit \<\> +- Atchuthan \<\> diff --git a/sentry/readme/CREDITS.md b/sentry/readme/CREDITS.md new file mode 100644 index 00000000000..ea589e738c7 --- /dev/null +++ b/sentry/readme/CREDITS.md @@ -0,0 +1 @@ +- Vauxoo diff --git a/sentry/readme/DESCRIPTION.md b/sentry/readme/DESCRIPTION.md new file mode 100644 index 00000000000..f8813c39a69 --- /dev/null +++ b/sentry/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows painless [Sentry](https://sentry.io/) integration +with Odoo. diff --git a/sentry/readme/INSTALL.md b/sentry/readme/INSTALL.md new file mode 100644 index 00000000000..8561c1034d0 --- /dev/null +++ b/sentry/readme/INSTALL.md @@ -0,0 +1,11 @@ +The module can be installed just like any other Odoo module, by adding +the module's directory to Odoo *addons_path*. In order for the module to +correctly wrap the Odoo WSGI application, it also needs to be loaded as +a server-wide module. This can be done with the `server_wide_modules` +parameter in your Odoo config file or with the `--load` command-line +parameter. + +This module additionally requires the sentry-sdk Python package to be +available on the system. It can be installed using pip: + + pip install sentry-sdk diff --git a/sentry/readme/ROADMAP.md b/sentry/readme/ROADMAP.md new file mode 100644 index 00000000000..4b9ee37f7ec --- /dev/null +++ b/sentry/readme/ROADMAP.md @@ -0,0 +1,11 @@ +- **No database separation** -- This module functions by intercepting + all Odoo logging records in a running Odoo process. This means that + once installed in one database, it will intercept and report errors + for all Odoo databases, which are used on that Odoo server. +- **Frontend integration** -- In the future, it would be nice to add + Odoo client-side error reporting to this module as well, by + integrating [raven-js](https://github.com/getsentry/raven-js). + Additionally, [Sentry user feedback + form](https://docs.sentry.io/learn/user-feedback/) could be integrated + into the Odoo client error dialog window to allow users shortly + describe what they were doing when things went wrong. diff --git a/sentry/readme/USAGE.md b/sentry/readme/USAGE.md new file mode 100644 index 00000000000..ce432c23afe --- /dev/null +++ b/sentry/readme/USAGE.md @@ -0,0 +1,5 @@ +Once configured and installed, the module will report any logging event +at and above the configured Sentry logging level, no additional actions +are necessary. + +[![Try me on Runbot](https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas)](https://runbot.odoo-community.org/runbot/149/14.0) diff --git a/sentry/static/description/icon.png b/sentry/static/description/icon.png new file mode 100644 index 00000000000..134c89f93b2 Binary files /dev/null and b/sentry/static/description/icon.png differ diff --git a/sentry/static/description/index.html b/sentry/static/description/index.html new file mode 100644 index 00000000000..c00d24a44f0 --- /dev/null +++ b/sentry/static/description/index.html @@ -0,0 +1,520 @@ + + + + + +Sentry + + + +
+

Sentry

+ + +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

This module allows painless Sentry integration +with Odoo.

+

Table of contents

+ +
+

Installation

+

The module can be installed just like any other Odoo module, by adding +the module’s directory to Odoo addons_path. In order for the module to +correctly wrap the Odoo WSGI application, it also needs to be loaded as +a server-wide module. This can be done with the server_wide_modules +parameter in your Odoo config file or with the --load command-line +parameter.

+

This module additionally requires the sentry-sdk Python package to be +available on the system. It can be installed using pip:

+
+pip install sentry-sdk
+
+
+
+

Configuration

+

The following additional configuration options can be added to your Odoo +configuration file:

+

[TABLE]

+

Other client +arguments +can be configured by prepending the argument name with sentry_ in +your Odoo config file. Currently supported additional client arguments +are: +with_locals, max_breadcrumbs, release, environment, server_name, shutdown_timeout, in_app_include, in_app_exclude, default_integrations, dist, sample_rate, send_default_pii, http_proxy, https_proxy, request_bodies, debug, attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, auto_enabling_integrations.

+
+

Example Odoo configuration

+

Below is an example of Odoo configuration file with Odoo Sentry +options:

+
+[options]
+sentry_dsn = https://<public_key>:<secret_key>@sentry.example.com/<project id>
+sentry_enabled = true
+sentry_logging_level = warn
+sentry_exclude_loggers = werkzeug
+sentry_ignore_exceptions = odoo.exceptions.AccessDenied,
+    odoo.exceptions.AccessError,odoo.exceptions.MissingError,
+    odoo.exceptions.RedirectWarning,odoo.exceptions.UserError,
+    odoo.exceptions.ValidationError,odoo.exceptions.Warning,
+    odoo.exceptions.except_orm
+sentry_include_context = true
+sentry_environment = production
+sentry_release = 1.3.2
+sentry_odoo_dir = /home/odoo/odoo/
+
+
+
+
+

Usage

+

Once configured and installed, the module will report any logging event +at and above the configured Sentry logging level, no additional actions +are necessary.

+

Try me on Runbot

+
+
+

Known issues / Roadmap

+
    +
  • No database separation – This module functions by intercepting +all Odoo logging records in a running Odoo process. This means that +once installed in one database, it will intercept and report errors +for all Odoo databases, which are used on that Odoo server.
  • +
  • Frontend integration – In the future, it would be nice to add +Odoo client-side error reporting to this module as well, by +integrating raven-js. +Additionally, Sentry user feedback +form could be +integrated into the Odoo client error dialog window to allow users +shortly describe what they were doing when things went wrong.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Mohammed Barsi
  • +
  • Versada
  • +
  • Nicolas JEUDY
  • +
  • Vauxoo
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+
    +
  • Vauxoo
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

barsi naglis versada moylop260 fernandahf

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sentry/tests/__init__.py b/sentry/tests/__init__.py new file mode 100644 index 00000000000..184488f04bf --- /dev/null +++ b/sentry/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_client, test_logutils, test_processor, test_generalutils diff --git a/sentry/tests/test_client.py b/sentry/tests/test_client.py new file mode 100644 index 00000000000..89af332a930 --- /dev/null +++ b/sentry/tests/test_client.py @@ -0,0 +1,251 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import sys +from unittest.mock import patch + +from sentry_sdk.integrations.logging import _IGNORED_LOGGERS +from sentry_sdk.transport import HttpTransport + +from odoo import exceptions +from odoo.tests import TransactionCase +from odoo.tools import config + +from ..const import to_int_if_defined +from ..hooks import initialize_sentry + +GIT_SHA = "d670460b4b4aece5915caf5c68d12f560a9fe3e4" +RELEASE = "test@1.2.3" + + +def remove_handler_ignore(handler_name): + """Removes handlers of handlers ignored list.""" + _IGNORED_LOGGERS.discard(handler_name) + + +class TestException(exceptions.UserError): + pass + + +class InMemoryTransport(HttpTransport): + """A :class:`sentry_sdk.Hub.transport` subclass which simply stores events + in a list. + + Extended based on the one found in raven-python to avoid additional testing + dependencies: https://git.io/vyGO3 + """ + + def __init__(self, *args, **kwargs): + self.events = [] + self.envelopes = [] + + def capture_envelope(self, envelope, *args, **kwargs): + self.envelopes.append(envelope) + + def has_event(self, event_level, event_msg): + for event in self.envelopes: + if ( + event.get_event().get("level") == event_level + and event.get_event().get("logentry", {}).get("message") == event_msg + ): + return True + return False + + def flush(self, *args, **kwargs): + pass + + def kill(self, *args, **kwargs): + pass + + +class NoopHandler(logging.Handler): + """ + A Handler subclass that does nothing with any given log record. + + Sentry's log patching works by having the integration process things after + the normal log handlers are run, so we use this handler to do nothing and + move to Sentry logic ASAP. + """ + + def emit(self, record): + pass + + +class TestClientSetup(TransactionCase): + def setUp(self): + super().setUp() + self.dsn = "http://public:secret@example.com/1" + self.patch_config( + { + "sentry_enabled": True, + "sentry_dsn": self.dsn, + "sentry_logging_level": "error", + } + ) + self.client = initialize_sentry(config)._client + self.client.transport = InMemoryTransport({"dsn": self.dsn}) + + # Setup our own logger so we don't flood stderr with error logs + self.logger = logging.getLogger("odoo.sentry.test.logger") + # Do not mutate list while iterating it + handlers = [handler for handler in self.logger.handlers] + for handler in handlers: + self.logger.removeHandler(handler) + self.logger.addHandler(NoopHandler()) + self.logger.propagate = False + + def patch_config(self, options: dict): + """ + Patch Odoo's config with the given `options`, ensuring that the patch + is undone when the test completes. + """ + _config_patcher = patch.dict( + in_dict=config.options, + values=options, + ) + _config_patcher.start() + self.addCleanup(_config_patcher.stop) + + def log(self, level, msg, exc_info=None): + self.logger.log(level, msg, exc_info=exc_info) + + def assertEventCaptured(self, client, event_level, event_msg): + self.assertTrue( + client.transport.has_event(event_level, event_msg), + msg=f"Event: {event_msg} was not captured", + ) + + def assertEventNotCaptured(self, client, event_level, event_msg): + self.assertFalse( + client.transport.has_event(event_level, event_msg), + msg=f"Event: {event_msg} was captured", + ) + + def test_initialize_raven_sets_dsn(self): + self.assertEqual(self.client.dsn, self.dsn) + + def test_ignore_low_level_event(self): + level, msg = logging.WARNING, "Test event, can be ignored" + self.log(level, msg) + level = "warning" + self.assertEventNotCaptured(self.client, level, msg) + + def test_capture_event(self): + level, msg = logging.ERROR, "Test event, should be captured" + self.log(level, msg) + level = "error" + self.assertEventCaptured(self.client, level, msg) + + def test_capture_event_exc(self): + level, msg = logging.ERROR, "Test event, can be ignored exception" + try: + raise TestException(msg) + except TestException: + exc_info = sys.exc_info() + self.log(level, msg, exc_info) + level = "error" + self.assertEventCaptured(self.client, level, msg) + + def test_ignore_exceptions(self): + self.patch_config( + { + "sentry_ignore_exceptions": "odoo.exceptions.UserError", + } + ) + client = initialize_sentry(config)._client + client.transport = InMemoryTransport({"dsn": self.dsn}) + level, msg = logging.ERROR, "Test exception" + try: + raise exceptions.UserError(msg) + except exceptions.UserError: + exc_info = sys.exc_info() + self.log(level, msg, exc_info) + level = "error" + self.assertEventNotCaptured(client, level, msg) + + def test_capture_exceptions_with_no_exc_info(self): + """A UserError that isn't in the DEFAULT_IGNORED_EXCEPTIONS list is captured + (there is no exc_info in the ValidationError exception).""" + client = initialize_sentry(config)._client + client.transport = InMemoryTransport({"dsn": self.dsn}) + level, msg = logging.ERROR, "Test exception" + + # Odoo handles UserErrors by logging the exception + with patch("odoo.addons.sentry.const.DEFAULT_IGNORED_EXCEPTIONS", new=[]): + self.log(level, exceptions.ValidationError(msg)) + + level = "error" + self.assertEventCaptured(client, level, msg) + + def test_ignore_exceptions_with_no_exc_info(self): + """A UserError that is in the DEFAULT_IGNORED_EXCEPTIONS is not captured + (there is no exc_info in the ValidationError exception).""" + client = initialize_sentry(config)._client + client.transport = InMemoryTransport({"dsn": self.dsn}) + level, msg = logging.ERROR, "Test exception" + + # Odoo handles UserErrors by logging the exception + self.log(level, exceptions.ValidationError(msg)) + + level = "error" + self.assertEventNotCaptured(client, level, msg) + + def test_exclude_logger(self): + self.patch_config( + { + "sentry_enabled": True, + "sentry_exclude_loggers": self.logger.name, + } + ) + client = initialize_sentry(config)._client + client.transport = InMemoryTransport({"dsn": self.dsn}) + level, msg = logging.ERROR, f"Test exclude logger {__name__}" + self.log(level, msg) + level = "error" + # Revert ignored logger so it doesn't affect other tests + remove_handler_ignore(self.logger.name) + self.assertEventNotCaptured(client, level, msg) + + def test_invalid_logging_level(self): + self.patch_config( + { + "sentry_logging_level": "foo_bar", + } + ) + client = initialize_sentry(config)._client + client.transport = InMemoryTransport({"dsn": self.dsn}) + level, msg = logging.WARNING, "Test we use the default" + self.log(level, msg) + level = "warning" + self.assertEventCaptured(client, level, msg) + + def test_undefined_to_int(self): + self.assertIsNone(to_int_if_defined("")) + + @patch("odoo.addons.sentry.hooks.get_odoo_commit", return_value=GIT_SHA) + def test_config_odoo_dir(self, get_odoo_commit): + self.patch_config({"sentry_odoo_dir": "/opt/odoo/core"}) + client = initialize_sentry(config)._client + + self.assertEqual( + client.options["release"], + GIT_SHA, + "Failed to use 'sentry_odoo_dir' parameter appropriately", + ) + + @patch("odoo.addons.sentry.hooks.get_odoo_commit", return_value=GIT_SHA) + def test_config_release(self, get_odoo_commit): + self.patch_config( + { + "sentry_odoo_dir": "/opt/odoo/core", + "sentry_release": RELEASE, + } + ) + client = initialize_sentry(config)._client + + self.assertEqual( + client.options["release"], + RELEASE, + "Failed to use 'sentry_release' parameter appropriately", + ) diff --git a/sentry/tests/test_generalutils.py b/sentry/tests/test_generalutils.py new file mode 100644 index 00000000000..8d986721932 --- /dev/null +++ b/sentry/tests/test_generalutils.py @@ -0,0 +1,48 @@ +import typing +from collections import namedtuple + +from odoo.tests import TransactionCase + +from .. import generalutils + + +class TestGeneralUtils(TransactionCase): + def test_is_namedtuple(self): + self.assertFalse(generalutils.is_namedtuple(["a list"])) + self.assertFalse(generalutils.is_namedtuple(("a normal tuple",))) + a_namedtuple = namedtuple("a_namedtuple", ["some_string"]) + self.assertTrue(generalutils.is_namedtuple(a_namedtuple("a namedtuple"))) + + class AnotherNamedtuple(typing.NamedTuple): + some_string: str + + self.assertTrue( + generalutils.is_namedtuple(AnotherNamedtuple("a subclassed namedtuple")) + ) + + def test_varmap(self): + top = { + "middle": [ + "a list", + "that contains", + "the outer dict", + ], + } + top["middle"].append(top) + + def func(_, two): + return two + + # Don't care about the result, just that we don't get a recursion error + generalutils.varmap(func, top) + + def test_get_environ(self): + fake_environ = { + "REMOTE_ADDR": None, + "SERVER_PORT": None, + "FORBIDDEN_VAR": None, + } + self.assertEqual( + ["REMOTE_ADDR", "SERVER_PORT"], + list(key for key, _ in generalutils.get_environ(fake_environ)), + ) diff --git a/sentry/tests/test_logutils.py b/sentry/tests/test_logutils.py new file mode 100644 index 00000000000..6785e9e2cc9 --- /dev/null +++ b/sentry/tests/test_logutils.py @@ -0,0 +1,75 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import os + +from odoo.tests import TransactionCase + +from .. import logutils + + +class TestOdooCookieSanitizer(TransactionCase): + def test_cookie_as_string(self): + data = { + "request": { + "cookies": "website_lang=en_us;" + "session_id=hello;" + "Session_ID=hello;" + "foo=bar" + } + } + + proc = logutils.SanitizeOdooCookiesProcessor() + result = proc.process(data) + + self.assertTrue("request" in result) + http = result["request"] + self.assertEqual( + http["cookies"], + "website_lang=en_us;" + f"session_id={proc.MASK};" + f"Session_ID={proc.MASK};" + "foo=bar", + ) + + def test_cookie_as_string_with_partials(self): + data = {"request": {"cookies": "website_lang=en_us;session_id;foo=bar"}} + + proc = logutils.SanitizeOdooCookiesProcessor() + result = proc.process(data) + + self.assertTrue("request" in result) + http = result["request"] + self.assertEqual( + http["cookies"], + "website_lang=en_us;session_id;foo=bar", + ) + + def test_cookie_header(self): + data = { + "request": { + "headers": { + "Cookie": "foo=bar;" + "session_id=hello;" + "Session_ID=hello;" + "a_session_id_here=hello" + } + } + } + + proc = logutils.SanitizeOdooCookiesProcessor() + result = proc.process(data) + + self.assertTrue("request" in result) + http = result["request"] + self.assertEqual( + http["headers"]["Cookie"], + "foo=bar;" + f"session_id={proc.MASK};" + f"Session_ID={proc.MASK};" + f"a_session_id_here={proc.MASK}", + ) + + def test_git_sha_failure(self): + with self.assertRaises(logutils.InvalidGitRepository): + # Assume this test file is not in the repo root + logutils.fetch_git_sha(os.path.dirname(__file__)) diff --git a/sentry/tests/test_processor.py b/sentry/tests/test_processor.py new file mode 100644 index 00000000000..fffed9c4b1f --- /dev/null +++ b/sentry/tests/test_processor.py @@ -0,0 +1,48 @@ +from odoo.tests import TransactionCase + +from .. import processor + + +class TestSanitizers(TransactionCase): + def test_sanitize_password(self): + sanitizer = processor.SanitizePasswordsProcessor() + for password in [ + "1234-5678-9012-3456", + "1234 5678 9012 3456", + "1234 - 5678- -0987---1234", + "123456789012345", + ]: + with self.subTest( + password=password, + msg="password should have been sanitized", + ): + self.assertEqual( + sanitizer.sanitize(None, password), + sanitizer.MASK, + ) + for not_password in [ + "1234", + "hello", + "text long enough", + "numbers and 73X7", + "12345678901234567890", + b"12345678901234567890", + b"1234 5678 9012 3456", + "1234-5678-9012-3456-7890", + ]: + with self.subTest( + not_password=password, + msg="not_password should not have been sanitized", + ): + self.assertEqual( + sanitizer.sanitize(None, not_password), + not_password, + ) + + def test_sanitize_keys(self): + sanitizer = processor.SanitizeKeysProcessor() + self.assertIsNone(sanitizer.sanitize_keys) + + def test_sanitize_none(self): + sanitizer = processor.SanitizePasswordsProcessor() + self.assertIsNone(sanitizer.sanitize(None, None))