Repository: PyCQA/bandit
Pull request #985 (2023-02-12 23:27:36)

Added a bit more `project_urls`


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..88b0d5f
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+---
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    interval:
+      schedule: "monthly"
diff --git a/bandit/core/node_visitor.py b/bandit/core/node_visitor.py
index c2aa393..e721db6 100644
--- a/bandit/core/node_visitor.py
+++ b/bandit/core/node_visitor.py
@@ -200,13 +200,6 @@ class BanditNodeVisitor:
         if hasattr(node, "lineno"):
             self.context["lineno"] = node.lineno
 
-            # explicitly check for empty set to skip all tests for a line
-            nosec_tests = self.nosec_lines.get(node.lineno)
-            if nosec_tests is not None and not len(nosec_tests):
-                LOG.debug("skipped, nosec without test number")
-                self.metrics.note_nosec()
-                return False
-
         if hasattr(node, "col_offset"):
             self.context["col_offset"] = node.col_offset
         if hasattr(node, "end_col_offset"):
diff --git a/bandit/core/utils.py b/bandit/core/utils.py
index 3ac78f5..a7e1c2b 100644
--- a/bandit/core/utils.py
+++ b/bandit/core/utils.py
@@ -370,3 +370,11 @@ def check_ast_node(name):
         pass
 
     raise TypeError("Error: %s is not a valid node type in AST" % name)
+
+
+def get_nosec(nosec_lines, context):
+    for lineno in context["linerange"]:
+        nosec = nosec_lines.get(lineno, None)
+        if nosec is not None:
+            return nosec
+    return None
diff --git a/bandit/plugins/injection_sql.py b/bandit/plugins/injection_sql.py
index c69750c..dd9b5c0 100644
--- a/bandit/plugins/injection_sql.py
+++ b/bandit/plugins/injection_sql.py
@@ -92,8 +92,18 @@ def _evaluate_ast(node):
     elif hasattr(ast, "JoinedStr") and isinstance(
         node._bandit_parent, ast.JoinedStr
     ):
-        statement = node.s
-        wrapper = node._bandit_parent._bandit_parent
+        substrings = [
+            child
+            for child in node._bandit_parent.values
+            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.s) for child in substrings])
+            wrapper = node._bandit_parent._bandit_parent
 
     if isinstance(wrapper, ast.Call):  # wrapped in "execute" call?
         names = ["execute", "executemany"]
diff --git a/examples/sql_multiline_statements.py b/examples/sql_multiline_statements.py
new file mode 100644
index 0000000..663446e
--- /dev/null
+++ b/examples/sql_multiline_statements.py
@@ -0,0 +1,182 @@
+import sqlalchemy
+
+# bad
+query = """SELECT *
+FROM foo WHERE id = '%s'""" % identifier
+query = """INSERT INTO foo
+VALUES ('a', 'b', '%s')""" % value
+query = """DELETE FROM foo
+WHERE id = '%s'""" % identifier
+query = """UPDATE foo
+SET value = 'b'
+WHERE id = '%s'""" % identifier
+query = """WITH cte AS (SELECT x FROM foo)
+SELECT x FROM cte WHERE x = '%s'""" % identifier
+# bad alternate forms
+query = """SELECT *
+FROM foo
+WHERE id = '""" + identifier + "'"
+query = """SELECT *
+FROM foo
+WHERE id = '{}'""".format(identifier)
+
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}
+"""
+
+# bad
+cur.execute("""SELECT *
+FROM foo
+WHERE id = '%s'""" % identifier)
+cur.execute("""INSERT INTO foo
+VALUES ('a', 'b', '%s')""" % value)
+cur.execute("""DELETE FROM foo
+WHERE id = '%s'""" % identifier)
+cur.execute("""UPDATE foo
+SET value = 'b'
+WHERE id = '%s'""" % identifier)
+# bad alternate forms
+cur.execute("""SELECT *
+FROM foo
+WHERE id = '""" + identifier + "'")
+cur.execute("""SELECT *
+FROM foo
+WHERE id = '{}'""".format(identifier))
+
+# bad with f-string
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}
+"""
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}
+"""
+
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}"""
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}"""
+
+cur.execute(f"""
+SELECT
+    {column_name}
+FROM foo
+WHERE id = 1""")
+
+cur.execute(f"""
+SELECT
+    {a + b}
+FROM foo
+WHERE id = 1""")
+
+cur.execute(f"""
+INSERT INTO
+    {table_name}
+VALUES (1)""")
+cur.execute(f"""
+UPDATE {table_name}
+SET id = 1""")
+
+# implicit concatenation mixed with f-strings
+cur.execute("SELECT "
+            f"{column_name} "
+            "FROM foo "
+            "WHERE id = 1"
+            )
+cur.execute("INSERT INTO "
+            f"{table_name} "
+            "VALUES (1)")
+cur.execute(f"UPDATE {table_name} "
+            "SET id = 1")
+
+# good
+cur.execute("""SELECT *
+FROM foo
+WHERE id = '%s'""", identifier)
+cur.execute("""INSERT INTO foo
+VALUES ('a', 'b', '%s')""", value)
+cur.execute("""DELETE FROM foo
+WHERE id = '%s'""", identifier)
+cur.execute("""UPDATE foo
+SET value = 'b'
+WHERE id = '%s'""", identifier)
+
+
+# bug: https://bugs.launchpad.net/bandit/+bug/1479625
+def a():
+    def b():
+        pass
+
+    return b
+
+
+a()("""SELECT %s
+FROM foo""" % val)
+
+# skip
+query = """SELECT *
+FROM foo WHERE id = '%s'""" % identifier  # nosec
+query = """SELECT *
+FROM foo WHERE id = '%s'""" % identifier  # nosec B608
+query = """
+SELECT *
+FROM foo
+WHERE id = '%s'
+""" % identifier  # nosec B608
+
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}
+"""  # nosec
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}
+"""  # nosec B608
+
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}"""  # nosec
+query = f"""
+SELECT *
+FROM foo
+WHERE id = {identifier}"""  # nosec B608
+
+cur.execute("SELECT * "  # nosec
+            "FROM foo "
+            f"WHERE id = {identifier}")
+cur.execute("SELECT * "  # nosec B608
+            "FROM foo "
+            f"WHERE id = {identifier}")
+
+query = ("SELECT * "  # nosec
+         "FROM foo "
+         f"WHERE id = {identifier}")
+query = ("SELECT * "  # nosec B608
+         "FROM foo "
+         f"WHERE id = {identifier}")
+
+# nosec is not recognized for the 4 below cases in python 3.7
+query = ("SELECT * "
+         "FROM foo "  # nosec
+         f"WHERE id = {identifier}")
+query = ("SELECT * "
+         "FROM foo "  # nosec B608
+         f"WHERE id = {identifier}")
+query = ("SELECT * "
+         "FROM foo "
+         f"WHERE id = {identifier}")  # nosec
+query = ("SELECT * "
+         "FROM foo "
+         f"WHERE id = {identifier}")  # nosec B608
diff --git a/examples/sql_statements.py b/examples/sql_statements.py
index 1fabb70..ff88a92 100644
--- a/examples/sql_statements.py
+++ b/examples/sql_statements.py
@@ -20,6 +20,12 @@ cur.execute("UPDATE foo SET value = 'b' WHERE id = '%s'" % identifier)
 cur.execute("SELECT * FROM foo WHERE id = '" + identifier + "'")
 cur.execute("SELECT * FROM foo WHERE id = '{}'".format(identifier))
 
+# bad f-strings
+cur.execute(f"SELECT {column_name} FROM foo WHERE id = 1")
+cur.execute(f"SELECT {a + b} FROM foo WHERE id = 1")
+cur.execute(f"INSERT INTO {table_name} VALUES (1)")
+cur.execute(f"UPDATE {table_name} SET id = 1")
+
 # good
 cur.execute("SELECT * FROM foo WHERE id = '%s'", identifier)
 cur.execute("INSERT INTO foo VALUES ('a', 'b', '%s')", value)
diff --git a/setup.cfg b/setup.cfg
index 9dafcca..3879a32 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,7 +26,9 @@ classifier =
     Programming Language :: Python :: 3 :: Only
     Topic :: Security
 project_urls =
-    Release notes = https://github.com/PyCQA/bandit/releases
+    Release Notes = https://github.com/PyCQA/bandit/releases
+    Source Code = https://github.com/PyCQA/bandit
+    Issue Tracker = https://github.com/PyCQA/bandit/issues
 
 [extras]
 yaml =


Test patch:
diff --git a/bandit/core/tester.py b/bandit/core/tester.py
index 448b917..042b940 100644
--- a/bandit/core/tester.py
+++ b/bandit/core/tester.py
@@ -76,12 +76,15 @@ class BanditTester:
 
                     # don't skip the test if there was no nosec comment
                     if nosec_tests_to_skip is not None:
-                        # if the set is empty or the test id is in the set of
-                        # tests to skip, log and increment the skip by test
-                        # count
-                        if not nosec_tests_to_skip or (
-                            result.test_id in nosec_tests_to_skip
-                        ):
+                        # If the set is empty then it means that nosec was
+                        # used without test number -> update nosecs counter.
+                        # If the test id is in the set of tests to skip,
+                        # log and increment the skip by test count.
+                        if not nosec_tests_to_skip:
+                            LOG.debug("skipped, nosec without test number")
+                            self.metrics.note_nosec()
+                            continue
+                        elif result.test_id in nosec_tests_to_skip:
                             LOG.debug(
                                 "skipped, nosec for test %s" % result.test_id
                             )
@@ -129,7 +132,7 @@ class BanditTester:
             if test_result
             else None
         )
-        context_tests = self.nosec_lines.get(context["lineno"], None)
+        context_tests = utils.get_nosec(self.nosec_lines, context)
 
         # if both are none there were no comments
         # this is explicitly different from being empty.
diff --git a/tests/functional/test_functional.py b/tests/functional/test_functional.py
index f30ca25..3939906 100644
--- a/tests/functional/test_functional.py
+++ b/tests/functional/test_functional.py
@@ -439,18 +439,61 @@ class FunctionalTests(testtools.TestCase):
             "SEVERITY": {
                 "UNDEFINED": 0,
                 "LOW": 0,
-                "MEDIUM": 14,
+                "MEDIUM": 18,
                 "HIGH": 0,
             },
             "CONFIDENCE": {
                 "UNDEFINED": 0,
                 "LOW": 8,
-                "MEDIUM": 6,
+                "MEDIUM": 10,
                 "HIGH": 0,
             },
         }
         self.check_example("sql_statements.py", expect)
 
+    def test_multiline_sql_statements(self):
+        """
+        Test for SQL injection through string building using
+        multi-line strings.
+        """
+        example_file = "sql_multiline_statements.py"
+        confidence_low_tests = 13
+        severity_medium_tests = 26
+        nosec_tests = 7
+        skipped_tests = 8
+        if sys.version_info[:2] <= (3, 7):
+            # In the case of implicit concatenation in python 3.7,
+            # we know only the first line of multi-line string.
+            # Thus, cases like:
+            # query = ("SELECT * "
+            #          "FROM foo "  # nosec
+            #          f"WHERE id = {identifier}")
+            # are not skipped but reported as errors.
+            confidence_low_tests = 17
+            severity_medium_tests = 30
+            nosec_tests = 5
+            skipped_tests = 6
+        expect = {
+            "SEVERITY": {
+                "UNDEFINED": 0,
+                "LOW": 0,
+                "MEDIUM": severity_medium_tests,
+                "HIGH": 0,
+            },
+            "CONFIDENCE": {
+                "UNDEFINED": 0,
+                "LOW": confidence_low_tests,
+                "MEDIUM": 13,
+                "HIGH": 0,
+            },
+        }
+        expect_stats = {
+            "nosec": nosec_tests,
+            "skipped_tests": skipped_tests,
+        }
+        self.check_example(example_file, expect)
+        self.check_metrics(example_file, expect_stats)
+
     def test_ssl_insecure_version(self):
         """Test for insecure SSL protocol versions."""
         expect = {