From 638af5879b32f383883f3b4425cf59a5341e58d3 Mon Sep 17 00:00:00 2001
From: "th.l" <thl-cmk@outlook.com>
Date: Wed, 2 Aug 2023 22:25:35 +0200
Subject: [PATCH] update project

---
 README.md                               |   2 +-
 agent_based/snmp_state.py               | 182 +++++++++++++++++++
 gui/wato/check_parameters/snmp_state.py | 229 ++++++++++++++++++++++++
 packages/snmp_state                     |  19 ++
 snmp_state-0.0.3-20230802.mkp           | Bin 0 -> 3823 bytes
 5 files changed, 431 insertions(+), 1 deletion(-)
 create mode 100644 agent_based/snmp_state.py
 create mode 100644 gui/wato/check_parameters/snmp_state.py
 create mode 100644 packages/snmp_state
 create mode 100644 snmp_state-0.0.3-20230802.mkp

diff --git a/README.md b/README.md
index 91cbd9e..5070457 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-[PACKAGE]: ../../raw/master/snmp_version-0.0.2-20230709.mkp "snmp_version-0.0.2-20230709.mkp"
+[PACKAGE]: ../../raw/master/snmp_state-0.0.3-20230802.mkp "snmp_state-0.0.3-20230802.mkp"
 # Title
 
 A short description about the plugin
