diff options
Diffstat (limited to 'python/samba/gp')
25 files changed, 5405 insertions, 0 deletions
diff --git a/python/samba/gp/__init__.py b/python/samba/gp/__init__.py new file mode 100644 index 0000000..af6e639 --- /dev/null +++ b/python/samba/gp/__init__.py @@ -0,0 +1,17 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) David Mulder <dmulder@samba.org> 2023 +# +# 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 <http://www.gnu.org/licenses/>. + +from samba.gp.gpclass import get_gpo_list diff --git a/python/samba/gp/gp_centrify_crontab_ext.py b/python/samba/gp/gp_centrify_crontab_ext.py new file mode 100644 index 0000000..b1055a1 --- /dev/null +++ b/python/samba/gp/gp_centrify_crontab_ext.py @@ -0,0 +1,135 @@ +# gp_centrify_crontab_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_pol_ext, drop_privileges, gp_file_applier, \ + gp_misc_applier +from tempfile import NamedTemporaryFile +from samba.gp.gp_scripts_ext import fetch_crontab, install_user_crontab + +intro = ''' +### autogenerated by samba +# +# This file is generated by the gp_centrify_crontab_ext Group Policy +# Client Side Extension. To modify the contents of this file, +# modify the appropriate Group Policy objects which apply +# to this machine. DO NOT MODIFY THIS FILE DIRECTLY. +# + +''' +end = ''' +### autogenerated by samba ### +''' + +class gp_centrify_crontab_ext(gp_pol_ext, gp_file_applier): + def __str__(self): + return 'Centrify/CrontabEntries' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + cdir=None): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, script in settings[str(self)].items(): + self.unapply(guid, attribute, script) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = \ + 'Software\\Policies\\Centrify\\UnixSettings\\CrontabEntries' + 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 + entries = [] + for e in pol_conf.entries: + if e.keyname == section and e.data.strip(): + entries.append(e.data) + def applier_func(entries): + cron_dir = '/etc/cron.d' if not cdir else cdir + with NamedTemporaryFile(prefix='gp_', mode="w+", + delete=False, dir=cron_dir) as f: + contents = intro + for entry in entries: + contents += '%s\n' % entry + contents += end + f.write(contents) + return [f.name] + attribute = self.generate_attribute(gpo.name) + value_hash = self.generate_value_hash(*entries) + self.apply(gpo.name, attribute, value_hash, applier_func, + entries) + + # Remove scripts for this GPO which are no longer applied + self.clean(gpo.name, keep=attribute) + + def rsop(self, gpo, target='MACHINE'): + output = {} + section = 'Software\\Policies\\Centrify\\UnixSettings\\CrontabEntries' + pol_file = '%s/Registry.pol' % target + 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.data.strip(): + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append(e.data) + return output + +class gp_user_centrify_crontab_ext(gp_centrify_crontab_ext, gp_misc_applier): + def unapply(self, guid, attribute, entry): + others, entries = fetch_crontab(self.username) + if entry in entries: + entries.remove(entry) + install_user_crontab(self.username, others, entries) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, attribute, entry): + old_val = self.cache_get_attribute_value(guid, attribute) + others, entries = fetch_crontab(self.username) + if not old_val or entry not in entries: + entries.append(entry) + install_user_crontab(self.username, others, entries) + self.cache_add_attribute(guid, attribute, entry) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, entry in settings[str(self)].items(): + self.unapply(guid, attribute, entry) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = \ + 'Software\\Policies\\Centrify\\UnixSettings\\CrontabEntries' + pol_file = 'USER/Registry.pol' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = drop_privileges('root', self.parse, path) + if not pol_conf: + continue + attrs = [] + for e in pol_conf.entries: + if e.keyname == section and e.data.strip(): + attribute = self.generate_attribute(e.data) + attrs.append(attribute) + self.apply(gpo.name, attribute, e.data) + self.clean(gpo.name, keep=attrs) + + def rsop(self, gpo): + return super().rsop(gpo, target='USER') diff --git a/python/samba/gp/gp_centrify_sudoers_ext.py b/python/samba/gp/gp_centrify_sudoers_ext.py new file mode 100644 index 0000000..4752f1e --- /dev/null +++ b/python/samba/gp/gp_centrify_sudoers_ext.py @@ -0,0 +1,80 @@ +# gp_centrify_sudoers_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_pol_ext, gp_file_applier +from samba.gp.gp_sudoers_ext import sudo_applier_func + +def ext_enabled(entries): + section = 'Software\\Policies\\Centrify\\UnixSettings' + for e in entries: + if e.keyname == section and e.valuename == 'sudo.enabled': + return e.data == 1 + return False + +class gp_centrify_sudoers_ext(gp_pol_ext, gp_file_applier): + def __str__(self): + return 'Centrify/Sudo Rights' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + sdir='/etc/sudoers.d'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, sudoers in settings[str(self)].items(): + self.unapply(guid, attribute, sudoers) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\\Policies\\Centrify\\UnixSettings\\SuDo' + pol_file = 'MACHINE/Registry.pol' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf or not ext_enabled(pol_conf.entries): + continue + sudo_entries = [] + for e in pol_conf.entries: + if e.keyname == section and e.data.strip(): + if '**delvals.' in e.valuename: + continue + sudo_entries.append(e.data) + # Each GPO applies only one set of sudoers, in a + # set of files, so the attribute does not need uniqueness. + attribute = self.generate_attribute(gpo.name, *sudo_entries) + # The value hash is generated from the sudo_entries, ensuring + # any changes to this GPO will cause the files to be rewritten. + value_hash = self.generate_value_hash(*sudo_entries) + self.apply(gpo.name, attribute, value_hash, sudo_applier_func, + sdir, sudo_entries) + # Cleanup any old entries that are no longer part of the policy + self.clean(gpo.name, keep=[attribute]) + + def rsop(self, gpo): + output = {} + section = 'Software\\Policies\\Centrify\\UnixSettings\\SuDo' + pol_file = 'MACHINE/Registry.pol' + 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.data.strip(): + if '**delvals.' in e.valuename: + continue + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append(e.data) + return output diff --git a/python/samba/gp/gp_cert_auto_enroll_ext.py b/python/samba/gp/gp_cert_auto_enroll_ext.py new file mode 100644 index 0000000..9b743cb --- /dev/null +++ b/python/samba/gp/gp_cert_auto_enroll_ext.py @@ -0,0 +1,572 @@ +# gp_cert_auto_enroll_ext samba group policy +# Copyright (C) David Mulder <dmulder@suse.com> 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 <http://www.gnu.org/licenses/>. + +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-----""" +endpoint_re = '(https|HTTPS)://(?P<server>[a-zA-Z0-9.-]+)/ADPolicyProvider' + \ + '_CEP_(?P<auth>[a-zA-Z]+)/service.svc/CEP' + +global_trust_dirs = ['/etc/pki/trust/anchors', # SUSE + '/etc/pki/ca-trust/source/anchors', # RHEL/Fedora + '/usr/local/share/ca-certificates'] # Debian/Ubuntu + +def octet_string_to_objectGUID(data): + """Convert an octet string to an objectGUID.""" + return '%s-%s-%s-%s-%s' % ('%02x' % struct.unpack('<L', data[0:4])[0], + '%02x' % struct.unpack('<H', data[4:6])[0], + '%02x' % struct.unpack('<H', data[6:8])[0], + '%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(base64.b64encode(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): + 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', + '/usr/libexec/certmonger'] + return which('cepces-submit', path=':'.join(certmonger_dirs)) + +def get_supported_templates(server): + cepces_submit = find_cepces_submit() + if not cepces_submit: + log.error('Failed to find cepces-submit') + return [] + + 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() + + +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('Could not connect to Network Device Enrollment Service.') + r = None + if r is None or r.content == b'' or r.headers['Content-Type'] == 'text/html': + log.warn('Unable to fetch root certificates (requires NDES).') + if 'cACertificate' in ca: + log.warn('Installing the server certificate only.') + der_certificate = base64.b64decode(ca['cACertificate']) + try: + cert = load_der_x509_certificate(der_certificate) + except TypeError: + cert = load_der_x509_certificate(der_certificate, + 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) + 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) + else: + log.warn('getca: Wrong (or missing) MIME content type') + + return root_certs + + +def find_global_trust_dir(): + """Return the global trust dir using known paths from various Linux distros.""" + for trust_dir in global_trust_dirs: + if os.path.isdir(trust_dir): + return trust_dir + return global_trust_dirs[0] + +def update_ca_command(): + """Return the command to update the CA trust store.""" + return which('update-ca-certificates') or which('update-ca-trust') + +def changed(new_data, old_data): + """Return True if any key present in both dicts has changed.""" + return any((new_data[k] != old_data[k] if k in old_data else False) + for k in new_data.keys()) + +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'] + + log.info("Try to get root or server certificates") + + root_certs = getca(ca, url, trust_dir) + data['files'].extend(root_certs) + global_trust_dir = find_global_trust_dir() + 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) + log.info("Created symlink: %s -> %s" % (src, 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 = update_ca_command() + log.info("Running %s" % (update)) + if update is not None: + ret = Popen([update]).wait() + if ret != 0: + log.error('Failed to run %s' % (update)) + + # Setup Certificate Auto Enrollment + getcert = which('getcert') + cepces_submit = find_cepces_submit() + if getcert is not None and cepces_submit is not None: + 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: + if p.returncode == 2: + log.info('The CA [%s] already exists' % ca['name']) + else: + 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: + if p.returncode == 2: + log.info('The template [%s] already exists' % (nickname)) + else: + 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: + ret = Popen([update]).wait() + if ret != 0: + log.error('Failed to run %s' % (update)) + 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 r'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 {} + templates = ['%s.%s' % (ca['name'], t.decode()) for t in get_supported_templates(ca['hostname'])] \ + if old_val is not None else [] + new_data = { 'templates': templates, **ca } + if changed(new_data, old_data) or self.cache_get_apply_state() == GPOSTATE.ENFORCE: + self.unapply(guid, attribute, old_val) + # If policy is already applied and unchanged, skip application + if old_val is not None and not changed(new_data, old_data) 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 = r'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=list(ca_attrs.keys())) + + 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 = r'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/python/samba/gp/gp_chromium_ext.py b/python/samba/gp/gp_chromium_ext.py new file mode 100644 index 0000000..5e54f0f --- /dev/null +++ b/python/samba/gp/gp_chromium_ext.py @@ -0,0 +1,473 @@ +# gp_chromium_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 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 <http://www.gnu.org/licenses/>. + +import os +import json +from samba.gp.gpclass import gp_pol_ext, gp_file_applier +from samba.dcerpc import misc +from samba.common import get_string +from samba.gp.util.logging import log +from tempfile import NamedTemporaryFile + +def parse_entry_data(name, e): + dict_entries = ['VirtualKeyboardFeatures', + 'DeviceArcDataSnapshotHours', + 'RequiredClientCertificateForDevice', + 'RequiredClientCertificateForUser', + 'RegisteredProtocolHandlers', + 'WebUsbAllowDevicesForUrls', + 'DeviceAutoUpdateTimeRestrictions', + 'DeviceUpdateStagingSchedule', + 'DeviceMinimumVersion', + 'DeviceDisplayResolution', + 'ExtensionSettings', + 'KerberosAccounts', + 'NetworkFileSharesPreconfiguredShares', + 'NetworkThrottlingEnabled', + 'TPMFirmwareUpdateSettings', + 'DeviceOffHours', + 'ParentAccessCodeConfig', + 'PerAppTimeLimits', + 'PerAppTimeLimitsWhitelist', + 'PerAppTimeLimitsAllowlist', + 'UsageTimeLimit', + 'PluginVmImage', + 'DeviceLoginScreenPowerManagement', + 'PowerManagementIdleSettings', + 'ScreenLockDelays', + 'ScreenBrightnessPercent', + 'DevicePowerPeakShiftDayConfig', + 'DeviceAdvancedBatteryChargeModeDayConfig', + 'PrintingPaperSizeDefault', + 'AutoLaunchProtocolsFromOrigins', + 'BrowsingDataLifetime', + 'DataLeakPreventionRulesList', + 'DeviceLoginScreenWebUsbAllowDevicesForUrls', + 'DeviceScheduledUpdateCheck', + 'KeyPermissions', + 'ManagedBookmarks', + 'ManagedConfigurationPerOrigin', + 'ProxySettings', + 'SystemProxySettings', + 'WebAppInstallForceList'] + bools = ['ShowAccessibilityOptionsInSystemTrayMenu', + 'LargeCursorEnabled', + 'SpokenFeedbackEnabled', + 'HighContrastEnabled', + 'VirtualKeyboardEnabled', + 'StickyKeysEnabled', + 'KeyboardDefaultToFunctionKeys', + 'DictationEnabled', + 'SelectToSpeakEnabled', + 'KeyboardFocusHighlightEnabled', + 'CursorHighlightEnabled', + 'CaretHighlightEnabled', + 'MonoAudioEnabled', + 'AccessibilityShortcutsEnabled', + 'AutoclickEnabled', + 'DeviceLoginScreenDefaultLargeCursorEnabled', + 'DeviceLoginScreenDefaultSpokenFeedbackEnabled', + 'DeviceLoginScreenDefaultHighContrastEnabled', + 'DeviceLoginScreenDefaultVirtualKeyboardEnabled', + 'DeviceLoginScreenLargeCursorEnabled', + 'DeviceLoginScreenSpokenFeedbackEnabled', + 'DeviceLoginScreenHighContrastEnabled', + 'DeviceLoginScreenVirtualKeyboardEnabled', + 'DeviceLoginScreenDictationEnabled', + 'DeviceLoginScreenSelectToSpeakEnabled', + 'DeviceLoginScreenCursorHighlightEnabled', + 'DeviceLoginScreenCaretHighlightEnabled', + 'DeviceLoginScreenMonoAudioEnabled', + 'DeviceLoginScreenAutoclickEnabled', + 'DeviceLoginScreenStickyKeysEnabled', + 'DeviceLoginScreenKeyboardFocusHighlightEnabled', + 'DeviceLoginScreenShowOptionsInSystemTrayMenu', + 'DeviceLoginScreenAccessibilityShortcutsEnabled', + 'FloatingAccessibilityMenuEnabled', + 'ArcEnabled', + 'UnaffiliatedArcAllowed', + 'AppRecommendationZeroStateEnabled', + 'DeviceBorealisAllowed', + 'UserBorealisAllowed', + 'SystemUse24HourClock', + 'DefaultSearchProviderEnabled', + 'ChromeOsReleaseChannelDelegated', + 'DeviceAutoUpdateDisabled', + 'DeviceAutoUpdateP2PEnabled', + 'DeviceUpdateHttpDownloadsEnabled', + 'RebootAfterUpdate', + 'BlockExternalExtensions', + 'VoiceInteractionContextEnabled', + 'VoiceInteractionHotwordEnabled', + 'EnableMediaRouter', + 'ShowCastIconInToolbar', + 'DriveDisabled', + 'DriveDisabledOverCellular', + 'DisableAuthNegotiateCnameLookup', + 'EnableAuthNegotiatePort', + 'BasicAuthOverHttpEnabled', + 'AuthNegotiateDelegateByKdcPolicy', + 'AllowCrossOriginAuthPrompt', + 'NtlmV2Enabled', + 'IntegratedWebAuthenticationAllowed', + 'BrowserSwitcherEnabled', + 'BrowserSwitcherKeepLastChromeTab', + 'BrowserSwitcherUseIeSitelist', + 'VirtualMachinesAllowed', + 'CrostiniAllowed', + 'DeviceUnaffiliatedCrostiniAllowed', + 'CrostiniExportImportUIAllowed', + 'CrostiniPortForwardingAllowed', + 'NativeMessagingUserLevelHosts', + 'NetworkFileSharesAllowed', + 'NetBiosShareDiscoveryEnabled', + 'NTLMShareAuthenticationEnabled', + 'DeviceDataRoamingEnabled', + 'DeviceWiFiFastTransitionEnabled', + 'DeviceWiFiAllowed', + 'DeviceAllowBluetooth', + 'DeviceAllowRedeemChromeOsRegistrationOffers', + 'DeviceQuirksDownloadEnabled', + 'SuggestedContentEnabled', + 'DeviceShowLowDiskSpaceNotification', + 'PasswordManagerEnabled', + 'PasswordLeakDetectionEnabled', + 'PluginVmAllowed', + 'PluginVmDataCollectionAllowed', + 'UserPluginVmAllowed', + 'DeviceRebootOnShutdown', + 'PowerManagementUsesAudioActivity', + 'PowerManagementUsesVideoActivity', + 'AllowWakeLocks', + 'AllowScreenWakeLocks', + 'WaitForInitialUserActivity', + 'PowerSmartDimEnabled', + 'DevicePowerPeakShiftEnabled', + 'DeviceBootOnAcEnabled', + 'DeviceAdvancedBatteryChargeModeEnabled', + 'DeviceUsbPowerShareEnabled', + 'PrintingEnabled', + 'CloudPrintProxyEnabled', + 'PrintingSendUsernameAndFilenameEnabled', + 'CloudPrintSubmitEnabled', + 'DisablePrintPreview', + 'PrintHeaderFooter', + 'PrintPreviewUseSystemDefaultPrinter', + 'UserNativePrintersAllowed', + 'UserPrintersAllowed', + 'DeletePrintJobHistoryAllowed', + 'DeviceLoginScreenPrivacyScreenEnabled', + 'PrivacyScreenEnabled', + 'PinUnlockWeakPinsAllowed', + 'PinUnlockAutosubmitEnabled', + 'RemoteAccessHostFirewallTraversal', + 'RemoteAccessHostRequireCurtain', + 'RemoteAccessHostAllowClientPairing', + 'RemoteAccessHostAllowRelayedConnection', + 'RemoteAccessHostAllowUiAccessForRemoteAssistance', + 'RemoteAccessHostAllowFileTransfer', + 'RemoteAccessHostAllowRemoteAccessConnections', + 'AttestationEnabledForUser', + 'SafeBrowsingEnabled', + 'SafeBrowsingExtendedReportingEnabled', + 'DeviceGuestModeEnabled', + 'DeviceAllowNewUsers', + 'DeviceShowUserNamesOnSignin', + 'DeviceEphemeralUsersEnabled', + 'DeviceShowNumericKeyboardForPassword', + 'DeviceFamilyLinkAccountsAllowed', + 'ShowHomeButton', + 'HomepageIsNewTabPage', + 'DeviceMetricsReportingEnabled', + 'DeviceWilcoDtcAllowed', + 'AbusiveExperienceInterventionEnforce', + 'AccessibilityImageLabelsEnabled', + 'AdditionalDnsQueryTypesEnabled', + 'AdvancedProtectionAllowed', + 'AllowDeletingBrowserHistory', + 'AllowDinosaurEasterEgg', + 'AllowFileSelectionDialogs', + 'AllowScreenLock', + 'AllowSyncXHRInPageDismissal', + 'AlternateErrorPagesEnabled', + 'AlwaysOpenPdfExternally', + 'AppCacheForceEnabled', + 'AudioCaptureAllowed', + 'AudioOutputAllowed', + 'AudioProcessHighPriorityEnabled', + 'AudioSandboxEnabled', + 'AutoFillEnabled', + 'AutofillAddressEnabled', + 'AutofillCreditCardEnabled', + 'AutoplayAllowed', + 'BackgroundModeEnabled', + 'BlockThirdPartyCookies', + 'BookmarkBarEnabled', + 'BrowserAddPersonEnabled', + 'BrowserGuestModeEnabled', + 'BrowserGuestModeEnforced', + 'BrowserLabsEnabled', + 'BrowserNetworkTimeQueriesEnabled', + 'BuiltInDnsClientEnabled', + 'CECPQ2Enabled', + 'CaptivePortalAuthenticationIgnoresProxy', + 'ChromeCleanupEnabled', + 'ChromeCleanupReportingEnabled', + 'ChromeOsLockOnIdleSuspend', + 'ClickToCallEnabled', + 'CloudManagementEnrollmentMandatory', + 'CloudPolicyOverridesPlatformPolicy', + 'CloudUserPolicyMerge', + 'CommandLineFlagSecurityWarningsEnabled', + 'ComponentUpdatesEnabled', + 'DNSInterceptionChecksEnabled', + 'DataLeakPreventionReportingEnabled', + 'DefaultBrowserSettingEnabled', + 'DefaultSearchProviderContextMenuAccessAllowed', + 'DeveloperToolsDisabled', + 'DeviceAllowMGSToStoreDisplayProperties', + 'DeviceDebugPacketCaptureAllowed', + 'DeviceLocalAccountManagedSessionEnabled', + 'DeviceLoginScreenPrimaryMouseButtonSwitch', + 'DevicePciPeripheralDataAccessEnabled', + 'DevicePowerwashAllowed', + 'DeviceSystemWideTracingEnabled', + 'Disable3DAPIs', + 'DisableSafeBrowsingProceedAnyway', + 'DisableScreenshots', + 'EasyUnlockAllowed', + 'EditBookmarksEnabled', + 'EmojiSuggestionEnabled', + 'EnableDeprecatedPrivetPrinting', + 'EnableOnlineRevocationChecks', + 'EnableSyncConsent', + 'EnterpriseHardwarePlatformAPIEnabled', + 'ExternalProtocolDialogShowAlwaysOpenCheckbox', + 'ExternalStorageDisabled', + 'ExternalStorageReadOnly', + 'ForceBrowserSignin', + 'ForceEphemeralProfiles', + 'ForceGoogleSafeSearch', + 'ForceMaximizeOnFirstRun', + 'ForceSafeSearch', + 'ForceYouTubeSafetyMode', + 'FullscreenAlertEnabled', + 'FullscreenAllowed', + 'GloballyScopeHTTPAuthCacheEnabled', + 'HardwareAccelerationModeEnabled', + 'HideWebStoreIcon', + 'ImportAutofillFormData', + 'ImportBookmarks', + 'ImportHistory', + 'ImportHomepage', + 'ImportSavedPasswords', + 'ImportSearchEngine', + 'IncognitoEnabled', + 'InsecureFormsWarningsEnabled', + 'InsecurePrivateNetworkRequestsAllowed', + 'InstantTetheringAllowed', + 'IntensiveWakeUpThrottlingEnabled', + 'JavascriptEnabled', + 'LacrosAllowed', + 'LacrosSecondaryProfilesAllowed', + 'LockScreenMediaPlaybackEnabled', + 'LoginDisplayPasswordButtonEnabled', + 'ManagedGuestSessionPrivacyWarningsEnabled', + 'MediaRecommendationsEnabled', + 'MediaRouterCastAllowAllIPs', + 'MetricsReportingEnabled', + 'NTPCardsVisible', + 'NTPCustomBackgroundEnabled', + 'NativeWindowOcclusionEnabled', + 'NearbyShareAllowed', + 'PaymentMethodQueryEnabled', + 'PdfAnnotationsEnabled', + 'PhoneHubAllowed', + 'PhoneHubNotificationsAllowed', + 'PhoneHubTaskContinuationAllowed', + 'PolicyAtomicGroupsEnabled', + 'PrimaryMouseButtonSwitch', + 'PromotionalTabsEnabled', + 'PromptForDownloadLocation', + 'QuicAllowed', + 'RendererCodeIntegrityEnabled', + 'RequireOnlineRevocationChecksForLocalAnchors', + 'RoamingProfileSupportEnabled', + 'SSLErrorOverrideAllowed', + 'SafeBrowsingForTrustedSourcesEnabled', + 'SavingBrowserHistoryDisabled', + 'ScreenCaptureAllowed', + 'ScrollToTextFragmentEnabled', + 'SearchSuggestEnabled', + 'SecondaryGoogleAccountSigninAllowed', + 'SharedArrayBufferUnrestrictedAccessAllowed', + 'SharedClipboardEnabled', + 'ShowAppsShortcutInBookmarkBar', + 'ShowFullUrlsInAddressBar', + 'ShowLogoutButtonInTray', + 'SignedHTTPExchangeEnabled', + 'SigninAllowed', + 'SigninInterceptionEnabled', + 'SitePerProcess', + 'SmartLockSigninAllowed', + 'SmsMessagesAllowed', + 'SpellCheckServiceEnabled', + 'SpellcheckEnabled', + 'StartupBrowserWindowLaunchSuppressed', + 'StricterMixedContentTreatmentEnabled', + 'SuggestLogoutAfterClosingLastWindow', + 'SuppressDifferentOriginSubframeDialogs', + 'SuppressUnsupportedOSWarning', + 'SyncDisabled', + 'TargetBlankImpliesNoOpener', + 'TaskManagerEndProcessEnabled', + 'ThirdPartyBlockingEnabled', + 'TouchVirtualKeyboardEnabled', + 'TranslateEnabled', + 'TripleDESEnabled', + 'UnifiedDesktopEnabledByDefault', + 'UrlKeyedAnonymizedDataCollectionEnabled', + 'UserAgentClientHintsEnabled', + 'UserFeedbackAllowed', + 'VideoCaptureAllowed', + 'VmManagementCliAllowed', + 'VpnConfigAllowed', + 'WPADQuickCheckEnabled', + 'WebRtcAllowLegacyTLSProtocols', + 'WebRtcEventLogCollectionAllowed', + 'WifiSyncAndroidAllowed', + 'WindowOcclusionEnabled'] + if name in dict_entries: + return json.loads(get_string(e.data)) + elif e.type == misc.REG_DWORD and name in bools: + return e.data == 1 + return e.data + +def assign_entry(policies, e): + if e.valuename.isnumeric(): + name = e.keyname.split('\\')[-1] + if name not in policies: + policies[name] = [] + policies[name].append(parse_entry_data(name, e)) + else: + name = e.valuename + policies[name] = parse_entry_data(name, e) + +def convert_pol_to_json(section, entries): + managed = {} + recommended = {} + recommended_section = '\\'.join([section, 'Recommended']) + for e in entries: + if '**delvals.' in e.valuename: + continue + if e.keyname.startswith(recommended_section): + assign_entry(recommended, e) + elif e.keyname.startswith(section): + assign_entry(managed, e) + return managed, recommended + +class gp_chromium_ext(gp_pol_ext, gp_file_applier): + managed_policies_path = '/etc/chromium/policies/managed' + recommended_policies_path = '/etc/chromium/policies/recommended' + + def __str__(self): + return 'Google/Chromium' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + policy_dir=None): + if policy_dir is not None: + self.recommended_policies_path = os.path.join(policy_dir, + 'recommended') + self.managed_policies_path = os.path.join(policy_dir, 'managed') + # Create the policy directories if necessary + if not os.path.exists(self.recommended_policies_path): + os.makedirs(self.recommended_policies_path, mode=0o755, + exist_ok=True) + if not os.path.exists(self.managed_policies_path): + os.makedirs(self.managed_policies_path, mode=0o755, + exist_ok=True) + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, policies in settings[str(self)].items(): + try: + json.loads(policies) + except json.decoder.JSONDecodeError: + self.unapply(guid, attribute, policies) + else: + # Policies were previously stored all in one file, but + # the Chromium documentation says this is not + # necessary. Unapply the old policy file if json was + # stored in the cache (now we store a hash and file + # names instead). + if attribute == 'recommended': + fname = os.path.join(self.recommended_policies_path, + 'policies.json') + elif attribute == 'managed': + fname = os.path.join(self.managed_policies_path, + 'policies.json') + self.unapply(guid, attribute, fname) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\\Policies\\Google\\Chrome' + 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 + + managed, recommended = convert_pol_to_json(section, + pol_conf.entries) + def applier_func(policies, location): + try: + with NamedTemporaryFile(mode='w+', prefix='gp_', + delete=False, + dir=location, + suffix='.json') as f: + json.dump(policies, f) + os.chmod(f.name, 0o644) + log.debug('Wrote Chromium preferences', policies) + return [f.name] + except PermissionError: + log.debug('Failed to write Chromium preferences', + policies) + value_hash = self.generate_value_hash(json.dumps(managed)) + self.apply(gpo.name, 'managed', value_hash, applier_func, + managed, self.managed_policies_path) + value_hash = self.generate_value_hash(json.dumps(recommended)) + self.apply(gpo.name, 'recommended', value_hash, applier_func, + recommended, self.recommended_policies_path) + + def rsop(self, gpo): + output = {} + pol_file = 'MACHINE/Registry.pol' + section = 'Software\\Policies\\Google\\Chrome' + 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.startswith(section): + output['%s\\%s' % (e.keyname, e.valuename)] = e.data + return output + +class gp_chrome_ext(gp_chromium_ext): + managed_policies_path = '/etc/opt/chrome/policies/managed' + recommended_policies_path = '/etc/opt/chrome/policies/recommended' + + def __str__(self): + return 'Google/Chrome' diff --git a/python/samba/gp/gp_drive_maps_ext.py b/python/samba/gp/gp_drive_maps_ext.py new file mode 100644 index 0000000..f998d0e --- /dev/null +++ b/python/samba/gp/gp_drive_maps_ext.py @@ -0,0 +1,168 @@ +# gp_drive_maps_user_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +import json +from samba.gp.gpclass import gp_xml_ext, gp_misc_applier, drop_privileges, \ + expand_pref_variables +from subprocess import Popen, PIPE +from samba.gp.gp_scripts_ext import fetch_crontab, install_user_crontab +from samba.gp.util.logging import log +from samba.gp import gp_scripts_ext +gp_scripts_ext.intro = ''' +### autogenerated by samba +# +# This file is generated by the gp_drive_maps_user_ext Group Policy +# Client Side Extension. To modify the contents of this file, +# modify the appropriate Group Policy objects which apply +# to this machine. DO NOT MODIFY THIS FILE DIRECTLY. +# + +''' + +def mount_drive(uri): + log.debug('Mounting drive', uri) + out, err = Popen(['gio', 'mount', uri], + stdout=PIPE, stderr=PIPE).communicate() + if err: + if b'Location is already mounted' not in err: + raise SystemError(err) + +def unmount_drive(uri): + log.debug('Unmounting drive', uri) + return Popen(['gio', 'mount', uri, '--unmount']).wait() + +class gp_drive_maps_user_ext(gp_xml_ext, gp_misc_applier): + def parse_value(self, val): + vals = super().parse_value(val) + if 'props' in vals.keys(): + vals['props'] = json.loads(vals['props']) + if 'run_once' in vals.keys(): + vals['run_once'] = json.loads(vals['run_once']) + return vals + + def unapply(self, guid, uri, val): + vals = self.parse_value(val) + if 'props' in vals.keys() and \ + vals['props']['action'] in ['C', 'R', 'U']: + unmount_drive(uri) + others, entries = fetch_crontab(self.username) + if 'crontab' in vals.keys() and vals['crontab'] in entries: + entries.remove(vals['crontab']) + install_user_crontab(self.username, others, entries) + self.cache_remove_attribute(guid, uri) + + def apply(self, guid, uri, props, run_once, entry): + old_val = self.cache_get_attribute_value(guid, uri) + val = self.generate_value(props=json.dumps(props), + run_once=json.dumps(run_once), + crontab=entry) + + # The policy has changed, unapply it first + if old_val: + self.unapply(guid, uri, old_val) + + if props['action'] in ['C', 'R', 'U']: + mount_drive(uri) + elif props['action'] == 'D': + unmount_drive(uri) + if not run_once: + others, entries = fetch_crontab(self.username) + if entry not in entries: + entries.append(entry) + install_user_crontab(self.username, others, entries) + self.cache_add_attribute(guid, uri, val) + + def __str__(self): + return 'Preferences/Drives' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for uri, val in settings[str(self)].items(): + self.unapply(guid, uri, val) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'USER/Preferences/Drives/Drives.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = drop_privileges('root', self.parse, path) + if not xml_conf: + continue + drives = xml_conf.findall('Drive') + attrs = [] + for drive in drives: + prop = drive.find('Properties') + if prop is None: + log.warning('Drive is missing Properties', drive.attrib) + continue + if prop.attrib['thisDrive'] == 'HIDE': + log.warning('Drive is hidden', prop.attrib) + continue # Don't mount a hidden drive + run_once = False + filters = drive.find('Filters') + if filters: + run_once_filter = filters.find('FilterRunOnce') + if run_once_filter is not None: + run_once = True + uri = 'smb:{}'.format(prop.attrib['path'].replace('\\', '/')) + # Ensure we expand the preference variables, or fail if we + # are unable to (the uri is invalid if we fail). + gptpath = os.path.join(gpo.file_sys_path, 'USER') + try: + uri = expand_pref_variables(uri, gptpath, self.lp, + username=self.username) + except NameError as e: + # If we fail expanding variables, then the URI is + # invalid and we can't continue processing this drive + # map. We can continue processing other drives, as they + # may succeed. This is not a critical error, since some + # Windows specific policies won't apply here. + log.warn('Failed to expand drive map variables: %s' % e, + prop.attrib) + continue + attrs.append(uri) + entry = '' + if not run_once: + if prop.attrib['action'] in ['C', 'R', 'U']: + entry = '@hourly gio mount {}'.format(uri) + elif prop.attrib['action'] == 'D': + entry = '@hourly gio mount {} --unmount'.format(uri) + self.apply(gpo.name, uri, prop.attrib, run_once, entry) + self.clean(gpo.name, keep=attrs) + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + xml = 'USER/Preferences/Drives/Drives.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + drives = xml_conf.findall('Drive') + for drive in drives: + prop = drive.find('Properties') + if prop is None: + continue + if prop.attrib['thisDrive'] == 'HIDE': + continue + uri = 'smb:{}'.format(prop.attrib['path'].replace('\\', '/')) + if prop.attrib['action'] in ['C', 'R', 'U']: + output[prop.attrib['label']] = 'gio mount {}'.format(uri) + elif prop.attrib['action'] == 'D': + output[prop.attrib['label']] = \ + 'gio mount {} --unmount'.format(uri) + return output diff --git a/python/samba/gp/gp_ext_loader.py b/python/samba/gp/gp_ext_loader.py new file mode 100644 index 0000000..705b973 --- /dev/null +++ b/python/samba/gp/gp_ext_loader.py @@ -0,0 +1,59 @@ +# Group Policy Client Side Extension Loader +# Copyright (C) David Mulder <dmulder@suse.com> 2018 +# +# 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 <http://www.gnu.org/licenses/>. + +from samba.gp.gpclass import list_gp_extensions +from samba.gp.gpclass import gp_ext +from samba.gp.util.logging import log + +try: + import importlib.util + + def import_file(name, location): + spec = importlib.util.spec_from_file_location(name, location) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module +except ImportError: + import imp + + def import_file(name, location): + return imp.load_source(name, location) + + +def get_gp_ext_from_module(name, mod): + if mod: + for k, v in vars(mod).items(): + if k == name and issubclass(v, gp_ext): + return v + return None + + +def get_gp_client_side_extensions(smb_conf): + user_exts = [] + machine_exts = [] + gp_exts = list_gp_extensions(smb_conf) + for gp_extension in gp_exts.values(): + module = import_file(gp_extension['ProcessGroupPolicy'], gp_extension['DllName']) + ext = get_gp_ext_from_module(gp_extension['ProcessGroupPolicy'], module) + if ext and gp_extension['MachinePolicy']: + machine_exts.append(ext) + log.info('Loaded machine extension from %s: %s' + % (gp_extension['DllName'], ext.__name__)) + if ext and gp_extension['UserPolicy']: + user_exts.append(ext) + log.info('Loaded user extension from %s: %s' + % (gp_extension['DllName'], ext.__name__)) + return (machine_exts, user_exts) diff --git a/python/samba/gp/gp_firefox_ext.py b/python/samba/gp/gp_firefox_ext.py new file mode 100644 index 0000000..a623314 --- /dev/null +++ b/python/samba/gp/gp_firefox_ext.py @@ -0,0 +1,219 @@ +# gp_firefox_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 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 <http://www.gnu.org/licenses/>. + +import os +import json +from samba.gp.gpclass import gp_pol_ext, gp_misc_applier +from samba.dcerpc import misc +from samba.common import get_string +from samba.gp.util.logging import log + +def parse_entry_data(e): + if e.type == misc.REG_MULTI_SZ: + data = get_string(e.data).replace('\x00', '') + return json.loads(data) + elif e.type == misc.REG_DWORD and e.data in [0, 1]: + return e.data == 1 + return e.data + +def convert_pol_to_json(section, entries): + result = {} + index_map = {} + for e in entries: + if not e.keyname.startswith(section): + continue + if '**delvals.' in e.valuename: + continue + sub_keys = e.keyname.replace(section, '').strip('\\') + if sub_keys: + sub_keys = sub_keys.split('\\') + current = result + index = -1 + if sub_keys[-1].isnumeric(): + name = '\\'.join(sub_keys[:-1]) + elif e.valuename.isnumeric(): + name = e.keyname + else: + name = '\\'.join([e.keyname, e.valuename]) + for i in range(len(sub_keys)): + if sub_keys[i] == 'PDFjs': + sub_keys[i] = 'PSFjs' + ctype = dict + if i == len(sub_keys)-1 and e.valuename.isnumeric(): + ctype = list + index = int(e.valuename) + if i < len(sub_keys)-1 and sub_keys[i+1].isnumeric(): + ctype = list + index = int(sub_keys[i+1]) + if type(current) == dict: + if sub_keys[i] not in current: + if ctype == dict: + current[sub_keys[i]] = {} + else: + current[sub_keys[i]] = [] + current = current[sub_keys[i]] + else: + if name not in index_map: + index_map[name] = {} + if index not in index_map[name].keys(): + if ctype == dict: + current.append({}) + else: + current.append([]) + index_map[name][index] = len(current)-1 + current = current[index_map[name][index]] + if type(current) == list: + current.append(parse_entry_data(e)) + else: + current[e.valuename] = parse_entry_data(e) + else: + result[e.valuename] = parse_entry_data(e) + return result + +class gp_firefox_ext(gp_pol_ext, gp_misc_applier): + firefox_installdir = '/etc/firefox/policies' + destfile = os.path.join(firefox_installdir, 'policies.json') + + def __str__(self): + return 'Mozilla/Firefox' + + def set_machine_policy(self, policies): + try: + os.makedirs(self.firefox_installdir, exist_ok=True) + with open(self.destfile, 'w') as f: + json.dump(policies, f) + log.debug('Wrote Firefox preferences', self.destfile) + except PermissionError: + log.debug('Failed to write Firefox preferences', + self.destfile) + + def get_machine_policy(self): + if os.path.exists(self.destfile): + with open(self.destfile, 'r') as r: + policies = json.load(r) + log.debug('Read Firefox preferences', self.destfile) + else: + policies = {'policies': {}} + return policies + + def parse_value(self, value): + data = super().parse_value(value) + for k, v in data.items(): + try: + data[k] = json.loads(v) + except json.decoder.JSONDecodeError: + pass + return data + + def unapply_policy(self, guid, policy, applied_val, val): + def set_val(policies, policy, val): + if val is None: + del policies[policy] + else: + policies[policy] = val + current = self.get_machine_policy() + if policy in current['policies'].keys(): + if applied_val is not None: + # Only restore policy if unmodified + if current['policies'][policy] == applied_val: + set_val(current['policies'], policy, val) + else: + set_val(current['policies'], policy, val) + self.set_machine_policy(current) + + def unapply(self, guid, policy, val): + cache = self.parse_value(val) + if policy == 'policies.json': + current = self.get_machine_policy() + for attr in current['policies'].keys(): + val = cache['old_val']['policies'][attr] \ + if attr in cache['old_val']['policies'] else None + self.unapply_policy(guid, attr, None, val) + else: + self.unapply_policy(guid, policy, + cache['new_val'] if 'new_val' in cache else None, + cache['old_val']) + self.cache_remove_attribute(guid, policy) + + def apply(self, guid, policy, val): + # If the policy has changed, unapply, then apply new policy + data = self.cache_get_attribute_value(guid, policy) + if data is not None: + self.unapply(guid, policy, data) + + current = self.get_machine_policy() + before = None + if policy in current['policies'].keys(): + before = current['policies'][policy] + + # Apply the policy and log the changes + new_value = self.generate_value(old_val=json.dumps(before), + new_val=json.dumps(val)) + current['policies'][policy] = val + self.set_machine_policy(current) + self.cache_add_attribute(guid, policy, get_string(new_value)) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + policy_dir=None): + if policy_dir is not None: + self.firefox_installdir = policy_dir + self.destfile = os.path.join(policy_dir, 'policies.json') + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for policy, val in settings[str(self)].items(): + self.unapply(guid, policy, val) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + pol_file = 'MACHINE/Registry.pol' + section = 'Software\\Policies\\Mozilla\\Firefox' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + continue + + # Unapply the old cache entry, if present + data = self.cache_get_attribute_value(gpo.name, 'policies.json') + if data is not None: + self.unapply(gpo.name, 'policies.json', data) + + policies = convert_pol_to_json(section, pol_conf.entries) + for policy, val in policies.items(): + self.apply(gpo.name, policy, val) + + # cleanup removed policies + self.clean(gpo.name, keep=policies.keys()) + + def rsop(self, gpo): + output = {} + pol_file = 'MACHINE/Registry.pol' + section = 'Software\\Policies\\Mozilla\\Firefox' + 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.startswith(section): + output['%s\\%s' % (e.keyname, e.valuename)] = e.data + return output + +class gp_firefox_old_ext(gp_firefox_ext): + firefox_installdir = '/usr/lib64/firefox/distribution' + destfile = os.path.join(firefox_installdir, 'policies.json') + + def __str__(self): + return 'Mozilla/Firefox (old profile directory)' diff --git a/python/samba/gp/gp_firewalld_ext.py b/python/samba/gp/gp_firewalld_ext.py new file mode 100644 index 0000000..5e125b0 --- /dev/null +++ b/python/samba/gp/gp_firewalld_ext.py @@ -0,0 +1,171 @@ +# gp_firewalld_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 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 <http://www.gnu.org/licenses/>. + +import os +from subprocess import Popen, PIPE +from shutil import which +import json +from samba.gp.gpclass import gp_pol_ext, gp_applier +from samba.gp.util.logging import log + +def firewall_cmd(*args): + fw_cmd = which('firewall-cmd') + if fw_cmd is not None: + cmd = [fw_cmd] + cmd.extend(list(args)) + + p = Popen(cmd, stdout=PIPE, stderr=PIPE) + stdoutdata, _ = p.communicate() + return p.returncode, stdoutdata + else: + return -1, 'firewall-cmd not found' + +def rule_segment_parse(name, rule_segment): + if isinstance(rule_segment, str): + return ('%s=%s' % (name, rule_segment)) + ' ' + else: + return '%s %s ' % (name, + ' '.join(['%s=%s' % (k, v) for k, v in rule_segment.items()])) + +class gp_firewalld_ext(gp_pol_ext, gp_applier): + def __str__(self): + return 'Security/Firewalld' + + def apply_zone(self, guid, zone): + zone_attrs = [] + ret = firewall_cmd('--permanent', '--new-zone=%s' % zone)[0] + if ret != 0: + log.error('Failed to add new zone', zone) + else: + attribute = 'zone:%s' % zone + self.cache_add_attribute(guid, attribute, zone) + zone_attrs.append(attribute) + # Default to matching the interface(s) for the default zone + ret, out = firewall_cmd('--list-interfaces') + if ret != 0: + log.error('Failed to set interfaces for zone', zone) + for interface in out.strip().split(): + ret = firewall_cmd('--permanent', '--zone=%s' % zone, + '--add-interface=%s' % interface.decode()) + if ret != 0: + log.error('Failed to set interfaces for zone', zone) + return zone_attrs + + def apply_rules(self, guid, rule_dict): + rule_attrs = [] + for zone, rules in rule_dict.items(): + for rule in rules: + if 'rule' in rule: + rule_parsed = rule_segment_parse('rule', rule['rule']) + else: + rule_parsed = 'rule ' + for segment in ['source', 'destination', 'service', 'port', + 'protocol', 'icmp-block', 'masquerade', + 'icmp-type', 'forward-port', 'source-port', + 'log', 'audit']: + names = [s for s in rule.keys() if s.startswith(segment)] + for name in names: + rule_parsed += rule_segment_parse(name, rule[name]) + actions = set(['accept', 'reject', 'drop', 'mark']) + segments = set(rule.keys()) + action = actions.intersection(segments) + if len(action) == 1: + rule_parsed += rule_segment_parse(list(action)[0], + rule[list(action)[0]]) + else: + log.error('Invalid firewall rule syntax') + ret = firewall_cmd('--permanent', '--zone=%s' % zone, + '--add-rich-rule', rule_parsed.strip())[0] + if ret != 0: + log.error('Failed to add firewall rule', rule_parsed) + else: + rhash = self.generate_value_hash(rule_parsed) + attribute = 'rule:%s:%s' % (zone, rhash) + self.cache_add_attribute(guid, attribute, rule_parsed) + rule_attrs.append(attribute) + return rule_attrs + + def unapply(self, guid, attribute, value): + if attribute.startswith('zone'): + ret = firewall_cmd('--permanent', + '--delete-zone=%s' % value)[0] + if ret != 0: + log.error('Failed to remove zone', value) + else: + self.cache_remove_attribute(guid, attribute) + elif attribute.startswith('rule'): + _, zone, _ = attribute.split(':') + ret = firewall_cmd('--permanent', '--zone=%s' % zone, + '--remove-rich-rule', value)[0] + if ret != 0: + log.error('Failed to remove firewall rule', value) + else: + self.cache_remove_attribute(guid, attribute) + + def apply(self, applier_func, *args): + return applier_func(*args) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, value in settings[str(self)].items(): + self.unapply(guid, attribute, value) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\\Policies\\Samba\\Unix Settings\\Firewalld' + 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 + attrs = [] + for e in pol_conf.entries: + if e.keyname.startswith(section): + if e.keyname.endswith('Rules'): + attrs.extend(self.apply(self.apply_rules, gpo.name, + json.loads(e.data))) + elif e.keyname.endswith('Zones'): + if e.valuename == '**delvals.': + continue + attrs.extend(self.apply(self.apply_zone, gpo.name, + e.data)) + + # Cleanup all old zones and rules from this GPO + self.clean(gpo.name, keep=attrs) + + def rsop(self, gpo): + output = {} + pol_file = 'MACHINE/Registry.pol' + section = 'Software\\Policies\\Samba\\Unix Settings\\Firewalld' + 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.startswith(section): + if e.keyname.endswith('Zones'): + if e.valuename == '**delvals.': + continue + if 'Zones' not in output.keys(): + output['Zones'] = [] + output['Zones'].append(e.data) + elif e.keyname.endswith('Rules'): + if 'Rules' not in output.keys(): + output['Rules'] = [] + output['Rules'].append(json.loads(e.data)) + return output diff --git a/python/samba/gp/gp_gnome_settings_ext.py b/python/samba/gp/gp_gnome_settings_ext.py new file mode 100644 index 0000000..567ab94 --- /dev/null +++ b/python/samba/gp/gp_gnome_settings_ext.py @@ -0,0 +1,418 @@ +# gp_gnome_settings_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os, re +from samba.gp.gpclass import gp_pol_ext, gp_file_applier +from tempfile import NamedTemporaryFile +import shutil +from configparser import ConfigParser +from subprocess import Popen, PIPE +from samba.common import get_string +from glob import glob +import xml.etree.ElementTree as etree +from samba.gp.util.logging import log + +def dconf_update(test_dir): + if test_dir is not None: + return + dconf = shutil.which('dconf') + if dconf is None: + log.error('Failed to update dconf. Command not found') + return + p = Popen([dconf, 'update'], stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + if p.returncode != 0: + log.error('Failed to update dconf', get_string(err)) + +def create_locks_dir(test_dir): + locks_dir = '/etc/dconf/db/local.d/locks' + if test_dir is not None: + locks_dir = os.path.join(test_dir, locks_dir[1:]) + os.makedirs(locks_dir, exist_ok=True) + return locks_dir + +def create_user_profile(test_dir): + user_profile = '/etc/dconf/profile/user' + if test_dir is not None: + user_profile = os.path.join(test_dir, user_profile[1:]) + if os.path.exists(user_profile): + return + os.makedirs(os.path.dirname(user_profile), exist_ok=True) + with NamedTemporaryFile('w', dir=os.path.dirname(user_profile), + delete=False) as w: + w.write('user-db:user\nsystem-db:local') + os.chmod(w.name, 0o644) + fname = w.name + shutil.move(fname, user_profile) + +def create_local_db(test_dir): + local_db = '/etc/dconf/db/local.d' + if test_dir is not None: + local_db = os.path.join(test_dir, local_db[1:]) + os.makedirs(local_db, exist_ok=True) + return local_db + +def select_next_conf(directory, fname=''): + configs = [re.match(r'(\d+)%s' % fname, f) for f in os.listdir(directory)] + return max([int(m.group(1)) for m in configs if m]+[0])+1 + +class gp_gnome_settings_ext(gp_pol_ext, gp_file_applier): + def __init__(self, *args): + super().__init__(*args) + self.keys = ['Compose Key', + 'Dim Screen when User is Idle', + 'Lock Down Specific Settings', + 'Whitelisted Online Accounts', + 'Enabled Extensions'] + self.lock_down_settings = {} + self.test_dir = None + + def __str__(self): + return 'GNOME Settings/Lock Down Settings' + + def __add_lockdown_data(self, k, e): + if k not in self.lock_down_settings: + self.lock_down_settings[k] = {} + self.lock_down_settings[k][e.valuename] = e.data + + def __enable_lockdown_data(self, e): + if e.valuename not in self.lock_down_settings: + self.lock_down_settings[e.valuename] = {} + self.lock_down_settings[e.valuename]['Enabled'] = e.data == 1 + + def __apply_compose_key(self, data): + create_user_profile(self.test_dir) + local_db_dir = create_local_db(self.test_dir) + + conf_id = select_next_conf(local_db_dir, '-input-sources') + local_db = os.path.join(local_db_dir, + '%010d-input-sources' % conf_id) + data_map = { 'Right Alt': 'compose:ralt', + 'Left Win': 'compose:lwin', + '3rd level of Left Win': 'compose:lwin-altgr', + 'Right Win': 'compose:rwin', + '3rd level of Right Win': 'compose:rwin-altgr', + 'Menu': 'compose:menu', + '3rd level of Menu': 'compose:menu-altgr', + 'Left Ctrl': 'compose:lctrl', + '3rd level of Left Ctrl': 'compose:lctrl-altgr', + 'Right Ctrl': 'compose:rctrl', + '3rd level of Right Ctrl': 'compose:rctrl-altgr', + 'Caps Lock': 'compose:caps', + '3rd level of Caps Lock': 'compose:caps-altgr', + 'The "< >" key': 'compose:102', + '3rd level of the "< >" key': 'compose:102-altgr', + 'Pause': 'compose:paus', + 'PrtSc': 'compose:prsc', + 'Scroll Lock': 'compose:sclk' + } + if data['Key Name'] not in data_map.keys(): + log.error('Compose Key not recognized', data) + return + parser = ConfigParser() + section = 'org/gnome/desktop/input-sources' + parser.add_section(section) + parser.set(section, 'xkb-options', + "['%s']" % data_map[data['Key Name']]) + with open(local_db, 'w') as w: + parser.write(w) + + # Lock xkb-options + locks_dir = create_locks_dir(self.test_dir) + conf_id = select_next_conf(locks_dir) + lock = os.path.join(locks_dir, '%010d-input-sources' % conf_id) + with open(lock, 'w') as w: + w.write('/org/gnome/desktop/input-sources/xkb-options') + + dconf_update(self.test_dir) + return [local_db, lock] + + def __apply_dim_idle(self, data): + create_user_profile(self.test_dir) + local_db_dir = create_local_db(self.test_dir) + conf_id = select_next_conf(local_db_dir, '-power') + local_power_db = os.path.join(local_db_dir, '%010d-power' % conf_id) + parser = ConfigParser() + section = 'org/gnome/settings-daemon/plugins/power' + parser.add_section(section) + parser.set(section, 'idle-dim', 'true') + parser.set(section, 'idle-brightness', str(data['Dim Idle Brightness'])) + with open(local_power_db, 'w') as w: + parser.write(w) + conf_id = select_next_conf(local_db_dir, '-session') + local_session_db = os.path.join(local_db_dir, '%010d-session' % conf_id) + parser = ConfigParser() + section = 'org/gnome/desktop/session' + parser.add_section(section) + parser.set(section, 'idle-delay', 'uint32 %d' % data['Delay']) + with open(local_session_db, 'w') as w: + parser.write(w) + + # Lock power-saving + locks_dir = create_locks_dir(self.test_dir) + conf_id = select_next_conf(locks_dir) + lock = os.path.join(locks_dir, '%010d-power-saving' % conf_id) + with open(lock, 'w') as w: + w.write('/org/gnome/settings-daemon/plugins/power/idle-dim\n') + w.write('/org/gnome/settings-daemon/plugins/power/idle-brightness\n') + w.write('/org/gnome/desktop/session/idle-delay') + + dconf_update(self.test_dir) + return [local_power_db, local_session_db, lock] + + def __apply_specific_settings(self, data): + create_user_profile(self.test_dir) + locks_dir = create_locks_dir(self.test_dir) + conf_id = select_next_conf(locks_dir, '-group-policy') + policy_file = os.path.join(locks_dir, '%010d-group-policy' % conf_id) + with open(policy_file, 'w') as w: + for key in data.keys(): + w.write('%s\n' % key) + dconf_update(self.test_dir) + return [policy_file] + + def __apply_whitelisted_account(self, data): + create_user_profile(self.test_dir) + local_db_dir = create_local_db(self.test_dir) + locks_dir = create_locks_dir(self.test_dir) + val = "['%s']" % "', '".join(data.keys()) + policy_files = self.__lockdown(local_db_dir, locks_dir, 'goa', + 'whitelisted-providers', val, + 'org/gnome/online-accounts') + dconf_update(self.test_dir) + return policy_files + + def __apply_enabled_extensions(self, data): + create_user_profile(self.test_dir) + local_db_dir = create_local_db(self.test_dir) + conf_id = select_next_conf(local_db_dir) + policy_file = os.path.join(local_db_dir, '%010d-extensions' % conf_id) + parser = ConfigParser() + section = 'org/gnome/shell' + parser.add_section(section) + exts = data.keys() + parser.set(section, 'enabled-extensions', "['%s']" % "', '".join(exts)) + parser.set(section, 'development-tools', 'false') + with open(policy_file, 'w') as w: + parser.write(w) + dconf_update(self.test_dir) + return [policy_file] + + def __lockdown(self, local_db_dir, locks_dir, name, key, val, + section='org/gnome/desktop/lockdown'): + policy_files = [] + conf_id = select_next_conf(local_db_dir) + policy_file = os.path.join(local_db_dir, + '%010d-%s' % (conf_id, name)) + policy_files.append(policy_file) + conf_id = select_next_conf(locks_dir) + lock = os.path.join(locks_dir, '%010d-%s' % (conf_id, name)) + policy_files.append(lock) + parser = ConfigParser() + parser.add_section(section) + parser.set(section, key, val) + with open(policy_file, 'w') as w: + parser.write(w) + with open(lock, 'w') as w: + w.write('/%s/%s' % (section, key)) + return policy_files + + def __apply_enabled(self, k): + policy_files = [] + + create_user_profile(self.test_dir) + local_db_dir = create_local_db(self.test_dir) + locks_dir = create_locks_dir(self.test_dir) + + if k == 'Lock Down Enabled Extensions': + conf_id = select_next_conf(locks_dir) + policy_file = os.path.join(locks_dir, '%010d-extensions' % conf_id) + policy_files.append(policy_file) + with open(policy_file, 'w') as w: + w.write('/org/gnome/shell/enabled-extensions\n') + w.write('/org/gnome/shell/development-tools') + elif k == 'Disable Printing': + policy_files = self.__lockdown(local_db_dir, locks_dir, 'printing', + 'disable-printing', 'true') + elif k == 'Disable File Saving': + policy_files = self.__lockdown(local_db_dir, locks_dir, + 'filesaving', + 'disable-save-to-disk', 'true') + elif k == 'Disable Command-Line Access': + policy_files = self.__lockdown(local_db_dir, locks_dir, 'cmdline', + 'disable-command-line', 'true') + elif k == 'Disallow Login Using a Fingerprint': + policy_files = self.__lockdown(local_db_dir, locks_dir, + 'fingerprintreader', + 'enable-fingerprint-authentication', + 'false', + section='org/gnome/login-screen') + elif k == 'Disable User Logout': + policy_files = self.__lockdown(local_db_dir, locks_dir, 'logout', + 'disable-log-out', 'true') + elif k == 'Disable User Switching': + policy_files = self.__lockdown(local_db_dir, locks_dir, 'logout', + 'disable-user-switching', 'true') + elif k == 'Disable Repartitioning': + actions = '/usr/share/polkit-1/actions' + udisk2 = glob(os.path.join(actions, + 'org.freedesktop.[u|U][d|D]isks2.policy')) + if len(udisk2) == 1: + udisk2 = udisk2[0] + else: + udisk2 = os.path.join(actions, + 'org.freedesktop.UDisks2.policy') + udisk2_etc = os.path.join('/etc/share/polkit-1/actions', + os.path.basename(udisk2)) + if self.test_dir is not None: + udisk2_etc = os.path.join(self.test_dir, udisk2_etc[1:]) + os.makedirs(os.path.dirname(udisk2_etc), exist_ok=True) + xml_data = etree.ElementTree(etree.Element('policyconfig')) + if os.path.exists(udisk2): + with open(udisk2, 'rb') as f: + data = f.read() + existing_xml = etree.ElementTree(etree.fromstring(data)) + root = xml_data.getroot() + root.append(existing_xml.find('vendor')) + root.append(existing_xml.find('vendor_url')) + root.append(existing_xml.find('icon_name')) + else: + vendor = etree.SubElement(xml_data.getroot(), 'vendor') + vendor.text = 'The Udisks Project' + vendor_url = etree.SubElement(xml_data.getroot(), 'vendor_url') + vendor_url.text = 'https://github.com/storaged-project/udisks' + icon_name = etree.SubElement(xml_data.getroot(), 'icon_name') + icon_name.text = 'drive-removable-media' + action = etree.SubElement(xml_data.getroot(), 'action') + action.attrib['id'] = 'org.freedesktop.udisks2.modify-device' + description = etree.SubElement(action, 'description') + description.text = 'Modify the drive settings' + message = etree.SubElement(action, 'message') + message.text = 'Authentication is required to modify drive settings' + defaults = etree.SubElement(action, 'defaults') + allow_any = etree.SubElement(defaults, 'allow_any') + allow_any.text = 'no' + allow_inactive = etree.SubElement(defaults, 'allow_inactive') + allow_inactive.text = 'no' + allow_active = etree.SubElement(defaults, 'allow_active') + allow_active.text = 'yes' + with open(udisk2_etc, 'wb') as w: + xml_data.write(w, encoding='UTF-8', xml_declaration=True) + policy_files.append(udisk2_etc) + else: + log.error('Unable to apply', k) + return + dconf_update(self.test_dir) + return policy_files + + def __clean_data(self, k): + data = self.lock_down_settings[k] + return {i: data[i] for i in data.keys() if i != 'Enabled'} + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + test_dir=None): + if test_dir is not None: + self.test_dir = test_dir + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, value in settings[str(self)].items(): + self.unapply(guid, attribute, value, sep=';') + dconf_update(test_dir) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section_name = 'GNOME Settings\\Lock Down Settings' + 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.startswith(section_name) and e.data and \ + '**delvals.' not in e.valuename: + for k in self.keys: + if e.keyname.endswith(k): + self.__add_lockdown_data(k, e) + break + else: + self.__enable_lockdown_data(e) + for k in self.lock_down_settings.keys(): + # Ignore disabled preferences + if not self.lock_down_settings[k]['Enabled']: + # Unapply the disabled preference if previously applied + self.clean(gpo.name, remove=k) + continue + + # Apply using the appropriate applier + data = str(self.lock_down_settings[k]) + value_hash = self.generate_value_hash(data) + if k == self.keys[0]: + self.apply(gpo.name, k, value_hash, + self.__apply_compose_key, + self.__clean_data(k), sep=';') + elif k == self.keys[1]: + self.apply(gpo.name, k, value_hash, + self.__apply_dim_idle, + self.__clean_data(k), sep=';') + elif k == self.keys[2]: + self.apply(gpo.name, k, value_hash, + self.__apply_specific_settings, + self.__clean_data(k), sep=';') + elif k == self.keys[3]: + self.apply(gpo.name, k, value_hash, + self.__apply_whitelisted_account, + self.__clean_data(k), sep=';') + elif k == self.keys[4]: + self.apply(gpo.name, k, value_hash, + self.__apply_enabled_extensions, + self.__clean_data(k), sep=';') + else: + self.apply(gpo.name, k, value_hash, + self.__apply_enabled, + k, sep=';') + + # Unapply any policy that has been removed + self.clean(gpo.name, keep=self.lock_down_settings.keys()) + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + section_name = 'GNOME Settings\\Lock Down Settings' + pol_file = 'MACHINE/Registry.pol' + 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.startswith(section_name) and e.data and \ + '**delvals.' not in e.valuename: + for k in self.keys: + if e.keyname.endswith(k): + self.__add_lockdown_data(k, e) + break + else: + self.__enable_lockdown_data(e) + for k in self.lock_down_settings.keys(): + if self.lock_down_settings[k]['Enabled']: + if len(self.lock_down_settings[k]) > 1: + data = self.__clean_data(k) + if all([i == data[i] for i in data.keys()]): + output[k] = list(data.keys()) + else: + output[k] = data + else: + output[k] = self.lock_down_settings[k] + return output diff --git a/python/samba/gp/gp_msgs_ext.py b/python/samba/gp/gp_msgs_ext.py new file mode 100644 index 0000000..9aadddf --- /dev/null +++ b/python/samba/gp/gp_msgs_ext.py @@ -0,0 +1,96 @@ +# gp_msgs_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_pol_ext, gp_misc_applier + +class gp_msgs_ext(gp_pol_ext, gp_misc_applier): + def unapply(self, guid, cdir, attribute, value): + if attribute not in ['motd', 'issue']: + raise ValueError('"%s" is not a message attribute' % attribute) + data = self.parse_value(value) + mfile = os.path.join(cdir, attribute) + if os.path.exists(mfile): + with open(mfile, 'r') as f: + current = f.read() + else: + current = '' + # Only overwrite the msg if it hasn't been modified. It may have been + # modified by another GPO. + if 'new_val' not in data or current.strip() == data['new_val'].strip(): + msg = data['old_val'] + with open(mfile, 'w') as w: + if msg: + w.write(msg) + else: + w.truncate() + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, cdir, entries): + section_name = 'Software\\Policies\\Samba\\Unix Settings\\Messages' + for e in entries: + if e.keyname == section_name and e.data.strip(): + if e.valuename not in ['motd', 'issue']: + raise ValueError('"%s" is not a message attribute' % + e.valuename) + mfile = os.path.join(cdir, e.valuename) + if os.path.exists(mfile): + with open(mfile, 'r') as f: + old_val = f.read() + else: + old_val = '' + # If policy is already applied, skip application + if old_val.strip() == e.data.strip(): + return + with open(mfile, 'w') as w: + w.write(e.data) + data = self.generate_value(old_val=old_val, new_val=e.data) + self.cache_add_attribute(guid, e.valuename, data) + + def __str__(self): + return 'Unix Settings/Messages' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + cdir='/etc'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, msg in settings[str(self)].items(): + self.unapply(guid, cdir, attribute, msg) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section_name = 'Software\\Policies\\Samba\\Unix Settings\\Messages' + 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 + self.apply(gpo.name, cdir, pol_conf.entries) + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + section_name = 'Software\\Policies\\Samba\\Unix Settings\\Messages' + pol_file = 'MACHINE/Registry.pol' + 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_name and e.data.strip(): + mfile = os.path.join('/etc', e.valuename) + output[mfile] = e.data + return output diff --git a/python/samba/gp/gp_scripts_ext.py b/python/samba/gp/gp_scripts_ext.py new file mode 100644 index 0000000..998b9cd --- /dev/null +++ b/python/samba/gp/gp_scripts_ext.py @@ -0,0 +1,187 @@ +# gp_scripts_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os, re +from subprocess import Popen, PIPE +from samba.gp.gpclass import gp_pol_ext, drop_privileges, gp_file_applier, \ + gp_misc_applier +from tempfile import NamedTemporaryFile +from samba.gp.util.logging import log + +intro = ''' +### autogenerated by samba +# +# This file is generated by the gp_scripts_ext Group Policy +# Client Side Extension. To modify the contents of this file, +# modify the appropriate Group Policy objects which apply +# to this machine. DO NOT MODIFY THIS FILE DIRECTLY. +# + +''' +end = ''' +### autogenerated by samba ### +''' + +class gp_scripts_ext(gp_pol_ext, gp_file_applier): + def __str__(self): + return 'Unix Settings/Scripts' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, cdir=None): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, script in settings[str(self)].items(): + self.unapply(guid, attribute, script) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + reg_key = 'Software\\Policies\\Samba\\Unix Settings' + sections = { '%s\\Daily Scripts' % reg_key : '/etc/cron.daily', + '%s\\Monthly Scripts' % reg_key : '/etc/cron.monthly', + '%s\\Weekly Scripts' % reg_key : '/etc/cron.weekly', + '%s\\Hourly Scripts' % reg_key : '/etc/cron.hourly' } + 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 + policies = {} + for e in pol_conf.entries: + if e.keyname in sections.keys() and e.data.strip(): + if e.keyname not in policies: + policies[e.keyname] = [] + policies[e.keyname].append(e.data) + def applier_func(keyname, entries): + ret = [] + cron_dir = sections[keyname] if not cdir else cdir + for data in entries: + with NamedTemporaryFile(prefix='gp_', mode="w+", + delete=False, dir=cron_dir) as f: + contents = '#!/bin/sh\n%s' % intro + contents += '%s\n' % data + f.write(contents) + os.chmod(f.name, 0o700) + ret.append(f.name) + return ret + for keyname, entries in policies.items(): + # Each GPO applies only one set of each type of script, so + # so the attribute matches the keyname. + attribute = keyname + # The value hash is generated from the script entries, + # ensuring any changes to this GPO will cause the scripts + # to be rewritten. + value_hash = self.generate_value_hash(*entries) + self.apply(gpo.name, attribute, value_hash, applier_func, + keyname, entries) + + # Cleanup any old scripts that are no longer part of the policy + self.clean(gpo.name, keep=policies.keys()) + + def rsop(self, gpo, target='MACHINE'): + output = {} + pol_file = '%s/Registry.pol' % target + 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: + key = e.keyname.split('\\')[-1] + if key.endswith('Scripts') and e.data.strip(): + if key not in output.keys(): + output[key] = [] + output[key].append(e.data) + return output + +def fetch_crontab(username): + p = Popen(['crontab', '-l', '-u', username], stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + if p.returncode != 0: + log.warning('Failed to read the crontab: %s' % err) + m = re.findall('%s(.*)%s' % (intro, end), out.decode(), re.DOTALL) + if len(m) == 1: + entries = m[0].strip().split('\n') + else: + entries = [] + m = re.findall('(.*)%s.*%s(.*)' % (intro, end), out.decode(), re.DOTALL) + if len(m) == 1: + others = '\n'.join([l.strip() for l in m[0]]) + else: + others = out.decode() + return others, entries + +def install_crontab(fname, username): + p = Popen(['crontab', fname, '-u', username], stdout=PIPE, stderr=PIPE) + _, err = p.communicate() + if p.returncode != 0: + raise RuntimeError('Failed to install crontab: %s' % err) + +def install_user_crontab(username, others, entries): + with NamedTemporaryFile() as f: + if len(entries) > 0: + f.write('\n'.join([others, intro, + '\n'.join(entries), end]).encode()) + else: + f.write(others.encode()) + f.flush() + install_crontab(f.name, username) + +class gp_user_scripts_ext(gp_scripts_ext, gp_misc_applier): + def unapply(self, guid, attribute, entry): + others, entries = fetch_crontab(self.username) + if entry in entries: + entries.remove(entry) + install_user_crontab(self.username, others, entries) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, attribute, entry): + old_val = self.cache_get_attribute_value(guid, attribute) + others, entries = fetch_crontab(self.username) + if not old_val or entry not in entries: + entries.append(entry) + install_user_crontab(self.username, others, entries) + self.cache_add_attribute(guid, attribute, entry) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, entry in settings[str(self)].items(): + self.unapply(guid, attribute, entry) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + reg_key = 'Software\\Policies\\Samba\\Unix Settings' + sections = { '%s\\Daily Scripts' % reg_key : '@daily', + '%s\\Monthly Scripts' % reg_key : '@monthly', + '%s\\Weekly Scripts' % reg_key : '@weekly', + '%s\\Hourly Scripts' % reg_key : '@hourly' } + pol_file = 'USER/Registry.pol' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = drop_privileges('root', self.parse, path) + if not pol_conf: + continue + attrs = [] + for e in pol_conf.entries: + if e.keyname in sections.keys() and e.data.strip(): + cron_freq = sections[e.keyname] + attribute = '%s:%s' % (e.keyname, + self.generate_attribute(e.data)) + attrs.append(attribute) + entry = '%s %s' % (cron_freq, e.data) + self.apply(gpo.name, attribute, entry) + self.clean(gpo.name, keep=attrs) + + def rsop(self, gpo): + return super().rsop(gpo, target='USER') diff --git a/python/samba/gp/gp_sec_ext.py b/python/samba/gp/gp_sec_ext.py new file mode 100644 index 0000000..39b9cdc --- /dev/null +++ b/python/samba/gp/gp_sec_ext.py @@ -0,0 +1,221 @@ +# gp_sec_ext kdc gpo policy +# Copyright (C) Luke Morrison <luc785@.hotmail.com> 2013 +# Copyright (C) David Mulder <dmulder@suse.com> 2018 +# +# 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 <http://www.gnu.org/licenses/>. + +import os.path +from samba.gp.gpclass import gp_inf_ext +from samba.auth import system_session +from samba.common import get_string +try: + from ldb import LdbError + from samba.samdb import SamDB +except ImportError: + pass +from samba.gp.util.logging import log + +def mins_to_hours(val): + return '%d' % (int(val) / 60) + +def days_to_hours(val): + return '%d' % (int(val) * 24) + +def days2rel_nttime(val): + seconds = 60 + minutes = 60 + hours = 24 + sam_add = 10000000 + val = int(val) + return str(-(val * seconds * minutes * hours * sam_add)) + +class gp_krb_ext(gp_inf_ext): + apply_map = { 'MaxTicketAge': 'kdc:user_ticket_lifetime', + 'MaxServiceAge': 'kdc:service_ticket_lifetime', + 'MaxRenewAge': 'kdc:renewal_lifetime' } + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + if self.lp.get('server role') != 'active directory domain controller': + return + inf_file = 'MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf' + for guid, settings in deleted_gpo_list: + self.gp_db.set_guid(guid) + for section in settings.keys(): + if section == str(self): + for att, value in settings[section].items(): + self.set_kdc_tdb(att, value) + self.gp_db.delete(section, att) + self.gp_db.commit() + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + self.gp_db.set_guid(gpo.name) + path = os.path.join(gpo.file_sys_path, inf_file) + inf_conf = self.parse(path) + if not inf_conf: + continue + for section in inf_conf.sections(): + if section == str(self): + for key, value in inf_conf.items(section): + if key not in gp_krb_ext.apply_map: + continue + att = gp_krb_ext.apply_map[key] + value_func = self.mapper().get(att) + self.set_kdc_tdb(att, value_func(value)) + self.gp_db.commit() + + def set_kdc_tdb(self, attribute, val): + old_val = self.gp_db.gpostore.get(attribute) + log.info('%s was changed from %s to %s' % (attribute, old_val, val)) + if val is not None: + self.gp_db.gpostore.store(attribute, get_string(val)) + self.gp_db.store(str(self), attribute, get_string(old_val) + if old_val else None) + else: + self.gp_db.gpostore.delete(attribute) + self.gp_db.delete(str(self), attribute) + + def mapper(self): + return {'kdc:user_ticket_lifetime': lambda val: val, + 'kdc:service_ticket_lifetime': mins_to_hours, + 'kdc:renewal_lifetime': days_to_hours, + } + + def __str__(self): + return 'Kerberos Policy' + + def rsop(self, gpo): + output = {} + if self.lp.get('server role') != 'active directory domain controller': + return output + inf_file = 'MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, inf_file) + inf_conf = self.parse(path) + if not inf_conf: + return output + if str(self) in inf_conf.sections(): + section = str(self) + output[section] = {k: v for k, v in inf_conf.items(section) + if gp_krb_ext.apply_map.get(k)} + return output + + +class gp_access_ext(gp_inf_ext): + """This class takes the .inf file parameter (essentially a GPO file mapped + to a GUID), hashmaps it to the Samba parameter, which then uses an ldb + object to update the parameter to Samba4. Not registry oriented whatsoever. + """ + + def load_ldb(self): + try: + self.ldb = SamDB(self.lp.samdb_url(), + session_info=system_session(), + credentials=self.creds, + lp=self.lp) + except (NameError, LdbError): + raise Exception('Failed to load SamDB for assigning Group Policy') + + apply_map = { 'MinimumPasswordAge': 'minPwdAge', + 'MaximumPasswordAge': 'maxPwdAge', + 'MinimumPasswordLength': 'minPwdLength', + 'PasswordComplexity': 'pwdProperties' } + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + if self.lp.get('server role') != 'active directory domain controller': + return + self.load_ldb() + inf_file = 'MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf' + for guid, settings in deleted_gpo_list: + self.gp_db.set_guid(guid) + for section in settings.keys(): + if section == str(self): + for att, value in settings[section].items(): + update_samba, _ = self.mapper().get(att) + update_samba(att, value) + self.gp_db.delete(section, att) + self.gp_db.commit() + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + self.gp_db.set_guid(gpo.name) + path = os.path.join(gpo.file_sys_path, inf_file) + inf_conf = self.parse(path) + if not inf_conf: + continue + for section in inf_conf.sections(): + if section == str(self): + for key, value in inf_conf.items(section): + if key not in gp_access_ext.apply_map: + continue + att = gp_access_ext.apply_map[key] + (update_samba, value_func) = self.mapper().get(att) + update_samba(att, value_func(value)) + self.gp_db.commit() + + def ch_minPwdAge(self, attribute, val): + old_val = self.ldb.get_minPwdAge() + log.info('KDC Minimum Password age was changed from %s to %s' + % (old_val, val)) + self.gp_db.store(str(self), attribute, str(old_val)) + self.ldb.set_minPwdAge(val) + + def ch_maxPwdAge(self, attribute, val): + old_val = self.ldb.get_maxPwdAge() + log.info('KDC Maximum Password age was changed from %s to %s' + % (old_val, val)) + self.gp_db.store(str(self), attribute, str(old_val)) + self.ldb.set_maxPwdAge(val) + + def ch_minPwdLength(self, attribute, val): + old_val = self.ldb.get_minPwdLength() + log.info('KDC Minimum Password length was changed from %s to %s' + % (old_val, val)) + self.gp_db.store(str(self), attribute, str(old_val)) + self.ldb.set_minPwdLength(val) + + def ch_pwdProperties(self, attribute, val): + old_val = self.ldb.get_pwdProperties() + log.info('KDC Password Properties were changed from %s to %s' + % (old_val, val)) + self.gp_db.store(str(self), attribute, str(old_val)) + self.ldb.set_pwdProperties(val) + + def mapper(self): + """ldap value : samba setter""" + return {"minPwdAge": (self.ch_minPwdAge, days2rel_nttime), + "maxPwdAge": (self.ch_maxPwdAge, days2rel_nttime), + # Could be none, but I like the method assignment in + # update_samba + "minPwdLength": (self.ch_minPwdLength, lambda val: val), + "pwdProperties": (self.ch_pwdProperties, lambda val: val), + + } + + def __str__(self): + return 'System Access' + + def rsop(self, gpo): + output = {} + if self.lp.get('server role') != 'active directory domain controller': + return output + inf_file = 'MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, inf_file) + inf_conf = self.parse(path) + if not inf_conf: + return output + if str(self) in inf_conf.sections(): + section = str(self) + output[section] = {k: v for k, v in inf_conf.items(section) + if gp_access_ext.apply_map.get(k)} + return output diff --git a/python/samba/gp/gp_smb_conf_ext.py b/python/samba/gp/gp_smb_conf_ext.py new file mode 100644 index 0000000..3ef9cfd --- /dev/null +++ b/python/samba/gp/gp_smb_conf_ext.py @@ -0,0 +1,127 @@ +# gp_smb_conf_ext smb.conf gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2018 +# +# 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 <http://www.gnu.org/licenses/>. + +import os, numbers +from samba.gp.gpclass import gp_pol_ext, gp_misc_applier +from tempfile import NamedTemporaryFile +from samba.gp.util.logging import log + +def is_number(x): + return isinstance(x, numbers.Number) and \ + type(x) != bool + +class gp_smb_conf_ext(gp_pol_ext, gp_misc_applier): + def unapply(self, guid, attribute, val): + current = self.lp.get(attribute) + data = self.parse_value(val) + + # Only overwrite the smb.conf setting if it hasn't been modified. It + # may have been modified by another GPO. + if 'new_val' not in data or \ + self.lptype_to_string(current) == data['new_val']: + self.lp.set(attribute, self.regtype_to_lptype(data['old_val'], + current)) + self.store_lp_smb_conf(self.lp) + log.info('smb.conf [global] was changed', + { attribute : str(data['old_val']) }) + + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, attribute, val): + old_val = self.lp.get(attribute) + val = self.regtype_to_lptype(val, old_val) + + self.lp.set(attribute, val) + self.store_lp_smb_conf(self.lp) + log.info('smb.conf [global] was changed', { attribute : str(val) }) + + data = self.generate_value(old_val=self.lptype_to_string(old_val), + new_val=self.lptype_to_string(val)) + self.cache_add_attribute(guid, attribute, data) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + pol_file = 'MACHINE/Registry.pol' + for guid, settings in deleted_gpo_list: + smb_conf = settings.get('smb.conf') + if smb_conf is None: + continue + for key, value in smb_conf.items(): + self.unapply(guid, key, value) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section_name = 'Software\\Policies\\Samba\\smb_conf' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + continue + attrs = [] + for e in pol_conf.entries: + if not e.keyname.startswith(section_name): + continue + attrs.append(e.valuename) + self.apply(gpo.name, e.valuename, e.data) + + # Cleanup settings which were removed from the policy + self.clean(gpo.name, keep=attrs) + + def regtype_to_lptype(self, val, old_val): + if type(val) == bytes: + val = val.decode() + if is_number(val) and is_number(old_val): + val = str(val) + elif is_number(val) and type(old_val) == bool: + val = bool(val) + if type(val) == bool: + val = 'yes' if val else 'no' + return val + + def store_lp_smb_conf(self, lp): + with NamedTemporaryFile(delete=False, + dir=os.path.dirname(lp.configfile)) as f: + lp.dump(False, f.name) + mode = os.stat(lp.configfile).st_mode + os.chmod(f.name, mode) + os.rename(f.name, lp.configfile) + + def lptype_to_string(self, val): + if is_number(val): + val = str(val) + elif type(val) == bool: + val = 'yes' if val else 'no' + elif type(val) == list: + val = ' '.join(val) + return val + + def __str__(self): + return "smb.conf" + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + section_name = 'Software\\Policies\\Samba\\smb_conf' + pol_file = 'MACHINE/Registry.pol' + 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 not e.keyname.startswith(section_name): + continue + if 'smb.conf' not in output.keys(): + output['smb.conf'] = {} + output['smb.conf'][e.valuename] = e.data + return output diff --git a/python/samba/gp/gp_sudoers_ext.py b/python/samba/gp/gp_sudoers_ext.py new file mode 100644 index 0000000..026aeba --- /dev/null +++ b/python/samba/gp/gp_sudoers_ext.py @@ -0,0 +1,116 @@ +# gp_sudoers_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_pol_ext, gp_file_applier +from tempfile import NamedTemporaryFile +from subprocess import Popen, PIPE +from samba.gp.util.logging import log + +def find_executable(executable, path): + paths = path.split(os.pathsep) + for p in paths: + f = os.path.join(p, executable) + if os.path.isfile(f): + return f + return None + +intro = ''' +### autogenerated by samba +# +# This file is generated by the gp_sudoers_ext Group Policy +# Client Side Extension. To modify the contents of this file, +# modify the appropriate Group Policy objects which apply +# to this machine. DO NOT MODIFY THIS FILE DIRECTLY. +# + +''' +visudo = find_executable('visudo', + path='%s:%s' % (os.environ['PATH'], '/usr/sbin')) + +def sudo_applier_func(sudo_dir, sudo_entries): + ret = [] + for p in sudo_entries: + contents = intro + contents += '%s\n' % p + with NamedTemporaryFile() as f: + with open(f.name, 'w') as w: + w.write(contents) + if visudo is None: + raise FileNotFoundError('visudo not found, please install it') + with Popen([visudo, '-c', '-f', f.name], + stdout=PIPE, stderr=PIPE) as proc: + sudo_validation = proc.wait() + if sudo_validation == 0: + with NamedTemporaryFile(prefix='gp_', + delete=False, + dir=sudo_dir) as f: + with open(f.name, 'w') as w: + w.write(contents) + ret.append(f.name) + else: + log.error('Sudoers apply failed', p) + return ret + +class gp_sudoers_ext(gp_pol_ext, gp_file_applier): + def __str__(self): + return 'Unix Settings/Sudo Rights' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + sdir='/etc/sudoers.d'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, sudoers in settings[str(self)].items(): + self.unapply(guid, attribute, sudoers) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\\Policies\\Samba\\Unix Settings\\Sudo Rights' + 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 + sudo_entries = [] + for e in pol_conf.entries: + if e.keyname == section and e.data.strip(): + sudo_entries.append(e.data) + # Each GPO applies only one set of sudoers, in a + # set of files, so the attribute does not need uniqueness. + attribute = self.generate_attribute(gpo.name) + # The value hash is generated from the sudo_entries, ensuring + # any changes to this GPO will cause the files to be rewritten. + value_hash = self.generate_value_hash(*sudo_entries) + self.apply(gpo.name, attribute, value_hash, sudo_applier_func, + sdir, sudo_entries) + # Cleanup any old entries that are no longer part of the policy + self.clean(gpo.name, keep=[attribute]) + + def rsop(self, gpo): + output = {} + pol_file = 'MACHINE/Registry.pol' + 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: + key = e.keyname.split('\\')[-1] + if key.endswith('Sudo Rights') and e.data.strip(): + if key not in output.keys(): + output[key] = [] + output[key].append(e.data) + return output diff --git a/python/samba/gp/gpclass.py b/python/samba/gp/gpclass.py new file mode 100644 index 0000000..08be472 --- /dev/null +++ b/python/samba/gp/gpclass.py @@ -0,0 +1,1312 @@ +# Reads important GPO parameters and updates Samba +# Copyright (C) Luke Morrison <luc785@.hotmail.com> 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 <http://www.gnu.org/licenses/>. + + +import sys +import os, shutil +import errno +import tdb +import pwd +sys.path.insert(0, "bin/python") +from samba import 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 uuid import UUID +from tempfile import NamedTemporaryFile +from samba.dcerpc import preg +from samba.ndr import 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_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 nbt +from datetime import datetime + + +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: + +<gp> + <user name="KDC-1$"> + <applylog> + <guid count="0" value="{31B2F340-016D-11D2-945F-00C04FB984F9}" /> + </applylog> + <guid value="{31B2F340-016D-11D2-945F-00C04FB984F9}"> + <gp_ext name="System Access"> + <attribute name="minPwdAge">-864000000000</attribute> + <attribute name="maxPwdAge">-36288000000000</attribute> + <attribute name="minPwdLength">7</attribute> + <attribute name="pwdProperties">1</attribute> + </gp_ext> + <gp_ext name="Kerberos Policy"> + <attribute name="ticket_lifetime">1d</attribute> + <attribute name="renew_lifetime" /> + <attribute name="clockskew">300</attribute> + </gp_ext> + </guid> + </user> +</gp> + + 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): + with open(data_file, 'rb') as f: + policy = f.read() + inf_conf = ConfigParser(interpolation=None) + inf_conf.optionxform = str + try: + inf_conf.read_file(StringIO(policy.decode())) + except UnicodeDecodeError: + inf_conf.read_file(StringIO(policy.decode('utf-16'))) + return inf_conf + + +class gp_pol_ext(gp_ext): + def read(self, data_file): + with open(data_file, 'rb') as f: + raw = f.read() + return ndr_unpack(preg.file, raw) + + +class gp_xml_ext(gp_ext): + def read(self, data_file): + with open(data_file, 'rb') as f: + raw = f.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 + + +""" 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 'gPLink' not 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_with_system_token(token_1): + sids = token_1.sids + system_token = system_session().security_token + sids.extend(system_token.sids) + token_1.sids = sids + token_1.rights_mask |= system_token.rights_mask + token_1.privilege_mask |= system_token.privilege_mask + # There are no claims in the system token, so it is safe not to merge the claims + return token_1 + + +def site_dn_for_machine(samdb, dc_hostname, lp, creds, hostname): + # [MS-GPOL] 3.2.5.1.4 Site Search + + # The netr_DsRGetSiteName() needs to run over local rpc, however we do not + # have the call implemented in our rpc_server. + # What netr_DsRGetSiteName() actually does is an ldap query to get + # the sitename, we can do the same. + + # NtVer=(NETLOGON_NT_VERSION_IP|NETLOGON_NT_VERSION_WITH_CLOSEST_SITE| + # NETLOGON_NT_VERSION_5EX) [0x20000014] + expr = "(&(DnsDomain=%s.)(User=%s)(NtVer=\\14\\00\\00\\20))" % ( + samdb.domain_dns_name(), + hostname) + res = samdb.search( + base='', + scope=ldb.SCOPE_BASE, + expression=expr, + attrs=["Netlogon"]) + if res.count != 1: + raise RuntimeError('site_dn_for_machine: No result') + + samlogon_response = ndr_unpack(nbt.netlogon_samlogon_response, + bytes(res.msgs[0]['Netlogon'][0])) + if samlogon_response.ntver not in [nbt.NETLOGON_NT_VERSION_5EX, + (nbt.NETLOGON_NT_VERSION_1 + | nbt.NETLOGON_NT_VERSION_5EX)]: + raise RuntimeError('site_dn_for_machine: Invalid NtVer in ' + + 'netlogon_samlogon_response') + + # We want NETLOGON_NT_VERSION_5EX out of the union! + samlogon_response.ntver = nbt.NETLOGON_NT_VERSION_5EX + samlogon_response_ex = samlogon_response.data + + client_site = "Default-First-Site-Name" + if (samlogon_response_ex.client_site + and len(samlogon_response_ex.client_site) > 1): + client_site = samlogon_response_ex.client_site + + site_dn = samdb.get_config_basedn() + site_dn.add_child("CN=Sites") + site_dn.add_child("CN=%s" % (client_site)) + + 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_with_system_token(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: + try: + 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) + except ldb.LdbError: + # [MS-GPOL] 3.2.5.1.4 Site Search: If the method returns + # ERROR_NO_SITENAME, the remainder of this message MUST be skipped + # and the protocol sequence MUST continue at GPO Search + pass + + # (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 + +def expand_pref_variables(text, gpt_path, lp, username=None): + utc_dt = datetime.utcnow() + dt = datetime.now() + cache_path = lp.cache_path(os.path.join('gpo_cache')) + # These are all the possible preference variables that MS supports. The + # variables set to 'None' here are currently unsupported by Samba, and will + # prevent the individual policy from applying. + variables = { 'AppDataDir': os.path.expanduser('~/.config'), + 'BinaryComputerSid': None, + 'BinaryUserSid': None, + 'CommonAppdataDir': None, + 'CommonDesktopDir': None, + 'CommonFavoritesDir': None, + 'CommonProgramsDir': None, + 'CommonStartUpDir': None, + 'ComputerName': lp.get('netbios name'), + 'CurrentProccessId': None, + 'CurrentThreadId': None, + 'DateTime': utc_dt.strftime('%Y-%m-%d %H:%M:%S UTC'), + 'DateTimeEx': str(utc_dt), + 'DesktopDir': os.path.expanduser('~/Desktop'), + 'DomainName': lp.get('realm'), + 'FavoritesDir': None, + 'GphPath': None, + 'GptPath': os.path.join(cache_path, + check_safe_path(gpt_path).upper()), + 'GroupPolicyVersion': None, + 'LastDriveMapped': None, + 'LastError': None, + 'LastErrorText': None, + 'LdapComputerSid': None, + 'LdapUserSid': None, + 'LocalTime': dt.strftime('%H:%M:%S'), + 'LocalTimeEx': dt.strftime('%H:%M:%S.%f'), + 'LogonDomain': lp.get('realm'), + 'LogonServer': None, + 'LogonUser': username, + 'LogonUserSid': None, + 'MacAddress': None, + 'NetPlacesDir': None, + 'OsVersion': None, + 'ProgramFilesDir': None, + 'ProgramsDir': None, + 'RecentDocumentsDir': None, + 'ResultCode': None, + 'ResultText': None, + 'ReversedComputerSid': None, + 'ReversedUserSid': None, + 'SendToDir': None, + 'StartMenuDir': None, + 'StartUpDir': None, + 'SystemDir': None, + 'SystemDrive': '/', + 'TempDir': '/tmp', + 'TimeStamp': str(datetime.timestamp(dt)), + 'TraceFile': None, + 'WindowsDir': None + } + for exp_var, val in variables.items(): + exp_var_fmt = '%%%s%%' % exp_var + if exp_var_fmt in text: + if val is None: + raise NameError('Expansion variable %s is undefined' % exp_var) + text = text.replace(exp_var_fmt, val) + return text diff --git a/python/samba/gp/util/logging.py b/python/samba/gp/util/logging.py new file mode 100644 index 0000000..da085d8 --- /dev/null +++ b/python/samba/gp/util/logging.py @@ -0,0 +1,112 @@ +# +# samba-gpupdate enhanced logging +# +# Copyright (C) 2019-2020 BaseALT Ltd. +# Copyright (C) David Mulder <dmulder@samba.org> 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 <http://www.gnu.org/licenses/>. + +import datetime +import logging +import gettext +import random +import sys + +logger = logging.getLogger("gp") + + +def logger_init(name, log_level): + 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/python/samba/gp/vgp_access_ext.py b/python/samba/gp/vgp_access_ext.py new file mode 100644 index 0000000..7efb3bb --- /dev/null +++ b/python/samba/gp/vgp_access_ext.py @@ -0,0 +1,178 @@ +# vgp_access_ext samba group policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os, re +from samba.gp.gpclass import gp_xml_ext, gp_file_applier +from tempfile import NamedTemporaryFile +from samba.gp.util.logging import log + +intro = ''' +### autogenerated by samba +# +# This file is generated by the vgp_access_ext Group Policy +# Client Side Extension. To modify the contents of this file, +# modify the appropriate Group Policy objects which apply +# to this machine. DO NOT MODIFY THIS FILE DIRECTLY. +# + +''' + +# The deny all file is implicit any time an allow entry is used +DENY_BOUND = 9000000000 +DENY_FILE = '_gp_DENY_ALL.conf' + +# Each policy MUST create it's own DENY_ALL file if an allow entry exists, +# otherwise policies will conflict and one could remove a DENY_ALL when another +# one still requires it. +def deny_file(access): + deny_filename = os.path.join(access, + '%d%s' % (select_next_deny(access), DENY_FILE)) + with NamedTemporaryFile(delete=False, dir=access) as f: + with open(f.name, 'w') as w: + w.write(intro) + w.write('-:ALL:ALL') + os.chmod(f.name, 0o644) + os.rename(f.name, deny_filename) + return deny_filename + +def select_next_deny(directory): + configs = [re.match(r'(\d+)', f) for f in os.listdir(directory) if DENY_FILE in f] + return max([int(m.group(1)) for m in configs if m]+[DENY_BOUND])+1 + +# Access files in /etc/security/access.d are read in the order of the system +# locale. Here we number the conf files to ensure they are read in the correct +# order. Ignore the deny file, since allow entries should always come before +# the implicit deny ALL. +def select_next_conf(directory): + configs = [re.match(r'(\d+)', f) for f in os.listdir(directory) if DENY_FILE not in f] + return max([int(m.group(1)) for m in configs if m]+[0])+1 + +class vgp_access_ext(gp_xml_ext, gp_file_applier): + def __str__(self): + return 'VGP/Unix Settings/Host Access' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + access='/etc/security/access.d'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, policy_files in settings[str(self)].items(): + self.unapply(guid, attribute, policy_files) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + allow = 'MACHINE/VGP/VTLA/VAS/HostAccessControl/Allow/manifest.xml' + path = os.path.join(gpo.file_sys_path, allow) + allow_conf = self.parse(path) + deny = 'MACHINE/VGP/VTLA/VAS/HostAccessControl/Deny/manifest.xml' + path = os.path.join(gpo.file_sys_path, deny) + deny_conf = self.parse(path) + entries = [] + policy_files = [] + winbind_sep = self.lp.get('winbind separator') + if allow_conf: + policy = allow_conf.find('policysetting') + data = policy.find('data') + allow_listelements = data.findall('listelement') + for listelement in allow_listelements: + adobject = listelement.find('adobject') + name = adobject.find('name').text + domain = adobject.find('domain').text + entries.append('+:%s%s%s:ALL' % (domain, + winbind_sep, + name)) + if len(allow_listelements) > 0: + log.info('Adding an implicit deny ALL because an allow' + ' entry is present') + policy_files.append(deny_file(access)) + if deny_conf: + policy = deny_conf.find('policysetting') + data = policy.find('data') + for listelement in data.findall('listelement'): + adobject = listelement.find('adobject') + name = adobject.find('name').text + domain = adobject.find('domain').text + entries.append('-:%s%s%s:ALL' % (domain, + winbind_sep, + name)) + if len(allow_listelements) > 0: + log.warn("Deny entry '%s' is meaningless with " + "allow present" % entries[-1]) + if len(entries) == 0: + continue + conf_id = select_next_conf(access) + access_file = os.path.join(access, '%010d_gp.conf' % conf_id) + policy_files.append(access_file) + access_contents = '\n'.join(entries) + # Each GPO applies only one set of access policies, so the + # attribute does not need uniqueness. + attribute = self.generate_attribute(gpo.name) + # The value hash is generated from the access policy, ensuring + # any changes to this GPO will cause the files to be rewritten. + value_hash = self.generate_value_hash(access_contents) + def applier_func(access, access_file, policy_files): + if not os.path.isdir(access): + os.mkdir(access, 0o644) + with NamedTemporaryFile(delete=False, dir=access) as f: + with open(f.name, 'w') as w: + w.write(intro) + w.write(access_contents) + os.chmod(f.name, 0o644) + os.rename(f.name, access_file) + return policy_files + self.apply(gpo.name, attribute, value_hash, applier_func, + access, access_file, policy_files) + # Cleanup any old entries that are no longer part of the policy + self.clean(gpo.name, keep=[attribute]) + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + allow = 'MACHINE/VGP/VTLA/VAS/HostAccessControl/Allow/manifest.xml' + path = os.path.join(gpo.file_sys_path, allow) + allow_conf = self.parse(path) + deny = 'MACHINE/VGP/VTLA/VAS/HostAccessControl/Deny/manifest.xml' + path = os.path.join(gpo.file_sys_path, deny) + deny_conf = self.parse(path) + entries = [] + winbind_sep = self.lp.get('winbind separator') + if allow_conf: + policy = allow_conf.find('policysetting') + data = policy.find('data') + allow_listelements = data.findall('listelement') + for listelement in allow_listelements: + adobject = listelement.find('adobject') + name = adobject.find('name').text + domain = adobject.find('domain').text + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append('+:%s%s%s:ALL' % (name, + winbind_sep, + domain)) + if len(allow_listelements) > 0: + output[str(self)].append('-:ALL:ALL') + if deny_conf: + policy = deny_conf.find('policysetting') + data = policy.find('data') + for listelement in data.findall('listelement'): + adobject = listelement.find('adobject') + name = adobject.find('name').text + domain = adobject.find('domain').text + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append('-:%s%s%s:ALL' % (name, + winbind_sep, + domain)) + return output diff --git a/python/samba/gp/vgp_files_ext.py b/python/samba/gp/vgp_files_ext.py new file mode 100644 index 0000000..78bfc28 --- /dev/null +++ b/python/samba/gp/vgp_files_ext.py @@ -0,0 +1,140 @@ +# vgp_files_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os, pwd, grp +from samba.gp.gpclass import gp_xml_ext, check_safe_path, gp_file_applier +from tempfile import NamedTemporaryFile +from shutil import copyfile, move +from samba.gp.util.logging import log + +def calc_mode(entry): + mode = 0o000 + for permissions in entry.findall('permissions'): + ptype = permissions.get('type') + if ptype == 'user': + if permissions.find('read') is not None: + mode |= 0o400 + if permissions.find('write') is not None: + mode |= 0o200 + if permissions.find('execute') is not None: + mode |= 0o100 + elif ptype == 'group': + if permissions.find('read') is not None: + mode |= 0o040 + if permissions.find('write') is not None: + mode |= 0o020 + if permissions.find('execute') is not None: + mode |= 0o010 + elif ptype == 'other': + if permissions.find('read') is not None: + mode |= 0o004 + if permissions.find('write') is not None: + mode |= 0o002 + if permissions.find('execute') is not None: + mode |= 0o001 + return mode + +def stat_from_mode(mode): + stat = '-' + for i in range(6, -1, -3): + mask = {0o4: 'r', 0o2: 'w', 0o1: 'x'} + for x in mask.keys(): + if mode & (x << i): + stat += mask[x] + else: + stat += '-' + return stat + +def source_file_change(fname): + if os.path.exists(fname): + return b'%d' % os.stat(fname).st_ctime + +class vgp_files_ext(gp_xml_ext, gp_file_applier): + def __str__(self): + return 'VGP/Unix Settings/Files' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, _ in settings[str(self)].items(): + self.unapply(guid, attribute, attribute) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Unix/Files/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + continue + policy = xml_conf.find('policysetting') + data = policy.find('data') + for entry in data.findall('file_properties'): + local_path = self.lp.cache_path('gpo_cache') + source = entry.find('source').text + source_file = os.path.join(local_path, + os.path.dirname(check_safe_path(path)).upper(), + source.upper()) + if not os.path.exists(source_file): + log.warn('Source file does not exist', source_file) + continue + target = entry.find('target').text + user = entry.find('user').text + group = entry.find('group').text + mode = calc_mode(entry) + + # The attribute is simply the target file. + attribute = target + # The value hash is generated from the source file last + # change stamp, the user, the group, and the mode, ensuring + # any changes to this GPO will cause the file to be + # rewritten. + value_hash = self.generate_value_hash( + source_file_change(source_file), + user, group, b'%d' % mode) + def applier_func(source_file, target, user, group, mode): + with NamedTemporaryFile(dir=os.path.dirname(target), + delete=False) as f: + copyfile(source_file, f.name) + os.chown(f.name, pwd.getpwnam(user).pw_uid, + grp.getgrnam(group).gr_gid) + os.chmod(f.name, mode) + move(f.name, target) + return [target] + self.apply(gpo.name, attribute, value_hash, applier_func, + source_file, target, user, group, mode) + + def rsop(self, gpo): + output = {} + xml = 'MACHINE/VGP/VTLA/Unix/Files/manifest.xml' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + policy = xml_conf.find('policysetting') + data = policy.find('data') + for entry in data.findall('file_properties'): + source = entry.find('source').text + target = entry.find('target').text + user = entry.find('user').text + group = entry.find('group').text + mode = calc_mode(entry) + p = '%s\t%s\t%s\t%s -> %s' % \ + (stat_from_mode(mode), user, group, target, source) + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append(p) + return output diff --git a/python/samba/gp/vgp_issue_ext.py b/python/samba/gp/vgp_issue_ext.py new file mode 100644 index 0000000..266e92b --- /dev/null +++ b/python/samba/gp/vgp_issue_ext.py @@ -0,0 +1,90 @@ +# vgp_issue_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_xml_ext, gp_misc_applier + +class vgp_issue_ext(gp_xml_ext, gp_misc_applier): + def unapply(self, guid, issue, attribute, value): + if attribute != 'issue': + raise ValueError('"%s" is not a message attribute' % attribute) + msg = value + data = self.parse_value(value) + if os.path.exists(issue): + with open(issue, 'r') as f: + current = f.read() + else: + current = '' + # Only overwrite the msg if it hasn't been modified. It may have been + # modified by another GPO. + if 'new_val' not in data or current.strip() == data['new_val'].strip(): + msg = data['old_val'] + with open(issue, 'w') as w: + if msg: + w.write(msg) + else: + w.truncate() + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, issue, text): + if os.path.exists(issue): + with open(issue, 'r') as f: + current = f.read() + else: + current = '' + if current != text.text: + with open(issue, 'w') as w: + w.write(text.text) + data = self.generate_value(old_val=current, new_val=text.text) + self.cache_add_attribute(guid, 'issue', data) + + def __str__(self): + return 'Unix Settings/Issue' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + issue='/etc/issue'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, msg in settings[str(self)].items(): + self.unapply(guid, issue, attribute, msg) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Unix/Issue/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + continue + policy = xml_conf.find('policysetting') + data = policy.find('data') + text = data.find('text') + self.apply(gpo.name, issue, text) + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Unix/Issue/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + policy = xml_conf.find('policysetting') + data = policy.find('data') + filename = data.find('filename') + text = data.find('text') + mfile = os.path.join('/etc', filename.text) + output[mfile] = text.text + return output diff --git a/python/samba/gp/vgp_motd_ext.py b/python/samba/gp/vgp_motd_ext.py new file mode 100644 index 0000000..845a5c4 --- /dev/null +++ b/python/samba/gp/vgp_motd_ext.py @@ -0,0 +1,90 @@ +# vgp_motd_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_xml_ext, gp_misc_applier + +class vgp_motd_ext(gp_xml_ext, gp_misc_applier): + def unapply(self, guid, motd, attribute, value): + if attribute != 'motd': + raise ValueError('"%s" is not a message attribute' % attribute) + msg = value + data = self.parse_value(value) + if os.path.exists(motd): + with open(motd, 'r') as f: + current = f.read() + else: + current = '' + # Only overwrite the msg if it hasn't been modified. It may have been + # modified by another GPO. + if 'new_val' not in data or current.strip() == data['new_val'].strip(): + msg = data['old_val'] + with open(motd, 'w') as w: + if msg: + w.write(msg) + else: + w.truncate() + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, motd, text): + if os.path.exists(motd): + with open(motd, 'r') as f: + current = f.read() + else: + current = '' + if current != text.text: + with open(motd, 'w') as w: + w.write(text.text) + data = self.generate_value(old_val=current, new_val=text.text) + self.cache_add_attribute(guid, 'motd', data) + + def __str__(self): + return 'Unix Settings/Message of the Day' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + motd='/etc/motd'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, msg in settings[str(self)].items(): + self.unapply(guid, motd, attribute, msg) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Unix/MOTD/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + continue + policy = xml_conf.find('policysetting') + data = policy.find('data') + text = data.find('text') + self.apply(gpo.name, motd, text) + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Unix/MOTD/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + policy = xml_conf.find('policysetting') + data = policy.find('data') + filename = data.find('filename') + text = data.find('text') + mfile = os.path.join('/etc', filename.text) + output[mfile] = text.text + return output diff --git a/python/samba/gp/vgp_openssh_ext.py b/python/samba/gp/vgp_openssh_ext.py new file mode 100644 index 0000000..6e0ab77 --- /dev/null +++ b/python/samba/gp/vgp_openssh_ext.py @@ -0,0 +1,115 @@ +# vgp_openssh_ext samba group policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +import re +from io import BytesIO +from samba.gp.gpclass import gp_xml_ext, gp_file_applier +from samba.common import get_bytes + +intro = b''' +### autogenerated by samba +# +# This file is generated by the vgp_openssh_ext Group Policy +# Client Side Extension. To modify the contents of this file, +# modify the appropriate Group Policy objects which apply +# to this machine. DO NOT MODIFY THIS FILE DIRECTLY. +# + +''' + +# For each key value pair in sshd_config, the first obtained value will be +# used. We must insert config files in reverse, so that the last applied policy +# takes precedence. +def select_next_conf(directory): + configs = [re.match(r'(\d+)', f) for f in os.listdir(directory)] + conf_ids = [int(m.group(1)) for m in configs if m] + conf_ids.append(9000000000) # The starting node + conf_id = min(conf_ids)-1 + return os.path.join(directory, '%010d_gp.conf' % conf_id) + +class vgp_openssh_ext(gp_xml_ext, gp_file_applier): + def __str__(self): + return 'VGP/Unix Settings/OpenSSH' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + cfg_dir='/etc/ssh/sshd_config.d'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, sshd_config in settings[str(self)].items(): + self.unapply(guid, attribute, sshd_config) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/SshCfg/SshD/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + continue + policy = xml_conf.find('policysetting') + data = policy.find('data') + configfile = data.find('configfile') + for configsection in configfile.findall('configsection'): + if configsection.find('sectionname').text: + continue + settings = {} + for kv in configsection.findall('keyvaluepair'): + settings[kv.find('key')] = kv.find('value') + raw = BytesIO() + for k, v in settings.items(): + raw.write(b'%s %s\n' % + (get_bytes(k.text), get_bytes(v.text))) + # Each GPO applies only one set of OpenSSH settings, in a + # single file, so the attribute does not need uniqueness. + attribute = self.generate_attribute(gpo.name) + # The value hash is generated from the raw data we will + # write to the OpenSSH settings file, ensuring any changes + # to this GPO will cause the file to be rewritten. + value_hash = self.generate_value_hash(raw.getvalue()) + if not os.path.isdir(cfg_dir): + os.mkdir(cfg_dir, 0o640) + def applier_func(cfg_dir, raw): + filename = select_next_conf(cfg_dir) + f = open(filename, 'wb') + f.write(intro) + f.write(raw.getvalue()) + os.chmod(filename, 0o640) + f.close() + return [filename] + self.apply(gpo.name, attribute, value_hash, applier_func, + cfg_dir, raw) + raw.close() + + def rsop(self, gpo): + output = {} + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/SshCfg/SshD/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + policy = xml_conf.find('policysetting') + data = policy.find('data') + configfile = data.find('configfile') + for configsection in configfile.findall('configsection'): + if configsection.find('sectionname').text: + continue + for kv in configsection.findall('keyvaluepair'): + if str(self) not in output.keys(): + output[str(self)] = {} + output[str(self)][kv.find('key').text] = \ + kv.find('value').text + return output diff --git a/python/samba/gp/vgp_startup_scripts_ext.py b/python/samba/gp/vgp_startup_scripts_ext.py new file mode 100644 index 0000000..c0edb16 --- /dev/null +++ b/python/samba/gp/vgp_startup_scripts_ext.py @@ -0,0 +1,136 @@ +# vgp_startup_scripts_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_xml_ext, check_safe_path, gp_file_applier +from tempfile import NamedTemporaryFile +from samba.common import get_bytes +from subprocess import Popen + +intro = b''' +### autogenerated by samba +# +# This file is generated by the vgp_startup_scripts_ext Group Policy +# Client Side Extension. To modify the contents of this file, +# modify the appropriate Group Policy objects which apply +# to this machine. DO NOT MODIFY THIS FILE DIRECTLY. +# + +''' + +class vgp_startup_scripts_ext(gp_xml_ext, gp_file_applier): + def __str__(self): + return 'VGP/Unix Settings/Startup Scripts' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + cdir='/etc/cron.d'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, script in settings[str(self)].items(): + self.unapply(guid, attribute, script) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Unix/Scripts/Startup/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + continue + policy = xml_conf.find('policysetting') + data = policy.find('data') + attributes = [] + for listelement in data.findall('listelement'): + local_path = self.lp.cache_path('gpo_cache') + script = listelement.find('script').text + script_file = os.path.join(local_path, + os.path.dirname(check_safe_path(path)).upper(), + script.upper()) + parameters = listelement.find('parameters') + if parameters is not None: + parameters = parameters.text + else: + parameters = '' + value_hash = listelement.find('hash').text + attribute = self.generate_attribute(script_file, + parameters) + attributes.append(attribute) + run_as = listelement.find('run_as') + if run_as is not None: + run_as = run_as.text + else: + run_as = 'root' + run_once = listelement.find('run_once') is not None + if run_once: + def applier_func(script_file, parameters): + Popen(['/bin/sh %s %s' % (script_file, parameters)], + shell=True).wait() + # Run once scripts don't create a file to unapply, + # so their is nothing to return. + return [] + self.apply(gpo.name, attribute, value_hash, applier_func, + script_file, parameters) + else: + def applier_func(run_as, script_file, parameters): + entry = '@reboot %s %s %s' % (run_as, script_file, + parameters) + with NamedTemporaryFile(prefix='gp_', dir=cdir, + delete=False) as f: + f.write(intro) + f.write(get_bytes(entry)) + os.chmod(f.name, 0o700) + return [f.name] + self.apply(gpo.name, attribute, value_hash, applier_func, + run_as, script_file, parameters) + + self.clean(gpo.name, keep=attributes) + + def rsop(self, gpo): + output = {} + xml = 'MACHINE/VGP/VTLA/Unix/Scripts/Startup/manifest.xml' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + policy = xml_conf.find('policysetting') + data = policy.find('data') + for listelement in data.findall('listelement'): + local_path = self.lp.cache_path('gpo_cache') + script = listelement.find('script').text + script_file = os.path.join(local_path, + os.path.dirname(check_safe_path(path)).upper(), + script.upper()) + parameters = listelement.find('parameters') + if parameters is not None: + parameters = parameters.text + else: + parameters = '' + run_as = listelement.find('run_as') + if run_as is not None: + run_as = run_as.text + else: + run_as = 'root' + run_once = listelement.find('run_once') is not None + if run_once: + entry = 'Run once as: %s `%s %s`' % (run_as, script_file, + parameters) + else: + entry = '@reboot %s %s %s' % (run_as, script_file, + parameters) + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append(entry) + return output diff --git a/python/samba/gp/vgp_sudoers_ext.py b/python/samba/gp/vgp_sudoers_ext.py new file mode 100644 index 0000000..b388d8b --- /dev/null +++ b/python/samba/gp/vgp_sudoers_ext.py @@ -0,0 +1,97 @@ +# vgp_sudoers_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_xml_ext, gp_file_applier +from samba.gp.gp_sudoers_ext import sudo_applier_func + +class vgp_sudoers_ext(gp_xml_ext, gp_file_applier): + def __str__(self): + return 'VGP/Unix Settings/Sudo Rights' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + sdir='/etc/sudoers.d'): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, sudoers in settings[str(self)].items(): + self.unapply(guid, attribute, sudoers) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Sudo/SudoersConfiguration/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + continue + policy = xml_conf.find('policysetting') + data = policy.find('data') + sudo_entries = [] + for entry in data.findall('sudoers_entry'): + command = entry.find('command').text + user = entry.find('user').text + listelements = entry.findall('listelement') + principals = [] + for listelement in listelements: + principals.extend(listelement.findall('principal')) + if len(principals) > 0: + uname = ','.join([u.text if u.attrib['type'] == 'user' + else '%s%%' % u.text for u in principals]) + else: + uname = 'ALL' + nopassword = entry.find('password') is None + np_entry = ' NOPASSWD:' if nopassword else '' + p = '%s ALL=(%s)%s %s' % (uname, user, np_entry, command) + sudo_entries.append(p) + # Each GPO applies only one set of sudoers, in a + # set of files, so the attribute does not need uniqueness. + attribute = self.generate_attribute(gpo.name) + # The value hash is generated from the sudo_entries, ensuring + # any changes to this GPO will cause the files to be rewritten. + value_hash = self.generate_value_hash(*sudo_entries) + self.apply(gpo.name, attribute, value_hash, sudo_applier_func, + sdir, sudo_entries) + # Cleanup any old entries that are no longer part of the policy + self.clean(gpo.name, keep=[attribute]) + + def rsop(self, gpo): + output = {} + xml = 'MACHINE/VGP/VTLA/Sudo/SudoersConfiguration/manifest.xml' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + policy = xml_conf.find('policysetting') + data = policy.find('data') + for entry in data.findall('sudoers_entry'): + command = entry.find('command').text + user = entry.find('user').text + listelements = entry.findall('listelement') + principals = [] + for listelement in listelements: + principals.extend(listelement.findall('principal')) + if len(principals) > 0: + uname = ','.join([u.text if u.attrib['type'] == 'user' + else '%s%%' % u.text for u in principals]) + else: + uname = 'ALL' + nopassword = entry.find('password') is None + np_entry = ' NOPASSWD:' if nopassword else '' + p = '%s ALL=(%s)%s %s' % (uname, user, np_entry, command) + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append(p) + return output diff --git a/python/samba/gp/vgp_symlink_ext.py b/python/samba/gp/vgp_symlink_ext.py new file mode 100644 index 0000000..4f85264 --- /dev/null +++ b/python/samba/gp/vgp_symlink_ext.py @@ -0,0 +1,76 @@ +# vgp_symlink_ext samba gpo policy +# Copyright (C) David Mulder <dmulder@suse.com> 2020 +# +# 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 <http://www.gnu.org/licenses/>. + +import os +from samba.gp.gpclass import gp_xml_ext, gp_file_applier +from samba.gp.util.logging import log + +class vgp_symlink_ext(gp_xml_ext, gp_file_applier): + def __str__(self): + return 'VGP/Unix Settings/Symbolic Links' + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for attribute, symlink in settings[str(self)].items(): + self.unapply(guid, attribute, symlink) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + xml = 'MACHINE/VGP/VTLA/Unix/Symlink/manifest.xml' + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + continue + policy = xml_conf.find('policysetting') + data = policy.find('data') + for entry in data.findall('file_properties'): + source = entry.find('source').text + target = entry.find('target').text + # We can only create a single instance of the target, so + # this becomes our unchanging attribute. + attribute = target + # The changeable part of our policy is the source (the + # thing the target points to), so our value hash is based + # on the source. + value_hash = self.generate_value_hash(source) + def applier_func(source, target): + if not os.path.exists(target): + os.symlink(source, target) + return [target] + else: + log.warn('Symlink destination exists', target) + return [] + self.apply(gpo.name, attribute, value_hash, applier_func, + source, target) + + def rsop(self, gpo): + output = {} + xml = 'MACHINE/VGP/VTLA/Unix/Symlink/manifest.xml' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, xml) + xml_conf = self.parse(path) + if not xml_conf: + return output + policy = xml_conf.find('policysetting') + data = policy.find('data') + for entry in data.findall('file_properties'): + source = entry.find('source').text + target = entry.find('target').text + if str(self) not in output.keys(): + output[str(self)] = [] + output[str(self)].append('ln -s %s %s' % (source, target)) + return output |