HEX
Server: Apache
System: Linux www3.pit.tblive.com 5.14.0-687.38.1.el9_8.x86_64 #1 SMP PREEMPT_DYNAMIC Wed Aug 12 17:19:12 EDT 2026 x86_64
User: awaldron (1020)
PHP: 8.1.34
Disabled: exec,passthru,shell_exec,system
Upload Files
File: //opt/imunify360/venv/lib/python3.11/site-packages/imav/malwarelib/plugins/detached_scan.py
"""
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.


This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 
See the GNU General Public License for more details.


You should have received a copy of the GNU General Public License
 along with this program.  If not, see <https://www.gnu.org/licenses/>.

Copyright © 2019 Cloud Linux Software Inc.

This software is also available under ImunifyAV commercial license,
see <https://www.imunify360.com/legal/eula>
"""
import shutil
import time
from logging import getLogger
from typing import Any, Optional, Union

from defence360agent.contracts.messages import MessageType
from defence360agent.contracts.plugins import (
    MessageSink,
    MessageSource,
    expect,
)
from defence360agent.internals.the_sink import TheSink
from defence360agent.utils import Scope
from imav.contracts.messages import MalwareDatabaseScan
from imav.malwarelib.config import (
    MalwareScanResourceType,
    MalwareScanType,
)
from imav.malwarelib.model import MalwareScan as MalwareScanModel
from imav.malwarelib.scan import (
    ScanAlreadyCompleteError,
    ScanInfoError,
)
from imav.malwarelib.scan.ai_bolit.detached import (
    AiBolitDetachedScan,
)
from imav.malwarelib.scan.detached import DetachedScan
from imav.malwarelib.scan.mds.detached import MDSDetachedScan
from imav.malwarelib.scan.queue_supervisor_sync import QueueSupervisorSync
from imav.malwarelib.scan.scan_result import aggregate_result
from imav.malwarelib.utils.user_list import fill_results_owner

logger = getLogger(__name__)


