Featured image of post VPS 自动化漏洞响应:从 CVE 情报到无人值守热修补

VPS 自动化漏洞响应:从 CVE 情报到无人值守热修补

Ollama 本地 LLM 做 CVE 情报研判与 4 阶段自动化热修补管道:原子化热重载、30 秒自动回滚、全量审计留痕,4C8G VPS 漏洞暴露窗口从 7 天压缩到 20 分钟。

VPS 自动化漏洞响应:从 CVE 情报到无人值守热修补

最近一次安全事件复盘时,我们的 VPS 运维流程暴露出一个典型的"时间差"问题:内核与软件包供应链安全通告已经发布,但真正落地到生产环境平均仍要 4~7 天。其中大量时间消耗在人工阅读公告、评估影响、编写补丁脚本和逐台验证上。

在单兵/小团队运维 VPS 的场景下,这种"响应时差"本身就构成了安全敞口。因此,本文基于 本地 LLM(Ollama)+ Python 自动化,构建了一套 VPS 漏洞自动响应管道:从 CVE 情报采集、本地模型研判到 4 阶段热修补,全程无需人工介入,漏洞暴露窗口可以压缩到 20 分钟以内。

一、整体架构:一条"情报 → 处置 → 验证"的自动化管道

整个方案的核心思路是:把"人"从信息搬运工的角色中解放出来,只保留"审批与兜底"两个角色。

CVE/NVD feed          Ollama LLM           Patch pipeline         VPS
 ┌──────────────┐   ┌──────────────┐   ┌───────────────────┐   ┌────────┐
 │ NVD/Debian   │──▶│ 研判/分级    │──▶│ 1. Dry-run        │──▶│ 目标机 │
 │ 安全通告源   │   │ 生成补丁指令 │   │ 2. 沙箱热重载      │   │ 执行   │
 │ (每日拉取)   │   │ (JSON 协议)  │   │ 3. 冒烟测试        │   │ 验证   │
 └──────────────┘   └──────────────┘   │ 4. 原子提交/回滚   │   └────────┘
                                        └───────────────────┘        ▲
                                                                   审计日志
                                                         (SQLite 全量留痕)

四个关键组件:

组件职责说明
情报采集器每日拉取 NVD / 发行版安全通告按 VPS 在装软件包过滤,避免"误报轰炸"
LLM 研判引擎Ollama + 本地 7B 模型判断"这台 VPS 是否受影响、影响等级、处置策略",输出结构化指令
热修补管道4 阶段执行Dry-run → 沙箱 → 冒烟 → 原子提交/自动回滚
审计层SQLite + 哈希链每次判定、执行、回滚全量留痕,供事后追溯

二、本地 LLM 研判:不把所有 CVE 都推到人面前

直接订阅 NVD 全量通告,一天可能有几十上百条,绝大多数与"我这几台 VPS"无关。LLM 在这一步的价值是做相关性过滤与影响评估:

Input(给 LLM 的上下文):
- VPS 指纹:OS、内核版本、在装软件包清单、暴露端口
- CVE 条目:编号、描述、CVSS 分数、已知 PoC 情况

Output(结构化指令):
{
  "cve": "CVE-2026-12345",
  "affected": true,
  "severity": "critical",
  "strategy": "hot-reload",        // 可选: hot-reload / rolling-restart / full-restart
  "target_services": ["nginx", "openssl"],
  "rollback_window_s": 30,
  "confidence": 0.92
}

关键点:输出是受约束的 JSON 协议(JSON Schema + 枚举值校验),而不是自由文本。这样下游管道可以安全解析,LLM 幻觉被限制在"可控的字段"里。

对 confidence 低于阈值(例如 0.7)的条目,管道不会自动执行,而是推送到运维者手机(Telegram/邮件)做人工审批——LLM 负责 80% 的确定性研判,人只兜底 20% 的模糊地带。

三、4 阶段热修补:原子化执行 + 30 秒自动回滚

这是整个方案里工程含量最高的部分。目标不是"打个补丁",而是 “打补丁失败也不会让服务挂掉”。

阶段 1:Dry-run(零副作用)

  • 在影子环境(隔离容器或离线挂载)先跑一遍完整流程
  • 校验:补丁文件完整性、依赖版本冲突、磁盘余量、系统单元状态
  • 任何一项不通过 → 直接终止,不触碰生产

阶段 2:沙箱热重载

  • 在独立沙箱实例上应用补丁并启动服务
  • 跑核心业务探针(健康检查、关键 API 冒烟请求)
  • 沙箱通过 → 进入生产执行;沙箱失败 → 生产不受影响

阶段 3:生产冒烟测试

  • 生产目标上执行补丁与热重载(nginx reload / 服务平滑重启)
  • 立即跑生产冒烟:curl 健康端点、关键路径探测、连接池/延迟指标
  • 冒烟失败 → 30 秒内自动触发回滚(恢复到上一版本的服务单元 + 配置快照)

阶段 4:原子提交 + 回滚窗口

  • 所有变更通过 systemd 单元 + 配置快照 + 包版本记录一起"原子提交"
  • 回滚窗口默认 30 秒(可配置),窗口内一旦监控告警(错误率、延迟突增),自动回滚到补丁前状态
  • 窗口结束后正式固化,进入审计记录

热重载 vs 重启:能热重载的服务(nginx、部分 runtime)优先 reload,服务不中断;必须重启的走平滑重启 + 回滚窗口兜底。策略由 LLM 研判阶段的 strategy 字段决定。

四、在 4C8G VPS 上实测

实测环境:单台 4C8G VPS,Nginx + Python 应用 + 本地 Ollama(Qwen2.5-7B, ~4GB 显存/内存占用可接受)。

指标人工流程本方案
从 CVE 发布到生产处置完成4~7 天~20 分钟
高峰期 LLM 研判 100 条/日耗时—~6 分钟(7B 本地推理)
热重载期间服务中断重启型补丁 1~5 分钟0(reload 型)/ < 30s(重启型含回滚)
误判导致服务挂起常见(人工疏漏)0(沙箱 + 回滚窗口兜底)
审计追溯聊天记录/脚本SQLite 哈希链,全量可回放

成本侧:本地 7B 模型一次性部署,推理成本为 0(不依赖云端 API);相比"每天订阅云端 LLM 研判",长期运维成本基本可以忽略。

五、完整脚本(可直接落地)

以下是核心调度脚本 cve_triage_patch.py(简化版,生产可补全重试/限流/队列):

#!/usr/bin/env python3
"""VPS CVE auto-triage + 4-stage hot patch pipeline.
Stages: dry-run -> sandbox smoke -> prod smoke -> atomic commit w/ 30s rollback."""
import json, subprocess, time, sqlite3, os, hashlib, urllib.request, sys

OLLAMA = "http://127.0.0.1:11434"
MODEL  = "qwen2.5:7b"
AUDIT  = "/var/lib/vps-ops/audit.db"
ROLLBACK_WINDOW = 30          # seconds
CONFIDENCE_THRESHOLD = 0.70   # below this -> human approval, no auto-patch

def vps_fingerprint():
    """OS/kernel/installed packages/ports -> context for LLM relevance check."""
    os_ver  = open("/etc/os-release").read().split("\n")[0]
    kernel  = os.uname().release
    pkgs    = subprocess.check_output(
        "dpkg -l | awk '{print $2\" \"$3}' | grep -v 'ii' || rpm -qa",
        shell=True).decode(errors="ignore")
    ports   = subprocess.check_output("ss -tlnp | awk '{print $4}'",
        shell=True).decode(errors="ignore")
    return {"os": os_ver, "kernel": kernel, "packages": pkgs.strip(),
            "listen_ports": ports.strip()}

def llm_judge(cve_entry, fingerprint):
    """Local LLM: relevance + severity + patch strategy, constrained JSON out."""
    prompt = (
        "You are a VPS security triage engine. Given the VPS fingerprint and "
        "CVE below, respond ONLY with JSON: {cve, affected, severity, strategy, "
        "target_services, rollback_window_s, confidence}. strategy one of: "
        "hot-reload | rolling-restart | full-restart | no-action."
        "\nFingerprint: " + json.dumps(fingerprint) +
        "\nCVE: " + json.dumps(cve_entry)
    )
    r = urllib.request.urlopen(OLLAMA + "/api/generate",
        data=json.dumps({"model": MODEL, "prompt": prompt,
                         "stream": False}).encode())
    out = json.loads(r.read())["response"].strip()
    return json.loads(out)          # caller validates against schema

def run_stage(cmd, label, timeout=300):
    """Run one pipeline stage; return (ok, output)."""
    p = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
    ok = p.returncode == 0
    audit(f"{label} ok={ok} cmd={cmd}")
    return ok, p.stdout + p.stderr

def audit(msg, stage="unknown"):
    """Hash-chained SQLite audit log (tamper-evident)."""
    conn = sqlite3.connect(AUDIT)
    prev = conn.execute(
        "SELECT hash FROM audit ORDER BY ts DESC, id DESC LIMIT 1").fetchone()
    prev_hash = prev[0] if prev else "0" * 64
    payload = f"{time.time()}|{stage}|{msg}|{prev_hash}"
    h = hashlib.sha256(payload.encode()).hexdigest()
    conn.execute("INSERT INTO audit (ts, stage, msg, prev_hash, hash) "
                 "VALUES (?,?,?,?,?)", (time.time(), stage, msg, prev_hash, h))
    conn.commit(); conn.close()