diff --git a/agent_based/snmp_state.py b/agent_based/snmp_state.py
new file mode 100644
index 0000000..f60f7c9
--- /dev/null
+++ b/agent_based/snmp_state.py
@@ -0,0 +1,182 @@
+#!/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_state.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-02: renamed from snmp_version to snmp_state
+
+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_state(string_table: StringTable):
+    return string_table
+
+
+def discovery_snmp_state(section) -> DiscoveryResult:
+    yield Service()
+
+
+def check_snmp_state(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']
+
+        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_state',
+    parse_function=parse_snmp_state,
+    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_state',
+    service_name='SNMP State',
+    discovery_function=discovery_snmp_state,
+    check_function=check_snmp_state,
+    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_state',
+)
diff --git a/gui/wato/check_parameters/snmp_state.py b/gui/wato/check_parameters/snmp_state.py
new file mode 100644
index 0000000..689629d
--- /dev/null
+++ b/gui/wato/check_parameters/snmp_state.py
@@ -0,0 +1,229 @@
+#!/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_state.py
+#
+# WATO file for snmp_state check plugin
+#
+# 2023-07-09: initial release
+# 2023-08-02: renamed from snmp_version to snmp_state
+
+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_state():
+    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_state',
+        group=RulespecGroupCheckParametersNetworking,
+        match_type='dict',
+        parameter_valuespec=_parameter_valuespec_snmp_state,
+        title=lambda: _('SNMP State'),
+    ))
diff --git a/packages/snmp_state b/packages/snmp_state
new file mode 100644
index 0000000..a9b5506
--- /dev/null
+++ b/packages/snmp_state
@@ -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_state.py'],
+           'gui': ['wato/check_parameters/snmp_state.py']},
+ 'name': 'snmp_state',
+ 'title': 'SNMP State',
+ 'version': '0.0.3-20230802',
+ 'version.min_required': '2.1.0b1',
+ 'version.packaged': '2.2.0p7',
+ 'version.usable_until': None}
diff --git a/snmp_state-0.0.3-20230802.mkp b/snmp_state-0.0.3-20230802.mkp
new file mode 100644
index 0000000000000000000000000000000000000000..3e147c86d796807003bb5b561dfaca2030e43e67
GIT binary patch
literal 3823
zcmbW%=OYvjzyM&fvbT`ErRa>#NXTAiD?2;ktbR6U?`)lYR^~YziDVqvBT~f4IETX7
z<4%Y7+xvchzde7!!=HYa<V(+l+nrk+&s-o{QTp2r4fskRV$}Mf3Mjn%bQQaN;OL1y
z0HJ$l?01hPWO==%43$$2c5&D>MghsxTz8up1<++?sGbNjxq1wV1`TpgCa#()>Dj>2
zU&&iW(;L6Cau}aYU7ctAod4V4%uvrA>)VvGECv$&sr|bz`K}>@_l7{fWK%K#fK{34
z7h+!R9~OhGVbZ=&=vlTYP<4}G1GS*mUC!SefNl9;A)p(0-L+*g_7EwMHr@klfrx_E
zWmfh?kZnaGfU>+Mg|N<=nYu+aRt_d~z-u1XYHfp8aB{&vZFDXiCH&VkH518)P_PjX
zR%3neoa=l?owDmu!@XRS{G<f!C-SUJX^$GRKgO9i<HfwBzZ>d+9li;Jw0w0^ajfWS
zX5oW4@>Tnz{z@}CAD8b8LV@nzn#gHzV~%E&g}g4hY?R67R(sob@BjqOs}OBDy8!{P
zeMw|j=q+t9_6Z$0N~U$EUF2jo#c*UWG%`@g*H1b=o5F+1A!t8wZ;o^J71G`UHjVHx
zrCV|0ZtJJ<T-V4b-dHXSwyMn7`?{s~=?N{UE6ju74>E8#%+6taCjnX=A;>|)h)OTe
zJ^S630-a~meM<ZESMkobUXY5;VyQ)#_SstM{ea4Mv0g>jQcW?egV>n1@r&3c#&5JZ
zeIKW=x9{;&+EKmKk>QJ1PDhY?n0`V84?c_YmTDnP@&*Qbm!y2dFLJ{lw4=<Ix4F`t
z*@NYDV1{iYQcMbdWxcyhzb=WrsfK)G>XUPXoE@^KeoFf70pPF_^{OgPL+N`>VHjM-
zyQ-fwRT@?t&3>q=&!ILYem$+Qdbs<`{jzEX*9fQj<-HdkGZv{t0+$(`2qd|LjwTjH
zKc%pLNfv4tudp5(9uiWs(OU6x_N#g*9<Qc2y!V{==@qHs&*NJr)1!f8r`zV!`JiKn
zeD1|3o$3wKWyoWm(xES{l%5M3IFqqCD@^z)_ZJ5o#60xmw?2!m_zMK&`SpDkg!AvC
z<Yw`j%P2;UPu+V?S^9$<y><ASb8=h9ZiaJLWihp0!aJf?6!#JzL81G^Uffq0NR}Cn
zf>r{S`mRX6`}sMcz|KaU2&I@)9w>MKrO>gcwntw+IK=PFJD8lPw1UZScNr}C{!`V2
zQQ*FfdiC@vBa8hth<BuC3p?71?U_o<(f(1*#Mi!X>Ub;T>hcYsirR7<XJ_M?Gzic#
zKTSoX>Rx+y?w@#bhX0xSCT$l&O`{V>Q8b@e7D|T>GB=61MsX$#MEZa@%vxDCjj1TO
z%%9y@PdX%$iU65?11vikP;pw%qP9kip6Xt^%mRmVeUiax@ehgw+hv7J-kQU`Cp<9Y
zQ*E(2)JkW$-iukr|NUo%$bp5q776f{q3SpDeM%1bVLffE1Na3$FS;wH5`*&j2FBI4
zyGa*(?J#iD{AVb+b{}&uCB>ZH_qsXyDUUER8_d6yiJmIXijV;6?S!}QY3Jk2a*9l)
z3xmXr){+bh5f67+ezbo1Dq_;HS>x(3XguDc;tJw4w^AQi<vn@9u7(D;IM8&%vzw;>
znRLCvd?q8_p^Ptjm?1Mp$gH93uSq!6V?42u=@NAcqzuu~&6pyCtc_0*w|2xbGIFD_
z9lEMkQhFLGqme95gQs2}t4PKZ%x3F<qu{->mp8k27~jd=erfAXc(1FWmdvF6=gYJm
zs<CxGmRdYnT}?$k!CEl-Ble5%M5!QfKC~2W9o(VgEIO}Z3asmdT_fjSm|ETi8y!4-
zZ^g(*Op8VUS28xUsR4ASmjmZ$VqadPSe1tnZp@~W!sUfTQ+$N>V|KNyrHE(`&B-m@
zaHfgre+|CkUTo}Rp2`UY!nN&BBgsTFyyTKJeBnBZ4Yesqk(1OkG5zWJLK^`pIhKVK
zhQHUQ42^N+oVgiqah4CAZ4w!rHe4Q;JU2-ljeGx@Cq{5mO3SW3-(qX>Ac6QgKH825
zNMGo#f-&ikl0skGHwHKnmAoyf>(7jV<?g;XA9n8$<zWPa&j)9&m;zO4i^2ltH)Hmw
zk+Q2(S!$k-%Z?SVPW0_+grNc6njD-Ahf=L@V>7LMfqQgfLso--(k0?74Xs={M$Vbe
z7J+J-Z%2pX?XybD?SDv7R?06*%HdinN(>C|^R_RL&>$N+d@MTC5KnNX%@0<SJc0-U
z-}AM3?qx3h9KU>KfW;d3I1hE1Q_}go&k^*<b&e4n8Q3r`r2jZc3`-${K<hQD3jlgh
zYA#T0@{vW@0~lYXi6Lpmj{cUmA#P4|M9qB-T%=zIi0~>@{Z&_+-vmrK4$-d$W#od!
z-QsK?6#Mqn12xCqg6r#2mb5H~)uGo7LdQ}dILJ{HhVD3LbbGuS+F~K4%6H}*k_?M^
zpHz8~1m=4<fGgm|$hwL->lJ*}I^O>$fhwknv!}kNT@>t9&Ge6URWnN5_H<}_>YTm2
zZk|Rs4|-D?Ej>KQ_FN@1J&J}lQaEu(me+Dg$xeGtCeUU#KZ6k2N1cO{BUo}dFq?3d
z7i-o1F7TwGW)Gr`MS{Cd+43T_)rN_(?v9a-M>0=h$n7@2hOh@bul40@hEX(GVT1#6
ze7ICF@yKy0lOGRx4;JjI+T#;Hn>22;4ZSR*Br{{VGS*S56YCsb^1WJ)b~Xio00R{~
zI%LU>fVnOIa5X8MUxQFnZ^nNz@$3YgC(>3J^*qAb`@>V^D0<p=44##9v5(}h4j*=i
z22l*TW;d;OdVQ?z+2)@_cxqZZcE~{-ltb>(a+8*~NxnHR?h(`wrTj*hUlziB0iYce
z&D|R>qa_t=*n9gacPU6qY0e-m@Z;~z(ub<9fK_q~ulxZYeq24APV$Lj;I~I-u_dkg
z4a!M73Za>;dXbsRlKfJ4Ybe}j0(1G?wy%ayXH!WdhAP7-Apy|W8p@>i$LK`;caE{h
z$Nrj_uX}i<1#-7{4Kq9Wu~^msv1RGG(Zyf0{T3o~W+T#0?57qh%Wb2WBNwsabc*K^
zI>FMp4RyQqS^~7STrLD2Eo}o$^=P~rA@^@Z7wP@H3(*>G2bYFZz*_iGa4E_eyUNK#
z3um#F9Nobmf2T~tw*WayJ)qU?XMuS62xTOtm0N+4b&C2XeaE16rfYZK&J~-!3SZ4U
zqJ6F|o9yHnW{c^9dhIjtb!i~Xe3!Zx9!>3@o|TpQL7?D7ZKn77Q!3$lH0zXsX24Gx
zx5{s^by0OGvvsjK%D6Ork7*N8ofc6y6DM)}+Gq*x0uHv-<(7XvTcHt8NzW}G>Fr<E
z#b|xG1iuOTu~vV(4`Y1fa0_92Z3?{}IETffj^vwfJ9HNQuHUu&=Sk*pOV~l&Ms}T;
zzz0@rZ&F!KkVZs<*u`ut4#{Cr`aqHbR#g*ZhuSG;FSOwmO0r%I->uV5YIk96d6}ZQ
zo1e~4*vR8;9@dD5yHTh}6eJzy&E`R0TAllFxfP_zMCr85bL;pJ-3513Fwb*D46+yW
zT<9LNZijzRHgd$AdULa{Vr-~V{-G$U#r8$3rBEWd^l|~AP?~bgEgOaYOA+?|%KaxP
z&tfeNKDu#0Jc{1@5UO#ZwnWr+o+PCr>c36s7T_=e_J8#Xn~<0eE=z_Ed@A~DjLrTH
z^FXtwWvRreOublpr(%?mw|L`B`6s<X4e%6hXHBVR-?x1N_~Z>kFN!o9Z++7}aTE%C
zL?&qc$av0qy<T-490ILec71u=C=l;O{|J2=hvsrf^ikblcUS1bKjH$iaSl4y)DN5q
zKhAp!O)Li+Wy%pUr<Vmk)RjjbfQ|-2N@mgZ`A>Vt>Bf4-%qboU7u<1et28$%ruWM-
zdx_R*C4??Z=J@VVtuW#OfSN*_yt7-Kax3Vc$m&~!B38OLV^9oj13VJn-=|WjA--aK
z6Kz^URd#4{@|fW%xy>C8#aQuui!&_l^Y8hO1tAJqn2ksuFV(hP%*z)+dY96@g~~Co
zdy0JD*9<kUA7u6yXU(=bs_`rM>k<YJf2&Cs3)ZzzRFd`k{L=rTjxAe9+K3YxnN>F2
z+d+79^WecrKfzEoUwb+}ft|~Uq||Gtxn~CQl8M890&oeLT1F;wHZWC&xJuUnui>`*
zNcS0WxJN8CPQ5Sm7~x4@{;cs(^1o&ZNFEZ`BYVn{q#IB@{rBt-!ct;2^tFirfhQbo
zHyYw6Gm&oRnAY^WFW(xa4)Z)gqV9}AZm<g%&yE#LiJUb3QTxXJSIjynu0&Z77JqRz
z-WXS@dpvt4RfRN}*hxlnLet9lX60Z7eX9M?kO|gdu{X&%-BM`uuo>a8;QY=3;mVf9
zL@h&Li~BmS)HmS9_PTc){u5G4U7Rk{|8>=(%-rl8MYU3Xyk=rsAlj8EpZ!UXD~_UH
z%Dbpz*xa*>u9N`~>`yBMsws3UpLL0Cu2IDD_qb~Un|ufx7+eW=d2-;!qu~_M;+~!1
z3&KFNS%90y-kZFpBqtTA;3>+8P0jQ&*+O(}0={6RP<8~8BeZ?!@f|LC)O(&(Hcew-
zB@74d*B;YQG&1r<6*!$UIW>=7&g39pC>aaU1xwsE>iOckbQ#))2$Mqjn);E0YIj{@
zBLSXHXas+|%Mk;iWEi2?<a+jt_}~EIl@MkYn9m`)*V-}^?eI&c0IK$CAK!g+Un6<N
zO36?K$s1{yZX;{U+`oGD0NwOl^rpn^KkaJDk`LzNH&R5$A8oG7UEbEKxA&u!dQ2A2
zEMh{k13q{4@%e;w&X;Vh8rKdM&EZ?_y1hsHyAXdiDZ}JLl!682X<37*fz%qq!PJXw
zU*5k#2MvX`94hbuf~EL{Jk^d)){kFL^s`C5a4h_LX+ck`c;cMS(&`QWS?EjnP7^C^
zRQx{J?cdW9rAe-tsZ>A+Z!HsC;;L}Jo_t(|c0TlC_YF8pKb%GM*=_FU-9Lx^|M%Vh
V#`pihnj(?Py4H?_q=JOxe*mK9fiD06

literal 0
HcmV?d00001

-- 
GitLab