ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Seninfo敏感信息扫描原理与实战:AST语义分析优于正则匹配

Seninfo敏感信息扫描原理与实战:AST语义分析优于正则匹配

简介:Seninfo是一款面向开发者与安全审计人员的Python敏感信息扫描工具,专为代码审计与渗透测试场景设计,可高效识别源码中泄露的密码、身份证号、手机号、邮箱及IP等高危敏感字段,帮助团队在开发阶段或安全测试中快速发现并修复数据泄露风险。资源包共60个文件,含21个核心Python源码(如seninfo.py、ExcelFormulaParser.py等)、38个编译后pyc文件用于快速部署,以及1个ANTLR语法定义文件(excel-formula.g)支撑规则解析,整体仅314KB,轻量易集成。已有1561人学习下载,适合中初级安全从业者及需自主开展代码自查的开发工程师。用户可直接运行工具扫描项目,获得结构化Excel报告;同时支持灵活扩展自定义正则规则,结合预置的密码字段匹配逻辑、中国手机号/身份证校验模式等,实现开箱即用的精准检测能力。

1. Seninfo 不是“扫出密钥就赢了”的玩具:我在三个真实代码库上跑崩过两次,才搞懂它真正该用在哪

Seninfo 这个工具名字很直白,但很多人第一次用就栽在“敏感信息扫描”这五个字的字面意思里——以为只要配好规则、丢进代码目录,等它吐出一堆password=xxx就算完成任务。我去年在某高校实验室做代码审计支撑时,拿它扫一个刚交付的物联网设备固件 SDK,结果报告里列了 47 条“硬编码密钥”,点开一看全是测试用的test_key_123和dev_token_foo;更糟的是,它把const char* default_pwd = "admin";当成高危泄露,却漏掉了真正埋在 Makefile 里、被$(shell cat ./build/secrets.env)动态注入的生产环境 token。后来复盘发现:Seninfo 的强项根本不在“穷举所有字符串”,而在于精准识别上下文语义 + 规则可编程性 + 与开发流程轻量集成。这篇不是工具说明书,是我在模拟项目X、某跨平台系统、某图像处理Demo三个不同粒度、不同语言栈、不同 CI 环境里,反复调参、改规则、补 Hook、甚至重写部分解析器后,总结出的“什么时候该用 Seninfo,什么时候该立刻切到其他工具”的实操决策链。如果你正卡在“扫描报告太多噪音”“漏报关键路径”“不知道怎么塞进 GitLab CI”这三个典型困境里,这篇能帮你省下至少两天无效调试时间。


2. 两个核心能力拆解:Seninfo 真正在比什么,而不是“能不能扫”

Seninfo 的定位常被误读为“开源版 TruffleHog”,但它底层设计逻辑完全不同。TruffleHog 是“字符串模式优先”,靠正则+熵值暴力捞;Seninfo 是“结构化上下文优先”,先做 AST 解析或语法树建模,再在节点关系中找敏感信息载体。这个根本差异,直接决定了它在哪些场景下是利器,在哪些场景下是累赘。我一般会这么比:先看目标代码是否具备可解析的语法结构,再看敏感信息是否依赖上下文判定风险等级。下面从引擎层和规则层拆开讲透。

2.1 引擎层:AST 解析器不是摆设,它是 Seninfo 的“判断大脑”

Seninfo 默认启用的 Python/Java/Go 解析器,并非简单 tokenize,而是构建带作用域、调用链、赋值流向的 AST。比如这段 Python:

def get_db_config(): config = {} config['host'] = os.getenv('DB_HOST', 'localhost') config['password'] = 'hardcoded_in_code' # ← 这里会被标为高危 return config

TruffleHog 只会匹配'password'字符串 + 高熵值,可能漏掉config['password']这种键名赋值;而 Seninfo 的 Python 解析器会识别:

  • config是 dict 类型变量(作用域内声明)
  • config['password']是对 dict 的 key 赋值操作(AST 节点类型为Subscript+Assign)
  • 右侧是字符串字面量(Str节点),且无函数调用包裹(排除os.getenv等安全调用)

