Repository: scVENUS/PeekabooAV (language: python)

Issue:
Switch frontend communications to REST API
Currently we speak a mix of the AMaViS AV protocol and our own JSON-based protocol on a UNIX domain socket between the client and Peekaboo. As discussed in rspamd/rspamd#1860 there are multiple reasons to put this on a more well-defined basis, using HTTP(S) and REST protocols and mechanisms.

Peekaboo's server, the `scan_file.py` client and the AMaViS plugin would need extension to support:
- other socket types than UNIX domain
- HTTP as basic transfer protocol
- definition and usage of REST/JSON for all operations, input and output
- HTTPS for secure scaling across machine boundaries including
   - server authentication (server certificate and CRL checking)
   - client authentication (e.g. pre-shared keys and TLS client certificates)


Discussion / hints:

Fix patch:
diff --git a/.travis.yml b/.travis.yml
index c8532412..ddd9e3d5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -7,6 +7,7 @@ python:
 
 install:
   - pip install -r requirements.txt
+  - pip install aiosqlite
 
 script:
   - cd tests
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f6e971b..d4c6320d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ See documentation for details.
 
 ## devel
 
+- Peekaboo now provides a REST API. The old UNIX domain socket is gone and
+  there's no longer a long-lived client connection providing a summary report
+  on multiple samples. Samples are now submitted individually, yielding a job ID
+  for subsequent attempts at retrieving a report. Both inputs and outputs of
+  the API are JSON. The AMaViS plugin and peekaboo-util are updated to match.
 - Breaking change: Equality operators in expressions using regexes do now need
   to match the whole string up to the end.
 - New database schema version 8.
diff --git a/amavis/10-ask_peekaboo b/amavis/10-ask_peekaboo
index 230a9a65..ee5ce8a5 100644
--- a/amavis/10-ask_peekaboo
+++ b/amavis/10-ask_peekaboo
@@ -24,24 +24,6 @@
 
 use strict;
 
-# define our own JSON-based protocol by plugging into AMaViS' AV control flow.
-# Standard ask_daemon call graph:
-#
-# virus_scan (iterate over active virus scanners)
-# -> ask_daemon (decide what protocol routine to use)
-# -> run_av (iterate over files, run scanner and collect results)
-# -> ask_daemon_internal (protocol routine: communicate with scanner)
-#
-# Our call graph:
-#
-# virus_scan
-# -> ask_peekaboo (force our protocol routine)
-# -> run_av
-# -> ask_peekaboo_internal (override query to JSON)
-# -> ask_daemon_internal
-#
-# See below for reasoning.
-
 # use pure-perl implementation since performance should be enough for our needs
 # and it's been a core module since 5.14, avoiding an additional dependency on
 # JSON or JSON::XS
@@ -49,6 +31,9 @@ use JSON::PP;
 # Yes, Amavis has its own minimalist JSON implementation Amavis::JSON. But it
 # doesn't seem to do UTF-8 encoding correctly - everything ends up as latin1.
 
+use LWP::UserAgent;
+use URI::Escape;
+
 # monkey patch a content_disposition setter/getter into the Parts package
 *{Amavis::Unpackers::Part::content_disposition} = sub {
   @_<2 ? shift->{co_disp} : ($_[0]->{co_disp} = $_[1]);
@@ -68,62 +53,261 @@ my $amavis_mime_traverse = \&Amavis::Unpackers::MIME::mime_traverse;
   }
 };
 
-sub ask_peekaboo_internal {
-  my ($dummy, $dummy, $names_to_parts, $dummy) = @_;
+# stolen directly from mime_traverse() for reproducing its results
+sub peekaboo_decode_filename($) {
+  my($val_raw) = @_;
 
-  # extract some additional info for peekaboo from the parts objects
-  # structure:
-  # [ { "full_name" => "<path>", "name_declared" => ..., ... },
-  #   { ... },
-  #   ... ]
-  my $metainfo = [];
-  foreach my $partname (keys %{$names_to_parts}) {
-    my $pmi = {};
+  my $val_dec = '';  # decoded, represented as native Perl characters
+  eval {
+    my(@chunks) = MIME::Words::decode_mimewords($val_raw);
+    for my $pair (@chunks) {
+      my($data,$encoding) = @$pair;
+      if (!defined $encoding || $encoding eq '') {
+        $val_dec .= safe_decode_latin1($data);  # assumes ISO-8859-1
+      } else {
+        $encoding =~ s/\*[^*]*\z//s;  # strip RFC 2231 language suffix
+        $val_dec .= safe_decode($encoding,$data);
+      }
+    }
+    1;
+  };
+
+  return $val_dec;
+}
+
+sub ask_peekaboo {
+  my($dummy, $names_to_parts, $dummy, $av_name, $dummy, $args, $dummy,
+     $dummy, $dummy) = @_;
 
+  # we receive the url and potentially other parameters from @av_scanners (15-av_scanners)
+  my $base_url = "http://127.0.0.1:8100";
+  my $polling_interval = 5;
+
+  if (defined($args)) {
+    $base_url = $$args[0] if (defined($$args[0]));
+    $polling_interval = $$args[1] if (defined($$args[1]));
+  }
+
+  my $scan_url = "${base_url}/v1/scan";
+  my $report_url_tmpl = "${base_url}/v1/report/%d";
+
+  # extract some additional info for peekaboo from the parts objects and
+  # prepare and run http reuqests to the peekaboo server
+  my $ua = LWP::UserAgent->new();
+  my @jobs;
+  my $result_text = "";
+  foreach my $partname (keys %{$names_to_parts}) {
     my $part = $names_to_parts->{$partname};
-    for my $field (qw( full_name name_declared type_declared content_disposition )) {
-      my $val = $part->$field();
-
-      # name_declared can be an array of names, if so use the last
-      $val = ref $val eq 'ARRAY' ? $val->[-1] : $val;
-
-      # when running under perl 5.22.1 e.g. on Ubuntu 16.04 safe_decode_mime
-      # somehow corrupts the original value of $val so we may get errors in
-      # totally unrelated code later on such as:
-      #   FAILED: Malformed UTF-8 character (fatal) at /usr/sbin/amavisd-new
-      #   line 10210.
-      # We work around that by forcing a copy.
-      $val = Amavis::Util::safe_decode_mime("" . $val) if $field eq "name_declared";
-
-      # do not transfer undef values since they're no good for nothing
-      $pmi->{$field} = $val if defined($val);
+    my $name_declared = $part->name_declared();
+
+    # let's put on our thinking caps here: In the case of MIME parts (as opposed
+    # to extracted archives which came from MIME parts), name_declared is
+    # extracted from MIME part headers Content-Disposition and Content-Type by
+    # sub mime_traverse() in that order.
+    #
+    # Decoding from MIME-encoding as per RFC1522/1867/2184/2231 is attempted.
+    # If it is successful, the decoded value is added to an array. The original
+    # raw value is added after that. Both are only added if they haven't been
+    # seen before. Finally, name_declared is turned into a scalar if it has only
+    # one element.
+    #
+    # A scalar name_declared will therefore always be a raw undecoded value
+    # from Content-Disposition or Content-Type (only one of them present or
+    # both with same value) that couldn't be decoded.
+    #
+    # An array is much more fluid because we can have a number of permutations
+    # in there without a means to distinguish them:
+    #
+    # [ fn_cd_dec, fn_cd_raw, fn_ct_dec, fn_ct_raw ]
+    # [ fn_cd_dec, fn_cd_raw ] # fn_ct_raw not present or == fn_cd_raw
+    # [ fn_ct_dec, fn_ct_raw ]
+    # [ fn_cd_dec, fn_cd_raw, fn_ct_raw ] # decoding failed or result == fn_cd_dec
+    # [ fn_cd_raw, fn_ct_dec, fn_ct_raw ]
+    # [ fn_cd_raw, fn_ct_raw ]
+    #
+    # What's the additional pity here is that a raw UTF-8 filename can (at least
+    # sometimes) be decoded by AMaViS using latin1 into something worse but we
+    # have no way to tell. :(
+    my $decoded_filename = $name_declared;
+    if (ref $name_declared eq 'ARRAY') {
+      # start out with a best-effort default for all cases and try to improve from
+      # there. First element either be decoded or raw:
+      # - for four elements it'll be decoded,
+      # - for two elements it'll either be decoded or raw. In the latter
+      #   case there's nothing to improve.
+      # - if AMaViS were to inspect more headers so the array and possible
+      #   permutations grows, we're still best off using the first element and
+      #   not mess with it
+      $decoded_filename = $$name_declared[0];
+
+      my $numnames = @$name_declared;
+      if ($numnames == 3) {
+        # default above would mean first element is decoded second element
+
+        # last element is guaranteed to be raw
+        if (peekaboo_decode_filename($$name_declared[2]) eq $$name_declared[1]) {
+          # second element is actually decoded third element, i.e. filename from
+          # Content-Disposition, so first element is raw, use second element
+          # because it's more good[tm]
+          $decoded_filename = $$name_declared[1];
+        }
+      }
+    }
+
+    # encode our hard-earned decoded filename for transfer as
+    # multipart/form-data. We choose the method according to RFC2388 section
+    # 4.4 (filename*=utf-8''<percent-encoded> derived from RFC2231) because
+    # it's supported by Peekaboo via sanic. Passing it raw but UTF-8-encoded
+    # (i.e. in form charset) is accepted practice as well and legitimated by
+    # RFC7578 sections 4.2 and 5.1.3 but libwww-perl has been observed to
+    # resort to latin1 and cannot be told otherwise (likely perl interpreter
+    # default for file descriptor output - which we do not want to change
+    # because it would affect all of AMaViS). Also, the HTML5 standard has gone
+    # through various iterations of how to encode special characters such as
+    # double quote, newline, linefeed and backslash over the years so that
+    # there's currently total confusion within implementations what to do.
+    # sanic for example decodes double quotes but not newlines or linefeeds as
+    # the current version of the standard requires.
+    #
+    # To make matters somewhat more interesting, RFC7578 indirectly forbids
+    # RFC2231 encoding via forbidding RFC5987. So currently our client will be
+    # RFC2388- but not RFC7578-compliant. Looking at the encoding uncertainty
+    # caused by HTML5 standard evolution this seems the lesser of two evils.
+    # This could be revisited as we find a way to send out UTF-8-encoded
+    # multipart/form-data from AMaViS and support for HTML5-style
+    # percent-encoding stabilizes in client and server libraries (libwww-perl
+    # and sanic in our case).
+    #
+    # Because RFC2388 is directly based on MIME, it requires encoding using
+    # MIME mechanisms and we could re-use existing email handling modules.
+    # Email::MIME::ContentType does not allow to encode just the filename
+    # (flimsy argument, see below) and Encode::MIME:Header does not do RFC2231.
+    # So we do it like python's email.utils.encode_rfc2231() does: Encode
+    # using URI::Escape and add the structure around it ourselves. URI::Escape
+    # is a dependency of LWP::UserAgent which we're using anyway.
+    #
+    # Because libwww-perl does not allow specifying a pre-encoded filename, we
+    # circle back to building the whole Content-Disposition header ourselves if
+    # a filename is to be supplied. Luckily, as per RFC5987 section 3.1 we do
+    # not need to deal with continuations here.
+    my $cd = undef;
+    if (defined($name_declared)) {
+      my $escaped_filename = uri_escape_utf8($decoded_filename);
+      my $encoded_filename = "utf-8''${escaped_filename}";
+      $cd = qq(form-data; name="file"; filename*="$encoded_filename")
     }
 
-    push(@$metainfo, $pmi);
+    my $answer = $ua->post($scan_url,
+      Content_Type => 'form-data',
+      # could be per-file but complicates handling server-side
+      'X-Content-Disposition' => $part->content_disposition(),
+      Content => [
+        'file' => [
+          $part->full_name(),
+          # suppress deriving from input file name, we add a header with
+          # encoded filename and asterisk for marking it as encoded ourselves
+          '',
+          Content_Type => $part->type_declared(),
+          Content_Disposition => $cd,
+        ]
+      ]
+    );
+
+    my $code = $answer->code;
+    if ($code != 200) {
+      my $message = sprintf("Peekaboo: Received error %d when submitting %s",
+              $code, $partname);
+      Amavis::Util::do_log(1, $message);
+      return (undef, $result_text . $message . "\n", undef);
+    }
+
+    # decode the text part of the response
+    my $peekaboo_response = JSON::PP->new->utf8->decode(
+            $answer->decoded_content);
+    my $job = $peekaboo_response->{"job_id"};
+
+    my $message = sprintf("Peekaboo: %s submitted as job %d", $partname, $job);
+    Amavis::Util::do_log(5, "Peekaboo: " . $message);
+    $result_text .= $message . "\n";
+
+    push(@jobs, $job);
   }
 
-  # format the whole thing as JSON, enforcing UTF-8 encoding and override
-  # AMaViS-expanded query for standard ask_daemon_internal to execute
-  $_[0] = JSON::PP->new->utf8->encode($metainfo) . "\n";
-  Amavis::AV::ask_daemon_internal(@_);
-}
+  # poll the running jobs until all are done
+  my @jobs_left = ();
+  while (@jobs) {
+    my $duration = $polling_interval;
+    my $numjobs = $#jobs + 1;
+    if (!@jobs_left) {
+      # sleep a hundredth of the polling interval per job on the first loop in
+      # case all jobs got answered from cached results and are ready very
+      # quickly
+      $duration = $polling_interval / 100 * $#jobs;
+    }
 
-# force run_av to execute our protocol routine so we can generate the final
-# JSON request structure there. We cannot do it here because run_av will expand
-# special placeholders {} and * in the query template and corrupt our JSON if
-# it contains those, either because of empty hashes formatted as {} or just a
-# simple file name containing the sequence.
-sub ask_peekaboo {
-  my (@run_av_args) = @_;
-
-  # be sure to patch a copy and not @_ because this would change the referenced
-  # call parameter and make the next call to us go directly to _internal
-  # instead, resulting in error messages like: Peekaboo-Analysis av-scanner
-  # FAILED: Can't use string ("/var/lib/amavis/tmp/amavis-20180"...) as a HASH
-  # ref while "strict refs" in use at /etc/amavis/conf.d/10-ask_peekaboo line
-  # 61.
-  $run_av_args[4] = \&ask_peekaboo_internal;
-  Amavis::AV::run_av(@run_av_args);
+    Amavis::Util::do_log(5, "Peekaboo: Sleeping %.2f seconds before " .
+            "polling %d jobs again", $duration, $numjobs);
+    sleep($duration);
+
+    @jobs_left = ();
+    for my $job (@jobs) {
+      my $answer = $ua->get(sprintf($report_url_tmpl, $job));
+
+      my $code = $answer->code;
+      if ($code == 200) {
+        my $message = sprintf("Peekaboo: Received report for job %d", $job);
+        Amavis::Util::do_log(5, $message);
+        $result_text .= $message . "\n";
+
+        # final answer
+        my $peekaboo_answer = JSON::PP->new->utf8->decode($answer->decoded_content);
+
+        # extend result text with report and reason from this result
+        my $report = $peekaboo_answer->{"report"};
+        if (defined($report)) {
+          $result_text .= join("\n", @{$report});
+        }
+
+        $result_text .= $peekaboo_answer->{"reason"} . "\n";
+
+        # check for bad or failure first
+        my $result = $peekaboo_answer->{"result"};
+        if ($result eq "failed") {
+          # no sense in dragging this out since one failed analysis means we
+          # cannot reach any conclusive verdict
+          my $message = sprintf("Peekaboo: Job %d failed", $job);
+          Amavis::Util::do_log(1, $message);
+          return (undef, $result_text . $message . "\n", undef)
+        } elsif ($result eq "bad") {
+          # same applies for one bad sample: we found malware, done
+          my $message = sprintf("Peekaboo: Job %d found malware", $job);
+          Amavis::Util::do_log(5, $message);
+          return (1, $result_text . $message . "\n", undef)
+        }
+
+        # everything else means that this job did not find malware nor fail
+      } elsif ($code == 404) {
+        # result not yet present
+        Amavis::Util::do_log(5, "Peekaboo: Job %d not finished yet", $job);
+        push(@jobs_left, $job);
+        next;
+      } else {
+        # all other codes indicate some kind of error
+        my $message = sprintf("Peekaboo: Received error %d when " .
+                "requesting report for job %d", $code, $job);
+        Amavis::Util::do_log(1, $message);
+        return (undef, $result_text . $message . "\n", undef);
+      }
+    }
+
+    @jobs = @jobs_left;
+  }
+
+  # see amavisd-new routines run_av() and virus_scan() for the meaning of these
+  # return values.
+  my $message = sprintf("Peekaboo: No malware found");
+  Amavis::Util::do_log(1, $message);
+  return (0, $result_text . $message . "\n", undef);
 }
 
 1;  # ensure a defined return value
diff --git a/bin/dummy_cuckoo_api.py b/bin/dummy_cuckoo_api.py
index 9e210df9..b15357fa 100755
--- a/bin/dummy_cuckoo_api.py
+++ b/bin/dummy_cuckoo_api.py
@@ -102,6 +102,11 @@ def get(self, job_id):
                             "DDE has been identified",
                     }
                 ],
+                "debug": {
+                    "cuckoo": [
+                        "analysis completed successfully",
+                    ],
+                },
             }
 
         return flask.jsonify(report)
