Skip to content

Commit 509def1

Browse files
committed
Add SCS110 and SCS111
- SCS110: Use of `os.popen()` should be avoided, as it internally uses `subprocess.Popen` with `shell=True` - SCS111: Use of `shlex.quote()` should be avoided on non-POSIX platforms (such as Windows)
1 parent ab69595 commit 509def1

5 files changed

Lines changed: 120 additions & 1 deletion

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
- Added SCS110 to avoid using `os.popen()` as it internally uses `subprocess.Popen` with `shell=True`
11+
- Added SCS111 to avoid using `shlex.quote()` on non-POSIX platforms.
12+
1013
## [1.1.0] - 2021-07-02
1114

1215
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ flake8 plugin that enforces some secure coding standards.
2323
| SCS107 | Use of debugging code shoud not be present in production code (e.g. `import pdb`) |
2424
| SCS108 | `assert` statements should not be present in production code |
2525
| SCS109 | Use of builtin `open` for writing is discouraged in favor of `os.open` to allow for setting file permissions |
26+
| SCS110 | Avoid using `os.popen()` as it internally uses `subprocess.Popen` with `shell=True` |
27+
| SCS111 | Use of `shlex.quote()` should be avoided on non-POSIX platforms |
2628

2729
## Pre-commit hook
2830

flake8_secure_coding_standard.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@
1616
"""Main file for the flake8_secure_coding_standard plugin."""
1717

1818
import ast
19+
import platform
1920
import sys
2021
from typing import Any, Dict, Generator, List, Tuple, Type
2122

2223
if sys.version_info < (3, 8): # pragma: no cover (<PY38)
2324
import importlib_metadata # pylint: disable=E0401
2425

25-
ast_Constant = ast.NameConstant
26+
ast_Constant = ast.NameConstant # pylint: disable=invalid-name
2627
else: # pragma: no cover (PY38+)
2728
ast_Constant = ast.Constant
2829
import importlib.metadata as importlib_metadata
@@ -47,6 +48,24 @@
4748
'SCS109 Use of builtin `open` for writing is discouraged in favor of `os.open` '
4849
+ 'to allow for setting file permissions'
4950
) # noqa: E501
51+
SCS110 = (
52+
'Use of `os.popen()` should be avoided, as it internally uses `subprocess.Popen` with `shell=True`' # noqa: E501
53+
)
54+
SCS111 = 'Use of `shlex.quote()` should be avoided on non-POSIX platforms (such as Windows)' # noqa: E501
55+
56+
57+
# ==============================================================================
58+
# Helper functions
59+
60+
61+
def _is_posix():
62+
"""Return True if the current system is POSIX-compatible."""
63+
# NB: we could simply use `os.name` instead of `platform.system()`. However, that solution would be difficult to
64+
# test using `mock` as a few modules (like `pytest`) actually use it internally...
65+
return platform.system() in ('Linux', 'Darwin')
66+
67+
68+
# ==============================================================================
5069

5170

5271
def _is_os_system_call(node: ast.Call) -> bool:
@@ -58,6 +77,15 @@ def _is_os_system_call(node: ast.Call) -> bool:
5877
)
5978

6079

