ok

Mini Shell

Direktori : /opt/imunify360/venv/lib64/python3.11/site-packages/im360/internals/core/
Upload File :
Current File : //opt/imunify360/venv/lib64/python3.11/site-packages/im360/internals/core/__init__.py

"""Core module for rules and sets managing."""

import logging
import math
import time
from typing import Dict, Iterable, List, Optional, Set, Tuple

from defence360agent.utils import await_for, retry_on, timeit
from im360.contracts.config import NetworkInterface, UnifiedAccessLogger
from im360.internals.core.ipset.port_deny import (
    InputPortBlockingDenyModeIPSet,
    OutputPortBlockingDenyModeIPSet,
)
from im360.utils.validate import IPVersion

from . import ip_versions
from .firewall import (
    FirewallRules,
    RuleDef,
    firewall_logging_enabled,
    is_nat_available,
)
from .ipset import IP_SET_PREFIX, libipset
from .ipset.country import IPSetCountry
from .ipset.ip import IPSet
from .ipset.libipset import IPSetCmdBuilder, IPSetRestoreCmd
from .ipset.port import IPSetIgnoredByPort, IPSetPort
from .ipset.redirect import (
    IPSetNoRedirectPort,
    IPSetWebshieldPort,
)
from .ipset.sync import IPSetSyncIPListPurpose, IPSetSyncIPListRecords

logger = logging.getLogger(__name__)