diff --git a/bin/scan_emailed_file.py b/bin/scan_emailed_file.py
index 5d07be41..22f97bab 100755
--- a/bin/scan_emailed_file.py
+++ b/bin/scan_emailed_file.py
@@ -68,14 +68,14 @@ def send_mail(send_from, send_to, subject, text, files=[],
     msg.attach(MIMEText(text))
 
     for f in files:
+        # triggers encoding as per RFC2231
+        filename_tuple = ('utf-8', '', os.path.basename(f))
         with open(f, "rb") as fil:
-            part = MIMEApplication(
-                fil.read(),
-                Name=os.path.basename(f)
-            )
+            part = MIMEApplication(fil.read(), Name=filename_tuple)
+
         # After the file is closed
-        part['Content-Disposition'] = 'attachment; filename="%s"'\
-                                      % os.path.basename(f)
+        part.add_header(
+            'Content-Disposition', 'attachment', filename=filename_tuple)
         msg.attach(part)
 
     smtp_client = smtplib.SMTP(server, port)
diff --git a/docs/source/config.rst b/docs/source/config.rst
index 60bab1f6..918c7b20 100644
--- a/docs/source/config.rst
+++ b/docs/source/config.rst
@@ -241,10 +241,21 @@ Put the following code into ``/etc/amavis/conf.d/15-av_scanners``:
 .. code-block:: perl
 
     @av_scanners = (
-        ['Peekaboo-Analysis',
-        \&ask_peekaboo, ["{}\n", "/var/run/peekaboo/peekaboo.sock"],
-        qr/has been categorized "(unknown|checked|good|ignored)"$/m,
-        qr/has been categorized "bad"$/m ],
+        ['Peekaboo-Analysis', \&ask_peekaboo]
+    );
+
+    1;  # ensure a defined return
+
+
+A third parameter can be added for custom configuration.
+This is an array which currently supports adjustment of the Peekaboo API base
+URL as well as the polling interval in positions 0 and 1, respectively.
+Overriding them with their default values would look like this:
+
+.. code-block:: perl
+
+    @av_scanners = (
+        ['Peekaboo-Analysis', \&ask_peekaboo, ["http://127.0.0.1:8100", 5]]
     );
 
     1;  # ensure a defined return
diff --git a/peekaboo.conf.sample b/peekaboo.conf.sample
index 9bf369d7..a435fbca 100644
--- a/peekaboo.conf.sample
+++ b/peekaboo.conf.sample
@@ -8,18 +8,12 @@
 #user             :    peekaboo
 # specific group to drop privileges to if not primary group of user
 #group            :    <empty>
-#socket_file      :    /var/run/peekaboo/peekaboo.sock
-# change socket group and mode to open up client access
-#socket_group     :    <empty>
-#socket_mode      :    0660
+#host             :    127.0.0.1
+#port             :    8100
 
 # can be set to empty to disable PID file creation
 #pid_file         :    /var/run/peekaboo/peekaboo.pid
 #worker_count     :    3
-#sample_base_dir  :    /tmp
-#job_hash_regex   :    /amavis/tmp/([^/]+)/parts/
-# Whether or not to cleanup temporary files under /tmp
-#keep_mail_data   :    no
 #processing_info_dir : /var/lib/peekaboo/malware_reports
 
 # Configure the language to use for messages in the report returned to the
@@ -61,6 +55,10 @@
 # PostgreSQL
 # url             :    postgresql://user:password@host:port/database
 
+# as a last resort it's possible to override the driver for asyncio database
+# accesses if auto-detection fails.
+# async_driver: aiosqlite
+
 # Enable additional logging by the SQLAlchemy database module beyond Peekaboo's
 # own logging. Can be considered another set of debug logging even beyond
 # Peekaboo's DEBUG log level.
diff --git a/peekaboo/cli.py b/peekaboo/cli.py
index 62bf15a8..49e44c07 100644
--- a/peekaboo/cli.py
+++ b/peekaboo/cli.py
@@ -34,6 +34,9 @@
 import re
 import socket
 import sys
+import time
+import urllib.parse
+import requests
 
 
 logging.basicConfig()
@@ -42,64 +45,34 @@
 
 class PeekabooUtil:
     """ Utility fo interface with Peekaboo API over the socket connection """
-    def __init__(self, socket_file):
+    def __init__(self, url, polling_interval, timeout):
         logger.debug('Initialising PeekabooUtil')
-        self.peekaboo = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
-        logger.debug('Opening socket %s', socket_file)
-        self.peekaboo.connect(socket_file)
+        logger.debug('Opening connection %s', url)
+        self.url = url
+        self.polling_interval = polling_interval
+        self.timeout = timeout
 
-    def send_receive(self, request):
-        """ Send request to peekaboo and return its answer """
-        logger.debug('Sending request: %s', request)
-
-        self.peekaboo.send(request.encode('utf-8'))
-        logger.info('Waiting for response...')
-
-        buf = ''
-        while True:
-            data = self.peekaboo.recv(1024)
-            if data:
-                buf += data.decode('utf-8')
-            else:
-                self.peekaboo.close()
-                break
-        logger.debug('Received from peekaboo: %s', buf)
-        return buf
-
-    def send_receive_json(self, data):
-        """ Send and receive data in JSON format. """
-        request = json.dumps(data)
-        response = self.send_receive(request)
-        outdata = None
-        for line in response.splitlines():
-            try:
-                # try to parse and stop at first thing that parses
-                outdata = json.loads(line)
-                break
-            except ValueError:
-                # FIXME: daemon talks a mix of plain text and JSON. So for now
-                # we must ignore everything that doesn't parse. This includes
-                # errors.  We can't even employ a heuristic since all of those
-                # can be translated.
-                pass
-
-        return outdata
-
-    def ping(self):
+    def ping(self, _):
         """ Send ping request to daemon and optionally print response. """
         logger.debug("Sending ping...")
         try:
-            pong = self.send_receive_json([{"request": "ping"}])
+            r = requests.get(
+                urllib.parse.urljoin(self.url, "/ping"),
+                timeout=self.timeout)
+            pong = r.json()
         except socket.error as error:
             logger.error("Error communicating with daemon: %s", error)
             return 2
+        except json.decoder.JSONDecodeError as error:
+            logger.error("Error decoding the response: %s", error)
+            return 2
 
         if not isinstance(pong, dict):
             logger.error("Invalid response from daemon: %s", pong)
             return 2
 
-        reqtype = pong.get('request')
-        response = pong.get('response')
+        reqtype = r.request
+        response = pong.get('answer')
         if reqtype is None or response is None:
             logger.error("Incomplete response from daemon: %s", pong)
             return 2
@@ -111,73 +84,186 @@ def ping(self):
         logger.info('Pong received.')
         return 0
 
-    def raw(self, raw):
-        """ Send raw data to the daemon and display response. """
-        logger.debug("Sending raw...")
-        try:
-            response = self.send_receive(raw)
-        except socket.error as error:
-            logger.error("Error communicating with daemon: %s", error)
-            return 2
-
-        print(response)
-        return 0
-
-    def scan_file(self, filenames):
+    def scan_file(self, args):
         """ Scan the supplied filenames with peekaboo and output result """
-        result_regex = re.compile(r'has been categorized',
-                                  re.MULTILINE + re.DOTALL + re.UNICODE)
-        requests = []
-        for filename in filenames:
-            requests.append({"request": "scan-file",
-                             "full_name": os.path.abspath(filename)})
-
-        buf = self.send_receive(json.dumps(requests))
+        jobs = []
+        for filename in args.filename:
+            logger.debug('Submitting file %s', filename)
+
+            content_type = None
+            if args.content_type:
+                ct_popped = args.content_type.pop(0)
+                # empty string is treated as no selection to allow specificaion
+                # for later file arguments still
+                if ct_popped:
+                    content_type = ct_popped
+                    logger.debug('Using content type %s', content_type)
+
+            content_disposition = None
+            if args.content_disposition:
+                cd_popped = args.content_disposition.pop(0)
+                if cd_popped:
+                    content_disposition = cd_popped
+                    logger.debug(
+                        'Using content disposition %s', content_disposition)
+
+            with open(filename, 'rb') as upload_file:
+                submit_name = os.path.basename(filename)
+                files = {'file': (submit_name, upload_file, content_type)}
+                headers = {'x-content-disposition': content_disposition}
+
+                # NOTE: As of this writing, requests via urllib3 1.26.x encodes
+                # filenames according to a WHATWG HTML5 spec draft using
+                # percent encoding and backslash escaping. Peekaboo via sanic
+                # only supports part of the current WHATWG HTML5 spec (no
+                # longer draft) by reverting double quote escaping (%22 -> ").
+                # We cannot (without serious hacking) switch requests to use
+                # RFC2231 encoding from here. Therefore filenames containing
+                # special characters can not currently be correctly transferred
+                # using this command. urllib3 has already been adjusted to
+                # default to the current version of the HTML5 escaping scheme.
+                # So sanic hopefully doing the same there's a chance this will
+                # sort itself out by itself eventually.
+                response = requests.post(urllib.parse.urljoin(
+                    self.url, '/v1/scan'), files=files, headers=headers,
+                    timeout=self.timeout)
+
+            json_resp = response.json()
+            job_id = json_resp.get('job_id')
+            if job_id is None:
+                logger.error('Job submission failed: %s', json_resp.get(
+                    'message', 'No explanation given by Peekaboo'))
+                continue
+
+            logger.debug('File %s submitted as job %d', filename, job_id)
+
+            jobs.append(job_id)
 
         exit_code = 0
-        for result in buf.splitlines():
-            output = result_regex.search(result)
-            if output:
-                if 'bad' in result:
-                    print(result)
+        while True:
+            jobs_left = []
+            for job in jobs:
+                response = requests.get(urllib.parse.urljoin(
+                    self.url, f'/v1/report/{job}'), timeout=self.timeout)
+
+                if response.status_code == 404:
+                    jobs_left.append(job)
+                    continue
+
+                json_resp = response.json()
+                if json_resp['result'] == 'bad':
+                    print(f"{job}: The file has been categorized 'bad'")
                     exit_code = 1
-                logger.info(result)
+
+                logger.info("%d: %s", job, json_resp['reason'])
+
+            if not jobs_left:
+                break
+
+            jobs = jobs_left
+            time.sleep(self.polling_interval)
 
         return exit_code
 
 def main():
     """ The peekaboo-util main program. """
-    parser = argparse.ArgumentParser()
-    subparsers = parser.add_subparsers(help='commands')
-
-    parser.add_argument('-v', '--verbose', action='store_true', required=False,
-                        help='List results of all files not only bad ones')
-    parser.add_argument('-vv', '--verbose2', action='store_true', required=False,
-                        help='List detailed analysis results of every rule')
-    parser.add_argument('-d', '--debug', action='store_true', required=False,
-                        help='Output additional diagnostics')
-    parser.add_argument('-t', '--timeout', type=float, required=False,
-                        default=None, help='Communications timeout')
-    parser.add_argument('-s', '--socket-file', action='store', required=True,
-                        help='Path to Peekaboo\'s socket file')
-
-    scan_file_parser = subparsers.add_parser('scan-file',
-                                             help='Scan a file and report it')
+    # do not use store_true and store_false because it initializes the dest to
+    # its opposite instead of None which interferes with our merging-down of
+    # global parser values if not specified per command
+    common_args = [
+        {
+            'args': ['-v', '--verbose'],
+            'kwargs': {
+                'action': 'store_const', 'const': True, 'required': False,
+                'help': 'List results of all files not only bad ones'},
+        }, {
+            'args': ['-vv', '--verbose2'],
+            'kwargs': {
+                'action': 'store_const', 'const': True, 'required': False,
+                'help': 'List detailed analysis results of every rule'},
+        }, {
+            'args': ['-d', '--debug'],
+            'kwargs': {
+                'action': 'store_const', 'const': True, 'required': False,
+                'help': 'Output additional diagnostics'},
+        }, {
+            'args': ['-t', '--timeout'],
+            'kwargs': {
+                'type': float, 'required': False, 'default': None,
+                'help': 'Communications timeout'},
+        }, {
+            'args': ['-i', '--polling-interval'],
+            'kwargs': {
+                'type': int, 'required': False, 'default': 5,
+                'help': 'Polling interval'},
+        }, {
+            'args': ['-r', '--remote-url'],
+            'kwargs': {
+                'action': 'store', 'required': False,
+                'default': 'http://127.0.0.1:8100/v1/',
+                'help': 'URL to Peekaboo instance e.g. '
+                        'http://127.0.0.1:8100/v1/'},
+        }
+    ]
+
+    # using the common parser as parent to the command parser does not work as
+    # the subcommand arguments will completely replace the command parser
+    # defaults and values even if not specified on the command line
+    common_parser = argparse.ArgumentParser(add_help=False)
+    command_parser = argparse.ArgumentParser()
+    for common_arg in common_args:
+        args, kwargs = common_arg['args'], common_arg['kwargs']
+        global_kwargs = kwargs.copy()
+
+        # subcommands args must not have defaults for the global args and
+        # potentially their defaults to be queried
+        kwargs.pop('default', None)
+        common_parser.add_argument(*args, **kwargs)
+
+        # again, dest can not be the same because the subparser arguments will
+        # completely replace the command parser value and defaults even if not
+        # specified on the command line
+        dest = kwargs.get('dest')
+        if dest is None:
+            dest = args[1].lstrip('-').replace('-', '_')
+        common_arg['dest'] = dest
+        global_kwargs['dest'] = "global_%s" % dest
+        command_parser.add_argument(*args, **global_kwargs)
+
+    command_parser.set_defaults(func=PeekabooUtil.ping)
+
+    subparsers = command_parser.add_subparsers(help='commands')
+    scan_file_parser = subparsers.add_parser(
+        'scan-file', help='Scan a file and report it', parents=[common_parser])
     scan_file_parser.add_argument('-f', '--filename', action='append', required=True,
                                   help='Path to the file to scan. Can be given more '
                                        'than once to scan multiple files.')
-    scan_file_parser.set_defaults(func=command_scan_file)
+    scan_file_parser.add_argument(
+        '-c', '--content-type', action='append', required=False,
+        help='Content type of file to scan. Can be given more than once and '
+        'has to match the file list in order.')
+    scan_file_parser.add_argument(
+        '-o', '--content-disposition', action='append', required=False,
+        help='Content disposition of file to scan. Can be given more than '
+        'once and has to match the file list in order.')
+
+    scan_file_parser.set_defaults(func=PeekabooUtil.scan_file)
+
+    ping_parser = subparsers.add_parser(
+        'ping', help='Ping the daemon', parents=[common_parser])
+    ping_parser.set_defaults(func=PeekabooUtil.ping)
 
-    ping_parser = subparsers.add_parser('ping', help='Ping the daemon')
-    ping_parser.set_defaults(func=command_ping)
+    args = command_parser.parse_args()
 
-    raw_parser = subparsers.add_parser('raw',
-                                       help='Send raw input to the daemon')
-    raw_parser.add_argument('-j', '--json', action='store', required=True,
-                            help='Raw JSON to send to daemon')
-    raw_parser.set_defaults(func=command_raw)
+    # merge down global values or defaults
+    for common_arg in common_args:
+        dest = common_arg['dest']
 
-    args = parser.parse_args()
+        # dest is not present if no subcommand was specified, so check with
+        # hasattr as well
+        if not hasattr(args, dest) or getattr(args, dest) is None:
+            global_value = getattr(args, "global_%s" % dest)
+            setattr(args, dest, global_value)
 
     logger.setLevel(logging.ERROR)
     if args.verbose:
@@ -185,32 +271,15 @@ def main():
     if args.verbose2 or args.debug:
         logger.setLevel(logging.DEBUG)
 
-    if args.timeout:
-        socket.setdefaulttimeout(args.timeout)
-
     try:
-        util = PeekabooUtil(args.socket_file)
+        util = PeekabooUtil(
+            args.remote_url, args.polling_interval, args.timeout)
     except socket.error as error:
-        logger.error("Error connecting to peekaboo socket: %s", error)
+        logger.error("Error connecting to peekaboo: %s", error)
         return 2
 
     return args.func(util, args)
 
 
-def command_scan_file(util, args):
-    """ Handler for command scan_file """
-    return util.scan_file(args.filename)
-
-
-def command_ping(util, _):
-    """ Handler for command ping """
-    return util.ping()
-
-
-def command_raw(util, args):
-    """ Handler for command raw """
-    return util.raw(args.json)
-
-
 if __name__ == "__main__":
     sys.exit(main())
diff --git a/peekaboo/config.py b/peekaboo/config.py
index 28d4a211..712c328a 100644
--- a/peekaboo/config.py
+++ b/peekaboo/config.py
@@ -365,19 +365,16 @@ def __init__(self, config_file=None, log_level=None):
         self.user = 'peekaboo'
         self.group = None
         self.pid_file = '/var/run/peekaboo/peekaboo.pid'
-        self.sock_file = '/var/run/peekaboo/peekaboo.sock'
-        self.sock_group = None
-        self.sock_mode = 0o0660
+        self.host = '127.0.0.1'
+        self.port = 8100
         self.log_level = logging.INFO
         self.log_format = '%(asctime)s - %(name)s - (%(threadName)s) - ' \
                           '%(levelname)s - %(message)s'
         self.worker_count = 3
-        self.sample_base_dir = '/tmp'
-        self.job_hash_regex = '/amavis/tmp/([^/]+)/parts/'
-        self.keep_mail_data = False
         self.processing_info_dir = '/var/lib/peekaboo/malware_reports'
         self.report_locale = None
         self.db_url = 'sqlite:////var/lib/peekaboo/peekaboo.db'
+        self.db_async_driver = None
         self.db_log_level = logging.WARNING
         self.config_file = '/opt/peekaboo/etc/peekaboo.conf'
         self.ruleset_config = '/opt/peekaboo/etc/ruleset.conf'
@@ -396,16 +393,13 @@ def __init__(self, config_file=None, log_level=None):
             'user': ['global', 'user'],
             'group': ['global', 'group'],
             'pid_file': ['global', 'pid_file'],
-            'sock_file': ['global', 'socket_file'],
-            'sock_group': ['global', 'socket_group'],
-            'sock_mode': ['global', 'socket_mode', self.OCTAL],
+            'host': ['global', 'host'],
+            'port': ['global', 'port'],
             'worker_count': ['global', 'worker_count'],
-            'sample_base_dir': ['global', 'sample_base_dir'],
-            'job_hash_regex': ['global', 'job_hash_regex'],
-            'keep_mail_data': ['global', 'keep_mail_data'],
             'processing_info_dir': ['global', 'processing_info_dir'],
             'report_locale': ['global', 'report_locale'],
             'db_url': ['db', 'url'],
+            'db_async_driver': ['db', 'async_driver'],
             'db_log_level': ['db', 'log_level', self.LOG_LEVEL],
             'ruleset_config': ['ruleset', 'config'],
             'analyzer_config': ['analyzers', 'config'],
diff --git a/peekaboo/daemon.py b/peekaboo/daemon.py
index e86c8a30..65156722 100644
--- a/peekaboo/daemon.py
+++ b/peekaboo/daemon.py
@@ -25,6 +25,7 @@
 """ The main peekaboo module, starting up and managing all the various
 components. """
 
+import asyncio
 import errno
 import gettext
 import os
@@ -50,15 +51,16 @@
 
 logger = logging.getLogger(__name__)
 
-
 class SignalHandler:
     """ Signal handler. """
-    def __init__(self):
+    def __init__(self, loop):
         """ register custom signal handler """
         self.listeners = []
 
-        signal.signal(signal.SIGINT, self.signal_handler)
-        signal.signal(signal.SIGTERM, self.signal_handler)
+        loop.add_signal_handler(
+            signal.SIGINT, self.signal_handler, signal.SIGINT)
+        loop.add_signal_handler(
+            signal.SIGTERM, self.signal_handler, signal.SIGTERM)
 
         self.shutdown_requested = False
 
@@ -69,7 +71,7 @@ def register_listener(self, listener):
         """
         self.listeners.append(listener)
 
-    def signal_handler(self, sig, _):
+    def signal_handler(self, sig):
         """ catch signal and call appropriate methods in registered listener
         classes """
         if sig in [signal.SIGINT, signal.SIGTERM]:
@@ -84,9 +86,8 @@ def signal_handler(self, sig, _):
 class PeekabooDaemonInfrastructure:
     """ A class that manages typical daemon infrastructure such as PID file and
     privileges. """
-    def __init__(self, pid_file, sock_file, user, group):
+    def __init__(self, pid_file, user, group):
         self.pid_file = pid_file
-        self.sock_file = sock_file
         self.user = user
         self.group = group
 
@@ -96,7 +97,6 @@ def init(self):
         """ Initialize daemon infrastructure. """
         self.drop_privileges()
         self.create_pid_file()
-        self.check_stale_socket()
 
     def drop_privileges(self):
         """ Check and potentially drop privileges. """
@@ -207,38 +207,6 @@ def create_pid_file(self):
         self.pid_file_created = True
         logger.debug('PID %d written to %s', ourpid, self.pid_file)
 
-    def check_stale_socket(self):
-        """ Check if the socket file exists already/still and if it is stale or
-        actively serviced. Remove it if stale. """
-        # is the socket also stale?
-        if not os.path.exists(self.sock_file):
-            return
-
-        stale = False
-        try:
-            sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
-            sock.connect(self.sock_file)
-            logger.debug('Someone answered on existing socket')
-        except socket.error as sockerr:
-            logger.debug('Existing socket connection attempt failed: %s',
-                         sockerr)
-            if sockerr.errno == errno.ECONNREFUSED:
-                stale = True
-
-        if not stale:
-            logger.critical('Socket %s exists and seems to be serviced. '
-                            'Please check for another instance running.',
-                            self.sock_file)
-            sys.exit(1)
-
-        logger.warning('Removing stale socket %s', self.sock_file)
-        try:
-            os.remove(self.sock_file)
-        except OSError as oserror:
-            logger.critical('Error removing stale socket %s: %s',
-                            self.sock_file, oserror)
-            sys.exit(1)
-
     def __del__(self):
         """ Clean up on shutdown, such as removing the PID file. """
         # only remove stuff if we created it. Otherwise we're bailing (but
@@ -316,7 +284,7 @@ def run():
         db_con = PeekabooDatabase(
             db_url=config.db_url, instance_id=config.cluster_instance_id,
             stale_in_flight_threshold=config.cluster_stale_in_flight_threshold,
-            log_level=config.db_log_level)
+            log_level=config.db_log_level, async_driver=config.db_async_driver)
     except PeekabooDatabaseError as error:
         logging.critical(error)
         sys.exit(1)
@@ -328,7 +296,7 @@ def run():
     # initialize the daemon infrastructure such as PID file and dropping
     # privileges, automatically cleans up after itself when going out of scope
     daemon_infrastructure = PeekabooDaemonInfrastructure(
-        config.pid_file, config.sock_file, config.user, config.group)
+        config.pid_file, config.user, config.group)
     daemon_infrastructure.init()
 
     # clear all our in flight samples and all instances' stale in flight
@@ -347,7 +315,8 @@ def run():
                            "interval to %d seconds.",
                            cldup_check_interval)
 
-    sig_handler = SignalHandler()
+    loop = asyncio.get_event_loop()
+    sig_handler = SignalHandler(loop)
 
     # read in the analyzer and ruleset configuration and start the job queue
     try:
@@ -367,17 +336,15 @@ def run():
     # config values and references to other objects they need, such as database
     # connection and connection map.
     sample_factory = SampleFactory(
-        config.sample_base_dir, config.job_hash_regex,
-        config.keep_mail_data, config.processing_info_dir)
+        config.processing_info_dir)
 
-    # We only want to accept 2 * worker_count connections.
     try:
         server = PeekabooServer(
-            sock_file=config.sock_file, job_queue=job_queue,
+            host=config.host, port=config.port,
+            job_queue=job_queue,
             sample_factory=sample_factory,
-            request_queue_size=config.worker_count * 2,
-            sock_group=config.sock_group,
-            sock_mode=config.sock_mode)
+            request_queue_size=100,
+            db_con=db_con)
     except Exception as error:
         logger.critical('Failed to start Peekaboo Server: %s', error)
         job_queue.shut_down()
diff --git a/peekaboo/db.py b/peekaboo/db.py
index a5238008..628cdc4f 100644
--- a/peekaboo/db.py
+++ b/peekaboo/db.py
@@ -25,6 +25,7 @@
 """ A class wrapping database operations needed by Peekaboo based on
 SQLAlchemy. """
 
+import asyncio
 import random
 import time
 import threading
@@ -32,6 +33,9 @@
 from datetime import datetime, timedelta
 from sqlalchemy import Column, Integer, String, Text, DateTime, \
         Enum, Index
+import sqlalchemy.sql.expression
+import sqlalchemy.ext.asyncio
+import sqlalchemy.pool
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.engine import create_engine
 from sqlalchemy.orm import sessionmaker, scoped_session
@@ -39,9 +43,10 @@
         DBAPIError
 from peekaboo import __version__
 from peekaboo.ruleset import Result
+from peekaboo.sample import JobState
 from peekaboo.exceptions import PeekabooDatabaseError
 
-DB_SCHEMA_VERSION = 8
+DB_SCHEMA_VERSION = 9
 
 logger = logging.getLogger(__name__)
 Base = declarative_base()
@@ -99,9 +104,11 @@ class SampleInfo(Base):
     # - general considerations: The table grows very large over time. Every
     #   sample is checked against it to find a cached analysis result.
     #   Otherwise it's quite unused currently.
-    # - compound: we frequently search by sha256sum and file extension
+    # - compound: we fetch the analsysis journal by id, state, result,
+    #   sha256sum and file extension
 
     id = Column(Integer, primary_key=True)
+    state = Column(Enum(JobState), nullable=False)
     sha256sum = Column(String(64), nullable=False)
     file_extension = Column(String(16), nullable=True)
     analysis_time = Column(DateTime, nullable=False,
@@ -110,8 +117,9 @@ class SampleInfo(Base):
     reason = Column(Text, nullable=True)
 
     __table_args__ = (
-        Index('ix_%s_sha_fe' % __tablename__, sha256sum, file_extension),
-        )
+        Index('ix_%s_id_st_re_sha_fe' % __tablename__,
+              id, state, result, sha256sum, file_extension),
+    )
 
     def __str__(self):
         return ('<SampleInfo(sample_sha256_hash="%s", file_extension="%s", '
@@ -133,7 +141,8 @@ class PeekabooDatabase:
     """ Peekaboo's database. """
     def __init__(self, db_url, instance_id=0,
                  stale_in_flight_threshold=15*60,
-                 log_level=logging.WARNING):
+                 log_level=logging.WARNING,
+                 async_driver=None):
         """
         Initialize the Peekaboo database handler.
 
@@ -145,13 +154,85 @@ def __init__(self, db_url, instance_id=0,
                             to worry about.
         @param stale_in_flight_threshold: Number of seconds after which a in
         flight marker is considered stale and deleted or ignored.
+        @param log_level: Overrides the log level of the database modules. The
+                          idea is for the database to be silent by default and
+                          only emit log messages if switched on explictly and
+                          independently of the Peekaboo log level.
+        @param async_driver: last resort override of the asyncio driver
+                             auto-detection
         """
         logging.getLogger('sqlalchemy.engine').setLevel(log_level)
+        logging.getLogger('sqlalchemy.pool').setLevel(log_level)
+        # aiosqlite picks up the global log level unconditionally so we need to
+        # override it as well and explicitly
+        logging.getLogger('aiosqlite').setLevel(log_level)
+
+        # <backend>[+<driver>]:// -> <backend>
+        backend = db_url.split(':')[0].split('+')[0]
 
-        self.__engine = create_engine(db_url)
+        connect_args = {}
+        if backend == 'sqlite':
+            connect_args['timeout'] = 0
+
+        self.__engine = create_engine(
+            db_url, future=True, connect_args=connect_args)
         session_factory = sessionmaker(bind=self.__engine)
         self.__session = scoped_session(session_factory)
         self.__lock = threading.RLock()
+
+        asyncio_drivers = {
+            'sqlite': ['aiosqlite'],
+            'mysql': ['asyncmy', 'aiomysql'],
+            'postgresql': ['asyncpg'],
+        }
+
+        if async_driver is not None:
+            drivers = [async_driver]
+        else:
+            drivers = asyncio_drivers.get(backend)
+            if drivers is None:
+                raise PeekabooDatabaseError(
+                    'Unknown database backend configured: %s' % backend)
+
+        async_engine = None
+        async_db_url = None
+        for driver in drivers:
+            # replace backend and driver with our asyncio alternative
+            scheme = "%s+%s" % (backend, driver)
+            async_db_url = ':'.join([scheme] + db_url.split(':')[1:])
+
+            try:
+                async_engine = sqlalchemy.ext.asyncio.create_async_engine(
+                    async_db_url, connect_args=connect_args)
+            except ModuleNotFoundError:
+                continue
+
+            logger.debug('Auto-detected %s SQLAlchemy backend+driver for '
+                         'asyncio database accesses', scheme)
+            break
+
+        if async_engine is None:
+            raise PeekabooDatabaseError(
+                'None of the asyncio drivers for backend %s could be '
+                'found: %s' % (backend, drivers))
+
+        self.__async_session_factory = sessionmaker(
+            bind=async_engine,
+            class_=sqlalchemy.ext.asyncio.AsyncSession)
+        # no scoping necessary as we're not using asyncio across threads
+
+        # special handling for sqlite: since it does not respond well to
+        # multiple modify operations in parallel to the same database, we
+        # serialise them through a QueuePool with only one connection
+        self.__async_session_factory_modify = self.__async_session_factory
+        if backend in ['sqlite']:
+            async_engine_modify = sqlalchemy.ext.asyncio.create_async_engine(
+                async_db_url, poolclass=sqlalchemy.pool.AsyncAdaptedQueuePool,
+                pool_size=1, max_overflow=0)
+            self.__async_session_factory_modify = sessionmaker(
+                bind=async_engine_modify,
+                class_=sqlalchemy.ext.asyncio.AsyncSession)
+
         self.instance_id = instance_id
         self.stale_in_flight_threshold = stale_in_flight_threshold
         self.retries = 5
