提交代码前,先查一遍密钥:我用华为云码道 CodeArts 做了个查一下
一键开通华为云码道 CodeArts 代码智能体
一个前端项目做完,页面跑通以后,往往还要整理源码:提交到仓库,发给同事参考,或者作为示例公开。这时候,除了清理调试代码,还有一件事需要确认:开发时用过的 API Key、Token,有没有留在项目里。
检查起来未必只是打开一个配置文件。临时验证接口写过的脚本、排查问题保存的日志、从文档里复制后改过的示例,都可能留下凭证。文件少的时候,可以挨个翻;项目稍微大一点,就得借助全局搜索。
但搜索什么也有点麻烦。搜key,会找到不少普通属性和变量;搜token,既有需要检查的值,也有正常的类型定义。换一种服务,凭证的名字和格式又可能不同。搜索能帮忙缩小范围,剩下的仍然要一处处确认。
我想把这样的检查做成一个小工具:将项目文件拖进浏览器,找出疑似硬编码的密钥,标出它位于哪个文件、哪一行,再给出处理建议。待检查的内容本身可能包含敏感信息,因此文件读取和扫描都放在本地,结果里的疑似密钥只显示打码后的内容。
这个工具叫查一下。这次我用华为云码道 CodeArts 来开发,从扫描规则开始,逐步接上文件读取和浏览器界面,也做了一个能直接检查本地目录的命令行入口。
先看做出来的效果。页面支持拖入文件或文件夹,也能直接粘贴一段文本。扫描结束后,结果按文件排列,每一项都能看到风险级别、行列位置和处理建议。
下面用内置示例演示,里面的凭证都是运行时生成的测试值。点击“加载示例项目”,会得到 16 条检测结果,覆盖几种常见的配置和日志场景:
图 1:加载内置示例项目后的扫描结果,共 16 条,按文件分组排列
我想要的就是这种检查方式:先找出值得留意的位置,再回到项目里逐个确认。源码已经放在 AtomGit,文末附了本地运行方法。
一、任务怎么交给码道
技术栈选了 Vue 3、TypeScript 和 Vite,测试用 Vitest。页面需要的交互不算复杂,更需要花心思的是底下的扫描逻辑:能不能识别常见格式,会不会把普通代码报成密钥,结果里又该保留哪些信息。
我先在 AtomGit 网页端通过华为云码道 Agent 建好仓库,随后转到桌面版的“编程”模式开发。下面是建仓时的记录:
图 2:在 AtomGit 网页端通过华为云码道 Agent 创建项目仓库的记录
第一轮,我只把扫描核心交给它。输入很明确,就是文件路径和文本;输出也提前约定好,每条结果需要包含规则、风险级别、位置、打码值和原始长度。完整密钥不能再跟着结果传出去,每条检测规则都要有对应的测试。
还有一个要求是把扫描器单独放进 core,不引用 Vue、浏览器 DOM 或 Node API。浏览器和命令行读取文件的方式不同,但同一段文本交进来,检测逻辑应该是一样的。先划清这一层,后面增加入口时就能直接复用。
这些约束写清楚以后,码道会在项目里创建文件、补类型和测试,运行命令检查结果。我再看这一轮的实现和反馈,决定下一轮接什么。实际顺序是先做 core,再接文件读取、Worker 和 CLI,最后做 Vue 界面。代码主要交给码道实现,提示词整理和独立复核也借助了其他 AI 助手。
这种推进方式用下来比较顺手。每次要看的内容有限,遇到问题也容易说清楚。核心阶段的一次误报,就是这样继续改下去的。
二、80 个测试通过,普通字符串还是被报成了密钥
第一阶段结束时,码道给出的结果是 80 个测试通过。接下来的复核却发现,下面这个普通字符串会被识别成 OpenAI 兼容密钥:
mask-position-x-coordinate问题出在mask-的后半截刚好是sk-,原来的正则没有限制左侧边界,就从单词中间开始匹配。类似的普通英文组合、文件名也可能触发。
对一个要扫描前端项目的工具来说,这个误报很影响使用。目录里本来就有大量样式和变量名,如果这些东西也被标成风险,每次检查都得先排除一堆无关结果。
我把这些反例带回下一轮任务,让码道修正匹配边界,并补上“不应命中”的测试。修正后的 OpenAI 兼容规则,使用的是这条正则:
/(?<![A-Za-z0-9_-])sk-(?!ant-)[A-Za-z0-9_-]{20,200}/g前面的(?<![A-Za-z0-9_-])限制了左侧边界:sk-前面不能紧挨着字母、数字、下划线或短横线。这样就不会从mask-中间截出一个sk-。后面的(?!ant-)排除 Anthropic 前缀,因为它有自己的专用规则;{20,200}则限定这一步匹配的候选主体长度。
正则之后还有一次校验。下面把相关函数和常量放在一起,省略了源码中的说明性注释:
constOPENAI_LONG_PREFIXES:readonlystring[]=['sk-proj-','sk-svcacct-','sk-admin-'];exportconstOPENAI_LONG_KEY_MIN_LENGTH=40;functionhasRandomSegment(value:string):boolean{returnvalue.split(/[-_]/).some((segment)=>segment.length>=16&&/[A-Za-z]/.test(segment)&&/[0-9]/.test(segment),);}exportfunctionisOpenAiCompatibleKey(value:string):boolean{if(OPENAI_LONG_PREFIXES.some((prefix)=>value.startsWith(prefix))){returnvalue.length>=OPENAI_LONG_KEY_MIN_LENGTH;}returnhasRandomSegment(value);}普通sk-候选值按短横线和下划线拆开后,至少要有一段达到 16 位,并且同时包含字母和数字。函数名虽然叫hasRandomSegment,做的其实是这种简单的形态判断,不能证明字符串真的随机。
sk-proj-等前缀单独走另一条分支,是因为主体里也可能有较密的短横线和下划线,切开以后每段都不长。当前实现对这几类候选值改为检查总长度是否达到 40。这些条件用于减少误报,扫描时不会调用服务商接口验证密钥是否有效。
这一轮修复既补误报用例,也继续检查应该命中的样本,核心阶段的测试最终增加到 102 个。
| 开发阶段 | 自动测试数量 |
|---|---|
| 扫描核心首版 | 80 |
| 修正匹配边界、补完回归用例后 | 102 |
| 接入界面与 CLI(当前版本) | 237 |
图 3:核心规则修正、补完回归用例后的执行记录
这段经历让我对怎么给码道反馈有了更具体的认识。直接提供一段输入,说明它现在报了什么、预期应该怎样,后续修改就有了明确目标。反例写进测试以后,下次再改规则,同样的输入还会重新检查一遍。
三、打码以后,也要看看到底露出了多少
在检查核心输出时,还发现了一个很容易忽略的细节。初版对稍长的值保留前后各四位,中间打码。放在很长的 Key 上看着还行,换成 13 位口令,就有 8 位直接显示出来了。
后来把保留字符数改成跟长度一起变化。实现很短:
exportconstELLIPSIS='…';exportfunctionmask(secret:string):string{constn=Math.min(4,Math.floor(secret.length/8));if(n===0)returnELLIPSIS;returnsecret.slice(0,n)+ELLIPSIS+secret.slice(-n);}n是每一端保留的字符数。先用长度除以 8 并向下取整,让两端加起来露出的字符不超过原长度的四分之一,再用Math.min(4, …)把每端上限卡在四位。13 位口令算出来的n是 1,所以abcdefghijklm会显示成a…m;7 位及以下的值只显示省略号。
打码就在扫描核心里完成。scanContent逐行处理文本,有关键词的规则先做预筛,再执行正则。下面是匹配成功后生成结果的节选,省略了外层循环和后续去重代码:
constextracted=extractSecret(match);if(!extracted)continue;const{value,start}=extracted;if(isPlaceholder(value))continue;if(rule.validate&&!rule.validate(value))continue;constfinding:Finding={ruleId:rule.id,ruleName:rule.name,severity:rule.severity,file:path,line:i+1,column:start+1,masked:mask(value),length:value.length,};这里先过滤已知占位值,再调用规则可选的validate,前面的 OpenAI 兼容校验就是从这里接入的。通过检查以后,才生成包含位置和打码值的Finding。
行号来自当前行下标i + 1,列号来自密钥本体在行内的起点start + 1。有些正则会一起匹配变量名和赋值符号,因此项目约定:带捕获组时,把第一个捕获组作为密钥本体,并通过正则的d标志和match.indices取得它的位置;没有捕获组时,就取整个匹配的起点。这样结果标出的就是密钥起点,找回原文件时更方便。
Vue 页面和 CLI 都使用这份结果,打码规则也就保持一致。页面需要的处理建议根据ruleId从规则定义中查询。导入和扫描过程仍需要读取原文,但结果对象不会再携带完整密钥。
我原先更关注“能不能查出来”,这个例子让我把检查范围也放到了输出上。尤其是这类工具,用户很可能会把结果截图拿去讨论,显示出来的内容同样值得仔细看一遍。
四、查出来以后,要方便回去改代码
页面接起来时,我考虑的是一次检查会怎么进行:选中项目目录,看看哪些文件有问题,先处理严重的,再回到源码里修改。
所以输入区放在页面最上面,拖入、选择文件和粘贴都能从这里开始。第一次打开的人可以先加载示例,不必拿自己的项目试。结果则按文件分组,文件名下面列出每一处命中,行列位置和建议放在一起。
顶部的风险统计也做成了筛选入口。示例里有 6 条严重级别的结果,点击对应卡片,页面就只留下这些内容;再点一次,恢复全部结果:
图 4:点击顶部“严重”统计卡片后,结果只保留 6 条严重级别的命中
页面用了暖灰背景和深色文字,普通操作用少量绿色强调,把红、橙、黄留给风险级别。扫描结束后,注意力可以先落在需要处理的内容上。
文件多了以后,还得照顾页面响应。扫描交给 Web Worker 执行,Vue 这边只负责提交文件、接收进度和更新结果。下面是界面发起扫描的节选,省略了状态初始化、异常处理和资源清理:
constmyToken=++scanToken;constworker=createScanWorker();activeWorker=worker;constresult=awaitrunScan(files,{worker,onProgress:(done,total)=>{if(myToken===scanToken)progress.value={done,total};},});if(myToken!==scanToken)return;findings.value=result.findings;stats.value=result.stats;status.value='done';runScan内部通过postMessage把文件传给 Worker,Worker 再逐个调用核心层的scanContent。扫描结束后返回的是打码后的结果,页面不用自己跑一遍规则。
这里还有一个界面侧的计数器scanToken。每次开始扫描先递增,把当时的值存进myToken,更新进度或结果前再比较。只要当前计数变了,旧回调就不再更新界面,避免过期结果覆盖当前状态。完整实现还会在finally里终止这次创建的 Worker,释放资源。
Worker 每扫完一个文件会检查是否需要回报进度:距离上次回报达到 50ms,或较上次回报累计增加至少 5 个百分点,就回报一次;最后一个文件一定回报。文件很多时,可以合并一部分进度更新。
读取目录时会跳过node_modules、.git、dist和build,超过 2MB 的文件以及识别出的二进制文件也会被过滤。
这些被跳过的文件,我在界面里单独留了一个区域,逐项写出原因:
图 5:扫描目录时被跳过的文件单独列出,并逐项写出跳过原因
拖进去的是整个目录,实际检查的可能只是其中一部分。把差别显示出来,用户才能知道扫描范围,也方便决定要不要单独处理某个文件。对我来说,这个说明和命中列表一样有用。
五、在终端里也能查
在项目目录里工作时,有时直接敲一条命令会更方便。这也是前面把 core 单独拆出来的原因。
CLI 负责遍历目录,再把文本交给同一个扫描核心,终端默认输出表格,需要继续处理结果时也可以输出 JSON:
npm run build:clinode dist-cli/index.js./your-project node dist-cli/index.js./your-project--json两边共用检测和打码逻辑,修复一条规则以后,浏览器和 CLI 都能用上。命令行还会在发现严重或高危问题时返回非零退出码,后续要接本地提交检查,可以继续从这里扩展。
做完这个入口,前面那层拆分的好处就很直观了:读取文件的代码各写各的,最需要反复调整的扫描规则只维护一份。
六、实际使用感受
我觉得码道比较省事的地方,是能把一轮需求直接落实到项目文件里。规则、类型、测试和调用代码按阶段补齐以后,再往上接界面,应用就逐渐能跑起来了。发现问题时,也可以继续在现有代码上修改。这次普通字符串被误报,反馈回去以后,留下了修正后的规则和回归用例。
而我需要花时间的地方,越来越集中在具体行为上:普通代码该不该命中,短口令显示多少合适,拖进目录后哪些文件被跳过,结果能不能帮助人回到源码里处理。这些要求在真正看过输入和输出以后,会比第一版提示词细得多。
目前项目有 17 条检测规则,237 个自动测试通过。当前扫描流程在浏览器本地处理文件,没有上传动作。不过这个版本还有需要继续补的地方,清空操作和粘贴输入的边界处理仍需完善,网页报告导出也还没做。
使用时,我会把它当作提交或分享项目之前的一次辅助检查。规则命中后仍要人工确认,未命中也不代表项目一定没有遗漏;如果发现凭证已经泄露,删除代码里的值之后还需要撤销或轮换凭证。
做这个工具的初衷,是让整理项目时少一点挨个翻找。现在至少可以先拿到一份带位置和建议的结果,再逐项处理。后面继续加规则时,mask-position-x-coordinate这个字符串,也会留在测试里一起检查。
回头看,这个项目里花时间最多的是规则边界和输出打码这些小地方,基本都是看过真实输入输出以后才发现要改。你提交代码前都是怎么排查硬编码密钥的?有好用的流程或工具,欢迎评论区聊聊。
想试试当前版本,可以拉下代码,在本地启动后先加载内置示例:
git clone https://atomgit.com/qq_40202349/local-secret-scan.git cd local-secret-scan npm install npm run dev源码与 README · MIT License