├── LICENSE.md
├── README.md
├── edu.psu.macoslaps-check.plist
├── edu.psu.macoslaps.plist
└── macOSLAPS
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Joshua D. Miller - The Pennsylvania State University
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | macOS LAPS (Local Administrator Password Solution)
2 | ==================================================
3 | Python script that utilizes Open Directory to determine if the
4 | local administrator password has expired as specified by the Active Directory
5 | attribute dsAttrTypeNative:ms-Mcs-AdmPwdExpirationTime. If this is the case
6 | then a new randomly generated password will be set for the local admin account
7 | and a new expiration date will be set. The LAPS password is stored in the
8 | Active Directory attribute dsAttrTypeNative:ms-Mcs-AdmPwd. This attribute can
9 | only be read by those designated to view the attribute. The computer record
10 | can write to this attribute but it cannot read.
11 |
12 | Requirements
13 | ------------
14 |
15 | The following parameters must be set or the application will use the defaults:
16 |
17 | **LocalAdminAccount** - Local Administrator Account. Default is 'admin'
18 | **DaysTillExpiration** - Expiration date of random password. Default is 60 Days
19 | **PasswordLength** - Length of randomly generated password. Default is 12
20 | **RemoveKeyChain** - Remove the local admin keychains after password change. (Recommended)
21 | **RemovePassChars** - Exclude any characters you'd like from the randomly generated password (In String format)
22 |
23 | These parameters are set in the location /Libary/Preferences/edu.psu.macoslaps.plist
24 | or you can use your MDM's Custom Settings to set these values.
25 |
26 | Exclusions
27 | ----------------
28 | As pointed out by one of my fellow colleagues, the **'** key on macOS cannot be used on Windows without opening
29 | the character map to enter it. Since this is very detriment to using a LAPS password from a Windows client I have made this key excluded by default.
30 |
31 | Installation Instructions
32 | -------------------------
33 | At this time you can clone the repo or download a zip of the repo or you can
34 | use the package created using Packages to install. This script will run
35 | 3 times a day between 8 A.M. and 5 P.M. at 9 A.M., 1 P.M. and 4 P.M.
36 |
37 | Logging
38 | -------
39 | The script will also perform logging so that you know when the password is changed
40 | and its new expiration date or when the current unchanged password will expire. This
41 | file is stored in /Library/Logs/ as macOSLAPS.log
42 |
43 | Feedback
44 | --------
45 | All feedback I have received is positive so far but please continue testing this in your environment and let me know the results.
46 |
47 | Local Admin Keychain
48 | --------
49 | By default, the local admin you choose has its keychain deleted since we wouldn't know the randomized password.
50 |
51 | Credits
52 | --------------
53 | * Rusty Myers - For helping to determine that Windows has its own time method vs
54 | Epoch time
55 | * Michael Lynn - For critiquing and assisting with generating the random password
56 | * Ben Toms - For sharing code from ADPassMon to query Active Directory with Open
57 | Directory
58 | * Tom Burgin - For showing me the benefits and how to use Open Directory with Python
59 | * Per Olofsson - For also assisting with Open Directory commands
60 | * Clayton Burlison - For keeping my lack of markdown skills in check
61 |
--------------------------------------------------------------------------------
/edu.psu.macoslaps-check.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Label
6 | edu.psu.macoslaps-check
7 | ProgramArguments
8 |
9 | /usr/local/laps/macOSLAPS
10 |
11 | StartCalendarInterval
12 |
13 |
14 | Minute
15 | 0
16 | Hour
17 | 9
18 |
19 |
20 | Minute
21 | 0
22 | Hour
23 | 13
24 |
25 |
26 | Minute
27 | 0
28 | Hour
29 | 16
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/edu.psu.macoslaps.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | DaysTillExpiration
6 | 30
7 | LocalAdminAccount
8 | admin
9 | PasswordLength
10 | 14
11 | RemovePassChars
12 | {}[]|
13 |
14 |
15 |
--------------------------------------------------------------------------------
/macOSLAPS:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | '''LAPS for macOS devices'''
3 | # pylint: disable=C0103, E0611, W0703
4 | # ############################################################
5 | # This python script will set a randomly generated password for your
6 | # local adminsitrator account on macOS if the expiration date has passed
7 | # in your Active Directory. Mimics behavior of LAPS
8 | # (Local Administrator Password Solution) for Windows
9 | #############################################################
10 | # Active Directory Attributes Modified:
11 | # dsAttrTypeNative:ms-Mcs-AdmPwd - Where Password is stored
12 | # dsAttrTypeNative:ms-Mcs-AdmPwdself.expirationTime - Expiration Time
13 | # #############################################################
14 | # Joshua D. Miller - josh@psu.edu - The Pennsylvania State University
15 | # Script was Last Updated June 2, 2017
16 | # #############################################################
17 |
18 | from Foundation import CFPreferencesCopyAppValue
19 | from datetime import datetime, timedelta
20 | from logging import (basicConfig as log_config,
21 | error as log_error, info as log_info)
22 | from OpenDirectory import (ODSession, ODNode,
23 | kODRecordTypeComputers, kODRecordTypeUsers)
24 | from os import path
25 | from random import choice
26 | from shutil import rmtree
27 | from string import ascii_letters, punctuation, digits
28 | from SystemConfiguration import (SCDynamicStoreCreate,
29 | SCDynamicStoreCopyValue)
30 | from time import mktime
31 | from unicodedata import normalize
32 |
33 |
34 | class macOSLAPS(object):
35 | '''main class of application'''
36 | # Current time
37 | now = datetime.now()
38 | # Preference Variables
39 | bundleid = 'edu.psu.macoslaps'
40 | defaultpreferences = {
41 | 'LocalAdminAccount': 'admin',
42 | 'PasswordLength': 12,
43 | 'DaysTillExpiration': 60,
44 | 'RemoveKeyChain': True,
45 | 'RemovePassChars': '\''
46 | }
47 | # Define Active Directory Attributes
48 | adpath = ''
49 | computerpath = ''
50 | expirationtime = ''
51 | lapsattributes = dict()
52 | computer_record = None
53 | # Setup Logging
54 | log_format = '%(asctime)s|%(levelname)s:%(message)s'
55 | log_config(filename='/Library/Logs/macOSLAPS.log',
56 | level=10, format=log_format)
57 |
58 | def get_config_settings(self, preference_key):
59 | '''Function to retrieve configuration settings from
60 | /Library/Preferences or /Library/Managed Preferences'''
61 | preference_file = self.bundleid
62 | preference_value = CFPreferencesCopyAppValue(preference_key,
63 | preference_file)
64 | if preference_value is None:
65 | preference_value = self.defaultpreferences.get(preference_key)
66 | if isinstance(preference_value, unicode):
67 | preference_value = normalize(
68 | 'NFKD', preference_value).encode('ascii', 'ignore')
69 | return preference_value
70 |
71 | def connect_to_ad(self):
72 | '''Function to connect and pull information from Active Directory
73 | some code borrowed from AD PassMon - Thanks @macmuleblog'''
74 | # Active Directory Connection and Extraction of Data
75 | try:
76 | # Create Net Config
77 | net_config = SCDynamicStoreCreate(None, "net", None, None)
78 | # Get Active Directory Info
79 | ad_info = dict(
80 | SCDynamicStoreCopyValue(
81 | net_config, 'com.apple.opendirectoryd.ActiveDirectory'))
82 | # Create Active Directory Path
83 | self.adpath = '{0:}/{1:}'.format(ad_info['NodeName'],
84 | ad_info['DomainNameDns'])
85 | # Computer Path
86 | self.computerpath = 'Computers/{0:}'.format(
87 | ad_info['TrustAccount'])
88 | # Use Open Directory To Connect to Active Directory
89 | node, error = ODNode.nodeWithSession_name_error_(
90 | ODSession.defaultSession(), self.adpath, None)
91 | # Grab the Computer Record
92 | self.computer_record, error = node.\
93 | recordWithRecordType_name_attributes_error_(
94 | kODRecordTypeComputers, ad_info
95 | ['TrustAccount'], None, None)
96 | # Convert to Readable Values
97 | values, error = self.computer_record.\
98 | recordDetailsForAttributes_error_(None, None)
99 | # LAPS Attributes
100 | self.lapsattributes[0] = 'dsAttrTypeNative:ms-Mcs-AdmPwd'
101 | self.lapsattributes[1] = '{0:}'.format(
102 | 'dsAttrTypeNative:ms-Mcs-AdmPwdExpirationTime')
103 | # Get Expiration Time of Password
104 | try:
105 | self.expirationtime = values[self.lapsattributes[1]]
106 | except Exception:
107 | log_info('There has never been a random password generated'
108 | ' for this device. Setting a default expiration'
109 | ' date of 01/01/2001 in Active Directory to'
110 | ' force a password change...')
111 | self.expirationtime = '126227988000000000'
112 | except Exception as error:
113 | log_error(error)
114 | exit(1)
115 |
116 | @staticmethod
117 | def make_random_password(length):
118 | '''Generate a Random Password
119 | Thanks Mike Lynn - @frogor'''
120 | # Characters used for random password
121 | characters = ascii_letters + punctuation + digits
122 | remove_pass_characters = macOSLAPS().get_config_settings(
123 | 'RemovePassChars')
124 | # Remove Characters if specified
125 | if remove_pass_characters:
126 | characters = characters.translate(None, remove_pass_characters)
127 | password = []
128 | for i in range(length):
129 | password.insert(i, choice(characters))
130 | return ''.join(password)
131 |
132 | def windows_epoch_time_converter(self, time_type, expires):
133 | '''Convert from Epoch to Windows or from Windows
134 | to Epoch - Thanks Rusty Myers for determining Windows vs.
135 | Epoch Time @rustymyers'''
136 | if time_type == 'epoch':
137 | # Convert Windows Time to Epoch Time
138 | format_expiration_time = int(
139 | self.expirationtime[0]) / 10000000 - 11644473600
140 | format_expiration_time = datetime.fromtimestamp(
141 | format_expiration_time)
142 | return format_expiration_time
143 | elif time_type == 'windows':
144 | # Convert the time back from Time Stamp to Epoch to Windows
145 | # and add 30 days onto the time
146 | new_expiration_time = (self.now + timedelta(days=expires))
147 | formatted_new_expiration_time = new_expiration_time
148 | new_expiration_time = new_expiration_time.timetuple()
149 | new_expiration_time = mktime(new_expiration_time)
150 | new_expiration_time = ((new_expiration_time + 11644473600) *
151 | 10000000)
152 | return (new_expiration_time, formatted_new_expiration_time)
153 |
154 | def password_check(self):
155 | '''Perform a password check and change the local
156 | admin password and write it to Active Directory if
157 | needed - Thanks to Tom Burgin and Ben Toms
158 | @tomjburgin, @macmuleblog'''
159 | local_admin = LAPS.get_config_settings('LocalAdminAccount')
160 | exp_days = LAPS.get_config_settings('DaysTillExpiration')
161 | pass_length = LAPS.get_config_settings('PasswordLength')
162 | keychain_remove = LAPS.get_config_settings('RemoveKeyChain')
163 | password = LAPS.make_random_password(pass_length)
164 | formatted_expiration_time = LAPS.windows_epoch_time_converter(
165 | 'epoch', exp_days)
166 | # Determine if the password expired and then change it
167 | if formatted_expiration_time < self.now:
168 | # Log that the password change is being started
169 | log_info('Password change required.'
170 | ' Performing password change...')
171 | try:
172 | # Set new random password in Active Directory
173 | self.computer_record.setValue_forAttribute_error_(
174 | password, self.lapsattributes[0], None)
175 | # Change the local admin password
176 | log_info('Setting random password for local'
177 | ' admin account %s...', local_admin)
178 | # Connect to Local Node
179 | local_node, error = ODNode.nodeWithSession_name_error_(
180 | ODSession.defaultSession(), '/Local/Default', None)
181 | # Pull Local Administrator Record
182 | local_admin_change, error = local_node.\
183 | recordWithRecordType_name_attributes_error_(
184 | kODRecordTypeUsers, local_admin, None, None)
185 | # Change the password for the account
186 | local_admin_change.changePassword_toPassword_error_(
187 | None, password, None)
188 | # Convert Time to Windows Time to prepare
189 | # for new expiration time to be written to AD
190 | new_expires = dict()
191 | new_expires[0], new_expires[1] = LAPS.\
192 | windows_epoch_time_converter('windows', exp_days)
193 | # Set the Expiration Time in AD
194 | self.computer_record.setValue_forAttribute_error_(
195 | str(int(new_expires[0])), self.lapsattributes[1], None)
196 | log_info('Password change has been completed. '
197 | 'New expiration date is %s',
198 | new_expires[1])
199 | if keychain_remove is True:
200 | local_admin_path = '/Users/{0:}/Library/Keychains'.\
201 | format(local_admin)
202 | if path.exists(local_admin_path):
203 | rmtree(local_admin_path)
204 | log_info('Removed keychains for local '
205 | 'administrator account {0:}.'
206 | .format(local_admin))
207 | else:
208 | log_info('The keychain directory for '
209 | '{0:} does not exist. Keychain '
210 | 'removal not required...'.format(local_admin))
211 | else:
212 | log_info('Keychain has NOT been modified. Keep '
213 | 'in mind that this may cause keychain '
214 | 'prompts and the old password may not '
215 | 'be accessible.')
216 | except Exception as error:
217 | log_error(error)
218 | exit(1)
219 | else:
220 | # Log that a password change is not necessary at this time
221 | log_info('Password change not necessary at this time as'
222 | ' the expiration date is %s', formatted_expiration_time)
223 | exit(0)
224 |
225 |
226 | LAPS = macOSLAPS()
227 | LAPS.connect_to_ad()
228 | LAPS.password_check()
229 |
--------------------------------------------------------------------------------