MOON
Server: Apache
System: Linux kvm.asjudinet.com 5.14.0-611.54.6.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Fri May 15 04:23:18 EDT 2026 x86_64
User: asjudine (1001)
PHP: 8.0.30
Disabled: exec,passthru,shell_exec,system
Upload Files
File: //opt/cloudlinux/venv/lib/python3.11/site-packages/wmt/common/config.py
import fnmatch
import json
import logging
import os
import re
from dataclasses import asdict, dataclass, field
from socket import gethostname

from clcommon.cpapi import get_admin_email

from wmt.common.const import CONFIG_PATH, PING_CONNECTIONS
from wmt.common.exceptions import WmtConfigException
from wmt.common.url_parser import hostname_only


@dataclass
class Cfg:
    """
    Default values, in case config has not been specified yet
    """

    ping_interval: int = 30
    ping_timeout: int = 10
    ping_connections: int = PING_CONNECTIONS
    report_email: str = None
    report_top: int = 4
    ignore_list: list[str] = field(default_factory=list)
    summary_notification_enabled: bool = True
    alert_notifications_enabled: bool = False
    allow_private_targets: bool = False


# Bugbot bc684f4b: $ matches end-of-string OR just-before-trailing-newline
# in Python's re module, so 'a@b.com\n' would pass; \Z anchors to true
# end-of-string, defeating CRLF-injection bypass.
_EMAIL_RE = re.compile(r'\A[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+\Z')

logger = logging.getLogger(__name__)

_NUMERIC_FIELDS = ('ping_interval', 'ping_timeout', 'ping_connections', 'report_top')
_BOOLEAN_FIELDS = ('summary_notification_enabled', 'alert_notifications_enabled', 'allow_private_targets')