80+
def _is_os_popen_call(node: ast.Call) -> bool:
81+
return (
82+
isinstance(node.func, ast.Attribute)
83+
and isinstance(node.func.value, ast.Name)
84+
and node.func.value.id == 'os'
85+
and node.func.attr == 'popen'
86+
)
87+
88+
6189
def _is_os_path_call(node: ast.Call) -> bool:
6290
return (
6391
isinstance(node.func, ast.Attribute) # pylint: disable=R0916
@@ -193,6 +221,15 @@ def _is_jsonpickle_encode_call(node: ast.Call) -> bool:
193221
return False
194222

195223

224+
def _is_shlex_quote_call(node: ast.Call) -> bool:
225+
return not _is_posix() and (
226+
isinstance(node.func, ast.Attribute)
227+
and isinstance(node.func.value, ast.Name)
228+
and node.func.value.id == 'shlex'
229+
and node.func.attr == 'quote'
230+
)
231+
232+
196233
class Visitor(ast.NodeVisitor):
197234
"""AST visitor class for the plugin."""
198235

@@ -215,12 +252,17 @@ def visit_Call(self, node: ast.Call) -> None:
215252
self.errors.append((node.lineno, node.col_offset, SCS102))
216253
elif _is_os_path_call(node):
217254
self.errors.append((node.lineno, node.col_offset, SCS100))
255+
elif _is_os_popen_call(node):
256+
self.errors.append((node.lineno, node.col_offset, SCS110))
218257
elif _is_subprocess_shell_true_call(node):
219258
self.errors.append((node.lineno, node.col_offset, SCS103))
220259
elif _is_builtin_open_for_writing(node):
221260
self.errors.append((node.lineno, node.col_offset, SCS109))
222261
elif isinstance(node.func, ast.Name) and (node.func.id in ('eval', 'exec')):
223262
self.errors.append((node.lineno, node.col_offset, SCS101))
263+
elif _is_shlex_quote_call(node):
264+
self.errors.append((node.lineno, node.col_offset, SCS111))
265+
224266
self.generic_visit(node)
225267

226268
def visit_Import(self, node: ast.Import) -> None:
@@ -254,6 +296,15 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
254296
# Cover:
255297
# * from os import system
256298
self.errors.append((node.lineno, node.col_offset, SCS102))
299+
elif node.module == 'os' and alias.name == 'popen':
300+
# Cover:
301+
# * from os import popen
302+
self.errors.append((node.lineno, node.col_offset, SCS110))
303+
elif not _is_posix() and node.module == 'shlex' and alias.name == 'quote':
304+
# Cover:
305+
# * from shlex import quote
306+
# * from shlex import quote as quoted
307+
self.errors.append((node.lineno, node.col_offset, SCS111))
257308

258309
self.generic_visit(node)
259310

tests/shell_exec_test.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ def test_shell_exec_ok(s):
5555
('os.system("ls -l")', flake8_scs.SCS102),
5656
('from os import system', flake8_scs.SCS102),
5757
('from os import system as os_system', flake8_scs.SCS102),
58+
('from os import popen', flake8_scs.SCS110),
59+
('from os import popen as os_popen', flake8_scs.SCS110),
5860
('subprocess.Popen(["cat", "/etc/passwd"], b, e, i, o, e, pre, c, True)', flake8_scs.SCS103),
5961
('subprocess.Popen(["cat", "/etc/passwd"], b, e, i, o, e, pre, c, True, cwd)', flake8_scs.SCS103),
6062
('subprocess.run(["cat", "/etc/passwd"], shell=True)', flake8_scs.SCS103),
@@ -65,6 +67,12 @@ def test_shell_exec_ok(s):
6567
('sp.check_call(["cat", "/etc/passwd"], shell=True)', flake8_scs.SCS103),
6668
('subprocess.check_output(["cat", "/etc/passwd"], shell=True)', flake8_scs.SCS103),
6769
('sp.check_output(["cat", "/etc/passwd"], shell=True)', flake8_scs.SCS103),
70+
('os.popen("cat")', flake8_scs.SCS110),
71+
('os.popen("cat", "r")', flake8_scs.SCS110),
72+
('os.popen("cat", "r", 1)', flake8_scs.SCS110),
73+
('os.popen("cat", buffering=1)', flake8_scs.SCS110),
74+
('os.popen("cat", mode="w")', flake8_scs.SCS110),
75+
('os.popen("cat", mode="w", buffering=1)', flake8_scs.SCS110),
6876
),
6977
)
7078
def test_shell_exec_not_ok(s, msg_id):

tests/shlex_quote_test.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 ast
17+
18+
import pytest
19+
20+
import flake8_secure_coding_standard as flake8_scs
21+
22+
23+
def results(s):
24+
return {'{}:{}: {}'.format(*r) for r in flake8_scs.Plugin(ast.parse(s)).run()}
25+
26+
27+
@pytest.mark.parametrize('s', ('', 'int(0)'))
28+
def test_shlex_quote_always_success(s):
29+
assert results(s) == set()
30+
31+
32+
@pytest.mark.parametrize(
33+
'platform, expected_success',
34+
(
35+
('Linux', True),
36+
('Darwin', True),
37+
('Java', False),
38+
('Windows', False),
39+
),
40+
)
41+
@pytest.mark.parametrize(
42+
's',
43+
(
44+
'from shlex import quote',
45+
'from shlex import quote as quoted',
46+
'shlex.quote("ls -l")',
47+
'shlex.quote(command_str)',
48+
),
49+
)
50+
def test_shlex_quote(mocker, platform, expected_success, s):
51+
mocker.patch('platform.system', lambda: platform)
52+
if expected_success:
53+
assert results(s) == set()
54+
else:
55+
assert results(s) == {'1:0: ' + flake8_scs.SCS111}

0 commit comments

Comments
 (0)