class DetachedScanPlugin(MessageSink, MessageSource):
    PROCESSING_ORDER = MessageSink.ProcessingOrder.PRE_PROCESS_MESSAGE
    SCOPE = Scope.AV
    loop = None
    sink: TheSink
    results_cache: dict[str, dict[str, Any]] = {}
    # scan_ids whose complete() just succeeded. A duplicate MalwareScanComplete
    # that then finds the report gone (it is removed right after the first,
    # successful completion) must not be finalized as aborted -- the scan
    # completed fine. Bounded; scan_ids are unique and the single-slot queue
    # completes one scan at a time, so a small cap is ample.
    _completed_scan_ids: dict[str, None] = {}
    _MAX_COMPLETED_SCAN_IDS = 256

    async def create_source(self, loop, sink):
        self.loop = loop
        self.sink = sink

    async def create_sink(self, loop):
        pass

    @expect(MessageType.MalwareScan, async_lock=True)
    async def complete_scan(self, message):
        message_type = MalwareScanMessageInfo(message)

        if not message_type.is_detached:
            total_malicious = await self._count_total_malicious(message)
            message["summary"]["total_malicious"] = total_malicious
            return message
        elif message_type.is_summary:
            return await self._handle_summary(message)

        # message_type.is_result
        return await self._handle_results(message)

    async def _handle_summary(self, message):
        scan_id = message["summary"]["scanid"]
        # If summary arrives after results, results are read from cache
        if scan_id in self.results_cache:
            message["summary"]["completed"] = time.time()
            message["results"] = self.results_cache.pop(scan_id)
            total_malicious = await self._count_total_malicious(message)
            message["summary"]["total_malicious"] = total_malicious
            queued_scan = QueueSupervisorSync.queue.find(
                scanid=message["summary"]["scanid"]
            )
            if queued_scan:
                message["summary"]["scan_args"] = queued_scan.args
                QueueSupervisorSync.queue.remove(queued_scan)
        return message

    async def _handle_results(self, message):
        message = await self.aggregate_result(message)
        message_type = MalwareScanMessageInfo(message)
        summary = message["summary"]
        logger.info("Scan stopped")
        queued_scan = QueueSupervisorSync.queue.find(scanid=summary["scanid"])

        if message_type.summary_from_db is None:
            if queued_scan:
                summary["file_patterns"] = queued_scan.args["file_patterns"]
                summary["exclude_patterns"] = queued_scan.args[
                    "exclude_patterns"
                ]
                summary["scan_args"] = queued_scan.args
                QueueSupervisorSync.queue.remove(queued_scan)
            if summary.get("path") or summary.get("error"):
                # Scan failed
                summary["total_malicious"] = 0
                await self._recheck_scan_queue()
                return message

            # Summary is not in DB yet, save results to cache
            scan_id = message["summary"]["scanid"]
            self.results_cache[scan_id] = message["results"]
            # Report an error to Sentry if cache grows
            cache_size = len(self.results_cache)
            if cache_size > 1:
                logger.error("MalwareScan cache size is %d", cache_size)
            return

        scan = message_type.summary_from_db
        summary["scanid"] = scan.scanid
        summary["path"] = scan.path
        summary["started"] = scan.started
        summary["completed"] = time.time()
        if summary.get("total_files") is None:
            summary["total_files"] = scan.total_resources

        summary["type"] = scan.type
        summary["error"] = summary.get("error", None)
        message["summary"] = summary

        total_malicious = await self._count_total_malicious(message)
        message["summary"]["total_malicious"] = total_malicious
        if queued_scan:
            summary["file_patterns"] = queued_scan.args["file_patterns"]
            summary["exclude_patterns"] = queued_scan.args["exclude_patterns"]
            summary["scan_args"] = queued_scan.args
            QueueSupervisorSync.queue.remove(queued_scan)
        await self._recheck_scan_queue()
        return message

    @staticmethod
    async def _count_total_malicious(message) -> int:
        return len(
            [
                k
                for k, v in message["results"].items()
                if v["hits"][0]["suspicious"] is False
            ]
        )

    @staticmethod
    def _get_detached_scan(
        resource_type: Optional[Union[str, MalwareScanResourceType]],
        scan_id: str,
    ) -> DetachedScan:
        return AiBolitDetachedScan(scan_id)

    @expect(MessageType.MalwareScanComplete)
    async def complete_detached_scan(self, message):
        scan_id = message.get("scan_id")
        resource_type = message.get("resource_type")
        detached_scan = self._get_detached_scan(resource_type, scan_id)

        try:
            scan_message = await detached_scan.complete()
            # Record the success before the finally clause removes the report.
            # A duplicate completion that then finds the report gone is checked
            # against this below, so it is never finalized as aborted.
            self._mark_completed(scan_id)
        except ScanAlreadyCompleteError as err:
            # A duplicate MalwareScanComplete is normal: the scan completed, its
            # report was consumed, then complete() runs again when AV is woken
            # up by AiBolit. If this scan completed (possibly concurrently --
            # messages are not serialized per scan), the success owns it.
            # Otherwise, if it is still queued its report is gone yet it never
            # reached a terminal state and would block the queue forever --
            # finalize it.
            if scan_id in self._completed_scan_ids:
                logger.warning(
                    "Scan %s already completed; ignoring duplicate completion"
                    ":\n%s",
                    scan_id,
                    err,
                )
            elif await self._finalize_stuck_scan(detached_scan, scan_id):
                logger.warning(
                    "Scan %s has no report but is still queued;"
                    " finalized it as aborted: %s",
                    scan_id,
                    err,
                )
            else:
                logger.warning(
                    "Cannot complete scan %s, assuming it is already complete"
                    ":\n%s",
                    scan_id,
                    err,
                )
            return
        except ScanInfoError as err:
            if scan_id in self._completed_scan_ids:
                logger.warning(
                    "Scan %s already completed; ignoring duplicate completion"
                    ":\n%s",
                    scan_id,
                    err,
                )
            elif await self._finalize_stuck_scan(detached_scan, scan_id):
                logger.warning(
                    "Scan %s has no scan_info but is still queued;"
                    " finalized it as aborted: %s",
                    scan_id,
                    err,
                )
            else:
                logger.error(
                    "Cannot complete %s scan %s, assuming it was not started"
                    ":\n%s",
                    detached_scan.RESOURCE_TYPE.value,
                    scan_id,
                    err,
                )
            return
        finally:
            shutil.rmtree(str(detached_scan.detached_dir), ignore_errors=True)

        await self.sink.process_message(scan_message)

    def _mark_completed(self, scan_id: str) -> None:
        """Remember a scan whose complete() just succeeded (bounded set)."""
        self._completed_scan_ids[scan_id] = None
        # dicts preserve insertion order -> drop the oldest entries past the cap.
        while len(self._completed_scan_ids) > self._MAX_COMPLETED_SCAN_IDS:
            del self._completed_scan_ids[next(iter(self._completed_scan_ids))]

    async def _finalize_stuck_scan(self, detached_scan, scan_id) -> bool:
        """Route a still-queued scan whose report/scan_info is gone through the
        aborted path so it is recorded failed and stops blocking the queue;
        return whether it was still queued and got finalized."""
        # A scan that completed (even concurrently) is filtered out by the
        # completed-id guard in the caller, so reaching here means it is stuck.
        queued = QueueSupervisorSync.queue.find(scanid=scan_id)
        if queued is None:
            return False
        # kill=False: there is no live worker to kill (its output is already
        # gone), and kill=True would block ~30s waiting for a pid file. Pass the
        # queued scan's start time so the aborted summary is persisted -- the
        # store drops summaries with a falsy "started".
        await detached_scan.handle_aborted_process(
            sink=self.sink,
            kill=False,
            scan_started=getattr(queued, "started", None),
        )
        return True

    @classmethod
    async def aggregate_result(cls, message):
        message["results"] = aggregate_result(message["results"])
        await fill_results_owner(message["results"])
        return message

    async def _recheck_scan_queue(self):
        await self.sink.process_message(MessageType.MalwareScanQueueRecheck())


