试试用这个扫描你的python代码!Bandit使用及源码解读

398 阅读16分钟

官方文档:bandit.readthedocs.io/en/latest/i…

如何使用(不难 简单略过)

  1. 下载

    pip install bandit
    
  2. 开始扫描 image.png

  3. 等待扫描结果 image.png

    image.png

  4. 也可以导出为json文件格式,方便查看

    image.png

    image.png

命令行可输入参数

以下是 Bandit CLI 命令行工具的参数及其说明,格式为表格形式:

参数作用描述示例
-h, --help显示帮助信息并退出。bandit -h
-r, --recursive递归查找并处理子目录中的文件。bandit -r /path/to/project
-a {file,vuln}, --aggregate {file,vuln}聚合输出内容,可按漏洞类型 (vuln) 或文件名 (file) 聚合。bandit -r /path/to/project -a vuln
-n CONTEXT_LINES, --number CONTEXT_LINES为每个问题输出的最大代码行数。bandit -r /path/to/project -n 5
-c CONFIG_FILE, --configfile CONFIG_FILE指定用于选择插件和覆盖默认设置的可选配置文件。bandit -c /path/to/config.yaml -r /path/to/project
-p PROFILE, --profile PROFILE指定分析配置文件(默认为执行所有测试)。bandit -p profile_name -r /path/to/project
-t TESTS, --tests TESTS指定要运行的测试 ID,多个 ID 用逗号分隔。bandit -t B101,B102 -r /path/to/project
-s SKIPS, --skip SKIPS指定要跳过的测试 ID,多个 ID 用逗号分隔。bandit -s B104 -r /path/to/project
-l, --level报告指定严重性级别或更高的所有问题:-l(低)、-ll(中)、-lll(高)。bandit -r /path/to/project -ll
--severity-level {all,low,medium,high}报告指定的严重性级别及更高级别的问题。bandit -r /path/to/project --severity-level high
-i, --confidence报告指定置信度级别或更高的所有问题:-i(低)、-ii(中)、-iii(高)。bandit -r /path/to/project -ii
--confidence-level {all,low,medium,high}报告指定的置信度级别及更高级别的问题。bandit -r /path/to/project --confidence-level high
-f {csv,custom,html,json,screen,txt,xml,yaml}, --format {csv,custom,html,json,screen,txt,xml,yaml}指定输出格式。bandit -r /path/to/project -f json
--msg-template MSG_TEMPLATE指定输出消息模板,仅与 --format custom 配合使用。bandit -r /path/to/project --format custom --msg-template
-o [OUTPUT_FILE], --output [OUTPUT_FILE]将报告写入文件,默认输出到控制台。bandit -r /path/to/project -o report.txt
-v, --verbose输出额外信息,如排除的文件。bandit -r /path/to/project -v
-d, --debug启用调试模式,显示调试信息。bandit -r /path/to/project -d
-q, --quiet, --silent仅在发生错误时显示输出。bandit -r /path/to/project -q
--ignore-nosec不跳过带有 # nosec 注释的行。bandit -r /path/to/project --ignore-nosec
-x EXCLUDED_PATHS, --exclude EXCLUDED_PATHS排除特定路径,支持逗号分隔和通配符。bandit -r /path/to/project -x /path/to/exclude1,/path2
-b BASELINE, --baseline BASELINE指定基线报告路径(仅支持 JSON 格式文件),用于与当前扫描结果对比。bandit -r /path/to/project -b baseline.json
--ini INI_PATH指定 .bandit 文件路径,文件中包含命令行参数。bandit -r /path/to/project --ini config.ini
--exit-zero即使找到问题,退出码也返回 0bandit -r /path/to/project --exit-zero
--version显示程序的版本号并退出。bandit --version

