You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

537 lines
26 KiB

# coding=utf-8
#
# Copyright (C) 2009-2021 Splunk Inc. All Rights Reserved.
from __future__ import absolute_import, division, print_function, unicode_literals
from collections import namedtuple, OrderedDict
from base64 import b64decode
import os
import ssl
import sys
from functools import reduce as reduce
from ldap3 import Server, Tls, core, ALL
from splunklib.searchcommands.validators import Boolean, Integer, List, Map
from splunklib.binding import HTTPError
from splunklib import data
import app
from .six import itervalues
try:
from ssl import create_default_context # New in version 2.7.9
use_ssl_context = True
except ImportError:
use_ssl_context = False
class Configuration(object):
Credentials = namedtuple('Credentials', ['realm', 'username', 'password', 'authorization_id'])
_tls = None
def __init__(self, command, is_expanded=False):
if command.debug: # debug option overrides logging_level option and logging.conf level setting
command.logging_level = 'DEBUG'
self._buffered_configurations = None
self.command = command
self.domain = None
self.settings = None
# All settings are ultimately reduced to these in Configuration._reset_fields
self.alternatedomain = None
self.basedn = None
self.server = None
self.credentials = None
self.decode = None
self.paged_size = None
command.logger.debug('Command = %s', command)
if is_expanded:
self._read_all_configurations()
else:
self._read_configuration()
return
def __str__(self):
username = self.credentials.username
text = '{0}(server={1}, credentials={2}, alternatedomain={3}, basedn={4}, decode={5}, paged_size={6})'.format(
self.command.name, self.server, username, self.alternatedomain, self.basedn, self.decode, self.paged_size)
return text
def open_connection_pool(self, attributes):
return app.ConnectionPool(self, attributes)
def select(self, domain):
settings = self._buffered_configurations[domain]
self._reset_fields(settings[0][0], settings[1])
# region Privates
def _add_buffered_configuration(self, domain, settings):
alternatedomain = settings.get('alternatedomain')
self._ensure_unique_configuration_names(domain, alternatedomain)
self._buffered_configurations[domain] = ((domain, alternatedomain), settings)
if alternatedomain:
self._buffered_configurations[alternatedomain] = ((domain, alternatedomain), settings)
return
def _ensure_unique_configuration_names(self, domain, alternatedomain):
existing_configuration = self._buffered_configurations.get(domain)
if existing_configuration:
existing_domain, existing_alternatedomain = existing_configuration[0]
assert domain != existing_domain # Configuration system guarantees stanza names are unique within a file
assert domain == existing_alternatedomain
message = 'Domain {0} clashes with alternatedomain = {1} in [{2}].'.format(
domain, existing_alternatedomain, existing_domain)
self.command.error_exit(ValueError(message), message)
if alternatedomain:
existing_configuration = self._buffered_configurations.get(alternatedomain)
if existing_configuration:
existing_domain, existing_alternatedomain = existing_configuration[0]
if alternatedomain == domain or alternatedomain == existing_domain:
message = 'Alternate domain {0} in [{1}] clashes with domain = [{2}].'.format(
alternatedomain, domain, existing_domain)
self.command.error_exit(ValueError(message), message)
if alternatedomain == existing_alternatedomain:
message = 'Alternate domain {0} in [{1}] clashes with alternatedomain = {2} in [{3}]'.format(
alternatedomain, domain, existing_alternatedomain, existing_domain)
self.command.error_exit(ValueError(message), message)
return
def _get_tls(self):
"""
Gets the TLS configuration of the application.
The default TLS configuration is obtained from the sslConfig stanza in server.conf. The application may
selectively override the defaults by specifying sslConfig settings in the custom sslConfig stanza in
local/ssl.conf. Settings are retrieved from configuration just once, the first time this method is called.
Differences between SA-ldapsearch and Splunk's treatment of `sslConfig` settings
--------------------------------------------------------------------------------
SA-ldapsearch is a pure Python application that executes in the runtime that ships with Splunk 6.0+:
+------------------+----------------+
| Splunk version | Python version |
+==================+================+
| Splunk 6.0 | Python 2.7.5 |
+------------------+----------------+
| Splunk 6.1 | Python 2.7.5 |
+------------------+----------------+
| Splunk 6.2 | Python 2.7.8 |
+------------------+----------------+
| Splunk 6.3 | Python 2.7.9 |
+------------------+----------------+
SA-ldapsearch depends on the Python :module:`ssl` module for SSL support and some features that Splunk lets
you configure are missing from this module in versions of Python prior to 2.7.9
1. Specific support for TLS 1.1 and TLS 1.2
Prior to Python 2.7.9:
You can specify an `sslVersions` value of `tls` to negotiate for TLS 1.0, TLS 1.1, or TLS 1.2. You can
specifically request TLS 1.0 by specifying an `sslVersions value of `tls1.0`. However, you cannot specify
any combination of `sslVersions` that includes `tls1.1` or `tls1.2`.
under Python 2.7.9:
You can specifically request TLS 1.0, 1.1, or 1.2 by specifying an sslVersions value of 'tls1.0', 'tls1.1',
or 'tls1.2'
2. Password protected private key files used in mutual authentication
If you want to configure SA-ldapsearch for mutual authentication, you cannot password protect the file
identified by `sslKeysfile` and the value of `sslKeysfilePassword` must be left blank.
Splunk `sslConfig` to ldap3.Tls settings map
--------------------------------------------
Here is the mapping between sslConfig settings and the arguments to :meth:`Tls.__init__`.
+-------+--------------------------------+-------------------------+-------------------------------------------+
| Supp. | Tls.__init__ argument | sslConfig setting | Description |
+=======+================================+=========================+===========================================+
| no | ca_certs_data[1]_ | #N/A | String containing the PEM- or DER- |
| | | | formatted certificates of the |
| | | | certification authorities. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| yes | ca_certs_file | os.path.join( | File containing the certificates of the |
| | | caPath, caCertFile) | certification authorities. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| yes | #N/A | caPath | Path to directory containing caCertFile |
| | | | and sslKeysfile. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| no | ca_certs_path[1]_ | #N/A | Path to directory containing the |
| | | | certification certificates authorities. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| yes | local_certificate_file | os.path.join( | File with the certificate of the client |
| | | caPath, sslKeysfile) | |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| yes | local_private_key_file | os.path.join( | File with the private key of the client |
| | | caPath, sslKeysfile) | |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| no | local_private_key_password[1]_ | sslKeysfilePassword[2]_ | Password required to access |
| | | | local_certificate_file. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| yes | validate | sslVerifyServerCert | Specifies if the server certificate must |
| | | | be validated. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| yes | valid_names | sslCommonNameToCheck, | List of valid host names. The matching |
| | | sslCommonNameList, | algorithm used is as outlined in |
| | | sslAltNameToCheck | `RFC-2818 <http://goo.gl/9nVMfp>`_ and |
| | | | `RFC-6125 <http://goo.gl/QfWKVn>`_ except |
| | | | that IP addresses are not currently |
| | | | supported. See `ssl.match_hostname |
| | | | <http://goo.gl/IWgnEK>`_ for additional |
| | | | information. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
| yes | version | sslVersions | SSL or TLS version to use. |
+-------+--------------------------------+-------------------------+-------------------------------------------+
.. [1] Requires `ssl.SSLContext <http://goo.gl/c0aI0s>`_ which is new in Python 2.7.9/3.2.
.. [2] Password protected SSL Keys Files are unsupported because we do not have access to `ssl.SSLContext
<http://goo.gl/c0aI0s>`_.
:return: TLS configuration of the app.
:rtype: Tls
References
----------
1. `Securing Splunk Enterprise: About securing Splunk with SSL <http://goo.gl/7IUFs9>`_
2. `Splunk Admin Manual: server.conf <http://goo.gl/HpkMYA>`_
3. `Microsoft Active Directory: Using SSL/TLS <http://goo.gl/3wE2Fa>`_
4. `Microsoft TechNet: LDAP over SSL (LDAPS) Certificate <http://goo.gl/qnBg41>`_
5. `RFC-2818: HTTP Over TLS <http://goo.gl/9nVMfp>`_
6. `RFC-6125: Representation and Verification of Domain-Based Application Service Identity within Internet
Public Key Infrastructure Using X.509 (PKIX) Certificates in the Context of Transport Layer Security (TLS)
<http://goo.gl/QfWKVn>`_
7. `ldap3: SSL & TLS <http://goo.gl/3P1vjG>`_
8. `ssl — TLS/SSL wrapper for socket objects: ssl.match_hostname(cert, hostname) <http://goo.gl/IWgnEK>`_
"""
if Configuration._tls:
return Configuration._tls
command = self.command
configuration_file = command.service.confs[str('ssl')]
try:
app_settings = configuration_file['sslConfig']
except KeyError as error:
message = 'Cannot use SSL because the sslConfig stanza is missing from ssl.conf.'
command.error_exit(error, message)
return
configuration_file = command.service.confs[str('server')]
try:
server_settings = configuration_file['sslConfig']
except KeyError as error:
message = 'Cannot use SSL because the sslConfig stanza is missing from server.conf.'
command.error_exit(error, message)
return
def get_ssl_configuration_setting(setting_name, default=None, require=None, validate=None):
default = self._get_value(server_settings, setting_name, default=default, validate=validate)
value = self._get_value(app_settings, setting_name, default=default, require=require, validate=validate)
return value
ca_path = os.path.expandvars(get_ssl_configuration_setting('caPath', default=''))
ca_cert_file = get_ssl_configuration_setting('caCertFile', default='')
if ca_path:
ca_cert_file = os.path.join(ca_path, ca_cert_file) if ca_cert_file else None
ssl_verify_server_cert = get_ssl_configuration_setting('sslVerifyServerCert', default=False, validate=Boolean())
ssl_versions = get_ssl_configuration_setting('sslVersions', default='', validate=List())
protocol_set = set()
for ssl_version in ssl_versions:
if ssl_version[0] == '-':
operation = protocol_set.difference_update
member = ssl_version[1:]
else:
operation = protocol_set.update
member = ssl_version
if member not in ('*', 'ssl2', 'ssl3', 'tls', 'tls1.0', 'tls1.1', 'tls1.2'):
message = 'SSL configuration issue: sslVersions="{0}" is unrecognized.'.format(ssl_versions)
command.error_exit(ValueError(message), message)
return
if member == '*':
member = ('ssl2', 'ssl3', 'tls1.0', 'tls1.1', 'tls1.2')
elif member == 'tls':
member = ('tls1.0', 'tls1.1', 'tls1.2')
else:
member = (member,)
operation(member)
if use_ssl_context: # python 2.7.9
tls = self.get_tls_python_2_7_9_or_later(command, protocol_set, ssl_versions, ca_cert_file, ssl_verify_server_cert)
else: #prior to python 2.7.9
tls = self.get_tls_python_2_7_8_or_earlier(command, protocol_set, ssl_versions, ca_cert_file, ssl_verify_server_cert)
Configuration._tls = tls
return tls
def get_tls_python_2_7_9_or_later(self, command, protocol_set, ssl_versions, ca_cert_file, ssl_verify_server_cert):
ssl_no_v2 = False
ssl_no_v3 = False
if not protocol_set.symmetric_difference(('ssl3', 'tls1.0', 'tls1.1', 'tls1.2')):
version = ssl.PROTOCOL_SSLv23
ssl_no_v2 = True
elif not protocol_set.symmetric_difference(('tls1.0', 'tls1.1', 'tls1.2')):
ssl_no_v2 = True
ssl_no_v3 = True
version = ssl.PROTOCOL_SSLv23
elif not protocol_set.symmetric_difference(('ssl3',)):
version = ssl.PROTOCOL_SSLv3
elif not protocol_set.symmetric_difference(('tls1.0',)):
version = ssl.PROTOCOL_TLSv1
elif not protocol_set.symmetric_difference(('tls1.1',)):
version = ssl.PROTOCOL_TLSv1_1
elif not protocol_set.symmetric_difference(('tls1.2',)):
version = ssl.PROTOCOL_TLSv1_2
else:
message = 'SSL configuration issue: sslVersions="{0}" is an invalid combination.'.format(ssl_versions)
command.error_exit(ValueError(message), message)
return
try:
tls = Tls(
ca_certs_file=ca_cert_file if ca_cert_file else None,
validate=ssl.CERT_REQUIRED if ssl_verify_server_cert else ssl.CERT_NONE,
version=version)
except core.exceptions.LDAPSSLConfigurationError as error:
message = 'SSL configuration issue: {0}'.format(error)
command.error_exit(error, message)
return
if ssl_no_v2==False or ssl_no_v3==False:
command.logger.warning(
'POODLE Vulnerable: "sslVersions = %s". Upgrade Splunk and disable ssl2 and ssl3 to mitigate this '
'issue. Consider using "sslVersions = tls". See "Splunk response to SSLv3 POODLE vulnerability '
'(CVE-2014-3566)" at http://www.splunk.com/view/SP-CAAANKE for additional information.', ssl_versions)
pass
return tls
def get_tls_python_2_7_8_or_earlier(self, command, protocol_set, ssl_versions, ca_cert_file, ssl_verify_server_cert):
if hasattr(ssl, 'PROTOCOL_SSLv23_NO23'):
is_poodle_vulnerable_splunk = False
else:
is_poodle_vulnerable_splunk = True
ssl.PROTOCOL_SSLv23_NO23 = 5 # intentionally provokes an error that's treated as a configuration error later
if not protocol_set.symmetric_difference(('ssl2', 'ssl3', 'tls1.0', 'tls1.1', 'tls1.2')):
version = ssl.PROTOCOL_SSLv23
elif not protocol_set.symmetric_difference(('ssl3', 'tls1.0', 'tls1.1', 'tls1.2')):
version = ssl.PROTOCOL_SSLv23_NO2
elif not protocol_set.symmetric_difference(('tls1.0', 'tls1.1', 'tls1.2')):
version = ssl.PROTOCOL_SSLv23_NO23
elif not protocol_set.symmetric_difference(('ssl2',)):
version = ssl.PROTOCOL_SSLv2
elif not protocol_set.symmetric_difference(('ssl3',)):
version = ssl.PROTOCOL_SSLv3
elif not protocol_set.symmetric_difference(('tls1.0',)):
version = ssl.PROTOCOL_TLSv1
else:
message = 'SSL configuration issue: sslVersions="{0}" is an invalid combination.'.format(ssl_versions)
command.error_exit(ValueError(message), message)
return
try:
tls = Tls(
ca_certs_file=ca_cert_file if ca_cert_file else None,
validate=ssl.CERT_REQUIRED if ssl_verify_server_cert else ssl.CERT_NONE,
version=version)
except core.exceptions.LDAPSSLConfigurationError as error:
message = 'SSL configuration issue: {0}'.format(error)
command.error_exit(error, message)
return
if is_poodle_vulnerable_splunk:
command.logger.warning(
'POODLE Vulnerable: "sslVersions = %s". Upgrade Splunk and disable ssl2 and ssl3 to mitigate this '
'issue. Consider using "sslVersions = tls". See "Splunk response to SSLv3 POODLE vulnerability '
'(CVE-2014-3566)" at http://www.splunk.com/view/SP-CAAANKE for additional information.', ssl_versions)
pass
elif version not in (ssl.PROTOCOL_SSLv23_NO23, ssl.PROTOCOL_TLSv1):
command.logger.warning(
'POODLE Vulnerable: "sslVersions = %s". Disable ssl2 and ssl3 to mitigate this issue. Consider using '
'"sslVersions = tls". See "Splunk response to SSLv3 POODLE vulnerability (CVE-2014-3566)" at '
'http://www.splunk.com/view/SP-CAAANKE for additional information.', ssl_versions)
pass
return tls
def _get_value(self, settings, setting_name, require=None, default=None, validate=None):
# Because the Splunk Python SDK does not respect the semantics of dict type that it inherits from we cannot
# use settings.get(setting_name, default_value). We must resort to our own helper instead.
command = self.command
try:
value = settings[setting_name]
if value is None or len(value) == 0:
raise KeyError
if validate is not None:
try:
value = validate(value)
except ValueError as e:
message = 'Illegal value for %s in ldap/%s: %s' % (setting_name, command.domain, e)
self.command.logger.error(message)
self.command.write_error(message)
sys.exit(1)
except (AttributeError, KeyError):
if require:
message = 'Missing required value for %s in ldap/%s.' % (setting_name, command.domain)
self.command.logger.error(message)
self.command.write_error(message)
sys.exit(1)
value = default
return value
def _read_all_configurations(self):
self._buffered_configurations = OrderedDict()
settings = self._read_default_configuration()
self._add_buffered_configuration('default', settings)
for configuration_stanza in self.command.service.confs[str('ldap')].iter():
domain = configuration_stanza.name
settings = configuration_stanza.content
self._add_buffered_configuration(domain, settings)
def _read_configuration(self):
command = self.command
if command.domain == 'default':
settings = self._read_default_configuration()
else:
configuration_file = command.service.confs[str('ldap')]
try:
stanza = configuration_file[command.domain]
except KeyError as error:
self._read_all_configurations()
try:
self.select(command.domain)
return
except Exception as error:
message = 'Cannot find the configuration stanza for domain={0} in ldap.conf.'.format(command.domain)
command.error_exit(error, message)
return
settings = stanza.content
self._reset_fields(command.domain, settings)
return
def _read_default_configuration(self):
command = self.command
service = command.service
namespace = service.namespace
try:
response = service.get('properties/ldap/default', namespace.owner, namespace.app, namespace.sharing)
except HTTPError as error:
command.error_exit(error, 'The default configuration stanza for ldap.conf is missing: ' + str(error))
return
body = response.body.read()
feed = data.load(body)
entries = feed['feed'].get('entry', ())
if isinstance(entries, data.Record):
entries = entries,
settings = {entry['title']: entry['content'].get('$text', '') for entry in entries}
return settings
def _reset_fields(self, domain, settings):
self.settings = settings
self.domain = domain
command = self.command
self.alternatedomain = self._get_value(settings, 'alternatedomain', require=True)
self.basedn = self._get_value(settings, 'basedn', require=True)
host = self._get_value(settings, 'server', require=True, validate=List())
use_ssl = self._get_value(settings, 'ssl', default=False, validate=Boolean())
port = self._get_value(settings, 'port', default=636 if use_ssl else 389, validate=Integer(0, 65535))
binddn = self._get_value(settings, 'binddn')
storage_passwords = command.service.storage_passwords
if domain == 'default':
storage_password_names = 'SA-ldapsearch:default:',
else:
domain = reduce(lambda v, c: v + ('\\' + c if c in '\\:' else c), self.domain, 'SA-ldapsearch:') + ':'
storage_password_names = domain, 'SA-ldapsearch:default:'
password = None
for ssl_version in storage_password_names:
try:
storage_password = storage_passwords[ssl_version]
password = storage_password.clear_password
break
except HTTPError as e:
if e.status != 403:
raise
command.logger.debug('Storage password "%s" access denied: %s', ssl_version, e)
break
except KeyError as e:
command.logger.debug('Storage password "%s" not found', ssl_version)
if password is None:
password = self._get_value(settings, 'password', default='')
if password.startswith('{64}'):
password = b64decode(password[4:])
pass
self.decode = self._get_value(settings, 'decode', default=True, validate=Boolean())
self.paged_size = int(self._get_value(settings, 'paged_size', default=1000, validate=Integer(1, 65535)))
for option in itervalues(command.options): # override settings with command option values, if they're present
if not option.is_set:
if not option.name == 'domain':
if hasattr(self, option.name):
option.value = getattr(self, option.name)
decode = command.decode if hasattr(command, 'decode') else self.decode
formatter = app.formatting_extensions if decode else None
tls = self._get_tls() if use_ssl else None
def create_server(hostname):
return Server(
hostname, int(port), use_ssl, formatter=formatter, get_info=ALL,
allowed_referral_hosts=[('*', True)], tls=tls)
self.server = create_server(host[0]) if len(host) == 1 else [create_server(h) for h in host]
self.credentials = Configuration.Credentials(None, binddn, password, None)
command.logger.debug('Configuration = %s', self)
# endregion