Skip to content

Commit 709b21d

Browse files
committed
Added config option to the plugin to turn ON/OFF warning R8009
Also added W8012 to warn about "unsafe" value for the mode argument to `os.open` (not implemented yet and missing unit tests).
1 parent 619bfda commit 709b21d

File tree

5 files changed

+224
-12
lines changed

5 files changed

+224
-12
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add plugin option to control whether we favour `os.open` over the builtin `open`
13+
- Added W8012 to warn when using `os.open` with unsafe permissions
14+
1015
### Repository
1116

1217
- Update pre-commit hooks

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,31 @@ pylint plugin that enforces some secure coding standards.
2525
| R8009 | Use of builtin `open` for writing is discouraged in favor of `os.open` to allow for setting file permissions |
2626
| E8010 | Avoid using `os.popen()` as it internally uses `subprocess.Popen` with `shell=True` |
2727
| E8011 | Use of `shlex.quote()` should be avoided on non-POSIX platforms |
28+
| W8012 | Avoid using `os.open` with unsafe permissions permissions |
2829

2930

31+
## Plugin configuration options
32+
33+
### File permissions when using `os.open`
34+
35+
Since version 1.3.0 you can control whether this plugin favors `os.open` over the builtin `open` function when opening files.
36+
37+
```toml
38+
[tool.pylint.plugins]
39+
os-open-mode = '0' # check disabled
40+
os-open-mode = '0o000' # check disabled
41+
os-open-mode = '493' # all modes from 0 to 0o755
42+
os-open-mode = '0o755' # all modes from 0 to 0o755
43+
os-open-mode = '0o755,' # only 0o755
44+
os-open-mode = '0o644,0o755' # only 0o644 and 0o755
45+
```
46+
47+
You can also specify this option directly on the command line:
48+
49+
```sh
50+
python3 -m pylint --load-plugins=pylint_secure_coding_standard --os-open-mode='0o755'
51+
```
52+
3053
## Pre-commit hook
3154

3255
See [pre-commit](https://github.com/pre-commit/pre-commit) for instructions

pylint_secure_coding_standard.py

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,24 @@ def _is_shlex_quote_call(node):
226226
class 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

366451
def 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

tests/builtin_open_test.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def test_builtin_open_ok(self):
4141
with_nodes = nodes[5:]
4242

4343
with self.assertNoMessages():
44+
self.checker.set_os_open_mode('True')
45+
assert self.checker._prefer_os_open
4446
for idx, node in enumerate(call_nodes):
4547
self.checker.visit_call(node)
4648
for idx, node in enumerate(with_nodes):
@@ -70,13 +72,27 @@ def test_builtin_open_ok(self):
7072
)
7173

7274
@pytest.mark.parametrize('s', _calls_not_ok)
73-
def test_builtin_open_call(self, s):
75+
@pytest.mark.parametrize('os_open_mode', (False, True))
76+
def test_builtin_open_call(self, s, os_open_mode):
7477
node = astroid.extract_node(s + ' #@')
75-
with self.assertAddsMessages(pylint.testutils.Message(msg_id='replace-builtin-open', node=node)):
76-
self.checker.visit_call(node)
78+
self.checker.set_os_open_mode(str(os_open_mode))
79+
assert self.checker._prefer_os_open == os_open_mode
80+
if os_open_mode:
81+
with self.assertAddsMessages(pylint.testutils.Message(msg_id='replace-builtin-open', node=node)):
82+
self.checker.visit_call(node)
83+
else:
84+
with self.assertNoMessages():
85+
self.checker.visit_call(node)
7786

7887
@pytest.mark.parametrize('s', ('with ' + s + ' as fd: fd.read()' for s in _calls_not_ok))
79-
def test_builtin_open_with(self, s):
88+
@pytest.mark.parametrize('os_open_mode', (False, True))
89+
def test_builtin_open_with(self, s, os_open_mode):
8090
node = astroid.extract_node(s + ' #@')
81-
with self.assertAddsMessages(pylint.testutils.Message(msg_id='replace-builtin-open', node=node)):
82-
self.checker.visit_with(node)
91+
self.checker.set_os_open_mode(str(os_open_mode))
92+
assert self.checker._prefer_os_open == os_open_mode
93+
if os_open_mode:
94+
with self.assertAddsMessages(pylint.testutils.Message(msg_id='replace-builtin-open', node=node)):
95+
self.checker.visit_with(node)
96+
else:
97+
with self.assertNoMessages():
98+
self.checker.visit_with(node)

tests/plugin_options_test.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2021 Damien Nguyen
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import pylint.testutils
17+
import pytest
18+
19+
import pylint_secure_coding_standard as pylint_scs
20+
21+
_default_modes = list(range(0, pylint_scs.SecureCodingStandardChecker.DEFAULT_MAX_MODE + 1))
22+
23+
24+
def _id_func(arg):
25+
_max_len = 4
26+
if arg == _default_modes:
27+
return 'default_modes'
28+
if isinstance(arg, list) and len(arg) > _max_len:
29+
return '[{}...{}]'.format(
30+
','.join(str(val) for val in arg[:_max_len]),
31+
','.join(str(val) for val in arg[-_max_len:]),
32+
)
33+
return str(arg)
34+
35+
36+
class TestSecureCodingStandardChecker(pylint.testutils.CheckerTestCase):
37+
CHECKER_CLASS = pylint_scs.SecureCodingStandardChecker
38+
39+
@pytest.mark.parametrize(
40+
'arg, prefer_os_open, allowed_modes',
41+
(
42+
('0', False, []),
43+
('false', False, []),
44+
('False', False, []),
45+
('n', False, []),
46+
('no', False, []),
47+
('No', False, []),
48+
('NO', False, []),
49+
('y', True, _default_modes),
50+
('yes', True, _default_modes),
51+
('Yes', True, _default_modes),
52+
('YES', True, _default_modes),
53+
('true', True, _default_modes),
54+
('True', True, _default_modes),
55+
('1', True, [0, 1]),
56+
('2', True, [0, 1, 2]),
57+
('0o755', True, _default_modes),
58+
('0o755,', True, [0o755]),
59+
('0o644, 0o755,', True, [0o644, 0o755]),
60+
('493', True, _default_modes),
61+
('0o644', True, list(range(0, 0o644 + 1))),
62+
),
63+
ids=_id_func,
64+
)
65+
def test_os_open_mode_option(self, arg, prefer_os_open, allowed_modes):
66+
print(f'INFO: allowed_modes: {allowed_modes}')
67+
print(self.checker._os_open_mode_allowed)
68+
self.checker.set_os_open_mode(arg)
69+
assert self.checker._prefer_os_open == prefer_os_open
70+
assert self.checker._os_open_mode_allowed == allowed_modes
71+
72+
@pytest.mark.parametrize('arg', ('', ',', ',,', 'asd', 'a,', '493, a'))
73+
def test_os_open_mode_option_invalid(self, arg):
74+
with pytest.raises(ValueError):
75+
self.checker.set_os_open_mode(arg)

0 commit comments

Comments
 (0)