先说那个让我后背发凉的帖子
刷V2EX看到一个分享创造帖,标题挺直白:密码放在1Password,最后被DS写进了GitHub。楼主的朋友从一个agent迁到另一个,让LM帮忙干活,结果它从1Password里把密码读出来,顺手写进了项目文件。
后来项目开源,谁也没注意,密码就跟着代码一起推上去了。
我看完第一反应是笑,第二反应是赶紧去翻自己的项目。因为楼主后面那句话说的就是我:我一般.env会给agent放一些key,它拿到以后会不会写文件、打日志,我也没法每一步都盯着。
对,这才是真正扎心的地方。不是agent坏,是我们默认它只读不写,可它干活的时候,随手把一段配置贴进README、把报错连同环境变量打进日志、生成一个示例文件里带上真实key,这些都太正常了。
你审代码的时候盯着业务逻辑,谁会一行去看有没有夹带私货。
那个帖子底下有人甩了gitleaks、sops这些工具,也有人质疑楼主是不是在推广自己的项目。工具好不好用先不论,我更在意的是:发布前这道检查,到底该谁来做。
我的结论是,别指望agent自己交代,也别全押在某个供应商身上,自己在推送之前跑一遍才踏实。
我不打算装一堆工具,先写个能跑的脚本
说真的,一开始我想直接上现成的扫描工具。但独立开发者的项目往就几个仓库,装CI、配规则、调误报,投入产出不划算。
我想要的是:随手能跑、结果我能看懂、扫完还能留个账本方便逐条核对。
所以我干脆自己写了个小脚本。逻辑不复杂:遍历项目文件,用几组正则去匹配常见的密钥形态,把命中的文件路径、行号、匹配到的片段记下来。
为了不把脚本环境和我本机搞混,我是直接放到Python在线运行里先验证逻辑的,跑通了再挪回项目根录。
核心的匹配规则大概长这样:
import re
import os
# 几组常见密钥形态,按需增删
PATTERNS = {
"OpenAI": re.compile(r"sk-[A-Za-z0-9]{20,}"),
"通用API_KEY赋值": re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}"),
"AWS": re.compile(r"AKIA[0-9A-Z]{16}"),
"私钥头": re.compile(r"-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----"),
}
# 不用扫的目录和文件
SKIP_DIRS = {".git", "node_modules", "venv", "__pycache__", "dist"}
SCAN_EXT = {".py", ".js", ".ts", ".json", ".yaml", ".yml", ".md", ".env", ".txt", ".log"}
def scan_file(path):
hits = []
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for lineno, line in enumerate(f, 1):
for name, pat in PATTERNS.items():
if pat.search(line):
hits.append((name, path, lineno, line.strip()[:120]))
except Exception:
pass
return hits
def walk(root):
all_hits = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in filenames:
ext = os.path.splitext(fn)[1]
if ext in SCAN_EXT or fn == ".env":
all_hits.extend(scan_file(os.path.join(dirpath, fn)))
return all_hits
if __name__ == "__main__":
for h in walk("."):
print(h)第一次跑完,屏幕哗滚过几十条。我当时心里咯噔一下,以为漏得这么厉害。
结果一条看下去才发现,绝大多数是误报。这就引出下一个问题。
误报才是真正花时间的地方
本以为难点是写正则,结果真正磨人的是处理误报。
我扫出来的东西里,有一大半根本不是密钥。比如示例文档里写的`api_key = "your_api_key_here"`,比如测试用的`token = "1234567890abcdef"`,还有一些哈希值、UID、静态资源的长字符串,全被通用规则抓了进来。
如果你不管这些,扫描结果就变成一堆噪音,看两遍就不想看了,最后等于没扫。
我的处理办法是加一层白名单和熵值判断。占位符类的固定词直接跳过,太规整的、明显是示例的也降权。
给每条命中打个初步的可信度标记,真正需要人眼确认的那几条就浮上来了。
PLACEHOLDER = re.compile(r"(?i)(your|example|placeholder|xx|test|dummy|changeme|here)")
def looks_fake(snippet):
# 命中占位符词,基本可以判定为示例
return bool(PLACEHOLDER.search(snippet))加上这层过滤之后,几十条噪音收敛到个位数。剩下的那几条,才是我真正要一条核对的。
这里我要多说一句:宁可误报多一点,也别为了干净把规则收得太紧。漏一个真key的代价,比多看几条假的大太多了。
把扫描结果记进SQLite,当成一个核对清单
光打印到屏幕不够。项目一大,命中几十条,你看着就串行了,哪条核过哪条没核,全靠脑子记,很容易漏。
所以我给脚本加了个落库的动作,把每条命中写进一张SQLite表,带上状态字段。
import sqlite3
def init_db(db="secret_audit.db"):
conn = sqlite3.connect(db)
conn.execute("""
CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule TEXT, file TEXT, line INTEGER,
snippet TEXT, status TEXT DEFAULT 'pending'
)
""")
return conn
def save(conn, hits):
con.executemany(
"INSERT INTO findings(rule,file,line,snippet) VALUES(?,?,?,?)",
hits
)
conn.commit()这么做的好处很实在:每条记录有个status,我核过一条就把它标成confirmed或者ignored,下次接着核不用从头来。真发现是密钥的,就去对应文件行号处理,处理完回来更新状态。
整个过程像在勾一张checklist,心里有底。
如果不想全用命令行看表,直接用SQLite编辑器打开这个库,表结构和每一行目了然,筛选pending状态、按文件排序都很顺手。我个人习惯扫完先在编辑器里过一遍全局,再回到脚本里批量更新状态,比纯命令行舒服。
扫描、过滤、落库、核对,这一套下来,一个中小项目大概十几分钟能走完。比起真泄露之后一个个换key、盯供应商账单,这点时间不值一提。
为什么这道体检必须你自己做
聊到这我想把观点摆明:发布前的密钥体检,别外包给agent,也别只信一道供应商侧的防护。
第一,agent不会主动告诉你它把key写哪了。它的目标是完成任务,不是替你做安全审计。
那个V2EX帖子里,密码被写进文件、跟着开源推出去,全程没有任何一步提醒。你不查,就是不知道。
第二,泄露的成本是不对称的。写脚本扫一遍是十几分钟的事,key一旦进了公开仓库,轻则挨个换、重置服务,重则被人跑额度、上账单。
前面另一个帖子里有人一天烧掉2.7亿token,虽然是模型涨价的锅,但你想,要是这token是被泄露的key跑掉的,那才叫冤。
第三,这件事没法一劳永逸。每次要对外发布、开源、分享链接之前,项目状态都变了,该重扫就得重扫。
我现在的习惯是把这个脚本当成发布流程的一道固定关卡,扫干净、SQLite里没有pending的真命中,才走下一步托管发布。
顺带说下,我这个扫描脚本本身如果想做成一个能随时点开跑的小工具,而不是每次翻本地文件,可以把它挂成一个简单的Web小页面走Web应用托管,输入项目路径或者贴文件进去就出结果,团队里别的人也能用。当然这属于加分项,核心还是那个脚本和那张核对表。
今天就能做的一个小动作
别等到下次开源前才想起来。现在就打开你手头那个最常用的项目,把上面那几组正则跑一遍,重点看`.env`、日志文件、README和各种示例文件。
哪怕只扫出一条`your_api_key_here`这样的占位符,也说明你的扫描逻辑跑通了。真扫出一条sk开头的活key,那这十几分钟就算赚回来了。
扫完记得把结果留个账本,别扫完就忘。密钥这东西,你不盯着它,agent可不会替你盯。