Bandit 扫描流程概述

  1. main函数入口

    1. bandit\bandit__main__.py

      from bandit.cli import main
      ​
      # main方法入口
      main.main()
      
    2. 跳转进bandit\bandit\cli\main.py

      def main():
          """Bandit CLI."""  # 主函数,启动 Bandit 命令行工具
      ​
          # 初始化日志设置,优先根据命令行参数确定日志级别
          debug = (
              logging.DEBUG  # 如果传入了 -d 或 --debug,则设置为 DEBUG 级别
              if "-d" in sys.argv or "--debug" in sys.argv
              else logging.INFO  # 否则设置为 INFO 级别
          )
          # 剩余代码逻辑
      
  2. 文件发现与加载:位于bandit\bandit\cli\main.py

    • Bandit 首先会使用文件发现机制来确定哪些文件需要进行扫描。通常,它会扫描指定目录下的 Python 文件(.py)。这一步通过函数 discover_files() 来实现,它会递归查找符合条件的 Python 文件。
    def discover_files(self, targets, recursive=False, excluded_paths=""):
        # 遍历目标文件夹并找到所有 Python 文件,忽略排除的文件
    
  3. 执行 Bandit 文件扫描:

    • 执行bandit测试:

      P:bandit\bandit\cli\main.py

    # 执行 Bandit 测试
    b_mgr.run_tests()
    
    • 准备解析数据:

      P:bandit\bandit\core\manager.py

    def run_tests(self):
        # 代码逻辑...
        
        # 解析标准输入的数据
        self._parse_file("<stdin>", fdata, new_files_list)
    
  4. AST 解析

    • 准备进行 AST 分析:

      P:bandit\bandit\core\manager.py

    def _parse_file(self, fname, fdata, new_files_list):
        # 业务逻辑...
        
        # 执行 AST 访问者,进行静态分析
        score = self._execute_ast_visitor(fname, fdata, data, nosec_lines)
    
    • 处理文件数据,开始解析并执行 AST 访问,获取风险分数:

      P:bandit\bandit\core\manager.py

    def _execute_ast_visitor(self, fname, fdata, data, nosec_lines):
        # 业务逻辑...
    ​
        # 处理文件数据,开始解析并执行 AST 访问
        score = res.process(data)
    
    • Bandit 使用 Python 内置的 ast 模块来解析代码文件并生成其 AST(抽象语法树) 。这一步主要在 BanditNodeVisitor 中完成。AST 是用于语法分析和代码遍历的核心数据结构。

      P:bandit\bandit\core\manager.py

    def process(self, data):
        # 业务逻辑...
        
        # 将给定的代码数据解析为 AST
        f_ast = ast.parse(data)
    

    上面这行代码直接将读取到的 Python 源文件内容传入 ast.parse() 函数,生成对应的 AST 树。

  5. AST 遍历与插件执行

    • 生成了 AST 之后,Bandit 会遍历 AST 树,查找潜在的安全问题。这一步通过 generic_visit()visit() 方法来实现。这些方法会遍历 AST 节点,并在发现指定类型的节点时调用相应的测试函数。
    def generic_visit(self, node):
        for _, value in ast.iter_fields(node):
            if isinstance(value, ast.AST):
                self.visit(value)
    
  6. 插件的加载和执行

    • Bandit 使用插件机制来定义具体的安全规则,这些规则通过特定的 AST 节点类型来匹配代码中的安全问题。插件的注册和执行通过装饰器 @test.checks@test.test_id 来实现。
    @test.test_id("B324")
    @test.checks("Call")
    def hashlib(context):
        # 匹配 hashlib 模块中的弱哈希函数,如 MD5 和 SHA1
    

    当遍历到 Call 类型的 AST 节点时,Bandit 会调用上面的 hashlib() 函数来检查是否使用了不安全的哈希函数。

  7. 扫描结果的生成与报告

    • 当某个测试函数检测到潜在的安全问题时,它会生成一个 Issue 对象,并将其添加到结果列表中。Bandit 最终会汇总这些问题,并生成扫描报告。