class ConfigManager:
    def __init__(self):
        self.allowed_params = Cfg.__dataclass_fields__.keys()
        self.from_email = f'web-monitoring-tool@{gethostname()}'
        self.default_report_email = get_admin_email()

        self.cfg = self._init_cfg()

        # self.ignored_domains is used as cache for checking domains if it is ignored or not
        self._ignored_domains = self.generate_ignored_domains()
        self.target_email = self._get_target_email()

    def _get_target_email(self):
        """
        This function checks to see which email address to use for TO: field of smtp.
        If report_email has been defined by user then report_email will be used.
        By default (in case not defined by user) default_report_email will be used
        """
        return self.cfg.report_email if self.cfg.report_email else self.default_report_email

    def to_dict(self):
        return asdict(self.cfg)

    def _init_cfg(self) -> Cfg:
        # if not present - use defaults
        if not self.is_present():
            return Cfg()

        data = self.read()

        cfg = Cfg()
        for key, value in data.items():
            if key not in self.allowed_params:
                logger.warning(
                    f'unsupported parameter "{key}", please ensure config contains'
                    f' only allowed parameters: {list(self.allowed_params)}'
                )
                continue
            # Apply the same type guards as modify() so a hand-edited
            # /etc/wmt/config.json cannot bypass validation. The
            # dangerous case is a string like "false" for
            # allow_private_targets: truthy in Python, would
            # silently disable the SSRF filter. On any rejection,
            # log and fall back to the dataclass default rather than
            # raising -- daemon startup must not be blocked by a
            # malformed value.
            try:
                self._validate_values({key: value})
            except WmtConfigException as e:
                logger.warning(f'ignoring invalid value for "{key}" in {CONFIG_PATH}: {e}; using default')
                continue
            setattr(cfg, key, value)

        return cfg

    @staticmethod
    def is_present():
        return os.path.isfile(CONFIG_PATH)

    @staticmethod
    def _validate_values(data):
        for key, value in data.items():
            if key in _NUMERIC_FIELDS:
                if not isinstance(value, int) or isinstance(value, bool):
                    raise WmtConfigException(f'"{key}" must be an integer')
            elif key in _BOOLEAN_FIELDS:
                # Reject non-bool inputs so a string like "false" cannot
                # truthy-disable allow_private_targets (SSRF filter opt-in).
                if not isinstance(value, bool):
                    raise WmtConfigException(f'"{key}" must be a boolean (true/false)')
            elif key == 'report_email':
                # Empty string and None are both documented sentinels
                # for "fall back to the system admin email" -- accept
                # either, validate format only for non-empty strings.
                if value in (None, ''):
                    continue
                if not isinstance(value, str):
                    raise WmtConfigException('"report_email" must be a string')
                if not _EMAIL_RE.match(value):
                    raise WmtConfigException('"report_email" is not a valid email address')

    def modify(self, new_json: str):
        """
        Changes configuration of wmt

        Returns:
            self.to_dict()

        Raises:
            WmtConfigException

        Example:
            wmt-api-solo --config-change {'key': 'val'}
        """
        try:
            new_config = json.loads(new_json)
        except json.JSONDecodeError as e:
            raise WmtConfigException(str(e))

        if not set(new_config.keys()).issubset(self.allowed_params):
            raise WmtConfigException(
                f'some of passed params are unsupported, only allowed parameters: {list(self.allowed_params)}'
            )

        self._validate_values(new_config)

        config = {**self.to_dict(), **new_config}

        if config.get('ignore_list') and isinstance(config.get('ignore_list'), str):
            config['ignore_list'] = config['ignore_list'].split(',')

        # Write config to /etc/wmt/config.json file
        with open(CONFIG_PATH, 'w') as f:
            json.dump(config, f, indent=4)
        self.cfg = Cfg(**config)
        return self.to_dict()

    @staticmethod
    def read():
        try:
            with open(CONFIG_PATH) as f:
                data = json.load(f)
        except json.JSONDecodeError as e:
            raise WmtConfigException(str(e))
        return data

    def reload(self):
        self.cfg = self._init_cfg()

    @staticmethod
    def _pattern_matches(pattern: str, value: str) -> bool:
        """
        Apply one ignore_list pattern to one value.

        If record contain *" then it will be processed as wildcard. Else as substring
        """
        if '*' in pattern:
            # set * on the first position if it not set to allow filter without scheme (e.g. domain.com)
            if not pattern.startswith('*'):
                pattern = f"*{pattern}"
            return fnmatch.fnmatch(value, pattern)
        return pattern in value

    @staticmethod
    def _is_hostname_pattern(pattern: str) -> bool:
        """
        True when the pattern carries a hostname and nothing else, i.e.
        normalising it is a no-op. 'example.com' and '*.example.com' qualify;
        'http://*', 'https://example.com/path' and 'example.com:8080' do not.

        A pattern hostname_only() cannot parse (e.g. the fnmatch character
        class '*.test[0-9].example.com') normalises to '' and so lands here as
        "not a hostname", which leaves it on verbatim-only matching -- exactly
        what it got before CLOS-6983.
        """
        return hostname_only(pattern) == pattern.lower()

    def is_domain_ignored(self, domain) -> bool:
        """
        Check if domain is in ignored list.

        Callers pass two shapes: the scanner passes 'http://host' (get_domains()
        runs every domain through url_parser.parse()), and report.py passes
        ScrapeResult.website, the post-redirect URL (scheme + trailing slash +
        path, sometimes a port).

        Matching is deliberately tried twice per pattern, and the verbatim
        attempt comes first. CLPRO-2619 supports ignoring *URLs*, not just
        domains, so a pattern carrying a scheme, path or port keeps being
        compared against the value exactly as passed -- reducing the value to a
        hostname first would break 'http://*', 'https://example.com' against
        '.../path', and 'https://*.example.com/*'.

        The second attempt exists for CLOS-6983: fnmatch() is end-anchored, so a
        hostname-only entry like '*.example.com' never matches the
        'https://sub.example.com/' shape report.py passes. Both sides are
        lowercased there, since hostname_only() lowercases the value and
        fnmatch() is case-sensitive on POSIX.

        The second attempt is purely additive -- it cannot stop anything the
        first already matched. Keep it that way.
        """
        host = hostname_only(domain)
        for pattern in self._ignored_domains:
            if self._pattern_matches(pattern, domain):
                return True
            if (
                host
                and host != domain
                and self._is_hostname_pattern(pattern)
                and self._pattern_matches(pattern.lower(), host)
            ):
                return True
        return False

    def generate_ignored_domains(self) -> set:
        """
        Generates ignored domains patterns from self.ignore_list and
        returns it for using as cache in self.ignored_domains set().
        """
        patterns = set()
        for pattern in self.cfg.ignore_list:
            patterns.add(pattern)
        return patterns