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
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__)
|