Lynis 本身不难装。难的是把它塞进一个由 Node 进程托管、要对着一堆 Linux 发行版干活的控制台里——每台机器环境都不一样,你不能靠人手 apt install lynis 解决问题,也不能指望「点一下就好」这条路没有分叉。
狸花云的安全中心是这么长出来的。下面四道关,每一道都是被真实报错怼出来的。
第一关:下载,返回 200 不代表你拿到了文件
GitHub 直连在国内机器上是不稳定的,这个大家都知道。第一版代码写了单源下载,失败率高得没法看。后来改成三源兜底:
const sources = [
{ name: 'GitHub 直连', url: raw },
{ name: 'ghfast.top 镜像', url: 'https://ghfast.top/' + raw },
{ name: 'gh-proxy 镜像', url: 'https://gh-proxy.com/' + raw },
]
顺序不能反。镜像偶尔会把错误页当成 200 返回,curl -f 这时候是不报错的——它只看 HTTP 状态码。所以下载完必须验一下尺寸:
const sz = fs.statSync(tmp).size
if (sz < 50000) return resolve({ ok: false, error: `文件过小(${sz}B)` })
50KB 是拍出来的经验值。Lynis 的 master.tar.gz 在两三百 KB 量级,一张 HTML 错误页顶多几 KB。这个阈值比任何状态码都老实。
第二关:上一次失败,会毒死下一次
Lynis 是以目录形式存在的,不是单个二进制。安装流程是「下载 → 解压 → 归整到 data/security/lynis」。第一版用的是 tar --one-top-level 直接解到目标目录,干净利落——直到有一次网络断在解压中间,留下半个目录。
下一次点安装,tar 直接甩一句 File exists。用户看到的是「安装失败」,实际上是「上次失败的尸体还在这」。
所以安装函数第一行不是下载,是清尸:
try { fs.rmSync(target, { recursive: true, force: true }) } catch (e) {}
同理,扫描目录 _stage 每次也要先删。这一步看着脏,但它把「可重入」这件事解决了。任何一个安装动作,只要它写磁盘,就应该先假设磁盘上有垃圾。
第三关:解压出来的目录叫什么,没人保证
master.tar.gz 解开以后顶层目录是 lynis-master。这是常态,但不是承诺。改了分支名、换了打包方式、镜像重新打了一次包,都可能变成平铺,或者多套一层。
所以解压不能直接落到目标位置,要先落到 _stage,然后去探测:
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name)
if (dirs.length === 1) topDir = path.join(stageDir, dirs[0])
else if (dirs.length > 1) {
const hit = dirs.find((d) => fs.existsSync(path.join(stageDir, d, 'lynis')))
topDir = hit ? path.join(stageDir, hit) : path.join(stageDir, dirs[0])
} else topDir = stageDir // 直接平铺
判断依据是「哪个目录里真的躺着 lynis 这个可执行脚本」,不是目录名。名字会变,文件不会。
探测完再递归复制到目标位置,中间层丢掉。这样无论上游怎么打包,落地的结构都是固定的——这是给未来的自己省事。
第四关:退出码骗人,输出格式也骗人
Lynis 扫出问题时,退出码不是 0。这意味着你不能用 if (err) return fail(...) 判断扫描失败,否则每台有警告的机器都是「扫描失败」。
判据得换成输出内容:
const run = await new Promise((resolve) => {
execFile(bin, ['audit', 'system', '--quick', '--no-colors'], {
cwd: LYNIS_DIR(), // 不设这个,它读不到 include/ 下的库
timeout: 180000,
maxBuffer: 8 * 1024 * 1024, // 默认 1MB,报告会被腰斩
env: { ...process.env, TERM: 'dumb' },
}, (err, stdout, stderr) => {
resolve({ ok: !err || /hardening index/i.test(stdout || ''), out: (stdout || '') + (stderr || '') })
})
})
三个参数,每个都是坑:
| 参数 | 少了会怎样 |
|---|---|
cwd: LYNIS_DIR() |
找不到 include/ 里的函数库,直接报错退出 |
maxBuffer: 8MB |
完整报告被截断在 1MB,只剩前半截测试项 |
env.TERM='dumb' |
部分环境走交互分支,进程挂住直到超时 |
然后是解析。Lynis 输出带 ANSI 色码,正则直接匹配会全失效,必须先剥:
const s = L.replace(/\x1b\[[0-9;]*m/g, '').trim()
if ((m = s.match(/Hardening index\s*:\s*(\d+)/))) out.hardening = Number(m[1])
if ((m = s.match(/^\s*\*\s*(.+?)\s*\[([A-Z0-9\-]+)\]/)) && /^(WARN|SUGG|FIND)/.test(m[2])) {
// ...
}
加固指数是唯一一个能让人一眼看懂的分数,所以它是这个页面的主角。其余的建议项按 [SSH-7408] 这种 tag 前缀分到 WARN / SUGG 两个桶里,前端一次最多给 40 条——多的没人看。
分数只是一个入口
真正有价值的是分数背后的动作。控制台里预置了一键安装的安全工具集(fail2ban / ufw / rkhunter),剩下的靠报告逐条推着走。经验上,一台刚装好的 Ubuntu LTS 容器,把下面这几件事做完,加固指数会明显往上走:
- SSH 关掉密码登录,只留密钥,
PermitRootLogin no - 装
ufw,默认 deny,只放业务端口 fail2ban盯 sshd,连错五次封一小时- 开 unattended-upgrades,安全补丁别等人
- 定期
rkhunter --check,被换过的二进制它会喊 - 关掉不用的服务,
ss -lntu里每一条都要能解释 - 日志留存,出了事才有得查
这些都不是新技术,只是没人愿意做。工具的价值不在于告诉你哪里不对,在于把「哪里不对」变成一张能划掉的清单。
顺便一句:报告落在 data/security/last-report.json,目录权限特意设成 755——宿主机上的备份进程是以另一个用户跑的,权限设成 700 它读不到,备份里就永远缺这一块。
这种细节没有任何一处会报错,只会在你需要它的时候缺席。