Skip to content

正则扫描密钥:能抓真泄漏也误报 ​

译注:本文编译自 dev.to 上的一篇技术文章,原文标题为 "API Secret Scanner: Why Regex Finds Both Real Leaks and False Alarms",作者介绍了其构建的客户端密钥扫描工具的实现思路与边界。原文链接见文末。

密钥扫描器应该在密钥进入 Git 历史之前就发挥作用,而不是等到安全事件发生之后。作者构建的 API Secret Scanner 是一个刻意保持小巧的客户端分析器:粘贴代码或上传文本文件,逐行匹配已知的令牌形态,并显示带掩码的命中结果及其行号。这个流程足够快,可用于提交前的抽查,也足够透明,便于审查。

目标读者是正在检查 .env、配置文件或 pull request 的开发者,他们需要理解基于模式的扫描器能承诺什么、不能承诺什么。核心结论不是"正则证明凭证仍然有效",而是如何在保护所发现值的前提下,设计一个有用的首轮检测器。工具本身只是示例,不能替代吊销凭证、清理历史或完整的密钥管理系统。

模式表让策略可见 ​

实现将识别器与类型和严重级别一起存储。高严重级别的例子包括 AWS 访问密钥 ID、私钥头、JWT 和 Stripe live key:

javascript
const SECRET_PATTERNS = [
  { type: "AWS Access Key ID", severity: "HIGH", re: /\bAKIA[0-9A-Z]{16}\b/ },
  { type: "RSA/EC/OPENSSH Private Key", severity: "HIGH",
    re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
  { type: "JWT Token", severity: "HIGH",
    re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
  { type: "GitHub PAT (ghp_)", severity: "MEDIUM",
    re: /\bghp_[A-Za-z0-9]{36,}\b/ },
  { type: "Hardcoded token=", severity: "LOW",
    re: /(?:access_token|auth_token)\s*[=:]\s*["'][^"']{16,}["']/i },
];

完整源码还包含 GitHub、GitLab、Google、Slack、SendGrid、Twilio、Stripe、npm、PyPI、Shopify、Azure、Firebase、Telegram、Discord 等提供商的特定格式,以及通用的硬编码密码、secret、API key、token 和 basic-auth 模式。把这些作为数据保存,使得策略可以被审计,而不是把每个决策都藏在一个巨大的表达式里。

逐行扫描,只保留掩码预览 ​

scanText 按换行拆分,对当前行测试每个模式,并记录该模式返回的第一个匹配:

javascript
function scanText(text) {
  const lines = text.split("\n");
  const result = [];
  for (let i = 0; i < lines.length; i++) {
    for (const pattern of SECRET_PATTERNS) {
      const match = lines[i].match(pattern.re);
      if (match) {
        const raw = match[1] || match[0];
        result.push({
          type: pattern.type,
          severity: pattern.severity || "LOW",
          line: i + 1,
          masked: maskValue(raw),
        });
      }
    }
  }
  return result;
}

输出只存储行号和分类,而不是整个源文件的副本。掩码函数保留两端各四个字符,中间用有上限的星号替换:

javascript
function maskValue(val) {
  if (val.length <= 8) return "****";
  return val.slice(0, 4) +
    "*".repeat(Math.min(val.length - 8, 20)) +
    val.slice(-4);
}

这足以让人认出发现的是哪个凭证,又不会把完整值放进结果表或下载的报告中。报告包含类型、严重级别、行号和掩码值,以及时间戳和汇总计数。

客户端是边界,不是安全保证 ​

文件上传使用 FileReader.readAsText(file, "utf-8");扫描器从不调用 API,也不把内容发送到服务器。因此隐私徽章是有实现依据的。但浏览器扩展、注入脚本或被攻陷的机器仍可读取页面内存,所以"客户端"不应被等同于安全保险库。如果真实凭证出现在扫描结果中,仍应立即轮换。

严重级别计数由 findings 数组计算得出:

javascript
const counts = computed(() => ({
  HIGH: findings.value.filter((f) => f.severity === "HIGH").length,
  MEDIUM: findings.value.filter((f) => f.severity === "MEDIUM").length,
  LOW: findings.value.filter((f) => f.severity === "LOW").length,
}));

扫描明确标注结果来源。匹配项带有模式的可读类型、归一化后的严重级别,以及 i + 1 行号,因此审查可以跳回源码,而不必在庞大报告中搜索。上传文件后,读取器把文本放入同一个 inputText ref 并清除旧结果;上传新文件不会悄悄混入两个文件的结果。点击 Clear 会同时重置文本、结果和已扫描标志。

这种重置行为是一个小而重要的安全属性。用户不应看到昨天的结果被标记为今天干净文件的扫描结果,空输入也会在模板中禁用扫描按钮。报告仅由当前结果生成,new Date().toISOString() 让时间戳跨时区也无歧义。

正则的边界在哪里 ​

这是静态匹配。形状正确的假测试字符串可能造成误报,而新出现的提供商格式、被拆分的令牌、编码后的值,或运行时才拼装的密钥则可能被漏掉。由于代码对每个模式调用 match 且不带全局标志,每行每个模式最多记录一个匹配;同一行中重复出现的同类型凭证不会被穷尽列出。通用模式也可能把普通配置值标记出来。

这些限制正是"未检测到已知密钥模式"不等于"安全"的原因。把结果当作审查队列,然后检查上下文、查看仓库历史、吊销暴露的凭证,并在项目需要时加入真正的 pre-commit 或 CI 扫描器。作者已将该实现做成一个小型免费工具:API Secret Scanner。

原文链接 ​

https://dev.to/begoodtool/api-secret-scanner-why-regex-finds-both-real-leaks-and-false-alarms-40cn