Repository: PyCQA/bandit (python)

Issue:
Bandit version 1.8.1 showing false-positives in B106 warnings
### Describe the bug

Openstack Neutron (and other Openstack projects) use bandit to check our python files for possible security issues.

Today, right after version 1.8.1 dropped, it started breaking our gate. Here's a copy/paste of one of the snippets:

```
>> Issue: [B106:hardcoded_password_funcarg] Possible hardcoded password: 'True'
   Severity: Low   Confidence: Medium
   CWE: CWE-259 (https://cwe.mitre.org/data/definitions/259.html)
   More Info: https://bandit.readthedocs.io/en/1.8.1/plugins/b106_hardcoded_password_funcarg.html
   Location: neutron/conf/agent/metadata/config.py:56:4
54	                default=8775,
55	                help=_("TCP Port used by Nova metadata server.")),
56	    cfg.StrOpt('metadata_proxy_shared_secret',
57	               default='',
58	               help=_('When proxying metadata requests, Neutron signs the '
59	                      'Instance-ID header with a shared secret to prevent '
60	                      'spoofing. You may select any string for a secret, '
61	                      'but it must match here and in the configuration used '
62	                      'by the Nova metadata server. NOTE: Nova uses the same '
63	                      'config key, but in [neutron] section.'),
64	               secret=True),
65	    cfg.StrOpt('nova_metadata_protocol',
66	               default='http',
```

The 'secret=True' isn't a password at all. The entire file is at:

https://github.com/openstack/neutron/blob/master/neutron/conf/agent/metadata/config.py#L56

There were other places with this same signature failing as well.

We can work around it by either ignoring B106 or pinning to 1.8.0 but would be good to get it fixed.

Please let me know if there is anything else needed to help track this down.

Oh, and the url that gets printed there is a 404, don't know if that is temporary.

### Reproduction steps

```bash
Run bandit against the file I linked.
```

### Expected behavior

Should not see any failure.

### Bandit version

1.8.0 (Default)

### Python version

3.13 (Default)

### Additional context

_No response_

Pull request:
Revert "Start testing with 3.14 alphas"
Reverts PyCQA/bandit#1189

Fixes #1216

Patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 777d8d1..edfd03a 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -52,7 +52,6 @@ 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 8d8ed02..2bbb093 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.Constant):
-                    name = context.node.args[0].value
+                if isinstance(context.node.args[0], ast.Str):
+                    name = context.node.args[0].s
                 else:
                     # TODO(??): import through a variable, need symbol tab
                     name = "UNKNOWN"
diff --git a/bandit/core/context.py b/bandit/core/context.py
index 67a26e7..8a2d4fb 100644
--- a/bandit/core/context.py
+++ b/bandit/core/context.py
@@ -178,13 +178,11 @@ class Context:
         :param literal: The AST literal to convert
         :return: The value of the AST literal
         """
-        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
+        if isinstance(literal, ast.Num):
+            literal_value = literal.n
+
+        elif isinstance(literal, ast.Str):
+            literal_value = literal.s
 
         elif isinstance(literal, ast.List):
             return_list = list()
@@ -207,9 +205,19 @@ class Context:
         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 fcad051..938e873 100644
--- a/bandit/core/node_visitor.py
+++ b/bandit/core/node_visitor.py
@@ -168,7 +168,7 @@ class BanditNodeVisitor:
         :param node: The node that is being inspected
         :return: -
         """
-        self.context["str"] = node.value
+        self.context["str"] = node.s
         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 @@ class BanditNodeVisitor:
         :param node: The node that is being inspected
         :return: -
         """
-        self.context["bytes"] = node.value
+        self.context["bytes"] = node.s
         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 ce987c3..7fb7753 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.Constant nodes wrapped in
+    This will build a string from a series of ast.Str 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.Constant or ast.BinOp) The node to process
-    :param stop: (ast.Constant or ast.BinOp) Optional base node to stop at
+    :param node: (ast.Str or ast.BinOp) The node to process
+    :param stop: (ast.Str or ast.BinOp) Optional base node to stop at
     :returns: (Tuple) the root node of the expression, the string value
     """
 
@@ -300,10 +300,7 @@ def concat_string(node, stop=None):
         node = node._bandit_parent
     if isinstance(node, ast.BinOp):
         _get(node, bits, stop)
-    return (
-        node,
-        " ".join([x.value for x in bits if isinstance(x, ast.Constant)]),
-    )
+    return (node, " ".join([x.s for x in bits if isinstance(x, ast.Str)]))
 
 
 def get_called_name(node):
@@ -364,17 +361,6 @@ 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 96f2ea9..a57ff46 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.Constant):
+                        if not isinstance(val, ast.Str):
                             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.Constant):
+                    if not isinstance(k, ast.Str):
                         insecure = True
                         break
                 if not insecure:
                     for v in kwargs["select"].values:
-                        if not isinstance(v, ast.Constant):
+                        if not isinstance(v, ast.Str):
                             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.Constant):
+            if not isinstance(sql, ast.Str):
                 return bandit.Issue(
                     severity=bandit.MEDIUM,
                     confidence=bandit.MEDIUM,
diff --git a/bandit/plugins/django_xss.py b/bandit/plugins/django_xss.py
index 6ed0a39..e96522a 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.Constant):
+                if isinstance(to, ast.Str):
                     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.Constant):
+                        if isinstance(some_to, ast.Str):
                             num_secure += 1
                         elif isinstance(some_to, ast.Name):
                             if evaluate_var(
@@ -131,10 +131,7 @@ 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.Constant)
-            and call.func.attr == "format"
-        ):
+        if isinstance(call.func.value, ast.Str) and call.func.attr == "format":
             evaluate = True
             if call.keywords:
                 evaluate = False  # TODO(??) get support for this
@@ -143,7 +140,7 @@ def evaluate_call(call, parent, ignore_nodes=None):
         args = list(call.args)
         num_secure = 0
         for arg in args:
-            if isinstance(arg, ast.Constant):
+            if isinstance(arg, ast.Str):
                 num_secure += 1
             elif isinstance(arg, ast.Name):
                 if evaluate_var(arg, parent, call.lineno, ignore_nodes):
@@ -170,7 +167,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.Constant)
+        is_left_str = isinstance(var.left, ast.Str)
         if is_mod and is_left_str:
             new_call = ast.Call()
             new_call.args = []
@@ -215,7 +212,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.Constant)
+        is_left_str = isinstance(xss_var.left, ast.Str)
         if is_mod and is_left_str:
             parent = node._bandit_parent
             while not isinstance(parent, (ast.Module, ast.FunctionDef)):
@@ -275,5 +272,5 @@ def django_mark_safe(context):
         ]
         if context.call_function_name in affected_functions:
             xss = context.node.args[0]
-            if not isinstance(xss, ast.Constant):
+            if not isinstance(xss, ast.Str):
                 return check_risk(context.node)
diff --git a/bandit/plugins/general_hardcoded_password.py b/bandit/plugins/general_hardcoded_password.py
index 594c23f..cc3e7d0 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.value)
+                return _report(node.s)
             elif isinstance(targ, ast.Attribute) and RE_CANDIDATES.search(
                 targ.attr
             ):
-                return _report(node.value)
+                return _report(node.s)
 
     elif isinstance(
         node._bandit_parent, ast.Subscript
-    ) and RE_CANDIDATES.search(node.value):
+    ) and RE_CANDIDATES.search(node.s):
         # 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.Constant
+            assign.value, ast.Str
         ):
-            return _report(assign.value.value)
+            return _report(assign.value.s)
 
     elif isinstance(node._bandit_parent, ast.Index) and RE_CANDIDATES.search(
-        node.value
+        node.s
     ):
         # 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.Constant
+            assign.value, ast.Str
         ):
-            return _report(assign.value.value)
+            return _report(assign.value.s)
 
     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.Constant):
-                    return _report(comp.comparators[0].value)
+                if isinstance(comp.comparators[0], ast.Str):
+                    return _report(comp.comparators[0].s)
         elif isinstance(comp.left, ast.Attribute):
             if RE_CANDIDATES.search(comp.left.attr):
-                if isinstance(comp.comparators[0], ast.Constant):
-                    return _report(comp.comparators[0].value)
+                if isinstance(comp.comparators[0], ast.Str):
+                    return _report(comp.comparators[0].s)
 
 
 @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.Constant) and RE_CANDIDATES.search(kw.arg):
-            return _report(kw.value.value)
+        if isinstance(kw.value, ast.Str) and RE_CANDIDATES.search(kw.arg):
+            return _report(kw.value.s)
 
 
 @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.Constant) and RE_CANDIDATES.search(key.arg):
