diff --git a/trytond/doc/topics/configuration.rst b/trytond/doc/topics/configuration.rst index 211735728e..7dd2bf4e65 100644 --- a/trytond/doc/topics/configuration.rst +++ b/trytond/doc/topics/configuration.rst @@ -304,6 +304,24 @@ The name of the similarity function. Default: ``similarity`` +binary_scanner +~~~~~~~~~~~~~~ + +The command used to scan the content of Binary fields. +The command receive the quarantine directory as last argument. +If the command returns a non zero status, it is considered that at least one +file in the directory is malicious. + +binary_scanner_directory +~~~~~~~~~~~~~~~~~~~~~~~~ + +The directory where the binary content are stored before being checked +by the scanner. + +Default: The temporary directory as determined by Python's tempfile_ module. + +.. _tempfile: https://docs.python.org/3/library/tempfile.html#tempfile.gettempdir + .. _config-request: request diff --git a/trytond/trytond/ir/message.xml b/trytond/trytond/ir/message.xml index c30a6f5bb8..13d270012b 100644 --- a/trytond/trytond/ir/message.xml +++ b/trytond/trytond/ir/message.xml @@ -361,6 +361,9 @@ this repository contains the full copyright notices and license terms. --> Condition "%(condition)s" is not a valid PYSON expression for trigger "%(trigger)s". + + The content of field "%(field)s" has been detected as malicious. + Failed to save, please retry. diff --git a/trytond/trytond/model/fields/binary.py b/trytond/trytond/model/fields/binary.py index 94919c0b69..951f16f809 100644 --- a/trytond/trytond/model/fields/binary.py +++ b/trytond/trytond/model/fields/binary.py @@ -1,13 +1,22 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +import logging +import shlex +import subprocess +import tempfile + from sql import Column, Null +from trytond.config import config from trytond.filestore import filestore +from trytond.i18n import gettext from trytond.tools import cached_property, grouped_slice, reduce_ids from trytond.transaction import Transaction from .field import Field +logger = logging.getLogger(__name__) + def caster(d): if isinstance(d, bytes): @@ -17,6 +26,32 @@ def caster(d): return bytes(d, encoding='utf8') +def check_content(field_name, *binaries): + from trytond.model.modelstorage import BinaryScanError + + scanner = config.get('database', 'binary_scanner') + scanner_dir = config.get( + 'database', 'binary_scanner_directory', default=tempfile.gettempdir()) + if not scanner: + return + + with tempfile.TemporaryDirectory(dir=scanner_dir) as tempdir: + for binary in binaries: + with tempfile.NamedTemporaryFile(dir=tempdir, delete=False) as fd: + fd.write(binary) + + try: + subprocess.check_call( + shlex.split(scanner) + [tempdir], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError as error: + logger.critical( + "'%s %s' exited with code '%s'", + scanner, tempdir, error.returncode) + raise BinaryScanError(gettext( + 'ir.msg_malicious_binary', field=field_name)) + + class Binary(Field): _type = 'binary' _sql_type = 'BLOB' @@ -112,6 +147,9 @@ def set(self, Model, name, ids, value, *args): if prefix is None: prefix = transaction.database.name + self._check_contents( + Model.__names__(name)['field'], *((ids, value) + args)[1::2]) + args = iter((ids, value) + args) for ids, value in zip(args, args): if self.file_id: @@ -124,6 +162,10 @@ def set(self, Model, name, ids, value, *args): cursor.execute(*table.update(columns, values, where=reduce_ids(table.id, ids))) + @classmethod + def _check_contents(cls, field, *values): + check_content(field, *values) + def definition(self, model, language): definition = super().definition(model, language) definition['searchable'] = False diff --git a/trytond/trytond/model/modelstorage.py b/trytond/trytond/model/modelstorage.py index 137f8287dd..15a3f136ff 100644 --- a/trytond/trytond/model/modelstorage.py +++ b/trytond/trytond/model/modelstorage.py @@ -98,6 +98,10 @@ def __init__(self, record): self.record = record +class BinaryScanError(ValidationError): + pass + + def is_leaf(expression): return (isinstance(expression, (list, tuple)) and len(expression) > 2