diff --git a/.github/samba/_patches/0001-Revert-gpclass.py-to-65ab33dffab2.patch b/.github/samba/_patches/0001-Revert-gpclass.py-to-65ab33dffab2.patch new file mode 100644 index 000000000..bd63b7b21 --- /dev/null +++ b/.github/samba/_patches/0001-Revert-gpclass.py-to-65ab33dffab2.patch @@ -0,0 +1,415 @@ +From a201b971295c069b2120f0ba53ee51cc2e337da7 Mon Sep 17 00:00:00 2001 +From: Gabriel Nagy +Date: Mon, 14 Aug 2023 13:55:27 +0300 +Subject: [PATCH 1/5] Revert gpclass.py to 65ab33dffab2 + +This is to ensure compatibility with older Samba versions such as the +one on Jammy. We don't benefit from these changes for our certificate +applier use case anyway. +--- + python/samba/gp/gpclass.py | 349 +------------------------------------ + 1 file changed, 9 insertions(+), 340 deletions(-) + +diff --git a/python/samba/gp/gpclass.py b/python/samba/gp/gpclass.py +index d70643421d0..605f94f3317 100644 +--- a/python/samba/gp/gpclass.py ++++ b/python/samba/gp/gpclass.py +@@ -21,7 +21,7 @@ import errno + import tdb + import pwd + sys.path.insert(0, "bin/python") +-from samba import NTSTATUSError, WERRORError ++from samba import NTSTATUSError + from configparser import ConfigParser + from io import StringIO + import traceback +@@ -44,15 +44,6 @@ from samba.gp.util.logging import log + from hashlib import blake2b + import numbers + from samba.common import get_string +-from samba.samdb import SamDB +-from samba.auth import system_session +-import ldb +-from samba.dsdb import UF_WORKSTATION_TRUST_ACCOUNT, UF_SERVER_TRUST_ACCOUNT, GPLINK_OPT_ENFORCE, GPLINK_OPT_DISABLE, GPO_INHERIT, GPO_BLOCK_INHERITANCE +-from samba.auth import AUTH_SESSION_INFO_DEFAULT_GROUPS, AUTH_SESSION_INFO_AUTHENTICATED, AUTH_SESSION_INFO_SIMPLE_PRIVILEGES +-from samba.dcerpc import security +-import samba.security +-from samba.dcerpc import netlogon +- + + try: + from enum import Enum +@@ -445,7 +436,7 @@ class gp_applier(object): + + The importance here is the digest of the data makes the attribute + reproducible and uniquely identifies it. Hashing the name with +- the data ensures we don't falsely identify a match which is the same ++ the data ensures we don't falsly identify a match which is the same + text in a different file. Using this attribute generator is optional. + ''' + data = b''.join([get_bytes(arg) for arg in [*args]]) +@@ -513,33 +504,6 @@ class gp_applier(object): + self.unapply(guid, attribute, value, **kwargs) + + +-class gp_misc_applier(gp_applier): +- '''Group Policy Miscellaneous Applier/Unapplier/Modifier +- ''' +- +- def generate_value(self, **kwargs): +- data = etree.Element('data') +- for k, v in kwargs.items(): +- arg = etree.SubElement(data, k) +- arg.text = get_string(v) +- return get_string(etree.tostring(data, 'utf-8')) +- +- def parse_value(self, value): +- vals = {} +- try: +- data = etree.fromstring(value) +- except etree.ParseError: +- # If parsing fails, then it's an old cache value +- return {'old_val': value} +- except TypeError: +- return {} +- itr = data.iter() +- next(itr) # Skip the top element +- for item in itr: +- vals[item.tag] = item.text +- return vals +- +- + class gp_file_applier(gp_applier): + '''Group Policy File Applier/Unapplier/Modifier + Subclass of abstract class gp_applier for monitoring policy applied +@@ -609,312 +573,17 @@ def get_dc_hostname(creds, lp): + nbt.NBT_SERVER_DS)) + return cldap_ret.pdc_dns_name + +-def get_dc_netbios_hostname(creds, lp): +- net = Net(creds=creds, lp=lp) +- cldap_ret = net.finddc(domain=lp.get('realm'), flags=(nbt.NBT_SERVER_LDAP | +- nbt.NBT_SERVER_DS)) +- return cldap_ret.pdc_name +- + + ''' Fetch a list of GUIDs for applicable GPOs ''' + + +-def get_gpo(samdb, gpo_dn): +- g = gpo.GROUP_POLICY_OBJECT() +- attrs = [ +- "cn", +- "displayName", +- "flags", +- "gPCFileSysPath", +- "gPCFunctionalityVersion", +- "gPCMachineExtensionNames", +- "gPCUserExtensionNames", +- "gPCWQLFilter", +- "name", +- "nTSecurityDescriptor", +- "versionNumber" +- ] +- if gpo_dn.startswith("LDAP://"): +- gpo_dn = gpo_dn.lstrip("LDAP://") +- +- sd_flags = (security.SECINFO_OWNER | +- security.SECINFO_GROUP | +- security.SECINFO_DACL) +- try: +- res = samdb.search(gpo_dn, ldb.SCOPE_BASE, "(objectclass=*)", attrs, +- controls=['sd_flags:1:%d' % sd_flags]) +- except Exception: +- log.error('Failed to fetch gpo object with nTSecurityDescriptor') +- raise +- if res.count != 1: +- raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, +- 'get_gpo: search failed') +- +- g.ds_path = gpo_dn +- if 'versionNumber' in res.msgs[0].keys(): +- g.version = int(res.msgs[0]['versionNumber'][0]) +- if 'flags' in res.msgs[0].keys(): +- g.options = int(res.msgs[0]['flags'][0]) +- if 'gPCFileSysPath' in res.msgs[0].keys(): +- g.file_sys_path = res.msgs[0]['gPCFileSysPath'][0].decode() +- if 'displayName' in res.msgs[0].keys(): +- g.display_name = res.msgs[0]['displayName'][0].decode() +- if 'name' in res.msgs[0].keys(): +- g.name = res.msgs[0]['name'][0].decode() +- if 'gPCMachineExtensionNames' in res.msgs[0].keys(): +- g.machine_extensions = str(res.msgs[0]['gPCMachineExtensionNames'][0]) +- if 'gPCUserExtensionNames' in res.msgs[0].keys(): +- g.user_extensions = str(res.msgs[0]['gPCUserExtensionNames'][0]) +- if 'nTSecurityDescriptor' in res.msgs[0].keys(): +- g.set_sec_desc(bytes(res.msgs[0]['nTSecurityDescriptor'][0])) +- return g +- +-class GP_LINK: +- def __init__(self, gPLink, gPOptions): +- self.link_names = [] +- self.link_opts = [] +- self.gpo_parse_gplink(gPLink) +- self.gp_opts = int(gPOptions) +- +- def gpo_parse_gplink(self, gPLink): +- for p in gPLink.decode().split(']'): +- if not p: +- continue +- log.debug('gpo_parse_gplink: processing link') +- p = p.lstrip('[') +- link_name, link_opt = p.split(';') +- log.debug('gpo_parse_gplink: link: {}'.format(link_name)) +- log.debug('gpo_parse_gplink: opt: {}'.format(link_opt)) +- self.link_names.append(link_name) +- self.link_opts.append(int(link_opt)) +- +- def num_links(self): +- if len(self.link_names) != len(self.link_opts): +- raise RuntimeError('Link names and opts mismatch') +- return len(self.link_names) +- +-def find_samaccount(samdb, samaccountname): +- attrs = ['dn', 'userAccountControl'] +- res = samdb.search(samdb.get_default_basedn(), ldb.SCOPE_SUBTREE, +- '(sAMAccountName={})'.format(samaccountname), attrs) +- if res.count != 1: +- raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, +- "Failed to find samAccountName '{}'".format(samaccountname) +- ) +- uac = int(res.msgs[0]['userAccountControl'][0]) +- dn = res.msgs[0]['dn'] +- log.info('Found dn {} for samaccountname {}'.format(dn, samaccountname)) +- return uac, dn +- +-def get_gpo_link(samdb, link_dn): +- res = samdb.search(link_dn, ldb.SCOPE_BASE, +- '(objectclass=*)', ['gPLink', 'gPOptions']) +- if res.count != 1: +- raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, 'get_gpo_link: no result') +- if not 'gPLink' in res.msgs[0]: +- raise ldb.LdbError(ldb.ERR_NO_SUCH_ATTRIBUTE, +- "get_gpo_link: no 'gPLink' attribute found for '{}'".format(link_dn) +- ) +- gPLink = res.msgs[0]['gPLink'][0] +- gPOptions = 0 +- if 'gPOptions' in res.msgs[0]: +- gPOptions = res.msgs[0]['gPOptions'][0] +- else: +- log.debug("get_gpo_link: no 'gPOptions' attribute found") +- return GP_LINK(gPLink, gPOptions) +- +-def add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, link_dn, gp_link, +- link_type, only_add_forced_gpos, token): +- for i in range(gp_link.num_links()-1, -1, -1): +- is_forced = (gp_link.link_opts[i] & GPLINK_OPT_ENFORCE) != 0 +- if gp_link.link_opts[i] & GPLINK_OPT_DISABLE: +- log.debug('skipping disabled GPO') +- continue +- +- if only_add_forced_gpos: +- if not is_forced: +- log.debug("skipping nonenforced GPO link " +- "because GPOPTIONS_BLOCK_INHERITANCE " +- "has been set") +- continue +- else: +- log.debug("adding enforced GPO link although " +- "the GPOPTIONS_BLOCK_INHERITANCE " +- "has been set") +- +- try: +- new_gpo = get_gpo(samdb, gp_link.link_names[i]) +- except ldb.LdbError as e: +- (enum, estr) = e.args +- log.debug("failed to get gpo: %s" % gp_link.link_names[i]) +- if enum == ldb.ERR_NO_SUCH_OBJECT: +- log.debug("skipping empty gpo: %s" % gp_link.link_names[i]) +- continue +- return +- else: +- try: +- sec_desc = ndr_unpack(security.descriptor, +- new_gpo.get_sec_desc_buf()) +- samba.security.access_check(sec_desc, token, +- security.SEC_STD_READ_CONTROL | +- security.SEC_ADS_LIST | +- security.SEC_ADS_READ_PROP) +- except Exception as e: +- log.debug("skipping GPO \"%s\" as object " +- "has no access to it" % new_gpo.display_name) +- continue +- +- new_gpo.link = str(link_dn) +- new_gpo.link_type = link_type +- +- if is_forced: +- forced_gpo_list.insert(0, new_gpo) +- else: +- gpo_list.insert(0, new_gpo) +- +- log.debug("add_gplink_to_gpo_list: added GPLINK #%d %s " +- "to GPO list" % (i, gp_link.link_names[i])) +- +-def merge_nt_token(token_1, token_2): +- sids = token_1.sids +- sids.extend(token_2.sids) +- token_1.sids = sids +- token_1.rights_mask |= token_2.rights_mask +- token_1.privilege_mask |= token_2.privilege_mask +- return token_1 +- +-def site_dn_for_machine(samdb, dc_hostname, lp, creds, hostname): +- # [MS-GPOL] 3.2.5.1.4 Site Search +- config_context = samdb.get_config_basedn() +- try: +- c = netlogon.netlogon("ncacn_np:%s[seal]" % dc_hostname, lp, creds) +- site_name = c.netr_DsRGetSiteName(hostname) +- return 'CN={},CN=Sites,{}'.format(site_name, config_context) +- except WERRORError: +- # Fallback to the old method found in ads_site_dn_for_machine +- nb_hostname = get_dc_netbios_hostname(creds, lp) +- res = samdb.search(config_context, ldb.SCOPE_SUBTREE, +- "(cn=%s)" % nb_hostname, ['dn']) +- if res.count != 1: +- raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, +- 'site_dn_for_machine: no result') +- dn = res.msgs[0]['dn'] +- site_dn = dn.parent().parent() +- return site_dn +- + def get_gpo_list(dc_hostname, creds, lp, username): +- '''Get the full list of GROUP_POLICY_OBJECTs for a given username. +- Push GPOs to gpo_list so that the traversal order of the list matches +- the order of application: +- (L)ocal (S)ite (D)omain (O)rganizational(U)nit +- For different domains and OUs: parent-to-child. +- Within same level of domains and OUs: Link order. +- Since GPOs are pushed to the front of gpo_list, GPOs have to be +- pushed in the opposite order of application (OUs first, local last, +- child-to-parent). +- Forced GPOs are appended in the end since they override all others. +- ''' +- gpo_list = [] +- forced_gpo_list = [] +- url = 'ldap://' + dc_hostname +- samdb = SamDB(url=url, +- session_info=system_session(), +- credentials=creds, lp=lp) +- # username is DOM\\SAM, but get_gpo_list expects SAM +- uac, dn = find_samaccount(samdb, username.split('\\')[-1]) +- add_only_forced_gpos = False +- +- # Fetch the security token +- session_info_flags = (AUTH_SESSION_INFO_DEFAULT_GROUPS | +- AUTH_SESSION_INFO_AUTHENTICATED) +- if url.startswith('ldap'): +- session_info_flags |= AUTH_SESSION_INFO_SIMPLE_PRIVILEGES +- session = samba.auth.user_session(samdb, lp_ctx=lp, dn=dn, +- session_info_flags=session_info_flags) +- gpo_list_machine = False +- if uac & UF_WORKSTATION_TRUST_ACCOUNT or uac & UF_SERVER_TRUST_ACCOUNT: +- gpo_list_machine = True +- token = merge_nt_token(session.security_token, +- system_session().security_token) +- else: +- token = session.security_token +- +- # (O)rganizational(U)nit +- parent_dn = dn.parent() +- while True: +- if str(parent_dn) == str(samdb.get_default_basedn().parent()): +- break +- +- # An account can be a member of more OUs +- if parent_dn.get_component_name(0) == 'OU': +- try: +- log.debug("get_gpo_list: query OU: [%s] for GPOs" % parent_dn) +- gp_link = get_gpo_link(samdb, parent_dn) +- except ldb.LdbError as e: +- (enum, estr) = e.args +- log.debug(estr) +- else: +- add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, +- parent_dn, gp_link, +- gpo.GP_LINK_OU, +- add_only_forced_gpos, token) +- +- # block inheritance from now on +- if gp_link.gp_opts & GPO_BLOCK_INHERITANCE: +- add_only_forced_gpos = True +- +- parent_dn = parent_dn.parent() +- +- # (D)omain +- parent_dn = dn.parent() +- while True: +- if str(parent_dn) == str(samdb.get_default_basedn().parent()): +- break +- +- # An account can just be a member of one domain +- if parent_dn.get_component_name(0) == 'DC': +- try: +- log.debug("get_gpo_list: query DC: [%s] for GPOs" % parent_dn) +- gp_link = get_gpo_link(samdb, parent_dn) +- except ldb.LdbError as e: +- (enum, estr) = e.args +- log.debug(estr) +- else: +- add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, +- parent_dn, gp_link, +- gpo.GP_LINK_DOMAIN, +- add_only_forced_gpos, token) +- +- # block inheritance from now on +- if gp_link.gp_opts & GPO_BLOCK_INHERITANCE: +- add_only_forced_gpos = True +- +- parent_dn = parent_dn.parent() +- +- # (S)ite +- if gpo_list_machine: +- site_dn = site_dn_for_machine(samdb, dc_hostname, lp, creds, username) +- +- try: +- log.debug("get_gpo_list: query SITE: [%s] for GPOs" % site_dn) +- gp_link = get_gpo_link(samdb, site_dn) +- except ldb.LdbError as e: +- (enum, estr) = e.args +- log.debug(estr) +- else: +- add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, +- site_dn, gp_link, +- gpo.GP_LINK_SITE, +- add_only_forced_gpos, token) +- +- # (L)ocal +- gpo_list.insert(0, gpo.GROUP_POLICY_OBJECT("Local Policy", +- "Local Policy", +- gpo.GP_LINK_LOCAL)) +- +- # Append |forced_gpo_list| at the end of |gpo_list|, +- # so that forced GPOs are applied on top of non enforced GPOs. +- return gpo_list+forced_gpo_list ++ gpos = [] ++ ads = gpo.ADS_STRUCT(dc_hostname, lp, creds) ++ if ads.connect(): ++ # username is DOM\\SAM, but get_gpo_list expects SAM ++ gpos = ads.get_gpo_list(username.split('\\')[-1]) ++ return gpos + + + def cache_gpo_dir(conn, cache, sub_dir): +@@ -1009,7 +678,7 @@ def apply_gp(lp, creds, store, gp_extensions, username, target, force=False): + if target == 'Computer': + ext.process_group_policy(del_gpos, changed_gpos) + else: +- drop_privileges(username, ext.process_group_policy, ++ drop_privileges(creds.get_principal(), ext.process_group_policy, + del_gpos, changed_gpos) + except Exception as e: + log.error('Failed to apply extension %s' % str(ext)) +-- +2.41.0 + diff --git a/.github/samba/_patches/0002-gp-Make-global-trust-dir-configurable.patch b/.github/samba/_patches/0002-gp-Make-global-trust-dir-configurable.patch new file mode 100644 index 000000000..1713416e1 --- /dev/null +++ b/.github/samba/_patches/0002-gp-Make-global-trust-dir-configurable.patch @@ -0,0 +1,114 @@ +From 10c33ec5da7e19e5710f44eaa91be6906dc59e2a Mon Sep 17 00:00:00 2001 +From: Gabriel Nagy +Date: Fri, 11 Aug 2023 18:39:13 +0300 +Subject: [PATCH 2/5] gp: Make global trust dir configurable + +The global trust directory differs between Linux distributions, e.g. on +Debian-based systems the directory performing a similar function is +`/usr/local/share/ca-certificates`. + +Make the path configurable similar to the other certificate directories, +while defaulting to the previous one to maintain backwards +compatibility. + +Signed-off-by: Gabriel Nagy +--- + python/samba/gp/gp_cert_auto_enroll_ext.py | 22 ++++++++++++---------- + 1 file changed, 12 insertions(+), 10 deletions(-) + +diff --git a/python/samba/gp/gp_cert_auto_enroll_ext.py b/python/samba/gp/gp_cert_auto_enroll_ext.py +index 312c8ddf467..bf6dcc4a98d 100644 +--- a/python/samba/gp/gp_cert_auto_enroll_ext.py ++++ b/python/samba/gp/gp_cert_auto_enroll_ext.py +@@ -45,7 +45,6 @@ cert_wrap = b""" + -----BEGIN CERTIFICATE----- + %s + -----END CERTIFICATE-----""" +-global_trust_dir = '/etc/pki/trust/anchors' + endpoint_re = '(https|HTTPS)://(?P[a-zA-Z0-9.-]+)/ADPolicyProvider' + \ + '_CEP_(?P[a-zA-Z]+)/service.svc/CEP' + +@@ -249,7 +248,7 @@ def getca(ca, url, trust_dir): + return root_certs + + +-def cert_enroll(ca, ldb, trust_dir, private_dir, auth='Kerberos'): ++def cert_enroll(ca, ldb, trust_dir, private_dir, global_trust_dir, auth='Kerberos'): + """Install the root certificate chain.""" + data = dict({'files': [], 'templates': []}, **ca) + url = 'http://%s/CertSrv/mscep/mscep.dll/pkiclient.exe?' % ca['hostname'] +@@ -351,11 +350,13 @@ class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + self.cache_add_attribute(guid, attribute, data) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, +- trust_dir=None, private_dir=None): ++ trust_dir=None, private_dir=None, global_trust_dir=None): + if trust_dir is None: + trust_dir = self.lp.cache_path('certs') + if private_dir is None: + private_dir = self.lp.private_path('certs') ++ if global_trust_dir is None: ++ global_trust_dir = '/etc/pki/trust/anchors' + if not os.path.exists(trust_dir): + os.mkdir(trust_dir, mode=0o755) + if not os.path.exists(private_dir): +@@ -385,7 +386,8 @@ class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + if enroll: + ca_names = self.__enroll(gpo.name, + pol_conf.entries, +- trust_dir, private_dir) ++ trust_dir, private_dir, ++ global_trust_dir) + + # Cleanup any old CAs that have been removed + ca_attrs = [base64.b64encode(n.encode()).decode() \ +@@ -399,7 +401,7 @@ class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + self.clean(gpo.name, remove=ca_attrs) + + def __read_cep_data(self, guid, ldb, end_point_information, +- trust_dir, private_dir): ++ trust_dir, private_dir, global_trust_dir): + """Read CEP Data. + + [MS-CAESO] 4.4.5.3.2.4 +@@ -454,19 +456,19 @@ class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + cas = fetch_certification_authorities(ldb) + for _ca in cas: + self.apply(guid, _ca, cert_enroll, _ca, ldb, trust_dir, +- private_dir) ++ private_dir, global_trust_dir) + ca_names.append(_ca['name']) + # If EndPoint.URI starts with "HTTPS//": + elif ca['URL'].lower().startswith('https://'): + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, +- private_dir, auth=ca['auth']) ++ private_dir, global_trust_dir, auth=ca['auth']) + ca_names.append(ca['name']) + else: + edata = { 'endpoint': ca['URL'] } + log.error('Unrecognized endpoint', edata) + return ca_names + +- def __enroll(self, guid, entries, trust_dir, private_dir): ++ def __enroll(self, guid, entries, trust_dir, private_dir, global_trust_dir): + url = 'ldap://%s' % get_dc_hostname(self.creds, self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) +@@ -476,12 +478,12 @@ class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + if len(end_point_information) > 0: + ca_names.extend(self.__read_cep_data(guid, ldb, + end_point_information, +- trust_dir, private_dir)) ++ trust_dir, private_dir, global_trust_dir)) + else: + cas = fetch_certification_authorities(ldb) + for ca in cas: + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, +- private_dir) ++ private_dir, global_trust_dir) + ca_names.append(ca['name']) + return ca_names + +-- +2.41.0 + diff --git a/.github/samba/_patches/0003-gp-Change-root-cert-extension-suffix.patch b/.github/samba/_patches/0003-gp-Change-root-cert-extension-suffix.patch new file mode 100644 index 000000000..280de33c4 --- /dev/null +++ b/.github/samba/_patches/0003-gp-Change-root-cert-extension-suffix.patch @@ -0,0 +1,30 @@ +From f3096f35f4fe62eec5589c1bbe0a1ff576005a85 Mon Sep 17 00:00:00 2001 +From: Gabriel Nagy +Date: Fri, 11 Aug 2023 18:46:42 +0300 +Subject: [PATCH 3/5] gp: Change root cert extension suffix + +On Ubuntu, certificates must end in '.crt' in order to be considered by +the `update-ca-certificates` helper. + +Signed-off-by: Gabriel Nagy +--- + python/samba/gp/gp_cert_auto_enroll_ext.py | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/python/samba/gp/gp_cert_auto_enroll_ext.py b/python/samba/gp/gp_cert_auto_enroll_ext.py +index bf6dcc4a98d..e428737ba50 100644 +--- a/python/samba/gp/gp_cert_auto_enroll_ext.py ++++ b/python/samba/gp/gp_cert_auto_enroll_ext.py +@@ -238,7 +238,8 @@ def getca(ca, url, trust_dir): + certs = load_der_pkcs7_certificates(r.content) + for i in range(0, len(certs)): + cert = certs[i].public_bytes(Encoding.PEM) +- dest = '%s.%d' % (root_cert, i) ++ filename, extension = root_cert.rsplit('.', 1) ++ dest = '%s.%d.%s' % (filename, i, extension) + with open(dest, 'wb') as w: + w.write(cert) + root_certs.append(dest) +-- +2.41.0 + diff --git a/.github/samba/_patches/0004-gp-wip.patch b/.github/samba/_patches/0004-gp-wip.patch new file mode 100644 index 000000000..51283c54d --- /dev/null +++ b/.github/samba/_patches/0004-gp-wip.patch @@ -0,0 +1,57 @@ +From fe018c46b3515f43d1bcb392622fc97223e279d1 Mon Sep 17 00:00:00 2001 +From: Gabriel Nagy +Date: Fri, 11 Aug 2023 18:58:07 +0300 +Subject: [PATCH 4/5] gp: wip + +--- + python/samba/gp/gp_cert_auto_enroll_ext.py | 13 +++++++------ + 1 file changed, 7 insertions(+), 6 deletions(-) + +diff --git a/python/samba/gp/gp_cert_auto_enroll_ext.py b/python/samba/gp/gp_cert_auto_enroll_ext.py +index e428737ba50..30ff07ba433 100644 +--- a/python/samba/gp/gp_cert_auto_enroll_ext.py ++++ b/python/samba/gp/gp_cert_auto_enroll_ext.py +@@ -155,7 +155,7 @@ def fetch_certification_authorities(ldb): + for es in res: + data = { 'name': get_string(es['cn'][0]), + 'hostname': get_string(es['dNSHostName'][0]), +- 'cACertificate': get_string(es['cACertificate'][0]) ++ 'cACertificate': get_string(base64.b64encode(es['cACertificate'][0])) + } + result.append(data) + return result +@@ -173,8 +173,7 @@ def fetch_template_attrs(ldb, name, attrs=None): + return {'msPKI-Minimal-Key-Size': ['2048']} + + def format_root_cert(cert): +- cert = base64.b64encode(cert.encode()) +- return cert_wrap % re.sub(b"(.{64})", b"\\1\n", cert, 0, re.DOTALL) ++ return cert_wrap % re.sub(b"(.{64})", b"\\1\n", cert.encode(), 0, re.DOTALL) + + def find_cepces_submit(): + certmonger_dirs = [os.environ.get("PATH"), '/usr/lib/certmonger', +@@ -337,8 +336,10 @@ class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + # If the policy has changed, unapply, then apply new policy + old_val = self.cache_get_attribute_value(guid, attribute) + old_data = json.loads(old_val) if old_val is not None else {} +- if all([(ca[k] == old_data[k] if k in old_data else False) \ +- for k in ca.keys()]) or \ ++ templates = ['%s.%s' % (ca['name'], t.decode()) for t in get_supported_templates(ca['hostname'])] ++ new_data = { 'templates': templates, **ca } ++ if any((new_data[k] != old_data[k] if k in old_data else False) \ ++ for k in new_data.keys()) or \ + self.cache_get_apply_state() == GPOSTATE.ENFORCE: + self.unapply(guid, attribute, old_val) + # If policy is already applied, skip application +@@ -399,7 +400,7 @@ class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + # remove any existing policy + ca_attrs = \ + self.cache_get_all_attribute_values(gpo.name) +- self.clean(gpo.name, remove=ca_attrs) ++ self.clean(gpo.name, remove=list(ca_attrs.keys())) + + def __read_cep_data(self, guid, ldb, end_point_information, + trust_dir, private_dir, global_trust_dir): +-- +2.41.0 + diff --git a/.github/samba/_patches/0005-gp-update-samba-imports-to-vendored.patch b/.github/samba/_patches/0005-gp-update-samba-imports-to-vendored.patch new file mode 100644 index 000000000..0f4a02e68 --- /dev/null +++ b/.github/samba/_patches/0005-gp-update-samba-imports-to-vendored.patch @@ -0,0 +1,52 @@ +From acf2d7e0cd96e03c5d8edadce7736f3e4d6922f0 Mon Sep 17 00:00:00 2001 +From: Gabriel Nagy +Date: Mon, 14 Aug 2023 13:34:32 +0300 +Subject: [PATCH 5/5] gp: update samba imports to vendored + +Signed-off-by: Gabriel Nagy +--- + python/samba/gp/gp_cert_auto_enroll_ext.py | 6 +++--- + python/samba/gp/gpclass.py | 2 +- + 2 files changed, 4 insertions(+), 4 deletions(-) + +diff --git a/python/samba/gp/gp_cert_auto_enroll_ext.py b/python/samba/gp/gp_cert_auto_enroll_ext.py +index 30ff07ba433..54be3bc2823 100644 +--- a/python/samba/gp/gp_cert_auto_enroll_ext.py ++++ b/python/samba/gp/gp_cert_auto_enroll_ext.py +@@ -17,17 +17,17 @@ + import os + import operator + import requests +-from samba.gp.gpclass import gp_pol_ext, gp_applier, GPOSTATE ++from vendor_samba.gp.gpclass import gp_pol_ext, gp_applier, GPOSTATE + from samba import Ldb + from ldb import SCOPE_SUBTREE, SCOPE_BASE + from samba.auth import system_session +-from samba.gp.gpclass import get_dc_hostname ++from vendor_samba.gp.gpclass import get_dc_hostname + import base64 + from shutil import which + from subprocess import Popen, PIPE + import re + import json +-from samba.gp.util.logging import log ++from vendor_samba.gp.util.logging import log + import struct + try: + from cryptography.hazmat.primitives.serialization.pkcs7 import \ +diff --git a/python/samba/gp/gpclass.py b/python/samba/gp/gpclass.py +index 605f94f3317..0ef86576de2 100644 +--- a/python/samba/gp/gpclass.py ++++ b/python/samba/gp/gpclass.py +@@ -40,7 +40,7 @@ from samba.dcerpc import preg + from samba.dcerpc import misc + from samba.ndr import ndr_pack, ndr_unpack + from samba.credentials import SMB_SIGNING_REQUIRED +-from samba.gp.util.logging import log ++from vendor_samba.gp.util.logging import log + from hashlib import blake2b + import numbers + from samba.common import get_string +-- +2.41.0 + diff --git a/.github/samba/python/samba/gp/gp_cert_auto_enroll_ext.py b/.github/samba/python/samba/gp/gp_cert_auto_enroll_ext.py new file mode 100644 index 000000000..312c8ddf4 --- /dev/null +++ b/.github/samba/python/samba/gp/gp_cert_auto_enroll_ext.py @@ -0,0 +1,532 @@ +# gp_cert_auto_enroll_ext samba group policy +# Copyright (C) David Mulder 2021 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import operator +import requests +from samba.gp.gpclass import gp_pol_ext, gp_applier, GPOSTATE +from samba import Ldb +from ldb import SCOPE_SUBTREE, SCOPE_BASE +from samba.auth import system_session +from samba.gp.gpclass import get_dc_hostname +import base64 +from shutil import which +from subprocess import Popen, PIPE +import re +import json +from samba.gp.util.logging import log +import struct +try: + from cryptography.hazmat.primitives.serialization.pkcs7 import \ + load_der_pkcs7_certificates +except ModuleNotFoundError: + def load_der_pkcs7_certificates(x): return [] + log.error('python cryptography missing pkcs7 support. ' + 'Certificate chain parsing will fail') +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.x509 import load_der_x509_certificate +from cryptography.hazmat.backends import default_backend +from samba.common import get_string + +cert_wrap = b""" +-----BEGIN CERTIFICATE----- +%s +-----END CERTIFICATE-----""" +global_trust_dir = '/etc/pki/trust/anchors' +endpoint_re = '(https|HTTPS)://(?P[a-zA-Z0-9.-]+)/ADPolicyProvider' + \ + '_CEP_(?P[a-zA-Z]+)/service.svc/CEP' + + +def octet_string_to_objectGUID(data): + """Convert an octet string to an objectGUID.""" + return '%s-%s-%s-%s-%s' % ('%02x' % struct.unpack('H', data[8:10])[0], + '%02x%02x' % struct.unpack('>HL', data[10:])) + + +def group_and_sort_end_point_information(end_point_information): + """Group and Sort End Point Information. + + [MS-CAESO] 4.4.5.3.2.3 + In this step autoenrollment processes the end point information by grouping + it by CEP ID and sorting in the order with which it will use the end point + to access the CEP information. + """ + # Create groups of the CertificateEnrollmentPolicyEndPoint instances that + # have the same value of the EndPoint.PolicyID datum. + end_point_groups = {} + for e in end_point_information: + if e['PolicyID'] not in end_point_groups.keys(): + end_point_groups[e['PolicyID']] = [] + end_point_groups[e['PolicyID']].append(e) + + # Sort each group by following these rules: + for end_point_group in end_point_groups.values(): + # Sort the CertificateEnrollmentPolicyEndPoint instances in ascending + # order based on the EndPoint.Cost value. + end_point_group.sort(key=lambda e: e['Cost']) + + # For instances that have the same EndPoint.Cost: + cost_list = [e['Cost'] for e in end_point_group] + costs = set(cost_list) + for cost in costs: + i = cost_list.index(cost) + j = len(cost_list)-operator.indexOf(reversed(cost_list), cost)-1 + if i == j: + continue + + # Sort those that have EndPoint.Authentication equal to Kerberos + # first. Then sort those that have EndPoint.Authentication equal to + # Anonymous. The rest of the CertificateEnrollmentPolicyEndPoint + # instances follow in an arbitrary order. + def sort_auth(e): + # 0x2 - Kerberos + if e['AuthFlags'] == 0x2: + return 0 + # 0x1 - Anonymous + elif e['AuthFlags'] == 0x1: + return 1 + else: + return 2 + end_point_group[i:j+1] = sorted(end_point_group[i:j+1], + key=sort_auth) + return list(end_point_groups.values()) + +def obtain_end_point_information(entries): + """Obtain End Point Information. + + [MS-CAESO] 4.4.5.3.2.2 + In this step autoenrollment initializes the + CertificateEnrollmentPolicyEndPoints table. + """ + end_point_information = {} + section = 'Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\' + for e in entries: + if not e.keyname.startswith(section): + continue + name = e.keyname.replace(section, '') + if name not in end_point_information.keys(): + end_point_information[name] = {} + end_point_information[name][e.valuename] = e.data + for ca in end_point_information.values(): + m = re.match(endpoint_re, ca['URL']) + if m: + name = '%s-CA' % m.group('server').replace('.', '-') + ca['name'] = name + ca['hostname'] = m.group('server') + ca['auth'] = m.group('auth') + elif ca['URL'].lower() != 'ldap:': + edata = { 'endpoint': ca['URL'] } + log.error('Failed to parse the endpoint', edata) + return {} + end_point_information = \ + group_and_sort_end_point_information(end_point_information.values()) + return end_point_information + +def fetch_certification_authorities(ldb): + """Initialize CAs. + + [MS-CAESO] 4.4.5.3.1.2 + """ + result = [] + basedn = ldb.get_default_basedn() + # Autoenrollment MUST do an LDAP search for the CA information + # (pKIEnrollmentService) objects under the following container: + dn = 'CN=Enrollment Services,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + attrs = ['cACertificate', 'cn', 'dNSHostName'] + expr = '(objectClass=pKIEnrollmentService)' + res = ldb.search(dn, SCOPE_SUBTREE, expr, attrs) + if len(res) == 0: + return result + for es in res: + data = { 'name': get_string(es['cn'][0]), + 'hostname': get_string(es['dNSHostName'][0]), + 'cACertificate': get_string(es['cACertificate'][0]) + } + result.append(data) + return result + +def fetch_template_attrs(ldb, name, attrs=None): + if attrs is None: + attrs = ['msPKI-Minimal-Key-Size'] + basedn = ldb.get_default_basedn() + dn = 'CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + expr = '(cn=%s)' % name + res = ldb.search(dn, SCOPE_SUBTREE, expr, attrs) + if len(res) == 1 and 'msPKI-Minimal-Key-Size' in res[0]: + return dict(res[0]) + else: + return {'msPKI-Minimal-Key-Size': ['2048']} + +def format_root_cert(cert): + cert = base64.b64encode(cert.encode()) + return cert_wrap % re.sub(b"(.{64})", b"\\1\n", cert, 0, re.DOTALL) + +def find_cepces_submit(): + certmonger_dirs = [os.environ.get("PATH"), '/usr/lib/certmonger', + '/usr/libexec/certmonger'] + return which('cepces-submit', path=':'.join(certmonger_dirs)) + +def get_supported_templates(server): + cepces_submit = find_cepces_submit() + if os.path.exists(cepces_submit): + env = os.environ + env['CERTMONGER_OPERATION'] = 'GET-SUPPORTED-TEMPLATES' + p = Popen([cepces_submit, '--server=%s' % server, '--auth=Kerberos'], + env=env, stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + if p.returncode != 0: + data = { 'Error': err.decode() } + log.error('Failed to fetch the list of supported templates.', data) + return out.strip().split() + return [] + + +def getca(ca, url, trust_dir): + """Fetch Certificate Chain from the CA.""" + root_cert = os.path.join(trust_dir, '%s.crt' % ca['name']) + root_certs = [] + + try: + r = requests.get(url=url, params={'operation': 'GetCACert', + 'message': 'CAIdentifier'}) + except requests.exceptions.ConnectionError: + log.warn('Failed to establish a new connection') + r = None + if r is None or r.content == b'' or r.headers['Content-Type'] == 'text/html': + log.warn('Failed to fetch the root certificate chain.') + log.warn('The Network Device Enrollment Service is either not' + + ' installed or not configured.') + if 'cACertificate' in ca: + log.warn('Installing the server certificate only.') + try: + cert = load_der_x509_certificate(ca['cACertificate']) + except TypeError: + cert = load_der_x509_certificate(ca['cACertificate'], + default_backend()) + cert_data = cert.public_bytes(Encoding.PEM) + with open(root_cert, 'wb') as w: + w.write(cert_data) + root_certs.append(root_cert) + return root_certs + + if r.headers['Content-Type'] == 'application/x-x509-ca-cert': + # Older versions of load_der_x509_certificate require a backend param + try: + cert = load_der_x509_certificate(r.content) + except TypeError: + cert = load_der_x509_certificate(r.content, default_backend()) + cert_data = cert.public_bytes(Encoding.PEM) + with open(root_cert, 'wb') as w: + w.write(cert_data) + root_certs.append(root_cert) + elif r.headers['Content-Type'] == 'application/x-x509-ca-ra-cert': + certs = load_der_pkcs7_certificates(r.content) + for i in range(0, len(certs)): + cert = certs[i].public_bytes(Encoding.PEM) + dest = '%s.%d' % (root_cert, i) + with open(dest, 'wb') as w: + w.write(cert) + root_certs.append(dest) + else: + log.warn('getca: Wrong (or missing) MIME content type') + + return root_certs + + +def cert_enroll(ca, ldb, trust_dir, private_dir, auth='Kerberos'): + """Install the root certificate chain.""" + data = dict({'files': [], 'templates': []}, **ca) + url = 'http://%s/CertSrv/mscep/mscep.dll/pkiclient.exe?' % ca['hostname'] + root_certs = getca(ca, url, trust_dir) + data['files'].extend(root_certs) + for src in root_certs: + # Symlink the certs to global trust dir + dst = os.path.join(global_trust_dir, os.path.basename(src)) + try: + os.symlink(src, dst) + data['files'].append(dst) + except PermissionError: + log.warn('Failed to symlink root certificate to the' + ' admin trust anchors') + except FileNotFoundError: + log.warn('Failed to symlink root certificate to the' + ' admin trust anchors.' + ' The directory was not found', global_trust_dir) + except FileExistsError: + # If we're simply downloading a renewed cert, the symlink + # already exists. Ignore the FileExistsError. Preserve the + # existing symlink in the unapply data. + data['files'].append(dst) + update = which('update-ca-certificates') + if update is not None: + Popen([update]).wait() + # Setup Certificate Auto Enrollment + getcert = which('getcert') + cepces_submit = find_cepces_submit() + if getcert is not None and os.path.exists(cepces_submit): + p = Popen([getcert, 'add-ca', '-c', ca['name'], '-e', + '%s --server=%s --auth=%s' % (cepces_submit, + ca['hostname'], auth)], + stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + log.debug(out.decode()) + if p.returncode != 0: + data = { 'Error': err.decode(), 'CA': ca['name'] } + log.error('Failed to add Certificate Authority', data) + supported_templates = get_supported_templates(ca['hostname']) + for template in supported_templates: + attrs = fetch_template_attrs(ldb, template) + nickname = '%s.%s' % (ca['name'], template.decode()) + keyfile = os.path.join(private_dir, '%s.key' % nickname) + certfile = os.path.join(trust_dir, '%s.crt' % nickname) + p = Popen([getcert, 'request', '-c', ca['name'], + '-T', template.decode(), + '-I', nickname, '-k', keyfile, '-f', certfile, + '-g', attrs['msPKI-Minimal-Key-Size'][0]], + stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + log.debug(out.decode()) + if p.returncode != 0: + data = { 'Error': err.decode(), 'Certificate': nickname } + log.error('Failed to request certificate', data) + data['files'].extend([keyfile, certfile]) + data['templates'].append(nickname) + if update is not None: + Popen([update]).wait() + else: + log.warn('certmonger and cepces must be installed for ' + + 'certificate auto enrollment to work') + return json.dumps(data) + +class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + def __str__(self): + return 'Cryptography\AutoEnrollment' + + def unapply(self, guid, attribute, value): + ca_cn = base64.b64decode(attribute) + data = json.loads(value) + getcert = which('getcert') + if getcert is not None: + Popen([getcert, 'remove-ca', '-c', ca_cn]).wait() + for nickname in data['templates']: + Popen([getcert, 'stop-tracking', '-i', nickname]).wait() + for f in data['files']: + if os.path.exists(f): + if os.path.exists(f): + os.unlink(f) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, ca, applier_func, *args, **kwargs): + attribute = base64.b64encode(ca['name'].encode()).decode() + # If the policy has changed, unapply, then apply new policy + old_val = self.cache_get_attribute_value(guid, attribute) + old_data = json.loads(old_val) if old_val is not None else {} + if all([(ca[k] == old_data[k] if k in old_data else False) \ + for k in ca.keys()]) or \ + self.cache_get_apply_state() == GPOSTATE.ENFORCE: + self.unapply(guid, attribute, old_val) + # If policy is already applied, skip application + if old_val is not None and \ + self.cache_get_apply_state() != GPOSTATE.ENFORCE: + return + + # Apply the policy and log the changes + data = applier_func(*args, **kwargs) + self.cache_add_attribute(guid, attribute, data) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + trust_dir=None, private_dir=None): + if trust_dir is None: + trust_dir = self.lp.cache_path('certs') + if private_dir is None: + private_dir = self.lp.private_path('certs') + if not os.path.exists(trust_dir): + os.mkdir(trust_dir, mode=0o755) + if not os.path.exists(private_dir): + os.mkdir(private_dir, mode=0o700) + + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for ca_cn_enc, data in settings[str(self)].items(): + self.unapply(guid, ca_cn_enc, data) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\Policies\Microsoft\Cryptography\AutoEnrollment' + pol_file = 'MACHINE/Registry.pol' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + continue + for e in pol_conf.entries: + if e.keyname == section and e.valuename == 'AEPolicy': + # This policy applies as specified in [MS-CAESO] 4.4.5.1 + if e.data & 0x8000: + continue # The policy is disabled + enroll = e.data & 0x1 == 0x1 + manage = e.data & 0x2 == 0x2 + retrive_pending = e.data & 0x4 == 0x4 + if enroll: + ca_names = self.__enroll(gpo.name, + pol_conf.entries, + trust_dir, private_dir) + + # Cleanup any old CAs that have been removed + ca_attrs = [base64.b64encode(n.encode()).decode() \ + for n in ca_names] + self.clean(gpo.name, keep=ca_attrs) + else: + # If enrollment has been disabled for this GPO, + # remove any existing policy + ca_attrs = \ + self.cache_get_all_attribute_values(gpo.name) + self.clean(gpo.name, remove=ca_attrs) + + def __read_cep_data(self, guid, ldb, end_point_information, + trust_dir, private_dir): + """Read CEP Data. + + [MS-CAESO] 4.4.5.3.2.4 + In this step autoenrollment initializes instances of the + CertificateEnrollmentPolicy by accessing end points associated with CEP + groups created in the previous step. + """ + # For each group created in the previous step: + for end_point_group in end_point_information: + # Pick an arbitrary instance of the + # CertificateEnrollmentPolicyEndPoint from the group + e = end_point_group[0] + + # If this instance does not have the AutoEnrollmentEnabled flag set + # in the EndPoint.Flags, continue with the next group. + if not e['Flags'] & 0x10: + continue + + # If the current group contains a + # CertificateEnrollmentPolicyEndPoint instance with EndPoint.URI + # equal to "LDAP": + if any([e['URL'] == 'LDAP:' for e in end_point_group]): + # Perform an LDAP search to read the value of the objectGuid + # attribute of the root object of the forest root domain NC. If + # any errors are encountered, continue with the next group. + res = ldb.search('', SCOPE_BASE, '(objectClass=*)', + ['rootDomainNamingContext']) + if len(res) != 1: + continue + res2 = ldb.search(res[0]['rootDomainNamingContext'][0], + SCOPE_BASE, '(objectClass=*)', + ['objectGUID']) + if len(res2) != 1: + continue + + # Compare the value read in the previous step to the + # EndPoint.PolicyId datum CertificateEnrollmentPolicyEndPoint + # instance. If the values do not match, continue with the next + # group. + objectGUID = '{%s}' % \ + octet_string_to_objectGUID(res2[0]['objectGUID'][0]).upper() + if objectGUID != e['PolicyID']: + continue + + # For each CertificateEnrollmentPolicyEndPoint instance for that + # group: + ca_names = [] + for ca in end_point_group: + # If EndPoint.URI equals "LDAP": + if ca['URL'] == 'LDAP:': + # This is a basic configuration. + cas = fetch_certification_authorities(ldb) + for _ca in cas: + self.apply(guid, _ca, cert_enroll, _ca, ldb, trust_dir, + private_dir) + ca_names.append(_ca['name']) + # If EndPoint.URI starts with "HTTPS//": + elif ca['URL'].lower().startswith('https://'): + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, + private_dir, auth=ca['auth']) + ca_names.append(ca['name']) + else: + edata = { 'endpoint': ca['URL'] } + log.error('Unrecognized endpoint', edata) + return ca_names + + def __enroll(self, guid, entries, trust_dir, private_dir): + url = 'ldap://%s' % get_dc_hostname(self.creds, self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) + + ca_names = [] + end_point_information = obtain_end_point_information(entries) + if len(end_point_information) > 0: + ca_names.extend(self.__read_cep_data(guid, ldb, + end_point_information, + trust_dir, private_dir)) + else: + cas = fetch_certification_authorities(ldb) + for ca in cas: + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, + private_dir) + ca_names.append(ca['name']) + return ca_names + + def rsop(self, gpo): + output = {} + pol_file = 'MACHINE/Registry.pol' + section = 'Software\Policies\Microsoft\Cryptography\AutoEnrollment' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + return output + for e in pol_conf.entries: + if e.keyname == section and e.valuename == 'AEPolicy': + enroll = e.data & 0x1 == 0x1 + if e.data & 0x8000 or not enroll: + continue + output['Auto Enrollment Policy'] = {} + url = 'ldap://%s' % get_dc_hostname(self.creds, self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) + end_point_information = \ + obtain_end_point_information(pol_conf.entries) + cas = fetch_certification_authorities(ldb) + if len(end_point_information) > 0: + cas2 = [ep for sl in end_point_information for ep in sl] + if any([ca['URL'] == 'LDAP:' for ca in cas2]): + cas.extend(cas2) + else: + cas = cas2 + for ca in cas: + if 'URL' in ca and ca['URL'] == 'LDAP:': + continue + policy = 'Auto Enrollment Policy' + cn = ca['name'] + if policy not in output: + output[policy] = {} + output[policy][cn] = {} + if 'cACertificate' in ca: + output[policy][cn]['CA Certificate'] = \ + format_root_cert(ca['cACertificate']).decode() + output[policy][cn]['Auto Enrollment Server'] = \ + ca['hostname'] + supported_templates = \ + get_supported_templates(ca['hostname']) + output[policy][cn]['Templates'] = \ + [t.decode() for t in supported_templates] + return output diff --git a/.github/samba/python/samba/gp/gpclass.py b/.github/samba/python/samba/gp/gpclass.py new file mode 100644 index 000000000..d70643421 --- /dev/null +++ b/.github/samba/python/samba/gp/gpclass.py @@ -0,0 +1,1215 @@ +# Reads important GPO parameters and updates Samba +# Copyright (C) Luke Morrison 2013 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import sys +import os, shutil +import errno +import tdb +import pwd +sys.path.insert(0, "bin/python") +from samba import NTSTATUSError, WERRORError +from configparser import ConfigParser +from io import StringIO +import traceback +from samba.common import get_bytes +from abc import ABCMeta, abstractmethod +import xml.etree.ElementTree as etree +import re +from samba.net import Net +from samba.dcerpc import nbt +from samba.samba3 import libsmb_samba_internal as libsmb +import samba.gpo as gpo +from samba.param import LoadParm +from uuid import UUID +from tempfile import NamedTemporaryFile +from samba.dcerpc import preg +from samba.dcerpc import misc +from samba.ndr import ndr_pack, ndr_unpack +from samba.credentials import SMB_SIGNING_REQUIRED +from samba.gp.util.logging import log +from hashlib import blake2b +import numbers +from samba.common import get_string +from samba.samdb import SamDB +from samba.auth import system_session +import ldb +from samba.dsdb import UF_WORKSTATION_TRUST_ACCOUNT, UF_SERVER_TRUST_ACCOUNT, GPLINK_OPT_ENFORCE, GPLINK_OPT_DISABLE, GPO_INHERIT, GPO_BLOCK_INHERITANCE +from samba.auth import AUTH_SESSION_INFO_DEFAULT_GROUPS, AUTH_SESSION_INFO_AUTHENTICATED, AUTH_SESSION_INFO_SIMPLE_PRIVILEGES +from samba.dcerpc import security +import samba.security +from samba.dcerpc import netlogon + + +try: + from enum import Enum + GPOSTATE = Enum('GPOSTATE', 'APPLY ENFORCE UNAPPLY') +except ImportError: + class GPOSTATE: + APPLY = 1 + ENFORCE = 2 + UNAPPLY = 3 + + +class gp_log: + ''' Log settings overwritten by gpo apply + The gp_log is an xml file that stores a history of gpo changes (and the + original setting value). + + The log is organized like so: + + + + + + + + + -864000000000 + -36288000000000 + 7 + 1 + + + 1d + + 300 + + + + + + Each guid value contains a list of extensions, which contain a list of + attributes. The guid value represents a GPO. The attributes are the values + of those settings prior to the application of the GPO. + The list of guids is enclosed within a user name, which represents the user + the settings were applied to. This user may be the samaccountname of the + local computer, which implies that these are machine policies. + The applylog keeps track of the order in which the GPOs were applied, so + that they can be rolled back in reverse, returning the machine to the state + prior to policy application. + ''' + def __init__(self, user, gpostore, db_log=None): + ''' Initialize the gp_log + param user - the username (or machine name) that policies are + being applied to + param gpostore - the GPOStorage obj which references the tdb which + contains gp_logs + param db_log - (optional) a string to initialize the gp_log + ''' + self._state = GPOSTATE.APPLY + self.gpostore = gpostore + self.username = user + if db_log: + self.gpdb = etree.fromstring(db_log) + else: + self.gpdb = etree.Element('gp') + self.user = user + user_obj = self.gpdb.find('user[@name="%s"]' % user) + if user_obj is None: + user_obj = etree.SubElement(self.gpdb, 'user') + user_obj.attrib['name'] = user + + def state(self, value): + ''' Policy application state + param value - APPLY, ENFORCE, or UNAPPLY + + The behavior of the gp_log depends on whether we are applying policy, + enforcing policy, or unapplying policy. During an apply, old settings + are recorded in the log. During an enforce, settings are being applied + but the gp_log does not change. During an unapply, additions to the log + should be ignored (since function calls to apply settings are actually + reverting policy), but removals from the log are allowed. + ''' + # If we're enforcing, but we've unapplied, apply instead + if value == GPOSTATE.ENFORCE: + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + apply_log = user_obj.find('applylog') + if apply_log is None or len(apply_log) == 0: + self._state = GPOSTATE.APPLY + else: + self._state = value + else: + self._state = value + + def get_state(self): + '''Check the GPOSTATE + ''' + return self._state + + def set_guid(self, guid): + ''' Log to a different GPO guid + param guid - guid value of the GPO from which we're applying + policy + ''' + self.guid = guid + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + obj = user_obj.find('guid[@value="%s"]' % guid) + if obj is None: + obj = etree.SubElement(user_obj, 'guid') + obj.attrib['value'] = guid + if self._state == GPOSTATE.APPLY: + apply_log = user_obj.find('applylog') + if apply_log is None: + apply_log = etree.SubElement(user_obj, 'applylog') + prev = apply_log.find('guid[@value="%s"]' % guid) + if prev is None: + item = etree.SubElement(apply_log, 'guid') + item.attrib['count'] = '%d' % (len(apply_log) - 1) + item.attrib['value'] = guid + + def store(self, gp_ext_name, attribute, old_val): + ''' Store an attribute in the gp_log + param gp_ext_name - Name of the extension applying policy + param attribute - The attribute being modified + param old_val - The value of the attribute prior to policy + application + ''' + if self._state == GPOSTATE.UNAPPLY or self._state == GPOSTATE.ENFORCE: + return None + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is None: + ext = etree.SubElement(guid_obj, 'gp_ext') + ext.attrib['name'] = gp_ext_name + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is None: + attr = etree.SubElement(ext, 'attribute') + attr.attrib['name'] = attribute + attr.text = old_val + + def retrieve(self, gp_ext_name, attribute): + ''' Retrieve a stored attribute from the gp_log + param gp_ext_name - Name of the extension which applied policy + param attribute - The attribute being retrieved + return - The value of the attribute prior to policy + application + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is not None: + return attr.text + return None + + def retrieve_all(self, gp_ext_name): + ''' Retrieve all stored attributes for this user, GPO guid, and CSE + param gp_ext_name - Name of the extension which applied policy + return - The values of the attributes prior to policy + application + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attrs = ext.findall('attribute') + return {attr.attrib['name']: attr.text for attr in attrs} + return {} + + def get_applied_guids(self): + ''' Return a list of applied ext guids + return - List of guids for gpos that have applied settings + to the system. + ''' + guids = [] + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + if user_obj is not None: + apply_log = user_obj.find('applylog') + if apply_log is not None: + guid_objs = apply_log.findall('guid[@count]') + guids_by_count = [(g.get('count'), g.get('value')) + for g in guid_objs] + guids_by_count.sort(reverse=True) + guids.extend(guid for count, guid in guids_by_count) + return guids + + def get_applied_settings(self, guids): + ''' Return a list of applied ext guids + return - List of tuples containing the guid of a gpo, then + a dictionary of policies and their values prior + policy application. These are sorted so that the + most recently applied settings are removed first. + ''' + ret = [] + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + for guid in guids: + guid_settings = user_obj.find('guid[@value="%s"]' % guid) + exts = guid_settings.findall('gp_ext') + settings = {} + for ext in exts: + attr_dict = {} + attrs = ext.findall('attribute') + for attr in attrs: + attr_dict[attr.attrib['name']] = attr.text + settings[ext.attrib['name']] = attr_dict + ret.append((guid, settings)) + return ret + + def delete(self, gp_ext_name, attribute): + ''' Remove an attribute from the gp_log + param gp_ext_name - name of extension from which to remove the + attribute + param attribute - attribute to remove + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is not None: + ext.remove(attr) + if len(ext) == 0: + guid_obj.remove(ext) + + def commit(self): + ''' Write gp_log changes to disk ''' + self.gpostore.store(self.username, etree.tostring(self.gpdb, 'utf-8')) + + +class GPOStorage: + def __init__(self, log_file): + if os.path.isfile(log_file): + self.log = tdb.open(log_file) + else: + self.log = tdb.Tdb(log_file, 0, tdb.DEFAULT, os.O_CREAT | os.O_RDWR) + + def start(self): + self.log.transaction_start() + + def get_int(self, key): + try: + return int(self.log.get(get_bytes(key))) + except TypeError: + return None + + def get(self, key): + return self.log.get(get_bytes(key)) + + def get_gplog(self, user): + return gp_log(user, self, self.log.get(get_bytes(user))) + + def store(self, key, val): + self.log.store(get_bytes(key), get_bytes(val)) + + def cancel(self): + self.log.transaction_cancel() + + def delete(self, key): + self.log.delete(get_bytes(key)) + + def commit(self): + self.log.transaction_commit() + + def __del__(self): + self.log.close() + + +class gp_ext(object): + __metaclass__ = ABCMeta + + def __init__(self, lp, creds, username, store): + self.lp = lp + self.creds = creds + self.username = username + self.gp_db = store.get_gplog(username) + + @abstractmethod + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + pass + + @abstractmethod + def read(self, policy): + pass + + def parse(self, afile): + local_path = self.lp.cache_path('gpo_cache') + data_file = os.path.join(local_path, check_safe_path(afile).upper()) + if os.path.exists(data_file): + return self.read(data_file) + return None + + @abstractmethod + def __str__(self): + pass + + @abstractmethod + def rsop(self, gpo): + return {} + + +class gp_inf_ext(gp_ext): + def read(self, data_file): + policy = open(data_file, 'rb').read() + inf_conf = ConfigParser(interpolation=None) + inf_conf.optionxform = str + try: + inf_conf.readfp(StringIO(policy.decode())) + except UnicodeDecodeError: + inf_conf.readfp(StringIO(policy.decode('utf-16'))) + return inf_conf + + +class gp_pol_ext(gp_ext): + def read(self, data_file): + raw = open(data_file, 'rb').read() + return ndr_unpack(preg.file, raw) + + +class gp_xml_ext(gp_ext): + def read(self, data_file): + raw = open(data_file, 'rb').read() + try: + return etree.fromstring(raw.decode()) + except UnicodeDecodeError: + return etree.fromstring(raw.decode('utf-16')) + + +class gp_applier(object): + '''Group Policy Applier/Unapplier/Modifier + The applier defines functions for monitoring policy application, + removal, and modification. It must be a multi-derived class paired + with a subclass of gp_ext. + ''' + __metaclass__ = ABCMeta + + def cache_add_attribute(self, guid, attribute, value): + '''Add an attribute and value to the Group Policy cache + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being applied + value - The value of the policy being applied + + Normally called by the subclass apply() function after applying policy. + ''' + self.gp_db.set_guid(guid) + self.gp_db.store(str(self), attribute, value) + self.gp_db.commit() + + def cache_remove_attribute(self, guid, attribute): + '''Remove an attribute from the Group Policy cache + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being unapplied + + Normally called by the subclass unapply() function when removing old + policy. + ''' + self.gp_db.set_guid(guid) + self.gp_db.delete(str(self), attribute) + self.gp_db.commit() + + def cache_get_attribute_value(self, guid, attribute): + '''Retrieve the value stored in the cache for the given attribute + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy + ''' + self.gp_db.set_guid(guid) + return self.gp_db.retrieve(str(self), attribute) + + def cache_get_all_attribute_values(self, guid): + '''Retrieve all attribute/values currently stored for this gpo+policy + guid - The GPO guid which applies this policy + ''' + self.gp_db.set_guid(guid) + return self.gp_db.retrieve_all(str(self)) + + def cache_get_apply_state(self): + '''Return the current apply state + return - APPLY|ENFORCE|UNAPPLY + ''' + return self.gp_db.get_state() + + def generate_attribute(self, name, *args): + '''Generate an attribute name from arbitrary data + name - A name to ensure uniqueness + args - Any arbitrary set of args, str or bytes + return - A blake2b digest of the data, the attribute + + The importance here is the digest of the data makes the attribute + reproducible and uniquely identifies it. Hashing the name with + the data ensures we don't falsely identify a match which is the same + text in a different file. Using this attribute generator is optional. + ''' + data = b''.join([get_bytes(arg) for arg in [*args]]) + return blake2b(get_bytes(name)+data).hexdigest() + + def generate_value_hash(self, *args): + '''Generate a unique value which identifies value changes + args - Any arbitrary set of args, str or bytes + return - A blake2b digest of the data, the value represented + ''' + data = b''.join([get_bytes(arg) for arg in [*args]]) + return blake2b(data).hexdigest() + + @abstractmethod + def unapply(self, guid, attribute, value): + '''Group Policy Unapply + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being unapplied + value - The value of the policy being unapplied + ''' + pass + + @abstractmethod + def apply(self, guid, attribute, applier_func, *args): + '''Group Policy Apply + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being applied + applier_func - An applier function which takes variable args + args - The variable arguments to pass to applier_func + + The applier_func function MUST return the value of the policy being + applied. It's important that implementations of `apply` check for and + first unapply any changed policy. See for example calls to + `cache_get_all_attribute_values()` which searches for all policies + applied by this GPO for this Client Side Extension (CSE). + ''' + pass + + def clean(self, guid, keep=None, remove=None, **kwargs): + '''Cleanup old removed attributes + keep - A list of attributes to keep + remove - A single attribute to remove, or a list of attributes to + remove + kwargs - Additional keyword args required by the subclass unapply + function + + This is only necessary for CSEs which provide multiple attributes. + ''' + # Clean syntax is, either provide a single remove attribute, + # or a list of either removal attributes or keep attributes. + if keep is None: + keep = [] + if remove is None: + remove = [] + + if type(remove) != list: + value = self.cache_get_attribute_value(guid, remove) + if value is not None: + self.unapply(guid, remove, value, **kwargs) + else: + old_vals = self.cache_get_all_attribute_values(guid) + for attribute, value in old_vals.items(): + if (len(remove) > 0 and attribute in remove) or \ + (len(keep) > 0 and attribute not in keep): + self.unapply(guid, attribute, value, **kwargs) + + +class gp_misc_applier(gp_applier): + '''Group Policy Miscellaneous Applier/Unapplier/Modifier + ''' + + def generate_value(self, **kwargs): + data = etree.Element('data') + for k, v in kwargs.items(): + arg = etree.SubElement(data, k) + arg.text = get_string(v) + return get_string(etree.tostring(data, 'utf-8')) + + def parse_value(self, value): + vals = {} + try: + data = etree.fromstring(value) + except etree.ParseError: + # If parsing fails, then it's an old cache value + return {'old_val': value} + except TypeError: + return {} + itr = data.iter() + next(itr) # Skip the top element + for item in itr: + vals[item.tag] = item.text + return vals + + +class gp_file_applier(gp_applier): + '''Group Policy File Applier/Unapplier/Modifier + Subclass of abstract class gp_applier for monitoring policy applied + via a file. + ''' + + def __generate_value(self, value_hash, files, sep): + data = [value_hash] + data.extend(files) + return sep.join(data) + + def __parse_value(self, value, sep): + '''Parse a value + return - A unique HASH, followed by the file list + ''' + if value is None: + return None, [] + data = value.split(sep) + if '/' in data[0]: + # The first element is not a hash, but a filename. This is a + # legacy value. + return None, data + else: + return data[0], data[1:] if len(data) > 1 else [] + + def unapply(self, guid, attribute, files, sep=':'): + # If the value isn't a list of files, parse value from the log + if type(files) != list: + _, files = self.__parse_value(files, sep) + for file in files: + if os.path.exists(file): + os.unlink(file) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, attribute, value_hash, applier_func, *args, sep=':'): + ''' + applier_func MUST return a list of files created by the applier. + + This applier is for policies which only apply to a single file (with + a couple small exceptions). This applier will remove any policy applied + by this GPO which doesn't match the new policy. + ''' + # If the policy has changed, unapply, then apply new policy + old_val = self.cache_get_attribute_value(guid, attribute) + # Ignore removal if this policy is applied and hasn't changed + old_val_hash, old_val_files = self.__parse_value(old_val, sep) + if (old_val_hash != value_hash or \ + self.cache_get_apply_state() == GPOSTATE.ENFORCE) or \ + not all([os.path.exists(f) for f in old_val_files]): + self.unapply(guid, attribute, old_val_files) + else: + # If policy is already applied, skip application + return + + # Apply the policy and log the changes + files = applier_func(*args) + new_value = self.__generate_value(value_hash, files, sep) + self.cache_add_attribute(guid, attribute, new_value) + + +''' Fetch the hostname of a writable DC ''' + + +def get_dc_hostname(creds, lp): + net = Net(creds=creds, lp=lp) + cldap_ret = net.finddc(domain=lp.get('realm'), flags=(nbt.NBT_SERVER_LDAP | + nbt.NBT_SERVER_DS)) + return cldap_ret.pdc_dns_name + +def get_dc_netbios_hostname(creds, lp): + net = Net(creds=creds, lp=lp) + cldap_ret = net.finddc(domain=lp.get('realm'), flags=(nbt.NBT_SERVER_LDAP | + nbt.NBT_SERVER_DS)) + return cldap_ret.pdc_name + + +''' Fetch a list of GUIDs for applicable GPOs ''' + + +def get_gpo(samdb, gpo_dn): + g = gpo.GROUP_POLICY_OBJECT() + attrs = [ + "cn", + "displayName", + "flags", + "gPCFileSysPath", + "gPCFunctionalityVersion", + "gPCMachineExtensionNames", + "gPCUserExtensionNames", + "gPCWQLFilter", + "name", + "nTSecurityDescriptor", + "versionNumber" + ] + if gpo_dn.startswith("LDAP://"): + gpo_dn = gpo_dn.lstrip("LDAP://") + + sd_flags = (security.SECINFO_OWNER | + security.SECINFO_GROUP | + security.SECINFO_DACL) + try: + res = samdb.search(gpo_dn, ldb.SCOPE_BASE, "(objectclass=*)", attrs, + controls=['sd_flags:1:%d' % sd_flags]) + except Exception: + log.error('Failed to fetch gpo object with nTSecurityDescriptor') + raise + if res.count != 1: + raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, + 'get_gpo: search failed') + + g.ds_path = gpo_dn + if 'versionNumber' in res.msgs[0].keys(): + g.version = int(res.msgs[0]['versionNumber'][0]) + if 'flags' in res.msgs[0].keys(): + g.options = int(res.msgs[0]['flags'][0]) + if 'gPCFileSysPath' in res.msgs[0].keys(): + g.file_sys_path = res.msgs[0]['gPCFileSysPath'][0].decode() + if 'displayName' in res.msgs[0].keys(): + g.display_name = res.msgs[0]['displayName'][0].decode() + if 'name' in res.msgs[0].keys(): + g.name = res.msgs[0]['name'][0].decode() + if 'gPCMachineExtensionNames' in res.msgs[0].keys(): + g.machine_extensions = str(res.msgs[0]['gPCMachineExtensionNames'][0]) + if 'gPCUserExtensionNames' in res.msgs[0].keys(): + g.user_extensions = str(res.msgs[0]['gPCUserExtensionNames'][0]) + if 'nTSecurityDescriptor' in res.msgs[0].keys(): + g.set_sec_desc(bytes(res.msgs[0]['nTSecurityDescriptor'][0])) + return g + +class GP_LINK: + def __init__(self, gPLink, gPOptions): + self.link_names = [] + self.link_opts = [] + self.gpo_parse_gplink(gPLink) + self.gp_opts = int(gPOptions) + + def gpo_parse_gplink(self, gPLink): + for p in gPLink.decode().split(']'): + if not p: + continue + log.debug('gpo_parse_gplink: processing link') + p = p.lstrip('[') + link_name, link_opt = p.split(';') + log.debug('gpo_parse_gplink: link: {}'.format(link_name)) + log.debug('gpo_parse_gplink: opt: {}'.format(link_opt)) + self.link_names.append(link_name) + self.link_opts.append(int(link_opt)) + + def num_links(self): + if len(self.link_names) != len(self.link_opts): + raise RuntimeError('Link names and opts mismatch') + return len(self.link_names) + +def find_samaccount(samdb, samaccountname): + attrs = ['dn', 'userAccountControl'] + res = samdb.search(samdb.get_default_basedn(), ldb.SCOPE_SUBTREE, + '(sAMAccountName={})'.format(samaccountname), attrs) + if res.count != 1: + raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, + "Failed to find samAccountName '{}'".format(samaccountname) + ) + uac = int(res.msgs[0]['userAccountControl'][0]) + dn = res.msgs[0]['dn'] + log.info('Found dn {} for samaccountname {}'.format(dn, samaccountname)) + return uac, dn + +def get_gpo_link(samdb, link_dn): + res = samdb.search(link_dn, ldb.SCOPE_BASE, + '(objectclass=*)', ['gPLink', 'gPOptions']) + if res.count != 1: + raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, 'get_gpo_link: no result') + if not 'gPLink' in res.msgs[0]: + raise ldb.LdbError(ldb.ERR_NO_SUCH_ATTRIBUTE, + "get_gpo_link: no 'gPLink' attribute found for '{}'".format(link_dn) + ) + gPLink = res.msgs[0]['gPLink'][0] + gPOptions = 0 + if 'gPOptions' in res.msgs[0]: + gPOptions = res.msgs[0]['gPOptions'][0] + else: + log.debug("get_gpo_link: no 'gPOptions' attribute found") + return GP_LINK(gPLink, gPOptions) + +def add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, link_dn, gp_link, + link_type, only_add_forced_gpos, token): + for i in range(gp_link.num_links()-1, -1, -1): + is_forced = (gp_link.link_opts[i] & GPLINK_OPT_ENFORCE) != 0 + if gp_link.link_opts[i] & GPLINK_OPT_DISABLE: + log.debug('skipping disabled GPO') + continue + + if only_add_forced_gpos: + if not is_forced: + log.debug("skipping nonenforced GPO link " + "because GPOPTIONS_BLOCK_INHERITANCE " + "has been set") + continue + else: + log.debug("adding enforced GPO link although " + "the GPOPTIONS_BLOCK_INHERITANCE " + "has been set") + + try: + new_gpo = get_gpo(samdb, gp_link.link_names[i]) + except ldb.LdbError as e: + (enum, estr) = e.args + log.debug("failed to get gpo: %s" % gp_link.link_names[i]) + if enum == ldb.ERR_NO_SUCH_OBJECT: + log.debug("skipping empty gpo: %s" % gp_link.link_names[i]) + continue + return + else: + try: + sec_desc = ndr_unpack(security.descriptor, + new_gpo.get_sec_desc_buf()) + samba.security.access_check(sec_desc, token, + security.SEC_STD_READ_CONTROL | + security.SEC_ADS_LIST | + security.SEC_ADS_READ_PROP) + except Exception as e: + log.debug("skipping GPO \"%s\" as object " + "has no access to it" % new_gpo.display_name) + continue + + new_gpo.link = str(link_dn) + new_gpo.link_type = link_type + + if is_forced: + forced_gpo_list.insert(0, new_gpo) + else: + gpo_list.insert(0, new_gpo) + + log.debug("add_gplink_to_gpo_list: added GPLINK #%d %s " + "to GPO list" % (i, gp_link.link_names[i])) + +def merge_nt_token(token_1, token_2): + sids = token_1.sids + sids.extend(token_2.sids) + token_1.sids = sids + token_1.rights_mask |= token_2.rights_mask + token_1.privilege_mask |= token_2.privilege_mask + return token_1 + +def site_dn_for_machine(samdb, dc_hostname, lp, creds, hostname): + # [MS-GPOL] 3.2.5.1.4 Site Search + config_context = samdb.get_config_basedn() + try: + c = netlogon.netlogon("ncacn_np:%s[seal]" % dc_hostname, lp, creds) + site_name = c.netr_DsRGetSiteName(hostname) + return 'CN={},CN=Sites,{}'.format(site_name, config_context) + except WERRORError: + # Fallback to the old method found in ads_site_dn_for_machine + nb_hostname = get_dc_netbios_hostname(creds, lp) + res = samdb.search(config_context, ldb.SCOPE_SUBTREE, + "(cn=%s)" % nb_hostname, ['dn']) + if res.count != 1: + raise ldb.LdbError(ldb.ERR_NO_SUCH_OBJECT, + 'site_dn_for_machine: no result') + dn = res.msgs[0]['dn'] + site_dn = dn.parent().parent() + return site_dn + +def get_gpo_list(dc_hostname, creds, lp, username): + '''Get the full list of GROUP_POLICY_OBJECTs for a given username. + Push GPOs to gpo_list so that the traversal order of the list matches + the order of application: + (L)ocal (S)ite (D)omain (O)rganizational(U)nit + For different domains and OUs: parent-to-child. + Within same level of domains and OUs: Link order. + Since GPOs are pushed to the front of gpo_list, GPOs have to be + pushed in the opposite order of application (OUs first, local last, + child-to-parent). + Forced GPOs are appended in the end since they override all others. + ''' + gpo_list = [] + forced_gpo_list = [] + url = 'ldap://' + dc_hostname + samdb = SamDB(url=url, + session_info=system_session(), + credentials=creds, lp=lp) + # username is DOM\\SAM, but get_gpo_list expects SAM + uac, dn = find_samaccount(samdb, username.split('\\')[-1]) + add_only_forced_gpos = False + + # Fetch the security token + session_info_flags = (AUTH_SESSION_INFO_DEFAULT_GROUPS | + AUTH_SESSION_INFO_AUTHENTICATED) + if url.startswith('ldap'): + session_info_flags |= AUTH_SESSION_INFO_SIMPLE_PRIVILEGES + session = samba.auth.user_session(samdb, lp_ctx=lp, dn=dn, + session_info_flags=session_info_flags) + gpo_list_machine = False + if uac & UF_WORKSTATION_TRUST_ACCOUNT or uac & UF_SERVER_TRUST_ACCOUNT: + gpo_list_machine = True + token = merge_nt_token(session.security_token, + system_session().security_token) + else: + token = session.security_token + + # (O)rganizational(U)nit + parent_dn = dn.parent() + while True: + if str(parent_dn) == str(samdb.get_default_basedn().parent()): + break + + # An account can be a member of more OUs + if parent_dn.get_component_name(0) == 'OU': + try: + log.debug("get_gpo_list: query OU: [%s] for GPOs" % parent_dn) + gp_link = get_gpo_link(samdb, parent_dn) + except ldb.LdbError as e: + (enum, estr) = e.args + log.debug(estr) + else: + add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, + parent_dn, gp_link, + gpo.GP_LINK_OU, + add_only_forced_gpos, token) + + # block inheritance from now on + if gp_link.gp_opts & GPO_BLOCK_INHERITANCE: + add_only_forced_gpos = True + + parent_dn = parent_dn.parent() + + # (D)omain + parent_dn = dn.parent() + while True: + if str(parent_dn) == str(samdb.get_default_basedn().parent()): + break + + # An account can just be a member of one domain + if parent_dn.get_component_name(0) == 'DC': + try: + log.debug("get_gpo_list: query DC: [%s] for GPOs" % parent_dn) + gp_link = get_gpo_link(samdb, parent_dn) + except ldb.LdbError as e: + (enum, estr) = e.args + log.debug(estr) + else: + add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, + parent_dn, gp_link, + gpo.GP_LINK_DOMAIN, + add_only_forced_gpos, token) + + # block inheritance from now on + if gp_link.gp_opts & GPO_BLOCK_INHERITANCE: + add_only_forced_gpos = True + + parent_dn = parent_dn.parent() + + # (S)ite + if gpo_list_machine: + site_dn = site_dn_for_machine(samdb, dc_hostname, lp, creds, username) + + try: + log.debug("get_gpo_list: query SITE: [%s] for GPOs" % site_dn) + gp_link = get_gpo_link(samdb, site_dn) + except ldb.LdbError as e: + (enum, estr) = e.args + log.debug(estr) + else: + add_gplink_to_gpo_list(samdb, gpo_list, forced_gpo_list, + site_dn, gp_link, + gpo.GP_LINK_SITE, + add_only_forced_gpos, token) + + # (L)ocal + gpo_list.insert(0, gpo.GROUP_POLICY_OBJECT("Local Policy", + "Local Policy", + gpo.GP_LINK_LOCAL)) + + # Append |forced_gpo_list| at the end of |gpo_list|, + # so that forced GPOs are applied on top of non enforced GPOs. + return gpo_list+forced_gpo_list + + +def cache_gpo_dir(conn, cache, sub_dir): + loc_sub_dir = sub_dir.upper() + local_dir = os.path.join(cache, loc_sub_dir) + try: + os.makedirs(local_dir, mode=0o755) + except OSError as e: + if e.errno != errno.EEXIST: + raise + for fdata in conn.list(sub_dir): + if fdata['attrib'] & libsmb.FILE_ATTRIBUTE_DIRECTORY: + cache_gpo_dir(conn, cache, os.path.join(sub_dir, fdata['name'])) + else: + local_name = fdata['name'].upper() + f = NamedTemporaryFile(delete=False, dir=local_dir) + fname = os.path.join(sub_dir, fdata['name']).replace('/', '\\') + f.write(conn.loadfile(fname)) + f.close() + os.rename(f.name, os.path.join(local_dir, local_name)) + + +def check_safe_path(path): + dirs = re.split('/|\\\\', path) + if 'sysvol' in path.lower(): + ldirs = re.split('/|\\\\', path.lower()) + dirs = dirs[ldirs.index('sysvol') + 1:] + if '..' not in dirs: + return os.path.join(*dirs) + raise OSError(path) + + +def check_refresh_gpo_list(dc_hostname, lp, creds, gpos): + # Force signing for the connection + saved_signing_state = creds.get_smb_signing() + creds.set_smb_signing(SMB_SIGNING_REQUIRED) + conn = libsmb.Conn(dc_hostname, 'sysvol', lp=lp, creds=creds) + # Reset signing state + creds.set_smb_signing(saved_signing_state) + cache_path = lp.cache_path('gpo_cache') + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + cache_gpo_dir(conn, cache_path, check_safe_path(gpo_obj.file_sys_path)) + + +def get_deleted_gpos_list(gp_db, gpos): + applied_gpos = gp_db.get_applied_guids() + current_guids = set([p.name for p in gpos]) + deleted_gpos = [guid for guid in applied_gpos if guid not in current_guids] + return gp_db.get_applied_settings(deleted_gpos) + +def gpo_version(lp, path): + # gpo.gpo_get_sysvol_gpt_version() reads the GPT.INI from a local file, + # read from the gpo client cache. + gpt_path = lp.cache_path(os.path.join('gpo_cache', path)) + return int(gpo.gpo_get_sysvol_gpt_version(gpt_path)[1]) + + +def apply_gp(lp, creds, store, gp_extensions, username, target, force=False): + gp_db = store.get_gplog(username) + dc_hostname = get_dc_hostname(creds, lp) + gpos = get_gpo_list(dc_hostname, creds, lp, username) + del_gpos = get_deleted_gpos_list(gp_db, gpos) + try: + check_refresh_gpo_list(dc_hostname, lp, creds, gpos) + except: + log.error('Failed downloading gpt cache from \'%s\' using SMB' + % dc_hostname) + return + + if force: + changed_gpos = gpos + gp_db.state(GPOSTATE.ENFORCE) + else: + changed_gpos = [] + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + guid = gpo_obj.name + path = check_safe_path(gpo_obj.file_sys_path).upper() + version = gpo_version(lp, path) + if version != store.get_int(guid): + log.info('GPO %s has changed' % guid) + changed_gpos.append(gpo_obj) + gp_db.state(GPOSTATE.APPLY) + + store.start() + for ext in gp_extensions: + try: + ext = ext(lp, creds, username, store) + if target == 'Computer': + ext.process_group_policy(del_gpos, changed_gpos) + else: + drop_privileges(username, ext.process_group_policy, + del_gpos, changed_gpos) + except Exception as e: + log.error('Failed to apply extension %s' % str(ext)) + _, _, tb = sys.exc_info() + filename, line_number, _, _ = traceback.extract_tb(tb)[-1] + log.error('%s:%d: %s: %s' % (filename, line_number, + type(e).__name__, str(e))) + continue + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + guid = gpo_obj.name + path = check_safe_path(gpo_obj.file_sys_path).upper() + version = gpo_version(lp, path) + store.store(guid, '%i' % version) + store.commit() + + +def unapply_gp(lp, creds, store, gp_extensions, username, target): + gp_db = store.get_gplog(username) + gp_db.state(GPOSTATE.UNAPPLY) + # Treat all applied gpos as deleted + del_gpos = gp_db.get_applied_settings(gp_db.get_applied_guids()) + store.start() + for ext in gp_extensions: + try: + ext = ext(lp, creds, username, store) + if target == 'Computer': + ext.process_group_policy(del_gpos, []) + else: + drop_privileges(username, ext.process_group_policy, + del_gpos, []) + except Exception as e: + log.error('Failed to unapply extension %s' % str(ext)) + log.error('Message was: ' + str(e)) + continue + store.commit() + + +def __rsop_vals(vals, level=4): + if type(vals) == dict: + ret = [' '*level + '[ %s ] = %s' % (k, __rsop_vals(v, level+2)) + for k, v in vals.items()] + return '\n' + '\n'.join(ret) + elif type(vals) == list: + ret = [' '*level + '[ %s ]' % __rsop_vals(v, level+2) for v in vals] + return '\n' + '\n'.join(ret) + else: + if isinstance(vals, numbers.Number): + return ' '*(level+2) + str(vals) + else: + return ' '*(level+2) + get_string(vals) + +def rsop(lp, creds, store, gp_extensions, username, target): + dc_hostname = get_dc_hostname(creds, lp) + gpos = get_gpo_list(dc_hostname, creds, lp, username) + check_refresh_gpo_list(dc_hostname, lp, creds, gpos) + + print('Resultant Set of Policy') + print('%s Policy\n' % target) + term_width = shutil.get_terminal_size(fallback=(120, 50))[0] + for gpo_obj in gpos: + if gpo_obj.display_name.strip() == 'Local Policy': + continue # We never apply local policy + print('GPO: %s' % gpo_obj.display_name) + print('='*term_width) + for ext in gp_extensions: + ext = ext(lp, creds, username, store) + cse_name_m = re.findall(r"'([\w\.]+)'", str(type(ext))) + if len(cse_name_m) > 0: + cse_name = cse_name_m[-1].split('.')[-1] + else: + cse_name = ext.__module__.split('.')[-1] + print(' CSE: %s' % cse_name) + print(' ' + ('-'*int(term_width/2))) + for section, settings in ext.rsop(gpo_obj).items(): + print(' Policy Type: %s' % section) + print(' ' + ('-'*int(term_width/2))) + print(__rsop_vals(settings).lstrip('\n')) + print(' ' + ('-'*int(term_width/2))) + print(' ' + ('-'*int(term_width/2))) + print('%s\n' % ('='*term_width)) + + +def parse_gpext_conf(smb_conf): + from samba.samba3 import param as s3param + lp = s3param.get_context() + if smb_conf is not None: + lp.load(smb_conf) + else: + lp.load_default() + ext_conf = lp.state_path('gpext.conf') + parser = ConfigParser(interpolation=None) + parser.read(ext_conf) + return lp, parser + + +def atomic_write_conf(lp, parser): + ext_conf = lp.state_path('gpext.conf') + with NamedTemporaryFile(mode="w+", delete=False, dir=os.path.dirname(ext_conf)) as f: + parser.write(f) + os.rename(f.name, ext_conf) + + +def check_guid(guid): + # Check for valid guid with curly braces + if guid[0] != '{' or guid[-1] != '}' or len(guid) != 38: + return False + try: + UUID(guid, version=4) + except ValueError: + return False + return True + + +def register_gp_extension(guid, name, path, + smb_conf=None, machine=True, user=True): + # Check that the module exists + if not os.path.exists(path): + return False + if not check_guid(guid): + return False + + lp, parser = parse_gpext_conf(smb_conf) + if guid not in parser.sections(): + parser.add_section(guid) + parser.set(guid, 'DllName', path) + parser.set(guid, 'ProcessGroupPolicy', name) + parser.set(guid, 'NoMachinePolicy', "0" if machine else "1") + parser.set(guid, 'NoUserPolicy', "0" if user else "1") + + atomic_write_conf(lp, parser) + + return True + + +def list_gp_extensions(smb_conf=None): + _, parser = parse_gpext_conf(smb_conf) + results = {} + for guid in parser.sections(): + results[guid] = {} + results[guid]['DllName'] = parser.get(guid, 'DllName') + results[guid]['ProcessGroupPolicy'] = \ + parser.get(guid, 'ProcessGroupPolicy') + results[guid]['MachinePolicy'] = \ + not int(parser.get(guid, 'NoMachinePolicy')) + results[guid]['UserPolicy'] = not int(parser.get(guid, 'NoUserPolicy')) + return results + + +def unregister_gp_extension(guid, smb_conf=None): + if not check_guid(guid): + return False + + lp, parser = parse_gpext_conf(smb_conf) + if guid in parser.sections(): + parser.remove_section(guid) + + atomic_write_conf(lp, parser) + + return True + + +def set_privileges(username, uid, gid): + ''' + Set current process privileges + ''' + + os.setegid(gid) + os.seteuid(uid) + + +def drop_privileges(username, func, *args): + ''' + Run supplied function with privileges for specified username. + ''' + current_uid = os.getuid() + + if not current_uid == 0: + raise Exception('Not enough permissions to drop privileges') + + user_uid = pwd.getpwnam(username).pw_uid + user_gid = pwd.getpwnam(username).pw_gid + + # Drop privileges + set_privileges(username, user_uid, user_gid) + + # We need to catch exception in order to be able to restore + # privileges later in this function + out = None + exc = None + try: + out = func(*args) + except Exception as e: + exc = e + + # Restore privileges + set_privileges('root', current_uid, 0) + + if exc: + raise exc + + return out diff --git a/.github/samba/python/samba/gp/util/logging.py b/.github/samba/python/samba/gp/util/logging.py new file mode 100644 index 000000000..a74a8707d --- /dev/null +++ b/.github/samba/python/samba/gp/util/logging.py @@ -0,0 +1,112 @@ +# +# samba-gpupdate enhanced logging +# +# Copyright (C) 2019-2020 BaseALT Ltd. +# Copyright (C) David Mulder 2022 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import datetime +import logging +import gettext +import random +import sys + +logger = logging.getLogger() +def logger_init(name, log_level): + logger = logging.getLogger(name) + logger.addHandler(logging.StreamHandler(sys.stdout)) + logger.setLevel(logging.CRITICAL) + if log_level == 1: + logger.setLevel(logging.ERROR) + elif log_level == 2: + logger.setLevel(logging.WARNING) + elif log_level == 3: + logger.setLevel(logging.INFO) + elif log_level >= 4: + logger.setLevel(logging.DEBUG) + +class slogm(object): + ''' + Structured log message class + ''' + def __init__(self, message, kwargs=None): + if kwargs is None: + kwargs = {} + self.message = message + self.kwargs = kwargs + if not isinstance(self.kwargs, dict): + self.kwargs = { 'val': self.kwargs } + + def __str__(self): + now = str(datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')) + args = dict() + args.update(self.kwargs) + result = '{}|{} | {}'.format(now, self.message, args) + + return result + +def message_with_code(mtype, message): + random.seed(message) + code = random.randint(0, 99999) + return '[' + mtype + str(code).rjust(5, '0') + ']| ' + \ + gettext.gettext(message) + +class log(object): + @staticmethod + def info(message, data=None): + if data is None: + data = {} + msg = message_with_code('I', message) + logger.info(slogm(msg, data)) + return msg + + @staticmethod + def warning(message, data=None): + if data is None: + data = {} + msg = message_with_code('W', message) + logger.warning(slogm(msg, data)) + return msg + + @staticmethod + def warn(message, data=None): + if data is None: + data = {} + return log.warning(message, data) + + @staticmethod + def error(message, data=None): + if data is None: + data = {} + msg = message_with_code('E', message) + logger.error(slogm(msg, data)) + return msg + + @staticmethod + def fatal(message, data=None): + if data is None: + data = {} + msg = message_with_code('F', message) + logger.fatal(slogm(msg, data)) + return msg + + @staticmethod + def debug(message, data=None): + if data is None: + data = {} + msg = message_with_code('D', message) + logger.debug(slogm(msg, data)) + return msg diff --git a/.github/workflows/patch-vendored-samba.yaml b/.github/workflows/patch-vendored-samba.yaml new file mode 100644 index 000000000..bf9c0c47f --- /dev/null +++ b/.github/workflows/patch-vendored-samba.yaml @@ -0,0 +1,91 @@ +name: Patch vendored Samba code + +on: + schedule: + - cron: '0 9 * * 1' # run on a weekly cadence + workflow_dispatch: + +env: + checkout_files: | + python/samba/gp/gp_cert_auto_enroll_ext.py + python/samba/gp/gpclass.py + python/samba/gp/util/logging.py + +jobs: + check-for-changes: + name: Check for changes in vendored code + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.compute-diff.outputs.changed }} + samba-ref: ${{ steps.compute-diff.outputs.samba-ref }} + steps: + - uses: actions/checkout@v3 + - name: Checkout Samba files + uses: actions/checkout@v3 + with: + repository: samba-team/samba + sparse-checkout: ${{ env.checkout_files }} + sparse-checkout-cone-mode: false + path: samba-git + - name: Check for changes + id: compute-diff + run: | + echo "samba-ref=$(git -C samba-git rev-parse HEAD)" >> $GITHUB_OUTPUT + for file in $checkout_files; do + if ! diff -q samba-git/$file .github/samba/$file; then + echo "changed=true" >> $GITHUB_OUTPUT + break + fi + done + - name: Upload + if: ${{ steps.compute-diff.outputs.changed == 'true' }} + uses: actions/upload-artifact@v3 + with: + name: samba + path: | + samba-git + !samba-git/.git + + patch-vendored-code: + name: Patch vendored code + runs-on: ubuntu-latest + needs: check-for-changes + if: ${{ needs.check-for-changes.outputs.changed == 'true' }} + steps: + - uses: actions/checkout@v3 + - name: Replace with updated Samba source + uses: actions/download-artifact@v3 + with: + path: .github + - name: Prepare patch working directory + run: cp -a .github/samba samba-patched + - name: Prepare pull request body + run: echo 'Automated changes to vendored Samba code - [`${{ needs.check-for-changes.outputs.samba-ref }}`](https://github.com/samba-team/samba/tree/${{ needs.check-for-changes.outputs.samba-ref }})' > samba-patched/pr-body + - name: Apply patch series + run: patch -f -d samba-patched -r rejected --no-backup-if-mismatch -p1 < <(cat .github/samba/_patches/*.patch) + - name: Add rejected hunks to PR body + if: ${{ failure() }} + run: | + if [ -f samba-patched/rejected ]; then + echo "### Rejected hunks:" >> samba-patched/pr-body + echo '```patch' >> samba-patched/pr-body + cat samba-patched/rejected >> samba-patched/pr-body + echo '```' >> samba-patched/pr-body + else + echo "No rejected hunks, please check job output for failure details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> samba-patched/pr-body + fi + - name: Replace vendored code + run: cp -a samba-patched/python/samba/* internal/policies/certificate/python/vendor_samba + - name: Create Pull Request + if: ${{ always() }} + uses: peter-evans/create-pull-request@v5 + with: + commit-message: Auto update vendored Samba code + title: Auto update vendored Samba code + labels: automated pr + body-path: samba-patched/pr-body + add-paths: | + .github/samba/ + internal/policies/certificate/python/vendor_samba/ + branch: auto-update-samba + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 1c643a379..9100b71dc 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ debian/adsys-windows node_modules package-lock.json package.json +samba-patched/