iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
AI Security

30 天打造 AI 輔助 SOC 資安事件分析平台系列 第 26 篇

Day 26|儲存 AI 分析結果:不再每次重新呼叫 Gemini

  • 分享至 

  • xImage
  •  

Day 25 把測試事件增加到 30 筆之後,我開始一筆一筆按 Analyze with AI 來看不同攻擊類型的分析結果。按了幾筆之後就發現一個問題:只要重新整理頁面,或是改按另一筆事件,前一筆的分析結果就不見了。想再看一次,只能重新呼叫 Gemini。
這會造成三個問題:

  1. 每次都要重新等待 Gemini 回應。
  2. 同一筆事件重複分析,會一直消耗 API 的使用額度。
  3. LLM 每次產生的文字不一定相同。同一筆事件今天和明天看到的摘要可能不一樣,分析人員沒辦法回頭確認「當時 AI 是怎麼說的」。
    對 SOC 來說第三點最重要,事件的分析過程應該要留下紀錄。所以今天要把 AI 分析結果存進 SQLite,分析過的事件直接從資料庫讀取。

建立 ai_analyses 資料表

修改 main.py,新增 import

from datetime import datetime

在 init_database() 建立 events 資料表之後加入

    # 保存 AI 分析結果,一筆事件對應一筆分析
    conn.execute("""
        CREATE TABLE IF NOT EXISTS ai_analyses (
            event_id INTEGER PRIMARY KEY,
            summary TEXT NOT NULL,
            attack_type TEXT NOT NULL,
            risk TEXT NOT NULL,
            recommendation TEXT NOT NULL,
            mitre_technique_id TEXT NOT NULL,
            mitre_technique_name TEXT NOT NULL,
            analyzed_at TEXT NOT NULL
        )
    """)

欄位直接對應 Day 18 定義的 Structured Output,再加上 analyzed_at 記錄分析時間。event_id 設為 PRIMARY KEY,代表一筆事件只保留一筆最新的分析結果。
這裡沒有把分析結果直接加在 events 資料表裡,是因為事件本身是 Suricata 或使用者提供的「事實」,而 AI 分析是「推論」。把兩者分開存放,之後要重新分析或刪除分析結果時,都不會動到原始事件。

讓事件列表知道哪些已經分析過

前端需要知道每一筆事件是否已經有分析結果,所以查詢事件時用 LEFT JOIN 多帶出一個 analyzed 欄位。
在 # API 上方加入

# 查詢事件時一併帶出是否已有 AI 分析
EVENT_SELECT = """
    SELECT events.*,
           ai_analyses.event_id IS NOT NULL AS analyzed
    FROM events
    LEFT JOIN ai_analyses
           ON ai_analyses.event_id = events.id
"""

修改 GET /events

    rows = conn.execute(
        EVENT_SELECT + "ORDER BY events.id"
    ).fetchall()