代码传入并进行匹配的细节(篇幅较长)

  1. 传入代码的时刻

    • run_tests() 方法中,Bandit 会从发现的文件列表中读取每个文件的内容,并调用 _parse_file() 方法对文件进行解析。在 _parse_file() 中,文件数据会被传递给 ast.parse(),从而生成 AST。

      P:bandit\bandit\core\manager.py

    def _parse_file(self, fname, fdata, new_files_list):
        # 读取文件内容并解析为 AST
        f_ast = ast.parse(data)
    
  2. AST 节点与插件匹配

    • AST 树生成后,Bandit 会通过 BanditNodeVisitor 遍历 AST 节点,并调用注册的插件来执行具体的安全检查。关键是 generic_visit()visit() 函数,它们会根据节点类型决定调用哪个测试插件。

      P:bandit\bandit\core\node_visitor.py

    def visit(self, node):
        name = node.__class__.__name__  # 获取当前 AST 节点的类型(如 "Call"、"Import" 等)
        method = "visit_" + name  # 动态选择访问方法 ( visit_Call...位于node_visitor.py)
        visitor = getattr(self, method, None)  # 动态获取方法, 在本类获取visit_Call,visit_Bytes等方法
    ​
        if visitor is not None:
            if self.debug:
                LOG.debug("%s called (%s)", method, ast.dump(node))
            visitor(node)  # 调用特定的访问方法
        else:
            # 没有特定访问方法时,直接运行测试。
            self.update_scores(self.tester.run_tests(self.context, name))
    

    例如,当 AST 中有一个 Call 类型的节点(表示函数调用)时,Bandit 会调用 hashlib() 插件,检查这个函数调用是否涉及不安全的哈希算法。

  3. visitor 匹配逻辑(举其一visit_Call 为例):

    • visit_Call 方法,用于处理 AST 中的函数调用节点 (Call 类型)。它提取函数的全限定名 (qualname) 和简短名称 (name),将这些信息记录在上下文中,然后执行与函数调用相关的测试,更新评分。

      P:bandit\bandit\core\node_visitor.py

        def visit_Call(self, node):
            # 处理函数调用节点,并记录其上下文信息。
            self.context["call"] = node
            qualname = b_utils.get_call_name(node, self.import_aliases)
            name = qualname.split(".")[-1]
    ​
            self.context["qualname"] = qualname
            self.context["name"] = name
    ​
            # 运行与 Call 相关的测试
            self.update_scores(self.tester.run_tests(self.context, "Call"))
    ​
    
  4. tester.run_tests()逻辑

    1. tester解释

      • Bandit 中的测试单位,由BanditNodeVisitor创建

        P:bandit\bandit\core\node_visitor.py

        class BanditNodeVisitor:
            def __init__(
                    self, fname, fdata, metaast, testset, debug, nosec_lines, metrics
            ):
                # 业务逻辑...
                
                # 创建 BanditTester 实例,用于执行测试
                self.tester = b_tester.BanditTester(
                    self.testset, self.debug, nosec_lines, metrics
                )
        
    2. testset测试集(上方 self.testset, self.debug, nosec_lines, metrics 位置),由BanditTestSet进行创建

      P:bandit\bandit\core\test_set.py

      class BanditTestSet:
          def __init__(self, config, profile=None):
              # 初始化 BanditTestSet 类的实例
              if not profile:
                  profile = {}  # 如果未提供 profile,则初始化为空字典
              extman = extension_loader.MANAGER  # 获取扩展管理器
              filtering = self._get_filter(config, profile)  # 获取过滤后的测试 ID
              # 根据过滤后的测试 ID,筛选出插件
              self.plugins = [
                  p for p in extman.plugins if p.plugin._test_id in filtering
              ]
              # 加载内置插件并添加到插件列表
              self.plugins.extend(self._load_builtins(filtering, profile))
              # 加载测试配置
              self._load_tests(config, self.plugins)
      
      • MANAGER(extman = extension_loader.MANAGER 位置)

        用于管理 Bandit 的格式化器、插件和黑名单测试。构造函数 __init__ 初始化时调用 load_formattersload_pluginsload_blacklists 方法,读取 Bandit 目录下的相关配置文件进行加载

        P:bandit\bandit\core\extension_loader.py

        class Manager:
          # 这些是 Bandit 内置测试的 ID
          builtin = ["B001"]  # 内置黑名单测试
        ​
          def __init__(
                  self,
                # formatters:存储输出格式相关文件(如json, txt...)
                  formatters_namespace="bandit.formatters", 
                # plugins:存储模板文件,动态分析,更为复杂的扫描,上下文扫描(如检测硬编码, 注入问题...)
                  plugins_namespace="bandit.plugins",       
                # blacklists:黑名单扩展,只检测是否有特殊模块,不做复杂分析,直接上报(如是否导入“pickle”, “subprocess”包...)
                  blacklists_namespace="bandit.blacklists", 
          ):
              # 加载格式化器、插件和黑名单的扩展
              self.load_formatters(formatters_namespace)
              self.load_plugins(plugins_namespace)
              self.load_blacklists(blacklists_namespace)
        ​
        
      • _load_tests(self._load_tests(config, self.plugins)位置)

        用于注册 tests 空字典集,在tests.get(checktype)中可获取tests中对应测试方法

        def _load_tests(self, config, plugins):
            """Builds a dict mapping tests to node types."""
            self.tests = {}  # 初始化一个空字典,用于存储测试与节点类型的映射
        ​
            for plugin in plugins:  # 遍历传入的插件列表
                if hasattr(plugin.plugin, "_takes_config"):
                    # 检查插件是否有 _takes_config 属性,用于确定是否需要配置
                    cfg = config.get_option(plugin.plugin._takes_config)  # 从配置中获取相应的配置选项
        ​
                    if cfg is None:  # 如果配置未找到
                        genner = importlib.import_module(plugin.plugin.__module__)  # 动态导入插件模块
                        cfg = genner.gen_config(plugin.plugin._takes_config)  # 生成默认配置
        ​
                    plugin.plugin._config = cfg  # 将配置赋值给插件
        ​
                for check in plugin.plugin._checks:  # 遍历插件的所有检查项 (_checks:"Files", "Call"...)
                    self.tests.setdefault(check, []).append(plugin.plugin)  # 将插件添加到对应的检查项列表中
                    LOG.debug(
                        "added function %s (%s) targeting %s",
                        plugin.name,
                        plugin.plugin._test_id,
                        check,
                    )  # 记录调试信息,显示添加的插件信息
        
    3. run_tests(self.context, "Call")

      主要执行分析逻辑的函数,下面只显示重要代码

      def run_tests(self, raw_context, checktype):
          # 获取指定类型的测试
          tests = self.testset.get_tests(checktype)   #checktype为"Call", "Str"等,tests则为从testset中获取一系列归属checktype的测试集
          for test in tests:
              name = test.__name__  # 获取测试的名称
              # 复制原始上下文以供测试使用
              temp_context = copy.copy(raw_context)
              # 将原文件内容temp_context转化为新的上下文对象Context,Context内部封装了许多函数(如获取函数名,获取与上下文关联的原始 AST 节点...)
              context = b_context.Context(temp_context)
      ​
              try:
                  # 如果测试具有配置,则使用该配置进行测试
                  if hasattr(test, "_config"):
                      # 将context传进每一个test中处理分析(test:hardcoded_password_string(context)......,plugins或blacklists目录下)
                      result = test(context, test._config)    
                  else:
                      result = test(context)  # 执行测试
      ​
                  if result is not None:  # 如果结果有效
                      nosec_tests_to_skip = self._get_nosecs_from_contexts(
                          temp_context, test_result=result
                      )   # 检查代码里是否有# nosec 注释,若有,则跳过
                      # 检查是否跳过测试
                      if nosec_tests_to_skip is not None:
                          if not nosec_tests_to_skip:
                              self.metrics.note_nosec()  # 记录 nosec 计数
                              continue
                          if result.test_id in nosec_tests_to_skip:
                              self.metrics.note_skipped_test()  # 记录跳过的测试
                              continue
                      self.results.append(result)  # 将结果添加到结果列表
                  else:
                      # 处理没有结果的情况
                      nosec_tests_to_skip = self._get_nosecs_from_contexts(temp_context)
                      if nosec_tests_to_skip and test._test_id in nosec_tests_to_skip:
                          LOG.warning(
                              f"nosec encountered ({test._test_id}), but no "
                              f"failed test on line {temp_context['lineno']}"
                          )
          return scores  # 返回分数
      

