@@ -226,9 +226,24 @@ def _is_shlex_quote_call(node):
226226class SecureCodingStandardChecker (BaseChecker ):
227227 """Plugin class."""
228228
229- __implements__ = IAstroidChecker
229+ DEFAULT_MAX_MODE = 0o755
230+ W8012_DISPLAY_MSG = 'Avoid using `os.open` with unsafe permissions permissions'
231+
232+ __implements__ = (IAstroidChecker ,)
230233
231234 name = 'secure-coding-standard'
235+ options = (
236+ (
237+ 'os-open-mode' ,
238+ {
239+ 'default' : False ,
240+ 'type' : 'string' ,
241+ 'metavar' : '<os-open-mode>' ,
242+ 'help' : 'Integer or comma-separated list of integers (octal or decimal) of allowed modes. If set to a '
243+ 'truthful value (ie. >0 or non-empty list), this checker will prefer `os.open` over the builtin `open`' ,
244+ },
245+ ),
246+ )
232247 priority = - 1
233248
234249 msg = {
@@ -294,9 +309,18 @@ class SecureCodingStandardChecker(BaseChecker):
294309 'avoid-shlex-quote-on-non-posix' ,
295310 'Use of `shlex.quote()` should be avoided on non-POSIX platforms (such as Windows)' ,
296311 ),
312+ 'W8012' : (
313+ W8012_DISPLAY_MSG ,
314+ 'os-open-unsafe-permissions' ,
315+ 'Avoid using `os.open` with unsafe file permissions (by default 0 <= mode <= 0o755)' ,
316+ ),
297317 }
298318
299- options = {}
319+ def __init__ (self , * args , ** kwargs ):
320+ """Initialize a SecureCodingStandardChecker object."""
321+ super ().__init__ (* args , ** kwargs )
322+ self ._prefer_os_open = False
323+ self ._os_open_mode_allowed = []
300324
301325 def visit_call (self , node ):
302326 """Visitor method called for astroid.Call nodes."""
@@ -316,7 +340,7 @@ def visit_call(self, node):
316340 self .add_message ('avoid-shell-true' , node = node )
317341 elif _is_os_popen_call (node ):
318342 self .add_message ('avoid-os-popen' , node = node )
319- elif _is_builtin_open_for_writing (node ):
343+ elif _is_builtin_open_for_writing (node ) and self . _prefer_os_open :
320344 self .add_message ('replace-builtin-open' , node = node )
321345 elif isinstance (node .func , astroid .Name ) and (node .func .name in ('eval' , 'exec' )):
322346 self .add_message ('avoid-eval-exec' , node = node )
@@ -354,15 +378,84 @@ def visit_importfrom(self, node):
354378
355379 def visit_with (self , node ):
356380 """Visitor method called for astroid.With nodes."""
357- for item in node .items :
358- if item and isinstance (item [0 ], astroid .Call ) and _is_builtin_open_for_writing (item [0 ]):
359- self .add_message ('replace-builtin-open' , node = node )
381+ if self ._prefer_os_open :
382+ for item in node .items :
383+ if item and isinstance (item [0 ], astroid .Call ) and _is_builtin_open_for_writing (item [0 ]):
384+ self .add_message ('replace-builtin-open' , node = node )
360385
361386 def visit_assert (self , node ):
362387 """Visitor method called for astroid.Assert nodes."""
363388 self .add_message ('avoid-assert' , node = node )
364389
390+ def set_os_open_mode (self , arg ):
391+ """
392+ Control whether we prefer `os.open` over the builtin `open`.
393+
394+ Args:
395+ arg (str): String with with mode value. Can be either of:
396+ - 'yes', 'y', 'true' (case-insensitive)
397+ The maximum mode value is then set to self.DEFAULT_MAX_MODE
398+ - a single octal or decimal integer
399+ The maximum mode value is then set to that integer value
400+ - a comma-separated list of integers (octal or decimal)
401+ The allowed mode values are then those found in the list
402+ - anything else will disable the feature
403+ """
404+
405+ def _str_to_int (arg ):
406+ try :
407+ return int (arg , 8 )
408+ except ValueError :
409+ return int (arg )
410+
411+ def _update_display_msg (suffix = '' ):
412+ self .msg ['W8012' ] = (self .W8012_DISPLAY_MSG + suffix , self .msg ['W8012' ][1 ], self .msg ['W8012' ][2 ])
413+
414+ arg = arg .lower ()
415+ modes = [mode .strip () for mode in arg .split (',' )]
416+
417+ if len (modes ) > 1 :
418+ # Lists of allowed modes
419+ try :
420+ self ._os_open_mode_allowed = [_str_to_int (mode ) for mode in modes if mode ]
421+ if not self ._os_open_mode_allowed :
422+ raise ValueError ('Calculated empty value for `os_open_mode`!' )
423+ self ._prefer_os_open = True
424+ _update_display_msg (suffix = f' (mode in { modes } )' )
425+ except ValueError as error :
426+ raise ValueError (f'Unable to convert { modes } elements to integers!' ) from error
427+ elif modes and modes [0 ]:
428+ # Single values (ie. max allowed value for mode)
429+ try :
430+ val = _str_to_int (arg )
431+ self ._prefer_os_open = val > 0
432+ if self ._prefer_os_open :
433+ self ._os_open_mode_allowed = list (range (0 , val + 1 ))
434+ _update_display_msg (suffix = f' (mode <= { arg } )' )
435+ else :
436+ self ._os_open_mode_allowed .clear ()
437+ except ValueError as error :
438+ if arg in ('y' , 'yes' , 'true' ):
439+ self ._prefer_os_open = True
440+ self ._os_open_mode_allowed = list (range (0 , self .DEFAULT_MAX_MODE + 1 ))
441+ _update_display_msg (suffix = f' (mode <= { oct (self .DEFAULT_MAX_MODE )} )' )
442+ elif arg in ('n' , 'no' , 'false' ):
443+ self ._prefer_os_open = False
444+ self ._os_open_mode_allowed .clear ()
445+ else :
446+ raise ValueError (f'Invalid value for `os_open_mode`: { arg } !' ) from error
447+ else :
448+ raise ValueError (f'Invalid value for `os_open_mode`: { arg } !' )
449+
365450
366451def register (linter ): # pragma: no cover
367452 """Register the plugin to Pylint."""
368453 linter .register_checker (SecureCodingStandardChecker (linter ))
454+
455+
456+ def load_configuration (linter ): # pragma: no cover
457+ """Load data from the configuration file."""
458+ for checker in linter .get_checkers ():
459+ if isinstance (checker , SecureCodingStandardChecker ):
460+ checker .set_os_open_mode (checker .config .os_open_mode )
461+ break
0 commit comments