Skip to content

Commit 559677f

Browse files
committed
bpo-38893: Add preserve_security_context to shutil
Users typically don't have permission to modify the restricted `security` namespace of extended attributes. An LSM prevents root processes in confined contexts from changing `security` attributes, too. Any write attempt from a confined context is considered a security violation and logged in the system's security audit log. Therefore we cannot safely except and ignore exceptions without flooding the audit system. Linux coreutils has a similar workaround. Tools like `cp` skips `security.selinux` when it detects that SELinux is enabled (`preserve_security_context`). The (security) context is only copied with non-default `--preserve=context` or `--preserve=all`. `shutil.copy2()` now behaves mostly like `cp -p --preserve=xattr` by default. Signed-off-by: Christian Heimes <christian@python.org>
1 parent 550e467 commit 559677f

4 files changed

Lines changed: 134 additions & 19 deletions

File tree

Doc/library/shutil.rst

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ Directory and files operations
108108
.. versionchanged:: 3.3
109109
Added *follow_symlinks* argument.
110110

111-
.. function:: copystat(src, dst, *, follow_symlinks=True)
111+
.. function:: copystat(src, dst, *, follow_symlinks=True, \
112+
preserve_security_context=False)
112113

113114
Copy the permission bits, last access time, last modification time, and
114115
flags from *src* to *dst*. On Linux, :func:`copystat` also copies the
@@ -123,6 +124,10 @@ Directory and files operations
123124
*src* symbolic link, and writing the information to the
124125
*dst* symbolic link.
125126

127+
If the optional flag *preserve_security_context* is set, extended
128+
attributes in the security namespace are copied as well. By default,
129+
attributes like ``security.selinux`` are ignored.
130+
126131
.. note::
127132

128133
Not all platforms provide the ability to examine and
@@ -155,6 +160,10 @@ Directory and files operations
155160
.. versionchanged:: 3.3
156161
Added *follow_symlinks* argument and support for Linux extended attributes.
157162

163+
.. versionchanged:: 3.10
164+
Added *preserve_security_context* argument.
165+
Skip xattrs in the security namespace by default.
166+
158167
.. function:: copy(src, dst, *, follow_symlinks=True)
159168

160169
Copies the file *src* to the file or directory *dst*. *src* and *dst*
@@ -186,7 +195,8 @@ Directory and files operations
186195
copy the file more efficiently. See
187196
:ref:`shutil-platform-dependent-efficient-copy-operations` section.
188197

189-
.. function:: copy2(src, dst, *, follow_symlinks=True)
198+
.. function:: copy2(src, dst, *, follow_symlinks=True, \
199+
preserve_security_context=False)
190200

191201
Identical to :func:`~shutil.copy` except that :func:`copy2`
192202
also attempts to preserve file metadata.
@@ -218,6 +228,10 @@ Directory and files operations
218228
copy the file more efficiently. See
219229
:ref:`shutil-platform-dependent-efficient-copy-operations` section.
220230

231+
.. versionchanged:: 3.10
232+
Added *preserve_security_context* argument.
233+
Skip xattrs in the security namespace by default.
234+
221235
.. function:: ignore_patterns(\*patterns)
222236

223237
This factory function creates a function that can be used as a callable for
@@ -227,7 +241,7 @@ Directory and files operations
227241

228242
.. function:: copytree(src, dst, symlinks=False, ignore=None, \
229243
copy_function=copy2, ignore_dangling_symlinks=False, \
230-
dirs_exist_ok=False)
244+
dirs_exist_ok=False, preserve_security_context=False)
231245

232246
Recursively copy an entire directory tree rooted at *src* to a directory
233247
named *dst* and return the destination directory. *dirs_exist_ok* dictates
@@ -286,6 +300,10 @@ Directory and files operations
286300
.. versionadded:: 3.8
287301
The *dirs_exist_ok* parameter.
288302

303+
.. versionchanged:: 3.10
304+
Added *preserve_security_context* argument.
305+
Skip xattrs in the security namespace by default.
306+
289307
.. function:: rmtree(path, ignore_errors=False, onerror=None)
290308