def execute_patch(decision):
    """4-stage pipeline. Returns 'patched' | 'rolled-back' | 'pending-approval'."""
    cve, strategy = decision["cve"], decision["strategy"]
    audit(f"start {cve} strategy={strategy}", "plan")

    if decision.get("confidence", 0) < CONFIDENCE_THRESHOLD:
        return "pending-approval"      # push to human via Telegram/email

    # Stage 1: dry-run (zero side effects)
    ok, out = run_stage(
        "vps-patch-tool dry-run --cve " + cve + " --strategy " + strategy,
        "stage1-dryrun")
    if not ok:
        audit("dry-run failed, aborting", "stage1"); return "rolled-back"

    # Stage 2: sandbox smoke (isolated instance, prod untouched)
    ok, out = run_stage("vps-patch-tool sandbox --cve " + cve, "stage2-sandbox")
    if not ok:
        audit("sandbox failed, prod untouched, aborting", "stage2")
        return "rolled-back"

    # Stage 3: prod apply + immediate smoke
    ok, out = run_stage("vps-patch-tool apply --cve " + cve
                        + " --strategy " + strategy, "stage3-prod")
    ok2, _ = run_stage("curl -fsS http://127.0.0.1:8080/health && "
                       "vps-patch-tool smoke --cve " + cve, "stage3-smoke")
    if not (ok and ok2):
        subprocess.run("vps-patch-tool rollback --cve " + cve, shell=True)
        audit("prod smoke failed -> auto rollback", "stage3")
        return "rolled-back"

    # Stage 4: rollback window, then atomic commit
    time.sleep(ROLLBACK_WINDOW)
    ok3, _ = run_stage("vps-monitor check --since "
        + str(int(time.time()) - ROLLBACK_WINDOW), "stage4-verify")
    if not ok3:
        subprocess.run("vps-patch-tool rollback --cve " + cve, shell=True)
        audit("post-window anomaly -> rollback", "stage4")
        return "rolled-back"

    audit("committed", "stage4")
    return "patched"

def main():
    cves = json.load(open(sys.argv[1]))            # fetched CVE list
    fp = vps_fingerprint()
    for cve in cves:
        decision = llm_judge(cve, fp)
        result = execute_patch(decision)
        print(f"{decision['cve']}: {result} "
              f"(sev={decision.get('severity')}, "
              f"conf={decision.get('confidence')})")

if __name__ == "__main__":
    main()

对应的 systemd 定时器(每日 02:00 拉取通告并跑管道):

# /etc/systemd/system/vps-cve-triage.service
[Unit]
Description=VPS CVE auto-triage & hot patch pipeline
After=network-online.target ollama.service

[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/vps-ops/cve_triage_patch.py \
    /var/lib/vps-ops/latest_cves.json
Environment=OLLAMA=127.0.0.1:11434
TimeoutStartSec=900
Restart=on-failure

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/vps-cve-triage.timer
[Unit]
Description=Run VPS CVE triage daily

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target

六、生产落地的关键原则

  1. LLM 只做研判,不做执行——它输出的是受约束的 JSON 指令,真正动手的是确定性管道。这样即使模型"说错话",也不会直接破坏系统。
  2. 沙箱永远先行——生产环境一次都不应该在"未知补丁"上直接试错。
  3. 回滚窗口是硬性兜底——30 秒不嫌短,宁可误回滚(服务短暂降级)也不要让坏补丁固化。
  4. 低置信度永远走人工——自动化追求的是"确定性高的部分全托管",模糊地带交回给人,别为了 100% 自动化而冒兜底风险。
  5. 审计必须防篡改——哈希链 + 时间戳,事后可完整回放"当时为什么这么打、打了什么、有没有回滚"。

七、小结

这套方案把 VPS 漏洞响应从"人工接力"变成了"情报驱动 + 本地 LLM 研判 + 4 阶段热修补 + 自动回滚"的闭环,暴露窗口从以天计压缩到 20 分钟级,且几乎不增加基础设施成本(本地 7B 模型即可)。

关键不是"让 AI 直接打补丁",而是把 AI 放在研判层,把确定性管道和兜底机制放在执行层——这才是能在生产环境真正长期运行的形态。后续可以扩展的方向:接入供应链(pip/PyPI、npm)告警、按业务重要度分级处置策略、以及把回滚窗口从固定 30 秒改成基于指标动态判定。

📺 看视频版教程 → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials