diff --git a/requirements.txt b/requirements.txt index 249fb4d5c01..b2a556237cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,5 @@ odoorpc openpyxl openupgradelib pysftp -raven +sentry_sdk unidecode diff --git a/sentry/README.rst b/sentry/README.rst index 113892e0ba0..bbe9e61f0b8 100644 --- a/sentry/README.rst +++ b/sentry/README.rst @@ -33,6 +33,20 @@ Odoo. .. 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 ============= @@ -67,16 +81,6 @@ configuration file: odoo.exceptions.Warning, odoo.exceptions.except_orm`` -``sentry_processors`` A string of comma-separated processor classes which will be applied ``raven.processors.SanitizePasswordsProcessor, - on an event before sending it to Sentry. odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor`` - -``sentry_transport`` Transport class which will be used to send events to Sentry. ``threaded`` - Possible values: *threaded*: spawns an async worker for processing - messages, *synchronous*: a synchronous blocking transport; - *requests_threaded*: an asynchronous transport using the *requests* - library; *requests_synchronous* - blocking transport using the - *requests* library. - ``sentry_include_context`` If enabled, additional context data will be extracted from current ``True`` HTTP request and user session (if available). This has no effect for Cron jobs, as no request/session is available inside a Cron job. @@ -94,11 +98,14 @@ configuration file: ============================= ==================================================================== ========================================================== Other `client arguments -`_ can be +`_ can be configured by prepending the argument name with *sentry_* in your Odoo config -file. Currently supported additional client arguments are: ``install_sys_hook, -include_paths, exclude_paths, machine, auto_log_stacks, capture_locals, -string_max_length, list_max_length, site, include_versions, environment``. +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 ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -110,14 +117,15 @@ Below is an example of Odoo configuration file with *Odoo Sentry* options:: 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_processors = raven.processors.SanitizePasswordsProcessor,odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor - sentry_transport = threaded + 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_auto_log_stacks = false - sentry_odoo_dir = /home/odoo/odoo/ sentry_release = 1.3.2 + sentry_odoo_dir = /home/odoo/odoo/ Usage ===== @@ -127,7 +135,7 @@ above the configured Sentry logging level, no additional actions are necessary. .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas :alt: Try me on Runbot - :target: https://runbot.odoo-community.org/runbot/149/13.0 + :target: https://runbot.odoo-community.org/runbot/149/14.0 Known issues / Roadmap ====================== @@ -163,6 +171,7 @@ Authors * Mohammed Barsi * Versada * Nicolas JEUDY +* Vauxoo Contributors ~~~~~~~~~~~~ @@ -172,6 +181,11 @@ Contributors * Naglis Jonaitis * Atte Isopuro +Other credits +~~~~~~~~~~~~~ + +* Vauxoo + Maintainers ~~~~~~~~~~~ @@ -185,6 +199,26 @@ 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 index 916ea8885e5..7001103db4d 100644 --- a/sentry/__init__.py +++ b/sentry/__init__.py @@ -1,86 +1 @@ -# Copyright 2016-2017 Versada -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo.service import wsgi_server -from odoo.tools import config as odoo_config - -from . import const -from .logutils import LoggerNameFilter, OdooSentryHandler - -from collections import abc - -_logger = logging.getLogger(__name__) -HAS_RAVEN = True -try: - import raven - from raven.middleware import Sentry -except ImportError: - HAS_RAVEN = False - _logger.debug('Cannot import "raven". Please make sure it is installed.') - - -def get_odoo_commit(odoo_dir): - """Attempts to get Odoo git commit from :param:`odoo_dir`.""" - if not odoo_dir: - return - try: - return raven.fetch_git_sha(odoo_dir) - except raven.exceptions.InvalidGitRepository: - _logger.debug('Odoo directory: "%s" not a valid git repository', odoo_dir) - - -def initialize_raven(config, client_cls=None): - """ - Setup an instance of :class:`raven.Client`. - - :param config: Sentry configuration - :param client: class used to instantiate the raven client. - """ - enabled = config.get("sentry_enabled", False) - if not (HAS_RAVEN and enabled): - return - - if config.get("sentry_odoo_dir") and config.get("sentry_release"): - _logger.debug( - "Both sentry_odoo_dir and sentry_release defined, choosing sentry_release" - ) - options = {} - for option in const.get_sentry_options(): - value = config.get("sentry_%s" % option.key, option.default) - if isinstance(option.converter, abc.Callable): - value = option.converter(value) - options[option.key] = value - - level = config.get("sentry_logging_level", const.DEFAULT_LOG_LEVEL) - exclude_loggers = const.split_multiple( - config.get("sentry_exclude_loggers", const.DEFAULT_EXCLUDE_LOGGERS) - ) - if level not in const.LOG_LEVEL_MAP: - level = const.DEFAULT_LOG_LEVEL - - if not options.get("release"): - options["release"] = config.get( - "sentry_release", get_odoo_commit(config.get("sentry_odoo_dir")) - ) - - client_cls = client_cls or raven.Client - client = client_cls(**options) - handler = OdooSentryHandler( - config.get("sentry_include_context", True), - client=client, - level=const.LOG_LEVEL_MAP[level], - ) - if exclude_loggers: - handler.addFilter( - LoggerNameFilter(exclude_loggers, name="sentry.logger.filter") - ) - raven.conf.setup_logging(handler) - wsgi_server.application = Sentry(wsgi_server.application, client=client) - - client.captureMessage("Starting Odoo Server") - return client - - -sentry_client = initialize_raven(odoo_config) +from .hooks import post_load diff --git a/sentry/__manifest__.py b/sentry/__manifest__.py index 2239a0df78c..d64c4ce1aa0 100644 --- a/sentry/__manifest__.py +++ b/sentry/__manifest__.py @@ -3,16 +3,25 @@ { "name": "Sentry", "summary": "Report Odoo errors to Sentry", - "version": "14.0.1.0.2", + "version": "14.0.1.1.0", "category": "Extra Tools", "website": "https://github.com/OCA/server-tools", "author": "Mohammed Barsi," "Versada," "Nicolas JEUDY," - "Odoo Community Association (OCA)", + "Odoo Community Association (OCA)," + "Vauxoo", + "maintainers": ["barsi", "naglis", "versada", "moylop260", "fernandahf"], "license": "AGPL-3", "application": False, "installable": True, - "external_dependencies": {"python": ["raven"]}, - "depends": ["base"], + "external_dependencies": { + "python": [ + "sentry_sdk", + ] + }, + "depends": [ + "base", + ], + "post_load": "post_load", } diff --git a/sentry/const.py b/sentry/const.py index 8ea20aebb4c..c3135a9dfe0 100644 --- a/sentry/const.py +++ b/sentry/const.py @@ -1,17 +1,13 @@ # Copyright 2016-2017 Versada # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - import collections import logging -import odoo.loglevels +from sentry_sdk import HttpTransport +from sentry_sdk.consts import DEFAULT_OPTIONS +from sentry_sdk.integrations.logging import LoggingIntegration -_logger = logging.getLogger(__name__) -try: - import raven - from raven.conf import defaults -except ImportError: - _logger.debug('Cannot import "raven". Please make sure it is installed.') +import odoo.loglevels def split_multiple(string, delimiter=",", strip_chars=None): @@ -21,6 +17,18 @@ def split_multiple(string, delimiter=",", strip_chars=None): 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. @@ -43,43 +51,70 @@ def split_multiple(string, delimiter=",", strip_chars=None): ] DEFAULT_IGNORED_EXCEPTIONS = ",".join(ODOO_USER_EXCEPTIONS) -PROCESSORS = ( - "raven.processors.SanitizePasswordsProcessor", - "odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor", -) -DEFAULT_PROCESSORS = ",".join(PROCESSORS) - EXCLUDE_LOGGERS = ("werkzeug",) DEFAULT_EXCLUDE_LOGGERS = ",".join(EXCLUDE_LOGGERS) +DEFAULT_ENVIRONMENT = "develop" + DEFAULT_TRANSPORT = "threaded" def select_transport(name=DEFAULT_TRANSPORT): return { - "requests_synchronous": raven.transport.RequestsHTTPTransport, - "requests_threaded": raven.transport.ThreadedRequestsHTTPTransport, - "synchronous": raven.transport.HTTPTransport, - "threaded": raven.transport.ThreadedHTTPTransport, - }.get(name, DEFAULT_TRANSPORT) + "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(level=LOG_LEVEL_MAP[level], event_level=logging.WARNING) def get_sentry_options(): return [ SentryOption("dsn", "", str.strip), - SentryOption("install_sys_hook", False, None), - SentryOption("transport", DEFAULT_TRANSPORT, select_transport), - SentryOption("include_paths", "", split_multiple), - SentryOption("exclude_paths", "", split_multiple), - SentryOption("machine", defaults.NAME, None), - SentryOption("auto_log_stacks", defaults.AUTO_LOG_STACKS, None), - SentryOption("capture_locals", defaults.CAPTURE_LOCALS, None), - SentryOption("string_max_length", defaults.MAX_LENGTH_STRING, None), - SentryOption("list_max_length", defaults.MAX_LENGTH_LIST, None), - SentryOption("site", None, None), - SentryOption("include_versions", True, None), + SentryOption("transport", DEFAULT_OPTIONS["transport"], select_transport), + SentryOption("logging_level", DEFAULT_LOG_LEVEL, get_sentry_logging), + SentryOption("with_locals", DEFAULT_OPTIONS["with_locals"], 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("processors", DEFAULT_PROCESSORS, split_multiple), - SentryOption("environment", None, None), - SentryOption("release", None, None), + SentryOption("request_bodies", DEFAULT_OPTIONS["request_bodies"], 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..c659f476073 --- /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 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(type(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..789f932393f --- /dev/null +++ b/sentry/hooks.py @@ -0,0 +1,136 @@ +# 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 import wsgi_server +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): + """Add context to event if include_context is True + and sanitize sensitive data""" + 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, + ) + options = {} + for option in const.get_sentry_options(): + value = config.get("sentry_%s" % 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 + wsgi_server.application = SentryWsgiMiddleware(wsgi_server.application) + + with sentry_sdk.push_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/logutils.py b/sentry/logutils.py index 93709c9ce4f..465641d3d97 100644 --- a/sentry/logutils.py +++ b/sentry/logutils.py @@ -1,20 +1,14 @@ # Copyright 2016-2017 Versada # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import logging +import os.path import urllib.parse -import odoo.http +from sentry_sdk._compat import text_type +from werkzeug import datastructures -_logger = logging.getLogger(__name__) -try: - from raven.handlers.logging import SentryHandler - from raven.processors import SanitizePasswordsProcessor - from raven.utils.wsgi import get_environ, get_headers -except ImportError: - _logger.debug('Cannot import "raven". Please make sure it is installed.') - SentryHandler = object - SanitizePasswordsProcessor = object +from .generalutils import get_environ +from .processor import SanitizePasswordsProcessor def get_request_info(request): @@ -28,70 +22,99 @@ def get_request_info(request): "url": "{}://{}{}".format(urlparts.scheme, urlparts.netloc, urlparts.path), "query_string": urlparts.query, "method": request.method, - "headers": dict(get_headers(request.environ)), + "headers": dict(datastructures.EnvironHeaders(request.environ)), "env": dict(get_environ(request.environ)), } -def get_extra_context(): +def get_extra_context(request): """ Extracts additional context from the current request (if such is set). """ - request = odoo.http.request try: session = getattr(request, "session", {}) except RuntimeError: ctx = {} else: ctx = { - "tags": {"database": session.get("db", None)}, + "tags": { + "database": session.get("db", None), + }, "user": { - "login": session.get("login", None), - "uid": session.get("uid", None), + "email": session.get("login", None), + "id": session.get("uid", None), + }, + "extra": { + "context": session.get("context", {}), }, - "extra": {"context": session.get("context", {})}, } if request.httprequest: ctx.update({"request": get_request_info(request.httprequest)}) return ctx -class LoggerNameFilter(logging.Filter): - """ - Custom :class:`logging.Filter` which allows to filter loggers by name. - """ - - def __init__(self, loggers, name=""): - super(LoggerNameFilter, self).__init__(name=name) - self._exclude_loggers = set(loggers) - - def filter(self, event): - return event.name not in self._exclude_loggers - - -class OdooSentryHandler(SentryHandler): - """ - Customized :class:`raven.handlers.logging.SentryHandler`. - - Allows to add additional Odoo and HTTP request data to the event which is - sent to Sentry. +class SanitizeOdooCookiesProcessor(SanitizePasswordsProcessor): + """Custom :class:`raven.processors.Processor`. + Allows to sanitize sensitive Odoo cookies, namely the "session_id" cookie. """ - def __init__(self, include_extra_context, *args, **kwargs): - super(OdooSentryHandler, self).__init__(*args, **kwargs) - self.include_extra_context = include_extra_context + KEYS = frozenset( + [ + "session_id", + ] + ) - def emit(self, record): - if self.include_extra_context: - self.client.context.merge(get_extra_context()) - return super(OdooSentryHandler, self).emit(record) +class InvalidGitRepository(Exception): + pass -class SanitizeOdooCookiesProcessor(SanitizePasswordsProcessor): - """ - Custom :class:`raven.processors.Processor`. - Allows to sanitize sensitive Odoo cookies, namely the "session_id" cookie. +def fetch_git_sha(path, head=None): + """>>> fetch_git_sha(os.path.dirname(__file__)) + Taken from https://git.io/JITmC """ - - KEYS = FIELDS = frozenset(["session_id"]) + if not head: + head_path = os.path.join(path, ".git", "HEAD") + if not os.path.exists(head_path): + raise InvalidGitRepository( + "Cannot identify HEAD for git repository at %s" % (path,) + ) + + with open(head_path, "r") as fp: + head = text_type(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( + "%s does not seem to be the root of a git repository" % (path,) + ) + + # 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 text_type(revision) + + raise InvalidGitRepository( + 'Unable to find ref to head "%s" in repository' % (head,) + ) + + with open(revision_file) as fh: + return text_type(fh.read()).strip() diff --git a/sentry/processor.py b/sentry/processor.py new file mode 100644 index 00000000000..8afb7d75483 --- /dev/null +++ b/sentry/processor.py @@ -0,0 +1,138 @@ +""" 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. +""" + +from __future__ import absolute_import + +import re + +from sentry_sdk._compat import text_type + +from .generalutils import string_types, varmap + + +class SanitizeKeysProcessor(object): + """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 = text_type(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(SanitizePasswordsProcessor, self).sanitize(item, value) + if isinstance(value, string_types()) and self.VALUES_RE.match(value): + return self.MASK + return value diff --git a/sentry/readme/CONFIGURE.rst b/sentry/readme/CONFIGURE.rst index ac71b087600..25de942df51 100644 --- a/sentry/readme/CONFIGURE.rst +++ b/sentry/readme/CONFIGURE.rst @@ -29,16 +29,6 @@ configuration file: odoo.exceptions.Warning, odoo.exceptions.except_orm`` -``sentry_processors`` A string of comma-separated processor classes which will be applied ``raven.processors.SanitizePasswordsProcessor, - on an event before sending it to Sentry. odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor`` - -``sentry_transport`` Transport class which will be used to send events to Sentry. ``threaded`` - Possible values: *threaded*: spawns an async worker for processing - messages, *synchronous*: a synchronous blocking transport; - *requests_threaded*: an asynchronous transport using the *requests* - library; *requests_synchronous* - blocking transport using the - *requests* library. - ``sentry_include_context`` If enabled, additional context data will be extracted from current ``True`` HTTP request and user session (if available). This has no effect for Cron jobs, as no request/session is available inside a Cron job. @@ -56,11 +46,14 @@ configuration file: ============================= ==================================================================== ========================================================== Other `client arguments -`_ can be +`_ can be configured by prepending the argument name with *sentry_* in your Odoo config -file. Currently supported additional client arguments are: ``install_sys_hook, -include_paths, exclude_paths, machine, auto_log_stacks, capture_locals, -string_max_length, list_max_length, site, include_versions, environment``. +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 ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -72,11 +65,12 @@ Below is an example of Odoo configuration file with *Odoo Sentry* options:: 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_processors = raven.processors.SanitizePasswordsProcessor,odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor - sentry_transport = threaded + 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_auto_log_stacks = false - sentry_odoo_dir = /home/odoo/odoo/ sentry_release = 1.3.2 + sentry_odoo_dir = /home/odoo/odoo/ diff --git a/sentry/readme/CONTRIBUTORS.rst b/sentry/readme/CONTRIBUTORS.rst index 059d6b3a6ab..7281929d25f 100644 --- a/sentry/readme/CONTRIBUTORS.rst +++ b/sentry/readme/CONTRIBUTORS.rst @@ -2,3 +2,4 @@ * Andrius Preimantas * Naglis Jonaitis * Atte Isopuro +* Florian Mounier diff --git a/sentry/readme/CREDITS.rst b/sentry/readme/CREDITS.rst new file mode 100644 index 00000000000..7f8b9f7ab7e --- /dev/null +++ b/sentry/readme/CREDITS.rst @@ -0,0 +1 @@ +* Vauxoo diff --git a/sentry/readme/INSTALL.rst b/sentry/readme/INSTALL.rst new file mode 100644 index 00000000000..6ccb9e34199 --- /dev/null +++ b/sentry/readme/INSTALL.rst @@ -0,0 +1,10 @@ +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/USAGE.rst b/sentry/readme/USAGE.rst index 6656ddeb507..e50ec6d6145 100644 --- a/sentry/readme/USAGE.rst +++ b/sentry/readme/USAGE.rst @@ -3,4 +3,4 @@ above the configured Sentry logging level, no additional actions are necessary. .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas :alt: Try me on Runbot - :target: https://runbot.odoo-community.org/runbot/149/13.0 + :target: https://runbot.odoo-community.org/runbot/149/14.0 diff --git a/sentry/static/description/index.html b/sentry/static/description/index.html index 93a70b0f5b9..7a34cca981a 100644 --- a/sentry/static/description/index.html +++ b/sentry/static/description/index.html @@ -3,7 +3,7 @@ - + Sentry