291309
.. index:: single: directory; deleting
@@ -334,7 +352,8 @@ Directory and files operations
334352
.. versionadded:: 3.3
335353

336354

337-
.. function:: move(src, dst, copy_function=copy2)
355+
.. function:: move(src, dst, copy_function=copy2, \
356+
preserve_security_context=False)
338357

339358
Recursively move a file or directory (*src*) to another location (*dst*)
340359
and return the destination.
@@ -374,6 +393,10 @@ Directory and files operations
374393
.. versionchanged:: 3.9
375394
Accepts a :term:`path-like object` for both *src* and *dst*.
376395

396+
.. versionchanged:: 3.10
397+
Added *preserve_security_context* argument.
398+
Skip xattrs in the security namespace by default.
399+
377400
.. function:: disk_usage(path)
378401

379402
Return disk usage statistics about the given path as a :term:`named tuple`

Lib/shutil.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -308,21 +308,25 @@ def copymode(src, dst, *, follow_symlinks=True):
308308
chmod_func(dst, stat.S_IMODE(st.st_mode))
309309

310310
if hasattr(os, 'listxattr'):
311-
def _copyxattr(src, dst, *, follow_symlinks=True):
311+
def _copyxattr(src, dst, *, follow_symlinks=True, names_filter=None):
312312
"""Copy extended filesystem attributes from `src` to `dst`.
313313
314314
Overwrite existing attributes.
315315
316316
If `follow_symlinks` is false, symlinks won't be followed.
317317
318+
`names_filter` is a callback to filter xattr names.
318319
"""
319-
320320
try:
321321
names = os.listxattr(src, follow_symlinks=follow_symlinks)
322322
except OSError as e:
323323
if e.errno not in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL):
324324
raise
325325
return
326+
if not names:
327+
return
328+
if names_filter is not None:
329+
names = names_filter(names)
326330
for name in names:
327331
try:
328332
value = os.getxattr(src, name, follow_symlinks=follow_symlinks)
@@ -335,7 +339,8 @@ def _copyxattr(src, dst, *, follow_symlinks=True):
335339
def _copyxattr(*args, **kwargs):
336340
pass
337341

338-
def copystat(src, dst, *, follow_symlinks=True):
342+
def copystat(src, dst, *, follow_symlinks=True,
343+
preserve_security_context=False):
339344
"""Copy file metadata
340345
341346
Copy the permission bits, last access time, last modification time, and
@@ -346,6 +351,14 @@ def copystat(src, dst, *, follow_symlinks=True):
346351
347352
If the optional flag `follow_symlinks` is not set, symlinks aren't
348353
followed if and only if both `src` and `dst` are symlinks.
354+
355+
If the optional flag `preserve_security_context` is set, extended
356+
attributes in the security namespace are copied as well. By default,
357+
attributes like `security.selinux` are ignored.
358+
359+
`preserve_security_context=False` is equivalent to
360+
`cp -p --preserve=xattr`. `preserve_security_context=True` behaves like
361+
`cp -p --preserve=xattr,context`.
349362
"""
350363
sys.audit("shutil.copystat", src, dst)
351364

@@ -367,6 +380,16 @@ def lookup(name):
367380
return fn
368381
return _nop
369382

383+
# don't copy security xattr by default
384+
# see coreutils src/copy.c:check_selinux_attr()
385+
if not preserve_security_context:
386+
def names_filter(names):
387+
return [
388+
name for name in names if not name.startswith("security.")
389+
]
390+
else:
391+
names_filter = None
392+
370393
if isinstance(src, os.DirEntry):
371394
st = src.stat(follow_symlinks=follow)
372395
else:
@@ -376,7 +399,7 @@ def lookup(name):
376399
follow_symlinks=follow)
377400
# We must copy extended attributes before the file is (potentially)
378401
# chmod()'ed read-only, otherwise setxattr() will error with -EACCES.
379-
_copyxattr(src, dst, follow_symlinks=follow)
402+
_copyxattr(src, dst, follow_symlinks=follow, names_filter=names_filter)
380403
try:
381404
lookup("chmod")(dst, mode, follow_symlinks=follow)
382405
except NotImplementedError:
@@ -419,7 +442,7 @@ def copy(src, dst, *, follow_symlinks=True):
419442
copymode(src, dst, follow_symlinks=follow_symlinks)
420443
return dst
421444