@@ -163,23 +244,23 @@ def __init__(self, db_url, instance_id=0,
         self.deadlock_backoff_base = 10
         self.connect_backoff_base = 2000
 
-        with self.__lock:
-            attempt = 1
-            while attempt <= self.retries:
+        attempt = 1
+        delay = 0
+        while attempt <= self.retries:
+            with self.__lock:
                 try:
                     Base.metadata.create_all(self.__engine)
+                    break
                 except (OperationalError, DBAPIError,
                         SQLAlchemyError) as error:
-                    attempt = self.was_transient_error(
+                    attempt, delay = self.was_transient_error(
                         error, attempt, 'create metadata')
-                    if attempt > 0:
-                        continue
 
-                    raise PeekabooDatabaseError(
-                        'Failed to create schema in database: %s' %
-                        error)
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            'Failed to create schema in database: %s' % error)
 
-                break
+            time.sleep(delay)
 
     def was_transient_error(self, error, attempt, action):
         """ Decide if an exception signals a transient error condition and
@@ -192,12 +273,13 @@ def was_transient_error(self, error, attempt, action):
         """
         # will not be retried anyway, so no use checking and sleeping
         if attempt >= self.retries:
-            return -1
+            return -1, 0
 
         # only DBAPIError has connection_invalidated
+
         if getattr(error, 'connection_invalidated', False):
             logger.debug('Connection invalidated %s. Retrying.', action)
-            return attempt + 1
+            return attempt + 1, 0
 
         # Access the original DBAPI exception anonymously.
         # We intentionally do some crude duck-typing here to avoid
@@ -206,7 +288,7 @@ def was_transient_error(self, error, attempt, action):
         # identically numbered error of another RDBMS.
         if (getattr(error, 'orig', None) is None or
                 getattr(error.orig, 'args', None) is None):
-            return -1
+            return -1, 0
 
         args = error.orig.args
 
@@ -218,58 +300,102 @@ def was_transient_error(self, error, attempt, action):
             backoff = random.randint(maxmsecs/2, maxmsecs)
             logger.debug('Connection failed %s, backing off for %d '
                          'milliseconds before retrying', action, backoff)
-            time.sleep(backoff / 1000)
-            return attempt + 1
+            return attempt + 1, backoff / 1000
 
         # (MySQLdb._exceptions.OperationalError) (1213, 'Deadlock
         # found when trying to get lock; try restarting transaction')
-        if (isinstance(args, tuple) and len(args) > 0 and args[0] in [1213]):
+        # (sqlite3.OperationalError) database is locked
+        if (isinstance(args, tuple) and len(args) > 0 and
+                args[0] in [1213, 'database is locked']):
             # sleep some millisecs
             maxmsecs = self.deadlock_backoff_base * 2**attempt
             backoff = random.randint(maxmsecs/2, maxmsecs)
             logger.debug('Database deadlock detected %s, backing off for %d '
                          'milliseconds before retrying.', action, backoff)
-            time.sleep(backoff / 1000)
-            return attempt + 1
+            return attempt + 1, backoff / 1000
 
-        return -1
+        return -1, 0
 
-    def analysis_save(self, sample):
+    async def analysis_add(self, sample):
         """
-        Save an analysis task to the analysis journal in the database.
+        Add an analysis task to the analysis journal in the database.
 
         @param sample: The sample object for this analysis task.
+        @returns: ID of the newly created analysis task (also updated
+                  in the sample)
         """
         sample_info = SampleInfo(
+            state=sample.state,
             sha256sum=sample.sha256sum,
             file_extension=sample.file_extension,
             analysis_time=datetime.now(),
             result=sample.result,
             reason=sample.reason)
 
-        with self.__lock:
-            attempt = 1
-            while attempt <= self.retries:
-                session = self.__session()
+        job_id = None
+        attempt = 1
+        delay = 0
+        while attempt <= self.retries:
+            async with self.__async_session_factory_modify() as session:
                 session.add(sample_info)
                 try:
-                    session.commit()
+                    # flush to retrieve the automatically assigned primary
+                    # key value
+                    await session.flush()
+                    job_id = sample_info.id
+                    await session.commit()
+                    break
                 except (OperationalError, DBAPIError,
                         SQLAlchemyError) as error:
-                    session.rollback()
+                    await session.rollback()
+
+                    attempt, delay = self.was_transient_error(
+                       error, attempt, 'adding analysis')
 
-                    attempt = self.was_transient_error(
-                        error, attempt, 'saving analysis result')
-                    if attempt > 0:
-                        continue
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            'Failed to add analysis task to the database: %s' %
+                            error)
 
-                    raise PeekabooDatabaseError(
-                        'Failed to add analysis task to the database: %s' %
-                        error)
-                finally:
-                    session.close()
+            await asyncio.sleep(delay)
 
-                break
+        sample.update_id(job_id)
+        return job_id
+
+    def analysis_update(self, sample):
+        """
+        Update an analysis task in the analysis journal in the database.
+
+        @param sample: The sample object for this analysis task.
+        """
+        statement = sqlalchemy.sql.expression.update(SampleInfo).where(
+            SampleInfo.id == sample.id).values(
+                state=sample.state,
+                result=sample.result,
+                reason=sample.reason)
+
+        attempt = 1
+        delay = 0
+        while attempt <= self.retries:
+            with self.__lock:
+                with self.__session() as session:
+                    try:
+                        session.execute(statement)
+                        session.commit()
+                        break
+                    except (OperationalError, DBAPIError,
+                            SQLAlchemyError) as error:
+                        session.rollback()
+
+                        attempt, delay = self.was_transient_error(
+                            error, attempt, 'updating analysis')
+
+                        if attempt < 0:
+                            raise PeekabooDatabaseError(
+                                'Failed to update analysis task in the database: %s' %
+                                error)
+
+            time.sleep(delay)
 
     def analysis_journal_fetch_journal(self, sample):
         """
@@ -280,17 +406,77 @@ def analysis_journal_fetch_journal(self, sample):
         @return: A sorted list of (analysis_time, result, reason) of the
                  requested sample.
         """
-        with self.__lock:
-            session = self.__session()
-            sample_journal = session.query(
-                SampleInfo.analysis_time, SampleInfo.result, SampleInfo.reason
-                ).filter_by(
-                    sha256sum=sample.sha256sum,
-                    file_extension=sample.file_extension
-                    ).order_by(SampleInfo.analysis_time).all()
-            session.close()
+        statement = sqlalchemy.sql.expression.select(
+            SampleInfo.analysis_time, SampleInfo.result,
+            SampleInfo.reason).where(
+                SampleInfo.id != sample.id).where(
+                    SampleInfo.result != Result.failed).filter_by(
+                        state=JobState.FINISHED,
+                        sha256sum=sample.sha256sum,
+                        file_extension=sample.file_extension).order_by(
+                            SampleInfo.analysis_time)
+
+        sample_journal = None
+        attempt = 1
+        delay = 0
+        while attempt <= self.retries:
+            with self.__session() as session:
+                try:
+                    sample_journal = session.execute(statement).all()
+                    break
+                except (OperationalError, DBAPIError,
+                        SQLAlchemyError) as error:
+                    session.rollback()
+
+                    attempt, delay = self.was_transient_error(
+                        error, attempt, 'fetching analysis journal')
+
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            'Failed to fetch analysis journal from the database: %s' %
+                            error)
+
+            time.sleep(delay)
+
         return sample_journal
 
+    async def analysis_retrieve(self, job_id):
+        """
+        Fetch information stored in the database about a given sample object.
+
+        @param job_id: ID of the analysis to retrieve
+        @type job_id: int
+        @return: reason and result for the given analysis task
+        """
+        statement = sqlalchemy.sql.expression.select(
+            SampleInfo.reason, SampleInfo.result).filter_by(
+                id=job_id, state=JobState.FINISHED)
+
+        result = None
+        attempt = 1
+        delay = 0
+        while attempt <= self.retries:
+            async with self.__async_session_factory() as session:
+                try:
+                    proxy = await session.execute(statement)
+                    result = proxy.first()
+                    break
+                except (OperationalError, DBAPIError,
+                        SQLAlchemyError) as error:
+                    await session.rollback()
+
+                    attempt, delay = self.was_transient_error(
+                        error, attempt, 'retrieving analysis result')
+
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            'Failed to retrieve analysis from the database: %s' %
+                            error)
+
+            await asyncio.sleep(delay)
+
+        return result
+
     def mark_sample_in_flight(self, sample, instance_id=None, start_time=None):
         """
         Mark a sample as in flight, i.e. being worked on by an instance.
@@ -313,45 +499,42 @@ def mark_sample_in_flight(self, sample, instance_id=None, start_time=None):
         if start_time is None:
             start_time = datetime.utcnow()
 
-        sha256sum = sample.sha256sum
-        in_flight_marker = InFlightSample(sha256sum=sha256sum,
+        in_flight_marker = InFlightSample(sha256sum=sample.sha256sum,
                                           instance_id=instance_id,
                                           start_time=start_time)
         attempt = 1
-        locked = False
+        delay = 0
         while attempt <= self.retries:
             # a new session needs to be constructed on each attempt
-            session = self.__session()
+            with self.__session() as session:
+                # try to mark this sample as in flight in an atomic insert
+                # operation (modulo possible deadlocks with various RDBMS)
+                session.add(in_flight_marker)
 
-            # try to mark this sample as in flight in an atomic insert
-            # operation (modulo possible deadlocks with various RDBMS)
-            session.add(in_flight_marker)
+                try:
+                    session.commit()
+                    logger.debug('%d: Marked sample in flight', sample.id)
+                    return True
+                # duplicate primary key == entry already exists
+                except IntegrityError:
+                    session.rollback()
+                    logger.debug('%d: Sample is already in flight on another '
+                                 'instance', sample.id)
+                    return False
+                except (OperationalError, DBAPIError,
+                        SQLAlchemyError) as error:
+                    session.rollback()
 
-            try:
-                session.commit()
-                locked = True
-                logger.debug('Marked sample %s as in flight', sha256sum)
-            # duplicate primary key == entry already exists
-            except IntegrityError:
-                session.rollback()
-                logger.debug('Sample %s is already in flight on another '
-                             'instance', sha256sum)
-            except (OperationalError, DBAPIError,
-                    SQLAlchemyError) as error:
-                session.rollback()
-
-                attempt = self.was_transient_error(
-                    error, attempt, 'marking sample %s as in flight' %
-                    sha256sum)
-                if attempt > 0:
-                    continue
+                    attempt, delay = self.was_transient_error(
+                        error, attempt, 'marking sample %d in flight' %
+                        sample.id)
 
-                raise PeekabooDatabaseError(
-                    'Unable to mark sample as in flight: %s' % error)
-            finally:
-                session.close()
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            '%d: Unable to mark sample as in flight: %s' % (
+                                sample.id, error))
 
-            return locked
+            time.sleep(delay)
 
         return False
 
@@ -372,49 +555,45 @@ def clear_sample_in_flight(self, sample, instance_id=None):
         if instance_id is None:
             instance_id = self.instance_id
 
-        sha256sum = sample.sha256sum
+        statement = sqlalchemy.sql.expression.delete(
+            InFlightSample).where(
+                InFlightSample.sha256sum == sample.sha256sum).where(
+                    InFlightSample.instance_id == instance_id)
 
         attempt = 1
+        cleared = 0
         while attempt <= self.retries:
-            session = self.__session()
+            with self.__session() as session:
+                try:
+                    # clear in-flight marker from database
+                    marker = session.execute(statement)
+                    session.commit()
+                    cleared = marker.rowcount
+                    break
+                except (OperationalError, DBAPIError,
+                        SQLAlchemyError) as error:
+                    session.rollback()
 
-            # clear in-flight marker from database
-            query = session.query(InFlightSample).filter(
-                InFlightSample.sha256sum == sha256sum).filter(
-                    InFlightSample.instance_id == instance_id)
+                    attempt, delay = self.was_transient_error(
+                        error, attempt, 'clearing in-flight status of '
+                        'sample %d' % sample.id)
 
-            try:
-                # delete() is not queued and goes to the DB before commit()
-                cleared = query.delete()
-                session.commit()
-            except (OperationalError, DBAPIError,
-                    SQLAlchemyError) as error:
-                session.rollback()
-
-                attempt = self.was_transient_error(
-                    error, attempt, 'clearing in-flight status of sample %s' %
-                    sha256sum)
-                if attempt > 0:
-                    continue
-
-                raise PeekabooDatabaseError('Unable to clear in-flight status '
-                                            'of sample: %s' % error)
-            finally:
-                session.close()
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            '%d: Unable to clear in-flight status of sample: '
+                            '%s' % (sample.id, error))
 
-            break
+            time.sleep(delay)
 
         if cleared == 0:
-            raise PeekabooDatabaseError('Unexpected inconsistency: Sample %s '
-                                        'not recoreded as in-flight upon '
-                                        'clearing flag.' % sha256sum)
+            raise PeekabooDatabaseError(
+                '%d: Unexpected inconsistency: Sample not recorded as '
+                'in-flight upon clearing flag.' % sample.id)
         elif cleared > 1:
-            raise PeekabooDatabaseError('Unexpected inconsistency: Multiple '
-                                        'instances of sample %s in-flight '
-                                        'status cleared against database '
-                                        'constraints!?' % sha256sum)
-
-        logger.debug('Cleared sample %s from in-flight list', sha256sum)
+            raise PeekabooDatabaseError(
+                '%d: Unexpected inconsistency: Multiple instances of sample '
+                'in-flight status cleared against database constraints!?' %
+                sample.id)
 
     def clear_in_flight_samples(self, instance_id=None):
         """
@@ -437,39 +616,39 @@ def clear_in_flight_samples(self, instance_id=None):
         if instance_id is None:
             instance_id = self.instance_id
 
+        if instance_id < 0:
+            # delete all locks
+            statement = sqlalchemy.sql.expression.delete(InFlightSample)
+            logger.debug('Clearing database of all in-flight samples.')
+        else:
+            # delete only the locks of a specific instance
+            statement = sqlalchemy.sql.expression.delete(
+                InFlightSample).where(
+                    InFlightSample.instance_id == instance_id)
+            logger.debug('Clearing database of all in-flight samples of '
+                         'instance %d.', instance_id)
+
         attempt = 1
         while attempt <= self.retries:
-            session = self.__session()
-
-            if instance_id < 0:
-                # delete all locks
-                query = session.query(InFlightSample)
-                logger.debug('Clearing database of all in-flight samples.')
-            else:
-                # delete only the locks of a specific instance
-                query = session.query(InFlightSample).filter(
-                    InFlightSample.instance_id == instance_id)
-                logger.debug('Clearing database of all in-flight samples of '
-                             'instance %d.', instance_id)
-            try:
-                # delete() is not queued and goes to the DB before commit()
-                query.delete()
-                session.commit()
-            except (OperationalError, DBAPIError,
-                    SQLAlchemyError) as error:
-                session.rollback()
-
-                attempt = self.was_transient_error(
-                    error, attempt, 'clearing database of in-flight samples')
-                if attempt > 0:
-                    continue
-
-                raise PeekabooDatabaseError('Unable to clear the database of '
-                                            'in-flight samples: %s' % error)
-            finally:
-                session.close()
+            with self.__session() as session:
+                try:
+                    session.execute(statement)
+                    session.commit()
+                    break
+                except (OperationalError, DBAPIError,
+                        SQLAlchemyError) as error:
+                    session.rollback()
 
