From 451f44a1b26891f1fa808a3d989beb5d1a1ee737 Mon Sep 17 00:00:00 2001 From: "th.l" <thl-cmk@outlook.com> Date: Fri, 4 Aug 2023 14:53:04 +0200 Subject: [PATCH] update project --- README.md | 2 +- agent_based/snmp_configuration.py | 279 ++++++++++++++++++ .../check_parameters/snmp_configuration.py | 236 +++++++++++++++ packages/snmp_configuration | 19 ++ snmp_configuration-0.0.5-20230804.mkp | Bin 0 -> 4619 bytes 5 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 agent_based/snmp_configuration.py create mode 100644 gui/wato/check_parameters/snmp_configuration.py create mode 100644 packages/snmp_configuration create mode 100644 snmp_configuration-0.0.5-20230804.mkp diff --git a/README.md b/README.md index 130d539..fd461b9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[PACKAGE]: ../../raw/master/snmp_state-0.0.3-20230802.mkp "snmp_state-0.0.3-20230802.mkp" +[PACKAGE]: ../../raw/master/snmp_configuration-0.0.5-20230804.mkp "snmp_configuration-0.0.5-20230804.mkp" # SNMP Configuration This plugin is intended to help you keep track of the SNMP configuration CheckMK is using to monitor your hosts.\ diff --git a/agent_based/snmp_configuration.py b/agent_based/snmp_configuration.py new file mode 100644 index 0000000..76c5543 --- /dev/null +++ b/agent_based/snmp_configuration.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2023-06-28 +# File : snmp_configuration.py + +# +# Monitor snmp version used by CMK for a host object +# +# 2023-06-28: initial release +# 2023-07-09: added WATO parameters +# 2023-07-11: added check key length, key complexity, use of default keys +# 2023-08-04: renamed from snmp_version to snmp_configuration +# streamlined function parameters +# added option to check for reversed keys in default keys list +# added report only option +# added top level _check_key function +# added check for duplicate char in key + +# ToDo +# maybe add number of repeated chars? + + +import re +from typing import List +from cmk.base.check_api import host_name +from cmk.base.config import get_config_cache +from cmk.base.plugins.agent_based.agent_based_api.v1.type_defs import ( + DiscoveryResult, + CheckResult, + StringTable, +) + +from cmk.base.plugins.agent_based.agent_based_api.v1 import ( + register, + Service, + SNMPTree, + Result, + State, + exists, +) + + +def _check_expected(value, message: str, state: int, report_only: bool, excepted: List): + if report_only: + state = 0 + + if value in excepted: + yield Result(state=State.OK, summary=f'{message}: {value}') + else: + yield Result(state=State(state), summary=f'{message} {value}') + + +def _result(message: str, state: int, report_only: bool): + if report_only: + yield Result(state=State.OK, summary=message) + else: + yield Result(state=State(state), notice=message) + + +def _check_key_length(key: str, message: str, state: int, report_only: bool, min_length: int): + if len(key) < min_length: + yield from _result( + state=state, report_only=report_only, message=f'{message} length below {min_length}' + ) + else: + yield Result(state=State.OK, notice=f'{message} length is not below {min_length}') + + +def _check_default_key( + key: str, message: str, state: int, report_only: bool, default_keys: List[str], reversed_key: bool +): + if key.lower() in default_keys: + yield from _result(state=state, report_only=report_only, message=f'{message} uses default value') + elif reversed_key and key.lower()[::-1] in default_keys: + yield from _result(state=state, report_only=report_only, message=f'{message} uses default value (reverse)') + else: + yield Result(state=State.OK, notice=f'{message} is not using default keys') + + +def _check_key_complexity(key: str, message: str, state: int, report_only: bool, regex: List[str]): + _regex = ''.join(regex) + + if not re.search(_regex, key): + yield from _result(state=state, report_only=report_only, message=f'{message} complexity is not met') + else: + yield Result(state=State.OK, notice=f'{message} complexity is met.') + + +def _check_repeated_chars(key: str, message: str, state: int, report_only: bool): + _regex = '(.)\\1{1}' # {x} number of repetitions + if re.search(_regex, key): + # re.search(_regex, key).group() gives the repeated chars + yield from _result( + state=state, report_only=report_only, + message=f'{message} has repeated chars' + ) + else: + yield Result(state=State.OK, notice=f'{message} has no repeated chars') + + +def _check_key( + key: str, + message: str, + report_only: bool, + key_length_state: int, + key_length_min: int, + default_key_state: int, + default_key_list: List[str], + default_key_reversed: bool, + complexity_state: int, + complexity_regex: List[str], + complexity_repeated: bool, +): + if key_length_min > 0: + yield from _check_key_length(key, message, key_length_state, report_only, key_length_min) + if default_key_list: + yield from _check_default_key( + key, message, default_key_state, report_only, default_key_list, default_key_reversed + ) + if complexity_regex: + yield from _check_key_complexity(key, message, complexity_state, report_only, complexity_regex) + if complexity_repeated: + yield from _check_repeated_chars(key, message, complexity_state, report_only) + + +def parse_snmp_configuration(string_table: StringTable): + return string_table + + +def discovery_snmp_configuration(section) -> DiscoveryResult: + yield Service() + + +def check_snmp_configuration(params, section) -> CheckResult: + config_cache = get_config_cache() + snmp_host = False + report_only = params['report_only'] + + try: + # test for CMK 2.2.x + snmp_host = config_cache.is_snmp_host(host_name()) + snmp_credentials = config_cache._snmp_credentials(host_name()) + snmp_backend = config_cache.get_snmp_backend(host_name()).value + snmp_version1 = config_cache._is_host_snmp_v1(host_name()) + except AttributeError: + # try cmk 2.0.x - 2.1.x + host_config = config_cache.get_host_config(host_name()) + snmp_config = host_config.snmp_config(None) + snmp_credentials = snmp_config.credentials + snmp_backend = snmp_config.snmp_backend.value + snmp_version1 = host_config._is_host_snmp_v1() + if snmp_credentials: + snmp_host = True + + if snmp_host: + snmp_version = '1' if snmp_version1 else '2c' if type(snmp_credentials) is str else '3' + + excepted, state = params['snmp_version'] + yield from _check_expected(snmp_version, 'Version', state, report_only, excepted) + + excepted, state = params['snmp_backend'] + yield from _check_expected(snmp_backend.lower(), 'Backend', state, report_only, excepted) + + if snmp_version == '3': + excepted, state = params['v3_level'] + yield from _check_expected(snmp_credentials[0].lower(), 'Level', state, report_only, excepted) + + if len(snmp_credentials) > 2: + excepted, state = params['v3_authentication'] + excepted = list(map(str.upper, excepted)) + yield from _check_expected(snmp_credentials[1].upper(), 'Authentication', state, report_only, excepted) + + try: + snmp_encryption = snmp_credentials[4] + except IndexError: + pass + else: + excepted, state = params['v3_encryption'] + excepted = list(map(str.upper, excepted)) + yield from _check_expected(snmp_encryption.upper(), 'Encryption', state, report_only, excepted) + + min_key_length, state_min_key = params['min_key_length'] + default_keys, reversed_keys, state_default_key = params['default_keys'] + default_keys = list(map(str.lower, default_keys)) + key_complexity, repeated_chars, state_key_complexity = params['key_complexity'] + + if snmp_version != '3': + message = 'Community string' + yield from _check_key( + key=snmp_credentials, + message=message, + report_only=report_only, + key_length_state=state_min_key, + key_length_min=min_key_length, + default_key_state=state_default_key, + default_key_list=default_keys, + default_key_reversed=reversed_keys, + complexity_state=state_key_complexity, + complexity_regex=key_complexity, + complexity_repeated=repeated_chars, + ) + else: + if len(snmp_credentials) > 2: + message = 'Authentication key' + yield from _check_key( + key=snmp_credentials[3], + message=message, + report_only=report_only, + key_length_state=state_min_key, + key_length_min=min_key_length, + default_key_state=state_default_key, + default_key_list=default_keys, + default_key_reversed=reversed_keys, + complexity_state=state_key_complexity, + complexity_regex=key_complexity, + complexity_repeated=repeated_chars, + ) + if len(snmp_credentials) == 6: + message = 'Encryption key' + yield from _check_key( + key=snmp_credentials[5], + message=message, + report_only=report_only, + key_length_state=state_min_key, + key_length_min=min_key_length, + default_key_state=state_default_key, + default_key_list=default_keys, + default_key_reversed=reversed_keys, + complexity_state=state_key_complexity, + complexity_regex=key_complexity, + complexity_repeated=repeated_chars, + ) + if snmp_credentials[3] != snmp_credentials[5]: + yield Result(state=State.OK, notice=F'Authentication and Encryption key are different') + else: + yield Result( + state=State(params['auth_enc_key_identical']), + notice=F'Authentication and Encryption key are identical' + ) + + else: + yield Result(state=State.OK, summary='No SNMP host') + + +register.snmp_section( + name='snmp_configuration', + parse_function=parse_snmp_configuration, + fetch=SNMPTree( + base='.1.3.6.1.2.1.1', # + oids=[ + '1', # sysDescr + ]), + detect=exists('.1.3.6.1.2.1.1.1.0', ), # sysDescr +) + +register.check_plugin( + name='snmp_configuration', + service_name='SNMP Configuration', + discovery_function=discovery_snmp_configuration, + check_function=check_snmp_configuration, + check_default_parameters={ + 'report_only': False, + 'snmp_version': (['3', 1]), + 'v3_level': (['authpriv'], 1), + 'v3_authentication': (['sha', 'sha-224', 'sha-256', 'sha-384', 'sha-512'], 1), + 'v3_encryption': (['aes'], 1), + 'snmp_backend': (['inline', 'classic'], 1), + 'default_keys': (['public', 'private'], True, 1), + 'min_key_length': (10, 1), + 'auth_enc_key_identical': 1, + 'key_complexity': (['(?=.*\\d)', '(?=.*[a-z])', '(?=.*[A-Z])', '(?=.*\\W)', '(?=.{10,})'], True, 1), + }, + check_ruleset_name='snmp_configuration', +) diff --git a/gui/wato/check_parameters/snmp_configuration.py b/gui/wato/check_parameters/snmp_configuration.py new file mode 100644 index 0000000..b218f1e --- /dev/null +++ b/gui/wato/check_parameters/snmp_configuration.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 + +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2023-07-09 +# File : snmp_configuration.py +# +# WATO file for snmp_configuration check plugin +# +# 2023-07-09: initial release +# 2023-08-04: renamed from snmp_version to snmp_configuration +# added options for report only, reversed keys, no repeated chars +# optimized layout +# moved rule set from Networking to Applications, Processes & Services + + +from cmk.gui.i18n import _ +from cmk.gui.valuespec import ( + Dictionary, + MonitoringState, + Tuple, + ListChoice, + Integer, + ListOfStrings, + FixedValue, + Checkbox, +) + +from cmk.gui.plugins.wato.utils import ( + CheckParameterRulespecWithoutItem, + rulespec_registry, + RulespecGroupCheckParametersApplications, +) + + +def _parameter_valuespec_snmp_configuration(): + return Dictionary( + elements=[ + ('report_only', + FixedValue( + value=True, + title=_('Report only'), + totext=_(''), + help=_('If enabled the check will not create any alerts (WAR/CRIT)'), + )), + ('snmp_version', + Tuple( + title=_('SNMP Version'), + elements=[ + ListChoice( + choices=[ + ('1', 'Version 1'), + ('2c', 'Version 2c'), + ('3', 'Version 3') + ], + allow_empty=False, + help=_('Select excepted SNMP versions. Default is "Version 3".'), + default_value=['3'], + ), + MonitoringState( + label=_('Monitoring state if version not in list'), + default_value=1, + ) + ])), + ('v3_level', + Tuple( + title=_('SNMP v3 level'), + elements=[ + ListChoice( + choices=[ + ('authpriv', 'authentication and privacy'), + ('authnopriv', 'authentication and no privacy'), + ('noauthnopriv', 'no authentication and no privacy'), + ], + allow_empty=False, + help=_('Select excepted SNMP v3 protocol level. Default is "authentication and privacy".'), + default_value=['authpriv'], + ), + MonitoringState( + label=_('Monitoring state if level is not list'), + default_value=1, + ) + ])), + ('v3_authentication', + Tuple( + title=_('SNMP v3 authentication'), + elements=[ + ListChoice( + choices=[ + ('md5', 'MD5'), + ('sha', 'SHA'), + ('sha-224', 'SHA-224'), + ('sha-256', 'SHA-256'), + ('sha-384', 'SHA-384'), + ('sha-512', 'SHA-512'), + ], + allow_empty=False, + help=_('Select excepted SNMP v3 authentication. Default is "SHA*".'), + default_value=['sha', 'sha-224', 'sha-256', 'sha-384', 'sha-512'], + ), + MonitoringState( + label=_('Monitoring state if authentication is not in list'), + default_value=1, + ) + ])), + ('v3_encryption', + Tuple( + title=_('SNMP v3 encryption'), + elements=[ + ListChoice( + choices=[ + ('des', 'DES'), + ('aes', 'AES-128'), + ], + allow_empty=False, + help=_('Select excepted encryption. Default is "AES-128".'), + default_value=['aes'], + ), + MonitoringState( + label=_('Monitoring state if encryption is not in list'), + default_value=1, + ) + ])), + ('snmp_backend', + Tuple( + title=_('SNMP backend'), + elements=[ + ListChoice( + choices=[ + ('inline', 'Inline'), + ('classic', 'Classic'), + ('storedwalk', 'Stored walk') + ], + allow_empty=False, + help=_('Select excepted SNMP backend. Default is "Inline/Classic".'), + default_value=['inline', 'classic'], + ), + MonitoringState( + label=_('Monitoring state if backend not in list'), + default_value=1, + ) + ])), + ('min_key_length', + Tuple( + title=_('Minimal key length'), + elements=[ + Integer( + minvalue=0, + help=_( + 'Minimal expected Community string/key length. ' + 'Use "0" to disable this check. Default is "10".' + ), + default_value=10, + ), + MonitoringState( + label=_('Monitoring state if key length below minimal key length'), + help=_( + 'Set the monitoring state when the length of the Community string (SNMP v1/2c) ' + 'or the authentication/encryption key (SNMP v3) is below min. length. Default is WARN' + ), + default_value=1, + ) + ])), + ('auth_enc_key_identical', + MonitoringState( + title=_('Monitoring state when authentication and encryption keys are identical'), + help=_( + 'Set the monitoring state when authentication key and encryption key are identical. ' + 'This setting is for SNMP v3 only. Default is WARN.' + ), + default_value=1, + )), + ('default_keys', + Tuple( + title=_('Check default key values'), + elements=[ + ListOfStrings( + help=_( + 'List of default Community strings/keys to check against. Default is "public, private".' + ), + default_value=['public', 'private'], + ), + Checkbox( + label=_('Check reversed keys'), + help=_('This will also check for keys in reverse. So "cilbup" will match "public".'), + default_value=True, + ), + MonitoringState( + label=_('Monitoring state if key used is a default key'), + help=_( + 'Set the monitoring state when the key used is in the list of default keys. ' + 'Default is WARN' + ), + default_value=1, + ) + ])), + ('key_complexity', + Tuple( + title=_('Key complexity'), + elements=[ + ListChoice( + choices=[ + ('(?=.*\\d)', 'Digit'), + ('(?=.*[a-z])', 'Lowercase'), + ('(?=.*[A-Z])', 'Uppercase'), + ('(?=.*\\W)', 'Special') + ], + help=_( + 'Select expected character types in the Community string or authentication/encryption key.' + ' Default is "Number, Lower case, Upper case, Special".'), + default_value=['(?=.*\\d)', '(?=.*[a-z])', '(?=.*[A-Z])', '(?=.*\\W)'], + ), + Checkbox( + label=_('no repeated chars'), + help=_('This will also check if the key contains repeated characters..'), + default_value=True, + ), + MonitoringState( + label=_('Monitoring state if key complexity not met'), + default_value=1, + ) + ])), + ]) + + +rulespec_registry.register( + CheckParameterRulespecWithoutItem( + check_group_name='snmp_configuration', + group=RulespecGroupCheckParametersApplications, + match_type='dict', + parameter_valuespec=_parameter_valuespec_snmp_configuration, + title=lambda: _('Checkmk SNMP Configuration'), + )) diff --git a/packages/snmp_configuration b/packages/snmp_configuration new file mode 100644 index 0000000..fd08db5 --- /dev/null +++ b/packages/snmp_configuration @@ -0,0 +1,19 @@ +{'author': 'Th.L. (thl-cmk[at]outlook[dot]com)', + 'description': 'Monitors SNMP configuration used by CheckMK to access ' + 'monitored hosts.\n' + '\n' + 'This check is intended to help keep track on the SNMP ' + 'configuration used by CheckMK. \n' + '\n' + 'It will create an alert if old SNMP versions (or bad ' + 'parameters) are in use.\n' + 'All this can be configured via WATO.\n', + 'download_url': 'https://thl-cmk.hopto.org', + 'files': {'agent_based': ['snmp_configuration.py'], + 'gui': ['wato/check_parameters/snmp_configuration.py']}, + 'name': 'snmp_configuration', + 'title': 'SNMP Configuration', + 'version': '0.0.5-20230804', + 'version.min_required': '2.0.0b1', + 'version.packaged': '2.2.0p7', + 'version.usable_until': None} diff --git a/snmp_configuration-0.0.5-20230804.mkp b/snmp_configuration-0.0.5-20230804.mkp new file mode 100644 index 0000000000000000000000000000000000000000..edd4609bcac5b61a53eb101afe0fd32600569da8 GIT binary patch literal 4619 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 CtoJ_v literal 0 HcmV?d00001 -- GitLab