公司动态
多安全扫描器结果统一与去重:从Bandit、Semgrep到聚合CLI实践
如果你所在的 Python 项目已经陆续接入了 Bandit、Semgrep、Safety、pip-audit 这类安全扫描器很可能已经体会过一种“工具越多反而越焦虑”的状态CI 管道里跑了一长串任务日志里躺着格式各异的四份报告同一个脆弱点被不同工具用不同措辞报了两三遍真伪难辨严重级别也各不相同。安全团队问“现在还有没有高危漏洞”时你很难用一句话回答清楚。这里真正的问题往往不是“扫描器不够多”而是“扫描结果不够统一”。每接入一个新工具表面上是多了一层检测覆盖实际上也多了一份需要人肉合并的报告。重复报告会带来一个隐蔽成本团队成员对安全报告脱敏开始默认“这份报告里有重复项先不管”。当团队养成了忽略报告的习惯新出现的真实漏洞反而更容易被淹没。所以与其继续在 CI 里堆扫描器不如换一个思路把它们合并成一个去重的 CLI。这里的“合并”不是简单地把命令串成一行而是要做三件事——扫描器插件化、结果归一化、按缺陷指纹去重。我按照这个思路整理了一套可实现、可接入 CI、可扩展的多扫描器聚合方案并在下文给出完整示例代码希望对你所在团队的工具链整合有参考价值。读完这篇文章你能得到一个可运行的最小版本一个命令行工具会自动调度 Bandit、Safety、Semgrep 等扫描器把各自输出解析成统一模型然后基于指纹去重最后输出一份汇总 JSON 报告。这个方案不依赖任何付费 SaaS 平台也不要求立刻替换现有工具链所有代码都在本地可控范围内。1. 这篇文章真正要解决的问题1.1 多扫描器带来的不只是多几份报告多个安全扫描器并行运行看起来是“多一层保险”实际合并阶段却要面对一堆问题。首先是输出格式不统一有的工具输出 JSON有的输出纯文本有的甚至默认只输出终端彩色日志需要额外配置开关才能拿到结构化数据。其次是字段命名差异Bandit 里叫rule_idSemgrep 里叫check_idSafety 里可能叫vulnerability_id。如果靠人工去读每一份报告都要重新学习一套字段含义。然后是严重等级不对齐A 工具报 “HIGH”B 工具报 “Critical”C 工具用 1 到 10 打分D 工具用 “ERROR / WARNING / INFO”。想要按风险排序得先做一层等级映射。更重要的是重复检测。同一个硬编码密码Bandit 的 B105 规则能发现Semgrep 的 hardcoded-secret 规则也能发现同一个 CVESafety 和 pip-audit 都会报告。两个工具各自输出看起来是“两个问题”实际上修一个依赖版本就能全部解决。这种重复会严重干扰优先级判断。1.2 真正值得做的是把“结果”变成“同一个模型”解决上述问题核心是设计一个统一的漏洞结果模型。所有扫描器的输出最终都转换成同一种Finding对象字段包括scanner、severity、rule_id、file、line、message、fingerprint。这样下游无论是输出 JSON、SARIF还是接入 CI 判断都只需要针对一种结构写逻辑。去重逻辑再往前走一步fingerprint才是判断“两个结果是否属于同一个问题”的关键。它不是简单地把工具名拼上就算数而是要回答一个更难的问题——如何判断 Bandit 报的 B105 和 Semgrep 报的 hardcoded-secret 其实是同一个硬编码密码。1.3 什么人适合读这篇文章如果你正在维护一个 Python 项目CI 里已经有至少一个安全扫描器但报告越来越乱如果你负责团队的安全基建想让扫描结果在 GitLab CI 或 GitHub Actions 中变得可消费如果你只是对 SAST、SCA、密钥检测工具链感兴趣想弄明白它们之间的区别和重叠这篇文章都适合。反过来如果你只是想快速付费使用一家商业安全平台并不打算维护任何自定义逻辑这篇文章帮助不大。2. 基础概念与核心原理2.1 扫描器到底在扫描什么在整合之前需要先分清这些扫描器的工作层次。它们不是同一类东西只是都被装进了“安全扫描”这个大筐里。类型代表工具扫描对象典型发现静态应用安全测试SASTBandit、SemgrepPython 源码、AST硬编码密码、SQL 注入、eval 使用软件成分分析SCASafety、pip-auditrequirements.txt、锁定依赖已知 CVE、过期依赖、恶意包密钥扫描Gitleaks、TruffleHog、detect-secrets全仓库文本私钥、API Token、云凭证其他代码质量检查Vulture、mypy源码死代码、类型漏洞举一个典型例子Bandit 是基于 AST 的 Python 安全扫描器它能在不运行代码的情况下发现eval()、subprocess拼接命令、硬编码密码等常见问题。Semgrep 则是一个更通用的规则引擎你可以把“禁止assert用于权限校验”“禁止使用md5”等规则写成 YAML 文件让它像 grep 一样扫描代码结构。Safety 和 pip-audit 的目标完全不同它们不读业务代码只看依赖清单。2.2 为什么需要去重同一个问题的不同面孔去重的难点不在于比较字符串是否完全相同而在于如何识别“不同工具对同一个问题的不同表达”。举个例子一个 Git 仓库里有一段代码password admin123Bandit 会报告一条 B105 规则Semgrep 的security-audit规则集也可能报告一条 hardcoded-secret。两条结果的file相同line可能相同但rule_id不同、message也不同。如果指纹里带上scanner和rule_id那这两条永远不会被合并。因此设计指纹时要考虑“问题本质”。对于 CVE最好统一用 CVE 编号做指纹无论来自 Safety 还是 pip-audit只要是同一个 CVE就应当合并。对于硬编码密钥则要提取“文件 行号 规范化消息”或“文件 行号 密钥类型”。这里的规范化是指把消息里的多余空白、单双引号差异去掉避免因为措辞不同而漏合并。2.3 去重指纹的核心设计原则综合来看一个好的指纹设计遵循三个原则不包含工具名或者不包含最终来源只有不依赖来源的指纹才能跨工具去重。指纹必须能容忍行号偏移。同一个问题Semgrep 可能定位到函数定义行Bandit 可能定位到赋值行。必要时可配置--ignore-line-number在指纹计算时忽略行号。CVE 类问题优先使用 CVE 编号它天然具备全局唯一性。这三点会直接指导后面dedup.py的实现。3. 总体架构设计与数据流3.1 一次聚合扫描的数据流整体数据流可以描述为CLI 解析参数决定启用哪些扫描器每个扫描器作为独立子进程运行产物是 JSON主进程解析 JSON将字段映射成统一Finding模型然后进入去重模块基于指纹合并重复项最后输出汇总报告。为什么不直接在 Python 进程内调用 Bandit、Semgrep 的 API而是用子进程核心原因是隔离和版本管理。安全扫描器自己的依赖很重Semgrep 会带一大堆规则包Safety 也可能和项目的依赖版本冲突。如果都装进同一个 Python 环境很可能出现“为了装扫描器把项目环境搞坏”的情况。用子进程方式每个扫描器可以装在独立环境里甚至通过--scanner-path指定不同二进制路径。3.2 插件化设计为了让扫描器可扩展抽象一个基类# scanmerge/scanners/base.py from abc import ABC, abstractmethod from pathlib import Path from typing import List, Optional from scanmerge.models import Finding class Scanner(ABC): name: str base def __init__(self, exec_path: Optional[str] None): self.exec_path exec_path or self.name abstractmethod def scan(self, target: Path) - List[Finding]: ...每个具体扫描器只需要继承Scanner实现scan()方法。CLI 里维护一个注册表遇到不同参数就实例化不同插件。这种做法带来的直接好处是新增扫描器时不需要改动现有调用逻辑删掉某个扫描器也不会影响其他部分。4. 环境准备与前置条件4.1 基础环境整个项目基于 Python建议使用 Python 3.9 及以上版本具体版本以你的实际运行环境为准。会用到标准库模块subprocess、json、argparse、dataclasses还有第三方模块用于命令行体验增强。操作系统方面Linux 和 macOS 可以直接跑。Windows 上需要注意扫描器子进程调用时不要依赖shellTrue参数列表方式能避开大部分路径和引号问题。4.2 安装扫描器与依赖先创建虚拟环境避免污染系统 Pythonpython -m venv .venv source .venv/bin/activate然后安装基础依赖pip install typer rich安装本文示例中用到的三个扫描器pip install bandit safety semgrep这里要特别提醒Safety 的 CLI 参数在不同大版本之间变化较大比如safety check --json在 2.x、3.x 中是可行方案但 4.x 对参数做了调整。如果你使用的版本和示例不一致请以对应版本safety check --help的输出为准。这不是示例代码有问题而是工具本身的演进比较快。4.3 项目结构后面所有代码会按下面的目录结构组织scanmerge/ ├── pyproject.toml └── scanmerge/ ├── __init__.py ├── cli.py ├── models.py ├── dedup.py └── scanners/ ├── __init__.py ├── base.py ├── bandit_scanner.py ├── safety_scanner.py └── semgrep_scanner.py5. 核心流程拆解5.1 定义统一结果模型第一步是定义结果模型。所有扫描器输出最终都会转换成Finding对象。# scanmerge/models.py from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict class Severity(str, Enum): CRITICAL critical HIGH high MEDIUM medium LOW low INFO info dataclass class Finding: scanner: str severity: Severity rule_id: str file: str line: int message: str fingerprint: str metadata: Dict[str, Any] field(default_factorydict) def to_dict(self) - Dict[str, Any]: return { scanner: self.scanner, severity: self.severity.value, rule_id: self.rule_id, file: self.file, line: self.line, message: self.message, fingerprint: self.fingerprint, metadata: self.metadata, }这里统一了severity无论上游工具用HIGH还是ERROR都会映射成Severity枚举。这样下游做排序、聚合、过滤都比较容易。metadata字段是一个兜底容器用来保存原始工具里那些当前模型还没细分的额外信息比如 Semgrep 的end_line、Safety 的package_name。它不会影响主流程但可以在排查问题时提供线索。5.2 实现 Bandit 扫描器Bandit 是 Python 生态里最常见的 AST 安全扫描器。它的 JSON 输出结构相对稳定字段包括rule_id、issue_severity、issue_text、filename、line_number。# scanmerge/scanners/bandit_scanner.py import json import subprocess from pathlib import Path from typing import List from scanmerge.models import Finding, Severity from scanmerge.scanners.base import Scanner def _map_severity(value: str) - Severity: return { critical: Severity.CRITICAL, high: Severity.HIGH, medium: Severity.MEDIUM, low: Severity.LOW, }.get(value, Severity.INFO) class BanditScanner(Scanner): name bandit def scan(self, target: Path) - List[Finding]: cmd [self.exec_path, -r, str(target), -f, json, -q] proc subprocess.run( cmd, capture_outputTrue, textTrue, encodingutf-8, errorsreplace, ) # Bandit 返回码 0 表示没有发现问题1 表示发现问题2 表示运行错误。 if proc.returncode not in (0, 1): raise RuntimeError(proc.stderr) data json.loads(proc.stdout or {}) findings: List[Finding] [] for item in data.get(results, []): sev _map_severity(item.get(issue_severity, ).lower()) rule_id item.get(rule_id, ) filename item.get(filename, ) line int(item.get(line_number, 0) or 0) message item.get(issue_text, ).strip() findings.append( Finding( scannerself.name, severitysev, rule_idrule_id, filefilename, lineline, messagemessage, fingerprintf{filename}:{line}:{rule_id}, metadata{code: item.get(code)}, ) ) return findings这里的指纹是filename:line:rule_id注意没有带上scanner。这为后续跨工具去重留下了空间。如果 Semgrep 也检测到同一个文件同一行同一个问题指纹会自然重合。5.3 实现 Safety 扫描器Safety 负责检查依赖清单中的已知漏洞。下面示例基于 Safety 2.x/3.x 的check --json命令如果你的版本不同务必先确认参数。# scanmerge/scanners/safety_scanner.py import json import subprocess from pathlib import Path from typing import List from scanmerge.models import Finding, Severity from scanmerge.scanners.base import Scanner class SafetyScanner(Scanner): name safety def scan(self, target: Path) - List[Finding]: req_file target / requirements.txt if not req_file.exists(): return [] cmd [self.exec_path, check, --json, --file, str(req_file)] proc subprocess.run( cmd, capture_outputTrue, textTrue, encodingutf-8, errorsreplace, ) if proc.returncode not in (0, 1): raise RuntimeError(proc.stderr) data json.loads(proc.stdout or {}) findings: List[Finding] [] if not isinstance(data, dict): return findings for vuln in data.get(vulnerabilities, []): cve vuln.get(CVE, vuln.get(id, )) if not cve: continue findings.append( Finding( scannerself.name, severitySeverity.HIGH, rule_idcve, filerequirements.txt, line0, messagevuln.get(advisory, ).strip(), fingerprintfcve:{cve}, metadata{ package: vuln.get(package_name), installed: vuln.get(vulnerable_spec), }, ) ) return findings这里指纹使用了cve:{cve}不带工具名。当 pip-audit 也报出同一个 CVE 时两者会被归为同一个缺陷。5.4 实现 Semgrep 扫描器Semgrep 的输出结构稍有不同结果存放在results数组中文件路径保存在path行号在start.line严重级别的名字是ERROR、WARNING、INFO。# scanmerge/scanners/semgrep_scanner.py import json import subprocess from pathlib import Path from typing import List from scanmerge.models import Finding, Severity from scanmerge.scanners.base import Scanner _SEVERITY_MAP { ERROR: Severity.HIGH, WARNING: Severity.MEDIUM, INFO: Severity.LOW, } class SemgrepScanner(Scanner): name semgrep def scan(self, target: Path) - List[Finding]: cmd [ self.exec_path, --configauto, --json, --quiet, str(target), ] proc subprocess.run( cmd, capture_outputTrue, textTrue, encodingutf-8, errorsreplace, ) # Semgrep 在“发现匹配规则”时可能返回 1需要接受。 if proc.returncode not in (0, 1): raise RuntimeError(proc.stderr) data json.loads(proc.stdout or {}) findings: List[Finding] [] for item in data.get(results, []): check_id item.get(check_id, ) path item.get(path, ) start item.get(start, {}) or {} line int(start.get(line, 0) or 0) extra item.get(extra, {}) or {} message (extra.get(message) or ).strip() severity _SEVERITY_MAP.get(extra.get(severity, ), Severity.INFO) findings.append( Finding( scannerself.name, severityseverity, rule_idcheck_id, filepath, lineline, messagemessage, fingerprintf{path}:{line}:{check_id}, metadata{end_line: ((item.get(end) or {}).get(line))}, ) ) return findings注意Semgrep 的--configauto会自动选择规则集但首次运行可能需要联网下载规则。如果项目是纯离线环境建议改用项目内已有的 Semgrep 配置文件例如--config .semgrep.yml避免网络依赖。5.5 实现去重逻辑去重是整个 CLI 的核心。它的输入是所有扫描器返回的原始Finding列表输出是去重后的列表。# scanmerge/dedup.py from collections import Counter from typing import Dict, List from scanmerge.models import Finding, Severity _SEVERITY_RANK { Severity.CRITICAL: 4, Severity.HIGH: 3, Severity.MEDIUM: 2, Severity.LOW: 1, Severity.INFO: 0, } def _normalize_message(message: str) - str: return .join(message.strip().split()).lower() def _source_fingerprint(f: Finding, ignore_line_number: bool) - str: line_part if ignore_line_number else str(f.line) return f{f.file}:{line_part}:{f.rule_id}:{_normalize_message(f.message)} def deduplicate(findings: List[Finding], ignore_line_number: bool False) - List[Finding]: grouped: Dict[str, Finding] {} duplicate_counter: Counter[str] Counter() for finding in findings: # CVE 类问题用 CVE 编号作为指纹天然跨工具。 if finding.rule_id.startswith(CVE-) or finding.fingerprint.startswith(cve:): key fcve:{finding.rule_id} else: key _source_fingerprint(finding, ignore_line_number) existing grouped.get(key) if existing is None: grouped[key] finding continue duplicate_counter[key] 1 # 多个扫描器命中了同一问题保留严重级别更高的那条。 if _SEVERITY_RANK[finding.severity] _SEVERITY_RANK[existing.severity]: grouped[key] finding grouped[key].metadata[duplicate_scanners] [existing.scanner, finding.scanner] else: dup_scanners existing.metadata.setdefault(duplicate_scanners, [existing.scanner]) if finding.scanner not in dup_scanners: dup_scanners.append(finding.scanner) return sorted(grouped.values(), keylambda f: (f.file, f.line, f.rule_id))这份代码体现了一个工程判断不同来源对同一问题的严重级别评分不一致时取更高级别并把所有来源记录到metadata.duplicate_scanners。这样下游不仅知道“这里有一个高危问题”还知道“是哪些工具同时发现了它”对评估工具覆盖度非常有价值。5.6 去重时的一个常见认知误区有人会把去重做成“相同消息文本只保留一条”但这样会误伤。两个不同文件里的相同消息很可能是两个独立的漏洞不能合并。两个不同文件里的同一个 CVE对依赖清单来说是同一个修复动作却也可能因为多环境依赖而需要分开处理。所以去重指纹必须根据问题类型差异化处理。去重也不是“约等于去重”就行我们要控制的是“漏合并”和“误合并”两种风险。忽略行号会提高召回率但可能把同一文件里两个相邻的同类问题误判为同一个。更稳妥的做法是默认带上行号先保证不误合并运行一次后如果确实发现行号漂移导致重复再针对特定工具开启忽略行号。6. 完整示例代码与 CLI 入口6.1 CLI 主入口CLI 使用标准库argparse逻辑很容易读懂。它负责解析参数、加载扫描器注册表、调用扫描、去重、输出报告。# scanmerge/cli.py import argparse import json from pathlib import Path from typing import Dict, List, Type from scanmerge.dedup import deduplicate from scanmerge.models import Finding from scanmerge.scanners.bandit_scanner import BanditScanner from scanmerge.scanners.safety_scanner import SafetyScanner from scanmerge.scanners.semgrep_scanner import SemgrepScanner from scanmerge.scanners.base import Scanner SCANNERS: Dict[str, Type[Scanner]] { bandit: BanditScanner, safety: SafetyScanner, semgrep: SemgrepScanner, } def main() - int: parser argparse.ArgumentParser( descriptionRun multiple Python security scanners and dedupe results. ) parser.add_argument(target, typePath, helpProject directory to scan) parser.add_argument( --scanners, defaultbandit,safety,semgrep, helpComma separated scanner names, e.g. bandit,safety,semgrep, ) parser.add_argument( --output, -o, defaultscan-report.json, helpOutput JSON report path, ) parser.add_argument( --ignore-line-number, actionstore_true, helpIgnore line numbers when computing fingerprint, ) args parser.parse_args() selected [s.strip() for s in args.scanners.split(,) if s.strip()] findings: List[Finding] [] for name in selected: scanner_cls SCANNERS.get(name) if scanner_cls is None: print(f[warn] unknown scanner: {name}) continue scanner scanner_cls() print(f[scan] running {name} on {args.target}) try: findings.extend(scanner.scan(args.target)) except Exception as exc: print(f[error] {name} failed: {exc}) deduped deduplicate(findings, ignore_line_numberargs.ignore_line_number) summary { raw_findings: len(findings), deduped_findings: len(deduped), severity: { sev: sum(1 for f in deduped if f.severity.value sev) for sev in [critical, high, medium, low, info] }, } report { scanners: selected, summary: summary, findings: [f.to_dict() for f in deduped], } with open(args.output, w, encodingutf-8) as fp: json.dump(report, fp, ensure_asciiFalse, indent2) print(json.dumps(summary, ensure_asciiFalse, indent2)) return 1 if deduped else 0 if __name__ __main__: raise SystemExit(main())这里要解释返回值1表示发现至少一个去重后的安全问题0表示没有发现问题。这个退出码可以直接被 CI 使用例如 GitLab CI 的任务失败条件、Jenkins 的构建结果判断。6.2 添加 pyproject.toml 脚本入口要让项目可以被scanmerge命令直接调用可以加一个简化的pyproject.toml[project] name scanmerge version 0.1.0 description Aggregate Python security scanners into one deduped CLI requires-python 3.9 dependencies [] [project.scripts] scanmerge scanmerge.cli:main [build-system] requires [setuptools61.0] build-backend setuptools.build_meta [tool.setuptools.packages.find] include [scanmerge*]安装到当前虚拟环境pip install -e .之后就可以直接运行scanmerge ./src --scanners bandit,safety,semgrep -o report.json如果你不想用pip install -e .也可以直接用 Python 模块方式运行python -m scanmerge.cli ./src --scanners bandit,safety,semgrep7. 运行结果与效果验证7.1 运行示例假设项目根目录是一个标准的 Python 项目包含src/目录和requirements.txt。运行scanmerge ./ --scanners bandit,safety,semgrep --output report.json你会看到类似输出[scan] running bandit on . [scan] running safety on . [scan] running semgrep on . { raw_findings: 15, deduped_findings: 8, severity: { critical: 0, high: 2, medium: 1, low: 5, info: 0 } }如果deduped_findings比raw_findings小很多说明工具间确实存在大量重叠结果。去重后数量下降是正常现象关键是去重后的每一条都能对应到一个独立缺陷。7.2 报告结构与判断标准打开report.json顶层包含scanners、