Featured image of post Automating CVE Response on Your VPS: From Intel to Unattended Hot-Patching

Automating CVE Response on Your VPS: From Intel to Unattended Hot-Patching

A local-LLM CVE triage engine (Ollama + 7B) feeding a 4-stage automated hot-patch pipeline: atomic hot reload, 30s auto-rollback, full audit trail — collapsing the VPS vulnerability window from days to ~20 minutes on a 4C8G box.

Automating CVE Response on Your VPS: From Intel to Unattended Hot-Patching

After the last security incident, one pattern stood out in our VPS ops review: advisories were published days before we actually shipped fixes. Most of that time wasn’t spent fixing — it was spent reading, assessing, scripting, and verifying by hand.

For a solo or small-team VPS setup, that lag is itself the risk. In this post we close it with a local-LLM triage engine (Ollama + 7B) feeding a 4-stage automated hot-patch pipeline — no human in the loop for the high-confidence path, and a 30-second auto-rollback window for everything else. Result on a 4C8G VPS: vulnerability window drops from 4–7 days to roughly 20 minutes.

1. Architecture: an intel → action → verify pipeline

The core idea: take humans out of the “information courier” role and keep them only for approval and last-resort fallback.

CVE/NVD feed          Ollama LLM           Patch pipeline         VPS
 ┌──────────────┐   ┌──────────────┐   ┌───────────────────┐   ┌────────┐
 │ NVD / distro │──▶│ triage /     │──▶│ 1. Dry-run        │──▶│ target │
 │ security     │   │ severity /   │   │ 2. Sandbox reload │   │ apply  │
 │ advisories   │   │ strategy     │   │ 3. Smoke test     │   │ +      │
 │ (daily poll) │   │ (JSON plan)  │   │ 4. Commit / roll  │   │ verify │
 └──────────────┘   └──────────────┘   └───────────────────┘   └────────┘
        ▲                                                          │
        └──────────── SQLite hash-chained audit log ◀─────────────┘

Four components:

ComponentJobNotes
Intel collectorDaily NVD / distro advisory pullFilters to packages actually installed on your VPS — no noise
LLM triage engineOllama + local 7BDecides “is this VPS affected, how bad, which strategy” and emits a structured plan
Hot-patch pipeline4-stage executionDry-run → sandbox → prod smoke → atomic commit with auto-rollback
Audit layerSQLite + hash chainEvery decision, execution, and rollback is logged and replayable

2. LLM triage: don’t blast every CVE into your inbox

Subscribing to the full NVD feed is dozens-to-hundreds of items a day, most irrelevant to “these few VPS boxes.” The LLM’s value is relevance filtering and impact assessment, not patch writing.

Input (LLM context):
- VPS fingerprint: OS, kernel, installed packages, open ports
- CVE entry: id, description, CVSS, PoC status

Output (constrained JSON plan):
{
  "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
}

The critical detail: the output is a constrained JSON schema, not free text. Downstream stages parse it safely, and LLM hallucination is confined to “which field” rather than “what happens to my box.”

Entries with confidence below a threshold (e.g. 0.7) do not auto-patch — they get routed to the operator (Telegram/email) for approval. The LLM handles the ~80% deterministic work; humans backstop the ~20% ambiguous tail.

3. The 4-stage hot-patch pipeline: atomic, reversible by construction

The goal is not “apply a patch.” It’s “apply a patch that cannot break the service.”

Stage 1 — Dry-run (zero side effects)

  • Full pipeline rehearsal in a shadow/isolated environment
  • Validates: patch integrity, dependency conflicts, disk headroom, unit state
  • Any failure → abort, production untouched

Stage 2 — Sandbox smoke

  • Apply the patch on an isolated sandbox instance, boot the services, run probes (health endpoint, key API calls)
  • Pass → proceed to production; fail → production is still pristine

Stage 3 — Production apply + immediate smoke

  • Apply with hot reload where possible (nginx -s reload, graceful service restart)
  • Immediately run production smoke: health endpoint, key routes, latency/connection metrics
  • Smoke fail → auto-rollback within 30s to the pre-patch unit + config snapshot

Stage 4 — Atomic commit + rollback window

  • All changes (service unit, config snapshot, package versions) are committed atomically
  • For rollback_window_s (default 30s) post-commit, any monitored anomaly (error rate, latency spike) triggers automatic rollback
  • After the window, the change is permanent and audited

Hot reload vs. restart: services that support it (nginx, most runtimes) get reload — zero downtime. Others take a graceful restart, backstopped by the rollback window. The LLM triage plan’s strategy field decides which path to take.

4. Measured on a 4C8G VPS

Environment: single 4C8G VPS, Nginx + Python app + local Ollama (Qwen2.5-7B, ~4GB footprint — fits comfortably).

MetricManual flowThis pipeline
CVE published → fix live in prod4–7 days~20 min
Triage throughput (100 CVEs/day)—~6 min (local 7B)
Service downtime during patch1–5 min (restart patches)0 (reload) / <30s (restart, incl. rollback)
Bad-patch incidentscommon (human slip)0 (sandbox + rollback window)
Auditabilitychat logs + ad-hoc scriptsSQLite hash chain, fully replayable

Cost side: local 7B model, zero per-inference cost (no cloud API). Versus a “cloud LLM triage subscription,” the long-run ops cost is negligible.

5. The full script (drop-in ready)

Core orchestrator, cve_triage_patch.py (trimmed; add retries/rate-limiting/queueing for production):

#!/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/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 + 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 timer (daily 02:00, fetch advisories, run pipeline):

# /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

6. Production principles that made this work

  1. LLM advises, never executes — it emits a constrained JSON plan; the deterministic pipeline does the actual work. A hallucination stays a hallucination, not a broken service.
  2. Sandbox first, always — production never runs the first experiment on an unknown patch.
  3. The rollback window is a hard backstop — 30 seconds is fine. A false rollback (brief degradation) beats a stuck bad patch.
  4. Low confidence routes to humans — automation’s job is to fully own the high-certainty work, not to gamble on the fuzzy tail for 100% coverage.
  5. Audit must be tamper-evident — hash chain + timestamps, so you can replay why a patch ran, what it did, and whether it rolled back.

7. Summary

This pipeline turns VPS vulnerability response from a “human relay” into a closed loop: intel-driven, locally LLM-triaged, 4-stage hot-patched, auto-rolled-back — and it shrinks the exposure window from days to ~20 minutes with effectively zero added infrastructure cost (a local 7B model is enough).

The lesson: don’t have AI apply patches. Put the AI in the judgment layer and a deterministic, backstopped pipeline in the execution layer. That’s the shape that survives production. Natural next steps: supply-chain alerts (pip/npm), per-service criticality-based strategy tiers, and making the rollback window dynamic (metric-driven instead of a fixed 30s).

📺 看视频版教程 → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials