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