-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
393 lines (325 loc) · 13.5 KB
/
__init__.py
File metadata and controls
393 lines (325 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
"""
This module contains utility functions to simplify getting user information on macOS.
Primary Class
User() -- Contains details about a user on macOS.
Primary Functions
primary() -- Returns the current or last logged in console user as a User.
users() -- Returns a list of users as a User class.
admins() -- Returns a list of admin users as a User class.
"""
import pathlib
import plistlib
import subprocess
import warnings
from typing import Any, List, Optional, Union
APFS_LIST: Optional[str] = None
FDE_LIST: Optional[str] = None
class User:
"""
This class contains details about a user on macOS.
Args:
username (str): The username to initialize information about.
The following properties can be accessed from this class:
- username : The user's username
- real_name : Full name
- uid : The user's ID
- gid : Primary group ID
- guid : Generated user ID - used by APFS
- home : Home folder
- shell : Default shell
- admin : If the user is an admin
- ssh_access : If the user has SSH access
- volume_owner : If the user is an APFS volume owner
- secure_token : If the user has a secure token
- created : Epoch time when the user was created
- password_updated: Epoch time when the user's password was last changed
"""
def __init__(self, username):
comp = subprocess.run(
['dscl', '-plist', '.', 'read', f'/Users/{username}'],
capture_output=True, check=False)
self.data = _plist(comp.stdout)
# `dscl` nestles a PLIST with some data i want, so i unwrap it here.
self.account_policy_data = _plist(_first(
self.data.get('dsAttrTypeNative:accountPolicyData', {})))
self.username: str = username
self.real_name: str = _first(self.data.get('dsAttrTypeStandard:RealName'))
self.uid: int = int(_first(self.data.get('dsAttrTypeStandard:UniqueID')))
self.gid: int = int(_first(self.data.get(
'dsAttrTypeStandard:PrimaryGroupID')))
# guid is used to match the user to APFS volume ownership.
self.guid: str = _first(self.data.get('dsAttrTypeStandard:GeneratedUID'))
self.home: Optional[pathlib.Path] = _path(_first(self.data.get(
'dsAttrTypeStandard:NFSHomeDirectory')))
self.shell: Optional[pathlib.Path] = _path(_first(self.data.get(
'dsAttrTypeStandard:UserShell')))
self.admin: bool = group_member(self.uid, 'admin')
self.ssh_access: bool = group_member(self.uid, 'com.apple.access_ssh')
# volume_owner defaults to the volume '/' which might be incorrect in
# some cases. anyone running into this should make a subsequent call to
# resolve. e.g.:
# user = User('bryan.heinz')
# user.volume_owner # False
# user.volume_owner = apfs_owner(self.guid, volume='/System/Volumes/OTHER_DISK')
# user.volume_owner # True
self.volume_owner: bool = apfs_owner(self.guid)
self.secure_token: bool = secure_token_status(username)
self.created: Optional[str] = self.account_policy_data.get('creationTime')
self.password_updated: Optional[str] = self.account_policy_data.get('passwordLastSetTime')
def fv_access(self) -> Optional[bool]:
"""
Return if a user has FileVault access. This is a shortcut for calling fv_access('USERNAME').
NOTE: Requires running with sudo/root. Because of this, it was left outside
of the User class.
Returns: True if the user has FileVault access, False if not, None if run
without sudo.
"""
return fv_access(self.username)
def apfs_owner(self, volume: str = '/') -> bool:
"""
Return if a user is a volume owner. This is a shortcut for calling apfs_owner(User.guid).
Args:
volume (str): The volume to check. Defaults to /.
Returns: True of the user is listed as a volume owner, False otherwise.
"""
return apfs_owner(self.guid, volume)
def secure_token_status(self) -> bool:
"""
Return if a user has a secure token. This is a shortcut for calling secure_token_status('USERNAME').
Returns: True if the user has a secure token, False otherwise.
"""
return secure_token_status(self.username)
def dump(self):
"""
Print all attributes for this User.
"""
for key, value in self.__dict__.items():
if key == 'data': continue
if key == 'account_policy_data': continue
print(f"{key}: {value}")
def _first(item: Optional[List[Any]]) -> Any:
"""
Return the first item in a list.
NOTE: This function should be considered private. It may change at any point
without consideration outside of this module working.
Args:
item (list): Any type of list.
Returns: The first item in the list or None if there isn't one.
"""
return next(iter(item or []), None)
def _path(path: Optional[str]) -> Optional[pathlib.Path]:
"""
Returns a pathlib.Path object if the input path exists.
NOTE: This function should be considered private. It may change at any point
without consideration outside of this module working.
Args:
path (str): A path to a file or folder.
Returns: A pathlib.Path object if the path exists or None.
"""
if path:
_path = pathlib.Path(path)
if _path.exists():
return _path
return None
def _plist(data: Union[str, bytes]) -> dict:
"""
Returns a dict object from the input plist data.
NOTE: This function should be considered private. It may change at any point
without consideration outside of this module working.
Args:
data (str|bytes): A string (encoded or not) of plist data.
Returns: A dict object with the plist data if valid, otherwise an empty dict.
"""
if not data: return {}
if isinstance(data, str):
data = data.encode('utf-8')
return plistlib.loads(data)
def primary() -> User:
"""
Return best-guess primary user for the Mac.
This function does this by getting the current or last logged in console user.
Returns: The best-guess primary user as a User class.
"""
comp = subprocess.run(
['/usr/bin/stat', '-f', '"%Su"', '/dev/console'],
capture_output=True, check=False)
username: str = comp.stdout.decode('utf-8')
username = username.replace('"', '')
# fallback in case user is still root
# if the user is still root after this, root is likely logged in or was the
# last user to be logged in.
if username == 'root':
comp = subprocess.run(
["/usr/bin/defaults", "read",
"/Library/Preferences/com.apple.loginwindow.plist",
"lastUserName"],
capture_output=True, check=False)
username = comp.stdout.decode('utf-8')
user = User(username.strip())
return user
def users(root: bool = True, gid: Optional[int] = None) -> List[User]:
"""
Return a list of users with a shell.
Args:
root (bool): True by default to include the root user account. Set to
False to not include the root account.
gid (int): Filter users based on their primary group ID. The default
is None which returns all users with a shell.
Returns: A list of Users.
Examples:
macOS Users: root, _www, bryan.heinz, morris.moss
> macusers.users()
>> [root, bryan.heinz]
> macusers.users(gid=20)
>> [bryan.heinz, morris.moss]
"""
user_list = []
comp = subprocess.run(
['dscl', '.', 'list', '/Users', 'UserShell'],
capture_output=True, check=False)
dscl_users: str = comp.stdout.decode('utf-8')
for line in dscl_users.splitlines():
if 'false' in line: continue
username = line.split(' ')[0]
user_list.append(User(username))
if gid is not None:
return list(filter(lambda u: u.gid == gid, user_list))
elif root is False:
return list(filter(lambda u: u.username != 'root', user_list))
return user_list
def admins(root: bool = True, gid: Optional[int] = None) -> List[User]:
"""
Returns a list admin users with a shell.
Args:
root (bool): True by default to include the root user account. Set to
False to not include the root account.
gid (int): Filter users based on their primary group ID. The default
is None which returns all admin users with a shell.
Returns: A list of admin users as the User class.
Examples:
macOS Users: root, _www, bryan.heinz (admin), morris.moss (user)
> macusers.admins()
>> [root, bryan.heinz]
> macusers.admins(gid=20)
>> [bryan.heinz]
"""
user_list = users(root=root, gid=gid)
admin_list = list(filter(lambda u: u.admin is True, user_list))
return admin_list
def group_member(uid: int, group: str) -> bool:
"""
Check if a user is in a group.
Args:
uid (str): User's ID as an int or str.
group (str): The group name.
Returns: True if the user is an admin, False if not.
Examples:
> macusers.group_member(501, 'admin')
>> True
> macusers.group_member(501, 'com.apple.access_ssh')
>> False
"""
comp = subprocess.run(
['dsmemberutil', 'checkmembership', '-u', str(uid), '-G', group],
capture_output=True, check=False)
output: str = comp.stdout.decode('utf-8')
if 'group not found' in output.lower():
warnings.warn(
f"The group '{group}' wasn't found.", RuntimeWarning,
stacklevel=2)
if 'is a member' in output:
return True
return False
def console() -> str:
"""
Return current or last logged in console user as a String.
DEPRECATED: this function will be removed in the future.
"""
warnings.warn(
"console() is deprecated, use primary().username for similar functionality.",
DeprecationWarning, stacklevel=2)
comp = subprocess.run(
['/usr/bin/stat', '-f', '"%Su"', '/dev/console'],
capture_output=True, check=False)
user: str = comp.stdout.decode('utf-8')
user = user.replace('"', '')
# fallback in case user is still root
# if the user is still root after this, root is likely logged in or was the
# last user to be logged in.
if user == 'root':
comp = subprocess.run(
["/usr/bin/defaults", "read",
"/Library/Preferences/com.apple.loginwindow.plist",
"lastUserName"],
capture_output=True, check=False)
user = comp.stdout.decode('utf-8')
return user.strip()
def fv_access(username: str) -> Optional[bool]:
"""
Return if a user has FileVault access.
NOTE: Requires running with sudo/root. Because of this, it was left outside
of the User class.
Args:
username (str): The username for the account to check.
Returns: True if the user has FileVault access, False if not, None if run
without sudo.
"""
# using a global to speed up subsequent FDE checks.
global FDE_LIST
if FDE_LIST is None:
comp = subprocess.run(
['fdesetup', 'list'],
capture_output=True, check=False)
FDE_LIST = comp.stdout.decode('utf-8')
err: str = comp.stderr.decode('utf-8')
if 'requires root access' in err:
warnings.warn(
"Getting FileVault status requires this script to run as admin.",
RuntimeWarning, stacklevel=2)
FDE_LIST = ''
if FDE_LIST == '':
return None
if username in FDE_LIST:
return True
return False
def apfs_owner(guid: str, volume: str = '/') -> bool:
"""
Return if a user is a volume owner.
Args:
guid (str): The GeneratedUID of the user to check.
volume (str): The volume to check. Defaults to /.
Returns: True of the user is listed as a volume owner, False otherwise.
"""
# getting the APFS owner list is slow. using a global here to make the call once to speed up subsequent user checks.
global APFS_LIST
if APFS_LIST is None:
comp = subprocess.run(
['diskutil', 'apfs', 'listUsers', volume],
capture_output=True, check=False)
APFS_LIST = comp.stdout.decode('utf-8')
if guid in APFS_LIST:
return True
return False
def secure_token_status(username: str) -> bool:
"""
Return if a user has a secure token.
Args:
username (str): The username to check for a secure token.
Returns: True if the user has a secure token, False otherwise.
"""
# for some reason this commands sends output to stderr instead of stdout...
comp = subprocess.run(
['sysadminctl', '-secureTokenStatus', username],
capture_output=True, check=False)
err: str = comp.stderr.decode('utf-8')
if 'Secure token is ENABLED' in err:
return True
return False
if __name__ == '__main__':
for user in users():
user.dump()
print("\n---\n")
print(repr(console()))
print(repr(primary().username))
print([admin.username for admin in admins()])
print(fv_access('bryanh')) # only works if run as root