From ac8b9f16fe896a8497cb8b2e6f2e5c1e29b115ed Mon Sep 17 00:00:00 2001 From: Gabriel Nagy Date: Mon, 14 Aug 2023 13:29:43 +0300 Subject: [PATCH 1/2] Vendor required files from samba#5cb8805811 For each of the vendored and modified Samba files in `internal/policies/certificate`, add the unmodified versions from the official Samba repository, alongside the set of patches needed to bring them to the "final" state. --- ...01-Revert-gpclass.py-to-65ab33dffab2.patch | 415 ++++++ ...p-Make-global-trust-dir-configurable.patch | 114 ++ ...gp-Change-root-cert-extension-suffix.patch | 30 + .github/samba/_patches/0004-gp-wip.patch | 57 + ...-gp-update-samba-imports-to-vendored.patch | 52 + .../samba/gp/gp_cert_auto_enroll_ext.py | 532 ++++++++ .github/samba/python/samba/gp/gpclass.py | 1215 +++++++++++++++++ .github/samba/python/samba/gp/util/logging.py | 112 ++ 8 files changed, 2527 insertions(+) create mode 100644 .github/samba/_patches/0001-Revert-gpclass.py-to-65ab33dffab2.patch create mode 100644 .github/samba/_patches/0002-gp-Make-global-trust-dir-configurable.patch create mode 100644 .github/samba/_patches/0003-gp-Change-root-cert-extension-suffix.patch create mode 100644 .github/samba/_patches/0004-gp-wip.patch create mode 100644 .github/samba/_patches/0005-gp-update-samba-imports-to-vendored.patch create mode 100644 .github/samba/python/samba/gp/gp_cert_auto_enroll_ext.py create mode 100644 .github/samba/python/samba/gp/gpclass.py create mode 100644 .github/samba/python/samba/gp/util/logging.py 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 From ff32d16399224916c43c3862a3c78fbe306edf36 Mon Sep 17 00:00:00 2001 From: Gabriel Nagy Date: Mon, 14 Aug 2023 14:27:30 +0300 Subject: [PATCH 2/2] Workflow to auto-patch vendored Samba code Since our vendored Samba code has a few patches, a good way to ensure we keep it in sync with upstream is to apply an auto-patch workflow where we take the original files and apply a series of patches to get the files to the final state. This way we ensure that we don't miss any of the changes that happen upstream. The implementation is self-explanatory for the most part, taking inspiration from our other automated PR workflows. Auto-merging is disabled to give maintainers the opportunity to review (and test) the changes before merging anything in. This means, in addition to vendoring the "final" versions in `./internal/policies/certificate`, we also need to vendor the upstream versions, and the series of patches to apply. As there's no reliable way to trigger this workflow only on upstream code changes (i.e. webhooks), the next best thing is to have the workflow run on schedule. We don't expect changes to the vendored part of our codebase given that it's been around 5-6 months since the last commits, so running it on a weekly cadence should suffice. I'm also leaving the `workflow_dispatch` trigger on in case we want to run it on demand. If the patching fails, the PR body will contain the hunks that failed to apply. In this case, the developer is expected to manually perform the actions of the workflow, updating the patches so they are applicable to the new Samba version. Fixes UDENG-1113 --- .github/workflows/patch-vendored-samba.yaml | 91 +++++++++++++++++++++ .gitignore | 1 + 2 files changed, 92 insertions(+) create mode 100644 .github/workflows/patch-vendored-samba.yaml 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/