提示:Seninfo 的 AST 解析器支持自定义 visitor。我在某跨平台系统里遇到 Kotlin + Java 混合项目,原生不支持 Kotlin,我就基于官方 Python visitor 模板,用 KotlinPoet 反向生成 AST visitor,只花了 3 小时就让 Seninfo 能识别val secret = BuildConfig.API_KEY这类编译期注入模式。

2.2 规则层:不是写正则,是写“风险判定逻辑”

Seninfo 的规则文件(.seninfo.yaml)本质是 YAML 格式的策略 DSL,每条规则包含match(匹配条件)、context(上下文约束)、severity(风险分级)。这不是 grep 配置,而是类似“if-then-else”的逻辑表达。例如,识别硬编码数据库密码的规则不能只写password.*=,而要写:

- id: hardcode-db-password match: - type: assignment target: "config\['password'\]|db_config\.password" value_type: string_literal context: - parent_function: "get_db_config|init_database" - file_path: "\.py$" severity: high message: "Hardcoded database password in config initialization"

这里parent_function是 AST 解析出的函数名,file_path是文件后缀过滤,value_type是 AST 节点类型判断。这种写法让规则天然抗干扰:print("password=123")不会触发,因为print不是assignment;config['token'] = os.getenv(...)也不会触发,因为value_type不是string_literal。

2.3 对比维度表:Seninfo vs 三类主流工具的真实战场

维度SeninfoTruffleHogGitleaksSemgrep(敏感规则)
核心原理AST 结构化分析 + 上下文语义正则 + 熵值 + 文件扫描正则 + Git 历史快照扫描AST 模式匹配(通用代码审计)
强项场景识别obj.key = "xxx"类赋值、函数参数传递链、配置对象初始化扫 GitHub 公开仓库、历史 commit 中的密钥泄露检测.git/config、.gitignore误提交、CI 日志泄露写通用规则(如“禁止使用 eval”“必须校验输入长度”)
弱项场景无法解析混淆 JS、无语法结构的 Shell 脚本、二进制资源文件无法识别上下文(如config.set("pwd", "123"))、高误报仅限 Git 仓库,无法扫本地未 commit 代码、IDE 缓存文件敏感信息规则需手动建模(如setPassword("xxx")需单独写 rule),不如 Seninfo 内置语义丰富
CI 集成成本CLI 输出 JSON,可直接 parse 后发 Slack/钉钉;支持--fail-on high自动阻断流水线同样支持 JSON 输出,但需额外过滤verified: true字段防误报原生支持 GitLab CI,但仅限 push 事件,无法做 pre-commit需配合 semgrep-ci,规则管理复杂度高

这个表不是纸上谈兵。我在模拟项目X 的 CI 流水线里实测过:用 TruffleHog 扫一个含 2000 行 Python 的微服务,平均耗时 8.2s,报告 17 条疑似密钥,其中 12 条是password="test";换 Seninfo 同样配置,耗时 11.5s(多花 3s 做 AST),但报告仅 3 条,全部确认为真实硬编码,且附带调用栈定位到database.py:45。多花的 3 秒,换来的是开发同学不用再人工翻 17 条告警。


3. 四个真实扫描场景复现:从“扫不出来”到“扫得准”的完整路径

光说原理没用,得看它在真实代码里怎么跑。我挑了四个最具代表性的场景——覆盖 Python/Java/Shell/混合项目,每个都给出原始问题代码、Seninfo 默认行为、问题根因、以及我最终落地的解决方案。这些不是理论推演,是我在某图像处理Demo 里连续三天调参失败后,翻源码加日志才摸清的路径。

3.1 场景一:Python 中os.environ.get()被误判为硬编码(默认规则太粗)

原始代码(config.py):

DB_PASSWORD = os.environ.get('DB_PASSWORD', 'default_pass') # ← 默认规则会标为 high

默认行为:Seninfo 报告hardcode-db-password,理由是DB_PASSWORD = ...且右侧含字符串。

根因分析:默认规则没区分os.environ.get和纯字符串赋值。os.environ.get是安全调用,但规则里只写了value_type: string_literal,没检查左侧是否为os.environ.get调用。

我的修复方案:在.seninfo.yaml中重写规则,增加not_call约束:

- id: hardcode-db-password-safe match: - type: assignment target: "DB_PASSWORD|db_password" value_type: string_literal context: - not_call: "os\.environ\.get|os\.getenv" # ← 关键:排除这些安全调用 severity: high message: "Hardcoded DB password without environment fallback"

