Skip to content

Commit f4d5535

Browse files
committed
Suggest fixes for issues
This change introduces a new feature that will suggest a fix in the form of a line of code as replacement for the line range of the issue. This is the first step to have the ability to auto-correct problems detected. Later more changes can be merged to modify the file with the suggested fix. The Issue class has a new fix string attribute that denotes how the lines of affected code can be replaced. This suggested fix will not preserve code comments and possibly other optimizations the AST does not capture. Closes #439 Signed-off-by: Eric Brown <[email protected]> Signed-off-by: Eric Brown <[email protected]>
1 parent bcb6648 commit f4d5535

13 files changed

+183
-2
lines changed

bandit/core/context.py

+44
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
#
44
# SPDX-License-Identifier: Apache-2.0
55
import ast
6+
import linecache
7+
import sys
68

79
from bandit.core import utils
810

@@ -311,6 +313,48 @@ def is_module_imported_like(self, module):
311313
return True
312314
return False
313315

316+
def get_outer_text(self):
317+
"""Get the text to the left and right of the node in context.
318+
319+
Gets the text to the left and text to the right of the node in
320+
context. This function depends on knowing the line range, col_offset,
321+
and end_col_offset.
322+
323+
:return: outer text as tuple
324+
"""
325+
lineno = self._context.get("linerange")[0]
326+
end_lineno = self._context.get("linerange")[-1]
327+
col_offset = self._context.get("col_offset")
328+
end_col_offset = self._context.get("end_col_offset")
329+
330+
if self._context.get("filename") == "<stdin>":
331+
self._context.get("file_data").seek(0)
332+
for line_num in range(1, lineno):
333+
self._context.get("file_data").readline()
334+
line = self._context.get("file_data").readline()
335+
end_line = line
336+
if end_lineno > lineno:
337+
for line_num in range(1, end_lineno):
338+
self._context.get("file_data").readline()
339+
end_line = self._context.get("file_data").readline()
340+
else:
341+
line = linecache.getline(self._context.get("filename"), lineno)
342+
end_line = linecache.getline(
343+
self._context.get("filename"), end_lineno
344+
)
345+
346+
return (line[:col_offset], end_line[end_col_offset:])
347+
348+
def unparse(self, transformer):
349+
"""Unparse an ast node using given transformer
350+
351+
:param transformer: NodeTransformer that fixes the ast
352+
:return: node as statement string
353+
"""
354+
fixed_node = ast.fix_missing_locations(transformer)
355+
outer_text = self.get_outer_text()
356+
return outer_text[0] + ast.unparse(fixed_node) + outer_text[1]
357+
314358
@property
315359
def filename(self):
316360
return self._context.get("filename")

