From 13d3406dbba67e22d80f8f8e5b182c5f6e45face Mon Sep 17 00:00:00 2001 From: Eric Brown Date: Tue, 7 Jan 2025 07:12:18 -0800 Subject: [PATCH] Start testing with 3.14 alphas (#1189) * Test with official 3.13 and 3.14 alphas This change updates the unit testing to use the official Python 3.13 released yesterday (Oct 7). It also starts testing against the alpha versions of Python 3.14 to catch potential problems early before it is officially released. Signed-off-by: Eric Brown * Update setup.cfg * Update setup.cfg Signed-off-by: Eric Brown --------- Signed-off-by: Eric Brown --- .github/workflows/pythonpackage.yml | 1 + bandit/core/blacklisting.py | 4 +-- bandit/core/context.py | 22 +++++--------- bandit/core/node_visitor.py | 4 +-- bandit/core/utils.py | 22 +++++++++++--- bandit/plugins/django_sql_injection.py | 8 ++--- bandit/plugins/django_xss.py | 17 ++++++----- bandit/plugins/general_hardcoded_password.py | 32 ++++++++++---------- bandit/plugins/injection_shell.py | 12 ++++---- bandit/plugins/injection_sql.py | 6 ++-- bandit/plugins/tarfile_unsafe_members.py | 2 +- tests/unit/core/test_context.py | 29 ++++++++---------- 12 files changed, 83 insertions(+), 76 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index edfd03ac3..777d8d1af 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -52,6 +52,7 @@ jobs: ["3.11", "311"], ["3.12", "312"], ["3.13", "313"], + ["3.14.0-alpha - 3.14", "314"], ] os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} diff --git a/bandit/core/blacklisting.py b/bandit/core/blacklisting.py index 2bbb093d5..8d8ed02ca 100644 --- a/bandit/core/blacklisting.py +++ b/bandit/core/blacklisting.py @@ -35,8 +35,8 @@ def blacklist(context, config): func = context.node.func if isinstance(func, ast.Name) and func.id == "__import__": if len(context.node.args): - if isinstance(context.node.args[0], ast.Str): - name = context.node.args[0].s + if isinstance(context.node.args[0], ast.Constant): + name = context.node.args[0].value else: # TODO(??): import through a variable, need symbol tab name = "UNKNOWN" diff --git a/bandit/core/context.py b/bandit/core/context.py index 8a2d4fbbc..67a26e7fd 100644 --- a/bandit/core/context.py +++ b/bandit/core/context.py @@ -178,11 +178,13 @@ def _get_literal_value(self, literal): :param literal: The AST literal to convert :return: The value of the AST literal """ - if isinstance(literal, ast.Num): - literal_value = literal.n - - elif isinstance(literal, ast.Str): - literal_value = literal.s + if isinstance(literal, ast.Constant): + if isinstance(literal.value, bool): + literal_value = str(literal.value) + elif literal.value is None: + literal_value = str(literal.value) + else: + literal_value = literal.value elif isinstance(literal, ast.List): return_list = list() @@ -205,19 +207,9 @@ def _get_literal_value(self, literal): elif isinstance(literal, ast.Dict): literal_value = dict(zip(literal.keys, literal.values)) - elif isinstance(literal, ast.Ellipsis): - # what do we want to do with this? - literal_value = None - elif isinstance(literal, ast.Name): literal_value = literal.id - elif isinstance(literal, ast.NameConstant): - literal_value = str(literal.value) - - elif isinstance(literal, ast.Bytes): - literal_value = literal.s - else: literal_value = None diff --git a/bandit/core/node_visitor.py b/bandit/core/node_visitor.py index 938e8733b..fcad0512c 100644 --- a/bandit/core/node_visitor.py +++ b/bandit/core/node_visitor.py @@ -168,7 +168,7 @@ def visit_Str(self, node): :param node: The node that is being inspected :return: - """ - self.context["str"] = node.s + self.context["str"] = node.value if not isinstance(node._bandit_parent, ast.Expr): # docstring self.context["linerange"] = b_utils.linerange(node._bandit_parent) self.update_scores(self.tester.run_tests(self.context, "Str")) @@ -181,7 +181,7 @@ def visit_Bytes(self, node): :param node: The node that is being inspected :return: - """ - self.context["bytes"] = node.s + self.context["bytes"] = node.value if not isinstance(node._bandit_parent, ast.Expr): # docstring self.context["linerange"] = b_utils.linerange(node._bandit_parent) self.update_scores(self.tester.run_tests(self.context, "Bytes")) diff --git a/bandit/core/utils.py b/bandit/core/utils.py index 7fb775305..ce987c3bd 100644 --- a/bandit/core/utils.py +++ b/bandit/core/utils.py @@ -273,12 +273,12 @@ def linerange(node): def concat_string(node, stop=None): """Builds a string from a ast.BinOp chain. - This will build a string from a series of ast.Str nodes wrapped in + This will build a string from a series of ast.Constant nodes wrapped in ast.BinOp nodes. Something like "a" + "b" + "c" or "a %s" % val etc. The provided node can be any participant in the BinOp chain. - :param node: (ast.Str or ast.BinOp) The node to process - :param stop: (ast.Str or ast.BinOp) Optional base node to stop at + :param node: (ast.Constant or ast.BinOp) The node to process + :param stop: (ast.Constant or ast.BinOp) Optional base node to stop at :returns: (Tuple) the root node of the expression, the string value """ @@ -300,7 +300,10 @@ def _get(node, bits, stop=None): node = node._bandit_parent if isinstance(node, ast.BinOp): _get(node, bits, stop) - return (node, " ".join([x.s for x in bits if isinstance(x, ast.Str)])) + return ( + node, + " ".join([x.value for x in bits if isinstance(x, ast.Constant)]), + ) def get_called_name(node): @@ -361,6 +364,17 @@ def parse_ini_file(f_loc): def check_ast_node(name): "Check if the given name is that of a valid AST node." try: + # These ast Node types don't exist in Python 3.14, but plugins may + # still check on them. + if sys.version_info >= (3, 14) and name in ( + "Num", + "Str", + "Ellipsis", + "NameConstant", + "Bytes", + ): + return name + node = getattr(ast, name) if issubclass(node, ast.AST): return name diff --git a/bandit/plugins/django_sql_injection.py b/bandit/plugins/django_sql_injection.py index a57ff46a3..96f2ea963 100644 --- a/bandit/plugins/django_sql_injection.py +++ b/bandit/plugins/django_sql_injection.py @@ -68,7 +68,7 @@ def django_extra_used(context): if key in kwargs: if isinstance(kwargs[key], ast.List): for val in kwargs[key].elts: - if not isinstance(val, ast.Str): + if not isinstance(val, ast.Constant): insecure = True break else: @@ -77,12 +77,12 @@ def django_extra_used(context): if not insecure and "select" in kwargs: if isinstance(kwargs["select"], ast.Dict): for k in kwargs["select"].keys: - if not isinstance(k, ast.Str): + if not isinstance(k, ast.Constant): insecure = True break if not insecure: for v in kwargs["select"].values: - if not isinstance(v, ast.Str): + if not isinstance(v, ast.Constant): insecure = True break else: @@ -135,7 +135,7 @@ def django_rawsql_used(context): kwargs = keywords2dict(context.node.keywords) sql = kwargs["sql"] - if not isinstance(sql, ast.Str): + if not isinstance(sql, ast.Constant): return bandit.Issue( severity=bandit.MEDIUM, confidence=bandit.MEDIUM, diff --git a/bandit/plugins/django_xss.py b/bandit/plugins/django_xss.py index e96522a55..6ed0a3975 100644 --- a/bandit/plugins/django_xss.py +++ b/bandit/plugins/django_xss.py @@ -96,7 +96,7 @@ def evaluate_var(xss_var, parent, until, ignore_nodes=None): break to = analyser.is_assigned(node) if to: - if isinstance(to, ast.Str): + if isinstance(to, ast.Constant): secure = True elif isinstance(to, ast.Name): secure = evaluate_var(to, parent, to.lineno, ignore_nodes) @@ -105,7 +105,7 @@ def evaluate_var(xss_var, parent, until, ignore_nodes=None): elif isinstance(to, (list, tuple)): num_secure = 0 for some_to in to: - if isinstance(some_to, ast.Str): + if isinstance(some_to, ast.Constant): num_secure += 1 elif isinstance(some_to, ast.Name): if evaluate_var( @@ -131,7 +131,10 @@ def evaluate_call(call, parent, ignore_nodes=None): secure = False evaluate = False if isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute): - if isinstance(call.func.value, ast.Str) and call.func.attr == "format": + if ( + isinstance(call.func.value, ast.Constant) + and call.func.attr == "format" + ): evaluate = True if call.keywords: evaluate = False # TODO(??) get support for this @@ -140,7 +143,7 @@ def evaluate_call(call, parent, ignore_nodes=None): args = list(call.args) num_secure = 0 for arg in args: - if isinstance(arg, ast.Str): + if isinstance(arg, ast.Constant): num_secure += 1 elif isinstance(arg, ast.Name): if evaluate_var(arg, parent, call.lineno, ignore_nodes): @@ -167,7 +170,7 @@ def evaluate_call(call, parent, ignore_nodes=None): def transform2call(var): if isinstance(var, ast.BinOp): is_mod = isinstance(var.op, ast.Mod) - is_left_str = isinstance(var.left, ast.Str) + is_left_str = isinstance(var.left, ast.Constant) if is_mod and is_left_str: new_call = ast.Call() new_call.args = [] @@ -212,7 +215,7 @@ def check_risk(node): secure = evaluate_call(xss_var, parent) elif isinstance(xss_var, ast.BinOp): is_mod = isinstance(xss_var.op, ast.Mod) - is_left_str = isinstance(xss_var.left, ast.Str) + is_left_str = isinstance(xss_var.left, ast.Constant) if is_mod and is_left_str: parent = node._bandit_parent while not isinstance(parent, (ast.Module, ast.FunctionDef)): @@ -272,5 +275,5 @@ def django_mark_safe(context): ] if context.call_function_name in affected_functions: xss = context.node.args[0] - if not isinstance(xss, ast.Str): + if not isinstance(xss, ast.Constant): return check_risk(context.node) diff --git a/bandit/plugins/general_hardcoded_password.py b/bandit/plugins/general_hardcoded_password.py index cc3e7d09d..594c23fd0 100644 --- a/bandit/plugins/general_hardcoded_password.py +++ b/bandit/plugins/general_hardcoded_password.py @@ -83,45 +83,45 @@ def hardcoded_password_string(context): # looks for "candidate='some_string'" for targ in node._bandit_parent.targets: if isinstance(targ, ast.Name) and RE_CANDIDATES.search(targ.id): - return _report(node.s) + return _report(node.value) elif isinstance(targ, ast.Attribute) and RE_CANDIDATES.search( targ.attr ): - return _report(node.s) + return _report(node.value) elif isinstance( node._bandit_parent, ast.Subscript - ) and RE_CANDIDATES.search(node.s): + ) and RE_CANDIDATES.search(node.value): # Py39+: looks for "dict[candidate]='some_string'" # subscript -> index -> string assign = node._bandit_parent._bandit_parent if isinstance(assign, ast.Assign) and isinstance( - assign.value, ast.Str + assign.value, ast.Constant ): - return _report(assign.value.s) + return _report(assign.value.value) elif isinstance(node._bandit_parent, ast.Index) and RE_CANDIDATES.search( - node.s + node.value ): # looks for "dict[candidate]='some_string'" # assign -> subscript -> index -> string assign = node._bandit_parent._bandit_parent._bandit_parent if isinstance(assign, ast.Assign) and isinstance( - assign.value, ast.Str + assign.value, ast.Constant ): - return _report(assign.value.s) + return _report(assign.value.value) elif isinstance(node._bandit_parent, ast.Compare): # looks for "candidate == 'some_string'" comp = node._bandit_parent if isinstance(comp.left, ast.Name): if RE_CANDIDATES.search(comp.left.id): - if isinstance(comp.comparators[0], ast.Str): - return _report(comp.comparators[0].s) + if isinstance(comp.comparators[0], ast.Constant): + return _report(comp.comparators[0].value) elif isinstance(comp.left, ast.Attribute): if RE_CANDIDATES.search(comp.left.attr): - if isinstance(comp.comparators[0], ast.Str): - return _report(comp.comparators[0].s) + if isinstance(comp.comparators[0], ast.Constant): + return _report(comp.comparators[0].value) @test.checks("Call") @@ -176,8 +176,8 @@ def hardcoded_password_funcarg(context): """ # looks for "function(candidate='some_string')" for kw in context.node.keywords: - if isinstance(kw.value, ast.Str) and RE_CANDIDATES.search(kw.arg): - return _report(kw.value.s) + if isinstance(kw.value, ast.Constant) and RE_CANDIDATES.search(kw.arg): + return _report(kw.value.value) @test.checks("FunctionDef") @@ -242,5 +242,5 @@ def hardcoded_password_default(context): # go through all (param, value)s and look for candidates for key, val in zip(context.node.args.args, defs): if isinstance(key, (ast.Name, ast.arg)): - if isinstance(val, ast.Str) and RE_CANDIDATES.search(key.arg): - return _report(val.s) + if isinstance(val, ast.Constant) and RE_CANDIDATES.search(key.arg): + return _report(val.value) diff --git a/bandit/plugins/injection_shell.py b/bandit/plugins/injection_shell.py index 229368340..f48545507 100644 --- a/bandit/plugins/injection_shell.py +++ b/bandit/plugins/injection_shell.py @@ -15,7 +15,7 @@ def _evaluate_shell_call(context): - no_formatting = isinstance(context.node.args[0], ast.Str) + no_formatting = isinstance(context.node.args[0], ast.Constant) if no_formatting: return bandit.LOW @@ -83,16 +83,14 @@ def has_shell(context): for key in keywords: if key.arg == "shell": val = key.value - if isinstance(val, ast.Num): - result = bool(val.n) + if isinstance(val, ast.Constant): + result = bool(val.value) elif isinstance(val, ast.List): result = bool(val.elts) elif isinstance(val, ast.Dict): result = bool(val.keys) elif isinstance(val, ast.Name) and val.id in ["False", "None"]: result = False - elif isinstance(val, ast.NameConstant): - result = val.value else: result = True return result @@ -687,7 +685,9 @@ def start_process_with_partial_path(context, config): node = node.elts[0] # make sure the param is a string literal and not a var name - if isinstance(node, ast.Str) and not full_path_match.match(node.s): + if isinstance(node, ast.Constant) and not full_path_match.match( + node.value + ): return bandit.Issue( severity=bandit.LOW, confidence=bandit.HIGH, diff --git a/bandit/plugins/injection_sql.py b/bandit/plugins/injection_sql.py index bd7aa92a1..5071be177 100644 --- a/bandit/plugins/injection_sql.py +++ b/bandit/plugins/injection_sql.py @@ -96,7 +96,7 @@ def _evaluate_ast(node): elif isinstance( node._bandit_parent, ast.Attribute ) and node._bandit_parent.attr in ("format", "replace"): - statement = node.s + statement = node.value # Hierarchy for "".format() is Wrapper -> Call -> Attribute -> Str wrapper = node._bandit_parent._bandit_parent._bandit_parent if node._bandit_parent.attr == "replace": @@ -107,14 +107,14 @@ def _evaluate_ast(node): substrings = [ child for child in node._bandit_parent.values - if isinstance(child, ast.Str) + if isinstance(child, ast.Constant) ] # JoinedStr consists of list of Constant and FormattedValue # instances. Let's perform one test for the whole string # and abandon all parts except the first one to raise one # failed test instead of many for the same SQL statement. if substrings and node == substrings[0]: - statement = "".join([str(child.s) for child in substrings]) + statement = "".join([str(child.value) for child in substrings]) wrapper = node._bandit_parent._bandit_parent if isinstance(wrapper, ast.Call): # wrapped in "execute" call? diff --git a/bandit/plugins/tarfile_unsafe_members.py b/bandit/plugins/tarfile_unsafe_members.py index 5ad145c1a..d47a241e6 100644 --- a/bandit/plugins/tarfile_unsafe_members.py +++ b/bandit/plugins/tarfile_unsafe_members.py @@ -98,7 +98,7 @@ def is_filter_data(context): for keyword in context.node.keywords: if keyword.arg == "filter": arg = keyword.value - return isinstance(arg, ast.Str) and arg.s == "data" + return isinstance(arg, ast.Constant) and arg.value == "data" @test.test_id("B202") diff --git a/tests/unit/core/test_context.py b/tests/unit/core/test_context.py index 23b3436da..32dff63df 100644 --- a/tests/unit/core/test_context.py +++ b/tests/unit/core/test_context.py @@ -132,39 +132,36 @@ def test_function_def_defaults_qual(self, get_qual_attr): def test__get_literal_value(self): new_context = context.Context() - value = ast.Num(42) - expected = value.n + value = ast.Constant(42) + expected = value.value self.assertEqual(expected, new_context._get_literal_value(value)) - value = ast.Str("spam") - expected = value.s + value = ast.Constant("spam") + expected = value.value self.assertEqual(expected, new_context._get_literal_value(value)) - value = ast.List([ast.Str("spam"), ast.Num(42)], ast.Load()) - expected = [ast.Str("spam").s, ast.Num(42).n] + value = ast.List([ast.Constant("spam"), ast.Constant(42)], ast.Load()) + expected = [ast.Constant("spam").value, ast.Constant(42).value] self.assertListEqual(expected, new_context._get_literal_value(value)) - value = ast.Tuple([ast.Str("spam"), ast.Num(42)], ast.Load()) - expected = (ast.Str("spam").s, ast.Num(42).n) + value = ast.Tuple([ast.Constant("spam"), ast.Constant(42)], ast.Load()) + expected = (ast.Constant("spam").value, ast.Constant(42).value) self.assertTupleEqual(expected, new_context._get_literal_value(value)) - value = ast.Set([ast.Str("spam"), ast.Num(42)]) - expected = {ast.Str("spam").s, ast.Num(42).n} + value = ast.Set([ast.Constant("spam"), ast.Constant(42)]) + expected = {ast.Constant("spam").value, ast.Constant(42).value} self.assertSetEqual(expected, new_context._get_literal_value(value)) value = ast.Dict(["spam", "eggs"], [42, "foo"]) expected = dict(spam=42, eggs="foo") self.assertDictEqual(expected, new_context._get_literal_value(value)) - value = ast.Ellipsis() - self.assertIsNone(new_context._get_literal_value(value)) - value = ast.Name("spam", ast.Load()) expected = value.id self.assertEqual(expected, new_context._get_literal_value(value)) - value = ast.Bytes(b"spam") - expected = value.s + value = ast.Constant(b"spam") + expected = value.value self.assertEqual(expected, new_context._get_literal_value(value)) self.assertIsNone(new_context._get_literal_value(None)) @@ -207,7 +204,7 @@ def test_get_lineno_for_call_arg(self, node): def test_get_call_arg_at_position(self): expected_arg = "spam" ref_call = mock.Mock() - ref_call.args = [ast.Str(expected_arg)] + ref_call.args = [ast.Constant(expected_arg)] ref_context = dict(call=ref_call) new_context = context.Context(context_object=ref_context) self.assertEqual(expected_arg, new_context.get_call_arg_at_position(0))