AST树结构

import ast
import io
​
# 假设 'bandit' 是你的文件名
file_path = 'C:/Users/admin/Desktop/bandit/bandit/blacklists/utils.py'  # 替换为实际路径# 打开文件并读取内容
with open(file_path, "rb") as f:
    fdata = io.BytesIO(f.read())  # 使用 BytesIO 处理文件数据# 读取数据并解析为 AST
data = fdata.read()
fdata.seek(0)  # 重置文件指针
parsed_ast = ast.parse(data)  # 解析 AST
print(parsed_ast)
# 打印 AST 的结构
print(ast.dump(parsed_ast, annotate_fields=True, include_attributes=True))

输入以上代码后,得到如下结果,可知ast树为python对象,bandit通过ast对象扫描结果来进行分析

  • parsed_ast image.png

  • ast.dump(parsed_ast) image.png

    具体结构分析

    Module(  # AST 树的根节点,表示一个 Python 模块
        body=[  # 模块的主体部分,包含模块内的所有语句
            Expr(  # 表达式节点,表示一行独立的表达式
                value=Constant(  # 值节点,表示常量值
                    value='Utils module.',  # 常量的值
                    lineno=5,  # 该常量所在的代码行号(第5行)
                    col_offset=0,  # 该常量在行中的起始列(第0列)
                    end_lineno=5,  # 该常量结束的行号(与起始行号相同,都是第5行)
                    end_col_offset=20),  # 该常量在行中的结束列(第20列)
                lineno=5,  # 表达式节点的行号
                col_offset=0,  # 表达式节点的列偏移量
                end_lineno=5,  # 表达式结束的行号
                end_col_offset=20),  # 表达式结束的列号
            FunctionDef(  # 函数定义节点,表示一个函数的定义
                name='build_conf_dict',  # 函数的名字
                args=arguments(  # 函数的参数列表
                    posonlyargs=[],  # 仅限位置的参数列表(在该例中为空)
                    args=[  # 函数的普通参数
                        arg(  # 参数节点,表示一个参数
                            arg='name',  # 参数名
                            lineno=8,  # 参数定义的行号
                            col_offset=20,  # 参数在行中的起始列
                            end_lineno=8,  # 参数结束的行号
                            end_col_offset=24),  # 参数结束的列号
                        
                        # .................
                        
                        arg(  # 参数6
                            arg='level',  # 参数名
                            lineno=8,  # 参数定义的行号
                            col_offset=56,  # 参数在行中的起始列
                            end_lineno=8,  # 参数结束的行号
                            end_col_offset=61)],  # 参数结束的列号
                    kwonlyargs=[],  # 关键字参数列表
                    kw_defaults=[],  # 关键字参数的默认值
                    defaults=[  # 参数的默认值列表
                        Constant(  # 常量值,表示参数 'level' 的默认值
                            value='MEDIUM',  # 常量的值
                            lineno=8,  # 默认值的行号
                            col_offset=62,  # 默认值在行中的起始列
                            end_lineno=8,  # 默认值的结束行号
                            end_col_offset=70)]),  # 默认值的结束列号
                body=[  # 函数的主体部分,包含函数内部的所有语句
                    Expr(  # 表达式节点
                        value=Constant(  # 常量值
                            value='Build and return a blacklist configuration dict.',  # 常量值
                            lineno=9,  # 常量值所在行号
                            col_offset=4,  # 常量值在行中的起始列
                            end_lineno=9,  # 常量值的结束行号
                            end_col_offset=58),  # 常量值的结束列号
                        lineno=9,  # 表达式的起始行号
                        col_offset=4,  # 表达式的起始列
                        end_lineno=9,  # 表达式的结束行号
                        end_col_offset=58),  # 表达式的结束列号
                    Expr(  # 表示一个表达式语句节点
                        value=Call(  # 表示一个函数调用节点
                            func=Name(  # 表示函数名(这里是 'print')
                                id='print',  # 函数名是 'print'
                                ctx=Load(),  # 'Load' 表示变量在加载上下文中被使用(这里是函数调用)
                                lineno=10,  # 函数名所在的行号
                                col_offset=4,  # 函数名的起始列号
                                end_lineno=10,  # 函数名所在的结束行号
                                end_col_offset=9),  # 函数名的结束列号
                            args=[  # 表示函数调用的参数列表
                                Constant(  # 表示传递给函数的常量参数
                                    value='Hello',  # 该参数的值为 'Hello'
                                    lineno=10,  # 参数所在的行号
                                    col_offset=10,  # 参数在行中的起始列
                                    end_lineno=10,  # 参数的结束行号
                                    end_col_offset=15)],  # 参数的结束列号
                            keywords=[],  # 表示没有传递关键字参数
                            lineno=10,  # 函数调用的行号
                            col_offset=4,  # 函数调用的起始列
                            end_lineno=10,  # 函数调用的结束行号
                            end_col_offset=16),  # 函数调用的结束列号
                        lineno=10,  # 表达式所在的行号
                        col_offset=4,  # 表达式的起始列
                        end_lineno=10,  # 表达式的结束行号
                        end_col_offset=16),  # 表达式的结束列号
                    Return(  # 返回值节点,表示函数的返回值
                        value=Dict(  # 字典节点,表示返回的字典
                            keys=[  # 字典中的键列表
                                Constant(  # 键值,字典中的每个键
                                    value='name',  # 键的值
                                    lineno=11,  # 键的行号
                                    col_offset=8,  # 键的起始列
                                    end_lineno=11,  # 键的结束行号
                                    end_col_offset=14),  # 键的结束列号
                                
                                # ..............
                                
                                Constant(  # 键值6
                                    value='level',  # 键的值
                                    lineno=16,  # 键的行号
                                    col_offset=8,  # 键的起始列
                                    end_lineno=16,  # 键的结束行号
                                    end_col_offset=15)],  # 键的结束列号
                            values=[  # 字典中的值列表
                                Name(  # 值1,变量名
                                    id='name',  # 变量的名称
                                    ctx=Load(),  # 变量的上下文(读取)
                                    lineno=11,  # 变量所在的行号
                                    col_offset=16,  # 变量的起始列
                                    end_lineno=11,  # 变量的结束行号
                                    end_col_offset=20),  # 变量的结束列号
                                
                                # ............
                                
                                Name(  # 值6,变量名
                                    id='level',  # 变量名
                                    ctx=Load(),  # 变量上下文
                                    lineno=16,  # 变量的行号
                                    col_offset=17,  # 变量的起始列
                                    end_lineno=16,  # 变量的结束行号
                                    end_col_offset=22)],  # 变量的结束列号
                            lineno=10,  # 返回的字典所在的行号
                            col_offset=11,  # 字典的起始列
                            end_lineno=17,  # 字典的结束行号
                            end_col_offset=5),  # 字典的结束列号
                        lineno=10,  # 返回节点的行号
                        col_offset=4,  # 返回节点的起始列
                        end_lineno=17,  # 返回节点的结束行号
                        end_col_offset=5)],  # 返回节点的结束列号
                decorator_list=[],  # 无装饰器
                lineno=8,  # 函数定义的行号
                col_offset=0,  # 函数定义的起始列
                end_lineno=17,  # 函数定义的结束行号
                end_col_offset=5)],  # 函数定义的结束列号
        type_ignores=[]  # 忽略类型
    )
    ​
    