class MalwareScanMessageInfo:
    """A helper class that allows to receive information about scan
    from MalwareScan message.
    """

    def __init__(self, message):
        self.message = message
        self._summary_from_db = None
        self.scan_id = self.message["summary"]["scanid"]

    @property
    def is_detached(self):
        summary = self.message["summary"]
        return summary.get("type") in (
            MalwareScanType.ON_DEMAND,
            MalwareScanType.BACKGROUND,
            MalwareScanType.USER,
            None,
        )

    @property
    def is_summary(self):
        return self.message["results"] is None

    @property
    def summary_from_db(self):
        if not self._summary_from_db:
            summary_from_db = (
                MalwareScanModel.select()
                .where(MalwareScanModel.scanid == self.scan_id)
                .limit(1)
            )
            if summary_from_db:
                self._summary_from_db = summary_from_db[0]
        return self._summary_from_db


class DetachedScanPluginIm360(DetachedScanPlugin):
    SCOPE = Scope.IM360

    @staticmethod
    def _get_detached_scan(
        resource_type: Optional[Union[str, MalwareScanResourceType]],
        scan_id: str,
    ) -> DetachedScan:
        if resource_type is not None and (
            MalwareScanResourceType(resource_type)
            is MalwareScanResourceType.DB
        ):
            return MDSDetachedScan(scan_id)
        return AiBolitDetachedScan(scan_id)

    @expect(MessageType.MalwareDatabaseScan)
    async def complete_scan_db(self, message: MalwareDatabaseScan):
        queued_scan = QueueSupervisorSync.queue.find(scanid=message["scan_id"])
        if queued_scan:
            QueueSupervisorSync.queue.remove(queued_scan)
            await self._recheck_scan_queue()