HookAudit:构建一个零依赖的供应链安全扫描器
一位开发者在 Dev.to 上发表文章,分享了构建 HookAudit 的工程经验。HookAudit 是一个供应链安全扫描器,用于检测 npm 包中的恶意钩子(hooks)。文章的核心问题是:当你强制一个安全工具只使用标准库原语来检查不可信代码时,会发生什么?这是一篇关于系统复杂性和零依赖的工程复盘。
背景:npm 供应链安全问题
npm 钩子的安全风险
npm(Node Package Manager)允许包在安装过程中执行各种钩子脚本,如 preinstall、postinstall、prepublish 等。这些钩子脚本在安装时自动执行,具有以下安全风险:
- 代码执行:钩子脚本可以在用户的机器上执行任意代码
- 数据窃取:恶意脚本可以窃取环境变量、配置文件、SSH 密钥等敏感信息
- 后门安装:恶意脚本可以在系统中安装后门或恶意软件
- 供应链攻击:攻击者可以通过攻陷流行包的维护者账户,注入恶意钩子
- 难以检测:钩子脚本通常在安装时静默执行,用户可能不知道发生了什么
著名的供应链攻击事件
近年来,npm 生态系统发生了多起著名的供应链攻击事件:
event-stream事件:攻击者通过添加依赖注入恶意代码ua-parser-js事件:维护者账户被攻陷,发布了包含恶意代码的版本node-ipc事件:包作者在代码中加入了针对特定地区的恶意行为coa和rc事件:多个流行包被攻陷,注入了窃取密码的恶意代码
这些事件表明,npm 供应链安全是一个真实而严重的问题。
HookAudit 的设计目标
什么是 HookAudit
HookAudit 是一个用于扫描 npm 包中恶意钩子的安全工具。它的核心功能:
- 解析 npm 包的
package.json,提取钩子脚本定义 - 分析钩子脚本的内容,检测潜在的恶意行为
- 生成安全报告,标记可疑的钩子
- 提供风险评估和建议
为什么选择零依赖
HookAudit 的一个关键设计决策是:只使用 Node.js 标准库,不引入任何第三方依赖。
这个决策的原因:
- 安全性:安全工具本身不应该引入安全风险。如果安全扫描器依赖了有漏洞的包,那它本身就成了供应链攻击的载体
- 可信度:用户需要信任安全工具的输出。如果工具本身有大量依赖,用户需要审计所有依赖才能信任工具
- 最小化攻击面:零依赖意味着最小的攻击面,减少了被供应链攻击的可能性
- 性能:零依赖意味着更小的体积、更快的启动速度、更少的内存占用
- 可维护性:不需要处理依赖更新、兼容性问题、安全漏洞修复等维护负担
- 教育价值:只使用标准库可以深入理解 Node.js 的能力,也是一种很好的学习体验
零依赖的挑战
零依赖虽然有很多好处,但也带来了挑战:
- 功能受限:标准库可能没有某些功能丰富的第三方库
- 开发效率:需要自己实现很多现成的功能
- 复杂性:自己实现功能可能引入 bug
- 标准化:需要自己处理各种边缘情况和兼容性问题
核心实现
1. npm 包解析
HookAudit 需要解析 npm 包的结构和内容。只使用标准库的实现方式:
下载包
使用 Node.js 内置的 https 模块下载 npm 包:
const https = require('https');
const { createWriteStream } = require('fs');
const { pipeline } = require('stream/promises');
async function downloadPackage(name, version, destPath) {
const url = `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`;
return new Promise((resolve, reject) => {
https.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode}`));
return;
}
pipeline(response, createWriteStream(destPath))
.then(resolve)
.catch(reject);
}).on('error', reject);
});
}
解压 tarball
npm 包是 .tgz 格式(gzip 压缩的 tar 归档)。Node.js 标准库提供了 zlib 模块用于 gzip 解压,但没有内置的 tar 解析。
零依赖的解决方案:
- 使用
zlib解压 gzip - 自己实现 tar 格式解析(tar 格式相对简单,是固定长度的头部 + 数据块)
const zlib = require('zlib');
const { createReadStream } = require('fs');
// 简化的 tar 解析
function parseTar(buffer) {
const entries = [];
let offset = 0;
while (offset < buffer.length) {
// tar 头部是 512 字节
const header = buffer.slice(offset, offset + 512);
const name = header.slice(0, 100).toString('utf8').replace(/\0/g, '');
if (!name) break; // 空头部表示结束
const size = parseInt(header.slice(124, 136).toString('utf8').replace(/\0/g, ''), 8);
const data = buffer.slice(offset + 512, offset + 512 + size);
entries.push({ name, size, data });
// 移动到下一个头部(数据按 512 字节对齐)
offset += 512 + Math.ceil(size / 512) * 512;
}
return entries;
}
2. package.json 解析
从解压后的包中提取 package.json,解析钩子定义:
function extractHooks(packageJson) {
const scripts = packageJson.scripts || {};
const hookNames = [
'preinstall', 'install', 'postinstall',
'prepublish', 'prepare', 'prepublishOnly',
'prepack', 'postpack',
'preuninstall', 'uninstall', 'postuninstall',
'preversion', 'version', 'postversion',
'pretest', 'test', 'posttest',
'prestop', 'stop', 'poststop',
'prestart', 'start', 'poststart',
'prerestart', 'restart', 'postrestart'
];
const hooks = {};
for (const name of hookNames) {
if (scripts[name]) {
hooks[name] = scripts[name];
}
}
return hooks;
}
3. 恶意行为检测
分析钩子脚本的内容,检测潜在的恶意行为。零依赖的实现方式:
静态分析
使用正则表达式和字符串匹配检测可疑模式:
const suspiciousPatterns = [
// 网络请求(可能窃取数据)
{ pattern: /curl\s+.*\|\s*(bash|sh)/, risk: 'high', desc: '通过 curl 管道执行远程脚本' },
{ pattern: /wget\s+.*\|\s*(bash|sh)/, risk: 'high', desc: '通过 wget 管道执行远程脚本' },
{ pattern: /(http|https):\/\/[^\s]+/, risk: 'medium', desc: '包含网络请求' },
// 文件系统访问(可能窃取文件)
{ pattern: /~\/\.ssh/, risk: 'high', desc: '访问 SSH 密钥目录' },
{ pattern: /~\/\.aws/, risk: 'high', desc: '访问 AWS 凭证目录' },
{ pattern: /~\/\.npmrc/, risk: 'high', desc: '访问 npm 配置文件' },
{ pattern: /\/etc\/passwd/, risk: 'medium', desc: '访问系统用户文件' },
// 环境变量访问(可能窃取凭证)
{ pattern: /process\.env\./, risk: 'medium', desc: '访问环境变量' },
{ pattern: /\$[A-Z_]+/, risk: 'low', desc: '引用 shell 环境变量' },
// 执行外部命令
{ pattern: /child_process|exec|spawn/, risk: 'medium', desc: '执行外部命令' },
{ pattern: /eval\(/, risk: 'high', desc: '使用 eval 执行代码' },
// 持久化/后门
{ pattern: /crontab|systemctl/, risk: 'high', desc: '修改系统定时任务或服务' },
{ pattern: /~\/\.bashrc|~\/\.zshrc/, risk: 'medium', desc: '修改 shell 配置文件' },
];
function analyzeScript(scriptContent) {
const findings = [];
for (const { pattern, risk, desc } of suspiciousPatterns) {
if (pattern.test(scriptContent)) {
findings.push({ risk, desc, match: scriptContent.match(pattern)[0] });
}
}
return findings;
}
混淆检测
恶意脚本经常使用混淆技术来逃避检测。零依赖的混淆检测:
function detectObfuscation(script) {
const indicators = [];
// 大量的字符串拼接(可能隐藏恶意代码)
const stringConcatCount = (script.match(/['"]\s*\+\s*['"]/g) || []).length;
if (stringConcatCount > 5) {
indicators.push({ type: 'string_concat', count: stringConcatCount, desc: '大量字符串拼接' });
}
// base64 编码
if (/Buffer\.from\(.*,\s*['"]base64['"]\)/.test(script)) {
indicators.push({ type: 'base64', desc: '使用 base64 解码' });
}
// 十六进制编码
const hexCount = (script.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
if (hexCount > 10) {
indicators.push({ type: 'hex_encoding', count: hexCount, desc: '大量十六进制编码' });
}
// 异常的变量名
const shortVarCount = (script.match(/\b[a-z]{1,2}\b/g) || []).length;
if (shortVarCount > 20) {
indicators.push({ type: 'short_vars', count: shortVarCount, desc: '大量短变量名' });
}
return indicators;
}
4. 报告生成
生成结构化的安全报告。零依赖的实现方式:
function generateReport(packageName, packageVersion, hooks, findings) {
const report = {
package: { name: packageName, version: packageVersion },
scanTime: new Date().toISOString(),
hooksFound: Object.keys(hooks).length,
hooks: {},
riskSummary: { high: 0, medium: 0, low: 0 },
recommendations: []
};
for (const [hookName, scriptContent] of Object.entries(hooks)) {
const scriptFindings = analyzeScript(scriptContent);
const obfuscation = detectObfuscation(scriptContent);
report.hooks[hookName] = {
script: scriptContent,
findings: scriptFindings,
obfuscation: obfuscation,
risk: calculateRisk(scriptFindings, obfuscation)
};
for (const f of scriptFindings) {
report.riskSummary[f.risk]++;
}
}
// 生成建议
if (report.riskSummary.high > 0) {
report.recommendations.push('检测到高风险行为,建议不要安装此包');
}
if (report.riskSummary.medium > 0) {
report.recommendations.push('检测到中等风险行为,建议仔细审查钩子脚本');
}
if (Object.keys(hooks).length > 3) {
report.recommendations.push('包定义了较多的钩子脚本,建议审查是否必要');
}
return report;
}
零依赖的经验教训
1. 标准库比想象中更强大
在构建 HookAudit 的过程中,作者发现 Node.js 标准库比想象中更强大:
https模块可以处理大部分网络请求zlib模块可以处理压缩和解压fs模块可以处理文件操作stream模块可以处理流式数据crypto模块可以处理加密和哈希path模块可以处理路径操作url模块可以处理 URL 解析
很多时候,第三方依赖只是对标准库的简单封装,直接使用标准库并不复杂。
2. 有些功能确实需要自己实现
但也有些功能,标准库确实没有提供,需要自己实现:
- tar 格式解析:Node.js 没有内置的 tar 解析器,需要自己实现
- 更复杂的 HTTP 功能:如重试、连接池、Cookie 管理等
- 高级的字符串处理:如模糊匹配、相似度计算等
- 格式化输出:如彩色终端输出、表格格式化等
自己实现这些功能需要更多的开发时间,但也带来了更好的控制和理解。
3. 测试变得更加重要
零依赖意味着更多的自己实现的代码,而自己实现的代码可能有 bug。因此,测试变得更加重要:
- 单元测试:测试每个函数的正确性
- 集成测试:测试整个扫描流程
- 边缘情况测试:测试各种异常输入
- 回归测试:确保修改不会引入新问题
4. 文档和注释很重要
自己实现的功能需要更好的文档和注释,以便未来维护:
- 解释为什么这样实现
- 记录参考的规范和标准
- 标注已知的限制和不足
- 提供使用示例
对安全工具开发的启示
HookAudit 的构建经验对安全工具开发有以下启示:
1. 安全工具应该最小化依赖
安全工具本身应该尽可能少地依赖第三方库,以减少供应链攻击的风险。用户需要信任安全工具的输出,而信任的基础是工具本身的安全性。
2. 零依赖是可行的
对于很多安全工具来说,零依赖是完全可行的。安全工具通常不需要复杂的 UI 框架或高级的功能库,标准库往往足够。
3. 自己实现带来更深的理解
自己实现功能(如 tar 解析、恶意行为检测)带来了对问题域更深的理解,这对安全工具来说尤其重要。
4. 平衡零依赖和开发效率
零依赖并不意味着绝对不使用任何第三方库。关键是平衡:
- 对于核心安全功能,尽量零依赖
- 对于非核心功能(如测试框架、构建工具),可以使用依赖
- 对于确实复杂的功能,评估自己实现 vs 使用依赖的成本和收益
总结
HookAudit 的构建经验展示了零依赖安全工具的可行性和价值。
核心要点:
- 背景:npm 钩子脚本是供应链攻击的重要载体,需要专门的安全扫描工具
- 设计决策:HookAudit 选择只使用 Node.js 标准库,零第三方依赖,以最小化自身的安全风险
- 核心实现:包括 npm 包下载和解压、package.json 解析、恶意行为静态分析、混淆检测、报告生成
- 零依赖的好处:安全性、可信度、最小攻击面、性能、可维护性、教育价值
- 零依赖的挑战:功能受限、开发效率、复杂性、标准化
- 经验教训:标准库比想象中更强大、有些功能需要自己实现、测试更加重要、文档和注释很重要
- 对安全工具的启示:安全工具应该最小化依赖、零依赖是可行的、自己实现带来更深的理解、平衡零依赖和开发效率
HookAudit 的故事告诉我们,在供应链安全日益重要的今天,安全工具本身的安全性和可信度是至关重要的。零依赖虽然带来了一些开发上的挑战,但它确保了安全工具不会成为新的安全风险,这是值得的。
对于正在开发安全工具的团队来说,HookAudit 的经验提供了一个有价值的参考:在追求功能丰富的同时,不要忽视工具本身的安全性和可信度。有时候,少即是多。
原文链接:https://dev.to/bappadala_rohithkumarna/hookaudit-building-a-supply-chain-security-scanner-without-a-supply-chain-1aec