"""
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 .
Copyright © 2019 Cloud Linux Software Inc.
This software is also available under ImunifyAV commercial license,
see
"""
import asyncio
import os
import time
from datetime import datetime, timedelta
from logging import getLogger
from defence360agent.api import inactivity
from defence360agent.contracts.messages import MessageType
from defence360agent.contracts.plugins import Scope
from defence360agent.internals.global_scope import g
from defence360agent.mr_proper import BaseCleaner
from defence360agent.subsys import persistent_state
from defence360agent.utils import is_cluster, nice_iterator, split_for_chunk
from imav.malwarelib.config import (
MalwareHitStatus,
MalwareScanResourceType,
MalwareScanType,
)
from imav.malwarelib.model import MalwareHistory, MalwareHit, MalwareScan
from imav.malwarelib.scan.utils import safe_realpath
from imav.malwarelib.subsys.malware import MalwareAction
from imav.subsys import realtime_av
logger = getLogger(__name__)
class OutdatedScansCleaner(BaseCleaner):
PERIOD = timedelta(days=1).total_seconds()
CLEANUP_LIMIT_DELTA = timedelta(days=30)
LOCK_FILE = persistent_state.register_lock_file(
"malware-scans-cleaner", Scope.AV_IM360
)
@classmethod
async def cleanup(cls) -> None:
cleanup_time_limit = int(
(datetime.now() - cls.CLEANUP_LIMIT_DELTA).timestamp()
)
deleted = (
MalwareScan.delete()
.where(MalwareScan.started < cleanup_time_limit)
.execute()
)
logger.info("Cleaned %s outdated scans", deleted)
class OutdatedHistoryCleaner(BaseCleaner):
PERIOD = timedelta(days=1).total_seconds()
CLEANUP_LIMIT_DELTA = timedelta(days=30)
LOCK_FILE = persistent_state.register_lock_file(
"malware-history-cleaner", Scope.AV_IM360
)
@classmethod
async def cleanup(cls) -> None:
keep_time_threshold = int(
(datetime.now() - cls.CLEANUP_LIMIT_DELTA).timestamp()
)
deleted = (
MalwareHistory.delete()
.where(MalwareHistory.ctime < keep_time_threshold)
.execute()
)
logger.info("Cleaned %s outdated malware history", deleted)
class OutdatedHitsCleaner(BaseCleaner):
PERIOD = int(
os.getenv(
"IMUNIFY360_OUTDATED_HITS_CHECK_INTERVAL",
timedelta(days=1).total_seconds(),
)
)
FILE_NAME = os.getenv(
"MALWARE_HITS_CLEANER_LOCK_FILE", "malware-hits-cleaner"
)
LOCK_FILE = persistent_state.register_lock_file(FILE_NAME, Scope.AV_IM360)
REALTIME_SCAN_THRESHOLD = timedelta(minutes=10).total_seconds()
CHUNK_SIZE = 1000
@classmethod
async def _cleanup(cls) -> None:
"""Rescan irrelevant malware hits"""
to_rescan = []
not_exist_hits = []
# re-check only *malicious* hits
hits = (
MalwareHit.select()
.where(
MalwareHit.status == MalwareHitStatus.FOUND,
MalwareHit.resource_type == MalwareScanResourceType.FILE.value,
MalwareHit.malicious == True, # noqa: E712
)
.order_by(MalwareHit.timestamp.asc())
)
async for hit in nice_iterator(hits, chunk_size=cls.CHUNK_SIZE):
# orig_file_path is a TenantPath: standalone stats the local file;
# in k8s it routes through the syncer so we read the tenant pod's
# filesystem, not the agent container's (where the file is absent
# and every hit would otherwise look deleted).
tenant_path = hit.orig_file_path
try:
st = await tenant_path.stat_async()
except FileNotFoundError:
# Definitively gone (local stat, or the syncer reported
# missing) — safe to drop the stale hit.
not_exist_hits.append(hit.id)
continue
except (OSError, ValueError) as exc:
# OSError: transient (gateway down, permission). ValueError: a
# tenantless path we cannot resolve in cluster mode. We could
# NOT verify the file, so keep the hit rather than delete a
# finding we have not confirmed is gone.
logger.warning(
"Can't check %r due to %s; keeping hit", tenant_path, exc
)
continue
# ctime locally; the k8s syncer exposes only mtime, an equivalent
# "changed since the scan" signal.
changed_at = st.st_mtime if tenant_path.is_remote else st.st_ctime
if hit.timestamp < changed_at:
# rescan the modified files after scanning,
# they may not be infected anymore
realtime_threshold = (
time.time() - cls.REALTIME_SCAN_THRESHOLD
) # don't scan file twice
if (
not realtime_av.should_be_running()
or changed_at < realtime_threshold
):
to_rescan.append(hit.orig_file)
if to_rescan:
for files in split_for_chunk(to_rescan, chunk_size=cls.CHUNK_SIZE):
logger.info("Rescan %s outdated malware files", len(files))
await g.sink.process_message(
MessageType.MalwareRescanFiles(
files=files, type=MalwareScanType.RESCAN_OUTDATED
)
)
# delete db entries for non-existent files
for hits_to_delete in split_for_chunk(
not_exist_hits, chunk_size=cls.CHUNK_SIZE
):
hits = list(
MalwareHit.select()
.where(MalwareHit.id.in_(hits_to_delete))
.where(MalwareHit.status == MalwareHitStatus.FOUND)
.where(MalwareHit.malicious == True) # noqa: E712
)
deleted = (
MalwareHit.delete().where(MalwareHit.id.in_(hits)).execute()
)
await MalwareAction.not_exist(
hits, cause=MalwareScanType.RESCAN_OUTDATED
)
logger.info("Deleted %s not exist malware hits", deleted)
# don't block the whole loop for too long,
# return control to the loop every iteration
await asyncio.sleep(0)
@classmethod
async def cleanup(cls):
with inactivity.track.task("malware hits relevance check"):
await cls._cleanup()
class OutdatedDbHitsCleaner(BaseCleaner):
PERIOD = timedelta(days=1).total_seconds()
LOCK_FILE = persistent_state.register_lock_file(
"malware-db-hits-cleaner", Scope.AV_IM360
)
CHUNK_SIZE = 1000
@staticmethod
def _app_path_is_gone(path: str) -> bool:
# gone only if both symlink forms are missing; other errors != gone
real = safe_realpath(path)
if real is None:
return False
forms = (path, real)
for form in forms:
try:
os.stat(form)
return False
except FileNotFoundError:
continue
except (OSError, ValueError):
return False
return True
@classmethod
async def _cleanup(cls) -> None:
if is_cluster():
# tenant-prefixed paths are invisible here; stat would retire all
return
hits = MalwareHit.select().where(
MalwareHit.status == MalwareHitStatus.FOUND,
MalwareHit.resource_type == MalwareScanResourceType.DB.value,
)
gone = []
async for hit in nice_iterator(hits, chunk_size=cls.CHUNK_SIZE):
if cls._app_path_is_gone(hit.orig_file):
gone.append(hit.id)
deleted = 0
for chunk in split_for_chunk(gone, chunk_size=cls.CHUNK_SIZE):
# a collected hit may leave FOUND before the delete runs
chunk_hits = list(
MalwareHit.select().where(
MalwareHit.id.in_(chunk),
MalwareHit.status == MalwareHitStatus.FOUND,
MalwareHit.resource_type
== MalwareScanResourceType.DB.value,
)
)
deleted += (
MalwareHit.delete()
.where(MalwareHit.id.in_(chunk_hits))
.execute()
)
await MalwareAction.not_exist(
chunk_hits, cause=MalwareScanType.RESCAN_OUTDATED
)
await asyncio.sleep(0)
if deleted:
logger.info("Deleted %s db hits with missing app paths", deleted)
@classmethod
async def cleanup(cls):
with inactivity.track.task("db malware hits relevance check"):
await cls._cleanup()