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.

273 lines
11 KiB

#!/usr/bin/env python
# coding=utf-8
#
# Copyright (C) 2009-2021 Splunk Inc. All Rights Reserved.
from __future__ import absolute_import, division, print_function, unicode_literals
import default
import app
import ldap3
from splunklib.searchcommands import dispatch, StreamingCommand, Configuration, Option, validators
from collections import namedtuple, OrderedDict
from itertools import chain, islice
from app.six.moves import filter, map
from app.six import iteritems, string_types
@Configuration()
class LdapGroupCommand(StreamingCommand):
""" Filters and augments events with information from Active Directory.
This command follows a search or similar command in the pipeline so that you can feed it events:
.. code-block:: text
| ldapsearch domain=splunk.com search="(objectClass=groups)" | ldapgroup
"""
# region Command options
debug = Option(
doc=''' True, if the logging_level should be set to DEBUG; otherwise False.
**Default:** The current value of logging_level.
''',
default=False, validate=validators.Boolean())
decode = Option(
doc=''' True, if Active Directory formatting rules should be applied to attribute types.
**Default:** The value of decode as specified in the configuration stanza for domain.
''',
default=True, validate=validators.Boolean())
domain = Option(
doc=''' Specifies the Active Directory domain to search.
''',
default='default')
groupdn = Option(
doc=''' Specifies the name of the field holding the distinguished names of the group to expand.
''',
default='distinguishedName')
# endregion
# region Command implementation
Group = namedtuple('Group', (
'dn', 'object_sid'))
GroupMember = namedtuple('GroupMember', (
'dn', 'is_group', 'netbios_domain_name', 'object_sid', 'primary_group_id', 'sam_account_name'))
GroupMembership = namedtuple('GroupMemberList', (
'cycles', 'direct', 'nested'))
def stream(self, records):
"""
:param records: An iterable stream of events from the command pipeline.
:return: `None`.
"""
configuration = app.Configuration(self, is_expanded=True)
expanded_domain = app.ExpandedString(self.domain)
search_scope = ldap3.BASE
search_filter = '(objectCategory=Group)'
attributes = ['objectSid']
try:
with configuration.open_connection_pool(attributes) as connection_pool:
for record in records:
name = record.get(self.groupdn)
if name is None:
continue # there's no value for name in the current record
if name in self.names:
continue # we've already processed this name
domain = expanded_domain.get_value(record)
if domain is None:
continue # there's no domain to search (bad data?)
connection = connection_pool.select(domain)
if not connection:
self.logger.warning('groupdn="%s": domain="%s" is not configured', self.groupdn, domain)
continue
self.paged_size = configuration.paged_size
self.basedn = configuration.basedn
try:
connection.search(name, search_filter, search_scope, attributes=attributes)
except ldap3.core.exceptions.LDAPNoSuchObjectResult:
self.logger.warning('groupdn="%s" domain="%s": %s does not exist', self.groupdn, domain, name)
continue # this name is not the distinguished name of a group (bad data?)
except ldap3.core.exceptions.LDAPCommunicationError as error:
self.logger.warning(
'groupdn="%s" domain="%s", name="%s": %s', self.groupdn, domain, name, error)
continue # this name is not the distinguished name of a group (bad data?)
if not connection.response:
self.logger.warning('groupdn="%s" domain="%s": %s is not a group', self.groupdn, domain, name)
continue
do = connection.response[0]
do_attributes = app.get_attributes(self, do)
if do_attributes:
group = LdapGroupCommand.Group(do['dn'], do_attributes['objectSid'])
membership = LdapGroupCommand.GroupMembership(cycles=OrderedDict(), direct=[], nested=[])
self._get_group_membership(connection, group, membership)
LdapGroupCommand._augment_record(record, group, membership, self.logger, name)
yield record
self.names.add(name)
except ldap3.core.exceptions.LDAPException as error:
self.error_exit(error, app.get_ldap_error_message(error, configuration))
return
def __init__(self):
super(LdapGroupCommand, self).__init__()
self.paged_size = None
self.names = set()
self.basedn = None
return
@staticmethod
def _augment_record(record, group, membership, logger, groupname):
"""
:param record:
:param group:
:param membership:
:return: `None`.
"""
group_id = group.object_sid[group.object_sid.rindex('-') + 1:]
record['errors'] = [
(x, y) for x, y in iteritems(membership.cycles) if len(y) > 0]
record['member_dn'] = member_dn = [
x.dn for x in chain(membership.direct, membership.nested)]
record['member_domain'] = member_domain = [
x.netbios_domain_name for x in chain(membership.direct, membership.nested)]
record['member_name'] = member_name = [
x.sam_account_name for x in chain(membership.direct, membership.nested)]
record['member_type'] = member_type = [
'PRIMARY' if group_id == x.primary_group_id else y for x, y in chain(
map(lambda z: (z, 'DIRECT'), membership.direct), map(lambda z: (z, 'NESTED'), membership.nested))]
# Parsing the field list for mv_combo field. If field list contains another list, parse them into single list. Parsing level one only.
def parse_fields(field):
new_output = []
for names in field:
if isinstance(names, string_types):
new_output.append(names)
elif isinstance(names, list):
for name in names:
if isinstance(name, list):
continue
else:
new_output.append(name)
else:
try:
new_output.append(str(names))
logger.info('Unexpected data type encountered while data parsing for group = "%s". Appending it as a string', groupname)
except Exception as e:
logger.warning('Invalid data type encountered while data parsing for group = "%s" Reason = "%s"', groupname, str(e))
return new_output
record['mv_combo'] = '###'.join(
[','.join(parse_fields(member_dn)), ','.join(parse_fields(member_name)), ','.join(parse_fields(member_domain)), ','.join(parse_fields(member_type))])
return
@staticmethod
def _create_group_filter(members, start, stop):
return filter(lambda x: x.is_group, islice(members, start, stop))
def _get_group_membership(self, connection, group, membership):
self._get_direct_group_members(connection, group, membership.cycles, membership.direct)
groups = LdapGroupCommand._create_group_filter(membership.direct, 0, len(membership.direct))
self._get_nested_group_members(connection, groups, membership.cycles, membership.nested)
def _get_direct_group_members(self, connection, group, cycles, members):
"""
:param members:
:param group:
:param connection:
:return: `None`.
"""
if group.dn in cycles:
return
search_filter = '(memberOf={0})'.format(app.escape_assertion_value(group.dn))
cycles[group.dn] = []
entry_generator = connection.extend.standard.paged_search(
paged_size=self.paged_size,
search_base=self.basedn,
search_filter=search_filter,
attributes=('groupType', 'msDS-PrincipalName', 'objectSid', 'primaryGroupID', 'sAMAccountName'))
try:
for entry in entry_generator:
entry_attributes = app.get_attributes(self, entry)
if entry_attributes is None:
continue
netbios_domain_name = ""
if isinstance(entry_attributes.get('msDS-PrincipalName', ''), string_types):
netbios_domain_name = entry_attributes.get('msDS-PrincipalName', '').split('\\', 1)[0]
member = LdapGroupCommand.GroupMember(
dn=entry['dn'],
is_group=entry_attributes.get('groupType') is not None,
netbios_domain_name=netbios_domain_name,
object_sid=entry_attributes.get('objectSid', ''),
primary_group_id=entry_attributes.get('primaryGroupID', ''),
sam_account_name=entry_attributes.get('sAMAccountName', ''))
if member.is_group and member.dn in cycles:
# There's a cycle from group to member
cycles[group.dn].append(member.dn)
members.append(member)
except ldap3.core.exceptions.LDAPInvalidFilterError as error:
error.message += ': {0}'.format(search_filter)
raise error
return
def _get_nested_group_members(self, connection, groups, cycles, members):
""" Adds the members of each group in member_slice to member_list recursively.
:param members: A list of GroupMember objects.
:param groups: A slice of the GroupMember objects in member_list or--on first call--some other sequence
of GroupMember objects.
:param connection: A connection to an LDAP directory service that is queried for group members.
:return: `None`.
"""
start = len(members)
for group in groups:
self._get_direct_group_members(connection, group, cycles, members)
groups = LdapGroupCommand._create_group_filter(members, start, len(members))
self._get_nested_group_members(connection, groups, cycles, members)
return
# endregion
dispatch(LdapGroupCommand, module_name=__name__)