-            break
+                    attempt, delay = self.was_transient_error(
+                        error, attempt,
+                        'clearing database of in-flight samples')
+
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            'Unable to clear the database of in-flight '
+                            'samples: %s' % error)
+
+            time.sleep(delay)
 
     def clear_stale_in_flight_samples(self):
         """
@@ -485,49 +664,54 @@ def clear_stale_in_flight_samples(self):
             'Clearing database of all stale in-flight samples '
             '(%d seconds)', self.stale_in_flight_threshold)
 
-        attempt = 1
-        while attempt <= self.retries:
-            session = self.__session()
-
+        def clear_statement(statement_class):
             # delete only the locks of a specific instance
-            query = session.query(InFlightSample).filter(
+            return statement_class(InFlightSample).where(
                 InFlightSample.start_time <= datetime.utcnow() - timedelta(
                     seconds=self.stale_in_flight_threshold))
-            try:
-                # the loop triggers the query, so only do it if debugging is
-                # enabled
-                if logger.isEnabledFor(logging.DEBUG):
-                    # obviously there's a race between logging and actual
-                    # delete here, use with caution, compare with actual number
-                    # of markers cleared below before relying on it for
-                    # debugging
-                    for stale in query:
-                        logger.debug(
-                            'Stale in-flight marker to clear: %s', stale)
-
-                # delete() is not queued and goes to the DB before commit()
-                cleared = query.delete()
-                session.commit()
-                if cleared > 0:
-                    logger.warning(
-                        '%d stale in-flight samples cleared.', cleared)
-            except (OperationalError, DBAPIError,
-                    SQLAlchemyError) as error:
-                session.rollback()
-
-                attempt = self.was_transient_error(
-                    error, attempt,
-                    'clearing the database of stale in-flight samples')
-                if attempt > 0:
-                    continue
 
-                raise PeekabooDatabaseError(
-                    'Unable to clear the database of stale in-flight '
-                    'samples: %s' % error)
-            finally:
-                session.close()
+        delete_statement = clear_statement(sqlalchemy.sql.expression.delete)
+        select_statement = clear_statement(sqlalchemy.sql.expression.select)
 
-            break
+        attempt = 1
+        cleared = 0
+        while attempt <= self.retries:
+            with self.__session() as session:
+                try:
+                    # only do the query if debugging is enabled
+                    if logger.isEnabledFor(logging.DEBUG):
+                        # obviously there's a race between logging and actual
+                        # delete here, use with caution, compare with actual
+                        # number of markers cleared below before relying on it
+                        # for debugging
+                        markers = session.execute(select_statement)
+                        for stale in markers:
+                            logger.debug(
+                                'Stale in-flight marker to clear: %s', stale)
+
+                    markers = session.execute(delete_statement)
+                    session.commit()
+
+                    cleared = markers.rowcount
+                    if cleared > 0:
+                        logger.warning(
+                            '%d stale in-flight samples cleared.', cleared)
+
+                    break
+                except (OperationalError, DBAPIError,
+                        SQLAlchemyError) as error:
+                    session.rollback()
+
+                    attempt, delay = self.was_transient_error(
+                        error, attempt,
+                        'clearing the database of stale in-flight samples')
+
+                    if attempt < 0:
+                        raise PeekabooDatabaseError(
+                            'Unable to clear the database of stale in-flight '
+                            'samples: %s' % error)
+
+            time.sleep(delay)
 
         return cleared > 0
 
diff --git a/peekaboo/locale/de/LC_MESSAGES/peekaboo.po b/peekaboo/locale/de/LC_MESSAGES/peekaboo.po
index 8b5e1226..f0167e7e 100644
--- a/peekaboo/locale/de/LC_MESSAGES/peekaboo.po
+++ b/peekaboo/locale/de/LC_MESSAGES/peekaboo.po
@@ -6,7 +6,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PeekabooAV 1.6.2\n"
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2020-01-17 15:38+0000\n"
+"POT-Creation-Date: 2022-01-05 09:46+0000\n"
 "PO-Revision-Date: 2019-02-14 22:02+0000\n"
 "Last-Translator: Michael Weiser <michael.weiser@gmx.de>\n"
 "Language: de\n"
@@ -15,146 +15,85 @@ msgstr ""
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=utf-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.4.0\n"
+"Generated-By: Babel 2.9.1\n"
 
-#: peekaboo/queuing.py:381
-msgid "Sample initialization failed"
-msgstr "Initialisierung der zu analysierenden Datei fehlgeschlagen"
-
-#: peekaboo/sample.py:185
-#, python-format
-msgid "File \"%s\" %s is being analyzed"
-msgstr "Datei \"%s\" %s wird analysiert"
-
-#: peekaboo/sample.py:238
+#: peekaboo/sample.py:146
 #, python-format
 msgid "File \"%s\" is considered \"%s\""
 msgstr "Die Datei \"%s\" wird als \"%s\" betrachtet"
 
-#: peekaboo/sample.py:298
-#, python-format
-msgid "File \"%s\": %s"
-msgstr "Datei \"%s\": %s"
-
-#: peekaboo/sample.py:478
-#, python-format
-msgid "Sample %s successfully submitted to Cuckoo as job %d"
-msgstr "Erfolgreich an Cuckoo gegeben %s als Job %d"
-
-#: peekaboo/server.py:141
-msgid "Hello, this is Peekaboo."
-msgstr "Hallo das ist Peekaboo"
-
-#: peekaboo/server.py:161
-msgid "ERROR: Path does not exist or no permission to access it."
-msgstr "FEHLER: Pfad existiert nicht oder Zugriff verweigert."
-
-#: peekaboo/server.py:168
-msgid "ERRROR: Input is not a file"
-msgstr "FEHLER: Eingabe ist keine Datei"
-
-#: peekaboo/server.py:175
-msgid "Error submitting sample to job queue"
-msgstr "Fehler beim Einstellen in Warteschlange"
-
-#: peekaboo/server.py:198
-#, fuzzy
-msgid "Error: Invalid JSON in request."
-msgstr "FEHLER: Ungültiges JSON."
-
-#: peekaboo/server.py:203
-msgid "ERROR: Invalid data structure."
-msgstr "FEHLER: Ungültiges Datenformat."
-
-#: peekaboo/server.py:214
-msgid "ERROR: Incomplete data structure."
-msgstr "FEHLER: Unvollständige Datenstruktur."
-
-#: peekaboo/server.py:242
-msgid "Files are being analyzed..."
-msgstr "Dateien werden analysiert..."
-
-#: peekaboo/server.py:265
-msgid "Peekaboo is shutting down."
-msgstr "Peekaboo wird beendet."
-
-#: peekaboo/server.py:317
-#, python-format
-msgid "The file collection has been categorized \"%s\""
-msgstr "Die Datensammlung wurde als \"%s\" eingestuft"
-
-#: peekaboo/ruleset/__init__.py:64
+#: peekaboo/ruleset/__init__.py:79
 msgid "Rule without result"
 msgstr "Regel ohne Ergebnis"
 
-#: peekaboo/ruleset/__init__.py:68
+#: peekaboo/ruleset/__init__.py:83
 #, python-format
 msgid "Result \"%s\" of rule %s - %s, analysis continues: %s."
 msgstr "Ergebnis \"%s\" der Regel %s - %s, Analyse wird fortgesetzt: %s."
 
-#: peekaboo/ruleset/__init__.py:70
+#: peekaboo/ruleset/__init__.py:85
 msgid "Yes"
 msgstr "Ja"
 
-#: peekaboo/ruleset/__init__.py:70
+#: peekaboo/ruleset/__init__.py:85
 msgid "No"
 msgstr "Nein"
 
-#: peekaboo/ruleset/engine.py:131
+#: peekaboo/ruleset/engine.py:206
 msgid "Rule aborted with error"
 msgstr "Regel mit Fehler abgebrochen"
 
-#: peekaboo/ruleset/rules.py:157
+#: peekaboo/ruleset/rules.py:251
 msgid "File is not yet known to the system"
 msgstr "Datei ist dem System noch nicht bekannt"
 
-#: peekaboo/ruleset/rules.py:178
+#: peekaboo/ruleset/rules.py:272
 #, python-format
 msgid "Failure to determine sample file size: %s"
 msgstr "Ermittlung der Dateigröße fehlgeschlagen: %s"
 
-#: peekaboo/ruleset/rules.py:183
+#: peekaboo/ruleset/rules.py:277
 #, python-format
 msgid "File has more than %d bytes"
 msgstr "Datei hat mehr als %d bytes"
 
-#: peekaboo/ruleset/rules.py:189
+#: peekaboo/ruleset/rules.py:283
 #, python-format
 msgid "File is only %d bytes long"
 msgstr "Die Datei ist nur %d bytes groß"
 
-#: peekaboo/ruleset/rules.py:211
+#: peekaboo/ruleset/rules.py:309
 msgid "File type is on whitelist"
 msgstr "Dateityp ist auf Whitelist"
 
-#: peekaboo/ruleset/rules.py:215
+#: peekaboo/ruleset/rules.py:313
 msgid "File type is not on whitelist"
 msgstr "Dateityp ist nicht auf Whitelist"
 
-#: peekaboo/ruleset/rules.py:237
+#: peekaboo/ruleset/rules.py:339
 msgid "File type is on the list of types to analyze"
 msgstr "Dateityp ist auf der Liste der zu analysiserenden Typen"
 
-#: peekaboo/ruleset/rules.py:242
+#: peekaboo/ruleset/rules.py:344
 #, python-format
 msgid "File type is not on the list of types to analyse (%s)"
 msgstr "Dateityp ist nicht auf der Liste der zu analysierenden Typen (%s)"
 
-#: peekaboo/ruleset/rules.py:271
+#: peekaboo/ruleset/rules.py:373
 msgid "The file contains an Office macro"
 msgstr "Die Datei beinhaltet ein Office-Makro"
 
-#: peekaboo/ruleset/rules.py:275
+#: peekaboo/ruleset/rules.py:377
 msgid "The file does not contain a recognizable Office macro"
 msgstr "Die Datei beinhaltet kein erkennbares Office-Makro"
 
-#: peekaboo/ruleset/rules.py:296
+#: peekaboo/ruleset/rules.py:398
 msgid "The file contains an Office macro which runs at document open"
 msgstr ""
 "Die Datei beinhaltet ein Office Makro welches beim Öffnen der Datei "
 "ausgeführt wird"
 
-#: peekaboo/ruleset/rules.py:301
+#: peekaboo/ruleset/rules.py:403
 msgid ""
 "The file does not contain a recognizable Office macro that is run at "
 "document open"
@@ -162,7 +101,7 @@ msgstr ""
 "Die Datei beinhaltet kein erkennbares Office Makro welches beim Öffnen "
 "ausgeführt wird"
 
-#: peekaboo/ruleset/rules.py:327 peekaboo/ruleset/rules.py:462
+#: peekaboo/ruleset/rules.py:431 peekaboo/ruleset/rules.py:565
 msgid ""
 "Behavioral analysis by Cuckoo has produced an error and did not finish "
 "successfully"
@@ -170,58 +109,67 @@ msgstr ""
 "Die Verhaltensanalyse durch Cuckoo hat einen Fehler produziert und konnte"
 " nicht erfolgreich abgeschlossen werden"
 
-#: peekaboo/ruleset/rules.py:382
+#: peekaboo/ruleset/rules.py:485
 msgid "No signature suggesting malware detected"
 msgstr "Keine Signatur erkannt die auf Schadcode hindeutet"
 
-#: peekaboo/ruleset/rules.py:387
+#: peekaboo/ruleset/rules.py:490
 #, python-format
 msgid "The following signatures have been recognized: %s"
 msgstr "Folgende Signaturen wurden erkannt: %s"
 
-#: peekaboo/ruleset/rules.py:406
+#: peekaboo/ruleset/rules.py:509
 #, python-format
 msgid "Cuckoo score >= %s: %s"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:411
+#: peekaboo/ruleset/rules.py:514
 #, python-format
 msgid "Cuckoo score < %s: %s"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:435
+#: peekaboo/ruleset/rules.py:538
 #, python-format
 msgid "The file attempts to contact at least one domain on the blacklist (%s)"
 msgstr ""
 "Die Datei versucht mindestens eine Domain aus der Blacklist zu "
 "kontaktieren (%s)"
 
-#: peekaboo/ruleset/rules.py:441
+#: peekaboo/ruleset/rules.py:544
 msgid "File does not seem to attempt contact with domains on the blacklist"
 msgstr "Datei scheint keine Domains aus der Blacklist kontaktieren zu wollen"
 
-#: peekaboo/ruleset/rules.py:478
+#: peekaboo/ruleset/rules.py:581
 msgid "Behavioral analysis by Cuckoo completed successfully"
 msgstr "Die Verhaltensanalyse durch Cuckoo wurde erfolgreich abgeschlossen"
 
-#: peekaboo/ruleset/rules.py:538
+#: peekaboo/ruleset/rules.py:697
 msgid "Evaluation of expression couldn't get cuckoo report."
 msgstr "Auswertung des Ausdrucks konnte Cuckoo Report nicht bekommen."
 
-#: peekaboo/ruleset/rules.py:548
+#: peekaboo/ruleset/rules.py:715
+#, fuzzy
+msgid "Evaluation of expression couldn't get Cortex report."
+msgstr "Auswertung des Ausdrucks konnte Cortex Report nicht bekommen."
+
+#: peekaboo/ruleset/rules.py:722
 msgid "Evaluation of expression uses undefined identifier."
 msgstr "Auswertung des Ausdrucks nutzt nicht definierten Bezeichner."
 
-#: peekaboo/ruleset/rules.py:558
+#: peekaboo/ruleset/rules.py:758
+#, fuzzy
+msgid "Evaluation of expression failed to submit Cortex analysis."
+msgstr "Auswertung des Ausdrucks konnte keine Cortex-Analyse auslösen."
+
+#: peekaboo/ruleset/rules.py:777
 #, python-format
 msgid "The expression (%d) classified the sample as %s"
 msgstr "Der Ausdruck (%d) klassifizierte die Datei als %s"
 
-#: peekaboo/ruleset/rules.py:564
+#: peekaboo/ruleset/rules.py:783
 msgid "No expression classified the sample in any way."
 msgstr "Kein Ausdruck klassifizierte die Datei in irgendeiner Weise."
 
-#: peekaboo/ruleset/rules.py:576
+#: peekaboo/ruleset/rules.py:795
 msgid "File does not seem to exhibit recognizable malicious behaviour"
 msgstr "Datei scheint keine erkennbaren Schadroutinen zu starten"
-
diff --git a/peekaboo/locale/peekaboo.pot b/peekaboo/locale/peekaboo.pot
index 0dd967ff..b71a2ba3 100644
--- a/peekaboo/locale/peekaboo.pot
+++ b/peekaboo/locale/peekaboo.pot
@@ -1,218 +1,166 @@
 # Translations template for PROJECT.
-# Copyright (C) 2020 ORGANIZATION
+# Copyright (C) 2022 ORGANIZATION
 # This file is distributed under the same license as the PROJECT project.
-# FIRST AUTHOR <EMAIL@ADDRESS>, 2020.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2022.
 #
 #, fuzzy
 msgid ""
 msgstr ""
 "Project-Id-Version: PROJECT VERSION\n"
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2020-01-17 15:38+0000\n"
+"POT-Creation-Date: 2022-01-05 09:46+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=utf-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.4.0\n"
+"Generated-By: Babel 2.9.1\n"
 
-#: peekaboo/queuing.py:381
-msgid "Sample initialization failed"
-msgstr ""
-
-#: peekaboo/sample.py:185
-#, python-format
-msgid "File \"%s\" %s is being analyzed"
-msgstr ""
-
-#: peekaboo/sample.py:238
+#: peekaboo/sample.py:146
 #, python-format
 msgid "File \"%s\" is considered \"%s\""
 msgstr ""
 
-#: peekaboo/sample.py:298
-#, python-format
-msgid "File \"%s\": %s"
-msgstr ""
-
-#: peekaboo/sample.py:478
-#, python-format
-msgid "Sample %s successfully submitted to Cuckoo as job %d"
-msgstr ""
-
-#: peekaboo/server.py:141
-msgid "Hello, this is Peekaboo."
-msgstr ""
-
-#: peekaboo/server.py:161
-msgid "ERROR: Path does not exist or no permission to access it."
-msgstr ""
-
-#: peekaboo/server.py:168
-msgid "ERRROR: Input is not a file"
-msgstr ""
-
-#: peekaboo/server.py:175
-msgid "Error submitting sample to job queue"
-msgstr ""
-
-#: peekaboo/server.py:198
-msgid "Error: Invalid JSON in request."
-msgstr ""
-
-#: peekaboo/server.py:203
-msgid "ERROR: Invalid data structure."
-msgstr ""
-
-#: peekaboo/server.py:214
-msgid "ERROR: Incomplete data structure."
-msgstr ""
-
-#: peekaboo/server.py:242
-msgid "Files are being analyzed..."
-msgstr ""
-
-#: peekaboo/server.py:265
-msgid "Peekaboo is shutting down."
-msgstr ""
-
-#: peekaboo/server.py:317
-#, python-format
-msgid "The file collection has been categorized \"%s\""
-msgstr ""
-
-#: peekaboo/ruleset/__init__.py:64
+#: peekaboo/ruleset/__init__.py:79
 msgid "Rule without result"
 msgstr ""
 
-#: peekaboo/ruleset/__init__.py:68
+#: peekaboo/ruleset/__init__.py:83
 #, python-format
 msgid "Result \"%s\" of rule %s - %s, analysis continues: %s."
 msgstr ""
 
-#: peekaboo/ruleset/__init__.py:70
+#: peekaboo/ruleset/__init__.py:85
 msgid "Yes"
 msgstr ""
 
-#: peekaboo/ruleset/__init__.py:70
+#: peekaboo/ruleset/__init__.py:85
 msgid "No"
 msgstr ""
 
-#: peekaboo/ruleset/engine.py:131
+#: peekaboo/ruleset/engine.py:206
 msgid "Rule aborted with error"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:157
+#: peekaboo/ruleset/rules.py:251
 msgid "File is not yet known to the system"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:178
+#: peekaboo/ruleset/rules.py:272
 #, python-format
 msgid "Failure to determine sample file size: %s"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:183
+#: peekaboo/ruleset/rules.py:277
 #, python-format
 msgid "File has more than %d bytes"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:189
+#: peekaboo/ruleset/rules.py:283
 #, python-format
 msgid "File is only %d bytes long"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:211
+#: peekaboo/ruleset/rules.py:309
 msgid "File type is on whitelist"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:215
+#: peekaboo/ruleset/rules.py:313
 msgid "File type is not on whitelist"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:237
+#: peekaboo/ruleset/rules.py:339
 msgid "File type is on the list of types to analyze"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:242
+#: peekaboo/ruleset/rules.py:344
 #, python-format
 msgid "File type is not on the list of types to analyse (%s)"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:271
+#: peekaboo/ruleset/rules.py:373
 msgid "The file contains an Office macro"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:275
+#: peekaboo/ruleset/rules.py:377
 msgid "The file does not contain a recognizable Office macro"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:296
+#: peekaboo/ruleset/rules.py:398
 msgid "The file contains an Office macro which runs at document open"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:301
+#: peekaboo/ruleset/rules.py:403
 msgid ""
 "The file does not contain a recognizable Office macro that is run at "
 "document open"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:327 peekaboo/ruleset/rules.py:462
+#: peekaboo/ruleset/rules.py:431 peekaboo/ruleset/rules.py:565
 msgid ""
 "Behavioral analysis by Cuckoo has produced an error and did not finish "
 "successfully"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:382
+#: peekaboo/ruleset/rules.py:485
 msgid "No signature suggesting malware detected"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:387
+#: peekaboo/ruleset/rules.py:490
 #, python-format
 msgid "The following signatures have been recognized: %s"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:406
+#: peekaboo/ruleset/rules.py:509
 #, python-format
 msgid "Cuckoo score >= %s: %s"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:411
+#: peekaboo/ruleset/rules.py:514
 #, python-format
 msgid "Cuckoo score < %s: %s"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:435
+#: peekaboo/ruleset/rules.py:538
 #, python-format
 msgid "The file attempts to contact at least one domain on the blacklist (%s)"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:441
+#: peekaboo/ruleset/rules.py:544
 msgid "File does not seem to attempt contact with domains on the blacklist"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:478
+#: peekaboo/ruleset/rules.py:581
 msgid "Behavioral analysis by Cuckoo completed successfully"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:538
+#: peekaboo/ruleset/rules.py:697
 msgid "Evaluation of expression couldn't get cuckoo report."
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:548
+#: peekaboo/ruleset/rules.py:715
+msgid "Evaluation of expression couldn't get Cortex report."
+msgstr ""
+
+#: peekaboo/ruleset/rules.py:722
 msgid "Evaluation of expression uses undefined identifier."
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:558
+#: peekaboo/ruleset/rules.py:758
+msgid "Evaluation of expression failed to submit Cortex analysis."
+msgstr ""
+
+#: peekaboo/ruleset/rules.py:777
 #, python-format
 msgid "The expression (%d) classified the sample as %s"
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:564
+#: peekaboo/ruleset/rules.py:783
 msgid "No expression classified the sample in any way."
 msgstr ""
 
-#: peekaboo/ruleset/rules.py:576
+#: peekaboo/ruleset/rules.py:795
 msgid "File does not seem to exhibit recognizable malicious behaviour"
 msgstr ""
 
diff --git a/peekaboo/queuing.py b/peekaboo/queuing.py
index bffc5fa2..6b0db4b1 100644
--- a/peekaboo/queuing.py
+++ b/peekaboo/queuing.py
@@ -134,19 +134,16 @@ def start(self):
             self.close_down()
             raise PeekabooConfigException(error)
 
-    def submit(self, sample, submitter):
+    def submit(self, sample):
         """
         Adds a Sample object to the job queue.
         If the queue is full, we block for 300 seconds and then throw an
         exception.
 
         @param sample: The Sample object to add to the queue.
-        @param submitter: The name of the class / module that wants to submit
-                          the sample.
         @raises Full: if the queue is full.
         """
         sample_hash = sample.sha256sum
-        sample_str = "%s" % sample
         duplicate = None
         cluster_duplicate = None
         resubmit = None
@@ -161,12 +158,12 @@ def submit(self, sample, submitter):
                 # obviously isn't a duplicate but continued processing of the
                 # same sample.
                 if duplicates['master'] == sample:
-                    resubmit = sample_str
+                    resubmit = sample.id
                     self.jobs.put(sample, True, self.queue_timeout)
                 else:
                     # record the to-be-submitted sample as duplicate and do
                     # nothing
-                    duplicate = sample_str
+                    duplicate = sample.id
                     duplicates['duplicates'].append(sample)
             else:
                 # are we the first of potentially multiple instances working on
@@ -190,25 +187,21 @@ def submit(self, sample, submitter):
                     if self.cluster_duplicates.get(sample_hash) is None:
                         self.cluster_duplicates[sample_hash] = []
 
-                    cluster_duplicate = sample_str
+                    cluster_duplicate = sample.id
                     self.cluster_duplicates[sample_hash].append(sample)
 
-        if duplicate:
+        if duplicate is not None:
             logger.debug(
-                "Sample from %s is duplicate and waiting for running "
-                "analysis to finish: %s", submitter, duplicate)
-        elif cluster_duplicate:
+                "%d: Sample is duplicate and waiting for running analysis "
+                "to finish", duplicate)
+        elif cluster_duplicate is not None:
             logger.debug(
-                "Sample from %s is concurrently processed by another "
-                "instance and held: %s", submitter, cluster_duplicate)
-        elif resubmit:
-            logger.debug(
-                "Resubmitted sample to job queue for %s: %s", submitter,
-                resubmit)
+                "%d: Sample is concurrently processed by another instance "
+                "and held", cluster_duplicate)
+        elif resubmit is not None:
+            logger.debug("%d: Resubmitted sample to job queue", resubmit)
         else:
-            logger.debug(
-                "New sample submitted to job queue by %s. %s", submitter,
-                sample_str)
+            logger.debug("%d: New sample submitted to job queue", sample.id)
 
         return True
 
@@ -237,12 +230,11 @@ def submit_cluster_duplicates(self):
                     return False
 
                 if locked:
-                    sample_str = "%s" % sample_duplicates[0]
                     if self.duplicates.get(sample_hash) is not None:
                         logger.error(
-                            "Possible backlog corruption for sample %s! "
+                            "Possible backlog corruption for sample %d! "
                             "Please file a bug report. Trying to continue...",
-                            sample_str)
+                            sample.id)
                         continue
 
                     # submit one of the held-back samples as a new master
@@ -256,7 +248,7 @@ def submit_cluster_duplicates(self):
                         'master': sample,
                         'duplicates': sample_duplicates,
                     }
-                    submitted_cluster_duplicates.append(sample_str)
+                    submitted_cluster_duplicates.append(sample.id)
                     self.jobs.put(sample, True, self.queue_timeout)
                     del self.cluster_duplicates[sample_hash]
 
@@ -297,7 +289,7 @@ def submit_duplicates(self, sample_hash):
 
             # submit all samples which have accumulated in the backlog
             for sample in self.duplicates[sample_hash]['duplicates']:
-                submitted_duplicates.append("%s" % sample)
+                submitted_duplicates.append(sample.id)
                 self.jobs.put(sample, True, self.queue_timeout)
 
             sample = self.duplicates[sample_hash]['master']
@@ -306,10 +298,9 @@ def submit_duplicates(self, sample_hash):
             except PeekabooDatabaseError as dberr:
                 logger.error(dberr)
 
-            sample_str = "%s" % sample
             del self.duplicates[sample_hash]
 
-        logger.debug("Cleared sample %s from in-flight list", sample_str)
+        logger.debug("%d: Cleared sample from in-flight list", sample.id)
         if len(submitted_duplicates) > 0:
             logger.debug(
                 "Submitted duplicates from backlog: %s", submitted_duplicates)
@@ -322,10 +313,6 @@ def done(self, sample):
         @param sample: The Sample object to post-process. """
         self.submit_duplicates(sample.sha256sum)
 
-        # now that this sample is really done and cleared from the queue, tell
-        # its connection handler about it
-        sample.mark_done()
-
     def dequeue(self):
         """ Remove a sample from the queue. Used by the workers to get their
         work. Blocks indefinitely until some work is available. If we want to
@@ -441,8 +428,8 @@ def run(self):
                 # we just got pinged
                 continue
 
-            logger.info('Worker %d: Processing sample %s',
-                        self.worker_id, sample)
+            logger.info('%d: Worker %d: Processing sample',
+                        sample.id, self.worker_id)
 
             # The following used to be one big try/except block catching any
             # exception. This got complicated because in the case of
@@ -452,37 +439,25 @@ def run(self):
             # because of uncaught exceptions we should improve error handling
             # in the subroutines causing it.
 
-            if not sample.init():
-                logger.error('Sample initialization failed')
-                sample.add_rule_result(
-                    RuleResult(
-                        "Worker", result=Result.failed,
-                        reason=_("Sample initialization failed"),
-                        further_analysis=False))
-                self.job_queue.done(sample.sha256sum)
-                continue
-
             try:
                 self.ruleset_engine.run(sample)
             except PeekabooAnalysisDeferred:
-                logger.debug("Report for sample %s still pending", sample)
+                logger.debug('%d: Report still pending', sample.id)
                 continue
 
             if sample.result >= Result.failed:
                 sample.dump_processing_info()
 
-            if sample.result != Result.failed:
-                logger.debug('Saving results to database')
-                try:
-                    self.db_con.analysis_save(sample)
-                except PeekabooDatabaseError as dberr:
-                    logger.error('Failed to save analysis result to '
-                                 'database: %s', dberr)
-                    # no showstopper, we can limp on without caching in DB
-            else:
-                logger.debug('Not saving results of failed analysis')
+            sample.mark_done()
+
+            logger.debug('%d: Saving results to database', sample.id)
+            try:
+                self.db_con.analysis_update(sample)
+            except PeekabooDatabaseError as dberr:
+                logger.error('%d: Failed to save analysis result to '
+                             'database: %s', sample.id, dberr)
+                # no showstopper, we can limp on without caching in DB
 
-            sample.cleanup()
             self.job_queue.done(sample)
 
         logger.info('Worker %d: Stopped', self.worker_id)
diff --git a/peekaboo/ruleset/engine.py b/peekaboo/ruleset/engine.py
index 47ad33ae..c7f4549d 100644
--- a/peekaboo/ruleset/engine.py
+++ b/peekaboo/ruleset/engine.py
@@ -188,7 +188,7 @@ def run(self, sample):
         @returns: Nothing, all state is recorded in the sample """
         for rule in self.rules:
             rule_name = rule.rule_name
-            logger.debug("Processing rule '%s' for %s", rule_name, sample)
+            logger.debug("%d: Processing rule '%s'", sample.id, rule_name)
 
             try:
                 result = rule.evaluate(sample)
@@ -198,8 +198,8 @@ def run(self, sample):
                 raise
             # catch all other exceptions for this rule
             except Exception as error:
-                logger.warning("Unexpected error in '%s' for %s", rule_name,
-                               sample)
+                logger.warning(
+                    "%d: Unexpected error in '%s'", sample.id, rule_name)
                 logger.exception(error)
                 # create "fake" RuleResult
                 result = RuleResult("RulesetEngine", result=Result.failed,
@@ -207,11 +207,11 @@ def run(self, sample):
                                     further_analysis=False)
                 sample.add_rule_result(result)
 
-            logger.info("Rule '%s' processed for %s", rule_name, sample)
+            logger.info("%d: Rule '%s' processed", sample.id, rule_name)
             if not result.further_analysis:
                 return
 
-        logger.info("Rules evaluated")
+        logger.info("%d: Rules evaluated", sample.id)
 
     def shut_down_resources(self):
         """ Shut down dynamically allocated resources such as job trackers.
diff --git a/peekaboo/ruleset/rules.py b/peekaboo/ruleset/rules.py
index bfd8a79c..f3700583 100644
--- a/peekaboo/ruleset/rules.py
+++ b/peekaboo/ruleset/rules.py
@@ -155,15 +155,15 @@ def get_cuckoo_report(self, sample):
         if report is not None:
             return report
 
-        logger.debug("Submitting %s to Cuckoo", sample.submit_path)
+        logger.debug("%d: Submitting to Cuckoo", sample.id)
         try:
             job_id = self.cuckoo.submit(sample)
         except CuckooSubmitFailedException as failed:
-            logger.error("Submit to Cuckoo failed: %s", failed)
+            logger.error("%d: Submit to Cuckoo failed: %s", sample.id, failed)
             return None
 
-        logger.info('Sample submitted to Cuckoo. Job ID: %s. '
-                    'Sample: %s', job_id, sample)
+        logger.info("%d: Sample submitted to Cuckoo. Job ID: %s",
+                    sample.id, job_id)
         raise PeekabooAnalysisDeferred()
 
     def get_oletools_report(self, sample):
@@ -221,15 +221,15 @@ def submit_to_cortex(self, sample, analyzer):
                                           ruleset run until result has been
                                           retrieved.
         """
-        logger.debug("Submitting %s to Cortex", sample.submit_path)
+        logger.debug("%d: Submitting to Cortex", sample.id)
         try:
             job_id = self.cortex.submit(sample, analyzer)
         except CortexSubmitFailedException as failed:
-            logger.error("Submit to Cortex failed: %s", failed)
+            logger.error("%d: Submit to Cortex failed: %s", sample.id, failed)
             return None
 
-        logger.info('Sample submitted to Cortex. Job ID: %s. '
-                    'Sample: %s', job_id, sample)
+        logger.info("%d: Sample submitted to Cortex. Job ID: %s",
+                    sample.id, job_id)
         raise PeekabooAnalysisDeferred()
 
 
@@ -485,7 +485,7 @@ def evaluate_report(self, report):
                                _("No signature suggesting malware detected"),
                                True)
 