修改 GET /events/search

    rows = conn.execute(EVENT_SELECT + """
        WHERE event_type LIKE ?
           OR src_ip LIKE ?
           OR dest_ip LIKE ?
           OR protocol LIKE ?
           OR signature LIKE ?
        ORDER BY events.id
    """, (

修改 /events/{event_id}/analyze

因為現在有「從資料庫讀取」與「剛分析完成」兩種情況都要回傳相同格式,先把組合回傳內容的部分獨立成一個函式

def build_analysis_response(event, severity_level, analysis, cached):
    return {
        "event": {
            "id": event["id"],
            "event_type": event["event_type"],
            "src_ip": event["src_ip"],
            "dest_ip": event["dest_ip"],
            "dest_port": event["dest_port"],
            "protocol": event["protocol"],
            "signature": event["signature"],
            "severity": severity_level.value
        },
 
        "ai_analysis": {
            "summary": analysis["summary"],
            "attack_type": analysis["attack_type"],
            "risk": analysis["risk"],
            "recommendation": analysis["recommendation"],
            "mitre_technique_id": analysis["mitre_technique_id"],
            "mitre_technique_name": analysis["mitre_technique_name"]
        },
 
        "analyzed_at": analysis["analyzed_at"],
        "cached": cached
    }

Endpoint 增加 force 參數,並在查詢事件時一起查詢已保存的分析結果

@app.post("/events/{event_id}/analyze")
def analyze_event(event_id: int, force: bool = False):
 
    # 1. 從 SQLite 找指定事件與已保存的分析結果
    conn = get_db_connection()
 
    event = conn.execute(
        "SELECT * FROM events WHERE id = ?",
        (event_id,)
    ).fetchone()
 
    saved = conn.execute(
        "SELECT * FROM ai_analyses WHERE event_id = ?",
        (event_id,)
    ).fetchone()
 
    conn.close()

在 Severity Mapping 之後、準備 Prompt 之前加入

    # 已經分析過就直接回傳,除非指定 force 重新分析
    if saved is not None and not force:
        return build_analysis_response(
            event, severity_level, saved, cached=True
        )

最後把原本的「6. FastAPI 回傳 JSON」改成先保存再回傳

    # 6. 保存分析結果
    analysis = {
        "summary": result.summary,
        "attack_type": result.attack_type,
        "risk": result.risk,
        "recommendation": result.recommendation,
        "mitre_technique_id": result.mitre_technique_id,
        "mitre_technique_name": result.mitre_technique_name,
        "analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    }
 
    conn = get_db_connection()
    conn.execute("""
        INSERT OR REPLACE INTO ai_analyses
        (event_id, summary, attack_type, risk, recommendation,
         mitre_technique_id, mitre_technique_name, analyzed_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    """, (
        event_id,
        analysis["summary"],
        analysis["attack_type"],
        analysis["risk"],
        analysis["recommendation"],
        analysis["mitre_technique_id"],
        analysis["mitre_technique_name"],
        analysis["analyzed_at"]
    ))
    conn.commit()
    conn.close()
 
    # 7. FastAPI 回傳 JSON
    return build_analysis_response(
        event, severity_level, analysis, cached=False
    )

INSERT OR REPLACE 會在同一個 event_id 已經存在時直接覆蓋,所以重新分析之後保留的是最新的結果。
現在這個 Endpoint 的流程變成:

按下 Analyze with AI
        ↓
資料庫有分析結果? ── 有 ──→ 直接回傳(cached: true)
        ↓ 沒有,或指定 force
    呼叫 Gemini
        ↓
   存進 ai_analyses
        ↓
回傳(cached: false)

修改 React App.jsx

analyzeEvent() 增加 force 參數

const analyzeEvent = async (eventId, force = false) => {
  try {
    setAnalyzingId(eventId);
    setAiAnalysis(null);
 
    const response = await fetch(
      `http://127.0.0.1:8000/events/${eventId}/analyze${
        force ? "?force=true" : ""
      }`,
      {
        method: "POST",
      }
    );
 
    const data = await response.json();
 
    setAiAnalysis(data);
    // 更新事件列表中的分析狀態
    searchEvents();
  } catch (error) {
    console.error("AI analysis failed:", error);
  } finally {
    setAnalyzingId(null);
  }
};

分析完成後呼叫 searchEvents(),是為了讓事件列表重新取得 analyzed 欄位,同時保留目前的搜尋條件。
已經分析過的事件,按鈕文字改成 View Analysis

{analyzingId === event.id
  ? "Analyzing..."
  : event.analyzed
  ? "View Analysis"
  : "Analyze with AI"}

AI Analysis Panel 的狀態標籤依照 cached 顯示不同文字,並在標題下方加入分析時間與 Re-analyze 按鈕

      <span className="analysis-status">
        {aiAnalysis.cached ? "Saved Analysis" : "Analysis Complete"}
      </span>
    </div>
 
    <div className="analysis-meta">
      <span>分析時間:{aiAnalysis.analyzed_at}</span>
      <button
        onClick={() => analyzeEvent(aiAnalysis.event.id, true)}
        disabled={analyzingId !== null}
      >
        Re-analyze
      </button>
    </div>

nano src/App.css 在最下面加入

.analysis-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 20px;
  color: #6b7280;
  font-size: 14px;
}

測試

重新啟動 FastAPI,選擇 SQL Injection 的事件按下 Analyze with AI。第一次會呼叫 Gemini,Panel 右上角顯示 Analysis Complete,下方出現分析時間。
https://ithelp.ithome.com.tw/upload/images/20261010/201777542R8OxBOHJb.png
回到事件列表,這筆事件的按鈕已經變成 View Analysis。
https://ithelp.ithome.com.tw/upload/images/20261010/20177754dBgh1klS19.png
重新整理頁面後再按一次 View Analysis,結果立刻出現,狀態顯示 Saved Analysis,分析時間仍然是第一次分析的時間。
https://ithelp.ithome.com.tw/upload/images/20261010/201777548ramNywaIu.png
按下 Re-analyze 會重新呼叫 Gemini,分析時間更新成現在的時間。
https://ithelp.ithome.com.tw/upload/images/20261010/20177754i8UofLJQeN.png
比較前後兩次的結果可以發現,同一筆 SQL Injection 事件,兩次的事件摘要寫法並不相同。這也是前面提到需要把分析結果保存下來的原因。
小結
今天把 AI 分析結果存進 SQLite,分析過的事件不需要再重新呼叫 Gemini。
一開始我只是覺得每次重新等待很浪費時間,但實作之後發現,保存分析結果真正的意義是讓分析過程可以被追溯。LLM 的回答不是固定的,如果沒有保存下來,之後就沒有辦法確認當時是根據什麼內容做出判斷。
另外,把「原始事件」與「AI 分析」放在不同的資料表,也延續了 Day 19 的想法:確定的事實由 Backend 負責,AI 產生的內容則是另外保存、隨時可以重新產生的參考資訊。


上一篇
Day 25|建立測試案例:讓 Dashboard 有 Brute Force、Web Attack 等不同事件
下一篇
Day 27|把 Suricata 接回來:從 EVE JSON 匯入事件到資料庫
系列文
30 天打造 AI 輔助 SOC 資安事件分析平台 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言