From 8b6ebbe5ae0d8d55d20b937f632e66efccf3ab29 Mon Sep 17 00:00:00 2001
From: "th.l" <thl-cmk@outlook.com>
Date: Tue, 11 Jul 2023 22:09:24 +0200
Subject: [PATCH] update project

---
 README.md                                 |   2 +-
 agent_based/snmp_version.py               | 184 +++++++++++++++++
 gui/wato/check_parameters/snmp_version.py | 228 ++++++++++++++++++++++
 packages/snmp_version                     |  14 ++
 snmp_version-0.0.2-20230709.mkp           | Bin 0 -> 3743 bytes
 5 files changed, 427 insertions(+), 1 deletion(-)
 create mode 100644 agent_based/snmp_version.py
 create mode 100644 gui/wato/check_parameters/snmp_version.py
 create mode 100644 packages/snmp_version
 create mode 100644 snmp_version-0.0.2-20230709.mkp

diff --git a/README.md b/README.md
index 6d6b581..91cbd9e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-[PACKAGE]: ../../raw/master/packagee-0.1.2-20230706.mkp "package-0.1.2-20230706.mkp"
+[PACKAGE]: ../../raw/master/snmp_version-0.0.2-20230709.mkp "snmp_version-0.0.2-20230709.mkp"
 # Title
 
 A short description about the plugin