class RuleSet:
    """Managing iptables rules and ipsets."""

    _CHAINS = [
        FirewallRules.COUNTRY_WHITELIST_CHAIN,
        FirewallRules.COUNTRY_BLACKLIST_CHAIN,
        FirewallRules.BP_INPUT_CHAIN,
        FirewallRules.LOG_BLACKLIST_CHAIN,
        FirewallRules.LOG_GRAYLIST_CHAIN,
        FirewallRules.LOG_BLACKLISTED_COUNTRY_CHAIN,
        FirewallRules.WEBSHIELD_PORTS_INPUT_CHAIN,
        FirewallRules.LOG_BLOCK_PORT_CHAIN,
    ]
    # Since DB and ipset are updated at different times,
    # check relative value instead of compare absolute values.
    # Use a large enough relative number to avoid false positives,
    # 20% difference looks reasonable for this.
    _IPSET_COUNT_TO_RECREATE_THRESHOLD = 0.2

    def __init__(self):
        self.entities = (
            InputPortBlockingDenyModeIPSet(),
            OutputPortBlockingDenyModeIPSet(),
            IPSetPort(),
            IPSet(),
            # Order is important here,
            # Ensure IPSetSyncIPListRecords is created before IPSetSyncIPListPurpose
            IPSetSyncIPListRecords(),
            IPSetSyncIPListPurpose(),
            IPSetCountry(),
            IPSetIgnoredByPort(),
            IPSetNoRedirectPort(),
            IPSetWebshieldPort(),
        )

    @staticmethod
    def targets(ip_version: IPVersion) -> List[Tuple]:
        """
        Returns tables & chains that Imunify360 will use in firewall management
        :param ip_version: IPv4 or IPv6
        :return: List[Tuple]:
        """
        return [
            (FirewallRules.FILTER, "INPUT"),
            (
                (FirewallRules.NAT, "PREROUTING")
                if is_nat_available(ip_version)
                else (FirewallRules.MANGLE, "PREROUTING")
            ),
        ]

    @staticmethod
    def _apply_ignored_interfaces(action, interface_conf, *args, **kwargs):
        """
        :param interface_conf: interface configuration
        :param Callable action: action to perform with interface
        """
        for interface in interface_conf[NetworkInterface.DEVICE_SKIP]:
            yield action(
                FirewallRules.compose_rule(
                    FirewallRules.interface(interface),
                    action=FirewallRules.compose_action(FirewallRules.ACCEPT),
                ),
                chain=FirewallRules.IMUNIFY_INPUT_CHAIN,
                priority=0,  # max priority for firewalld
                *args,
                **kwargs,
            )

    @staticmethod
    def _compose_rule(ip_version: IPVersion, interface_conf: dict) -> RuleDef:
        """Compose rule based on NetworkInterface config"""
        target_interface = interface_conf[ip_version]
        action = FirewallRules.compose_action(
            FirewallRules.IMUNIFY_INPUT_CHAIN
        )
        if target_interface:
            rule = FirewallRules.compose_rule(
                FirewallRules.interface(target_interface), action=action
            )
        else:
            rule = action
        return rule

    async def ipset_create_commands(self, ip_version: IPVersion) -> List[str]:
        names = []  # type: List[str]
        for entity in self.entities:
            names.extend(entity.gen_ipset_create_ops(ip_version))
        return names

    async def ipset_flush_commands(
        self, ip_version: IPVersion, existing: Optional[Set[str]] = None
    ) -> Iterable[IPSetRestoreCmd]:
        """Generate ipset restore commands to destroy *existing* ipsets."""
        if existing is None:
            existing = await self.existing_ipsets(ip_version)

        # get entity specific flush commands
        cmds = []

        needed_entities = [
            entity
            for entity in self.entities
            if hasattr(entity, "gen_ipset_flush_cmds")
        ]
        for entity in needed_entities:
            cmds += entity.gen_ipset_flush_cmds(ip_version, existing)

        return cmds

    async def ipset_destroy_commands(
        self, ip_version: IPVersion, existing: Optional[Set[str]] = None
    ) -> Iterable[IPSetRestoreCmd]:
        """Generate ipset restore commands to destroy *existing* ipsets."""
        if existing is None:
            existing = await self.existing_ipsets(ip_version)

        # get entity specific destroy commands
        cmds = {}  # type: Dict[str, IPSetRestoreCmd]
        for entity in self.entities:
            entity_cmds = entity.gen_ipset_destroy_ops(ip_version, existing)
            cmds.update(entity_cmds)

        # generic destroy
        for ipset_name in existing:
            if ipset_name not in cmds:
                # ipset is not special, remove using a generic destroy command
                cmds[ipset_name] = IPSetCmdBuilder.get_destroy_cmd(ipset_name)
        return cmds.values()

    async def create_commands(
        self, firewall, interface_conf: dict, ip_version: IPVersion
    ) -> list:
        """Return a list of firewall commands to create all required rules."""
        actions = []
        # input chains
        for table, chain in self.targets(ip_version):
            actions.extend(
                [
                    firewall.create_chain(
                        table=table, chain=FirewallRules.IMUNIFY_INPUT_CHAIN
                    ),
                    firewall.insert_rule(
                        self._compose_rule(ip_version, interface_conf),
                        table=table,
                        chain=chain,
                    ),
                    *self._apply_ignored_interfaces(
                        firewall.insert_rule, interface_conf, table=table
                    ),
                ]
            )
        actions.extend(
            [
                firewall.create_chain(table=FirewallRules.FILTER, chain=chain)
                for chain in self._CHAINS
            ]
        )
        actions.extend(self._log_block_rules(firewall.append_rule, ip_version))
        # output chains
        actions.extend(
            [
                firewall.create_chain(
                    table=FirewallRules.FILTER,
                    chain=FirewallRules.IMUNIFY_OUTPUT_CHAIN,
                ),
                firewall.insert_rule(
                    FirewallRules.compose_action(
                        FirewallRules.IMUNIFY_OUTPUT_CHAIN
                    ),
                    chain="OUTPUT",
                ),
            ]
        )
        actions.extend(
            [
                firewall.create_chain(table=FirewallRules.FILTER, chain=chain)
                for chain in [FirewallRules.BP_OUTPUT_CHAIN]
            ]
        )
        actions.extend(
            [
                firewall.append_rule(**rule)
                for rule in await self._collect_ipset_rules(ip_version)
            ]
        )
        # Add connection tracking rule.
        actions.append(
            firewall.insert_rule(
                # fmt: off
                (
                    "-m", "comment",
                    "--comment", '"Connection tracking for Imunify360."',
                    "-j", "CT",
                ),
                # fmt: off
                table="raw", chain="PREROUTING"
            )
        )
        return actions

    def destroy_commands(
        self, firewall, interface_conf: dict, ip_version: IPVersion
    ) -> Iterable[list]:
        """Returns an iterable over list of commands to destroy firewall rules.

        Each list should be executed as a separate firewall commit
        operation."""
        # input chains
        for table, chain in self.targets(ip_version):
            yield [
                firewall.delete_rule(
                    self._compose_rule(ip_version, interface_conf),
                    table=table,
                    chain=chain,
                )
            ]
            yield [
                firewall.flush_chain(
                    FirewallRules.IMUNIFY_INPUT_CHAIN, table=table
                ),
                firewall.delete_chain(
                    FirewallRules.IMUNIFY_INPUT_CHAIN, table=table
                ),
            ]
        for chain in self._CHAINS:
            yield [
                firewall.flush_chain(chain, table=FirewallRules.FILTER),
                firewall.delete_chain(chain, table=FirewallRules.FILTER),
            ]
        # output chains
        yield [
            firewall.delete_rule(
                FirewallRules.compose_action(
                    FirewallRules.IMUNIFY_OUTPUT_CHAIN
                ),
                chain="OUTPUT",
            )
        ]
        yield [
            firewall.flush_chain(FirewallRules.IMUNIFY_OUTPUT_CHAIN),
            firewall.delete_chain(FirewallRules.IMUNIFY_OUTPUT_CHAIN),
        ]
        for chain in [FirewallRules.BP_OUTPUT_CHAIN]:
            yield [firewall.flush_chain(chain), firewall.delete_chain(chain)]
        # Delete connection tracking rule.
        yield [
            firewall.delete_rule(
                # fmt: off
                (
                    "-m", "comment",
                    "--comment", '"Connection tracking for Imunify360."',
                    "-j", "CT",
                ),
                # fmt: off
                table="raw",
                chain="PREROUTING",
            )
        ]

    def required_ipsets(self, ip_version: IPVersion) -> Set[str]:
        names = set()  # type: Set[str]
        for entity in self.entities:
            names.update(entity.get_all_ipsets(ip_version))
        return names

    async def check_commands(
        self, firewall, interface_conf, ip_version: IPVersion
    ) -> list:
        """Returns a list of firewall commands to check for firewall rules."""
        actions = []
        for table, chain in self.targets(ip_version):
            actions.extend(
                [
                    firewall.has_rule(
                        self._compose_rule(ip_version, interface_conf),
                        table=table,
                        chain=chain,
                    ),
                    *self._apply_ignored_interfaces(
                        firewall.has_rule, interface_conf, table=table
                    ),
                ]
            )
        actions.extend(self._log_block_rules(firewall.has_rule, ip_version))
        actions.extend(
            [
                firewall.has_rule(
                    FirewallRules.compose_action(
                        FirewallRules.IMUNIFY_OUTPUT_CHAIN
                    ),
                    table=FirewallRules.FILTER,
                    chain="OUTPUT",
                ),
            ]
        )
        actions.extend(
            [
                firewall.has_rule(**rule)
                for rule in await self._collect_ipset_rules(ip_version)
            ]
        )
        actions.append(
            firewall.has_rule(
                # fmt: off
                (
                    "-m", "comment",
                    "--comment", '"Connection tracking for Imunify360."',
                    "-j", "CT",
                ),
                # fmt: off
                table="raw", chain="PREROUTING"
            )
        )
        return actions

    def _log_block_rules(self, predicate, ip_version: IPVersion):
        rules = []
        for chain, prefix, action in (
            (
                FirewallRules.LOG_BLACKLIST_CHAIN,
                UnifiedAccessLogger.BLACKLIST,
                FirewallRules.compose_action(FirewallRules.DROP),
            ),
            (
                FirewallRules.LOG_GRAYLIST_CHAIN,
                UnifiedAccessLogger.GRAYLIST,
                FirewallRules.compose_action(FirewallRules.DROP),
            ),
            (
                FirewallRules.LOG_BLACKLISTED_COUNTRY_CHAIN,
                UnifiedAccessLogger.BLACKLIST_COUNTRY,
                FirewallRules.compose_action(FirewallRules.DROP),
            ),
            (
                FirewallRules.LOG_BLOCK_PORT_CHAIN,
                UnifiedAccessLogger.BLOCKED_BY_PORT,
                FirewallRules.compose_action(FirewallRules.REJECT),
            ),
        ):
            rules.extend(
                predicate(rule, table=FirewallRules.FILTER, chain=chain)
                for rule in self._log_drop_rules(ip_version, prefix, action)
            )
        return rules

    async def _collect_ipset_rules(self, ip_version: IPVersion) -> List[dict]:
        rules = []  # type: List[dict]
        for entity in self.entities:
            rules.extend(entity.get_rules(ip_version))
        rules.sort(key=lambda r: (r["chain"], r["priority"]))
        return rules

    async def fill_ipsets(
        self, ip_version: IPVersion, missing: Set[str]
    ) -> None:
        """Fills all ipsets with required elements."""
        create_and_restore_cmds = []
        for entity in self.entities:
            for ip_set in entity.get_all_ipset_instances(ip_version):
                if ip_set.gen_ipset_name_for_ip_version(ip_version) in missing:
                    create_and_restore_cmds.extend(
                        ip_set.gen_ipset_create_ops(ip_version)
                    )
                    create_and_restore_cmds.extend(
                        await ip_set.gen_ipset_restore_ops(ip_version)
                    )

        await libipset.restore(create_and_restore_cmds)
        logger.info("IP sets content restored from database")

    @staticmethod
    async def existing_ipsets(ip_version: IPVersion) -> Set[str]:
        prefix = ".".join([IP_SET_PREFIX, ip_version])
        return set(
            s for s in await libipset.list_set() if s.startswith(prefix)
        )

    async def destroy_ipsets(
        self, ip_version: IPVersion, existing: Optional[Set[str]] = None
    ) -> None:
        """Destroys ipsets with given names."""
        if existing is None:
            to_destroy = await self.existing_ipsets(ip_version)
        else:
            to_destroy = existing.copy()
        max_tries = 3
        attempt = 0
        while to_destroy or attempt > max_tries:
            # remove absent ipsets
            to_destroy &= await self.existing_ipsets(ip_version)
            try:
                await libipset.restore(
                    await self.ipset_flush_commands(ip_version, to_destroy)
                )
                await libipset.restore(
                    await self.ipset_destroy_commands(ip_version, to_destroy)
                )
                return
            except (
                libipset.IPSetNotFoundError,
                libipset.IPSetCannotBeDestroyedError,
            ):
                attempt += 1
        if to_destroy or attempt > max_tries:
            logger.error("Failed to destroy ipsets: %s", ", ".join(to_destroy))

    async def _recreate_ipsets(
        self, ip_version: IPVersion, existing: Optional[Set[str]] = None
    ):
        """Reset all ipsets, create them again and fill with IPs
        for given ip version."""
        for entity in self.entities:
            await entity.reset(ip_version, existing)

    async def recreate_ipsets(
        self, ip_version: IPVersion = None, existing: Optional[Set[str]] = None
    ):
        """Recreate existing ipsets (or given).
        If *ip_version* is None, recreate ipsets for all enabled ip versions.
        """

        if ip_version:
            await self._recreate_ipsets(ip_version, existing)
        else:
            for ip_version in ip_versions.enabled():
                await self._recreate_ipsets(ip_version, existing)

    @staticmethod
    def _log_drop_rules(ip_version: IPVersion, prefix, action):
        rules = []
        if firewall_logging_enabled():
            rules.append(
                FirewallRules.compose_rule(
                    action=FirewallRules.nflog_action(
                        group=FirewallRules.nflog_group(ip_version),
                        prefix=prefix,
                    )
                )
            )
        rules.append(action)
        return rules

    async def get_outdated_ipsets(self, ip_version: IPVersion) -> list:
        """
        Return list of ipsets the contents of which do not match the database
        """
        outdated: list = []
        for entity in self.entities:
            all_ipsets = await entity.get_ipsets_count(ip_version)
            outdated.extend(
                ipset
                for ipset in all_ipsets
                if not math.isclose(
                    ipset.ipset_count,
                    ipset.db_count,
                    rel_tol=self._IPSET_COUNT_TO_RECREATE_THRESHOLD,
                )
            )
        return outdated

Zerion Mini Shell 1.0