-        matched = ''.ljust(8).join(["%s\n" % s for s in matched_bad_sigs])
+        matched = ", ".join(matched_bad_sigs)
         return self.result(Result.bad,
                            _("The following signatures have been recognized: "
                              "%s") % matched,
@@ -613,7 +613,7 @@ def get_config(self):
         # context of dummy objects to test expressions against
         context = {
             'variables': {
-                'sample': Sample("dummy"),
+                'sample': Sample(b"dummy"),
                 'cuckooreport': CuckooReport(),
                 'olereport': OletoolsReport(),
                 'filereport': FiletoolsReport(),
diff --git a/peekaboo/sample.py b/peekaboo/sample.py
index 933b0d35..b9236225 100644
--- a/peekaboo/sample.py
+++ b/peekaboo/sample.py
@@ -23,6 +23,8 @@
 ###############################################################################
 
 
+import datetime
+import enum
 import os
 import hashlib
 import json
@@ -35,7 +37,6 @@
 # python 3's open with encoding parameter and implicit usage of the system
 # locale-specified encoding
 from builtins import open
-from datetime import datetime
 from peekaboo.ruleset import Result
 
 
@@ -47,22 +48,25 @@ class SampleFactory:
     Contains all the global configuration data and object references each
     sample needs and thus serves as a registry of potential API breakage
     perhaps deserving looking into. """
-    def __init__(self, base_dir, job_hash_regex,
-                 keep_mail_data, processing_info_dir):
+    def __init__(self, processing_info_dir):
         # configuration
-        self.base_dir = base_dir
-        self.job_hash_regex = job_hash_regex
-        self.keep_mail_data = keep_mail_data
         self.processing_info_dir = processing_info_dir
 
-    def make_sample(self, file_path, status_change=None, metainfo=None):
+    def make_sample(self, content, name=None, content_type=None,
+                    content_disposition=None):
         """ Create a new Sample object based on the factory's configured
         defaults and variable parameters. """
-        return Sample(file_path, status_change, metainfo,
-                      self.base_dir, self.job_hash_regex, self.keep_mail_data,
+        return Sample(content, name, content_type, content_disposition,
                       self.processing_info_dir)
 
 
+@enum.unique
+class JobState(enum.Enum):
+    """ Enumeration of states a job processing some sample can be in. """
+    ACCEPTED = 1
+    FINISHED = 2
+
+
 class Sample:
     """
     This class handles and describes samples to be analysed by Peekaboo.
@@ -72,15 +76,18 @@ class Sample:
     on first access, especially if that determination is somewhat expensive
     such as the file checksum.
     """
-    def __init__(self, file_path, status_change=None,
-                 metainfo=None, base_dir=None, job_hash_regex=None,
-                 keep_mail_data=False, processing_info_dir=None):
-        self.__path = file_path
-        self.__wd = None
-        self.__filename = os.path.basename(self.__path)
-        # A symlink that points to the actual file named
-        # sha256sum.suffix
-        self.__submit_path = None
+    def __init__(self, content, filename=None, content_type=None,
+                 content_disposition=None,
+                 processing_info_dir=None, job_id=None):
+        # we do neither need nor accept for path traversal attack avoidance
+        # full paths
+        if filename is not None:
+            filename = os.path.basename(filename)
+
+        self.__content = content
+        self.__filename = filename
+        self.__content_type = content_type
+        self.__content_disposition = content_disposition
         self.__cuckoo_failed = False
         self.__cuckoo_report = None
         self.__cortex_failed = False
@@ -88,125 +95,27 @@ def __init__(self, file_path, status_change=None,
         self.__oletools_report = None
         self.__filetools_report = None
         self.__knowntools_report = None
-        self.__done = False
-        self.__status_change = status_change
+        self.__id = job_id
+        self.__state = JobState.ACCEPTED
         self.__result = Result.unchecked
         self.__reason = None
-        self.__report = []  # Peekaboo's own report
+        self.__report = []
         self.__internal_report = []
-        self.__file_stat = None
         self.__sha256sum = None
         self.__file_extension = None
-        self.__base_dir = base_dir
-        self.__job_hash = None
-        self.__job_hash_regex = job_hash_regex
-        self.__keep_mail_data = keep_mail_data
         self.__processing_info_dir = processing_info_dir
 
-        # Additional attributes for a sample object (i.e. meta info)
-        # We do not make these private for the following reasons:
-        # - this way they still somewhat resemble the previous arbitrary
-        #   attribute dictionary idea
-        # - we'd have to implement the name mangling for setting below
-        # Even though, it is not recommended to access them directly they're an
-        # implementation detail. We add respective properties for that.
-        #
-        # Security: Add more below to allow them to be accepted from the
-        # client. We don't want anyone to be able to pollute our sample
-        # objects from the outside. This also serves as a registry of what we
-        # actually use and know how to deal with.
-        self.meta_info_name_declared = None
-        self.meta_info_type_declared = None
-        self.meta_info_content_disposition = None
-
-        self.initialized = False
-
-        if metainfo:
-            member_variables = vars(self)
-            for field in metainfo:
-                logger.debug('meta_info_%s = %s', field, metainfo[field])
-
-                # JSON will transfer null/None values but we don't want them as
-                # attributes in that case
-                member = 'meta_info_%s' % field
-                if member in member_variables and metainfo[field] is not None:
-                    member_variables[member] = metainfo[field]
-
-    def init(self):
-        """
-        Initialize the Sample object.
-
-        The actual initialization is done here, because the main thread should
-        not do the heavy lifting of e. g. parsing the meta info file to be able
-        to accept new connections as quickly as possible.
-        Instead, it only adds the sample objects to the queue and the workers
-        to the actual initialization.
-        """
-        if self.initialized:
-            return True
-
-        logger.debug("initializing sample")
-
-        # create a temporary directory where mkdtemp makes sure that
-        # creation is atomic, i.e. no other process is using it
-        try:
-            self.__wd = tempfile.mkdtemp(
-                prefix=self.job_hash, dir=self.__base_dir)
-        except OSError as oserr:
-            logger.error('Error creating working directory: %s', oserr)
-            return False
-
-        logger.debug('Working directory %s created', self.__wd)
-
-        # create a symlink to submit the file with the correct file extension
-        # to cuckoo via submit.py. This is so we do not leak the original
-        # filename by default.
-        submit_name = self.sha256sum
-        if self.file_extension:
-            submit_name = '%s.%s' % (submit_name, self.file_extension)
-
-        self.__submit_path = os.path.join(self.__wd, submit_name)
-
-        try:
-            os.symlink(self.__path, self.__submit_path)
-        except OSError as oserr:
-            logger.error('Error linking sample from %s to working '
-                         'directory as %s',
-                         self.__path, self.__submit_path)
-            self.cleanup()
-            return False
-
-        logger.debug('Sample symlinked from %s to %s',
-                     self.__path, self.__submit_path)
-
-        self.initialized = True
-
-        self.__report.append(_("File \"%s\" %s is being analyzed")
-                             % (self.__filename, self.sha256sum))
-
-        # log some additional info to report to aid debugging
-        if self.meta_info_name_declared:
-            self.__internal_report.append("meta info: name_declared: %s"
-                                          % self.meta_info_name_declared)
-
-        if self.meta_info_type_declared:
-            self.__internal_report.append("meta info: type_declared: %s"
-                                          % self.meta_info_type_declared)
-
-        return True
-
-    @property
-    def file_path(self):
-        """ Returns the path to the sample given on creation including
-        directories and filename. """
-        return self.__path
-
     @property
     def filename(self):
         """ Returns the name of the sample file, i.e. the basename without path
         but including the file extension. """
         return self.__filename
 
+    @property
+    def content(self):
+        """ Returns the content of the sample file. """
+        return self.__content
+
     @property
     def result(self):
         """ Returns the overall evaluation result for this sample.
@@ -238,66 +147,35 @@ def peekaboo_report(self):
                                 % (self.__filename, self.__result.name)]
 
     @property
-    def done(self):
+    def id(self):
+        """ Return the (job) ID of this sample. """
+        return self.__id
+
+    def update_id(self, job_id):
+        """ Update the (job) ID of this sample. """
+        self.__id = job_id
+
+    @property
+    def state(self):
         """ Tells whether the analysis of the sample is done, i.e. a final
         verdict has been reached and a result and reason are available. """
-        return self.__done
+        return self.__state
 
     def mark_done(self):
         """ Mark this sample as done, i.e. fully analysed and verdict reached.
         """
-        self.__done = True
-        if self.__status_change:
-            # notify whoever is interested that our status has changed
-            self.__status_change.set()
-
-    def generate_job_hash(self, size=8):
-        """
-        Generates a job hash (default: 8 characters).
-
-        @param size: The amount of random characters to use for a job hash.
-                     Defaults to 8.
-        @return: a job hash consisting of a static prefix, a timestamp
-                 representing the time when the method was invoked, and random
-                 characters.
-        """
-        job_hash = 'peekaboo-run_analysis-'
-        job_hash += '%s-' % datetime.now().strftime('%Y%m%dT%H%M%S')
-        job_hash += ''.join(
-            random.choice(string.digits + string.ascii_lowercase
-                          + string.ascii_uppercase) for _ in range(size))
-        return job_hash
-
-    @property
-    def job_hash(self):
-        """ Returns a job identifier extracted from the file path using a
-        configurable regular expression for use in other temporary or permanent
-        (dump) path names to keep correlation to the original input job. """
-        if self.__job_hash:
-            return self.__job_hash
-
-        match = re.search(self.__job_hash_regex, self.__path)
-        if match is not None:
-            job_hash = match.group(1)
-        else:
-            # regex did not match.
-            # so we generate our own job hash and create the
-            # working directory.
-            job_hash = self.generate_job_hash()
-
-        logger.debug("Job hash for this sample: %s" % job_hash)
-        self.__job_hash = job_hash
-        return job_hash
+        self.__state = JobState.FINISHED
 
     def add_rule_result(self, res):
         """ Add a rule result to the sample. This also adds a message about
         this to the report and updates the overall analysis result (so far).
         """
-        logger.debug('Adding rule result %s', res)
-        self.__report.append(_("File \"%s\": %s") % (self.__filename, res))
+        res_str = "%s" % res
+        logger.debug("%d: Adding rule result %s", self.__id, res_str)
+        self.__report.append(res_str)
 
-        logger.debug("Current overall result: %s, new rule result: %s",
-                     self.__result, res.result)
+        logger.debug("%d: Current overall result: %s, new rule result: %s",
+                     self.__id, self.__result, res.result)
         # check if result of this rule is worse than what we know so far
         if res.result >= self.__result:
             self.__result = res.result
@@ -313,7 +191,9 @@ def dump_processing_info(self):
                          'data is unconfigured.')
             return
 
-        dump_dir = os.path.join(self.__processing_info_dir, self.job_hash)
+        now = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
+        dump_dir = os.path.join(
+            self.__processing_info_dir, "%s-%s" % (now, self.sha256sum))
         if not os.path.isdir(dump_dir):
             try:
                 os.makedirs(dump_dir, 0o770)
@@ -324,8 +204,8 @@ def dump_processing_info(self):
 
         filename = self.__filename + '-' + self.sha256sum
 
-        logger.debug('Dumping processing info to %s for sample %s',
-                     dump_dir, self)
+        logger.debug('%d: Dumping processing info to %s',
+                     self.__id, dump_dir)
 
         # Peekaboo's report
         peekaboo_report = os.path.join(dump_dir, filename + '_report.txt')
@@ -342,14 +222,13 @@ def dump_processing_info(self):
 
         # store malicious sample along with the reports
         if self.__result == Result.bad:
+            sample_dump = os.path.join(dump_dir, filename + "_sample.bin")
             try:
-                shutil.copyfile(
-                    self.__path,
-                    os.path.join(dump_dir, self.__filename)
-                )
+                with open(sample_dump, 'wb') as dump_file:
+                    dump_file.write(self.__content)
             except (shutil.Error, IOError, OSError) as error:
-                logger.error('Failure to copy sample file %s to dump '
-                             'directory: %s', self.__path, error)
+                logger.error('Failure to dump sample file %s to dump '
+                             'directory: %s', self.__filename, error)
                 return
 
         # Cuckoo report
@@ -370,11 +249,8 @@ def dump_processing_info(self):
     def sha256sum(self):
         """ Returns the SHA256 checksum/fingerprint of this sample. Determines
         it automatically on first call. """
-
         if not self.__sha256sum:
-            with open(self.__path, 'rb') as f:
-                checksum = hashlib.sha256(f.read()).hexdigest()
-                self.__sha256sum = checksum
+            self.__sha256sum = hashlib.sha256(self.__content).hexdigest()
 
         return self.__sha256sum
 
@@ -382,13 +258,14 @@ def sha256sum(self):
     def content_disposition(self):
         """ Returns the content disposition in the original email, e.g. inline
         or attachment, None if not available. """
-        return self.meta_info_content_disposition
+        return self.__content_disposition
 
+    # for compat with ruleset
     @property
     def name_declared(self):
         """ Returns the name declared by the sample as its original filename,
         None if not available. """
-        return self.meta_info_name_declared
+        return self.__filename
 
     @property
     def file_extension(self):
@@ -396,16 +273,11 @@ def file_extension(self):
         if self.__file_extension:
             return self.__file_extension
 
-        # try to find a file name containing an extension. Using
-        # self.__filename will almost never yield anything useful because
-        # amavis intentionally hands us files named only p001, p002 and so on.
-        # But we still try it in case there's no declared name.
-        filename = self.__filename
-        if self.name_declared:
-            filename = self.name_declared
+        # no extension if we have not filename to extract it from
+        if self.__filename is None:
+            return None
 
-        # extension or the empty string if none found
-        ext = os.path.splitext(filename)[1][1:]
+        ext = os.path.splitext(self.__filename)[1][1:]
 
         # sanity checks, where splitext already promises:
         # - ext is empty or
@@ -443,17 +315,12 @@ def file_extension(self):
     def type_declared(self):
         """ Returns the MIME type declared by the original MIME part, None if
         not available. """
-        return self.meta_info_type_declared
+        return self.__content_type
 
     @property
     def file_size(self):
-        """ Determine and cache sample file size
-
-        @raises: OSError if e.g. file does not exist or is inaccessible """
-        if not self.__file_stat:
-            self.__file_stat = os.stat(self.__path)
-
-        return self.__file_stat.st_size
+        """ Return sample file size based on content length. """
+        return len(self.__content)
 
     @property
     def cuckoo_failed(self):
@@ -490,11 +357,6 @@ def knowntools_report(self):
         """ Returns the knowntools report """
         return self.__knowntools_report
 
-    @property
-    def submit_path(self):
-        """ Returns the path to use for submission to Cuckoo """
-        return self.__submit_path
-
     def mark_cuckoo_failure(self):
         """ Records whether Cuckoo analysis failed. """
         self.__cuckoo_failed = True
@@ -523,29 +385,10 @@ def register_knowntools_report(self, report):
         """ Records a Knowntools report for alter evaluation. """
         self.__knowntools_report = report
 
-    def cleanup(self):
-        """ Clean up after the sample has been analysed, removing a potentially
-        created workdir. """
-        # nothing to do if we never created a workdir
-        if not self.__wd:
-            return
-
-        if self.__keep_mail_data:
-            logger.debug('Keeping mail data in %s', self.__wd)
-            return
-
-        logger.debug("Deleting working directory %s", self.__wd)
-        try:
-            shutil.rmtree(self.__wd)
-        except OSError as oserr:
-            logger.error('Failed to remove working directory %s: %s',
-                         self.__wd, oserr)
-
     def __str__(self):
-        return ("<Sample(filename='%s',"
-                " result='%s', sha256sum='%s')>"
-                % (self.__filename,
-                   self.__result,
-                   self.sha256sum))
+        """ A string representation with selected information for positive
+        identification. ID should be enough but may be uninitialised. Should
+        not modify object state, i.e. should not use properties. """
+        return f"<Sample(id='{self.__id}')>"
 
     __repr__ = __str__
diff --git a/peekaboo/server.py b/peekaboo/server.py
index 40759199..2c65110f 100644
--- a/peekaboo/server.py
+++ b/peekaboo/server.py
@@ -25,407 +25,30 @@
 """ This module implements the Peekaboo server, i.e. the frontend to the
 client. """
 
-import errno
-import grp
-import json
+import asyncio
+import email.utils
 import logging
-import os
-import stat
-import socket
-import socketserver
-import threading
-from peekaboo.ruleset import Result
+import urllib.parse
 
+import sanic
+import sanic.headers
+import sanic.response
 
-logger = logging.getLogger(__name__)
-
-
-class PeekabooStreamServer(socketserver.ThreadingUnixStreamServer):
-    """ Asynchronous server. """
-    def __init__(self, server_address, request_handler_cls, job_queue,
-                 sample_factory, request_queue_size=10,
-                 status_change_timeout=60):
-        self.__job_queue = job_queue
-        self.__sample_factory = sample_factory
-        self.request_queue_size = request_queue_size
-        self.allow_reuse_address = True
-        self.status_change_timeout = status_change_timeout
-        self.__shutdown_requested = False
-        self.__request_triggers = {}
-
-        super().__init__(
-            server_address, request_handler_cls, bind_and_activate=False)
-
-    @property
-    def job_queue(self):
-        """ Return this server's reference to the job queue. Used by handler
-        threads to get access to it for submission of samples for processing.
-        """
-        return self.__job_queue
-
-    @property
-    def sample_factory(self):
-        """ Return this server's reference to a factory that can create
-        pre-configured sample objects. Used by handler threads to get access to
-        it for creation of samples prior to submission for processing. """
-        return self.__sample_factory
-
-    @property
-    def shutting_down(self):
-        """ Return True if we've received a shutdown request. """
-        return self.__shutdown_requested
-
-    def register_request(self, thread, event):
-        """ Register an event for a request being handled to trigger if we want
-        it to shut down. """
-        self.__request_triggers[thread] = event
-        logger.debug('Request registered with server.')
-
-    def deregister_request(self, thread):
-        """ Deregister a request which has finished handling and does no logner
-        need to be made aware that we want it to shut down. """
-        logger.debug('Request deregistered from server.')
-        del self.__request_triggers[thread]
-
-    def shutdown(self):
-        """ Shut down the server. In our case, notify requests which are
-        currently being handled to shut down as well and then wait until all
-        are finished. """
-        self.__shutdown_requested = True
-        for thread in self.__request_triggers:
-            # wake up the thread so it can see that we're shutting down
-            self.__request_triggers[thread].set()
-
-        socketserver.ThreadingUnixStreamServer.shutdown(self)
-
-
-class PeekabooStreamRequestHandler(socketserver.StreamRequestHandler):
-    """ Request handler used by PeekabooStreamServer to handle analysis
-    requests. """
-    def setup(self):
-        # rename thread for higher log message clarity
-        thread = threading.current_thread()
-        # keep trailing thread number by replacing just the base name
-        thread.name = thread.name.replace('Thread-', 'Request-')
-
-        socketserver.StreamRequestHandler.setup(self)
-        self.job_queue = self.server.job_queue
-        self.sample_factory = self.server.sample_factory
-        self.status_change_timeout = self.server.status_change_timeout
-
-        # create an event we will give to all the samples and our server to
-        # wake us if they need out attention
-        self.status_change = threading.Event()
-        self.status_change.clear()
-
-    def handle(self):
-        """ Handles an analysis request. """
-        # catch wavering clients early on
-        logger.debug('New connection incoming.')
-        if not self.talk_back([_('Hello, this is Peekaboo.'), '']):
-            return
-
-        # parse client input and see if it's valid and whether there are any
-        # requests to us in there
-        requests = self.parse()
-        if not requests:
-            return
-
-        # handle client requests. Sample submissions are special in that they
-        # submit into our work queue and we need to wait for them to finish. So
-        # see if handling the requests did submit something.
-        submitted = self.handle_requests(requests)
-        if not submitted:
-            return
-
-        # wait for jobs submitted by request handling
-        if not self.wait(submitted):
-            # something went wrong while waiting, i.e. client closed connection
-            # or we're shutting down
-            return
-
-        # here we know that all samples have reported back
-        self.report(submitted)
-
-    def submit_sample(self, api_data):
-        """ Submit API supplied file as Sample """
-        path = api_data['full_name']
-        logger.info("Got run_analysis request for %s", path)
-        if not os.path.exists(path):
-            self.talk_back(_('ERROR: Path does not exist or no '
-                             'permission to access it.'))
-            logger.error('Path does not exist or no permission '
-                         'to access it.')
-            return None
-
-        if not os.path.isfile(path):
-            self.talk_back(_('ERRROR: Input is not a file'))
-            logger.error('Input is not a file')
-            return None
-
-        sample = self.sample_factory.make_sample(
-            path, status_change=self.status_change, metainfo=api_data)
-        if not self.job_queue.submit(sample, self.__class__):
-            self.talk_back(_('Error submitting sample to job queue'))
-            # submit will have logged an error
-            return None
-        return sample
-
-    def parse(self):
-        """ Reads and parses an analysis request. This is expected to be a JSON
-        structure::
-
-            [ { "full_name": "<path>",
-                "name_declared": ...,
-                ... },
-              { "request": "scan-file",
-                "full_name": ... },
-              { "request": "ping" },
-              ... ]
-
-        The request field is optional with a default of "scan-file".
-        Valid requests are:
-        * scan-file
-        * ping
-
-        scan-file: requires the path of the directory / file to analyse
-        in "full_name".
-
-        The maximum buffer size is 512 KiB, because JSON incurs some bloat.
-        """
-        try:
-            client_input = self.request.recv(1024 * 512).rstrip()
-        except IOError as ioerror:
-            logger.error('Request receive failed: %s', ioerror)
-            return None
-
-        try:
-            requests = json.loads(client_input)
-        except ValueError as error:
-            self.talk_back(_('Error: Invalid JSON in request.'))
-            logger.error('Invalid JSON in request: %s', error)
-            return None
-
-        # do some generic input validation
-        if not isinstance(requests, (list, tuple)):
-            self.talk_back(_('ERROR: Invalid data structure.'))
-            logger.error('Invalid data structure.')
-            return None
-
-        for request in requests:
-            if not isinstance(request, dict):
-                self.talk_back(_('ERROR: Invalid request structure.'))
-                logger.error('Invalid request structure.')
-                return None
-
-            # default request type is scan-file
-            reqtype = request.get('request', 'scan-file')
-            if reqtype not in ['scan-file', 'ping']:
-                self.talk_back(_('ERROR: Unsupported request %s') % reqtype)
-                logger.error('Unsupported request %s', reqtype)
-                return None
-
-            if reqtype == 'scan-file' and 'full_name' not in request:
-                self.talk_back(_('ERROR: Incomplete data structure.'))
-                logger.error('Incomplete data structure.')
-                return None
-
-        return requests
-
-    def handle_requests(self, requests):
-        """ Handle requests.
-
-        @param requests: list/tuple of dicts describing requests
-        @returns: list of submitted samples or None on error
-        """
-        submitted = []
-        for request in requests:
-            reqtype = request.get('request', 'scan-file')
-            if reqtype == 'scan-file':
-                sample = self.submit_sample(request)
-                if sample is None:
-                    # here we abort the whole batch of requests which is
-                    # somewhat excessive since the others might just be pings
-                    # or complete successfully. But since we can't fulfill all
-                    # the requested analyses and have no way of communicating
-                    # that fact individually to the client it's the only option
-                    # for now.
-                    return None
-
-                submitted.append(sample)
-                logger.debug('Created and submitted sample %s', sample)
-            elif reqtype == 'ping':
-                self.talk_back(json.dumps({'request': 'ping',
-                                           'response': 'pong'}))
-                logger.debug('ping request received and answered')
-            else:
-                # paranoia
-                logger.error('Unknown request "%s" got through input '
-                             'validation', reqtype)
-                self.talk_back(_('ERROR: Unsupported request %s') % reqtype)
-                return None
-
-        return submitted
-
-    def wait(self, to_be_analysed):
-        """ Wait for submitted analysis jobs to finished.
-
-        @param to_be_analysed: samples that have been submitted for analysis
-                               and which will report back to us when they're
-                               finished.
-        @type to_be_analysed: List of Sample objects
-        """
-        # register with our server so it can notify us if it wants us to shut
-        # down
-        # NOTE: Every exit point from this routine needs to deregister this
-        # request from the server to avoid memory leaks. Unfortunately, the
-        # server cannot do this iteself on shutdown_request() because it does
-        # not have any thread ID available there.
-        self.server.register_request(self, self.status_change)
-
-        # wait for results to come in
-        while to_be_analysed:
-            # wait for an event to signal that its status has changed or
-            # timeout expires
-            if not self.status_change.wait(self.status_change_timeout):
-                # keep our client engaged
-                # TODO: Impose maximum processing time of our own?
-                if not self.talk_back(_('Files are being analyzed...')):
-                    # Abort handling this request since no-one's interested
-                    # any more. We could dequeue the samples here to avoid
-                    # unnecessary work. Instead we'll have them run their
-                    # course, assuming that we'll be much quicker
-                    # responding if the client decides to resubmit them.
-                    self.server.deregister_request(self)
-                    return False
-
-                logger.debug('Client updated that samples are still '
-                             'processing.')
-
-                # Fall through here and evaluate all samples for paranoia's
-                # sake in case our status change event has a race condition.
-                # It shouldn't though, because we wait for it, then first clear
-                # it and then look at all samples that might have set it. If
-                # while doing that another sample sets it and we don't catch it
-                # because we've already looked at it, the next wait will
-                # immediately return and send us back into checking all samples
-                # for status change.
-
-            # see if our server is shutting down and follow it if so
-            if self.server.shutting_down:
-                self.talk_back(_('Peekaboo is shutting down.'))
-                logger.debug('Request shutting down with server.')
-                self.server.deregister_request(self)
-                return False
-
-            self.status_change.clear()
-
-            # see which samples are done and which are still processing
-            still_analysing = []
-            for sample in to_be_analysed:
-                # remove samples that are done
-                if sample.done:
-                    continue
-
-                still_analysing.append(sample)
-
-            to_be_analysed = still_analysing
-
-        # deregister notification from server since we've exited our wait loop
-        self.server.deregister_request(self)
-        return True
-
-    def report(self, done):
-        """ Report individual files' and overall verdict to client.
-
-        @param done: List of samples that are done processing and need
-                     reporting.
-        @type done: List of Sample objects.
-        """
-        # evaluate results into an overall result: We want to present the
-        # client with an overall result instead of confusing them with
-        # assertions about individual files. Particularly in the case of
-        # AMaViS, this would otherwise lead to messages being passed on as
-        # clean where a single attachment evaluated to "good" but analysis of
-        # all the others failed.
-        result = Result.unchecked
-        logger.debug('Determining final verdict to report to client.')
-        for sample in done:
-            # check if result of this rule is worse than what we know so far
-            sample_result = sample.result
-            logger.debug('Current overall result: %s, Sample result: %s',
-                         result.name, sample_result.name)
-            if sample_result >= result:
-                result = sample_result
-
-            # and unconditionally send out its report to the client (plus an
-            # empty line)
-            if not self.talk_back(sample.peekaboo_report + ['']):
-                return
-
-        # report overall result.
-        logger.debug('Reporting batch as "%s" to client', result.name)
-        loc_verdict = _('The file collection has been categorized "%s"')
-        overall = [loc_verdict % result.name]
-
-        # Add untranslated verdict (if the above actually got translated) for
-        # potential pattern matching of the client to reliably latch on to.
-        # Need to duplicate strings here for pygettext and pybabel extract to
-        # find the translatable one in the above _().
-        verdict = 'The file collection has been categorized "%s"'
-        if verdict != loc_verdict:
-            overall.append(verdict % result.name)
-
-        # append newline and send
-        overall.append('')
-        if not self.talk_back(overall):
-            return
-
-        # shut down connection
-        logger.debug('Results reported back to client - closing connection.')
-
-    def talk_back(self, msgs):
-        """ Send message(s) back to the client. Automatically appends newline
-        to each message.
-
-        @param msgs: message(s) to send to client.
-        @type msgs: string or (list or tuple of strings)
-
-        @returns: True on successful sending of all messages, False on error of
-                  sending and None specifically if sending failed because the
-                  client closed the connection. """
-        if not isinstance(msgs, (list, tuple)):
-            msgs = (msgs, )
-
-        for msg in msgs:
-            try:
-                # FIXME: Hard-coded, arbitrary encoding since we have no
-                # clearly defined protocol here.
-                self.request.sendall(('%s\n' % msg).encode('utf-8'))
-            except IOError as ioerror:
-                if ioerror.errno == errno.EPIPE:
-                    logger.warning('Client closed connection on us: %s',
-                                   ioerror)
-                    return None
-
-                logger.warning('Error talking back to client: %s', ioerror)
-                return False
-
-        return True
+from peekaboo.db import PeekabooDatabaseError
 
+logger = logging.getLogger(__name__)
 
 class PeekabooServer:
     """ A class wrapping the server components of Peekaboo. """
-    def __init__(self, sock_file, job_queue, sample_factory, request_queue_size,
-                 sock_group=None,
-                 sock_mode=(stat.S_IREAD | stat.S_IWRITE |
-                            stat.S_IRGRP | stat.S_IWGRP |
-                            stat.S_IROTH | stat.S_IWOTH)):
+    def __init__(self, host, port, job_queue, sample_factory,
+                 request_queue_size, db_con):
         """ Initialise a new server and start it. All error conditions are
         returned as exceptions.
 
-        @param sock_file: The path of the socket file.
-        @type sock_file: String
+        @param host: The local address to bind the socket to.
+        @type host: String
+        @param port: The local port to listen on for client connections.
+        @type port: int
         @param job_queue: A reference to the job queue for submission of
                           samples.
         @type job_queue: JobQueue
@@ -435,70 +58,236 @@ def __init__(self, sock_file, job_queue, sample_factory, request_queue_size,
         @param request_queue_size: Number of requests that may be pending on
                                    the socket.
         @type request_queue_size: int
-        @param sock_group: An optional name of a group that shall own the
-                           socket.
-        @type sock_group: String
-        @param sock_mode: The permission bits of the socket.
-        @type sock_mode: Integer (bitmask)
         """
-        self.shutdown_requested = threading.Event()
-        self.shutdown_requested.clear()
-
         logger.debug('Starting up server.')
-        self.server = PeekabooStreamServer(
-            sock_file,
-            PeekabooStreamRequestHandler,
-            job_queue=job_queue,
-            sample_factory=sample_factory,
-            request_queue_size=request_queue_size)
+        self.app = sanic.Sanic("PeekabooAV", configure_logging=False)
+        self.app.config.FALLBACK_ERROR_FORMAT = "json"
+
+        # silence sanic to a reasonable amount
+        logging.getLogger('sanic.root').setLevel(logging.WARNING)
+        logging.getLogger('sanic.access').setLevel(logging.WARNING)
+
+        self.loop = asyncio.get_event_loop()
+        self.server_coroutine = self.app.create_server(
+            host=host, port=port, return_asyncio_server=True,
+            backlog=request_queue_size,
+            asyncio_server_kwargs=dict(start_serving=False))
+        self.server = None
+        self.job_queue = job_queue
+        self.sample_factory = sample_factory
+        self.db_con = db_con
+        # remember for diagnostics
+        self.host = host
+        self.port = port
+
+        self.app.add_route(self.hello, '/')
+        self.app.add_route(self.ping, '/ping')
+        self.app.add_route(self.scan, "/v1/scan", methods=['POST'])
+        self.app.add_route(
+            self.report, '/v1/report/<job_id:int>', methods=['GET'])
+
+    async def hello(self, _):
+        """ hello endpoint as fallback and catch all
+
+        @returns: hello world json response
+        """
+        return sanic.response.json({'hello': 'PeekabooAV'})
+
+    async def ping(self, _):
+        """ ping endpoint for diagnostics
+
+        @returns: pong json response
+        """
+        return sanic.response.json({'answer': 'pong'})
+
+    async def scan(self, request):
+        """ scan endpoint for job submission
+
+        @param request: sanic request object
+        @type request: sanic.Request
+        @returns: json response containing ID of newly created job
+        """
+        # this is sanic's multipart/form-data parser in a version that knows
+        # that our file field contains binary data. This allows transferring
+        # files without a filename. The generic parser would treat those as
+        # text fields and try to decode them using the form charset or UTF-8 as
+        # a fallback and cause errors such as: UnicodeDecodeError: 'utf-8'
+        # codec can't decode byte 0xc0 in position 1: invalid start byte
+        content_type, parameters = sanic.headers.parse_content_header(
+            request.content_type)
+
+        # application/x-www-form-urlencoded is inefficient at transporting
+        # binary data. Also it needs a separate field to transfer the filename.
+        # Make clear here that we do not support that format (yet).
+        if content_type != 'multipart/form-data':
+            logger.error('Invalid content type %s', content_type)
+            return sanic.response.json(
+                {'message': 'Invalid content type, use multipart/form-data'},
+                400)
+
+        boundary = parameters["boundary"].encode("utf-8")
+        form_parts = request.body.split(boundary)
+        # split above leaves preamble in form_parts[0] and epilogue in
+        # form_parts[2]
+        num_fields = len(form_parts) - 2
+        if num_fields <= 0:
+            logger.error('Invalid MIME structure in request, no fields '
+                         'or preamble or epilogue missing')
+            return sanic.response.json(
+                {'message': 'Invalid MIME structure in request'}, 400)
+
+        if num_fields != 1:
+            logger.error('Invalid number of fields in form: %d', num_fields)
+            return sanic.response.json(
+                {'message': 'Invalid number of fields in form, we accept '
+                    'only one field "file"'}, 400)
+
+        form_part = form_parts[1]
+        file_name = None
+        content_type = None
+        field_name = None
+        line_index = 2
+        line_end_index = 0
+        while line_end_index != -1:
+            line_end_index = form_part.find(b'\r\n', line_index)
+            # this constitutes a hard requirement for the multipart headers
+            # (and filenames therein) to be UTF-8-encoded. There are some
+            # obscure provisions for transferring an encoding in RFC7578
+            # section 5.1.2 for HTML forms which don't apply here so its
+            # fallback to UTF-8 applies. This is no problem for our field name
+            # (ASCII) and file names in RFC2231 encoding. For HTML5-style
+            # percent-encoded filenames it means that whatever isn't
+            # percent-encoded needs to be UTF-8 encoded. There are no rules in
+            # HTML5 currently to percent-encode any UTF-8 byte sequences.
+            form_line = form_part[line_index:line_end_index].decode('utf-8')
+            line_index = line_end_index + 2
+
+            if not form_line:
+                break
+
+            colon_index = form_line.index(':')
+            idx = colon_index + 2
+            form_header_field = form_line[0:colon_index].lower()
+
+            # parse_content_header() reverts some of the percent encoding as
+            # per HTML5 WHATWG spec. As it is a "living standard" (i.e. moving
+            # target), it has changed over the years. There used to be
+            # backslash doubling and explicit control sequence encoding. As of
+            # this writing this has been changed to escaping only newline,
+            # linefeed and double quote. Sanic only supports the double quote
+            # part of that: %22 are reverted back to %. Luckily this interacts
+            # reasonably well with RFC2231 decoding below since that would do
+            # the same.
+            #
+            # There is no way to tell what version of the standard (or draft
+            # thereof) the client was following when encoding. It seems accepted
+            # practice in the browser world to just require current versions of
+            # everything so their behaviour hopefully converges eventually.
+            # This is also the reason why we do not try to improve upon it here
+            # because it's bound to become outdated.
+            #
+            # NOTE: Since we fork the sanic code here we need to keep track of
+            # its changes, particularly how it interacts with RFC2231 encoding
+            # if escaping of the escape character %25 is ever added to the
+            # HTML5 WHATWG spec. In that case parse_content_header() would
+            # start breaking the RFC2231 encoding which would explain why its
+            # use is forbidden in RFC7578 section 4.2 via RFC5987.
+            form_header_value, form_parameters = sanic.headers.parse_content_header(
+                form_line[idx:]
+            )
+
+            if form_header_field == 'content-disposition':
+                field_name = form_parameters.get('name')
+                file_name = form_parameters.get('filename')
+
+                # non-ASCII filenames in RFC2231, "filename*" format
+                if file_name is None and form_parameters.get('filename*'):
+                    encoding, _, value = email.utils.decode_rfc2231(
+                        form_parameters['filename*']
+                    )
+                    file_name = urllib.parse.unquote(value, encoding=encoding)
+            elif form_header_field == 'content-type':
+                content_type = form_header_value
+
+        if field_name != 'file':
+            logger.error('Field file missing from request')
+            return sanic.response.json(
+                {'message': 'Field "file" missing from request'}, 400)
+
+        file_content = form_part[line_index:-4]
+        content_disposition = request.headers.get('x-content-disposition')
+        sample = self.sample_factory.make_sample(
+            file_content, file_name,
+            content_type, content_disposition)
 
         try:
-            # create the socket filesystem node but do not listen for
-            # connections yet so we can try to secure it and not have to worry
-            # about pending/established connections on error
-            self.server.server_bind()
-
-            if sock_mode is not None:
-                os.chmod(sock_file, sock_mode)
-
-            if sock_group is not None:
-                gid = grp.getgrnam(sock_group)[2]
-                os.chown(sock_file, -1, gid)
-
-            self.server.server_activate()
-        except OSError:
-            try:
-                os.remove(sock_file)
-            except OSError:
-                pass
-            self.server.server_close()
-            raise
-
-        logger.info('Peekaboo server is now listening on %s', sock_file)
-        self.sock_file = sock_file
+            await self.db_con.analysis_add(sample)
+        except PeekabooDatabaseError as dberr:
+            logger.error('Failed to add analysis to database: %s', dberr)
+            return sanic.response.json(
+                {'message': 'Failed to add analysis to database'}, 500)
+
+        if not self.job_queue.submit(sample):
+            logger.error('Error submitting sample to job queue')
+            return sanic.response.json(
+                {'message': 'Error submitting sample to job queue'}, 500)
+
+        # send answer to client
+        return sanic.response.json({'job_id': sample.id}, 200)
+
+    async def report(self, _, job_id):
+        """ report endpoint for report retrieval by job ID
+
+        @param request: sanic request object
+        @type request: sanic.Request
+        @param job_id: job ID extracted from endpoint path
+        @type job_id: int
+        @returns: report json response
+        """
+        if not job_id:
+            return sanic.response.json(
+                {'message': 'job ID missing from request'}, 400)
+
+        try:
+            job_info = await self.db_con.analysis_retrieve(job_id)
+        except PeekabooDatabaseError as dberr:
+            logger.error('Failed to retrieve analysis result from '
+                         'database: %s', dberr)
+            return sanic.response.json(
+                {'message': 'Failed to retrieve analysis result '
+                            'from database'}, 500)
+
+        if job_info is None:
+            logger.debug('No analysis result yet for job %d', job_id)
+            return sanic.response.json(
+                {'message': 'No analysis result yet for job %d' % job_id}, 404)
+
+        reason, result = job_info
+        return sanic.response.json({
+            'result': result.name,
+            'reason': reason,
+            # FIXME: depends on saving the report to the database
+            # 'report': report,
+            }, 200)
 
     def serve(self):
         """ Serves requests until shutdown is requested from the outside. """
-        runner = threading.Thread(
-            target=self.server.serve_forever, name='Server')
-        runner.start()
-        self.shutdown_requested.wait()
-        self.server.shutdown()
-        runner.join()
-
-        # no new connections from this point on
-        logger.debug('Removing connection socket %s', self.sock_file)
-        try:
-            os.remove(self.sock_file)
-        except OSError as oserror:
-            logger.warning('Removal of socket %s failed: %s',
-                           self.sock_file, oserror)
+        self.server = self.loop.run_until_complete(self.server_coroutine)
+
+        # sanic 21.9 introduced an explicit startup that finalizes the app,
+        # particularly the request routing. So we need to run it if present.
+        if hasattr(self.server, 'startup'):
+            self.loop.run_until_complete(self.server.startup())
 
-        self.server.server_close()
+        self.loop.run_until_complete(self.server.start_serving())
+        logger.info('Peekaboo server is now listening on %s:%d',
+                    self.host, self.port)
+        self.loop.run_until_complete(self.server.wait_closed())
         logger.debug('Server shut down.')
 
     def shut_down(self):
         """ Triggers a shutdown of the server, used by the signal handler and
         potentially other components to cause the main loop to exit. """
         logger.debug('Server shutdown requested.')
-        self.shutdown_requested.set()
+        if self.server is not None:
+            self.server.close()
diff --git a/peekaboo/toolbox/cortex.py b/peekaboo/toolbox/cortex.py
index 6458789e..13add123 100644
--- a/peekaboo/toolbox/cortex.py
+++ b/peekaboo/toolbox/cortex.py
@@ -134,43 +134,57 @@ class CortexAnalyzer:
     reportclass = CortexAnalyzerReport
 
 
-class CortexFileAnalyzer(CortexAnalyzer):
+class CortexFileAnalyzer:
     """ An analyzer which accepts a file as main input. """
-    def get_submit_parameters(self, sample, tlp, submit_original_filename=False):
-        """ Return this analyzer's submit parameters for a given sample. """
-        path = sample.submit_path
-        filename = os.path.basename(path)
+    @classmethod
+    def get_submit_parameters(cls, sample, sample_tlp,
+                              submit_original_filename=False):
+        """ Return this analyzer's submit parameters for a given sample and a
+        filtered, loggable version of them. """
+        filename = sample.sha256sum
 
-        # override with the original file name if available
-        if submit_original_filename:
-            if sample.name_declared:
-                filename = sample.name_declared
-            elif sample.filename:
-                filename = sample.filename
+        # append file extension to aid backend analyzers in file type detection
+        if sample.file_extension:
+            filename = '%s.%s' % (filename, sample.file_extension)
 
-        params = {
-            'data': path,
+        # override with the original file name if available
+        if submit_original_filename and sample.filename:
+            filename = sample.filename
+
+        # log-friendly params prevent flooding the log with superfluous data
+        # and can prevent information leakage
+        log_params = {
+            'data': '<%d bytes of sample data>' % sample.file_size,
+            'dataProvided': True,
             'dataType': 'file',
-            'tlp': tlp.value,
+            'tlp': sample_tlp.value,
             'parameters': {
                 'filename': filename,
             }
         }
 
-        return params
+        params = log_params.copy()
+        params['data'] = sample.content
+
+        return params, log_params
 
 
 class CortexHashAnalyzer(CortexAnalyzer):
     """ An analyzer which accepts hashes as main input. """
-    def get_submit_parameters(self, sample, tlp, submit_original_filename=False):
-        """ Return this analyzer's submit parameters for a given sample. """
+    @classmethod
+    def get_submit_parameters(cls, sample, sample_tlp,
+                              submit_original_filename=False):
+        """ Return this analyzer's submit parameters for a given sample and a
+        filtered, loggable version of them. """
+        del submit_original_filename
+
         params = {
             'data': sample.sha256sum,
             'dataType': 'hash',
-            'tlp': tlp.value,
+            'tlp': sample_tlp.value,
         }
 
-        return params
+        return params, params
 
 
 class FileInfoAnalyzerReport(CortexAnalyzerReport):
@@ -196,7 +210,7 @@ class FileInfoAnalyzerReport(CortexAnalyzerReport):
                             "md5": schema.Regex(r'^[0-9a-z]{32}$'),
                             "sha1": schema.Regex(r'^[0-9a-z]{40}$'),
                             "sha256": schema.Regex(r'^[0-9a-z]{64}$'),
-                            "ssdeep": schema.Regex(r'^[0-9A-Za-z:]*$'),
+                            "ssdeep": schema.Regex(r'^[0-9A-Za-z:+/]*$'),
                         }
                     },
                     {
@@ -620,9 +634,12 @@ def register_running_job(self, job_id, job):
         with self.running_jobs_lock:
             if (job_id in self.running_jobs and
                     self.running_jobs[job_id] is not job):
-                raise CortexSubmitFailedException(
-                    'A job with ID %s is already registered as running '
-                    'for sample %s' % (job_id, self.running_jobs[job_id]))
+                logger.warning(
+                    '%d: A job with ID %s already registered as running '
+                    'for different sample %d will be marked failed',
+                    job.sample.id, job_id,
+                    self.running_jobs[job_id].sample.id)
+                self.resubmit_as_failed(job_id)
 
             self.running_jobs[job_id] = job
 
@@ -666,7 +683,7 @@ def resubmit_with_analyzer_report(self, job_id):
             logger.debug('No job found for job ID %s', job_id)
             return None
 
-        logger.debug('Requesting Cortex report for sample %s', job.sample)
+        logger.debug('%d: Requesting Cortex report', job.sample.id)
         try:
             report = self.api.jobs.get_report(job_id)
             # register this job's analysis report with our main report object
@@ -682,7 +699,7 @@ def resubmit_with_analyzer_report(self, job_id):
                            'invalid data: %s', error)
             job.sample.mark_cortex_failure()
 
-        self.job_queue.submit(job.sample, self.__class__)
+        self.job_queue.submit(job.sample)
         return None
 
     def resubmit_as_failed(self, job_id):
@@ -694,10 +711,8 @@ def resubmit_as_failed(self, job_id):
         """
         job = self.deregister_running_job(job_id)
         if job is not None:
-            logger.warning("Dropped job %s because it has failed in Cortex",
-                           job_id)
             job.sample.mark_cortex_failure()
-            self.job_queue.submit(job.sample, self.__class__)
+            self.job_queue.submit(job.sample)
 
     def resubmit_as_failed_if_too_old(self, job_id, max_age):
         """ Resubmit a sample to the job queue with a failure report if the
@@ -713,7 +728,7 @@ def resubmit_as_failed_if_too_old(self, job_id, max_age):
             logger.warning("Dropped job %s because it has been running for "
                            "too long", job_id)
             job.sample.mark_cortex_failure()
-            self.job_queue.submit(job.sample, self.__class__)
+            self.job_queue.submit(job.sample)
 
     def submit(self, sample, analyzer):
         """ Submit a sample to Cortex for analysis.
@@ -723,11 +738,11 @@ def submit(self, sample, analyzer):
         @raises: CortexSubmitFailedException if submission failed
         @returns: ID of the submitted Cortex job.
         """
-        params = analyzer.get_submit_parameters(
+        params, log_params = analyzer.get_submit_parameters(
             sample, self.tlp, self.submit_original_filename)
 
         logger.debug("Creating Cortex job with analyzer %s and "
-                     "parameters %s", analyzer.name, params)
+                     "parameters %s", analyzer.name, log_params)
         try:
             job = self.api.analyzers.run_by_name(analyzer.name, params)
         except cortex4py.exceptions.CortexException as error:
@@ -788,6 +803,8 @@ def track(self):
                     continue
 
                 if cortexjob.status in ['Failure', 'Deleted']:
+                    logger.warning("Dropping job %s because it has failed "
+                                   "in Cortex", job_id)
                     self.resubmit_as_failed(job_id)
                     continue
 
diff --git a/peekaboo/toolbox/cuckoo.py b/peekaboo/toolbox/cuckoo.py
index 30c7f863..4b73783a 100644
--- a/peekaboo/toolbox/cuckoo.py
+++ b/peekaboo/toolbox/cuckoo.py
@@ -220,9 +220,12 @@ def register_running_job(self, job_id, sample):
         with self.running_jobs_lock:
             if (job_id in self.running_jobs and
                     self.running_jobs[job_id] is not sample):
-                raise CuckooSubmitFailedException(
-                    'A job with ID %d is already registered as running '
-                    'for sample %s' % (job_id, self.running_jobs[job_id]))
+                logger.warning(
+                    '%d: A job with ID %d already registered as running '
+                    'for different sample %d will be marked failed',
+                    sample.id, job_id,
+                    self.running_jobs[job_id].sample.id)
+                self.resubmit_as_failed(job_id)
 
             self.running_jobs[job_id] = CuckooJob(sample)
 
@@ -263,7 +266,7 @@ def resubmit_with_report(self, job_id):
             logger.debug('No sample found for job ID %d', job_id)
             return None
 
-        logger.debug('Requesting Cuckoo report for sample %s', sample)
+        logger.debug('%d: Requesting Cuckoo report', sample.id)
         report_path = "tasks/report/%d" % job_id
         report = self.__get(report_path)
         if report is None:
@@ -279,9 +282,21 @@ def resubmit_with_report(self, job_id):
                                'invalid data: %s', err)
                 sample.mark_cuckoo_failure()
 
-        self.job_queue.submit(sample, self.__class__)
+        self.job_queue.submit(sample)
         return None
 
+    def resubmit_as_failed(self, job_id):
+        """ Resubmit a sample to the job queue with a failure report if the
+        Cuckoo job has failed for some reason.
+
+        @param job_id: ID of job that failed.
+        @type job_id: int
+        """
+        sample = self.deregister_running_job(job_id)
+        if sample is not None:
+            sample.mark_cuckoo_failure()
+            self.job_queue.submit(sample)
+
     def resubmit_as_failed_if_too_old(self, job_id, max_age):
         """ Resubmit a sample to the job queue with a failure report if the
         Cuckoo job has been running for too long.
@@ -296,7 +311,7 @@ def resubmit_as_failed_if_too_old(self, job_id, max_age):
             logger.warning("Dropped job %d because it has been running for "
                            "too long", job_id)
             sample.mark_cuckoo_failure()
-            self.job_queue.submit(sample, self.__class__)
+            self.job_queue.submit(sample)
 
     def submit(self, sample):
         """ Submit a sample to Cuckoo for analysis.
