Repository: ivre/ivre (language: python)

Issue:
improve performance db2view
### Discussed in https://github.com/ivre/ivre/discussions/1495

db2view indexation process seems not being processed with multiple threads, having a big impact in performance, making it impossible to use IVRE to any massive task. Indexing process for some millions is taking weeks to finish (running ivre db2view ). MongoDB seems not be the bottleneck, but IVRE process is using just 1 CPU.


Discussion / hints:
Hi,
As workarround to be able search imported data directly at mongodb, instead of waiting to db2view to finish, i've created manually an index at IP Addres:
```
use ivre
db.hosts.createIndex({addr_1:1})
```
To access hosts data at mongodb bypassing IVRE:
`db.hosts.find( { addr_1: Long("xxxxxxxxxxxxxxx") } )`
Now trying to run IVRE in multithreading avoiding GIL using https://github.com/colesbury/nogil
I don't understand. You already have an index, and I don't think you need to "bypass" IVRE. Can you elaborate?
Hi Pierre,
As far as i know, until the 'magic' of db2view has been finished i cannot see or retrieve imported scan from IVRE API or dashboard. This task is taking weeks to finish. Did i miss something or did i miss anything?
Regards,
`db2view` has nothing magical! It certainly does not "lock" IVRE and won't prevent you access the data while it processes / creates it. If you can access the data via MongoDB, you can access it via IVRE (again, there is no internal lock)
BTW if you use the `hosts` column, it means you are accessing the "scan" purpose (where results are not merged); you can access it using `ivre scancli` CLI or the `db.nmap.get()` Python API) and not the "view" purpose.

Fix patch:
diff --git a/ivre/active/data.py b/ivre/active/data.py
index 885339cd7e..d472b09592 100644
--- a/ivre/active/data.py
+++ b/ivre/active/data.py
@@ -46,7 +46,7 @@
 from urllib.parse import urlparse
 
 from ivre.active.cpe import add_cpe_values
-from ivre.config import DATA_PATH, VIEW_SYNACK_HONEYPOT_COUNT
+from ivre.config import DATA_PATH, VIEW_MAX_HOSTNAMES_COUNT, VIEW_SYNACK_HONEYPOT_COUNT
 from ivre.data.abuse_ch.sslbl import SSLBL_CERTIFICATES, SSLBL_JA3
 from ivre.data.microsoft.exchange import EXCHANGE_BUILDS
 from ivre.types import NmapServiceMatch, ParsedCertificate, Tag
@@ -374,6 +374,14 @@ def is_synack_honeypot(host: NmapHost) -> bool:
     )
 
 
+def has_toomany_hostnames(host: NmapHost) -> bool:
+    """Returns True iff the host has the "Too many hostnames" tag."""
+    return any(
+        tag["value"] == "CDN" and "Too many hostnames" in tag.get("info", [])
+        for tag in host.get("tags", [])
+    )
+
+
 TAG_CDN: Tag = {"value": "CDN", "type": "info"}
 TAG_DEFAULT_PASSWORD: Tag = {"value": "Default password", "type": "danger"}
 TAG_HONEYPOT: Tag = {"value": "Honeypot", "type": "warning"}
@@ -556,6 +564,12 @@ def gen_auto_tags(
                     info=[f"Hostname {name} suggests a Shodan scanner"],
                 ),
             )
+    if (
+        VIEW_MAX_HOSTNAMES_COUNT
+        and len(host.get("hostnames", [])) > VIEW_MAX_HOSTNAMES_COUNT
+    ):
+        del host["hostnames"]
+        yield cast(Tag, dict(TAG_CDN, info=["Too many hostnames"]))
     for port in host.get("ports", []):
         if any("honeypot" in port.get(field, "").lower() for field in _SERVICE_FIELDS):
             cur_info = []
@@ -781,6 +795,10 @@ def gen_auto_tags(
         yield cast(Tag, dict(TAG_HONEYPOT, info=["SYN+ACK honeypot"]))
     # Now the "closed" / "filtered" ports. Note: this won't create any
     # tag but it is probably the best place to have this code!
+    clean_nonopen_ports(host)
+
+
+def clean_nonopen_ports(host: NmapHost) -> None:
     for status in ["closed", "filtered"]:
         n_ports = sum(
             1 for port in host.get("ports", []) if port.get("state_state") == status
@@ -1290,11 +1308,20 @@ def merge_scripts(
     return func(curscript, script, script_id)
 
 
-def merge_host_docs(rec1: NmapHost, rec2: NmapHost) -> NmapHost:
+def merge_host_docs(
+    rec1: NmapHost,
+    rec2: NmapHost,
+    auto_tags: bool = True,
+    openports_attribute: bool = True,
+) -> NmapHost:
     """Merge two host records and return the result. Unmergeable /
     hard-to-merge fields are lost (e.g., extraports).
 
     """
+    if not rec2:
+        return rec1
+    if not rec1:
+        return rec2
     if rec1.get("schema_version") != rec2.get("schema_version"):
         raise ValueError(
             "Cannot merge host documents. "
@@ -1362,14 +1389,18 @@ def merge_host_docs(rec1: NmapHost, rec2: NmapHost) -> NmapHost:
     rec["infos"] = {}
     for record in [rec1, rec2]:
         rec["infos"].update(record.get("infos", {}))
-    # We want to make sure of (type, name) unicity
-    hostnames = {
-        (h["type"], h["name"]): h.get("domains")
-        for h in (rec1.get("hostnames", []) + rec2.get("hostnames", []))
-    }
-    rec["hostnames"] = [
-        {"type": h[0], "name": h[1], "domains": d} for h, d in hostnames.items()
-    ]
+    if not (has_toomany_hostnames(rec1) or has_toomany_hostnames(rec2)):
+        # We want to make sure of (type, name) unicity
+        hostnames = {
+            (h["type"], h["name"]): h.get("domains")
+            for h in chain(rec1.get("hostnames", []), rec2.get("hostnames", []))
+        }
+        if VIEW_MAX_HOSTNAMES_COUNT and len(hostnames) > VIEW_MAX_HOSTNAMES_COUNT:
+            add_tags(rec, [cast(Tag, dict(TAG_CDN, info=["Too many hostnames"]))])
+        elif hostnames:
+            rec["hostnames"] = [
+                {"type": h[0], "name": h[1], "domains": d} for h, d in hostnames.items()
+            ]
     addresses: NmapAddress = {}
     for record in [rec1, rec2]:
         for atype, addrs in record.get("addresses", {}).items():
@@ -1383,10 +1414,6 @@ def merge_host_docs(rec1: NmapHost, rec2: NmapHost) -> NmapHost:
     if addresses:
         rec["addresses"] = addresses
     if sa_honeypot:
-        add_tags(
-            rec,
-            [{"value": "Honeypot", "type": "warning", "info": ["SYN+ACK honeypot"]}],
-        )
         for record in [rec1, rec2]:
             if not is_synack_honeypot(record):
                 record["ports"] = [
@@ -1447,8 +1474,38 @@ def merge_host_docs(rec1: NmapHost, rec2: NmapHost) -> NmapHost:
             port.get("port"),
         ),
     )
-    set_auto_tags(rec, update_openports=False)
-    set_openports_attribute(rec)
+    if auto_tags:
+        set_auto_tags(rec, update_openports=False)
+    else:
+        # we at least need to clean-up the ports
+        # first: syn-ack honeypot
+        n_ports = sum(
+            1 for port in rec.get("ports", []) if port.get("state_state") == "open"
+        )
+        if (
+            VIEW_SYNACK_HONEYPOT_COUNT is not None
+            and n_ports >= VIEW_SYNACK_HONEYPOT_COUNT
+        ):
+            # check if we have too many open ports that could be
+            # "syn-ack honeypots"...
+            newports = [
+                port
+                for port in rec["ports"]
+                if port.get("state_state") != "open" or is_real_service_port(port)
+            ]
+            if (
+                n_ports
+                - sum(1 for port in newports if port.get("state_state") == "open")
+                > VIEW_SYNACK_HONEYPOT_COUNT
+            ):
+                # ... if so, keep only the ports that cannot be "syn-ack
+                # honeypots"
+                rec["ports"] = newports
+                add_tags(
+                    rec, [cast(Tag, dict(TAG_HONEYPOT, info=["SYN+ACK honeypot"]))]
+                )
+    if openports_attribute:
+        set_openports_attribute(rec)
     for field in ["traces", "infos", "ports", "cpes"]:
         if not rec[field]:
             del rec[field]
diff --git a/ivre/activecli.py b/ivre/activecli.py
index 26dc55efad..7abe5a8110 100644
--- a/ivre/activecli.py
+++ b/ivre/activecli.py
@@ -1,7 +1,7 @@
 #! /usr/bin/env python
 
 # This file is part of IVRE.
-# Copyright 2011 - 2021 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -728,11 +728,10 @@ def displayfunction_json(
     else:
         indent = None
     for h in cur:
-        for fld in ["_id", "scanid"]:
-            try:
-                del h[fld]
-            except KeyError:
-                pass
+        try:
+            del h["_id"]
+        except KeyError:
+            pass
         for port in h.get("ports", []):
             if no_screenshots:
                 for fname in ["screenshot", "screendata"]:
diff --git a/ivre/config.py b/ivre/config.py
index 03d92f3cec..33258eed11 100644
--- a/ivre/config.py
+++ b/ivre/config.py
@@ -2,7 +2,7 @@
 # -*- coding: utf-8 -*-
 
 # This file is part of IVRE.
-# Copyright 2011 - 2021 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -255,6 +255,9 @@
 # situations. None means that no host should be treated as a "syn-ack
 # honeypot".
 VIEW_SYNACK_HONEYPOT_COUNT = 200
+# Some hostnames (often at CDNs) have too many hostnames and it might
+# not be very useful to count them all
+VIEW_MAX_HOSTNAMES_COUNT = 200
 
 WEB_ALLOWED_REFERERS = None
 WEB_NOTES_BASE = "/dokuwiki/#IP#"
diff --git a/ivre/db/__init__.py b/ivre/db/__init__.py
index c4cc21e270..9af920fc07 100644
--- a/ivre/db/__init__.py
+++ b/ivre/db/__init__.py
@@ -51,7 +51,7 @@
     USE_CLUSTER = False
 
 
-from ivre import config, flow, geoiputils, nmapout, passive, utils, xmlnmap
+from ivre import config, flow, nmapout, passive, utils, xmlnmap
 from ivre.active.cpe import add_cpe_values
 from ivre.active.data import (
     ALIASES_TABLE_ELEMS,
@@ -61,7 +61,6 @@
     handle_http_headers,
     merge_host_docs,
     set_auto_tags,
-    set_openports_attribute,
 )
 from ivre.data.microsoft.exchange import EXCHANGE_BUILDS
 from ivre.zgrabout import ZGRAB_PARSERS
@@ -101,14 +100,6 @@ def __init__(self):
         self.argparser.add_argument(
             "--category", metavar="CAT", help="show only results from this category"
         )
-        self.argparser.add_argument(
-            "--country", metavar="CODE", help="show only results from this country"
-        )
-        self.argparser.add_argument(
-            "--asnum",
-            metavar="NUM[,NUM[...]]",
-            help="show only results from this(those) AS(es)",
-        )
         self.argparser.add_argument("--port", metavar="PORT")
         self.argparser.add_argument("--service", metavar="SVC[:PORT]")
         self.argparser.add_argument("--svchostname", metavar="HOSTNAME")
@@ -150,15 +141,6 @@ def parse_args(self, args, flt=None):
             flt = self.flt_empty
         if args.category is not None:
             flt = self.flt_and(flt, self.searchcategory(utils.str2list(args.category)))
-        if args.country is not None:
-            flt = self.flt_and(flt, self.searchcountry(utils.str2list(args.country)))
-        if args.asnum is not None:
-            if args.asnum[:1] in "!-":
-                flt = self.flt_and(
-                    flt, self.searchasnum(utils.str2list(args.asnum[1:]), neg=True)
-                )
-            else:
-                flt = self.flt_and(flt, self.searchasnum(utils.str2list(args.asnum)))
         if args.port is not None:
             port = args.port.replace("_", "/")
             if "/" in port:
@@ -958,17 +940,12 @@ def __init__(self):
                 18: (19, self.__migrate_schema_hosts_18_19),
                 19: (20, self.__migrate_schema_hosts_19_20),
                 20: (21, self.__migrate_schema_hosts_20_21),
+                21: (22, self.__migrate_schema_hosts_21_22),
             },
         }
-        self.argparser.add_argument(
-            "--asname", metavar="NAME", help="show only results from this(those) AS(es)"
-        )
         self.argparser.add_argument(
             "--source", metavar="SRC", help="show only results from this source"
         )
-        self.argparser.add_argument(
-            "--tag", metavar="VALUE[:INFO]", help="show only results with this tag"
-        )
         self.argparser.add_argument("--version", metavar="VERSION", type=int)
         self.argparser.add_argument("--timeago", metavar="SECONDS", type=int)
         self.argparser.add_argument("--no-timeago", metavar="SECONDS", type=int)
@@ -1024,10 +1001,6 @@ def __init__(self):
         self.argparser.add_argument("--torcert", action="store_true")
         self.argparser.add_argument("--sshkey", metavar="FINGERPRINT")
 
-    @staticmethod
-    def is_scan_present(_):
-        return False
-
     def start_store_hosts(self):
         """Backend-specific subclasses may use this method to create some bulk
         insert structures.
@@ -1520,7 +1493,7 @@ def __migrate_schema_hosts_19_20(cls, doc):
 
     @staticmethod
     def __migrate_schema_hosts_20_21(doc):
-        """Converts a record from version 20 to version 21. Version 20
+        """Converts a record from version 20 to version 21. Version 21
         introduces a structured output for data from ssl-jarm.
 
         """
@@ -1534,14 +1507,23 @@ def __migrate_schema_hosts_20_21(doc):
         return doc
 
     @staticmethod
-    def json2dbrec(host):
-        return host
+    def __migrate_schema_hosts_21_22(doc):
+        """Converts a record from version 21 to version 22. Version 22
+        removes the `scanid` field and the `infos` and `tags` fields
+        in the Nmap purpose (they are now only used in the View
+        purpose).
 
-    def store_scan_doc(self, scan):
-        pass
+        """
+        assert doc["schema_version"] == 21
+        try:
+            del doc["scanid"]
+        except KeyError:
+            pass
+        doc["schema_version"] = 22
 
-    def update_scan_doc(self, scan_id, data):
-        pass
+    @staticmethod
+    def json2dbrec(host):
+        return host
 
     def remove(self, host):
         raise NotImplementedError
@@ -1952,24 +1934,11 @@ def _searchhassh(cls, value_or_hash=None):
 
     def parse_args(self, args, flt=None):
         flt = super().parse_args(args, flt=flt)
-        if not hasattr(args, "asname"):
+        if not hasattr(args, "source"):
             # This is not from DBActive, probably from DB
             return flt
-        if args.asname is not None:
-            flt = self.flt_and(flt, self.searchasname(utils.str2regexp(args.asname)))
         if args.source is not None:
             flt = self.flt_and(flt, self.searchsource(args.source))
-        if args.tag is not None:
-            tag = {}
-            if ":" in args.tag:
-                value, info = args.tag.split(":", 1)
-                if value:
-                    tag["value"] = utils.str2regexp(value)
-                if info:
-                    tag["info"] = utils.str2regexp(info)
-            elif args.tag:
-                tag["value"] = utils.str2regexp(args.tag)
-            flt = self.flt_and(flt, self.searchtag(tag))
         if args.version is not None:
             flt = self.flt_and(flt, self.searchversion(args.version))
         if args.timeago is not None:
@@ -2105,10 +2074,6 @@ def parse_args(self, args, flt=None):
     def cmp_schema_version_host(_):
         return 0
 
-    @staticmethod
-    def cmp_schema_version_scan(_):
-        return 0
-
 
 class DBNmap(DBActive):
     backends = {
@@ -2122,11 +2087,28 @@ class DBNmap(DBActive):
 
     def __init__(self, output_mode="json", output=sys.stdout):
         super().__init__()
+        self._schema_migrations["hosts"][21] = (22, self.__migrate_schema_hosts_21_22)
         self.output_function = {
             "normal": nmapout.displayhosts,
         }.get(output_mode, nmapout.displayhosts_json)
         self.output = output
 
+    @staticmethod
+    def __migrate_schema_hosts_21_22(doc):
+        """Converts a record from version 21 to version 22. Version 22
+        removes the `scanid` field and the `infos` and `tags` fields
+        in the Nmap purpose (they are now only used in the View
+        purpose).
+
+        """
+        assert doc["schema_version"] == 21
+        doc["schema_version"] = 22
+        for key in ["scanid", "infos"]:
+            try:
+                del doc[key]
+            except KeyError:
+                pass
+
     def store_host(self, host):
         if self.output_function is not None:
             self.output_function(host, out=self.output)
@@ -2136,10 +2118,6 @@ def store_scan(self, fname, **kargs):
         store_scan_* method to parse (and store) the scan result.
 
         """
-        scanid = utils.hash_file(fname, hashtype="sha256")
-        if self.is_scan_present(scanid):
-            utils.LOGGER.debug("Scan already present in Database (%r).", fname)
-            return False
         with utils.open_file(fname) as fdesc:
             fchar = fdesc.read(1)
             if fchar == b"{":
@@ -2197,7 +2175,7 @@ def store_scan(self, fname, **kargs):
                 raise ValueError(  # pylint: disable=raise-missing-from
                     f"Unknown file type {fname}"
                 )
-        return store_scan_function(fname, filehash=scanid, **kargs)
+        return store_scan_function(fname, **kargs)
 
     def store_scan_xml(self, fname, callback=None, **kargs):
         """This method parses an XML scan result, displays a JSON
@@ -2236,13 +2214,10 @@ def store_scan_xml(self, fname, callback=None, **kargs):
     def store_scan_json_ivre(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -2261,7 +2236,6 @@ def store_scan_json_ivre(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -2272,23 +2246,10 @@ def store_scan_json_ivre(
                     continue
                 if "_id" in host:
                     del host["_id"]
-                host["scanid"] = filehash
                 if categories:
                     host["categories"] = categories
                 if source is not None:
                     host["source"] = source
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
                 # Update schema if/as needed.
                 while host.get("schema_version") in self._schema_migrations["hosts"]:
                     oldvers = host.get("schema_version")
@@ -2301,11 +2262,6 @@ def store_scan_json_ivre(
                             host,
                         )
                         break
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -2315,13 +2271,10 @@ def store_scan_json_ivre(
     def store_scan_json_zgrab(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         zgrab_port=None,
         **_,
@@ -2341,7 +2294,6 @@ def store_scan_json_zgrab(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         if zgrab_port is not None:
             zgrab_port = int(zgrab_port)
@@ -2350,7 +2302,6 @@ def store_scan_json_zgrab(
                 rec = json.loads(line.decode())
                 host = {
                     "state": "up",
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                 }
                 try:
@@ -2410,8 +2361,6 @@ def store_scan_json_zgrab(
                             host.setdefault("ports", []).append(port)
                 if not host.get("ports"):
                     continue
-                set_auto_tags(host, update_openports=False)
-                set_openports_attribute(host)
                 if "cpes" in host:
                     host["cpes"] = list(host["cpes"].values())
                     for cpe in host["cpes"]:
@@ -2427,23 +2376,6 @@ def store_scan_json_zgrab(
                     )
                 ):
                     continue
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash, "scanner": "zgrab"})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -2453,13 +2385,10 @@ def store_scan_json_zgrab(
     def store_scan_json_zdns_ptr(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -2478,7 +2407,6 @@ def store_scan_json_zdns_ptr(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -2506,7 +2434,6 @@ def store_scan_json_zdns_ptr(
                 timestamp = rec.pop("timestamp")[:19].replace("T", " ")
                 host = {
                     "addr": rec.pop("name"),
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                     # [:19]: remove timezone info
                     "starttime": timestamp,
@@ -2518,23 +2445,6 @@ def store_scan_json_zdns_ptr(
                 if source is not None:
                     host["source"] = source
                 host = self.json2dbrec(host)
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash, "scanner": "zdns"})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -2544,13 +2454,10 @@ def store_scan_json_zdns_ptr(
     def store_scan_json_zdns_a(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -2569,7 +2476,6 @@ def store_scan_json_zdns_a(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -2588,7 +2494,6 @@ def store_scan_json_zdns_a(
                         continue
                     host = {
                         "addr": ans["answer"],
-                        "scanid": filehash,
                         "schema_version": xmlnmap.SCHEMA_VERSION,
                         # [:19]: remove timezone info
                         "starttime": timestamp,
@@ -2606,23 +2511,6 @@ def store_scan_json_zdns_a(
                     if source is not None:
                         host["source"] = source
                     host = self.json2dbrec(host)
-                    if (
-                        add_addr_infos
-                        and self.globaldb is not None
-                        and (force_info or "infos" not in host or not host["infos"])
-                    ):
-                        host["infos"] = {}
-                        for func in [
-                            self.globaldb.data.country_byip,
-                            self.globaldb.data.as_byip,
-                            self.globaldb.data.location_byip,
-                        ]:
-                            host["infos"].update(func(host["addr"]) or {})
-                    # We are about to insert data based on this file,
-                    # so we want to save the scan document
-                    if not scan_doc_saved:
-                        self.store_scan_doc({"_id": filehash, "scanner": "zdns"})
-                        scan_doc_saved = True
                     self.store_host(host)
                     if callback is not None:
                         callback(host)
@@ -2632,13 +2520,10 @@ def store_scan_json_zdns_a(
     def store_scan_json_zdns_recursion(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         masscan_probes=None,
         **_,
@@ -2668,7 +2553,6 @@ def store_scan_json_zdns_recursion(
                 '"--masscan-probes ZDNS:<query>:<type>:<expected result>" '
                 '(example: "ZDNS:ivre.rocks:A:1.2.3.4")'
             )
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -2714,7 +2598,6 @@ def store_scan_json_zdns_recursion(
                 host = {
                     "addr": addr,
                     "state": "up",
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                     # [:19]: remove timezone info
                     "starttime": timestamp,
@@ -2745,25 +2628,7 @@ def store_scan_json_zdns_recursion(
                     host["categories"] = categories
                 if source is not None:
                     host["source"] = source
-                set_openports_attribute(host)
                 host = self.json2dbrec(host)
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash, "scanner": "zdns"})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -2771,13 +2636,12 @@ def store_scan_json_zdns_recursion(
         return True
 
     @staticmethod
-    def _gen_records_json_dnsx(rec, name, filehash, timestamp):
+    def _gen_records_json_dnsx(rec, name, timestamp):
         # answers: ["a", "aaaa", "cname", "mx", "ns", "soa", "txt"]
         for ans_type in ["a", "aaaa"]:
             for addr in rec.get(ans_type, []):
                 yield {
                     "addr": addr,
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                     "starttime": timestamp,
                     "endtime": timestamp,
@@ -2793,7 +2657,6 @@ def _gen_records_json_dnsx(rec, name, filehash, timestamp):
             hostname = hostname.lower()
             yield {
                 "addr": name,
-                "scanid": filehash,
                 "schema_version": xmlnmap.SCHEMA_VERSION,
                 "starttime": timestamp,
                 "endtime": timestamp,
@@ -2809,13 +2672,10 @@ def _gen_records_json_dnsx(rec, name, filehash, timestamp):
     def store_scan_json_dnsx(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -2834,7 +2694,6 @@ def store_scan_json_dnsx(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -2858,29 +2717,12 @@ def store_scan_json_dnsx(
                     timestamp = timestamp[:-6]
                 # skip nanosec
                 timestamp = timestamp[:26]
-                for host in self._gen_records_json_dnsx(rec, name, filehash, timestamp):
+                for host in self._gen_records_json_dnsx(rec, name, timestamp):
                     if categories:
                         host["categories"] = categories
                     if source is not None:
                         host["source"] = source
                     host = self.json2dbrec(host)
-                    if (
-                        add_addr_infos
-                        and self.globaldb is not None
-                        and (force_info or "infos" not in host or not host["infos"])
-                    ):
-                        host["infos"] = {}
-                        for func in [
-                            self.globaldb.data.country_byip,
-                            self.globaldb.data.as_byip,
-                            self.globaldb.data.location_byip,
-                        ]:
-                            host["infos"].update(func(host["addr"]) or {})
-                    # We are about to insert data based on this file,
-                    # so we want to save the scan document
-                    if not scan_doc_saved:
-                        self.store_scan_doc({"_id": filehash, "scanner": "zdns"})
-                        scan_doc_saved = True
                     try:
                         self.store_host(host)
                     except Exception:
@@ -2896,13 +2738,10 @@ def store_scan_json_dnsx(
     def store_scan_json_nuclei(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -2921,7 +2760,6 @@ def store_scan_json_nuclei(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -3020,7 +2858,6 @@ def store_scan_json_nuclei(
                 host = {
                     "addr": addr,
                     "state": "up",
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                     "ports": [port_doc],
                 }
@@ -3109,30 +2946,11 @@ def store_scan_json_nuclei(
                     host["starttime"] = host["endtime"] = rec["timestamp"][:19].replace(
                         "T", " "
                     )
-                set_auto_tags(host, update_openports=False)
-                set_openports_attribute(host)
                 if categories:
                     host["categories"] = categories
                 if source is not None:
                     host["source"] = source
                 host = self.json2dbrec(host)
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash, "scanner": "nuclei"})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -3142,13 +2960,10 @@ def store_scan_json_nuclei(
     def store_scan_json_httpx(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -3167,7 +2982,6 @@ def store_scan_json_httpx(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -3191,7 +3005,6 @@ def store_scan_json_httpx(
                 host = {
                     "addr": rec["host"],
                     "state": "up",
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                     "starttime": timestamp,
                     "endtime": timestamp,
@@ -3232,30 +3045,11 @@ def store_scan_json_httpx(
                 # remaining fields (TODO): path body-sha256
                 # header-sha256 url content-type method content-length
                 # status-code response-time failed
-                set_auto_tags(host, update_openports=False)
-                set_openports_attribute(host)
                 if categories:
                     host["categories"] = categories
                 if source is not None:
                     host["source"] = source
                 host = self.json2dbrec(host)
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash, "scanner": "httpx"})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -3265,13 +3059,10 @@ def store_scan_json_httpx(
     def store_scan_json_tlsx(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -3290,7 +3081,6 @@ def store_scan_json_tlsx(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -3311,7 +3101,6 @@ def store_scan_json_tlsx(
                 timestamp = rec["timestamp"][:19].replace("T", " ")
                 host = {
                     "state": "up",
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                     "starttime": timestamp,
                     "endtime": timestamp,
@@ -3386,30 +3175,11 @@ def store_scan_json_tlsx(
                         }
                     )
                 # remaining fields (TODO): jarm_hash tls_connection cipher tls_version
-                set_auto_tags(host, update_openports=False)
-                set_openports_attribute(host)
                 if categories:
                     host["categories"] = categories
                 if source is not None:
                     host["source"] = source
                 host = self.json2dbrec(host)
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash, "scanner": "httpx"})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -3419,13 +3189,10 @@ def store_scan_json_tlsx(
     def store_scan_json_shodan(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -3444,7 +3211,6 @@ def store_scan_json_shodan(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             for line in fdesc:
@@ -3465,7 +3231,6 @@ def store_scan_json_shodan(
                 host = {
                     "addr": rec["ip_str"],
                     "state": "up",
-                    "scanid": filehash,
                     "schema_version": xmlnmap.SCHEMA_VERSION,
                     "starttime": timestamp,
                     "endtime": timestamp,
@@ -3552,30 +3317,11 @@ def store_scan_json_shodan(
                 # os
                 # _shodan / opts.raw
                 # tags (["cloud"]) / cloud
-                set_auto_tags(host, update_openports=False)
-                set_openports_attribute(host)
                 if categories:
                     host["categories"] = categories
                 if source is not None:
                     host["source"] = source
                 host = self.json2dbrec(host)
-                if (
-                    add_addr_infos
-                    and self.globaldb is not None
-                    and (force_info or "infos" not in host or not host["infos"])
-                ):
-                    host["infos"] = {}
-                    for func in [
-                        self.globaldb.data.country_byip,
-                        self.globaldb.data.as_byip,
-                        self.globaldb.data.location_byip,
-                    ]:
-                        host["infos"].update(func(host["addr"]) or {})
-                # We are about to insert data based on this file,
-                # so we want to save the scan document
-                if not scan_doc_saved:
-                    self.store_scan_doc({"_id": filehash, "scanner": "shodan"})
-                    scan_doc_saved = True
                 self.store_host(host)
                 if callback is not None:
                     callback(host)
@@ -3585,13 +3331,10 @@ def store_scan_json_shodan(
     def store_scan_json_dismap(
         self,
         fname,
-        filehash=None,
         needports=False,
         needopenports=False,
         categories=None,
         source=None,
-        add_addr_infos=True,
-        force_info=False,
         callback=None,
         **_,
     ):
@@ -3610,7 +3353,6 @@ def store_scan_json_dismap(
         """
         if categories is None:
             categories = []
-        scan_doc_saved = False
         self.start_store_hosts()
         with utils.open_file(fname) as fdesc:
             try:
@@ -3640,7 +3382,6 @@ def store_scan_json_dismap(
             host = {
                 "addr": rec["host"],
                 "state": "up",
-                "scanid": filehash,
                 "schema_version": xmlnmap.SCHEMA_VERSION,
                 "starttime": timestamp,
                 "endtime": timestamp,
@@ -3760,26 +3501,11 @@ def store_scan_json_dismap(
                     )
             # remaining fields / TODO:
             # banner.string note path uri
-            set_auto_tags(host, update_openports=False)
-            set_openports_attribute(host)
             if categories:
                 host["categories"] = categories
             if source is not None:
                 host["source"] = source
             host = self.json2dbrec(host)
-            if add_addr_infos and self.globaldb is not None:
-                host["infos"] = {}
-                for func in [
-                    self.globaldb.data.country_byip,
-                    self.globaldb.data.as_byip,
-                    self.globaldb.data.location_byip,
-                ]:
-                    host["infos"].update(func(host["addr"]) or {})
-            # We are about to insert data based on this file,
-            # so we want to save the scan document
-            if not scan_doc_saved:
-                self.store_scan_doc({"_id": filehash, "scanner": "dismap"})
-                scan_doc_saved = True
             self.store_host(host)
             if callback is not None:
                 callback(host)
@@ -3799,6 +3525,20 @@ class DBView(DBActive):
 
     def __init__(self):
         super().__init__()
+        self.argparser.add_argument(
+            "--tag", metavar="VALUE[:INFO]", help="show only results with this tag"
+        )
+        self.argparser.add_argument(
+            "--country", metavar="CODE", help="show only results from this country"
+        )
+        self.argparser.add_argument(
+            "--asname", metavar="NAME", help="show only results from this(those) AS(es)"
+        )
+        self.argparser.add_argument(
+            "--asnum",
+            metavar="NUM[,NUM[...]]",
+            help="show only results from this(those) AS(es)",
+        )
         if hasattr(self, "searchtext"):
             self.argparser.add_argument(
                 "--search", metavar="FREE TEXT", help="perform a full-text search"
@@ -3820,9 +3560,31 @@ def __init__(self):
 
     def parse_args(self, args, flt=None):
         flt = super().parse_args(args, flt=flt)
-        if not hasattr(args, "ssl_ja3_client"):
+        if not hasattr(args, "tag"):
             # This is not from DBView, probably from DB
             return flt
+        if args.tag is not None:
+            tag = {}
+            if ":" in args.tag:
+                value, info = args.tag.split(":", 1)
+                if value:
+                    tag["value"] = utils.str2regexp(value)
+                if info:
+                    tag["info"] = utils.str2regexp(info)
+            elif args.tag:
+                tag["value"] = utils.str2regexp(args.tag)
+            flt = self.flt_and(flt, self.searchtag(tag))
+        if args.asname is not None:
+            flt = self.flt_and(flt, self.searchasname(utils.str2regexp(args.asname)))
+        if args.asnum is not None:
+            if args.asnum[:1] in "!-":
+                flt = self.flt_and(
+                    flt, self.searchasnum(utils.str2list(args.asnum[1:]), neg=True)
+                )
+            else:
+                flt = self.flt_and(flt, self.searchasnum(utils.str2list(args.asnum)))
+        if args.country is not None:
+            flt = self.flt_and(flt, self.searchcountry(utils.str2list(args.country)))
         if hasattr(self, "searchtext") and args.search is not None:
             flt = self.flt_and(flt, self.searchtext(args.search))
         if args.ssl_ja3_client is not None:
@@ -4291,12 +4053,6 @@ def _extract(rec):
     def _search_field_exists(self, field):
         raise NotImplementedError
 
-    def searchcountry(self, code, neg=False):
-        return self.searchranges(geoiputils.get_ranges_by_country(code), neg=neg)
-
-    def searchasnum(self, asnum, neg=False):
-        return self.searchranges(geoiputils.get_ranges_by_asnum(asnum), neg=neg)
-
     @classmethod
     def searchranges(cls, ranges, neg=False):
         """Filters (if `neg` == True, filters out) some IP address ranges.
diff --git a/ivre/db/elastic.py b/ivre/db/elastic.py
index af83e3cc0a..676983e545 100644
--- a/ivre/db/elastic.py
+++ b/ivre/db/elastic.py
@@ -2,7 +2,7 @@
 # -*- coding: utf-8 -*-
 
 # This file is part of IVRE.
-# Copyright 2011 - 2022 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -306,7 +306,7 @@ def remove_many(self, flt):
     def distinct(self, field, flt=None, sort=None, limit=None, skip=None):
         if flt is None:
             flt = self.flt_empty
-        if field == "infos.coordinates":
+        if field == "infos.coordinates" and hasattr(self, "searchhaslocation"):
 
             def fix_result(value):
                 return tuple(float(v) for v in value.split(", "))
@@ -345,39 +345,6 @@ def fix_result(value):
                 break
             query["after"] = result["aggregations"]["values"]["after_key"]
 
-    def getlocations(self, flt):
-        query = {
-            "size": PAGESIZE,
-            "sources": [
-                {
-                    "coords": {
-                        "terms": {
-                            "script": {
-                                "lang": "painless",
-                                "source": "doc['infos.coordinates'].value",
-                            }
-                        }
-                    }
-                }
-            ],
-        }
-        flt = self.flt_and(flt & self.searchhaslocation())
-        while True:
-            result = self.db_client.search(
-                body={"query": flt.to_dict(), "aggs": {"values": {"composite": query}}},
-                index=self.indexes[0],
-                ignore_unavailable=True,
-                size=0,
-            )
-            for value in result["aggregations"]["values"]["buckets"]:
-                yield {
-                    "_id": tuple(float(v) for v in value["key"]["coords"].split(", ")),
-                    "count": value["doc_count"],
-                }
-            if "after_key" not in result["aggregations"]["values"]:
-                break
-            query["after"] = result["aggregations"]["values"]["after_key"]
-
     def topvalues(self, field, flt=None, topnbr=10, sort=None, least=False):
         """
         This method uses an aggregation to produce top values for a given
@@ -1212,7 +1179,7 @@ def outputproc(value):
                     }
                 },
             }
-        elif field == "tag":
+        elif field == "tag" and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
 
             def outputproc(value):
@@ -1232,10 +1199,10 @@ def outputproc(value):
                     }
                 },
             }
-        elif field.startswith("tag."):
+        elif field.startswith("tag.") and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             field = {"field": f"tags.{field[4:]}"}
-        elif field.startswith("tag:"):
+        elif field.startswith("tag:") and hasattr(self, "searchtag"):
             subfield = field[4:]
             flt = self.flt_and(flt, self.searchtag(tag={"value": subfield}))
             nested = {
@@ -1296,86 +1263,6 @@ def searchcategory(cls, cat, neg=False):
             return ~res
         return res
 
-    @classmethod
-    def searchtag(cls, tag=None, neg=False):
-        """Filters (if `neg` == True, filters out) one particular tag (records
-        may have zero, one or more tags).
-
-        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
-        `{"value": value, "info": info}`).
-
-        """
-        if not tag:
-            res = Q("exists", field="tags.value")
-            if neg:
-                return ~res
-            return res
-        if not isinstance(tag, dict):
-            tag = {"value": tag}
-        all_res = []
-        for key, value in tag.items():
-            if isinstance(value, list) and len(value) == 1:
-                value = value[0]
-            if isinstance(value, list):
-                res = Q("terms", **{f"tags.{key}": value})
-            elif isinstance(value, utils.REGEXP_T):
-                res = Q("regexp", **{f"tags.{key}": cls._get_pattern(value)})
-            else:
-                res = Q("match", **{f"tags.{key}": value})
-            if neg:
-                all_res.append(~res)
-            else:
-                all_res.append(res)
-        if neg:
-            return cls.flt_or(
-                ~Q("exists", field="tags.value"),
-                Q("nested", path="tags", query=cls.flt_or(*all_res)),
-            )
-        return Q("nested", path="tags", query=cls.flt_and(*all_res))
-
-    @staticmethod
-    def searchcountry(country, neg=False):
-        """Filters (if `neg` == True, filters out) one particular
-        country, or a list of countries.
-
-        """
-        country = utils.country_unalias(country)
-        if isinstance(country, list):
-            res = Q("terms", infos__country_code=country)
-        else:
-            res = Q("match", infos__country_code=country)
-        if neg:
-            return ~res
-        return res
-
-    @staticmethod
-    def searchasnum(asnum, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS number(s).
-
-        """
-        if not isinstance(asnum, str) and hasattr(asnum, "__iter__"):
-            res = Q("terms", infos__as_num=[int(val) for val in asnum])
-        else:
-            res = Q("match", infos__as_num=int(asnum))
-        if neg:
-            return ~res
-        return res
-
-    @classmethod
-    def searchasname(cls, asname, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS.
-
-        """
-        if isinstance(asname, utils.REGEXP_T):
-            res = Q("regexp", infos__as_name=cls._get_pattern(asname))
-        else:
-            res = Q("match", infos__as_name=asname)
-        if neg:
-            return ~res
-        return res
-
     @staticmethod
     def searchopenport(neg=False):
         "Filters records with at least one open port."
@@ -1712,3 +1599,116 @@ def __init__(self, url):
     def store_or_merge_host(self, host):
         if not self.merge_host(host):
             self.store_host(host)
+
+    @classmethod
+    def searchtag(cls, tag=None, neg=False):
+        """Filters (if `neg` == True, filters out) one particular tag (records
+        may have zero, one or more tags).
+
+        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
+        `{"value": value, "info": info}`).
+
+        """
+        if not tag:
+            res = Q("exists", field="tags.value")
+            if neg:
+                return ~res
+            return res
+        if not isinstance(tag, dict):
+            tag = {"value": tag}
+        all_res = []
+        for key, value in tag.items():
+            if isinstance(value, list) and len(value) == 1:
+                value = value[0]
+            if isinstance(value, list):
+                res = Q("terms", **{f"tags.{key}": value})
+            elif isinstance(value, utils.REGEXP_T):
+                res = Q("regexp", **{f"tags.{key}": cls._get_pattern(value)})
+            else:
+                res = Q("match", **{f"tags.{key}": value})
+            if neg:
+                all_res.append(~res)
+            else:
+                all_res.append(res)
+        if neg:
+            return cls.flt_or(
+                ~Q("exists", field="tags.value"),
+                Q("nested", path="tags", query=cls.flt_or(*all_res)),
+            )
+        return Q("nested", path="tags", query=cls.flt_and(*all_res))
+
+    @staticmethod
+    def searchcountry(country, neg=False):
+        """Filters (if `neg` == True, filters out) one particular
+        country, or a list of countries.
+
+        """
+        country = utils.country_unalias(country)
+        if isinstance(country, list):
+            res = Q("terms", infos__country_code=country)
+        else:
+            res = Q("match", infos__country_code=country)
+        if neg:
+            return ~res
+        return res
+
+    @staticmethod
+    def searchasnum(asnum, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS number(s).
+
+        """
+        if not isinstance(asnum, str) and hasattr(asnum, "__iter__"):
+            res = Q("terms", infos__as_num=[int(val) for val in asnum])
+        else:
+            res = Q("match", infos__as_num=int(asnum))
+        if neg:
+            return ~res
+        return res
+
+    @classmethod
+    def searchasname(cls, asname, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS.
+
+        """
+        if isinstance(asname, utils.REGEXP_T):
+            res = Q("regexp", infos__as_name=cls._get_pattern(asname))
+        else:
+            res = Q("match", infos__as_name=asname)
+        if neg:
+            return ~res
+        return res
+
+    def getlocations(self, flt):
+        query = {
+            "size": PAGESIZE,
+            "sources": [
+                {
+                    "coords": {
+                        "terms": {
+                            "script": {
+                                "lang": "painless",
+                                "source": "doc['infos.coordinates'].value",
+                            }
+                        }
+                    }
+                }
+            ],
+        }
+        flt = self.flt_and(flt & self.searchhaslocation())
+        while True:
+            result = self.db_client.search(
+                body={"query": flt.to_dict(), "aggs": {"values": {"composite": query}}},
+                index=self.indexes[0],
+                ignore_unavailable=True,
+                size=0,
+            )
+            for value in result["aggregations"]["values"]["buckets"]:
+                yield {
+                    "_id": tuple(float(v) for v in value["key"]["coords"].split(", ")),
+                    "count": value["doc_count"],
+                }
+            if "after_key" not in result["aggregations"]["values"]:
+                break
+            query["after"] = result["aggregations"]["values"]["after_key"]
diff --git a/ivre/db/mongo.py b/ivre/db/mongo.py
index ba3db678e5..2f5876fb98 100644
--- a/ivre/db/mongo.py
+++ b/ivre/db/mongo.py
@@ -813,7 +813,6 @@ class MongoDBActive(MongoDB, DBActive):
     indexes: List[List[Tuple[List[IndexKey], Dict[str, Any]]]] = [
         # hosts
         [
-            ([("scanid", pymongo.ASCENDING)], {}),
             ([("schema_version", pymongo.ASCENDING)], {}),
             (
                 [
@@ -834,13 +833,6 @@ class MongoDBActive(MongoDB, DBActive):
                 ],
                 {},
             ),
-            (
-                [
-                    ("tags.value", pymongo.ASCENDING),
-                    ("tags.info", pymongo.ASCENDING),
-                ],
-                {"sparse": True},
-            ),
             ([("hostnames.domains", pymongo.ASCENDING)], {}),
             ([("traces.hops.domains", pymongo.ASCENDING)], {}),
             ([("openports.count", pymongo.ASCENDING)], {}),
@@ -1006,7 +998,6 @@ class MongoDBActive(MongoDB, DBActive):
                 [("ports.scripts.ntlm-info.Product_Version", pymongo.ASCENDING)],
                 {"sparse": True},
             ),
-            ([("infos.as_num", pymongo.ASCENDING)], {}),
             (
                 [
                     ("traces.hops.ipaddr_0", pymongo.ASCENDING),
@@ -1015,14 +1006,6 @@ class MongoDBActive(MongoDB, DBActive):
                 ],
                 {"name": "ivre.hosts.$traces.$hops"},
             ),
-            (
-                [
-                    ("infos.country_code", pymongo.ASCENDING),
-                    ("infos.city", pymongo.ASCENDING),
-                ],
-                {},
-            ),
-            ([("infos.loc", pymongo.GEOSPHERE)], {}),
             (
                 [
                     ("cpes.type", pymongo.ASCENDING),
@@ -1296,6 +1279,7 @@ class MongoDBActive(MongoDB, DBActive):
                     ),
                 ]
             },
+            22: {},  # needed for MongoDBNmap
         },
     ]
     schema_latest_versions = [
@@ -1329,6 +1313,7 @@ def __init__(self, url):
                 18: (19, self.migrate_schema_hosts_18_19),
                 19: (20, self.migrate_schema_hosts_19_20),
                 20: (21, self.migrate_schema_hosts_20_21),
+                21: (22, self.migrate_schema_hosts_21_22),
             },
         ]
 
@@ -1951,7 +1936,7 @@ def migrate_schema_hosts_19_20(cls, doc):
 
     @staticmethod
     def migrate_schema_hosts_20_21(doc):
-        """Converts a record from version 20 to version 21. Version 20
+        """Converts a record from version 20 to version 21. Version 21
         introduces a structured output for data from ssl-jarm.
 
         """
@@ -1968,6 +1953,20 @@ def migrate_schema_hosts_20_21(doc):
             update["$set"]["ports"] = doc["ports"]
         return update
 
+    @staticmethod
+    def migrate_schema_hosts_21_22(doc):
+        """Converts a record from version 21 to version 22. Version 22
+        removes the `scanid` field and the `infos` and `tags` fields
+        in the Nmap purpose (they are now only used in the View
+        purpose).
+
+        """
+        assert doc["schema_version"] == 21
+        update = {"$set": {"schema_version": 22}}
+        if "scanid" in doc:
+            update["$unset"] = {"scanid": ""}
+        return update
+
     def _get(self, flt, **kargs):
         """Like .get(), but returns a MongoDB cursor (suitable for use with
         e.g.  .explain()).
@@ -2021,15 +2020,6 @@ def get(self, spec, **kargs):
                 ]
             yield host
 
-    @staticmethod
-    def getscanids(host):
-        scanids = host.get("scanid")
-        if scanids is None:
-            return []
-        if isinstance(scanids, list):
-            return scanids
-        return [scanids]
-
     def count(self, flt):
         """Count documents in hosts column."""
         if not flt:
@@ -2135,19 +2125,6 @@ def removescreenshot(self, host, port=None, protocol="tcp"):
                 {"_id": host["_id"]}, {"$set": {"ports": host["ports"]}}
             )
 
-    def getlocations(self, flt):
-        col = self.db[self.columns[self.column_hosts]]
-        pipeline = [
-            {"$match": self.flt_and(flt, self.searchhaslocation())},
-            {"$project": {"_id": 0, "coords": "$infos.loc.coordinates"}},
-            {"$group": {"_id": "$coords", "count": {"$sum": 1}}},
-        ]
-        log_pipeline(pipeline)
-        return (
-            {"_id": tuple(rec["_id"][::-1]), "count": rec["count"]}
-            for rec in col.aggregate(pipeline, cursor={})
-        )
-
     def get_ips_ports(self, flt, limit=None, skip=None):
         cur = self._get(
             flt,
@@ -2229,22 +2206,6 @@ def store_host(self, host):
         )
         return ident
 
-    def merge_host_docs(self, rec1, rec2):
-        """Merge two host records and return the result. Unmergeable /
-        hard-to-merge fields are lost (e.g., extraports).
-
-        """
-        rec = super().merge_host_docs(rec1, rec2)
-        scanid = set()
-        for record in [rec1, rec2]:
-            scanid.update(self.getscanids(record))
-        if scanid:
-            if len(scanid) == 1:
-                rec["scanid"] = scanid.pop()
-            else:
-                rec["scanid"] = list(scanid)
-        return rec
-
     def remove(self, host):
         """Removes the host from the active column. `host` must be the record
         as returned by `.get()`.
@@ -2256,23 +2217,6 @@ def remove_many(self, flt):
         """Removes hosts from the active column, based on the filter `flt`."""
         self.db[self.columns[self.column_hosts]].delete_many(flt)
 
-    def apply_auto_tags(self, flt):
-        fields = {"tags", "ports", "openports"}
-        for rec in self.get(flt):
-            orig = {fld: deepcopy(rec.get(fld)) for fld in fields}
-            set_auto_tags(rec)
-            updatespec = {}
-            for fld in fields:
-                if orig[fld] != rec.get(fld):
-                    if not rec.get(fld):
-                        updatespec.setdefault("$unset", {})[fld] = ""
-                    else:
-                        updatespec.setdefault("$set", {})[fld] = rec[fld]
-            if updatespec:
-                self.db[self.columns[self.column_hosts]].update_one(
-                    {"_id": rec["_id"]}, updatespec
-                )
-
     def store_or_merge_host(self, host):
         raise NotImplementedError
 
@@ -2484,115 +2428,6 @@ def searchcategory(cat, neg=False):
                 return {"categories": {"$in": cat}}
         return {"categories": cat}
 
-    @staticmethod
-    def searchtag(tag=None, neg=False):
-        """Filters (if `neg` == True, filters out) one particular tag (records
-        may have zero, one or more tags).
-
-        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
-        `{"value": value, "info": info}`).
-
-        """
-        if not tag:
-            return {"tags.value": {"$exists": not neg}}
-        if not isinstance(tag, dict):
-            tag = {"value": tag}
-        req = {}
-        for key, value in tag.items():
-            if isinstance(value, list) and len(value) == 1:
-                value = value[0]
-            if isinstance(value, utils.REGEXP_T):
-                if neg:
-                    req[key] = {"$not": value}
-                else:
-                    req[key] = value
-            elif isinstance(value, list):
-                if neg:
-                    req[key] = {"$nin": value}
-                else:
-                    req[key] = {"$in": value}
-            else:
-                if neg:
-                    req[key] = {"$ne": value}
-                else:
-                    req[key] = value
-        if len(req) == 1:
-            if neg:
-                # Either no tag at all, or no matching tag
-                return {
-                    "$or": [
-                        {"tags.value": {"$exists": False}},
-                        {f"tags.{key}": value for key, value in req.items()},
-                    ]
-                }
-            return {f"tags.{key}": value for key, value in req.items()}
-        if neg:
-            # Either no tag at all, or no matching tag
-            return {
-                "$or": [
-                    {"tags.value": {"$exists": False}},
-                    {
-                        "tags": {
-                            "$elemMatch": {
-                                "$or": [{key: value} for key, value in req.items()]
-                            }
-                        }
-                    },
-                ]
-            }
-        return {"tags": {"$elemMatch": req}}
-
-    @staticmethod
-    def searchcountry(country, neg=False):
-        """Filters (if `neg` == True, filters out) one particular
-        country, or a list of countries.
-
-        """
-        country = utils.country_unalias(country)
-        if isinstance(country, list):
-            return {"infos.country_code": {"$nin" if neg else "$in": country}}
-        return {"infos.country_code": {"$ne": country} if neg else country}
-
-    @staticmethod
-    def searchhaslocation(neg=False):
-        return {"infos.loc": {"$exists": not neg}}
-
-    @staticmethod
-    def searchcity(city, neg=False):
-        """
-        Filters (if `neg` == True, filters out) one particular city.
-        """
-        if neg:
-            if isinstance(city, utils.REGEXP_T):
-                return {"infos.city": {"$not": city}}
-            return {"infos.city": {"$ne": city}}
-        return {"infos.city": city}
-
-    @staticmethod
-    def searchasnum(asnum, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS number(s).
-
-        """
-        if not isinstance(asnum, str) and hasattr(asnum, "__iter__"):
-            return {
-                "infos.as_num": {"$nin" if neg else "$in": [int(val) for val in asnum]}
-            }
-        asnum = int(asnum)
-        return {"infos.as_num": {"$ne": asnum} if neg else asnum}
-
-    @staticmethod
-    def searchasname(asname, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS.
-
-        """
-        if neg:
-            if isinstance(asname, utils.REGEXP_T):
-                return {"infos.as_name": {"$not": asname}}
-            return {"infos.as_name": {"$ne": asname}}
-        return {"infos.as_name": asname}
-
     @staticmethod
     def searchsource(src, neg=False):
         """Filters (if `neg` == True, filters out) one particular
@@ -4226,7 +4061,7 @@ def outputproc(x):
         elif field == "scanner.name":
             flt = self.flt_and(flt, self.searchscript(name="scanner"))
             field = "ports.scripts.scanner.scanners.name"
-        elif field == "tag":
+        elif field == "tag" and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             specialproj = {
                 "_id": 0,
@@ -4242,10 +4077,10 @@ def outputproc(x):
                     "_id": (x["_id"]["value"], x["_id"].get("info")),
                 }
 
-        elif field.startswith("tag."):
+        elif field.startswith("tag.") and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             field = f"tags.{field[4:]}"
-        elif field.startswith("tag:"):
+        elif field.startswith("tag:") and hasattr(self, "searchtag"):
             subfield = field[4:]
             flt = self.flt_and(flt, self.searchtag(tag={"value": subfield}))
             specialproj = {
@@ -4377,93 +4212,120 @@ def categories_to_val(categories):
 
 
 class MongoDBNmap(MongoDBActive, DBNmap):
-    column_scans = 1
     content_handler = Nmap2Mongo
+    schema_migrations_indexes: List[
+        Dict[int, Dict[str, List[Tuple[List[IndexKey], Dict[str, Any]]]]]
+    ] = [
+        {
+            key: dict(
+                value,
+                drop=value.get("drop", [])
+                + [
+                    ([("infos.as_num", pymongo.ASCENDING)], {}),
+                    (
+                        [
+                            ("infos.country_code", pymongo.ASCENDING),
+                            ("infos.city", pymongo.ASCENDING),
+                        ],
+                        {},
+                    ),
+                    ([("infos.loc", pymongo.GEOSPHERE)], {}),
+                    (
+                        [
+                            ("tags.value", pymongo.ASCENDING),
+                            ("tags.info", pymongo.ASCENDING),
+                        ],
+                        {"sparse": True},
+                    ),
+                ],
+            )
+            if key == 22
+            else value
+            for key, value in idxs.items()
+        }
+        if i == 0
+        else idxs
+        for i, idxs in enumerate(MongoDBActive.schema_migrations_indexes)
+    ]
 
     def __init__(self, url):
         super().__init__(url)
         self.columns = [
             self.params.pop("colname_hosts", "hosts"),
-            self.params.pop("colname_scans", "scans"),
         ]
-        self.schema_migrations.append({})  # scans
         self.output_function = None
 
-    def store_scan_doc(self, scan):
-        ident = self.db[self.columns[self.column_scans]].insert_one(scan).inserted_id
-        utils.LOGGER.debug(
-            "SCAN STORED: %r in %r", ident, self.columns[self.column_scans]
-        )
-        return ident
-
-    def update_scan_doc(self, scan_id, data):
-        self.db[self.columns[self.column_scans]].update_one(
-            {"_id": scan_id},
-            {"$set": data},
-        )
-
-    def store_or_merge_host(self, host):
-        self.store_host(host)
-
-    def cmp_schema_version_scan(self, scan):
-        """Returns 0 if the `scan`'s schema version matches the code's
-        current version, -1 if it is higher (you need to update IVRE),
-        and 1 if it is lower (you need to call .migrate_schema()).
-
-        """
-        return self.cmp_schema_version(self.column_scans, scan)
-
-    def getscan(self, scanid):
-        return self.db[self.columns[self.column_scans]].find_one({"_id": scanid})
-
-    def is_scan_present(self, scanid):
-        if (
-            self.db[self.columns[self.column_scans]].find_one(
-                {"_id": scanid}, projection=[]
-            )
-            is not None
-        ):
-            return True
-        return False
-
-    def remove(self, host):
-        """Removes the host from the active column. `host` must be the host
-        record as returned by `.get()`.
-
-        If `host` has a `scanid` attribute, and if it refers to a scan that
-        have no more host record after the deletion of `host`, then the scan
-        record is also removed.
+    def migrate_schema(self, version):
+        """Process to schema migrations in column hosts starting from
+        `version`.
 
         """
-        super().remove(host)
-        for scanid in self.getscanids(host):
+        super().migrate_schema(version)
+        if (version or 0) < 22:
+            # column "scans" has been dropped in 22
+            colname_scans = self.params.get("colname_scans", "scans")
+            # let's check this collection exists and if its name is
+            # not in use in another database
             if (
-                self.db[self.columns[self.column_hosts]].find_one({"scanid": scanid})
-                is None
+                colname_scans in self.db.list_collection_names()
+                and colname_scans not in self.columns
+                and not any(
+                    colname_scans in otherdb.columns
+                    for otherdb in [
+                        self.globaldb.agent,
+                        self.globaldb.flow,
+                        self.globaldb.passive,
+                        self.globaldb.view,
+                    ]
+                    if isinstance(otherdb, MongoDB)
+                )
             ):
-                self.db[self.columns[self.column_scans]].delete_one({"_id": scanid})
-
-    def remove_many(self, flt):
-        """Removes hosts from the active column, based on the filter `flt`.
+                utils.LOGGER.info(
+                    "Dropping column %s as it is no longer used", colname_scans
+                )
+                self.db.drop_collection(colname_scans)
 
-        If the hosts removed had `scanid` attributes, and if some of them
-        refer to scans that have no more host record after the deletion of the
-        hosts, then the scan records are also removed.
+    @staticmethod
+    def migrate_schema_hosts_21_22(doc):
+        """Converts a record from version 21 to version 22. Version 22
+        removes the `scanid` field and the `infos` and `tags` fields
+        in the Nmap purpose (they are now only used in the View
+        purpose).
+
+        """
+        assert doc["schema_version"] == 21
+        update = {"$set": {"schema_version": 22}}
+        for key in ["scanid", "infos"]:
+            if key in doc:
+                update.setdefault("$unset", {})[key] = ""
+        return update
 
-        """
-        scanids = list(self.distinct("scanid", flt=flt))
-        super().remove_many(flt)
-        for scanid in scanids:
-            if (
-                self.db[self.columns[self.column_hosts]].find_one({"scanid": scanid})
-                is None
-            ):
-                self.db[self.columns[self.column_scans]].delete_one({"_id": scanid})
+    def store_or_merge_host(self, host):
+        self.store_host(host)
 
 
 class MongoDBView(MongoDBActive, DBView):
     indexes: List[List[Tuple[List[IndexKey], Dict[str, Any]]]] = [
-        idxs + [([(fld, "text") for fld in DBActive.text_fields], {"name": "text"})]
+        idxs
+        + [
+            ([("infos.as_num", pymongo.ASCENDING)], {}),
+            (
+                [
+                    ("infos.country_code", pymongo.ASCENDING),
+                    ("infos.city", pymongo.ASCENDING),
+                ],
+                {},
+            ),
+            ([("infos.loc", pymongo.GEOSPHERE)], {}),
+            (
+                [
+                    ("tags.value", pymongo.ASCENDING),
+                    ("tags.info", pymongo.ASCENDING),
+                ],
+                {"sparse": True},
+            ),
+            ([(fld, "text") for fld in DBActive.text_fields], {"name": "text"}),
+        ]
         if i == 0
         else idxs
         for i, idxs in enumerate(MongoDBActive.indexes)
@@ -4494,6 +4356,145 @@ def store_or_merge_host(self, host):
         if not self.merge_host(host):
             self.store_host(host)
 
+    def apply_auto_tags(self, flt):
+        fields = {"tags", "ports", "openports"}
+        for rec in self.get(flt):
+            orig = {fld: deepcopy(rec.get(fld)) for fld in fields}
+            set_auto_tags(rec)
+            updatespec = {}
+            for fld in fields:
+                if orig[fld] != rec.get(fld):
+                    if not rec.get(fld):
+                        updatespec.setdefault("$unset", {})[fld] = ""
+                    else:
+                        updatespec.setdefault("$set", {})[fld] = rec[fld]
+            if updatespec:
+                self.db[self.columns[self.column_hosts]].update_one(
+                    {"_id": rec["_id"]}, updatespec
+                )
+
+    def getlocations(self, flt):
+        col = self.db[self.columns[self.column_hosts]]
+        pipeline = [
+            {"$match": self.flt_and(flt, self.searchhaslocation())},
+            {"$project": {"_id": 0, "coords": "$infos.loc.coordinates"}},
+            {"$group": {"_id": "$coords", "count": {"$sum": 1}}},
+        ]
+        log_pipeline(pipeline)
+        return (
+            {"_id": tuple(rec["_id"][::-1]), "count": rec["count"]}
+            for rec in col.aggregate(pipeline, cursor={})
+        )
+
+    @staticmethod
+    def searchtag(tag=None, neg=False):
+        """Filters (if `neg` == True, filters out) one particular tag (records
+        may have zero, one or more tags).
+
+        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
+        `{"value": value, "info": info}`).
+
+        """
+        if not tag:
+            return {"tags.value": {"$exists": not neg}}
+        if not isinstance(tag, dict):
+            tag = {"value": tag}
+        req = {}
+        for key, value in tag.items():
+            if isinstance(value, list) and len(value) == 1:
+                value = value[0]
+            if isinstance(value, utils.REGEXP_T):
+                if neg:
+                    req[key] = {"$not": value}
+                else:
+                    req[key] = value
+            elif isinstance(value, list):
+                if neg:
+                    req[key] = {"$nin": value}
+                else:
+                    req[key] = {"$in": value}
+            else:
+                if neg:
+                    req[key] = {"$ne": value}
+                else:
+                    req[key] = value
+        if len(req) == 1:
+            if neg:
+                # Either no tag at all, or no matching tag
+                return {
+                    "$or": [
+                        {"tags.value": {"$exists": False}},
+                        {f"tags.{key}": value for key, value in req.items()},
+                    ]
+                }
+            return {f"tags.{key}": value for key, value in req.items()}
+        if neg:
+            # Either no tag at all, or no matching tag
+            return {
+                "$or": [
+                    {"tags.value": {"$exists": False}},
+                    {
+                        "tags": {
+                            "$elemMatch": {
+                                "$or": [{key: value} for key, value in req.items()]
+                            }
+                        }
+                    },
+                ]
+            }
+        return {"tags": {"$elemMatch": req}}
+
+    @staticmethod
+    def searchcountry(country, neg=False):
+        """Filters (if `neg` == True, filters out) one particular
+        country, or a list of countries.
+
+        """
+        country = utils.country_unalias(country)
+        if isinstance(country, list):
+            return {"infos.country_code": {"$nin" if neg else "$in": country}}
+        return {"infos.country_code": {"$ne": country} if neg else country}
+
+    @staticmethod
+    def searchhaslocation(neg=False):
+        return {"infos.loc": {"$exists": not neg}}
+
+    @staticmethod
+    def searchcity(city, neg=False):
+        """
+        Filters (if `neg` == True, filters out) one particular city.
+        """
+        if neg:
+            if isinstance(city, utils.REGEXP_T):
+                return {"infos.city": {"$not": city}}
+            return {"infos.city": {"$ne": city}}
+        return {"infos.city": city}
+
+    @staticmethod
+    def searchasnum(asnum, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS number(s).
+
+        """
+        if not isinstance(asnum, str) and hasattr(asnum, "__iter__"):
+            return {
+                "infos.as_num": {"$nin" if neg else "$in": [int(val) for val in asnum]}
+            }
+        asnum = int(asnum)
+        return {"infos.as_num": {"$ne": asnum} if neg else asnum}
+
+    @staticmethod
+    def searchasname(asname, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS.
+
+        """
+        if neg:
+            if isinstance(asname, utils.REGEXP_T):
+                return {"infos.as_name": {"$not": asname}}
+            return {"infos.as_name": {"$ne": asname}}
+        return {"infos.as_name": asname}
+
 
 class MongoDBPassive(MongoDB, DBPassive):
     column_passive = 0
diff --git a/ivre/db/sql/__init__.py b/ivre/db/sql/__init__.py
index 1d932353a6..591ff76bff 100644
--- a/ivre/db/sql/__init__.py
+++ b/ivre/db/sql/__init__.py
@@ -63,13 +63,11 @@
     Flow,
     N_Association_Scan_Category,
     N_Association_Scan_Hostname,
-    N_Association_Scan_ScanFile,
     N_Category,
     N_Hop,
     N_Hostname,
     N_Port,
     N_Scan,
-    N_ScanFile,
     N_Script,
     N_Tag,
     N_Trace,
@@ -161,10 +159,6 @@ def fixline(self, line):
         for field in ["cpes", "extraports", "openports", "os", "traces"]:
             line.pop(field, None)
         line["addr"] = self.ip2internal(line["addr"])
-        scanfileid = line.pop("scanid")
-        if isinstance(scanfileid, str):
-            scanfileid = [scanfileid]
-        line["scanfileid"] = "{%s}" % ",".join('"\\x%s"' % fid for fid in scanfileid)
         line["time_start"] = line.pop("starttime")
         line["time_stop"] = line.pop("endtime")
         line["info"] = line.pop("infos", None)
@@ -1796,55 +1790,6 @@ def searchcategory(cls, cat, neg=False):
             category=[cls._searchstring_re(cls.tables.category.name, cat, neg=neg)]
         )
 
-    @classmethod
-    def searchcountry(cls, country, neg=False):
-        """Filters (if `neg` == True, filters out) one particular
-        country, or a list of countries.
-
-        """
-        country = utils.country_unalias(country)
-        return cls.base_filter(
-            main=cls._searchstring_list(
-                cls.tables.scan.info["country_code"].astext, country, neg=neg
-            )
-        )
-
-    @classmethod
-    def searchcity(cls, city, neg=False):
-        """Filters (if `neg` == True, filters out) one particular
-        city
-
-        """
-        return cls.base_filter(
-            main=cls._searchstring_re(
-                cls.tables.scan.info["city"].astext, city, neg=neg
-            )
-        )
-
-    @classmethod
-    def searchasnum(cls, asnum, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS number(s).
-
-        """
-        return cls.base_filter(
-            main=cls._searchstring_list(
-                cls.tables.scan.info["as_num"], asnum, neg=neg, map_=str
-            )
-        )
-
-    @classmethod
-    def searchasname(cls, asname, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS.
-
-        """
-        return cls.base_filter(
-            main=cls._searchstring_rec(
-                cls.tables.scan.info["as_name"].astext, asname, neg=neg
-            )
-        )
-
     @classmethod
     def searchport(cls, port, protocol="tcp", state="open", neg=False):
         """Filters (if `neg` == True, filters out) records with
@@ -2453,31 +2398,11 @@ def searchhassh(cls, value_or_hash=None, server=None):
             ]
         )
 
-    @classmethod
-    def searchtag(cls, tag=None, neg=False):
-        """Filters (if `neg` == True, filters out) one particular tag (records
-        may have zero, one or more tags).
-
-        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
-        `{"value": value, "info": info}`).
-
-        """
-        if not tag:
-            return cls.base_filter(tag=[(not neg, True)])
-        if not isinstance(tag, dict):
-            tag = {"value": tag}
-        req = [
-            cls._searchstring_re(getattr(cls.tables.tag, key), value)
-            for key, value in tag.items()
-        ]
-        return cls.base_filter(tag=[(not neg, and_(*req))])
-
 
 class SQLDBNmap(SQLDBActive, DBNmap):
     table_layout = namedtuple(
         "nmap_layout",
         [
-            "scanfile",
             "category",
             "scan",
             "hostname",
@@ -2488,11 +2413,9 @@ class SQLDBNmap(SQLDBActive, DBNmap):
             "hop",
             "association_scan_hostname",
             "association_scan_category",
-            "association_scan_scanfile",
         ],
     )
     tables = table_layout(
-        N_ScanFile,
         N_Category,
         N_Scan,
         N_Hostname,
@@ -2503,13 +2426,11 @@ class SQLDBNmap(SQLDBActive, DBNmap):
         N_Hop,
         N_Association_Scan_Hostname,
         N_Association_Scan_Category,
-        N_Association_Scan_ScanFile,
     )
     fields = {
         "_id": N_Scan.id,
         "addr": N_Scan.addr,
         "source": N_Scan.source,
-        "scanid": N_Association_Scan_ScanFile.scan_file,
         "starttime": N_Scan.time_start,
         "endtime": N_Scan.time_stop,
         "infos": N_Scan.info,
@@ -2530,70 +2451,6 @@ class SQLDBNmap(SQLDBActive, DBNmap):
     def store_or_merge_host(self, host):
         self.store_host(host)
 
-    def get(self, flt, limit=None, skip=None, sort=None, **kargs):
-        for rec in super().get(flt, limit=limit, skip=skip, sort=sort, **kargs):
-            rec["scanid"] = [
-                scanfile[0]
-                for scanfile in self.db.execute(
-                    select([self.tables.association_scan_scanfile.scan_file]).where(
-                        self.tables.association_scan_scanfile.scan == rec["_id"]
-                    )
-                )
-            ]
-            yield rec
-
-    def _remove_unused_scan_files(self):
-        """Removes unused scan files, useful when some scan results have been
-        removed.
-
-        """
-        base = select([self.tables.association_scan_scanfile.scan_file]).cte("base")
-        self.db.execute(
-            delete(self.tables.scanfile).where(self.tables.scanfile.sha256.notin_(base))
-        )
-
-    def remove(self, host):
-        """Removes the host scan result. `host` must be a record as yielded by
-        .get().
-
-        The scan files that are no longer linked to a scan are removed at the
-        end of the call.
-
-        """
-        super().remove(host)
-        self._remove_unused_scan_files()
-
-    def remove_many(self, flt):
-        """Removes the host scan result. `flt` must be a valid NmapFilter()
-        instance.
-
-        The scan files that are no longer linked to a scan are removed at the
-        end of the call.
-
-        """
-        super().remove_many(flt)
-        self._remove_unused_scan_files()
-
-    @staticmethod
-    def getscanids(host):
-        return host["scanid"]
-
-    def getscan(self, scanid):
-        if isinstance(scanid, (str, bytes)) and len(scanid) == 64:
-            scanid = utils.decode_hex(scanid)
-        return self.db.execute(
-            select([self.tables.scanfile]).where(self.tables.scanfile.sha256 == scanid)
-        ).fetchone()
-
-    def is_scan_present(self, scanid):
-        return bool(
-            self.db.execute(
-                select([True])
-                .where(self.tables.scanfile.sha256 == utils.decode_hex(scanid))
-                .limit(1)
-            ).fetchone()
-        )
-
     @classmethod
     def searchsource(cls, src, neg=False):
         if isinstance(src, list):
@@ -2665,6 +2522,74 @@ def searchsource(cls, src, neg=False):
             )
         )
 
+    @classmethod
+    def searchtag(cls, tag=None, neg=False):
+        """Filters (if `neg` == True, filters out) one particular tag (records
+        may have zero, one or more tags).
+
+        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
+        `{"value": value, "info": info}`).
+
+        """
+        if not tag:
+            return cls.base_filter(tag=[(not neg, True)])
+        if not isinstance(tag, dict):
+            tag = {"value": tag}
+        req = [
+            cls._searchstring_re(getattr(cls.tables.tag, key), value)
+            for key, value in tag.items()
+        ]
+        return cls.base_filter(tag=[(not neg, and_(*req))])
+
+    @classmethod
+    def searchcountry(cls, country, neg=False):
+        """Filters (if `neg` == True, filters out) one particular
+        country, or a list of countries.
+
+        """
+        country = utils.country_unalias(country)
+        return cls.base_filter(
+            main=cls._searchstring_list(
+                cls.tables.scan.info["country_code"].astext, country, neg=neg
+            )
+        )
+
+    @classmethod
+    def searchcity(cls, city, neg=False):
+        """Filters (if `neg` == True, filters out) one particular
+        city
+
+        """
+        return cls.base_filter(
+            main=cls._searchstring_re(
+                cls.tables.scan.info["city"].astext, city, neg=neg
+            )
+        )
+
+    @classmethod
+    def searchasnum(cls, asnum, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS number(s).
+
+        """
+        return cls.base_filter(
+            main=cls._searchstring_list(
+                cls.tables.scan.info["as_num"], asnum, neg=neg, map_=str
+            )
+        )
+
+    @classmethod
+    def searchasname(cls, asname, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS.
+
+        """
+        return cls.base_filter(
+            main=cls._searchstring_rec(
+                cls.tables.scan.info["as_name"].astext, asname, neg=neg
+            )
+        )
+
 
 class PassiveFilter(Filter):
     def __init__(self, main=None, tables=None):
diff --git a/ivre/db/sql/postgres.py b/ivre/db/sql/postgres.py
index 718b9220e3..457bca880c 100644
--- a/ivre/db/sql/postgres.py
+++ b/ivre/db/sql/postgres.py
@@ -35,7 +35,6 @@
     ARRAY,
     Column,
     Index,
-    LargeBinary,
     String,
     Table,
     and_,
@@ -1147,21 +1146,21 @@ def topvalues(
                     ),
                 ),
             )
-        elif field == "tag":
+        elif field == "tag" and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             field = self._topstructure(
                 self.tables.tag, [self.tables.tag.value, self.tables.tag.info]
             )
-        elif field == "tag.value":
+        elif field == "tag.value" and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             field = self._topstructure(self.tables.tag, [self.tables.tag.value])
-        elif field == "tag.info":
+        elif field == "tag.info" and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             field = self._topstructure(self.tables.tag, [self.tables.tag.info])
-        elif field == "tag.type":
+        elif field == "tag.type" and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             field = self._topstructure(self.tables.tag, [self.tables.tag.type])
-        elif field.startswith("tag:"):
+        elif field.startswith("tag:") and hasattr(self, "searchtag"):
             subfield = field[4:]
             flt = self.flt_and(flt, self.searchtag(tag={"value": subfield}))
             field = self._topstructure(
@@ -1362,46 +1361,6 @@ def _features_port_get(
 
 
 class PostgresDBNmap(PostgresDBActive, SQLDBNmap):
-    def store_scan_doc(self, scan):
-        scan = scan.copy()
-        if "start" in scan:
-            scan["start"] = datetime.datetime.utcfromtimestamp(int(scan["start"]))
-        if "scaninfos" in scan:
-            scan["scaninfo"] = scan.pop("scaninfos")
-        scan["sha256"] = utils.decode_hex(scan.pop("_id"))
-        insrt = insert(self.tables.scanfile).values(
-            **{
-                key: scan[key]
-                for key in [
-                    "sha256",
-                    "args",
-                    "scaninfo",
-                    "scanner",
-                    "start",
-                    "version",
-                    "xmloutputversion",
-                ]
-                if key in scan
-            }
-        )
-        if config.DEBUG:
-            scanfileid = self.db.execute(
-                insrt.returning(self.tables.scanfile.sha256)
-            ).fetchone()[0]
-            utils.LOGGER.debug("SCAN STORED: %r", utils.encode_hex(scanfileid))
-        else:
-            self.db.execute(insrt)
-
-    def update_scan_doc(self, scan_id, data):
-        data = data.copy()
-        if "end" in data:
-            data["end"] = datetime.datetime.utcfromtimestamp(int(data["end"]))
-        self.db.execute(
-            update(self.tables.scanfile)
-            .where(self.tables.scanfile.sha256 == utils.decode_hex(scan_id))
-            .values(**{key: data[key] for key in ["end", "elapsed"] if key in data})
-        )
-
     def _store_host(self, host):
         addr = self.ip2internal(host["addr"])
         info = host.get("infos")
@@ -1516,19 +1475,12 @@ def _store_host(self, host):
         return scanid
 
     def store_host(self, host):
-        scanid = self._store_host(host)
-        insrt = postgresql.insert(self.tables.association_scan_scanfile)
-        self.db.execute(
-            insrt.values(
-                scan=scanid, scan_file=utils.decode_hex(host["scanid"])
-            ).on_conflict_do_nothing()
-        )
+        self._store_host(host)
 
     def store_hosts(self, hosts):
         tmp = self.create_tmp_table(
             self.tables.scan,
             extracols=[
-                Column("scanfileid", ARRAY(LargeBinary(32))),
                 Column("categories", ARRAY(String(32))),
                 Column("source", String(32)),
                 # Column("cpe", postgresql.JSONB),
diff --git a/ivre/db/sql/tables.py b/ivre/db/sql/tables.py
index ebdba42bb5..124871c7ec 100644
--- a/ivre/db/sql/tables.py
+++ b/ivre/db/sql/tables.py
@@ -2,7 +2,7 @@
 # -*- coding: utf-8 -*-
 
 # This file is part of IVRE.
-# Copyright 2011 - 2022 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -34,7 +34,6 @@
     ForeignKeyConstraint,
     Index,
     Integer,
-    LargeBinary,
     String,
     Text,
     event,
@@ -258,23 +257,6 @@ class Flow(Base):
 
 
 # Active
-class _Association_Scan_ScanFile:
-    scan = Column(Integer, primary_key=True)
-    scan_file = Column(LargeBinary(32), primary_key=True)
-
-
-class _ScanFile:
-    sha256 = Column(LargeBinary(32), primary_key=True)
-    args = Column(Text)
-    scaninfo = Column(SQLJSONB)
-    scanner = Column(String(16))
-    start = Column(DateTime)
-    end = Column(DateTime)
-    elapsed = Column(Float)
-    version = Column(String(16))
-    xmloutputversion = Column(String(16))
-
-
 class _Association_Scan_Category:
     scan = Column(Integer, primary_key=True)
     category = Column(Integer, primary_key=True)
@@ -366,18 +348,6 @@ class _Scan:
 
 
 # Nmap
-class N_Association_Scan_ScanFile(Base, _Association_Scan_ScanFile):
-    __tablename__ = "n_association_scan_scanfile"
-    __table_args__ = (
-        ForeignKeyConstraint(["scan"], ["n_scan.id"], ondelete="CASCADE"),
-        ForeignKeyConstraint(["scan_file"], ["n_scan_file.sha256"], ondelete="CASCADE"),
-    )
-
-
-class N_ScanFile(Base, _ScanFile):
-    __tablename__ = "n_scan_file"
-
-
 class N_Association_Scan_Category(Base, _Association_Scan_Category):
     __tablename__ = "n_association_scan_category"
     __table_args__ = (
diff --git a/ivre/db/tiny.py b/ivre/db/tiny.py
index 3dfc125dd4..ebe2c7095b 100644
--- a/ivre/db/tiny.py
+++ b/ivre/db/tiny.py
@@ -218,12 +218,7 @@ def _generate_field_values(
                 fullfield = "%s.%s" % (base, field)
             else:
                 fullfield = field
-            if fullfield in cls.list_fields or (
-                # Hack: this field may or may not be a list (this
-                # needs to be changed in a near future)
-                fullfield == "scanid"
-                and isinstance(record[field], list)
-            ):
+            if fullfield in cls.list_fields:
                 for val in record[field]:
                     if countval is not None:
                         yield val, countval
@@ -526,10 +521,6 @@ def store_host(self, host):
         # `host` may be an instance of Document, and have its own
         # doc_id: convert it to a dict instance instead.
         host = deepcopy(dict(host))
-        try:
-            host["scanid"] = [host["scanid"].decode()]
-        except KeyError:
-            pass
         try:
             host["addr"] = self.ip2internal(host["addr"])
         except (KeyError, ValueError):
@@ -569,10 +560,6 @@ def store_host(self, host):
         utils.LOGGER.debug("HOST STORED: %r in %r", _id, self.dbname)
         return _id
 
-    @staticmethod
-    def getscanids(host):
-        return host.get("scanid", [])
-
     @classmethod
     def searchdomain(cls, name, neg=False):
         q = Query()
@@ -611,163 +598,6 @@ def searchcategory(cls, cat, neg=False):
         """
         return cls._searchstring_re_inarray(Query().categories, cat, neg=neg)
 
-    @classmethod
-    def searchtag(cls, tag=None, neg=False):
-        """Filters (if `neg` == True, filters out) one particular tag (records
-        may have zero, one or more tags).
-
-        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
-        `{"value": value, "info": info}`).
-
-        """
-        if not tag:
-            res = cls._search_field_exists("tags.value")
-            if neg:
-                return ~res
-            return res
-        if not isinstance(tag, dict):
-            tag = {"value": tag}
-        tests = []
-        for key, value in tag.items():
-            if isinstance(value, list) and len(value) == 1:
-                value = value[0]
-            if isinstance(value, utils.REGEXP_T):
-                if neg:
-                    if key == "info":
-
-                        def test(val, value=value):
-                            return not any(
-                                value.search(info) for info in val.get("info", [])
-                            )
-
-                    else:
-
-                        def test(val, key=key, value=value):
-                            return not value.search(val.get(key, ""))
-
-                elif key == "info":
-
-                    def test(val, value=value):
-                        return any(value.search(info) for info in val.get("info", []))
-
-                else:
-
-                    def test(val, key=key, value=value):
-                        return value.search(val.get(key, ""))
-
-            elif isinstance(value, list):
-                if neg:
-                    if key == "info":
-
-                        def test(val, value=value):
-                            return not any(
-                                v_info in val.get("info", []) for v_info in value
-                            )
-
-                    else:
-
-                        def test(val, key=key, value=value):
-                            return val.get(key) not in value
-
-                elif key == "info":
-
-                    def test(val, value=value):
-                        return any(v_info in val.get("info", []) for v_info in value)
-
-                else:
-
-                    def test(val, key=key, value=value):
-                        return val.get(key) in value
-
-            elif neg:
-                if key == "info":
-
-                    def test(val, value=value):
-                        return value not in val.get("info", [])
-
-                else:
-
-                    def test(val, key=key, value=value):
-                        return value != val.get(key)
-
-            elif key == "info":
-
-                def test(val, value=value):
-                    return value in val.get("info", [])
-
-            else:
-
-                def test(val, key=key, value=value):
-                    return value == val.get(key)
-
-            tests.append(test)
-        if neg:
-            return cls.flt_or(
-                ~cls._search_field_exists("tags.value"),
-                Query().tags.test(
-                    lambda tags: all(any(test(tag) for test in tests) for tag in tags)
-                ),
-            )
-        return Query().tags.test(
-            lambda tags: any(all(test(tag) for test in tests) for tag in tags)
-        )
-
-    @staticmethod
-    def searchcountry(country, neg=False):
-        """Filters (if `neg` == True, filters out) one particular
-        country, or a list of countries.
-
-        """
-        q = Query()
-        country = utils.country_unalias(country)
-        if isinstance(country, list):
-            res = q.infos.country_code.one_of(country)
-            if neg:
-                return ~res
-            return res
-        if neg:
-            return q.infos.country_code != country
-        return q.infos.country_code == country
-
-    @classmethod
-    def searchcity(cls, city, neg=False):
-        """
-        Filters (if `neg` == True, filters out) one particular city.
-        """
-        return cls._searchstring_re(Query().infos.city, city, neg=neg)
-
-    @staticmethod
-    def searchhaslocation(neg=False):
-        res = Query().infos.coordinates.exists()
-        if neg:
-            return ~res
-        return res
-
-    @staticmethod
-    def searchasnum(asnum, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS number(s).
-
-        """
-        q = Query()
-        if not isinstance(asnum, str) and hasattr(asnum, "__iter__"):
-            res = q.infos.as_num.one_of([int(val) for val in asnum])
-            if neg:
-                return ~res
-            return res
-        asnum = int(asnum)
-        if neg:
-            return q.infos.as_num != asnum
-        return q.infos.as_num == asnum
-
-    @classmethod
-    def searchasname(cls, asname, neg=False):
-        """Filters (if `neg` == True, filters out) one or more
-        particular AS.
-
-        """
-        return cls._searchstring_re(Query().infos.as_num, asname, neg=neg)
-
     @classmethod
     def searchsource(cls, src, neg=False):
         """Filters (if `neg` == True, filters out) one particular
@@ -2509,7 +2339,7 @@ def _extractor(flt, field):
         elif field == "scanner.name":
             flt = self.flt_and(flt, self.searchscript(name="scanner"))
             field = "ports.scripts.scanner.scanners.name"
-        elif field == "tag":
+        elif field == "tag" and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
 
             def _newflt(field):
@@ -2527,10 +2357,10 @@ def _extractor(flt, field):
                         for info in tag.get("info", []):
                             yield (tag["value"], info)
 
-        elif field.startswith("tag."):
+        elif field.startswith("tag.") and hasattr(self, "searchtag"):
             flt = self.flt_and(flt, self.searchtag())
             field = f"tags.{field[4:]}"
-        elif field.startswith("tag:"):
+        elif field.startswith("tag:") and hasattr(self, "searchtag"):
             subfield = field[4:]
 
             def _newflt(field):
@@ -2661,98 +2491,181 @@ class TinyDBNmap(TinyDBActive, DBNmap):
 
     content_handler = Nmap2DB
     dbname = "nmap"
-    dbname_scans = "nmap_scans"
 
     def __init__(self, url):
         super().__init__(url)
         self.output_function = None
 
-    @property
-    def db_scans(self):
-        """The DB for scan files"""
-        try:
-            return self._db_scans
-        except AttributeError:
-            self._db_scans = TDB(
-                os.path.join(self.basepath, "%s.json" % self.dbname_scans)
-            )
-            return self._db_scans
+    def store_or_merge_host(self, host):
+        self.store_host(host)
 
-    def init(self):
-        super().init()
-        try:
-            self.db_scans.drop_tables()
-        except AttributeError:
-            # TinyDB < 4
-            self.db_scans.purge_tables()
 
-    def remove(self, rec):
-        """Removes the record from the active column. `rec` must be the record
-        as returned by `.get()` or the record id.
+class TinyDBView(TinyDBActive, DBView):
 
-        """
-        q = Query()
-        if isinstance(rec, dict):
-            scanids = rec.get("scanid", [])
-        else:
-            try:
-                scanids = self.get(q._id == rec)[0].get("scanid", [])
-            except IndexError:
-                scanids = []
-        super().remove(rec)
-        for scanid in scanids:
-            if not self.db.get(q.scanid.any([scanid])):
-                self.db_scans.remove(cond=Query()._id == scanid)
+    """A View-specific DB using TinyDB backend"""
 
-    def remove_many(self, flt):
-        """Removes hosts from the active column, based on the filter `flt`.
+    dbname = "view"
 
-        If the hosts removed had `scanid` attributes, and if some of them
-        refer to scans that have no more host record after the deletion of the
-        hosts, then the scan records are also removed.
+    def store_or_merge_host(self, host):
+        if not self.merge_host(host):
+            self.store_host(host)
+
+    @classmethod
+    def searchtag(cls, tag=None, neg=False):
+        """Filters (if `neg` == True, filters out) one particular tag (records
+        may have zero, one or more tags).
+
+        `tag` may be the value (as a str) or the tag (as a Tag, e.g.:
+        `{"value": value, "info": info}`).
 
         """
-        scanids = list(self.distinct("scanid", flt=flt))
-        super().remove_many(flt)
-        for scanid in scanids:
-            if not self.db.get(Query().scanid.any([scanid])):
-                self.db_scans.remove(cond=Query()._id == scanid)
+        if not tag:
+            res = cls._search_field_exists("tags.value")
+            if neg:
+                return ~res
+            return res
+        if not isinstance(tag, dict):
+            tag = {"value": tag}
+        tests = []
+        for key, value in tag.items():
+            if isinstance(value, list) and len(value) == 1:
+                value = value[0]
+            if isinstance(value, utils.REGEXP_T):
+                if neg:
+                    if key == "info":
 
-    def store_or_merge_host(self, host):
-        self.store_host(host)
+                        def test(val, value=value):
+                            return not any(
+                                value.search(info) for info in val.get("info", [])
+                            )
 
-    def getscan(self, scanid):
-        try:
-            scanid = scanid.decode()
-        except AttributeError:
-            pass
-        return self.db_scans.get(Query()._id == scanid)
-
-    def is_scan_present(self, scanid):
-        return self.getscan(scanid) is not None
-
-    def store_scan_doc(self, scan):
-        scan = deepcopy(scan)
-        _id = scan["_id"] = scan["_id"].decode()
-        if self.db_scans.get(Query()._id == _id) is not None:
-            raise ValueError("Duplicate entry for id %r" % _id)
-        self.db_scans.insert(scan)
-        utils.LOGGER.debug("SCAN STORED: %r in %r", _id, self.dbname_scans)
-        return _id
+                    else:
 
-    def update_scan_doc(self, scan_id, data):
-        self.db_scans.update(deepcopy(data), cond=Query()._id == scan_id.decode())
+                        def test(val, key=key, value=value):
+                            return not value.search(val.get(key, ""))
 
+                elif key == "info":
 
-class TinyDBView(TinyDBActive, DBView):
+                    def test(val, value=value):
+                        return any(value.search(info) for info in val.get("info", []))
 
-    """A View-specific DB using TinyDB backend"""
+                else:
 
-    dbname = "view"
+                    def test(val, key=key, value=value):
+                        return value.search(val.get(key, ""))
 
-    def store_or_merge_host(self, host):
-        if not self.merge_host(host):
-            self.store_host(host)
+            elif isinstance(value, list):
+                if neg:
+                    if key == "info":
+
+                        def test(val, value=value):
+                            return not any(
+                                v_info in val.get("info", []) for v_info in value
+                            )
+
+                    else:
+
+                        def test(val, key=key, value=value):
+                            return val.get(key) not in value
+
+                elif key == "info":
+
+                    def test(val, value=value):
+                        return any(v_info in val.get("info", []) for v_info in value)
+
+                else:
+
+                    def test(val, key=key, value=value):
+                        return val.get(key) in value
+
+            elif neg:
+                if key == "info":
+
+                    def test(val, value=value):
+                        return value not in val.get("info", [])
+
+                else:
+
+                    def test(val, key=key, value=value):
+                        return value != val.get(key)
+
+            elif key == "info":
+
+                def test(val, value=value):
+                    return value in val.get("info", [])
+
+            else:
+
+                def test(val, key=key, value=value):
+                    return value == val.get(key)
+
+            tests.append(test)
+        if neg:
+            return cls.flt_or(
+                ~cls._search_field_exists("tags.value"),
+                Query().tags.test(
+                    lambda tags: all(any(test(tag) for test in tests) for tag in tags)
+                ),
+            )
+        return Query().tags.test(
+            lambda tags: any(all(test(tag) for test in tests) for tag in tags)
+        )
+
+    @staticmethod
+    def searchcountry(country, neg=False):
+        """Filters (if `neg` == True, filters out) one particular
+        country, or a list of countries.
+
+        """
+        q = Query()
+        country = utils.country_unalias(country)
+        if isinstance(country, list):
+            res = q.infos.country_code.one_of(country)
+            if neg:
+                return ~res
+            return res
+        if neg:
+            return q.infos.country_code != country
+        return q.infos.country_code == country
+
+    @classmethod
+    def searchcity(cls, city, neg=False):
+        """
+        Filters (if `neg` == True, filters out) one particular city.
+        """
+        return cls._searchstring_re(Query().infos.city, city, neg=neg)
+
+    @staticmethod
+    def searchhaslocation(neg=False):
+        res = Query().infos.coordinates.exists()
+        if neg:
+            return ~res
+        return res
+
+    @staticmethod
+    def searchasnum(asnum, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS number(s).
+
+        """
+        q = Query()
+        if not isinstance(asnum, str) and hasattr(asnum, "__iter__"):
+            res = q.infos.as_num.one_of([int(val) for val in asnum])
+            if neg:
+                return ~res
+            return res
+        asnum = int(asnum)
+        if neg:
+            return q.infos.as_num != asnum
+        return q.infos.as_num == asnum
+
+    @classmethod
+    def searchasname(cls, asname, neg=False):
+        """Filters (if `neg` == True, filters out) one or more
+        particular AS.
+
+        """
+        return cls._searchstring_re(Query().infos.as_num, asname, neg=neg)
 
 
 def op_update(count, firstseen, lastseen):
@@ -2984,7 +2897,7 @@ def _newflt(field):  # noqa: F811
 
             def _extractor(flt, field):  # noqa: F811
                 for rec in self._get(
-                    flt, sort=sort, limit=limit, skip=skip, fields=fields
+                    flt, sort=sort, limit=limit, skip=skip, fields=[field]
                 ):
                     val = "%s/%s" % (
                         utils.int2ip(utils.ip2int(rec["addr"]) & mask),
diff --git a/ivre/tools/db2view.py b/ivre/tools/db2view.py
index 46721c267c..418eacdf97 100644
--- a/ivre/tools/db2view.py
+++ b/ivre/tools/db2view.py
@@ -1,7 +1,7 @@
 #! /usr/bin/env python
 
 # This file is part of IVRE.
-# Copyright 2011 - 2022 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -21,15 +21,54 @@
 
 
 import argparse
-from typing import Generator, List
+from functools import reduce
+from multiprocessing import Pool, cpu_count
+from typing import Generator, List, Optional
 
+from ivre.active.data import merge_host_docs
 from ivre.activecli import displayfunction_json
 from ivre.db import DB, DBView, db
 from ivre.types import Record
-from ivre.view import from_nmap, from_passive, to_view
+from ivre.view import (
+    nmap_to_view,
+    passive_to_view,
+    prepare_record,
+    to_view,
+    to_view_parallel,
+)
+
+
+def merge_and_output(records: List[Record]) -> None:
+    result = reduce(
+        lambda r1, r2: merge_host_docs(
+            r1, r2, auto_tags=False, openports_attribute=False
+        ),
+        records,
+    )
+    w_output(prepare_record(result, w_datadb))  # type: ignore
+
+
+def worker_initializer(dburl: Optional[str], no_merge: bool) -> None:
+    # pylint: disable=global-variable-undefined
+    global w_datadb, w_outdb, w_output
+    w_outdb = db.view if dburl is None else DBView.from_url(dburl)  # type: ignore
+    if no_merge:
+        w_output = w_outdb.store_host  # type: ignore
+    else:
+        w_output = w_outdb.store_or_merge_host  # type: ignore
+    try:
+        w_datadb = w_outdb.globaldb.data  # type: ignore
+    except AttributeError:
+        w_datadb = None  # type: ignore
+    w_outdb.start_store_hosts()  # type: ignore
+
+
+def worker_destroyer(_: None) -> None:
+    w_outdb.stop_store_hosts()  # type: ignore
 
 
 def main() -> None:
+    default_processes = max(2, cpu_count())
     parser = argparse.ArgumentParser(description=__doc__, parents=[DB().argparser])
     if db.nmap is None:
         fltnmap = None
@@ -69,6 +108,13 @@ def main() -> None:
         metavar="DB_URL",
         help="Store data to the provided URL instead of the default DB for view.",
     )
+    parser.add_argument(
+        "--processes",
+        metavar="COUNT",
+        type=int,
+        help=f"The number of processes to use to build the records. Default on this system is {default_processes}.",
+        default=default_processes,
+    )
 
     subparsers = parser.add_subparsers(
         dest="view_source",
@@ -89,38 +135,52 @@ def main() -> None:
         _from = []
         if db.nmap is not None:
             fltnmap = db.nmap.parse_args(args, flt=fltnmap)
-            _from.append(from_nmap(fltnmap, category=view_category))
+            _from.append(nmap_to_view(fltnmap, category=view_category))
         if db.passive is not None:
             fltpass = db.passive.parse_args(args, flt=fltpass)
-            _from.append(from_passive(fltpass, category=view_category))
+            _from.append(passive_to_view(fltpass, category=view_category))
     elif args.view_source == "nmap":
         if db.nmap is None:
             parser.error('Cannot use "nmap" (no Nmap database exists)')
         fltnmap = db.nmap.parse_args(args, fltnmap)
-        _from = [from_nmap(fltnmap, category=view_category)]
+        _from = [nmap_to_view(fltnmap, category=view_category)]
     elif args.view_source == "passive":
         if db.passive is None:
             parser.error('Cannot use "passive" (no Passive database exists)')
         fltpass = db.passive.parse_args(args, fltpass)
-        _from = [from_passive(fltpass, category=view_category)]
-    if args.to_db is not None:
-        outdb = DBView.from_url(args.to_db)
-    else:
-        outdb = db.view
+        _from = [passive_to_view(fltpass, category=view_category)]
     if args.test:
+        args.processes = 1
+    outdb = db.view if args.to_db is None else DBView.from_url(args.to_db)
 
-        def output(host: Record) -> None:
-            return displayfunction_json([host], outdb)
+    # Output results
 
-    elif args.no_merge:
-        output = outdb.store_host
+    if args.processes > 1:
+        nprocs = max(args.processes - 1, 1)
+        with Pool(
+            nprocs,
+            initializer=worker_initializer,
+            initargs=(args.to_db, args.no_merge),
+        ) as pool:
+            for _ in pool.imap(merge_and_output, to_view_parallel(_from)):
+                pass
+            for _ in pool.imap(worker_destroyer, [None] * nprocs):
+                pass
     else:
-        output = outdb.store_or_merge_host
-    # Output results
-    itr = to_view(_from)
-    if not itr:
-        return
-    outdb.start_store_hosts()
-    for elt in itr:
-        output(elt)
-    outdb.stop_store_hosts()
+        if args.test:
+
+            def output(host: Record) -> None:
+                return displayfunction_json([host], outdb)
+
+        elif args.no_merge:
+            output = outdb.store_host
+        else:
+            output = outdb.store_or_merge_host
+        try:
+            datadb = outdb.globaldb.data
+        except AttributeError:
+            datadb = None
+        outdb.start_store_hosts()
+        for record in to_view(_from, datadb):
+            output(record)
+        outdb.stop_store_hosts()
diff --git a/ivre/tools/scan2db.py b/ivre/tools/scan2db.py
index 7baf3913e0..141f36ceff 100644
--- a/ivre/tools/scan2db.py
+++ b/ivre/tools/scan2db.py
@@ -1,7 +1,7 @@
 #! /usr/bin/env python
 
 # This file is part of IVRE.
-# Copyright 2011 - 2021 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -28,6 +28,7 @@
 import ivre.db
 import ivre.utils
 import ivre.xmlnmap
+from ivre.active.data import set_auto_tags, set_openports_attribute
 from ivre.types import Record
 from ivre.view import nmap_record_to_view
 
@@ -89,12 +90,6 @@ def main() -> None:
         "needed since the port number does not appear in the"
         "result.",
     )
-    parser.add_argument(
-        "--force-info",
-        action="store_true",
-        help="Force information (AS, country, city, etc.)"
-        " renewal (only useful with JSON format)",
-    )
     parser.add_argument(
         "-r",
         "--recursive",
@@ -132,9 +127,19 @@ def main() -> None:
     else:
 
         def callback(x: Record) -> None:
-            ivre.db.db.view.start_store_hosts()
-            ivre.db.db.view.store_or_merge_host(nmap_record_to_view(x))
-            ivre.db.db.view.stop_store_hosts()
+            result = nmap_record_to_view(x)
+            set_auto_tags(result, update_openports=False)
+            set_openports_attribute(result)
+            result["infos"] = {}
+            for func in [
+                ivre.db.db.data.country_byip,
+                ivre.db.db.data.as_byip,
+                ivre.db.db.data.location_byip,
+            ]:
+                result["infos"].update(func(result["addr"]) or {})
+            ivre.db.db.view.store_or_merge_host(result)
+
+        ivre.db.db.view.start_store_hosts()
 
     count = 0
     for scan in scans:
@@ -149,7 +154,6 @@ def callback(x: Record) -> None:
                 source=args.source,
                 needports=args.ports,
                 needopenports=args.open_ports,
-                force_info=args.force_info,
                 masscan_probes=args.masscan_probes,
                 callback=callback,
                 zgrab_port=args.zgrab_port,
@@ -158,5 +162,7 @@ def callback(x: Record) -> None:
         except Exception:
             ivre.utils.LOGGER.warning("Exception (file %r)", scan, exc_info=True)
             error[0] = True
+    if callback is not None:
+        ivre.db.db.view.stop_store_hosts()
     ivre.utils.LOGGER.info("%d results imported.", count)
     sys.exit(error[0])
diff --git a/ivre/utils.py b/ivre/utils.py
index 2c8882ae8c..f35984b0db 100644
--- a/ivre/utils.py
+++ b/ivre/utils.py
@@ -807,15 +807,6 @@ def open_file(fname: str) -> FileOpener:
     return FileOpener(fname)
 
 
-def hash_file(fname: str, hashtype: str = "sha1") -> bytes:
-    """Compute a hash of data from a given file"""
-    with open_file(fname) as fdesc:
-        result = hashlib.new(hashtype)
-        for data in iter(lambda: fdesc.read(1048576), b""):
-            result.update(data)
-        return result.hexdigest().encode()
-
-
 def serialize(obj: Any) -> str:
     """Return a JSON-compatible representation for `obj`"""
     if isinstance(obj, REGEXP_T):
diff --git a/ivre/view.py b/ivre/view.py
index f12dc91839..ea66754b42 100644
--- a/ivre/view.py
+++ b/ivre/view.py
@@ -22,6 +22,7 @@
 
 import struct
 from datetime import datetime
+from functools import reduce
 from textwrap import wrap
 
 try:
@@ -38,6 +39,7 @@
     add_cert_hostnames,
     add_hostname,
     create_ssl_output,
+    merge_host_docs,
     set_auto_tags,
     set_openports_attribute,
 )
@@ -47,6 +49,8 @@
 from ivre.xmlnmap import SCHEMA_VERSION as ACTIVE_SCHEMA_VERSION
 from ivre.xmlnmap import add_service_hostname
 
+MAX_RECORDS_IN_MEMORY = 16384
+
 
 def load_plugins():
     try:
@@ -777,8 +781,6 @@ def passive_record_to_view(rec, category=None):
     if isinstance(function, dict):
         function = function.get(rec["source"], lambda _: {})
     outrec.update(function(rec))
-    set_auto_tags(outrec, update_openports=False)
-    set_openports_attribute(outrec)
     if category is not None:
         outrec["categories"] = [category]
     return outrec
@@ -815,36 +817,10 @@ def passive_to_view(flt, category=None):
             pass
 
 
-def from_passive(flt, category=None):
-    """Iterator over passive results, by address."""
-    records = passive_to_view(flt, category=category)
-    cur_addr = None
-    cur_rec = {}
-    for rec in records:
-        if cur_addr is None:
-            cur_addr = rec["addr"]
-            cur_rec = rec
-            continue
-        if cur_addr == rec["addr"]:
-            cur_rec = db.view.merge_host_docs(cur_rec, rec)
-            continue
-        # TODO: add_addr_info should be optional
-        cur_rec["infos"] = {}
-        for func in [db.data.country_byip, db.data.as_byip, db.data.location_byip]:
-            cur_rec["infos"].update(func(cur_addr) or {})
-        yield cur_rec
-        cur_rec = rec
-        cur_addr = rec["addr"]
-    if cur_rec:
-        yield cur_rec
-
-
 def nmap_record_to_view(rec, category=None):
     """Convert an nmap result in view."""
     if "_id" in rec:
         del rec["_id"]
-    if "scanid" in rec:
-        del rec["scanid"]
     if "source" in rec:
         if not rec["source"]:
             rec["source"] = []
@@ -862,11 +838,14 @@ def nmap_record_to_view(rec, category=None):
     return rec
 
 
-def from_nmap(flt, category=None):
-    """Return an Nmap entry in the View format."""
-    cur_addr = None
-    cur_rec = None
-    result = None
+def nmap_to_view(flt, category=None):
+    """Generates nmap entries in the View format.
+
+    Note that this entry is likely to have no sense in itself. This
+    function is intended to be used to format results for the merge
+    function.
+
+    """
     done = False
     skip = 0
     while not done:
@@ -884,51 +863,102 @@ def from_nmap(flt, category=None):
                 if "addr" not in rec:
                     skip += 1
                     continue
-                rec = nmap_record_to_view(rec, category=category)
-                if cur_addr is None:
-                    cur_addr = rec["addr"]
-                    cur_rec = rec
-                elif cur_addr == rec["addr"]:
-                    cur_rec = db.view.merge_host_docs(cur_rec, rec)
-                else:
-                    result = cur_rec
-                    cur_rec = rec
-                    cur_addr = rec["addr"]
-                    set_auto_tags(result)
-                    yield result
+                yield nmap_record_to_view(rec, category=category)
                 skip += 1
-            if cur_rec is not None:
-                set_auto_tags(cur_rec)
-                yield cur_rec
             done = True
         except db.nmap.cursor_timeout_exceptions:
             pass
 
 
-def to_view(itrs):
+def prepare_record(rec, datadb):
+    """Prepare a record before sending it to the view"""
+    for port in rec.get("ports", []):
+        if "screendata" in port:
+            port["screendata"] = db.view.to_binary(port["screendata"])
+        for script in port.get("scripts", []):
+            if "masscan" in script and "raw" in script["masscan"]:
+                script["masscan"]["raw"] = db.view.to_binary(script["masscan"]["raw"])
+    set_auto_tags(rec, update_openports=False)
+    set_openports_attribute(rec)
+    if datadb is not None:
+        rec["infos"] = {}
+        addr = rec["addr"]
+        for func in [
+            datadb.country_byip,
+            datadb.as_byip,
+            datadb.location_byip,
+        ]:
+            rec["infos"].update(func(addr) or {})
+    return rec
+
+
+def to_view_parallel(itrs):
+    """Takes a list of iterators over view-formated results, and
+    returns an iterator over a list of results to be merged (by
+    parallel workers), sorted by ip.
+
+    """
+
+    next_recs = []
+
+    # We cannot use a `for itr in itrs` loop here because itrs is
+    # modified in the loop.
+    i = 0
+    while i < len(itrs):
+        try:
+            next_recs.append(next(itrs[i]))
+        except StopIteration:
+            # We need to remove the corresponding iterator from itrs,
+            # which happens to be the n-th where n is the current
+            # length of next_recs.
+            del itrs[len(next_recs)]  # Do not increment i here
+        else:
+            i += 1
+    next_addrs = [rec["addr"] for rec in next_recs]
+    cur_recs = []
+    cur_addr = min(next_addrs, key=utils.ip2int, default=None)
+    while next_recs:
+        # We cannot use a `for i in range(len(itrs))` loop because
+        # itrs is modified in the loop.
+        i = 0
+        while i < len(itrs):
+            while next_addrs[i] == cur_addr:
+                cur_recs.append(next_recs[i])
+                if len(cur_recs) >= MAX_RECORDS_IN_MEMORY:
+                    cur_recs = [
+                        reduce(
+                            lambda r1, r2: merge_host_docs(
+                                r1, r2, auto_tags=False, openports_attribute=False
+                            ),
+                            cur_recs,
+                        )
+                    ]
+                try:
+                    next_recs[i] = next(itrs[i])
+                except StopIteration:
+                    del next_addrs[i]
+                    del next_recs[i]
+                    del itrs[i]
+                    i -= 1  # Do not increment i here
+                    break
+                next_addrs[i] = next_recs[i]["addr"]
+            i += 1
+        if next_addrs and cur_addr not in next_addrs:
+            yield cur_recs
+            cur_recs = []
+            cur_addr = min(next_addrs, key=utils.ip2int)
+    if cur_recs:
+        yield cur_recs
+
+
+def to_view(itrs, datadb):
     """Takes a list of iterators over view-formated results, and returns an
     iterator over merged results, sorted by ip.
 
     """
 
-    def next_record(rec, updt):
-        if rec is None:
-            return updt
-        return db.view.merge_host_docs(rec, updt)
-
     next_recs = []
 
-    def prepare_record(rec):
-        for port in rec.get("ports", []):
-            if "screendata" in port:
-                port["screendata"] = db.view.to_binary(port["screendata"])
-            for script in port.get("scripts", []):
-                if "masscan" in script and "raw" in script["masscan"]:
-                    script["masscan"]["raw"] = db.view.to_binary(
-                        script["masscan"]["raw"]
-                    )
-        return rec
-
     # We cannot use a `for itr in itrs` loop here because itrs is
     # modified in the loop.
     i = 0
@@ -943,7 +973,7 @@ def prepare_record(rec):
         else:
             i += 1
     next_addrs = [rec["addr"] for rec in next_recs]
-    cur_rec = None
+    cur_rec = {}
     cur_addr = min(next_addrs, key=utils.ip2int, default=None)
     while next_recs:
         # We cannot use a `for i in range(len(itrs))` loop because
@@ -951,7 +981,7 @@ def prepare_record(rec):
         i = 0
         while i < len(itrs):
             while next_addrs[i] == cur_addr:
-                cur_rec = next_record(cur_rec, next_recs[i])
+                cur_rec = merge_host_docs(cur_rec, next_recs[i])
                 try:
                     next_recs[i] = next(itrs[i])
                 except StopIteration:
@@ -963,11 +993,11 @@ def prepare_record(rec):
                 next_addrs[i] = next_recs[i]["addr"]
             i += 1
         if next_addrs and cur_addr not in next_addrs:
-            yield prepare_record(cur_rec)
-            cur_rec = None
+            yield prepare_record(cur_rec, datadb)
+            cur_rec = {}
             cur_addr = min(next_addrs, key=utils.ip2int)
-    if cur_rec is not None:
-        yield prepare_record(cur_rec)
+    if cur_rec:
+        yield prepare_record(cur_rec, datadb)
 
 
 if HAS_PLUGINS:
diff --git a/ivre/web/app.py b/ivre/web/app.py
index 6b51576afa..41857f3bfc 100644
--- a/ivre/web/app.py
+++ b/ivre/web/app.py
@@ -1,7 +1,7 @@
 #! /usr/bin/env python
 
 # This file is part of IVRE.
-# Copyright 2011 - 2022 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -36,6 +36,7 @@
 from bottle import Bottle, abort, request, response
 
 from ivre import VERSION, config, utils
+from ivre.active.data import set_auto_tags, set_openports_attribute
 from ivre.db import db
 from ivre.view import nmap_record_to_view
 from ivre.web import utils as webutils
@@ -629,11 +630,10 @@ def get_nmap(subdb):
     # XXX-WORKAROUND-PGSQL
     # for rec in result:
     for i, rec in enumerate(result):
-        for fld in ["_id", "scanid"]:
-            try:
-                del rec[fld]
-            except KeyError:
-                pass
+        try:
+            del rec["_id"]
+        except KeyError:
+            pass
         if flt_params.ipsasnumbers:
             rec["addr"] = utils.force_ip2int(rec["addr"])
         if not flt_params.datesasstrings:
@@ -741,11 +741,19 @@ def import_files(subdb, source, categories, files):
     if subdb == "view":
 
         def callback(x):
-            db.view.start_store_hosts()
-            res = db.view.store_or_merge_host(nmap_record_to_view(x))
-            db.view.stop_store_hosts()
-            return res
-
+            result = nmap_record_to_view(x)
+            set_auto_tags(result, update_openports=False)
+            set_openports_attribute(result)
+            result["infos"] = {}
+            for func in [
+                db.data.country_byip,
+                db.data.as_byip,
+                db.data.location_byip,
+            ]:
+                result["infos"].update(func(result["addr"]) or {})
+            db.view.store_or_merge_host(result)
+
+        db.view.start_store_hosts()
     else:
         callback = None
     for fileelt in files:
@@ -761,6 +769,8 @@ def callback(x):
                 utils.LOGGER.warning("Could not import %s", fdesc.name)
         except Exception:
             utils.LOGGER.warning("Could not import %s", fdesc.name, exc_info=True)
+    if callback is not None:
+        db.view.stop_store_hosts()
     return count
 
 
diff --git a/ivre/web/utils.py b/ivre/web/utils.py
index 3b859a85a9..7371e61681 100644
--- a/ivre/web/utils.py
+++ b/ivre/web/utils.py
@@ -2,7 +2,7 @@
 # -*- coding: utf-8 -*-
 
 # This file is part of IVRE.
-# Copyright 2011 - 2022 Pierre LALET <pierre@droids-corp.org>
+# Copyright 2011 - 2023 Pierre LALET <pierre@droids-corp.org>
 #
 # IVRE is free software: you can redistribute it and/or modify it
 # under the terms of the GNU General Public License as published by
@@ -302,15 +302,15 @@ def add_unused(neg, param, value):
             flt = dbase.flt_and(
                 flt, dbase.searchcategory(utils.str2regexp(value), neg=neg)
             )
-        elif param == "country":
+        elif param == "country" and hasattr(dbase, "searchcountry"):
             flt = dbase.flt_and(
                 flt, dbase.searchcountry(utils.str2list(value.upper()), neg=neg)
             )
-        elif param == "city":
+        elif param == "city" and hasattr(dbase, "searchcity"):
             flt = dbase.flt_and(flt, dbase.searchcity(utils.str2regexp(value), neg=neg))
-        elif param == "asnum":
+        elif param == "asnum" and hasattr(dbase, "searchasnum"):
             flt = dbase.flt_and(flt, dbase.searchasnum(utils.str2list(value), neg=neg))
-        elif param == "asname":
+        elif param == "asname" and hasattr(dbase, "searchasname"):
             flt = dbase.flt_and(
                 flt, dbase.searchasname(utils.str2regexp(value), neg=neg)
             )
@@ -776,7 +776,7 @@ def add_unused(neg, param, value):
                 flt = dbase.flt_and(flt, dbase.searchcpe(**cpe_kwargs))
             else:
                 flt = dbase.flt_and(flt, dbase.searchcpe())
-        elif param == "tag":
+        elif param == "tag" and hasattr(dbase, "searchtag"):
             if value:
                 if ":" in value:
                     tag_val, tag_info = value.split(":", 1)
diff --git a/ivre/xmlnmap.py b/ivre/xmlnmap.py
index fb3d76dd32..e188950977 100644
--- a/ivre/xmlnmap.py
+++ b/ivre/xmlnmap.py
@@ -40,13 +40,12 @@
     create_ssl_cert,
     handle_http_content,
     handle_http_headers,
-    set_auto_tags,
 )
 from ivre.analyzer import dicom, ike, ja3
 from ivre.config import MASSCAN_PROBES
 from ivre.data.microsoft.windows import WINDOWS_VERSION_TO_BUILD
 
-SCHEMA_VERSION = 21
+SCHEMA_VERSION = 22
 
 # Scripts that mix elem/table tags with and without key attributes,
 # which is not supported for now
@@ -1509,7 +1508,6 @@ class NmapHandler(ContentHandler):
     def __init__(
         self,
         fname,
-        filehash,
         needports=False,
         needopenports=False,
         masscan_probes=None,
@@ -1518,7 +1516,6 @@ def __init__(
         ContentHandler.__init__(self)
         self._needports = needports
         self._needopenports = needopenports
-        self._curscan = None
         self._curscript = None
         self._curhost = None
         self._curextraports = None
@@ -1529,11 +1526,9 @@ def __init__(
         self._curtablepath = []
         self._curhostnames = None
         self._fname = fname
-        self._filehash = filehash
         self.scanner = "nmap"
-        self.scan_doc_saved = False
         self.masscan_probes = masscan_probes or []
-        utils.LOGGER.debug("READING %r (%r)", fname, self._filehash)
+        utils.LOGGER.debug("READING %r", fname)
 
     @staticmethod
     def _to_binary(data):
@@ -1560,40 +1555,9 @@ def _pre_addhost(self):
     def _addhost(self):
         """Subclasses may store self._curhost here."""
 
-    def _storescan(self):
-        """Subclasses may store self._curscan here."""
-
-    def _updatescan(self, _):
-        """Subclasses may update the scan record here, based on the first
-        argument (a dict object).
-
-        """
-
-    def _addscaninfo(self, _):
-        """Subclasses may add scan information (first argument) to
-        self._curscan here.
-
-        """
-
     def startElement(self, name, attrs):
         if name == "nmaprun":
-            if self._curscan is not None:
-                utils.LOGGER.warning(
-                    "self._curscan should be None at this point (got %r)",
-                    self._curscan,
-                )
-            self._curscan = dict(attrs)
-            self.scanner = self._curscan.get("scanner", self.scanner)
-            self._curscan["_id"] = self._filehash
-        elif name == "finished":
-            curscan_more = dict(attrs)
-            if "time" in curscan_more:
-                curscan_more["end"] = curscan_more.pop("time")
-            if "timestr" in curscan_more:
-                curscan_more["endstr"] = curscan_more.pop("timestr")
-            self._updatescan(curscan_more)
-        elif name == "scaninfo" and self._curscan is not None:
-            self._addscaninfo(dict(attrs))
+            self.scanner = attrs.get("scanner", self.scanner)
         elif name == "host":
             if self._curhost is not None:
                 utils.LOGGER.warning(
@@ -1601,8 +1565,6 @@ def startElement(self, name, attrs):
                     self._curhost,
                 )
             self._curhost = {"schema_version": SCHEMA_VERSION}
-            if self._curscan:
-                self._curhost["scanid"] = self._curscan["_id"]
             for attr in attrs.keys():
                 self._curhost[attr] = attrs[attr]
             for field in ["starttime", "endtime"]:
@@ -2150,9 +2112,7 @@ def startElement(self, name, attrs):
             self._curdata = ""
 
     def endElement(self, name):
-        if name == "nmaprun":
-            self._curscan = None
-        elif name == "host":
+        if name == "host":
             # masscan -oX output has no "state" tag
             if (
                 self._curhost.get("state", "up") == "up"
@@ -2168,7 +2128,6 @@ def endElement(self, name):
                     # hosts with an open port are marked as up by
                     # default (masscan)
                     self._curhost["state"] = "up"
-                set_auto_tags(self._curhost)
                 self._pre_addhost()
                 self._addhost()
             self._curhost = None
@@ -2702,7 +2661,6 @@ def __init__(
         categories=None,
         source=None,
         callback=None,
-        add_addr_infos=True,
         **kargs,
     ):
         self._db = db
@@ -2710,7 +2668,6 @@ def __init__(
             self.categories = []
         else:
             self.categories = categories
-        self._add_addr_infos = add_addr_infos
         self.source = source
         self.callback = callback
         NmapHandler.__init__(
@@ -2718,7 +2675,6 @@ def __init__(
             fname,
             categories=categories,
             source=source,
-            add_addr_infos=add_addr_infos,
             **kargs,
         )
 
@@ -2731,33 +2687,8 @@ def _from_binary(self, data):
     def _addhost(self):
         if self.categories:
             self._curhost["categories"] = self.categories[:]
-        if self._add_addr_infos:
-            self._curhost["infos"] = {}
-            for func in [
-                self._db.data.country_byip,
-                self._db.data.as_byip,
-                self._db.data.location_byip,
-            ]:
-                self._curhost["infos"].update(func(self._curhost["addr"]) or {})
         if self.source:
             self._curhost["source"] = self.source
-        # We are about to insert data based on this file, so we want
-        # to save the scan document
-        if not self.scan_doc_saved:
-            self.scan_doc_saved = True
-            self._storescan()
         self._db.nmap.store_or_merge_host(self._curhost)
         if self.callback is not None:
             self.callback(self._curhost)
-
-    def _storescan(self):
-        ident = self._db.nmap.store_scan_doc(self._curscan)
-        return ident
-
-    def _updatescan(self, curscan_more):
-        self._db.nmap.update_scan_doc(self._filehash, curscan_more)
-
-    def _addscaninfo(self, i):
-        if i.get("numservices"):
-            i["numservices"] = int(i["numservices"])
-        self._curscan.setdefault("scaninfos", []).append(i)
diff --git a/requirements-all.txt b/requirements-all.txt
index adb5677a25..29944770f1 100644
--- a/requirements-all.txt
+++ b/requirements-all.txt
@@ -2,7 +2,7 @@ bandit
 black
 bottle
 codespell
-cryptography
+cryptography<40.0.0
 docutils!=0.18
 elasticsearch
 elasticsearch-dsl
diff --git a/requirements-elastic.txt b/requirements-elastic.txt
index 0f5a12ec91..226ab87ca4 100644
--- a/requirements-elastic.txt
+++ b/requirements-elastic.txt
@@ -1,5 +1,5 @@
 elasticsearch
 elasticsearch-dsl
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle
diff --git a/requirements-maxmind.txt b/requirements-maxmind.txt
index 542a545698..d60f72eb77 100644
--- a/requirements-maxmind.txt
+++ b/requirements-maxmind.txt
@@ -1,4 +1,4 @@
 pymongo>=3.7
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle
diff --git a/requirements-mongo.txt b/requirements-mongo.txt
index 542a545698..d60f72eb77 100644
--- a/requirements-mongo.txt
+++ b/requirements-mongo.txt
@@ -1,4 +1,4 @@
 pymongo>=3.7
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle
diff --git a/requirements-mongosrv.txt b/requirements-mongosrv.txt
index d158db7d96..d8daef163a 100644
--- a/requirements-mongosrv.txt
+++ b/requirements-mongosrv.txt
@@ -1,4 +1,4 @@
 pymongo[srv]>=3.7
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle
diff --git a/requirements-postgres.txt b/requirements-postgres.txt
index b24caf4d70..6ab57ffe52 100644
--- a/requirements-postgres.txt
+++ b/requirements-postgres.txt
@@ -1,5 +1,5 @@
 sqlalchemy<2
 psycopg2
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle
diff --git a/requirements-sqlite.txt b/requirements-sqlite.txt
index 889408aaa4..b802405677 100644
--- a/requirements-sqlite.txt
+++ b/requirements-sqlite.txt
@@ -1,4 +1,4 @@
 sqlalchemy<2
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle
diff --git a/requirements-tinydb.txt b/requirements-tinydb.txt
index 7548c585fb..6587bd84d8 100644
--- a/requirements-tinydb.txt
+++ b/requirements-tinydb.txt
@@ -1,4 +1,4 @@
 tinydb
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle
diff --git a/requirements.txt b/requirements.txt
index 542a545698..d60f72eb77 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
 pymongo>=3.7
-cryptography
+cryptography<40.0.0
 pyOpenSSL>=16.1.0
 bottle


Test patch:
diff --git a/tests/samples/results b/tests/samples/results
index 7f8bdb3cf0..b5e5186cb9 100644
--- a/tests/samples/results
+++ b/tests/samples/results
@@ -158,34 +158,19 @@ nmap_count_httphdr_contentype_textplain = 13
 nmap_count_imap = 10
 nmap_count_imap_or_ssh = 207
 nmap_count_ssh = 200
-nmap_count_tags = 43
-nmap_count_tags_honeypot = 7
-nmap_count_tags_honeypot_mushmush_102 = 3
-nmap_count_tags_tor = 0
 nmap_distinct_moduli = 296
 nmap_distinct_ssh_moduli = 86
 nmap_distinct_ssl_moduli = 212
 nmap_domain_com_count = 64
 nmap_domains_pttsh_tw = ['s228.pttsh.ttct.edu.tw']
-nmap_extended_eu_count = 445
-nmap_features_ports_FRDE_ncolumns = 477
-nmap_features_ports_FRDE_ndata = 122
 nmap_features_ports_ncolumns = 476
 nmap_features_ports_ndata = 945
-nmap_features_products_FRDE_ncolumns = 1837
-nmap_features_products_FRDE_ndata = 122
 nmap_features_products_ncolumns = 1836
 nmap_features_products_ndata = 945
-nmap_features_services_FRDE_ncolumns = 965
-nmap_features_services_FRDE_ndata = 122
 nmap_features_services_ncolumns = 964
 nmap_features_services_ndata = 945
-nmap_features_versions_FRDE_ncolumns = 2411
-nmap_features_versions_FRDE_ndata = 122
 nmap_features_versions_ncolumns = 2410
 nmap_features_versions_ndata = 945
-nmap_features_versions_noyieldall_FRDE_ncolumns = 1545
-nmap_features_versions_noyieldall_FRDE_ndata = 122
 nmap_features_versions_noyieldall_ncolumns = 1544
 nmap_features_versions_noyieldall_ndata = 945
 nmap_geovision_count = 6
@@ -195,7 +180,6 @@ nmap_httpauth_count = 0
 nmap_http_title_count = 438
 nmap_isakmp_count = 198
 nmap_ldapanon_count = 0
-nmap_location_count = 522
 nmap_max_moduli_reuse = 18
 nmap_max_moduli_ssh_reuse = 11
 nmap_max_moduli_ssl_reuse = 18
@@ -273,13 +257,6 @@ nmap_top_service = ['http']
 nmap_top_ssh_port_api = [('tcp', 22)]
 nmap_top_ssh_port_cgi = [['tcp', 22]]
 nmap_top_ssh_port_cli = ['tcp / 22']
-nmap_top_tag_api = [('Reserved address', 'Address type Private')]
-nmap_top_tag_cli = ['Reserved address / Address type Private']
-nmap_top_tag_cgi = [['Reserved address', 'Address type Private']]
-nmap_top_tag_value = ['Reserved address']
-nmap_top_tag_info = ['Address type Private']
-nmap_top_tag_type = ['info']
-nmap_top_tag_cdn = ['azure as listed at <https://cdn.nuclei.sh/>']
 nmap_top_uids = ['ftp']
 nmap_top_version_http_apache = ['http / Apache httpd / None']
 nmap_top_version_http = ['http / Microsoft IIS httpd / 7.5']
@@ -298,16 +275,6 @@ passive_basicauth_count = 0
 passive_cert_google = 0
 passive_cert_microsoft = 0
 passive_count = 570
-passive_count_as123456789 = 0
-passive_count_as15169 = 14
-passive_count_as15557 = 43
-passive_count_as2200 = 0
-passive_count_as3215 = 0
-passive_count_country_DE = 18
-passive_count_country_FR = 68
-passive_count_country_KP = 0
-passive_count_country_US = 111
-passive_count_country_XX = 0
 passive_count_dnstype_A = 75
 passive_count_dnstype_AAAA = 5
 passive_count_dnstype_PTR = 2
@@ -358,24 +325,14 @@ passive_dnsbl_count_after_update = 21
 passive_dnsbl_count_before_update = 12
 passive_dnsbl_results_after_update = [('127.0.0.2', 1, '127.0.0.10'), ('127.0.0.2', 1, '127.0.0.10'), ('127.0.0.2', 1, '127.0.0.2'), ('127.0.0.2', 1, '127.0.0.2'), ('127.0.0.2', 1, '127.0.0.4'), ('127.0.0.2', 1, '127.0.0.4'), ('138.36.1.237', 1, '127.0.0.3'), ('138.36.1.237', 1, '127.0.0.3'), ('138.36.1.237', 1, '127.0.0.4'), ('138.36.1.237', 1, '127.0.0.4'), ('178.88.114.106', 1, '127.0.0.3'), ('178.88.114.106', 1, '127.0.0.3'), ('178.88.114.106', 1, '127.0.0.4'), ('178.88.114.106', 1, '127.0.0.4'), ('181.92.119.72', 1, '127.0.0.11'), ('181.92.119.72', 1, '127.0.0.11'), ('181.92.119.72', 1, '127.0.0.4'), ('181.92.119.72', 1, '127.0.0.4'), ('185.12.45.167', 1, '127.0.0.8'), ('2607:f8b0:4864:20::341', 2, '127.0.0.8'), ('2607:f8b0:4864:20::944', 1, '127.0.0.8')]
 passive_dnsbl_results_before_update = [('127.0.0.2', 1, '127.0.0.10'), ('127.0.0.2', 1, '127.0.0.2'), ('127.0.0.2', 1, '127.0.0.4'), ('138.36.1.237', 1, '127.0.0.3'), ('138.36.1.237', 1, '127.0.0.4'), ('178.88.114.106', 1, '127.0.0.3'), ('178.88.114.106', 1, '127.0.0.4'), ('181.92.119.72', 1, '127.0.0.11'), ('181.92.119.72', 1, '127.0.0.4'), ('185.12.45.167', 1, '127.0.0.8'), ('2607:f8b0:4864:20::341', 2, '127.0.0.8'), ('2607:f8b0:4864:20::944', 1, '127.0.0.8')]
-passive_features_ports_FRDE_ncolumns = 32
-passive_features_ports_FRDE_ndata = 10
 passive_features_ports_ncolumns = 31
 passive_features_ports_ndata = 73
-passive_features_products_FRDE_ncolumns = 49
-passive_features_products_FRDE_ndata = 10
 passive_features_products_ncolumns = 48
 passive_features_products_ndata = 73
-passive_features_services_FRDE_ncolumns = 39
-passive_features_services_FRDE_ndata = 10
 passive_features_services_ncolumns = 38
 passive_features_services_ndata = 73
-passive_features_versions_FRDE_ncolumns = 58
-passive_features_versions_FRDE_ndata = 10
 passive_features_versions_ncolumns = 57
 passive_features_versions_ndata = 73
-passive_features_versions_noyieldall_FRDE_ncolumns = 46
-passive_features_versions_noyieldall_FRDE_ndata = 10
 passive_features_versions_noyieldall_ncolumns = 45
 passive_features_versions_noyieldall_ndata = 73
 passive_ftpauth_count = 0
@@ -498,24 +455,25 @@ view_count_tags_tor = 10
 view_count_text_honeypot = 7
 view_count_text_password = 2
 view_count_total = 1068
+view_extended_eu_count = 499
 view_features_ports_FRDE_ncolumns = 477
-view_features_ports_FRDE_ndata = 153
+view_features_ports_FRDE_ndata = 152
 view_features_ports_ncolumns = 476
 view_features_ports_ndata = 1068
 view_features_products_FRDE_ncolumns = 1838
-view_features_products_FRDE_ndata = 153
+view_features_products_FRDE_ndata = 152
 view_features_products_ncolumns = 1837
 view_features_products_ndata = 1068
 view_features_services_FRDE_ncolumns = 965
-view_features_services_FRDE_ndata = 153
+view_features_services_FRDE_ndata = 152
 view_features_services_ncolumns = 964
 view_features_services_ndata = 1068
 view_features_versions_FRDE_ncolumns = 2419
-view_features_versions_FRDE_ndata = 153
+view_features_versions_FRDE_ndata = 152
 view_features_versions_ncolumns = 2418
 view_features_versions_ndata = 1068
 view_features_versions_noyieldall_FRDE_ncolumns = 1554
-view_features_versions_noyieldall_FRDE_ndata = 153
+view_features_versions_noyieldall_FRDE_ndata = 152
 view_features_versions_noyieldall_ncolumns = 1553
 view_features_versions_noyieldall_ndata = 1068
 view_gnmap_up_count = 1014
diff --git a/tests/tests.py b/tests/tests.py
index 6b4c679804..a6f28c758a 100644
--- a/tests/tests.py
+++ b/tests/tests.py
@@ -900,9 +900,6 @@ def host_stored_test(line):
                 return 1
             return 0
 
-        scan_duplicate = re.compile(
-            b"^DEBUG:ivre:Scan already present in " b"Database", re.M
-        )
         for fname in self.nmap_files:
             # Insertion in DB
             options = ["ivre", "scan2db", "--port", "-c", "TEST", "-s", "SOURCE"]
@@ -937,12 +934,6 @@ def host_stored_test(line):
             host_counter_test += sum(
                 host_stored_test(line) for line in out.splitlines()
             )
-            # Duplicate insertion
-            res, _, err = RUN(
-                ["ivre", "scan2db", "--port", "-c", "TEST", "-s", "SOURCE", fname]
-            )
-            self.assertEqual(res, 0)
-            scan_warning += sum(1 for _ in scan_duplicate.finditer(err))
 
         # Specific test cases
         samples = [
@@ -1260,13 +1251,6 @@ def host_stored_test(line):
             "-countports:10-100",
         )
 
-        self.check_nmap_count_value(
-            "nmap_extended_eu_count",
-            ivre.db.db.nmap.searchcountry(["EU", "GB", "CH", "NO"]),
-            ["--country=EU,GB,CH,NO"],
-            "country:EU,GB,CH,NO",
-        )
-
         # Filters
         addr = next(
             iter(ivre.db.db.nmap.get(ivre.db.db.nmap.flt_empty, fields=["addr"]))
@@ -1293,13 +1277,6 @@ def host_stored_test(line):
         self.check_count_value_api(
             1, ivre.db.db.nmap.searchid(recid), database=ivre.db.db.nmap
         )
-        self.assertIsNotNone(
-            ivre.db.db.nmap.getscan(
-                ivre.db.db.nmap.getscanids(
-                    next(iter(ivre.db.db.nmap.get(ivre.db.db.nmap.flt_empty)))
-                )[0]
-            )
-        )
 
         self.check_nmap_count_value(
             0,
@@ -1756,12 +1733,6 @@ def _has_fingerprint(host):
         self.check_nmap_top_value("nmap_top_jarm", "jarm")
         self.check_nmap_top_value("nmap_top_jarm_443", "jarm:443")
 
-        self.check_nmap_top_value("nmap_top_tag", "tag")
-        self.check_nmap_top_value("nmap_top_tag_value", "tag.value")
-        self.check_nmap_top_value("nmap_top_tag_info", "tag.info")
-        self.check_nmap_top_value("nmap_top_tag_type", "tag.type")
-        self.check_nmap_top_value("nmap_top_tag_cdn", "tag:CDN")
-
         for base in ["", "-client", "-server"]:
             for field in ["", ".md5", ".sha1", ".sha256", ".raw"]:
                 self.check_nmap_top_value(
@@ -1802,32 +1773,6 @@ def _has_fingerprint(host):
             "nmap_top_hop_10+", "hop>10", database=ivre.db.db.nmap
         )
 
-        locations = list(ivre.db.db.nmap.getlocations(ivre.db.db.nmap.flt_empty))
-        self.assertTrue(all(len(elt) == 2 for elt in locations))
-        self.assertTrue(all(isinstance(elt["_id"], tuple) for elt in locations))
-        self.assertTrue(all(len(elt["_id"]) == 2 for elt in locations))
-        self.assertTrue(
-            all(all(isinstance(sub, float) for sub in elt["_id"]) for elt in locations)
-        )
-        self.assertTrue(all(isinstance(elt["count"], int) for elt in locations))
-        self.check_value("nmap_location_count", len(locations))
-
-        # Check that all coordinates for IPs in "FR" are in a
-        # rectangle given by 43 < lat < 51 and -5 < lon < 8 (for some
-        # reasons, overseas territories have they own country code,
-        # e.g., "RE").
-        self.assertTrue(
-            all(
-                43 < lat < 51 and -5 < lon < 8
-                for lat, lon in (
-                    elt["_id"]
-                    for elt in ivre.db.db.nmap.getlocations(
-                        ivre.db.db.nmap.searchcountry("FR")
-                    )
-                )
-            )
-        )
-
         # moduli
         proc = RUN_ITER(
             ["ivre", "getmoduli", "--active-ssl", "--active-ssh"], stderr=None
@@ -1924,71 +1869,6 @@ def _has_fingerprint(host):
         self.assertTrue(all(len(d) == ncolumns for d in data))
         self.check_value("nmap_features_versions_noyieldall_ndata", len(data))
 
-        subflts = [
-            (country, ivre.db.db.nmap.searchcountry(country))
-            for country in ["FR", "DE"]
-        ]
-        columns, data = ivre.db.db.nmap.features(use_service=False, subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("nmap_features_ports_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("nmap_features_ports_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.nmap.features(subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("nmap_features_services_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("nmap_features_services_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.nmap.features(use_product=True, subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("nmap_features_products_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("nmap_features_products_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.nmap.features(use_version=True, subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("nmap_features_versions_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("nmap_features_versions_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.nmap.features(
-            yieldall=False, use_version=True, subflts=subflts
-        )
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("nmap_features_versions_noyieldall_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("nmap_features_versions_noyieldall_FRDE_ndata", len(data))
-
-        # Tags
-        self.check_value(
-            "nmap_count_tags", ivre.db.db.nmap.count(ivre.db.db.nmap.searchtag())
-        )
-        self.check_value(
-            "nmap_count_tags_honeypot",
-            ivre.db.db.nmap.count(ivre.db.db.nmap.searchtag("Honeypot")),
-        )
-        self.check_value(
-            "nmap_count_tags_honeypot",
-            ivre.db.db.nmap.count(ivre.db.db.nmap.searchtag({"value": "Honeypot"})),
-        )
-        self.check_value(
-            "nmap_count_tags_tor",
-            ivre.db.db.nmap.count(ivre.db.db.nmap.searchtag({"value": "TOR"})),
-        )
-        self.check_value(
-            "nmap_count_tags_honeypot_mushmush_102",
-            ivre.db.db.nmap.count(
-                ivre.db.db.nmap.searchtag(
-                    {
-                        "value": "Honeypot",
-                        "info": "honeypot / MushMush Conpot on port tcp/102",
-                    }
-                )
-            ),
-        )
-
         # BEGIN Using the HTTP server as a database
         with tempfile.NamedTemporaryFile(delete=False) as fdesc:
             newenv = os.environ.copy()
@@ -2023,7 +1903,7 @@ def _has_fingerprint(host):
                 found = True
         self.assertTrue(found)
 
-        for topval in ["port:open", "service:80", "country", "portlist:open"]:
+        for topval in ["port:open", "service:80", "portlist:open"]:
             res, out1, err = RUN(["ivre", "scancli", "--top", topval], env=newenv)
             self.assertEqual(res, 0)
             self.assertFalse(err)
@@ -2816,36 +2696,6 @@ def test_40_passive(self):
         self.check_value("passive_distinct_ssh_moduli", distinct)
         self.check_value("passive_max_moduli_ssh_reuse", maxcount)
 
-        # ASNs / Countries / .searchranges()
-        for asnum in [15169, 15557, 3215, 2200, 123456789]:
-            if DATABASE == "tinydb" and asnum == 3215:
-                # With tinydb, the filter generates a huge expression
-                # which leads to the following error:
-                #
-                # RecursionError: maximum recursion depth exceeded
-                continue
-            res, out, err = RUN(["ivre", "ipinfo", "--count", "--asnum", str(asnum)])
-            self.assertEqual(ret, 0)
-            self.assertFalse(err)
-            self.check_value("passive_count_as%d" % asnum, int(out))
-        for cname in ["US", "FR", "DE", "KP", "XX"]:
-            if DATABASE in ["sqlite", "tinydb"] and cname in ["US", "FR", "DE"]:
-                # With sqlite, the filter generates a huge expression
-                # which leads to the following error:
-                #
-                # sqlite3.OperationalError: Expression tree is too
-                # large (maximum depth 10000)
-                #
-                # With tinydb, the filter generates a huge expression
-                # which leads to the following error:
-                #
-                # RecursionError: maximum recursion depth exceeded
-                continue
-            res, out, err = RUN(["ivre", "ipinfo", "--count", "--country", cname])
-            self.assertEqual(ret, 0)
-            self.assertFalse(err)
-            self.check_value("passive_count_country_%s" % cname, int(out))
-
         ret, out, _ = RUN(["ivre", "ipinfo", "--short"])
         self.assertEqual(ret, 0)
         count = sum(1 for _ in out.splitlines())
@@ -3165,53 +3015,6 @@ def alarm_handler(signum, stacktrace):
         self.assertTrue(all(len(d) == ncolumns for d in data))
         self.check_value("passive_features_versions_noyieldall_ndata", len(data))
 
-        if DATABASE == "tinydb":
-            # BUG in tinydb backend: the country filters generate huge
-            # expressions that cause the error:
-            #
-            # RecursionError: maximum recursion depth exceeded
-            return
-
-        subflts = [
-            (country, ivre.db.db.passive.searchcountry(country))
-            for country in ["FR", "DE"]
-        ]
-        columns, data = ivre.db.db.passive.features(use_service=False, subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("passive_features_ports_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("passive_features_ports_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.passive.features(subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("passive_features_services_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("passive_features_services_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.passive.features(use_product=True, subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("passive_features_products_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("passive_features_products_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.passive.features(use_version=True, subflts=subflts)
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value("passive_features_versions_FRDE_ncolumns", ncolumns)
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("passive_features_versions_FRDE_ndata", len(data))
-        columns, data = ivre.db.db.passive.features(
-            yieldall=False, use_version=True, subflts=subflts
-        )
-        ncolumns = len(columns)
-        data = list(data)
-        self.check_value(
-            "passive_features_versions_noyieldall_FRDE_ncolumns",
-            ncolumns,
-        )
-        self.assertTrue(all(len(d) == ncolumns for d in data))
-        self.check_value("passive_features_versions_noyieldall_FRDE_ndata", len(data))
-
         # BEGIN Using the HTTP server as a database
         with tempfile.NamedTemporaryFile(delete=False) as fdesc:
             newenv = os.environ.copy()
@@ -5047,6 +4850,8 @@ def _json_loads(data, deflt=None):
         shutil.rmtree("output")
 
     def test_50_view(self):
+        processes = random.choice([[], ["--processes", "1"]])
+        print(f"Running db2view with {processes!r}")
         #
         # Web server tests
         #
@@ -5161,7 +4966,7 @@ def test_50_view(self):
         print("Counting")
         view_count = 0
         # Count passive results
-        self.assertEqual(RUN(["ivre", "db2view", "passive"])[0], 0)
+        self.assertEqual(RUN(["ivre", "db2view"] + processes + ["passive"])[0], 0)
         if DATABASE == "elastic":
             time.sleep(ELASTIC_INSERT_TEMPO)
         ret, out, _ = RUN(["ivre", "view", "--count"])
@@ -5173,7 +4978,7 @@ def test_50_view(self):
             RUN(["ivre", "view", "--init"], stdin=subprocess.DEVNULL)[0], 0
         )
         # Count active results
-        self.assertEqual(RUN(["ivre", "db2view", "nmap"])[0], 0)
+        self.assertEqual(RUN(["ivre", "db2view"] + processes + ["nmap"])[0], 0)
         if DATABASE == "elastic":
             time.sleep(ELASTIC_INSERT_TEMPO)
         ret, out, _ = RUN(["ivre", "view", "--count"])
@@ -5183,7 +4988,12 @@ def test_50_view(self):
         self.check_value("view_count_active", view_count)
         # Count merged results
         self.assertEqual(
-            RUN(["ivre", "db2view", "--view-category", "PASSIVE", "passive"])[0], 0
+            RUN(
+                ["ivre", "db2view", "--view-category", "PASSIVE"]
+                + processes
+                + ["passive"]
+            )[0],
+            0,
         )
         if DATABASE == "elastic":
             time.sleep(ELASTIC_INSERT_TEMPO)
@@ -5199,6 +5009,29 @@ def test_50_view(self):
         self.assertEqual(ret, 0)
         self.assertFalse(err)
 
+        self.check_view_count_value(
+            "view_extended_eu_count",
+            ivre.db.db.view.searchcountry(["EU", "GB", "CH", "NO"]),
+            ["--country=EU,GB,CH,NO"],
+            "country:EU,GB,CH,NO",
+        )
+
+        # Check that all coordinates for IPs in "FR" are in a
+        # rectangle given by 43 < lat < 51 and -5 < lon < 8 (for some
+        # reasons, overseas territories have they own country code,
+        # e.g., "RE").
+        self.assertTrue(
+            all(
+                43 < lat < 51 and -5 < lon < 8
+                for lat, lon in (
+                    elt["_id"]
+                    for elt in ivre.db.db.view.getlocations(
+                        ivre.db.db.view.searchcountry("FR")
+                    )
+                )
+            )
+        )
+
         print("Outputs")
         # JSON
         ret, out, err = RUN(["ivre", "view", "--json"])