422-
def copy2(src, dst, *, follow_symlinks=True):
445+
def copy2(src, dst, *, follow_symlinks=True, preserve_security_context=False):
423446
"""Copy data and metadata. Return the file's destination.
424447
425448
Metadata is copied with copystat(). Please see the copystat function
@@ -429,11 +452,16 @@ def copy2(src, dst, *, follow_symlinks=True):
429452
430453
If follow_symlinks is false, symlinks won't be followed. This
431454
resembles GNU's "cp -P src dst".
455+
456+
If the optional flag `preserve_security_context` is set, extended
457+
attributes in the security namespace are copied as well. By default,
458+
attributes like `security.selinux` are ignored.
432459
"""
433460
if os.path.isdir(dst):
434461
dst = os.path.join(dst, os.path.basename(src))
435462
copyfile(src, dst, follow_symlinks=follow_symlinks)
436-
copystat(src, dst, follow_symlinks=follow_symlinks)
463+
copystat(src, dst, follow_symlinks=follow_symlinks,
464+
preserve_security_context=preserve_security_context)
437465
return dst
438466

439467
def ignore_patterns(*patterns):
@@ -449,7 +477,8 @@ def _ignore_patterns(path, names):
449477
return _ignore_patterns
450478

451479
def _copytree(entries, src, dst, symlinks, ignore, copy_function,
452-
ignore_dangling_symlinks, dirs_exist_ok=False):
480+
ignore_dangling_symlinks, dirs_exist_ok=False,
481+
preserve_security_context=False):
453482
if ignore is not None:
454483
ignored_names = ignore(os.fspath(src), [x.name for x in entries])
455484
else:
@@ -480,7 +509,8 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function,
480509
# code with a custom `copy_function` may rely on copytree
481510
# doing the right thing.
482511
os.symlink(linkto, dstname)
483-
copystat(srcobj, dstname, follow_symlinks=not symlinks)
512+
copystat(srcobj, dstname, follow_symlinks=not symlinks,
513+
preserve_security_context=preserve_security_context)
484514
else:
485515
# ignore dangling symlink if the flag is on
486516
if not os.path.exists(linkto) and ignore_dangling_symlinks:
@@ -493,7 +523,8 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function,
493523
copy_function(srcobj, dstname)
494524
elif srcentry.is_dir():
495525
copytree(srcobj, dstname, symlinks, ignore, copy_function,
496-
dirs_exist_ok=dirs_exist_ok)
526+
dirs_exist_ok=dirs_exist_ok,
527+
preserve_security_context=preserve_security_context)
497528
else:
498529
# Will raise a SpecialFileError for unsupported file types
499530
copy_function(srcobj, dstname)
@@ -504,7 +535,7 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function,
504535
except OSError as why:
505536
errors.append((srcname, dstname, str(why)))
506537
try:
507-
copystat(src, dst)
538+
copystat(src, dst, preserve_security_context=preserve_security_context)
508539
except OSError as why:
509540
# Copying file access times may fail on Windows
510541
if getattr(why, 'winerror', None) is None:
@@ -514,7 +545,8 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function,
514545
return dst
515546

516547
def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
517-
ignore_dangling_symlinks=False, dirs_exist_ok=False):
548+
ignore_dangling_symlinks=False, dirs_exist_ok=False,
549+
preserve_security_context=False):
518550
"""Recursively copy a directory tree and return the destination directory.
519551
520552
dirs_exist_ok dictates whether to raise an exception in case dst or any
@@ -550,14 +582,18 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
550582
destination path as arguments. By default, copy2() is used, but any
551583
function that supports the same signature (like copy()) can be used.
552584
585+
If the optional flag `preserve_security_context` is set, extended
586+
attributes in the security namespace are copied as well. By default,
587+
attributes like `security.selinux` are ignored.
553588
"""
554589
sys.audit("shutil.copytree", src, dst)
555590
with os.scandir(src) as itr:
556591
entries = list(itr)
557592
return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks,
558593
ignore=ignore, copy_function=copy_function,
559594
ignore_dangling_symlinks=ignore_dangling_symlinks,
560-
dirs_exist_ok=dirs_exist_ok)
595+
dirs_exist_ok=dirs_exist_ok,
596+
preserve_security_context=preserve_security_context)
561597

562598
if hasattr(os.stat_result, 'st_file_attributes'):
563599
# Special handling for directory junctions to make them behave like
@@ -761,7 +797,7 @@ def _basename(path):
761797
sep = os.path.sep + (os.path.altsep or '')
762798
return os.path.basename(path.rstrip(sep))
763799

764-
def move(src, dst, copy_function=copy2):
800+
def move(src, dst, copy_function=copy2, preserve_security_context=False):
765801
"""Recursively move a file or directory to another location. This is
766802
similar to the Unix "mv" command. Return the file or directory's
767803
destination.
@@ -814,10 +850,17 @@ def move(src, dst, copy_function=copy2):
814850
raise Error("Cannot move a directory '%s' into itself"
815851
" '%s'." % (src, dst))
816852
copytree(src, real_dst, copy_function=copy_function,
817-
symlinks=True)
853+
symlinks=True,
854+
preserve_security_context=preserve_security_context)
818855
rmtree(src)
819856
else:
820-
copy_function(src, real_dst)
857+
if preserve_security_context:
858+
copy_function(
859+
src, real_dst,
860+
preserve_security_context=preserve_security_context
861+
)
862+
else:
863+
copy_function(src, real_dst)
821864
os.unlink(src)
822865
return real_dst
823866

Lib/test/test_shutil.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,52 @@ def test_copyxattr_symlinks(self):
10271027
shutil._copyxattr(src_link, dst, follow_symlinks=False)
10281028
self.assertEqual(os.getxattr(dst, 'trusted.foo'), b'43')
10291029

1030+
@unittest.mock.patch('os.setxattr')
1031+
@unittest.mock.patch('os.getxattr')
1032+
@unittest.mock.patch('os.listxattr')
1033+
def test_copyxattr_preserve_security(
1034+
self, m_listxattr, m_getxattr, m_setxattr
1035+
):
1036+
tmp_dir = self.mkdtemp()
1037+
src = os.path.join(tmp_dir, 'foo')
1038+
write_file(src, 'foo')
1039+
dst = os.path.join(tmp_dir, 'bar')
1040+
write_file(dst, 'bar')
1041+
1042+
m_listxattr.return_value = ["security.selinux", "user.python"]
1043+
m_getxattr.return_value = "value"
1044+
1045+
# by default do not copy xattr in security namespace
1046+
shutil.copystat(src, dst)
1047+
m_getxattr.assert_any_call(
1048+
src, "user.python", follow_symlinks=True
1049+
)
1050+
m_setxattr.assert_any_call(
1051+
dst, "user.python", "value", follow_symlinks=True
1052+
)
1053+
self.assertEqual(m_getxattr.call_count, 1)
1054+
self.assertEqual(m_setxattr.call_count, 1)
1055+
1056+
m_getxattr.reset_mock()
1057+
m_setxattr.reset_mock()
1058+
1059+
# preserve_security_context=True copies all attributes
1060+
shutil.copystat(src, dst, preserve_security_context=True)
1061+
m_getxattr.assert_any_call(
1062+
src, "user.python", follow_symlinks=True
1063+
)
1064+
m_setxattr.assert_any_call(
1065+
dst, "user.python", "value", follow_symlinks=True
1066+
)
1067+
m_getxattr.assert_any_call(
1068+
src, "security.selinux", follow_symlinks=True
1069+
)
1070+
m_setxattr.assert_any_call(
1071+
dst, "security.selinux", "value", follow_symlinks=True
1072+
)
1073+
self.assertEqual(m_getxattr.call_count, 2)
1074+
self.assertEqual(m_setxattr.call_count, 2)
1075+
10301076
### shutil.copy
10311077

10321078
def _copy_file(self, method):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:mod:`shutil` functions no longer copy extended file attributes in the
2+
`security` namespace by default unless new *preserve_security_context*
3+
argument is given.

0 commit comments

Comments
 (0)