@@ -306,18 +321,18 @@ def submit(self, sample):
         @raises: CuckooSubmitFailedException if submission failed
         @returns: ID of the submitted Cuckoo job.
         """
-        path = sample.submit_path
-        filename = os.path.basename(path)
+        filename = sample.sha256sum
+
+        # append file extension to aid cuckoo in file type detection
+        if sample.file_extension:
+            filename = '%s.%s' % (filename, sample.file_extension)
+
         # override with the original file name if available
-        if self.submit_original_filename:
-            if sample.name_declared:
-                filename = sample.name_declared
-            elif sample.filename:
-                filename = sample.filename
-
-        files = {"file": (filename, open(path, 'rb'))}
-        logger.debug("Creating Cuckoo task with content from %s and "
-                     "filename %s", path, filename)
+        if self.submit_original_filename and sample.filename:
+            filename = sample.filename
+
+        files = {"file": (filename, sample.content)}
+        logger.debug("Creating Cuckoo task with filename %s", filename)
         headers = {"Authorization": "Bearer %s" % self.api_token}
 
         try:
diff --git a/peekaboo/toolbox/file.py b/peekaboo/toolbox/file.py
index 802c417e..23d59364 100644
--- a/peekaboo/toolbox/file.py
+++ b/peekaboo/toolbox/file.py
@@ -44,7 +44,7 @@ def __init__(self, sample):
 
     def guess_mime_type_from_contents(self):
         """  Get type from file magic bytes. """
-        mime_type = magic.from_file(self.sample.file_path, mime=True)
+        mime_type = magic.from_buffer(self.sample.content, mime=True)
         if not mime_type:
             return None
 
@@ -52,9 +52,9 @@ def guess_mime_type_from_contents(self):
 
     def guess_mime_type_from_name(self):
         """ Guess the type of a file based on its filename or URL. """
-        filename = self.sample.name_declared
+        filename = self.sample.filename
         if not filename:
-            filename = self.sample.filename
+            return None
         mime_type = mimetypes.guess_type(filename)[0]
         if not mime_type:
             return None
@@ -64,7 +64,7 @@ def guess_mime_type_from_name(self):
     def guess_mime_type_text_representation(self):
         """ Guess the type by content and hand back text representation rather
         than mime type. """
-        type_as_text = magic.from_file(self.sample.file_path)
+        type_as_text = magic.from_buffer(self.sample.content)
         if not type_as_text:
             return None
 
diff --git a/peekaboo/toolbox/ole.py b/peekaboo/toolbox/ole.py
index 250c1cdc..e428c98a 100644
--- a/peekaboo/toolbox/ole.py
+++ b/peekaboo/toolbox/ole.py
@@ -47,9 +47,9 @@ def get_report(self):
             'suspicious' : [],
         }
 
-        file_path = self.sample.file_path
+        filename = self.sample.filename
         try:
-            vbaparser = VBA_Parser(file_path)
+            vbaparser = VBA_Parser(filename, data=self.sample.content)
 
             # VBA_Parser reports macros for office documents
             report['has_macros'] = vbaparser.detect_vba_macros() or vbaparser.detect_xlm_macros()
@@ -63,7 +63,7 @@ def get_report(self):
             if (report['has_macros'] and len(all_macros) == 1
                     and isinstance(all_macros[0], tuple)
                     and len(all_macros[0]) >= 3
-                    and all_macros[0][2] == file_path):
+                    and all_macros[0][2] == filename):
                 logger.warning(
                     "Buggy oletools version detected, result overridden. May "
                     "lead to false negatives, please update to fixed version")
diff --git a/requirements.txt b/requirements.txt
index 3ab52f8b..1c7f61b0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,12 @@
-sqlalchemy>=1.1.0
+sqlalchemy[asyncio]>=1.4.24
 python-magic>=0.4.17
 oletools>=0.54
 sdnotify>=0.3.1
-enum34>=1.0.4
 yara-python>=3.6.3
 requests>=2.19.0
 configparser
 pyparsing
-cortex4py
+#cortex4py
+git+https://github.com/michaelweiser/cortex4py@data-provided-session#egg=cortex4py
 schema
+sanic
diff --git a/ruleset.conf.sample b/ruleset.conf.sample
index d8182eb9..75e3165b 100644
--- a/ruleset.conf.sample
+++ b/ruleset.conf.sample
@@ -84,7 +84,6 @@ keyword.2 : AutoClose
 
 #expression.0  : knownreport.known -> knownreport.result
 #expression.0  : knownreport.known and knownreport.first < 14 -> knownreport.result
-#expression.0  : cortexreport.FileInfoReport.full -> bad
 expression.1  : {sample.type_declared}|filereport.mime_types <= {
                     'text/plain', 'inode/x-empty'} -> ignore
 expression.2  : sample.name_declared == /smime.p7[mcs]/
diff --git a/setup.py b/setup.py
index b81ca72e..bd80e261 100755
--- a/setup.py
+++ b/setup.py
@@ -44,9 +44,15 @@
 with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f:
     all_reqs = f.read().split('\n')
 
-install_requires = [x.strip() for x in all_reqs if 'git+' not in x]
-dependency_links = [x.strip().replace('git+', '')
-                    for x in all_reqs if 'git+' not in x]
+install_requires = []
+for req in all_reqs:
+    # for git repo references, extract the egg/package name from the end and
+    # prepend it with @ as per pip Github issue 3939
+    if 'git+' in req:
+        egg = req.split('=')[-1]
+        req = "%s @ %s" % (egg, req)
+
+    install_requires.append(req.strip())
 
 setup(
     name='PeekabooAV',
@@ -115,7 +121,6 @@
     author=__author__,
     python_requires='>=3.6',
     install_requires=install_requires,
-    dependency_links=dependency_links,
     author_email='felix.bauer@atos.net',
     entry_points={
         'console_scripts': [


Test patch:
diff --git a/tests/test.py b/tests/test.py
index 59a8a3b6..cf16bb2c 100755
--- a/tests/test.py
+++ b/tests/test.py
@@ -26,6 +26,7 @@
 
 """ The testsuite. """
 
+import asyncio
 import gettext
 import sys
 import os
@@ -36,8 +37,6 @@
 import unittest
 from datetime import datetime, timedelta
 
-import schema
-
 
 # Add Peekaboo to PYTHONPATH
 TESTSDIR = os.path.dirname(os.path.abspath(__file__))
@@ -48,7 +47,7 @@
         PeekabooRulesetConfigError
 from peekaboo.config import (
     PeekabooConfig, PeekabooConfigParser, PeekabooAnalyzerConfig)
-from peekaboo.sample import SampleFactory
+from peekaboo.sample import Sample, JobState
 from peekaboo.ruleset import RuleResult, Result
 from peekaboo.ruleset.engine import RulesetEngine
 from peekaboo.ruleset.rules import FileTypeOnWhitelistRule, \
@@ -70,6 +69,27 @@
 # pylint: enable=wrong-import-position
 
 
+# unittest.IsolatedAsyncioTestCase exists only in Python 3.8+. Since earlier
+# versions do not allow for easily overriding the test execution by subclassing
+# we go the route of adding a decorator that handles asynchronous execution in
+# those versions.
+if sys.version_info[0] < 4 and sys.version_info[1] < 8:
+    AsyncioTestCase = unittest.TestCase
+
+    def asynctest(func):
+        """ decorator executing async test through the loop """
+        def wrapper(*args, **kwargs):
+            loop = asyncio.get_event_loop()
+            return loop.run_until_complete(func(*args, **kwargs))
+
+        return wrapper
+else:
+    AsyncioTestCase = unittest.IsolatedAsyncioTestCase
+
+    def asynctest(func):
+        """ no-op async test decorator """
+        return func
+
 class CreatingConfigMixIn:
     """ A class for adding config file creation logic to any other class. """
     def create_config(self, content):
@@ -175,17 +195,11 @@ def test_1_default_settings(self):
             self.config.config_file, self.config.created_config_file)
         self.assertEqual(self.config.user, 'peekaboo')
         self.assertEqual(self.config.group, None)
-        self.assertEqual(
-            self.config.sock_file, '/var/run/peekaboo/peekaboo.sock')
-        self.assertEqual(self.config.sock_group, None)
-        self.assertEqual(self.config.sock_mode, 0o0660)
+        self.assertEqual(self.config.host, '127.0.0.1')
+        self.assertEqual(self.config.port, 8100)
         self.assertEqual(
             self.config.pid_file, '/var/run/peekaboo/peekaboo.pid')
         self.assertEqual(self.config.worker_count, 3)
-        self.assertEqual(self.config.sample_base_dir, '/tmp')
-        self.assertEqual(
-            self.config.job_hash_regex, '/amavis/tmp/([^/]+)/parts/')
-        self.assertEqual(self.config.keep_mail_data, False)
         self.assertEqual(
             self.config.processing_info_dir,
             '/var/lib/peekaboo/malware_reports')
@@ -196,6 +210,8 @@ def test_1_default_settings(self):
             self.config.log_format, '%(asctime)s - %(name)s - '
             '(%(threadName)s) - %(levelname)s - %(message)s')
         self.assertEqual(self.config.db_url, 'sqlite:////var/lib/peekaboo/peekaboo.db')
+        self.assertEqual(self.config.db_async_driver, None)
+        self.assertEqual(self.config.db_log_level, logging.WARNING)
         self.assertEqual(self.config.cluster_instance_id, 0)
         self.assertEqual(self.config.cluster_stale_in_flight_threshold, 15*60)
         self.assertEqual(self.config.cluster_duplicate_check_interval, 60)
@@ -209,14 +225,10 @@ def setUpClass(cls):
         cls.config = CreatingPeekabooConfig('''[global]
 user             :    user1
 group            :    group1
-socket_file      :    /socket/1
-socket_group     :    riddlers
-socket_mode      :    0141
+host: 192.168.2.3
+port: 8200
 pid_file         :    /pid/1
 worker_count     :    18
-sample_base_dir  :    /tmp/1
-job_hash_regex   :    /var/2
-keep_mail_data   :    yes
 processing_info_dir : /var/3
 
 [ruleset]
@@ -228,6 +240,8 @@ def setUpClass(cls):
 
 [db]
 url              :    sqlite:////peekaboo.db1
+async_driver     :    async
+log_level        :    INFO
 
 [cluster]
 instance_id: 12
@@ -241,19 +255,17 @@ def test_1_read_settings(self):
             self.config.config_file, self.config.created_config_file)
         self.assertEqual(self.config.user, 'user1')
         self.assertEqual(self.config.group, 'group1')
-        self.assertEqual(self.config.sock_file, '/socket/1')
-        self.assertEqual(self.config.sock_group, 'riddlers')
-        self.assertEqual(self.config.sock_mode, 0o0141)
+        self.assertEqual(self.config.host, '192.168.2.3')
+        self.assertEqual(self.config.port, 8200)
         self.assertEqual(self.config.pid_file, '/pid/1')
         self.assertEqual(self.config.worker_count, 18)
-        self.assertEqual(self.config.sample_base_dir, '/tmp/1')
-        self.assertEqual(self.config.job_hash_regex, '/var/2')
-        self.assertEqual(self.config.keep_mail_data, True)
         self.assertEqual(self.config.processing_info_dir, '/var/3')
         self.assertEqual(self.config.ruleset_config, '/rules/1')
         self.assertEqual(self.config.log_level, logging.DEBUG)
         self.assertEqual(self.config.log_format, 'format%(foo1)s')
         self.assertEqual(self.config.db_url, 'sqlite:////peekaboo.db1')
+        self.assertEqual(self.config.db_async_driver, 'async')
+        self.assertEqual(self.config.db_log_level, logging.INFO)
         self.assertEqual(self.config.cluster_instance_id, 12)
         self.assertEqual(self.config.cluster_stale_in_flight_threshold, 31)
         self.assertEqual(self.config.cluster_duplicate_check_interval, 61)
@@ -386,34 +398,7 @@ def test_1_read_settings(self):
         self.assertEqual(self.config.cortex_api_token, 'tok2')
 
 
-class CreatingSampleFactory(SampleFactory):
-    """ A special kind of sample factory that creates the sample files with
-    defined content in a temporary directory and cleans up after itself. """
-    def __init__(self, *args, **kwargs):
-        self.directory = tempfile.mkdtemp()
-        super().__init__(*args, **kwargs)
-
-    def create_sample(self, relpath, content, *args, **kwargs):
-        """ Make a new sample with defined base name and content in the
-        previously created temporary directory. The given basename can
-        optionally be a path relative to the temporary directory and the
-        subdirectory will be created automatically. """
-        file_path = os.path.join(self.directory, relpath)
-        subdir = os.path.dirname(file_path)
-        if subdir != self.directory:
-            os.makedirs(subdir)
-        with open(file_path, 'w') as file_desc:
-            file_desc.write(content)
-
-        return super().make_sample(file_path, *args, **kwargs)
-
-    def __del__(self):
-        """ Remove the sample files we've created and the temporary directory
-        itself. """
-        shutil.rmtree(self.directory)
-
-
-class TestDatabase(unittest.TestCase):
+class TestDatabase(AsyncioTestCase):
     """ Unittests for Peekaboo's database module. """
     @classmethod
     def setUpClass(cls):
@@ -425,32 +410,68 @@ def setUpClass(cls):
                                       stale_in_flight_threshold=10)
         cls.no_cluster_db = PeekabooDatabase('sqlite:///' + cls.test_db,
                                              instance_id=0)
-        cls.factory = CreatingSampleFactory(
-            base_dir=cls.conf.sample_base_dir,
-            job_hash_regex=cls.conf.job_hash_regex, keep_mail_data=False,
-            processing_info_dir=None)
-        cls.sample = cls.factory.create_sample('test.py', 'test')
+        cls.sample = Sample(b'test', 'test.py')
         result = RuleResult('Unittest',
-                            Result.failed,
+                            Result.good,
                             'This is just a test case.',
                             further_analysis=False)
         cls.sample.add_rule_result(result)
 
-    def test_1_analysis_save(self):
-        """ Test saving of analysis results. """
-        self.db_con.analysis_save(self.sample)
-        self.db_con.analysis_save(self.sample)
+    @asynctest
+    async def test_1_analysis_add(self):
+        """ Test adding a new analysis. """
+        await self.db_con.analysis_add(self.sample)
+        # sample now contains a job ID
+
+    def test_2_analysis_update(self):
+        """ Test updating of analysis results. """
+        # mark sample done so journal and result retrieval tests can work
+        self.sample.mark_done()
+        self.db_con.analysis_update(self.sample)
 
-    def test_3_analysis_journal_fetch_journal(self):
+    @asynctest
+    async def test_3_analysis_journal_fetch_journal(self):
         """ Test retrieval of analysis results. """
+        await self.db_con.analysis_add(self.sample)
+        # sample now contains another, new job ID
+        # mark sample done so journal and result retrieval tests can work
+        self.sample.mark_done()
+        self.db_con.analysis_update(self.sample)
+
+        # add a failed analysis to check that it is ignored
+        result = RuleResult('Unittest',
+                            Result.failed,
+                            'This is just a test case.',
+                            further_analysis=False)
+        self.sample.add_rule_result(result)
+        await self.db_con.analysis_add(self.sample)
+        self.sample.mark_done()
+        self.db_con.analysis_update(self.sample)
+
+        # reset the job id so this sample is not ignored when fetching the
+        # journal
+        self.sample.update_id(None)
+
         journal = self.db_con.analysis_journal_fetch_journal(self.sample)
-        self.assertEqual(journal[0].result, Result.failed)
+        self.assertEqual(journal[0].result, Result.good)
         self.assertEqual(journal[0].reason, 'This is just a test case.')
         self.assertIsNotNone(journal[0].analysis_time)
-        self.assertEqual(journal[1].result, Result.failed)
+        self.assertEqual(journal[1].result, Result.good)
         self.assertEqual(journal[1].reason, 'This is just a test case.')
         self.assertIsNotNone(journal[1].analysis_time)
         self.assertNotEqual(journal[0].analysis_time, journal[1].analysis_time)
+        # does not contain the failed result
+        self.assertEqual(len(journal), 2)
+
+    @asynctest
+    async def test_4_analysis_retrieve(self):
+        """ Test retrieval of analysis results. """
+        await self.db_con.analysis_add(self.sample)
+        # sample now contains a job ID
+        reason, result = await self.db_con.analysis_retrieve(self.sample.id)
+        # does not ignore failed analyses like the journal above
+        self.assertEqual(result, Result.failed)
+        self.assertEqual(reason, 'This is just a test case.')
 
     def test_5_in_flight_no_cluster(self):
         """ Test that marking of samples as in-flight on a non-cluster-enabled
@@ -469,14 +490,14 @@ def test_6_in_flight_cluster(self):
         self.assertIsNone(self.db_con.clear_sample_in_flight(self.sample, 1))
         # unlocking twice should fail
         self.assertRaisesRegex(
-            PeekabooDatabaseError, "Unexpected inconsistency: Sample .* not "
-            "recoreded as in-flight upon clearing flag",
+            PeekabooDatabaseError, "Unexpected inconsistency: Sample not "
+            "recorded as in-flight upon clearing flag",
             self.db_con.clear_sample_in_flight, self.sample, 1)
 
     def test_7_in_flight_clear(self):
         """ Test clearing of in-flight markers. """
-        sample2 = self.factory.create_sample('foo.pyc', 'foo')
-        sample3 = self.factory.create_sample('bar.pyc', 'bar')
+        sample2 = Sample(b'foo', 'foo.pyc')
+        sample3 = Sample(b'bar', 'bar.pyc')
 
         self.assertTrue(self.db_con.mark_sample_in_flight(self.sample, 1))
         self.assertTrue(self.db_con.mark_sample_in_flight(sample2, 1))
@@ -521,7 +542,7 @@ def test_8_stale_in_flight(self):
         stale = datetime.utcnow() - timedelta(seconds=20)
         self.assertTrue(self.db_con.mark_sample_in_flight(
             self.sample, 1, stale))
-        sample2 = self.factory.create_sample('baz.pyc', 'baz')
+        sample2 = Sample(b'baz', 'baz.pyc')
         self.assertTrue(self.db_con.mark_sample_in_flight(sample2, 1))
 
         # should not clear anything because the database is not cluster-enabled
@@ -558,9 +579,6 @@ def test_8_stale_in_flight(self):
     def tearDownClass(cls):
         """ Clean up after the tests. """
         os.unlink(cls.test_db)
-        # test framework doesn't seem to give up reference so that __del__ is
-        # never run
-        del cls.factory
 
 
 class TestSample(unittest.TestCase):
@@ -571,45 +589,16 @@ def setUpClass(cls):
         cls.test_db = os.path.abspath('./test.db')
         cls.conf = CreatingPeekabooConfig()
         cls.db_con = PeekabooDatabase('sqlite:///' + cls.test_db)
-        cls.factory = CreatingSampleFactory(
-            base_dir=cls.conf.sample_base_dir,
-            job_hash_regex=cls.conf.job_hash_regex, keep_mail_data=False,
-            processing_info_dir=None)
-        part = {
-            "name_declared": "text.py",
-            "type_declared": "text/x-python"
-        }
-        cls.sample = cls.factory.create_sample('test.py', 'test', metainfo=part)
-
-    def test_job_hash_regex(self):
-        """ Test extraction of the job hash from the working directory path.
-        """
-        # class sample has no job hash in path and therefore generates one
-        # itself
-        self.assertIn('peekaboo-run_analysis', self.sample.job_hash)
-
-        # a new sample with a job hash in it's path should return it
-        job_hash = 'amavis-20170831T132736-07759-iSI0rJ4b'
-        path_with_job_hash = 'd/var/lib/amavis/tmp/%s/parts/file' % job_hash
-        sample = self.factory.make_sample(path_with_job_hash, 'file')
-        self.assertEqual(job_hash, sample.job_hash,
-                         'Job hash regex is not working')
-
-        legacy_factory = CreatingSampleFactory(
-            base_dir=self.conf.sample_base_dir,
-            job_hash_regex=r'/var/lib/amavis/tmp/([^/]+)/parts.*',
-            keep_mail_data=False, processing_info_dir=None)
-        sample = legacy_factory.make_sample(path_with_job_hash, 'file')
-        self.assertEqual(job_hash, sample.job_hash,
-                         'Job hash regex is not working')
+        cls.sample = Sample(
+            b'test', 'test.py', 'text/x-python', 'inline', 'dump', 11)
 
     def test_3_sample_attributes(self):
         """ Test the various sample attribute getters. """
-        self.assertEqual(self.sample.file_path,
-                         os.path.join(self.factory.directory, 'test.py'))
+        self.assertEqual(self.sample.content, b'test')
         self.assertEqual(self.sample.filename, 'test.py')
+        self.assertEqual(self.sample.name_declared, 'test.py')
         self.assertEqual(self.sample.file_extension, 'py')
-        self.assertEqual('text/x-python', self.sample.type_declared)
+        self.assertEqual(self.sample.type_declared, 'text/x-python')
         self.assertEqual(
             self.sample.sha256sum,
             '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08')
@@ -620,40 +609,13 @@ def test_3_sample_attributes(self):
             'File "%s" is considered "unchecked"'
             % self.sample.filename)
         self.assertEqual(self.sample.cuckoo_report, None)
-        self.assertEqual(self.sample.done, False)
-        self.assertEqual(self.sample.submit_path, None)
-        self.assertEqual(self.sample.file_size, 4)
-
-    def test_4_initialised_sample_attributes(self):
-        """ Test the various sample attributes of an initialised sample. """
-        self.sample.init()
-        self.assertEqual(self.sample.file_path,
-                         os.path.join(self.factory.directory, 'test.py'))
-        self.assertEqual(self.sample.filename, 'test.py')
-        self.assertEqual(self.sample.file_extension, 'py')
-        self.assertEqual('text/x-python', self.sample.type_declared)
-        self.assertEqual(
-            self.sample.sha256sum,
-            '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08')
-        self.assertEqual(self.sample.result, Result.unchecked)
-        self.assertEqual(self.sample.reason, None)
-        self.assertRegex(
-            self.sample.peekaboo_report[0], 'File "%s" %s is being analyzed'
-            % (self.sample.filename, self.sample.sha256sum))
-        self.assertRegex(
-            self.sample.peekaboo_report[1],
-            'File "%s" is considered "unchecked"'
-            % self.sample.filename)
-        self.assertEqual(self.sample.cuckoo_report, None)
-        self.assertEqual(self.sample.done, False)
-        self.assertRegex(
-            self.sample.submit_path, '/%s.py$' % self.sample.sha256sum)
+        self.assertEqual(self.sample.state, JobState.ACCEPTED)
         self.assertEqual(self.sample.file_size, 4)
 
     def test_5_mark_done(self):
         """ Test the marking of a sample as done. """
         self.sample.mark_done()
-        self.assertEqual(self.sample.done, True)
+        self.assertEqual(self.sample.state, JobState.FINISHED)
 
     def test_6_add_rule_result(self):
         """ Test the adding of a rule result. """
@@ -665,35 +627,18 @@ def test_6_add_rule_result(self):
         self.assertEqual(self.sample.result, Result.failed)
         self.assertEqual(self.sample.reason, reason)
 
-    def test_sample_attributes_with_meta_info(self):
-        """ Test use of optional meta data. """
-        sample = self.factory.make_sample(
-            'test.pyc', metainfo={
-                'full_name': '/tmp/test.pyc',
-                'name_declared': 'test.pyc',
-                'type_declared': 'application/x-bytecode.python',
-                'content_disposition': 'angry',
-                'type_long': 'application/x-python-bytecode',
-                'type_short': 'pyc',
-                'size': '200'})
-        self.assertEqual(sample.file_extension, 'pyc')
-        self.assertEqual(sample.content_disposition, 'angry')
-
     def test_sample_without_suffix(self):
         """ Test extraction of file extension from declared name. """
-        sample = self.factory.make_sample(
-            'junk', metainfo={
-                'full_name': '/tmp/junk',
-                'name_declared': 'Report.docx',
-                'type_declared': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-                'type_long': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-                'type_short': 'docx',
-                'size': '212'})
+        sample = Sample(None, 'Report.docx')
         self.assertEqual(sample.file_extension, 'docx')
+        sample = Sample(None, 'Report')
+        self.assertEqual(sample.file_extension, '')
+        sample = Sample(None, None)
+        self.assertEqual(sample.file_extension, None)
 
     def test_sample_extension_multiple_dots(self):
         """ Test file extension with name containing multiple dots. """
-        sample = self.factory.make_sample('junk..ext')
+        sample = Sample(None, 'junk..ext')
         self.assertEqual(sample.file_extension, 'ext')
 
     def test_sample_extension_filtering(self):
@@ -714,7 +659,7 @@ def test_sample_extension_filtering(self):
         ]
 
         for ext, accepted in testcases:
-            sample = self.factory.make_sample('junk.' + ext)
+            sample = Sample(None, 'junk.' + ext)
             self.assertEqual(
                 sample.file_extension is not None,
                 accepted,
@@ -724,20 +669,22 @@ def test_sample_extension_filtering(self):
     def tearDownClass(cls):
         """ Clean up after the tests. """
         os.unlink(cls.test_db)
-        del cls.factory
 
 
-class OletoolsSample:  # pylint: disable=too-few-public-methods
-    """ A dummy sample class that only contains a file_path and a dummy report
-    registration callback for testing the Oletools analyser. """
-    def __init__(self, file_path, report=None):
-        # don't even need to make it a property
-        self.file_path = file_path
-        self.oletools_report = report
+class FileSample(Sample):
+    """ A sample that reads its content from a file. """
+    def __init__(self, file_path, *args, **kwargs):
+        with open(file_path, 'rb') as sample_file:
+            content = sample_file.read()
+
+        # inject a derived filename into kwargs if none is supplied in args or
+        # kwargs, filename will be element 0 of args (and its length will be 1)
+        # if supplied because self and file_path of this routine will have
+        # consumed the two leading positional arguments
+        if len(args) < 1 and kwargs.get('filename') is None:
+            kwargs['filename'] = os.path.basename(file_path)
 
-    def register_oletools_report(self, report):
-        """ Dummy report registration. """
-        self.oletools_report = report
+        super().__init__(content, *args, **kwargs)
 
 
 class TestOletools(unittest.TestCase):
@@ -761,7 +708,7 @@ def test_analysis(self):
                 expected_has_autoexec, expected_detected_autoexec, \
                 expected_is_suspicious, expected_detected_suspicious in cases:
             file_path = os.path.join(self.office_data_dir, file_name)
-            report = Oletools(OletoolsSample(file_path)).get_report()
+            report = Oletools(FileSample(file_path)).get_report()
             self.assertEqual(
                 report.has_office_macros, expected_has_office_macros,
                 "Oletools has_office_macros: %s" % file_name)
@@ -784,54 +731,40 @@ def test_analysis(self):
     def test_caching(self):
         """ Test Oletools report caching in the sample. """
         report = object()
-        sample = OletoolsSample("dummy", report)
+        sample = Sample(None)
+        sample.register_oletools_report(report)
         new_report = Oletools(sample).get_report()
         self.assertIs(report, new_report)
 
 
-class FiletoolsSample:  # pylint: disable=too-few-public-methods
-    """ A dummy sample class that only contains a file_path and a dummy report
-    registration callback for testing the Filetools analyser. """
-    def __init__(self, filename, file_path=None, name_declared=None,
-                 report=None):
-        # don't even need to make it a property
-        self.filename = filename
-        self.file_path = file_path
-        self.name_declared = name_declared
-        self.filetools_report = report
-
-    def register_filetools_report(self, report):
-        """ Dummy report registration. """
-        self.filetools_report = report
-
-
 class TestFiletools(unittest.TestCase):
     """ Unittests for Oletools. """
     @classmethod
     def setUpClass(cls):
         """ Set up common test case resources. """
         cls.tests_data_dir = os.path.join(TESTSDIR, "test-data")
+        cls.office_data_dir = os.path.join(cls.tests_data_dir, "office")
 
     def test_analysis(self):
         """ Test Filetools analysis. """
+        textfile = os.path.join(self.tests_data_dir, 'textfile')
+        blank_doc = os.path.join(self.office_data_dir, 'blank.doc')
         cases = [
-            # subdir, , declared name, , type by name
-            # , file name, , type by content, , type as text
-            # a file without declared name
-            ['', 'textfile', None, 'text/plain', None, 'ASCII text'],
-            # a file with declared name but without file extension
-            ['', 'textfile', 'textfile', 'text/plain', None, 'ASCII text'],
-            # a file with declared name and file extension
-            ['', 'textfile', 'textfile.txt',
-             'text/plain', 'text/plain', 'ASCII text'],
-            # a file with declared name and a file extension that belies its
-            # actual content
-            ['', 'textfile', 'textfile.py',
-             'text/plain', 'text/x-python', 'ASCII text'],
+            # file path,       , type by content,  , type as text
+            #        , file name,              , type by name,
+            # a file without file extension
+            [textfile, 'textfile', 'text/plain', None, 'ASCII text'],
+            # a file with file extension
+            [textfile, 'textfile.txt', 'text/plain', 'text/plain',
+                'ASCII text'],
+            # a file with a file extension that belies its actual content
+            [textfile, 'textfile.py', 'text/plain', 'text/x-python',
+                'ASCII text'],
             # a word document - just because we can
             # interesting conundrum here: magic output depends on local timezone
-            ['office', 'blank.doc', None, 'application/msword',
-             'application/msword', '^Composite Document File V2 Document, '
+            [blank_doc, blank_doc,
+             'application/msword', 'application/msword',
+             '^Composite Document File V2 Document, '
              'Little Endian, Os: MacOS, Version 14.10, Code page: 10000, '
              'Author: Microsoft Office User, Template: Normal.dotm, Last '
              'Saved By: Microsoft Office User, Revision Number: 2, Name of '
@@ -840,25 +773,25 @@ def test_analysis(self):
              'Time/Date: Tue Jun 25 [0-2][0-9]:07:00 2019, Number of Pages: '
              '1, Number of Words: 0, Number of Characters: 0, Security: 0$'],
         ]
-        for subdir, file_name, name_declared, expected_type_by_content, \
+        for sample_path, file_name, expected_type_by_content, \
                 expected_type_by_name, expected_type_as_text in cases:
-            file_path = os.path.join(self.tests_data_dir, subdir, file_name)
-            sample = FiletoolsSample(file_name, file_path, name_declared)
+            sample = FileSample(sample_path, file_name)
             report = Filetools(sample).get_report()
             self.assertEqual(
                 report.type_by_content, expected_type_by_content,
-                "Filetools type_by_content: %s:%s" % (file_name, name_declared))
+                "Filetools type_by_content: %s" % file_name)
             self.assertEqual(
                 report.type_by_name, expected_type_by_name,
-                "Filetools type_by_name: %s:%s" % (file_name, name_declared))
+                "Filetools type_by_name: %s" % file_name)
             self.assertRegex(
                 report.type_as_text, expected_type_as_text,
-                "Filetools type_as_text: %s:%s" % (file_name, name_declared))
+                "Filetools type_as_text: %s" % file_name)
 
     def test_caching(self):
         """ Test Filetools report caching in the sample. """
         report = object()
-        sample = FiletoolsSample("dummy", report=report)
+        sample = Sample(None)
+        sample.register_filetools_report(report)
         new_report = Filetools(sample).get_report()
         self.assertIs(report, new_report)
 
@@ -1051,7 +984,7 @@ def __init__(self, report, failed=False):
         self.cuckoo_failed = failed
 
 
-class TestRules(unittest.TestCase):
+class TestRules(AsyncioTestCase):
     """ Unittests for Rules. """
     @classmethod
     def setUpClass(cls):
@@ -1125,52 +1058,48 @@ def test_rule_office_ole(self):
             keyword.2 : AutoClose
             keyword.3 : suSPi.ious'''
 
-        # sampe factory to create samples
-        factory = CreatingSampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
+        parsed_config = CreatingConfigParser(config)
 
         # test if macro with suspicious keyword
-        rule = OfficeMacroWithSuspiciousKeyword(
-            CreatingConfigParser(config), None)
         combinations = [
             # no office document file extension
-            [Result.unknown, factory.create_sample('test.nodoc', 'test')],
+            [Result.unknown, Sample(b'test', 'test.nodoc')],
             # test with empty file
-            [Result.unknown, factory.make_sample(os.path.join(
+            [Result.unknown, FileSample(os.path.join(
                 self.office_data_dir, 'empty.doc'))],
             # office document with 'suspicious' in macro code
-            [Result.bad, factory.make_sample(os.path.join(
+            [Result.bad, FileSample(os.path.join(
                 self.office_data_dir, 'suspiciousMacro.doc'))],
             # test with blank word doc
-            [Result.unknown, factory.make_sample(os.path.join(
+            [Result.unknown, FileSample(os.path.join(
                 self.office_data_dir, 'blank.doc'))],
             # test with legitimate macro
-            [Result.unknown, factory.make_sample(os.path.join(
+            [Result.unknown, FileSample(os.path.join(
                 self.office_data_dir, 'legitmacro.xls'))]
         ]
+        rule = OfficeMacroWithSuspiciousKeyword(parsed_config, None)
         for expected, sample in combinations:
             result = rule.evaluate(sample)
             self.assertEqual(result.result, expected)
 
         # test if macro present
-        rule = OfficeMacroRule(CreatingConfigParser(config), None)
         combinations = [
             # no office document file extension
-            [Result.unknown, factory.create_sample('test.nodoc', 'test')],
+            [Result.unknown, Sample(b'test', 'test.nodoc')],
             # test with empty file
-            [Result.unknown, factory.make_sample(os.path.join(
+            [Result.unknown, FileSample(os.path.join(
                 self.office_data_dir, 'empty.doc'))],
             # office document with 'suspicious' in macro code
-            [Result.bad, factory.make_sample(os.path.join(
+            [Result.bad, FileSample(os.path.join(
                 self.office_data_dir, 'suspiciousMacro.doc'))],
             # test with blank word doc
-            [Result.unknown, factory.make_sample(os.path.join(
+            [Result.unknown, FileSample(os.path.join(
                 self.office_data_dir, 'blank.doc'))],
             # test with legitimate macro
-            [Result.bad, factory.make_sample(os.path.join(
+            [Result.bad, FileSample(os.path.join(
                 self.office_data_dir, 'legitmacro.xls'))]
         ]
+        rule = OfficeMacroRule(parsed_config, None)
         for expected, sample in combinations:
             result = rule.evaluate(sample)
             self.assertEqual(result.result, expected)
@@ -1182,24 +1111,20 @@ def test_rule_ignore_generic_whitelist(self):
                               'text/plain', 'inode/x-empty', 'image/jpeg'
                           } -> ignore
         '''
-        factory = CreatingSampleFactory(
-            base_dir="",
-            job_hash_regex="", keep_mail_data=False,
-            processing_info_dir=None)
 
-        sample = factory.create_sample('file.txt', 'abc')
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
+
+        sample = Sample(b'abc', 'file.txt')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
-        sample = factory.create_sample('file.html', '<html')
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = Sample(b'<html', 'file.html')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         # bzip2 compressed data
-        sample = factory.create_sample('file.txt', 'BZh91AY=')
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = Sample(b'BZh91AY=', 'file.txt')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
@@ -1209,23 +1134,18 @@ def test_rule_ignore_no_name_declared(self):
             expression.3  : not sample.name_declared -> ignore
         '''
 
-        factory = CreatingSampleFactory(
-            base_dir="",
-            job_hash_regex="", keep_mail_data=False,
-            processing_info_dir=None)
+        sample_kwargs = {
+            'content_type': 'image/gif',
+        }
 
-        part = {"full_name": "file1.gif",
-                "name_declared": "file1.gif",
-                "type_declared": "image/gif"
-               }
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
 
-        sample = factory.create_sample('file1.gif', 'GIF87...', metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = Sample(b'GIF87...', 'file1.gif', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        sample = factory.create_sample('file2.gif', 'GIF87...')
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = Sample(b'GIF87...')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
@@ -1233,8 +1153,10 @@ def test_rule_ignore_no_name_declared(self):
             expression.3  : sample.name_declared is None -> ignore
         '''
 
-        sample = factory.create_sample('file2.gif', 'GIF87...')
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
+
+        sample = Sample(b'GIF87...')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
@@ -1253,67 +1175,55 @@ def test_rule_ignore_mail_signatures(self):
                     'application/pgp-signature'
                 } -> ignore
             '''
-        rule = ExpressionRule(CreatingConfigParser(config), None)
 
-        factory = SampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
 
-        sample = factory.make_sample('file.1')
+        sample = Sample(None, 'file.1')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         # test smime signatures
-        part = {
-            "full_name": "p001",
-            "name_declared": "smime.p7s",
-            "type_declared": "application/pkcs7-signature"
+        sample_kwargs = {
+            'content_type': 'application/pkcs7-signature'
         }
 
-        sample = factory.make_sample('', metainfo=part)
+        sample = Sample(None, filename='smime.p7s', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
-        part["name_declared"] = "asmime.p7m"
-        sample = factory.make_sample('', metainfo=part)
+        sample = Sample(None, filename='asmime.p7s', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        part["name_declared"] = "smime.p7m"
-        sample = factory.make_sample('', metainfo=part)
+        sample = Sample(None, filename='smime.p7m', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
-        part["name_declared"] = "smime.p7o"
-        sample = factory.make_sample('', metainfo=part)
+        sample = Sample(None, filename='smime.p7o', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        part["name_declared"] = "smime.p7"
-        sample = factory.make_sample('', metainfo=part)
+        sample = Sample(None, filename='smime.p7', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        part["name_declared"] = "smime.p7sm"
-        sample = factory.make_sample('', metainfo=part)
+        sample = Sample(None, filename='smime.p7sm', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        part["name_declared"] = "file"
-        sample = factory.make_sample('', metainfo=part)
+        sample = Sample(None, filename='file', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         # test gpg signatures
-        part["name_declared"] = "signature.asc"
-        sample = factory.make_sample('', metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        # test with wrong content type
+        sample = Sample(None, filename='signature.asc', **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        part["type_declared"] = "application/pgp-signature"
-        sample = factory.make_sample('', metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = Sample(None, filename='signature.asc',
+                        content_type="application/pgp-signature")
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
@@ -1330,13 +1240,10 @@ def test_rule_expressions(self):
         }
         cuckooreport = CuckooReport(report)
 
-        factory = SampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
-
-        sample = factory.make_sample('')
+        sample = Sample(None)
         sample.register_cuckoo_report(cuckooreport)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
@@ -1348,25 +1255,21 @@ def test_rule_expressions_rtf(self):
                     {sample.type_declared} | filereport.mime_types) -> bad
         '''
 
-        factory = SampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
-
-        part = {
-            "full_name": "p001",
-            "name_declared": "file1.doc",
-            "type_declared": "application/word"
+        kwargs = {
+            'filename': 'file1.doc',
+            'content_type': 'application/word'
         }
 
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
+
         path = os.path.join(self.office_data_dir, 'blank.doc')
-        sample = factory.make_sample(path, metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path, **kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         path = os.path.join(self.office_data_dir, 'example.rtf')
-        sample = factory.make_sample(path, metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path, **kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
@@ -1379,37 +1282,33 @@ def test_rule_expression_filetools(self):
                 and filereport.type_by_content != /application\/.*word/ -> bad
         '''
 
-        factory = SampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
-
-        part = {
-            "full_name": "p001",
-            "name_declared": "file1.doc",
-            "type_declared": "application/word"
+        sample_kwargs = {
+            'filename': 'file1.doc',
+            'content_type': 'application/word',
         }
 
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
+
         path = os.path.join(self.office_data_dir, 'blank.doc')
-        sample = factory.make_sample(path, metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path, **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         path = os.path.join(self.office_data_dir, 'example.rtf')
-        sample = factory.make_sample(path, metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path, **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
         path = os.path.join(
             self.office_data_dir,
             'AppleDoubleencodedMacintoshfileCheckVM.xls')
-        sample = factory.make_sample(path, metainfo=part)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path, **sample_kwargs)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
-    def test_rule_expression_knowntools(self):
+    @asynctest
+    async def test_rule_expression_knowntools(self):
         """ Test generic rule on knowntoolsreport. """
         config = r'''[expressions]
             expression.0  : sample.filename == "first.a" and knownreport.known -> bad
@@ -1424,45 +1323,68 @@ def test_rule_expression_knowntools(self):
         db_con = PeekabooDatabase('sqlite:///' + test_db,
                                       instance_id=1,
                                       stale_in_flight_threshold=10)
-        factory = CreatingSampleFactory(
-            base_dir=conf.sample_base_dir,
-            job_hash_regex=conf.job_hash_regex, keep_mail_data=False,
-            processing_info_dir=None)
 
-        sample = factory.create_sample('test.py', 'test')
+        sample = Sample(b'test', 'test.py')
+        good_result = RuleResult(
+            'Unittest', Result.good, 'This is just a test case.',
+            further_analysis=False)
+        sample.add_rule_result(good_result)
+        await db_con.analysis_add(sample)
+        sample.mark_done()
+        db_con.analysis_update(sample)
+
+        # add a failed analysis to check that it is ignored
         result = RuleResult('Unittest',
                             Result.failed,
                             'This is just a test case.',
                             further_analysis=False)
         sample.add_rule_result(result)
-        db_con.analysis_save(sample)
+        await db_con.analysis_add(sample)
+        sample.mark_done()
+        db_con.analysis_update(sample)
 
-        rule = ExpressionRule(CreatingConfigParser(config), db_con)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, db_con)
 
-        sample = factory.create_sample('test.py', 'test')
+        sample = Sample(b'test', 'test.py')
         result = rule.evaluate(sample)
-        self.assertEqual(result.result, Result.failed)
+        self.assertEqual(result.result, Result.good)
 
-        sample = factory.create_sample('first.a', 'firsttest')
+        sample = Sample(b'firsttest', 'first.a')
         result = rule.evaluate(sample)
-        sample.add_rule_result(result)
-        db_con.analysis_save(sample)
         self.assertEqual(result.result, Result.ignored)
 
-        sample = factory.create_sample('first.a', 'firsttest')
+        sample.add_rule_result(result)
+        await db_con.analysis_add(sample)
+        sample.mark_done()
+        db_con.analysis_update(sample)
+
+        sample = Sample(b'firsttest', 'first.a')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
-        sample = factory.create_sample('second.b', 'secondtest')
+        sample = Sample(b'secondtest', 'second.b')
         result = rule.evaluate(sample)
-        sample.add_rule_result(result)
-        db_con.analysis_save(sample)
         self.assertEqual(result.result, Result.ignored)
 
-        sample = factory.create_sample('second.b', 'secondtest')
+        sample.add_rule_result(result)
+        await db_con.analysis_add(sample)
+        sample.mark_done()
+        db_con.analysis_update(sample)
+
+        sample = Sample(b'secondtest', 'second.b')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.ignored)
 
+        sample.add_rule_result(good_result)
+        await db_con.analysis_add(sample)
+        sample.mark_done()
+        db_con.analysis_update(sample)
+
+        sample = Sample(b'secondtest', 'second.b')
+        result = rule.evaluate(sample)
+        self.assertEqual(result.result, Result.good)
+
         os.unlink(test_db)
 
     def test_rule_expressions_cuckooreport_context(self):
@@ -1472,22 +1394,18 @@ def test_rule_expressions_cuckooreport_context(self):
                 and cuckooreport.score > 4 -> bad
         '''
 
-        factory = CreatingSampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
-
         report = {
             "signatures": [
                 {"description": "EVIL"}
             ],
             "info": {"score": 4.2}
         }
-        cuckooreport = CuckooReport(report)
 
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'blank.doc'))
-        sample.register_cuckoo_report(cuckooreport)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        blank_doc = os.path.join(self.office_data_dir, 'blank.doc')
+        sample = FileSample(blank_doc)
+        sample.register_cuckoo_report(CuckooReport(report))
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
@@ -1498,14 +1416,6 @@ def test_rule_expressions_cortexreport_fileinfoanalyzerreport_context(self):
                 expression.4  : '.day.um' in cortexreport.FileInfoReport.domain_artifacts -> unknown
                 expression.6  : '8.8.8.8' in cortexreport.FileInfoReport.ip_artifacts -> bad
         '''
-        rule = ExpressionRule(CreatingConfigParser(config), None)
-
-        factory = CreatingSampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
-
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'blank.doc'))
 
         taxonomies = [
             {
@@ -1570,7 +1480,13 @@ def test_rule_expressions_cortexreport_fileinfoanalyzerreport_context(self):
 
         cortexreport = CortexReport()
         cortexreport.register_report(FileInfoAnalyzer, report)
+
+        blank_doc = os.path.join(self.office_data_dir, 'blank.doc')
+        sample = FileSample(blank_doc)
         sample.register_cortex_report(cortexreport)
+
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.good)
 
@@ -1579,9 +1495,14 @@ def test_rule_expressions_cortexreport_fileinfoanalyzerreport_context(self):
             "data": "8.8.8.8",
             "dataType": "ip",
         }]
+
         cortexreport = CortexReport()
         cortexreport.register_report(FileInfoAnalyzer, report)
+
+        sample = FileSample(blank_doc)
         sample.register_cortex_report(cortexreport)
+
+        # re-use rule
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
@@ -1591,6 +1512,7 @@ def test_rule_expressions_cortexreport_fileinfoanalyzerreport_context(self):
         }]
         cortexreport = CortexReport()
         cortexreport.register_report(FileInfoAnalyzer, report)
+        sample = FileSample(blank_doc)
         sample.register_cortex_report(cortexreport)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
@@ -1607,15 +1529,6 @@ def test_rule_expressions_cortexreport_virustotalqueryreport_context(self):
                 or cortexreport.VirusTotalQueryReport.level != 'safe'
                 ->  bad
         '''
-        rule = ExpressionRule(CreatingConfigParser(config), None)
-
-        factory = CreatingSampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
-
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'blank.doc'))
-
         tax = {
             "level": "malicious",
             "namespace": "VT",
@@ -1637,29 +1550,50 @@ def test_rule_expressions_cortexreport_virustotalqueryreport_context(self):
             "artifacts": [],
             "operations": []
         }
+
         cortexreport = CortexReport()
         cortexreport.register_report(VirusTotalQuery, report)
 
+        blank_doc = os.path.join(self.office_data_dir, 'blank.doc')
+        sample = FileSample(blank_doc)
         sample.register_cortex_report(cortexreport)
+
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
         result = rule.evaluate(sample)
+
         self.assertEqual(result.result, Result.bad)
 
+        # patch report for different outcome
         tax["level"] = "safe"
         tax["value"] = "0/86"
         report["summary"]["taxonomies"].append(tax)
+
+        cortexreport = CortexReport()
         cortexreport.register_report(VirusTotalQuery, report)
+
+        # re-use rule
+        sample = FileSample(blank_doc)
         sample.register_cortex_report(cortexreport)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
+        # patch report for different outcome
         report["summary"]["taxonomies"].pop(0)
+
+        cortexreport = CortexReport()
         cortexreport.register_report(VirusTotalQuery, report)
+
+        # re-use rule
+        sample = FileSample(blank_doc)
         sample.register_cortex_report(cortexreport)
         result = rule.evaluate(sample)
+
         self.assertEqual(result.result, Result.unknown)
 
         report["summary"]["taxonomies"][0]["value"] = "NAN"
         with self.assertRaises(schema.SchemaError):
+            cortexreport = CortexReport()
             cortexreport.register_report(VirusTotalQuery, report)
 
     def test_rule_expressions_olereport_context(self):
@@ -1669,57 +1603,50 @@ def test_rule_expressions_olereport_context(self):
                               and olereport.has_office_macros == True -> bad
         '''
 
-        factory = CreatingSampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
 
         path = os.path.join(self.office_data_dir, 'empty.doc')
-        sample = factory.make_sample(path)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         path = os.path.join(self.office_data_dir, 'file.txt')
-        sample = factory.make_sample(path)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         path = os.path.join(self.office_data_dir, 'blank.doc')
-        sample = factory.make_sample(path)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         path = os.path.join(self.office_data_dir, 'example.rtf')
-        sample = factory.make_sample(path)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
         path = os.path.join(self.office_data_dir, 'suspiciousMacro.rtf')
-        sample = factory.make_sample(path)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
         path = os.path.join(self.office_data_dir, 'suspiciousMacro.doc')
-        sample = factory.make_sample(path)
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
         path = os.path.join(self.office_data_dir, 'suspiciousMacro.doc')
-        sample = factory.make_sample(path, metainfo={
-            'name_declared': 'foo.rtx'})
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        sample = FileSample(path, filename='foo.rtx')
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
         config = '''[expressions]
             expression.3  : /suspicious/ in olereport.vba_code -> bad
         '''
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
@@ -1731,57 +1658,52 @@ def test_rule_expressions_olereport_autoexec_suspicious(self):
             expression.5  : "suspicious" in olereport.vba_code -> bad
         '''
 
-        factory = CreatingSampleFactory(
-            base_dir=None, job_hash_regex=None,
-            keep_mail_data=False, processing_info_dir=None)
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'empty.doc'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
+
+        path = os.path.join(self.office_data_dir, 'empty.doc')
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'file.txt'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        path = os.path.join(self.office_data_dir, 'file.txt')
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'blank.doc'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        path = os.path.join(self.office_data_dir, 'blank.doc')
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'example.rtf'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        path = os.path.join(self.office_data_dir, 'example.rtf')
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.unknown)
 
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'suspiciousMacro.rtf'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        path = os.path.join(self.office_data_dir, 'suspiciousMacro.rtf')
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'suspiciousMacro.doc'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        path = os.path.join(self.office_data_dir, 'suspiciousMacro.doc')
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'CheckVM.xls'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+        path = os.path.join(self.office_data_dir, 'CheckVM.xls')
+        sample = FileSample(path)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)
 
         config = '''[expressions]
             expression.5  : "VBOX" in olereport.detected_suspicious -> bad
         '''
-        sample = factory.make_sample(os.path.join(
-            self.office_data_dir, 'CheckVM.xls'))
-        rule = ExpressionRule(CreatingConfigParser(config), None)
+
+        path = os.path.join(self.office_data_dir, 'CheckVM.xls')
+        sample = FileSample(path)
+        parsed_config = CreatingConfigParser(config)
+        rule = ExpressionRule(parsed_config, None)
         result = rule.evaluate(sample)
         self.assertEqual(result.result, Result.bad)