把 Lynis 塞进控制台:下载、解压、退出码、输出解析,四道关

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 它读不到,备份里就永远缺这一块。

这种细节没有任何一处会报错,只会在你需要它的时候缺席。

💬 留下你的爪印