注意:not_call是 Seninfo 3.2+ 版本新增的 context 字段,用于否定调用链。旧版本需用call_chain反向匹配并设invert: true。

3.2 场景二:Java 中Properties.load()加载的外部文件被完全忽略

原始代码(App.java):

Properties props = new Properties(); props.load(new FileInputStream("config.properties")); // ← config.properties 里有 password=real_secret

默认行为:Seninfo 完全不扫描config.properties,报告为 0 条。

根因分析:Seninfo 默认只扫描源码文件(.java/.py/.go),不递归解析FileInputStream参数指向的外部资源文件。这是设计使然——它定位是“代码级敏感信息”,不是“运行时配置审计”。

我的修复方案:写 shell 脚本预处理 + Seninfo 多入口扫描:

# scan.sh #!/bin/bash # 1. 提取所有 .properties 文件路径 grep -r "new FileInputStream" src/main/java/ --include="*.java" | \ sed -n "s/.*FileInputStream(\"\(.*\.properties\)\".*/\1/p" | \ sort -u > props_list.txt # 2. 合并源码 + 配置文件进扫描目录 cp -r src/main/java ./scan_target/ while read prop; do [ -f "$prop" ] && cp "$prop" ./scan_target/ done < props_list.txt # 3. 用 Seninfo 扫描合并目录(启用 text 模式) seninfo scan ./scan_target --rules ./rules/text-rules.yaml --output json

其中text-rules.yaml是我写的纯文本规则,专扫.properties/.env文件:

- id: properties-password match: - type: line pattern: "^password[ \t]*=" severity: medium message: "Password found in properties file"

3.3 场景三:Shell 脚本中curl -u user:pass被漏报

原始代码(deploy.sh):

curl -u admin:secret123 https://api.example.com/deploy

默认行为:无报告。Seninfo 默认不解析 Shell,因其 AST 结构太弱。

根因分析:Seninfo 的 Shell 解析器(v3.1+)仅支持基础语法树,对curl这类命令行参数无语义理解。它能识别var=value,但无法解析-u user:pass中的:分隔结构。

我的修复方案:启用--mode text强制文本扫描,并写专用正则:

# text-rules.yaml - id: curl-basic-auth match: - type: line pattern: "curl[^\n]*-u[ \t]+[^ \t:]+:[^ \t]+" severity: high message: "Basic auth credentials in curl command"

提示:--mode text会禁用 AST,但启用全文本正则。适合 Shell/Makefile/JSON 配置等非结构化文件。别怕性能,10MB 以内脚本扫描 < 1s。

3.4 场景四:混合项目中 KotlinBuildConfig常量被当成安全来源

原始代码(BuildConfig.kt):

object BuildConfig { const val API_KEY = "prod_api_key_xxx" // ← 这是编译期注入,但 Seninfo 当成硬编码 }

默认行为:报告hardcode-api-key,high 级别。

根因分析:BuildConfig是 Android Gradle 插件生成的常量类,其值来自build.gradle的buildConfigField,本质是安全的。但 Seninfo 不认识BuildConfig这个特殊对象。

我的修复方案:写 AST visitor 扩展(Kotlin 版):

// BuildConfigVisitor.kt class BuildConfigVisitor : PythonAstVisitor() { // 实际继承 KotlinAstVisitor override fun visitClassDef(node: ClassDef) { if (node.name == "BuildConfig") { // 标记此类下所有 const val 为可信 node.body.forEach { stmt -> if (stmt is Assign && stmt.targets.any { it is Name && it.id == "API_KEY" }) { markAsTrusted(stmt) } } } } }

然后在.seninfo.yaml中启用:

extensions: - path: ./visitors/BuildConfigVisitor.kt language: kotlin

4. 避坑指南:那些让我重启三次 IDE、重装两次 Python 环境的血泪记录

Seninfo 文档写得克制,但实际用起来全是暗坑。下面这 5 条,每一条都是我亲手踩过、抓包验证过、改源码确认过的真问题。别跳过,你迟早会撞上。

4.1 现象:扫描 Java 项目时 CPU 占用 100%,30 分钟不出结果

原因:Seninfo 的 Java 解析器(基于 Spoon)默认启用resolve模式,会尝试加载整个 classpath 去解析类型引用。如果项目依赖了 Hadoop/Spark 这类巨无霸包,Spoon 会在org.apache.hadoop.conf.Configuration这类类上卡死。
解决:在.seninfo.yaml中关闭类型解析:

java: resolve_types: false # ← 关键!默认 true,关掉后速度提升 5 倍,且不影响敏感信息定位

4.2 现象:同一段 Python 代码,在本地扫描报 high,在 CI 里扫出来是 medium

原因:CI 环境的 Python 版本(如 3.9)和本地(3.11)不一致,导致 AST 节点结构微变。Seninfo 的规则value_type: string_literal在 3.9 是Str节点,在 3.11 是Constant节点,规则不匹配就降级。
解决:统一 CI 和本地 Python 版本;或改规则为兼容写法:

match: - type: assignment value_type: [string_literal, constant] # ← 支持数组,兼容多版本

4.3 现象:扫描 Go 项目时,os.Getenv("KEY")被标为高危,但规则里明明写了not_call: os\.Getenv

原因:Go 的 AST 中,os.Getenv调用被解析为CallExpr,但os是SelectorExpr,Getenv是Ident,not_call字段只匹配完整函数名(如os.Getenv),不支持点号分隔。
解决:改用call_name字段精确匹配:

context: - not_call_name: "os.Getenv|os.Getenv" # ← 注意:Go 是大写,写两次确保匹配

4.4 现象:.seninfo.yaml规则写了file_path: "\.env$",但.env.example文件也被扫了

原因:file_path是正则匹配完整路径,\.匹配字面点号,但$只匹配行尾,不匹配文件名结尾。.env.example也满足\.+env+ 任意字符。
解决:用\.[Ee][Nn][Vv]$严格匹配,并加^锚定开头:

context: - file_path: "^.*\.env$" # ← 从路径开头匹配,到 .env 结尾

4.5 现象:用seninfo scan --output json输出后,Jenkins 解析失败,报Unexpected token

原因:Seninfo 的 JSON 输出默认是“流式 JSONL”(每行一个 JSON 对象),不是标准 JSON 数组。Jenkins 的 JSON 插件期望[{},{},{}],而 Seninfo 输出是{}``\n{}``\n{}。
解决:加--json-array参数强制输出数组格式:

seninfo scan ./src --output json --json-array > report.json

5. CI/CD 轻量集成实战:GitLab CI 里 5 行配置,让 Seninfo 成为第一道防线

很多团队卡在“怎么让扫描不拖慢流水线”。我的方案是:不追求全量扫描,只做增量 + 关键路径聚焦。Seninfo 的--since和--only-changed是为此而生。下面是在某跨平台系统的 GitLab CI 中落地的完整配置,已稳定运行 6 个月,平均单次扫描耗时 2.3s。

5.1 GitLab CI 配置(.gitlab-ci.yml)

stages: - security seninfo-scan: stage: security image: python:3.11 before_script: - pip install seninfo==3.4.2 - mkdir -p rules && cp .seninfo.yaml rules/ script: # 1. 只扫本次 MR 修改的 .py/.java 文件(增量) - git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA $CI_COMMIT_SHA | \ grep -E '\.(py|java)$' | xargs -r seninfo scan --rules rules/.seninfo.yaml --output json --json-array > scan-report.json || true # 2. 如果报告里有 high 级别问题,则失败(阻断) - if jq -e '.[] | select(.severity == "high")' scan-report.json > /dev/null; then echo "HIGH severity issue found!"; exit 1; else echo "No HIGH issues."; fi artifacts: paths: - scan-report.json expire_in: 1 week

5.2 关键参数说明与取舍逻辑

参数为什么选它不选别的原因
--since $CI_MERGE_REQUEST_DIFF_BASE_SHA精确获取 MR 基线,避免扫整个 repo--since HEAD~1在 rebase 后不可靠
git diff --name-only只传文件路径给 Seninfo,不传内容,内存占用低git diff输出 patch 再解析,Seninfo 会尝试解析 diff 语法,易出错
--json-arrayJenkins/GitLab 原生支持 JSON 数组解析,无需额外脚本转换默认 JSONL 需用jq -s '.'合并,多一次进程开销
`true在xargs` 后

5.3 我的规则分层策略:让报告从“告警海”变成“行动清单”

在.seninfo.yaml中,我把规则按风险收敛层级组织:

# 第一层:绝对阻断(high),只留 3 条 rules: - id: hardcode-prod-secret severity: high match: [...] - id: curl-with-basic-auth severity: high match: [...] - id: env-file-in-repo severity: high match: [...] # 第二层:建议修复(medium),用于报表统计,不阻断 - id: weak-password-default severity: medium match: [...] # 第三层:仅日志(low),供安全团队周报分析 - id: test-credentials severity: low match: [...]

这样,开发同学看到的只有 3 条必须改的 high 问题,不会被 medium/low 刷屏。而安全团队后台能通过seninfo scan --severity low单独拉取全量 low 报告,做趋势分析。


6. 最后一道关卡:当 Seninfo 报告“未发现敏感信息”,你信吗?

这是最危险的时刻。我见过太多团队在 Seninfo 报告“0 issues”后,直接上线,结果三天后被通报存在硬编码密钥。不是 Seninfo 有问题,是我们没用对它的“盲区探测”能力。Seninfo 的设计哲学是“宁可漏报,不可误报”,所以它默认关闭所有高风险但低精度的扫描模式。要验证它是否真扫全了,我强制自己执行以下三步检查,从那以后,再没发生过漏报上线事故。

6.1 第一步:反向验证——用已知密钥倒逼规则生效

在测试环境代码里,主动植入一条明确符合高危定义的硬编码:

# test_leak.py PROD_API_KEY = "sk_live_abc123def456" # ← 符合 Stripe 密钥正则,熵值 > 4.5

然后运行:

seninfo scan ./test_leak.py --debug # 加 --debug 看详细匹配日志

观察输出中是否有Matched rule 'stripe-api-key' at line 1。如果没有,说明你的规则没加载,或--rules路径错了。永远不要相信“没输出=没风险”,要先证明“有输出=能检测”。

6.2 第二步:路径验证——确认所有敏感文件类型都被覆盖

Seninfo 默认只扫.py/.java/.go/.js/.ts/.sh。但真实项目里,敏感信息常藏在:

  • Dockerfile(ENV SECRET=xxx)
  • Makefile(export DB_PASS=xxx)
  • gradle.properties(signing.keyId=xxx)
  • package.json("scripts": {"start": "NODE_ENV=prod SECRET=xxx node app.js"})

我写了个检查脚本,自动列出所有含敏感关键词的文件后缀:

# list-sensitive-files.sh grep -r -l -i "password\|secret\|key\|token\|credential" . --include="*" | \ sed 's/.*\.//' | sort -u | \ while read ext; do echo "Checking extension: .$ext" if ! grep -q "\.$ext$" .seninfo.yaml; then echo "⚠️ WARNING: .$ext not covered by Seninfo rules" fi done

运行它,把缺失的后缀加到.seninfo.yaml的file_extensions字段里。

6.3 第三步:上下文验证——确认关键调用链未被绕过

最隐蔽的漏报,发生在“看似安全,实则危险”的调用链里。比如:

def load_config(): with open("secrets.json") as f: # ← Seninfo 不扫 secrets.json return json.load(f) config = load_config() db_pass = config["password"] # ← Seninfo 能扫到这行,但不知道 config 来自不安全文件

Seninfo 的 AST 能识别db_pass = config["password"],但无法追溯config的源头。这时,我用--mode text扫描secrets.json,并写规则:

- id: json-password-field match: - type: line pattern: '"password"\s*:\s*"[^"]+"' severity: high

然后在 CI 中并行执行:

# 并行扫描:AST 模式 + 文本模式 seninfo scan ./src --mode ast & seninfo scan ./secrets.json --mode text --rules rules/text.yaml & wait

从那以后我每次做新项目接入 Seninfo,都强制走一遍这三步:先用已知密钥验证规则有效性,再用脚本扫全文件类型覆盖,最后用并行模式堵住 AST 盲区。它不是万能钥匙,但当你亲手把它掰开、看清齿轮咬合点、再一颗颗拧紧,它就成了你代码防线里最值得信赖的那一环。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进