自定义匹配模板文件

模板文件位置

Bandit自带的模板文件都在bandit\bandit\plugins目录下,里面配有各种漏洞的匹配方式(多数为污点分析)

image.png

自动扫描逻辑

  • Bandit 自动扫描的核心机制依赖于 test 类,该类位于 bandit/bandit/core/test_properties.py 文件中。通过使用装饰器(类似于面向切面编程 - AOP)
  • Bandit 可以将这些装饰器放置在函数上方,使函数动态识别是否需要参与安全分析。这种方式类似于注解,能够灵活标记和管理需要检测的函数或规则。
@test.checks("Str")
@test.test_id("B105")
def hardcoded_password_string(context):
    """**B105: 检测硬编码密码字符串**
    # 具体代码逻辑

0. @test.checks("Str")

  • 作用: 该装饰器用于指定测试规则要检查的 AST 节点类型。Bandit 通过解析 Python 源代码生成 AST,@test.checks 装饰器告诉 Bandit 哪种类型的节点应该被此测试函数处理。
  • 参数: "Str" 表示该测试函数专门检查 Str 类型的 AST 节点(即 Python 源代码中的字符串字面量)。其他常见的 AST 节点类型包括 Call(函数调用)、Assign(赋值语句)等。
  1. @test.test_id("B105")
  • 作用: 该装饰器为测试函数分配一个唯一的标识符(ID),方便标识和引用测试规则。这个 ID 用于在扫描报告中标识问题的来源,也用于启用、禁用特定测试规则。
  • 参数: "B105" 是分配给该测试函数的唯一标识符。通常,Bandit 的测试 ID 以字母 B 开头,后面跟随三位数字。