summaryrefslogtreecommitdiffstats
path: root/python/samba/gp
diff options
context:
space:
mode:
Diffstat (limited to 'python/samba/gp')
-rw-r--r--python/samba/gp/__init__.py17
-rw-r--r--python/samba/gp/gp_centrify_crontab_ext.py135
-rw-r--r--python/samba/gp/gp_centrify_sudoers_ext.py80
-rw-r--r--python/samba/gp/gp_cert_auto_enroll_ext.py572
-rw-r--r--python/samba/gp/gp_chromium_ext.py473
-rw-r--r--python/samba/gp/gp_drive_maps_ext.py168
-rw-r--r--python/samba/gp/gp_ext_loader.py59
-rw-r--r--python/samba/gp/gp_firefox_ext.py219
-rw-r--r--python/samba/gp/gp_firewalld_ext.py171
-rw-r--r--python/samba/gp/gp_gnome_settings_ext.py418
-rw-r--r--python/samba/gp/gp_msgs_ext.py96
-rw-r--r--python/samba/gp/gp_scripts_ext.py187
-rw-r--r--python/samba/gp/gp_sec_ext.py221
-rw-r--r--python/samba/gp/gp_smb_conf_ext.py127
-rw-r--r--python/samba/gp/gp_sudoers_ext.py116
-rw-r--r--python/samba/gp/gpclass.py1312
-rw-r--r--python/samba/gp/util/logging.py112
-rw-r--r--python/samba/gp/vgp_access_ext.py178
-rw-r--r--python/samba/gp/vgp_files_ext.py140
-rw-r--r--python/samba/gp/vgp_issue_ext.py90
-rw-r--r--python/samba/gp/vgp_motd_ext.py90
-rw-r--r--python/samba/gp/vgp_openssh_ext.py115
-rw-r--r--python/samba/gp/vgp_startup_scripts_ext.py136
-rw-r--r--python/samba/gp/vgp_sudoers_ext.py97
-rw-r--r--python/samba/gp/vgp_symlink_ext.py76
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