bandit/core/issue.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(
8787
test_id="",
8888
col_offset=-1,
8989
end_col_offset=0,
90+
fix=None,
9091
):
9192
self.severity = severity
9293
self.cwe = Cwe(cwe)
@@ -103,6 +104,7 @@ def __init__(
103104
self.col_offset = col_offset
104105
self.end_col_offset = end_col_offset
105106
self.linerange = []
107+
self.fix = fix
106108

107109
def __str__(self):
108110
return (
@@ -195,7 +197,7 @@ def get_code(self, max_lines=3, tabbed=False):
195197
if not len(text):
196198
break
197199
lines.append(tmplt % (line, text))
198-
return "".join(lines)
200+
return "".join(lines).rstrip()
199201

200202
def as_dict(self, with_code=True, max_lines=3):
201203
"""Convert the issue to a dict of values for outputting."""
@@ -215,6 +217,8 @@ def as_dict(self, with_code=True, max_lines=3):
215217

216218
if with_code:
217219
out["code"] = self.get_code(max_lines=max_lines)
220+
if self.fix:
221+
out["fix"] = self.fix
218222
return out
219223

220224
def from_dict(self, data, with_code=True):
@@ -230,6 +234,7 @@ def from_dict(self, data, with_code=True):
230234
self.linerange = data["line_range"]
231235
self.col_offset = data.get("col_offset", 0)
232236
self.end_col_offset = data.get("end_col_offset", 0)
237+
self.fix = data.get("fix")
233238

234239

235240
def cwe_from_dict(data):

bandit/core/node_visitor.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
LOG = logging.getLogger(__name__)
1414

1515

16-
class BanditNodeVisitor:
16+
class BanditNodeVisitor(ast.NodeTransformer):
1717
def __init__(
1818
self, fname, fdata, metaast, testset, debug, nosec_lines, metrics
1919
):

bandit/formatters/html.py

+14
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,19 @@ def report(manager, fileobj, sev_level, conf_level, lines=-1):
270270
<b>Line number: </b>{line_number}<br>
271271
<b>More info: </b><a href="{url}" target="_blank">{url}</a><br>
272272
{code}
273+
<b>Suggested Fix:</b><br>
274+
{fix}
273275
{candidates}
274276
</div>
275277
</div>
278+
"""
279+
280+
fix_block = """
281+
<div class="fix">
282+
<pre>
283+
{fix}
284+
</pre>
285+
</div>
276286
"""
277287

278288
code_block = """
@@ -357,6 +367,9 @@ def report(manager, fileobj, sev_level, conf_level, lines=-1):
357367
candidates = candidate_block.format(candidate_list=candidates_str)
358368

359369
url = docs_utils.get_url(issue.test_id)
370+
fix = (
371+
fix_block.format(fix=html_escape(issue.fix)) if issue.fix else None
372+
)
360373
results_str += issue_block.format(
361374
issue_no=index,
362375
issue_class=f"issue-sev-{issue.severity.lower()}",
@@ -372,6 +385,7 @@ def report(manager, fileobj, sev_level, conf_level, lines=-1):
372385
candidates=candidates,
373386
url=url,
374387
line_number=issue.lineno,
388+
fix=fix,
375389
)
376390

377391
# build the metrics string to insert in the report

bandit/formatters/screen.py

+7
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ def _output_issue_str(
146146
[indent + line for line in issue.get_code(lines, True).split("\n")]
147147
)
148148

149+
if issue.fix:
150+
bits.append(
151+
f"{indent} {COLOR[issue.severity]}"
152+
f"Suggested Fix:{COLOR['DEFAULT']}"
153+
)
154+
bits.append(f"\t{issue.fix}")
155+
149156
return "\n".join([bit for bit in bits])
150157

151158

bandit/formatters/text.py

+4
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ def _output_issue_str(
112112
[indent + line for line in issue.get_code(lines, True).split("\n")]
113113
)
114114

115+
if issue.fix:
116+
bits.append(f"{indent} Suggested Fix:")
117+
bits.append(f"\t{issue.fix}")
118+
115119
return "\n".join([bit for bit in bits])
116120

117121

bandit/plugins/app_debug.py

+3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def flask_debug_true(context):
5252
if context.is_module_imported_like("flask"):
5353
if context.call_function_name_qual.endswith(".run"):
5454
if context.check_call_arg_value("debug", "True"):
55+
context.node.keywords[0].value.value = False
56+
5557
return bandit.Issue(
5658
severity=bandit.HIGH,
5759
confidence=bandit.MEDIUM,
@@ -60,4 +62,5 @@ def flask_debug_true(context):
6062
"which exposes the Werkzeug debugger and allows "
6163
"the execution of arbitrary code.",
6264
lineno=context.get_lineno_for_call_arg("debug"),
65+
fix=context.unparse(context.node),
6366
)

bandit/plugins/crypto_request_no_cert_validation.py

+3
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,14 @@ def request_with_no_cert_validation(context):
6565
and context.call_function_name in HTTPX_ATTRS
6666
):
6767
if context.check_call_arg_value("verify", "False"):
68+
context.node.keywords[0].value.value = True
69+
6870
return bandit.Issue(
6971
severity=bandit.HIGH,
7072
confidence=bandit.HIGH,
7173
cwe=issue.Cwe.IMPROPER_CERT_VALIDATION,
7274
text=f"Call to {qualname} with verify=False disabling SSL "
7375
"certificate checks, security issue.",
7476
lineno=context.get_lineno_for_call_arg("verify"),
77+
fix=context.unparse(context.node),
7578
)

bandit/plugins/hashlib_insecure_functions.py

+18
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
Added check for the crypt module weak hashes
4949
5050
""" # noqa: E501
51+
import ast
5152
import sys
5253

5354
import bandit
@@ -58,6 +59,18 @@
5859
WEAK_CRYPT_HASHES = ("METHOD_CRYPT", "METHOD_MD5", "METHOD_BLOWFISH")
5960

6061

62+
def transform(node):
63+
found = False
64+
for keyword in node.keywords:
65+
if keyword.arg == "usedforsecurity":
66+
keyword.value.value = False
67+
found = True
68+
if not found:
69+
keyword = ast.keyword("usedforsecurity", ast.Constant(False))
70+
node.keywords.append(keyword)
71+
return node
72+
73+
6174
def _hashlib_func(context, func):
6275
keywords = context.call_keywords
6376

@@ -70,6 +83,7 @@ def _hashlib_func(context, func):
7083
text=f"Use of weak {func.upper()} hash for security. "
7184
"Consider usedforsecurity=False",
7285
lineno=context.node.lineno,
86+
fix=context.unparse(context.node),
7387
)
7488
elif func == "new":
7589
args = context.call_args
@@ -83,6 +97,7 @@ def _hashlib_func(context, func):
8397
text=f"Use of weak {name.upper()} hash for "
8498
"security. Consider usedforsecurity=False",
8599
lineno=context.node.lineno,
100+
fix=context.unparse(context.node),
86101
)
87102

88103

@@ -91,13 +106,16 @@ def _hashlib_new(context, func):
91106
args = context.call_args
92107
keywords = context.call_keywords
93108
name = args[0] if args else keywords.get("name", None)
109+
if len(context.node.args):
110+
context.node.args[0].value = "sha224"
94111
if isinstance(name, str) and name.lower() in WEAK_HASHES:
95112
return bandit.Issue(
96113
severity=bandit.MEDIUM,
97114
confidence=bandit.HIGH,
98115
cwe=issue.Cwe.BROKEN_CRYPTO,
99116
text=f"Use of insecure {name.upper()} hash function.",
100117
lineno=context.node.lineno,
118+
fix=context.unparse(context.node),
101119
)
102120

103121

bandit/plugins/jinja2_templates.py

+12
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ def jinja2_autoescape_false(context):
8585
getattr(node.value, "id", None) == "False"
8686
or getattr(node.value, "value", None) is False
8787
):
88+
node.value.value = True
89+
8890
return bandit.Issue(
8991
severity=bandit.HIGH,
9092
confidence=bandit.HIGH,
@@ -94,6 +96,7 @@ def jinja2_autoescape_false(context):
9496
"Use autoescape=True or use the "
9597
"select_autoescape function to mitigate XSS "
9698
"vulnerabilities.",
99+
fix=context.unparse(context.node),
97100
)
98101
# found autoescape
99102
if getattr(node, "arg", None) == "autoescape":
@@ -112,6 +115,8 @@ def jinja2_autoescape_false(context):
112115
):
113116
return
114117
else:
118+
node.value = ast.Constant(value=True, kind=None)
119+
115120
return bandit.Issue(
116121
severity=bandit.HIGH,
117122
confidence=bandit.MEDIUM,
@@ -121,14 +126,21 @@ def jinja2_autoescape_false(context):
121126
"Ensure autoescape=True or use the "
122127
"select_autoescape function to mitigate "
123128
"XSS vulnerabilities.",
129+
fix=context.unparse(context.node),
124130
)
125131
# We haven't found a keyword named autoescape, indicating default
126132
# behavior
133+
keyword = ast.keyword(
134+
"autoescape", ast.Constant(value=True, kind=None)
135+
)
136+
context.node.keywords.append(keyword)
137+
127138
return bandit.Issue(
128139
severity=bandit.HIGH,
129140
confidence=bandit.HIGH,
130141
cwe=issue.Cwe.CODE_INJECTION,
131142
text="By default, jinja2 sets autoescape to False. Consider "
132143
"using autoescape=True or use the select_autoescape "
133144
"function to mitigate XSS vulnerabilities.",
145+
fix=context.unparse(context.node),
134146
)

bandit/plugins/ssh_no_host_key_verification.py

+3
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def ssh_no_host_key_verification(context):
6464
policy_argument_value = policy_argument.func.id
6565

6666
if policy_argument_value in ["AutoAddPolicy", "WarningPolicy"]:
67+
context.node.args[0].attr = "RejectPolicy"
68+
6769
return bandit.Issue(
6870
severity=bandit.HIGH,
6971
confidence=bandit.MEDIUM,
@@ -73,4 +75,5 @@ def ssh_no_host_key_verification(context):
7375
lineno=context.get_lineno_for_call_arg(
7476
"set_missing_host_key_policy"
7577
),
78+
fix=context.unparse(context.node),
7679
)

bandit/plugins/yaml_load.py

+16
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,27 @@ def yaml_load(context):
6666
not context.get_call_arg_at_position(1) == "CSafeLoader",
6767
]
6868
):
69+
if getattr(context.node.func, "attr", None) == "load":
70+
context.node.func.attr = "safe_load"
71+
for keyword in context.node.keywords:
72+
if keyword.arg == "Loader":
73+
context.node.keywords.remove(keyword)
74+
break
75+
elif getattr(context.node.func, "id", None) == "load":
76+
# Suggesting a switch to safe_load won't work without the import.
77+
# Therefore switch to a SafeLoader.
78+
# TODO: fix this
79+
for keyword in context.node.keywords:
80+
if keyword.arg == "Loader":
81+
context.node.keywords.remove(keyword)
82+
break
83+
6984
return bandit.Issue(
7085
severity=bandit.MEDIUM,
7186
confidence=bandit.HIGH,
7287
cwe=issue.Cwe.IMPROPER_INPUT_VALIDATION,
7388
text="Use of unsafe yaml load. Allows instantiation of"
7489
" arbitrary objects. Consider yaml.safe_load().",
7590
lineno=context.node.lineno,
91+
fix=context.unparse(context.node),
7692
)

0 commit comments

Comments
 (0)