diff --git a/agent_based/snmp_version.py b/agent_based/snmp_version.py
new file mode 100644
index 0000000..2925178
--- /dev/null
+++ b/agent_based/snmp_version.py
@@ -0,0 +1,184 @@
+#!/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_version.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
+
+# ToDo: check for repeated chars in key
+
+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_key_length(key: str, name: str, min_length: int, state: int):
+    if len(key) < min_length:
+        yield Result(state=State(state), notice=f'{name} length: {len(key)} - below {min_length}')
+    else:
+        yield Result(state=State.OK, notice=f'{name} length: {len(key)}')
+
+
+def _check_default_key(key: str, name: str, default_keys: List[str], state: int):
+    if key.lower() in default_keys:
+        yield Result(state=State(state), notice=f'{name} uses default value')
+
+
+def _check_expected(value, message: str, excepted: List, state: int):
+    if value in excepted:
+        yield Result(state=State.OK, summary=f'{message}: {value}')
+    else:
+        yield Result(state=State(state), summary=f'{message} {value}')
+
+
+def _check_key_complexity(value, message: str, regex: List[str], state: int):
+    _regex = ''.join(regex)
+    if re.search(_regex, value) is not None:
+        yield Result(state=State.OK, notice=f'{message} complexity is met.')
+    else:
+        yield Result(state=State(state), notice=f'{message} complexity is not met')
+
+
+def parse_snmp_version(string_table: StringTable):
+    return string_table
+
+
+def discovery_snmp_version(section) -> DiscoveryResult:
+    yield Service()
+
+
+def check_snmp_version(params, section) -> CheckResult:
+    config_cache = get_config_cache()
+    snmp_host = False
+
+    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', excepted, state)
+
+        excepted, state = params['snmp_backend']
+        yield from _check_expected(snmp_backend.lower(), 'Backend', excepted, state)
+
+        if snmp_version == '3':
+            excepted, state = params['v3_level']
+            yield from _check_expected(snmp_credentials[0].lower(), 'Level', excepted, state)
+
+            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', excepted, state)
+
+            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', excepted, state)
+
+        min_key_length, state_min_key = params['min_key_length']
+        default_keys, state_default_key = params['default_keys']
+        default_keys = list(map(str.lower, default_keys))
+        key_complexity, state_key_complexity = params['key_complexity']
+        print(key_complexity)
+        print(snmp_credentials)
+        if snmp_version != '3':
+            message = 'Community string'
+            yield from _check_key_length(snmp_credentials, message, min_key_length, state_min_key)
+            yield from _check_default_key(snmp_credentials, message, default_keys, state_default_key)
+            yield from _check_key_complexity(snmp_credentials,  message, key_complexity, state_key_complexity)
+        else:
+            if len(snmp_credentials) > 2:
+                message = 'Authentication key'
+                yield from _check_key_length(snmp_credentials[3], message, min_key_length, state_min_key)
+                yield from _check_default_key(snmp_credentials[3], message, default_keys, state_default_key)
+                yield from _check_key_complexity(snmp_credentials[3], message, key_complexity, state_key_complexity)
+            if len(snmp_credentials) == 6:
+                message = 'Encryption key'
+                yield from _check_key_length(snmp_credentials[5], message, min_key_length, state_min_key)
+                yield from _check_default_key(snmp_credentials[5], message, default_keys, state_default_key)
+                yield from _check_key_complexity(snmp_credentials[5], message, key_complexity, state_key_complexity)
+                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_version',
+    parse_function=parse_snmp_version,
+    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_version',
+    service_name='SNMP Version',
+    discovery_function=discovery_snmp_version,
+    check_function=check_snmp_version,
+    check_default_parameters={
+        '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'], 1),
+        'min_key_length': (10, 1),
+        'auth_enc_key_identical': 1,
+        'key_complexity': (['(?=.*\\d)', '(?=.*[a-z])', '(?=.*[A-Z])', '(?=.*\\W)', '(?=.{10,})'], 1),
+    },
+    check_ruleset_name='snmp_version',
+)
diff --git a/gui/wato/check_parameters/snmp_version.py b/gui/wato/check_parameters/snmp_version.py
new file mode 100644
index 0000000..3e79a5c
--- /dev/null
+++ b/gui/wato/check_parameters/snmp_version.py
@@ -0,0 +1,228 @@
+#!/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_version.py
+#
+# WATO file for snmp_version check plugin
+#
+# 2023-07-09: initial release
+
+from cmk.gui.i18n import _
+from cmk.gui.valuespec import (
+    Dictionary,
+    MonitoringState,
+    Tuple,
+    ListChoice,
+    Integer,
+    ListOfStrings
+)
+
+from cmk.gui.plugins.wato.utils import (
+    CheckParameterRulespecWithoutItem,
+    rulespec_registry,
+    RulespecGroupCheckParametersNetworking,
+)
+
+
+def _parameter_valuespec_snmp_version():
+    return Dictionary(
+        elements=[
+            ('snmp_version',
+             Tuple(
+                 orientation='horizontal',
+                 title=_('SNMP Version'),
+                 elements=[
+                     ListChoice(
+                         title=_('excepted SNMP Versions'),
+                         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(
+                         title=_('Monitoring state if version not in excepted versions'),
+                         default_value=1,
+                     )
+                 ])),
+            ('v3_level',
+             Tuple(
+                 orientation='horizontal',
+                 title=_('SNMP v3 level'),
+                 elements=[
+                     ListChoice(
+                         title=_('excepted level'),
+                         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(
+                         title=_('Monitoring state if level is not in excepted list'),
+                         default_value=1,
+                     )
+                 ])),
+            ('v3_authentication',
+             Tuple(
+                 orientation='horizontal',
+                 title=_('SNMP v3 authenticatio'),
+                 elements=[
+                     ListChoice(
+                         title=_('excepted authentication'),
+                         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(
+                         title=_('Monitoring state if authentication is not in excepted list'),
+                         default_value=1,
+                     )
+                 ])),
+            ('v3_encryption',
+             Tuple(
+                 orientation='horizontal',
+                 title=_('SNMP v3 encryption'),
+                 elements=[
+                     ListChoice(
+                         title=_('excepted encryption'),
+                         choices=[
+                             ('des', 'DES'),
+                             ('aes', 'AES-128'),
+                         ],
+                         allow_empty=False,
+                         help=_('Select excepted encryption. Default is "AES-128".'),
+                         default_value=['aes'],
+                     ),
+                     MonitoringState(
+                         title=_('Monitoring state if SNMP v3 encryption is not in excepted list'),
+                         default_value=1,
+                     )
+                 ])),
+            ('snmp_backend',
+             Tuple(
+                 orientation='horizontal',
+                 title=_('SNMP backend'),
+                 elements=[
+                     ListChoice(
+                         title=_('excepted SNMP backend'),
+                         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(
+                         title=_('Monitoring state if backend not in excepted backends'),
+                         default_value=1,
+                     )
+                 ])),
+            ('min_key_length',
+             Tuple(
+                 orientation='horizontal',
+                 title=_('Check key length'),
+                 elements=[
+                     Integer(
+                         title=_('minimal key length'),
+                         minvalue=1,
+
+                         help=_('Minimal expected Community string/key length. Default is "10".'),
+                         default_value=10,
+                     ),
+                     MonitoringState(
+                         title=_('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 key 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(
+                 orientation='horizontal',
+                 title=_('Check default key values'),
+                 elements=[
+                     ListOfStrings(
+                         title=_('List of default keys'),
+                         allow_empty=False,
+                         help=_(
+                             'List of default Community strings/keys to check against. Default is "public, private".'
+                         ),
+                         default_value=['public', 'private'],
+                     ),
+                     MonitoringState(
+                         title=_('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,
+                     )
+                 ])),
+            ('key_complexity',
+             Tuple(
+                 orientation='horizontal',
+                 title=_('Key complexity'),
+                 elements=[
+                     ListChoice(
+                         title=_('excepted characters in the key'),
+                         choices=[
+                             ('(?=.*\\d)', 'Number'),
+                             ('(?=.*[a-z])', 'Lowercase'),
+                             ('(?=.*[A-Z])', 'Uppercase'),
+                             ('(?=.*\\W)', 'Special')
+                         ],
+                         allow_empty=False,
+                         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)'],
+                     ),
+                     MonitoringState(
+                         title=_('Monitoring state if key complexity not met'),
+                         default_value=1,
+                     )
+                 ])),
+        ])
+
+
+rulespec_registry.register(
+    CheckParameterRulespecWithoutItem(
+        check_group_name='snmp_version',
+        group=RulespecGroupCheckParametersNetworking,
+        match_type='dict',
+        parameter_valuespec=_parameter_valuespec_snmp_version,
+        title=lambda: _('SNMP Version'),
+    ))
diff --git a/packages/snmp_version b/packages/snmp_version
new file mode 100644
index 0000000..6159cf2
--- /dev/null
+++ b/packages/snmp_version
@@ -0,0 +1,14 @@
+{'author': 'Th.L. (thl-cmk[at]outlook[dot]com)',
+ 'description': 'Monitors SNMP version used by CheckMK to access monitored '
+                'hosts.\n'
+                'This check is intended to create an alert if old SNMP '
+                'versions (or bad parameters) are in use.\n',
+ 'download_url': 'https://thl-cmk.hopto.org',
+ 'files': {'agent_based': ['snmp_version.py'],
+           'gui': ['wato/check_parameters/snmp_version.py']},
+ 'name': 'snmp_version',
+ 'title': 'SNMP Version',
+ 'version': '0.0.2-20230709',
+ 'version.min_required': '2.1.0b1',
+ 'version.packaged': '2.2.0p5',
+ 'version.usable_until': None}
diff --git a/snmp_version-0.0.2-20230709.mkp b/snmp_version-0.0.2-20230709.mkp
new file mode 100644
index 0000000000000000000000000000000000000000..820417d1213eeeb551841c9d55d77a1f9f305ceb
GIT binary patch
literal 3743
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

literal 0
HcmV?d00001

-- 
GitLab