-                return _report(val.value)
+            if isinstance(val, ast.Str) and RE_CANDIDATES.search(key.arg):
+                return _report(val.s)
diff --git a/bandit/plugins/injection_shell.py b/bandit/plugins/injection_shell.py
index f485455..2293683 100644
--- a/bandit/plugins/injection_shell.py
+++ b/bandit/plugins/injection_shell.py
@@ -15,7 +15,7 @@ full_path_match = re.compile(r"^(?:[A-Za-z](?=\:)|[\\\/\.])")
 
 
 def _evaluate_shell_call(context):
-    no_formatting = isinstance(context.node.args[0], ast.Constant)
+    no_formatting = isinstance(context.node.args[0], ast.Str)
 
     if no_formatting:
         return bandit.LOW
@@ -83,14 +83,16 @@ def has_shell(context):
         for key in keywords:
             if key.arg == "shell":
                 val = key.value
-                if isinstance(val, ast.Constant):
-                    result = bool(val.value)
+                if isinstance(val, ast.Num):
+                    result = bool(val.n)
                 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
@@ -685,9 +687,7 @@ 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.Constant) and not full_path_match.match(
-                node.value
-            ):
+            if isinstance(node, ast.Str) and not full_path_match.match(node.s):
                 return bandit.Issue(
                     severity=bandit.LOW,
                     confidence=bandit.HIGH,
diff --git a/bandit/plugins/injection_sql.py b/bandit/plugins/injection_sql.py
index 5071be1..bd7aa92 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.value
+        statement = node.s
         # 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.Constant)
+            if isinstance(child, ast.Str)
         ]
         # 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.value) for child in substrings])
+            statement = "".join([str(child.s) 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 d47a241..5ad145c 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.Constant) and arg.value == "data"
+            return isinstance(arg, ast.Str) and arg.s == "data"
 
 
 @test.test_id("B202")


Test patch:
diff --git a/tests/unit/core/test_context.py b/tests/unit/core/test_context.py
index 32dff63..23b3436 100644
--- a/tests/unit/core/test_context.py
+++ b/tests/unit/core/test_context.py
@@ -132,36 +132,39 @@ class ContextTests(testtools.TestCase):
     def test__get_literal_value(self):
         new_context = context.Context()
 
-        value = ast.Constant(42)
-        expected = value.value
+        value = ast.Num(42)
+        expected = value.n
         self.assertEqual(expected, new_context._get_literal_value(value))
 
-        value = ast.Constant("spam")
-        expected = value.value
+        value = ast.Str("spam")
+        expected = value.s
         self.assertEqual(expected, new_context._get_literal_value(value))
 
-        value = ast.List([ast.Constant("spam"), ast.Constant(42)], ast.Load())
-        expected = [ast.Constant("spam").value, ast.Constant(42).value]
+        value = ast.List([ast.Str("spam"), ast.Num(42)], ast.Load())
+        expected = [ast.Str("spam").s, ast.Num(42).n]
         self.assertListEqual(expected, new_context._get_literal_value(value))
 
-        value = ast.Tuple([ast.Constant("spam"), ast.Constant(42)], ast.Load())
-        expected = (ast.Constant("spam").value, ast.Constant(42).value)
+        value = ast.Tuple([ast.Str("spam"), ast.Num(42)], ast.Load())
+        expected = (ast.Str("spam").s, ast.Num(42).n)
         self.assertTupleEqual(expected, new_context._get_literal_value(value))
 
-        value = ast.Set([ast.Constant("spam"), ast.Constant(42)])
-        expected = {ast.Constant("spam").value, ast.Constant(42).value}
+        value = ast.Set([ast.Str("spam"), ast.Num(42)])
+        expected = {ast.Str("spam").s, ast.Num(42).n}
         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.Constant(b"spam")
-        expected = value.value
+        value = ast.Bytes(b"spam")
+        expected = value.s
         self.assertEqual(expected, new_context._get_literal_value(value))
 
         self.assertIsNone(new_context._get_literal_value(None))
@@ -204,7 +207,7 @@ class ContextTests(testtools.TestCase):
     def test_get_call_arg_at_position(self):
         expected_arg = "spam"
         ref_call = mock.Mock()
-        ref_call.args = [ast.Constant(expected_arg)]
+        ref_call.args = [ast.Str(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))