iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Security

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

Day 28|Human Decision:讓分析人員記錄最終判斷

  • 分享至 

  • xImage
  •  

Day 24 的 AI Analysis Panel 最下方有一個 Human Decision 區塊,上面寫:AI 分析結果僅供參考,最終風險判斷與處置仍由 SOC 分析人員確認。
不過到目前為止,這只是一段提醒文字。分析人員看完 AI 的分析之後,沒有任何地方可以記錄自己的判斷。這筆事件到底是真的攻擊、還是誤判?有沒有人看過?系統完全不知道。
Day 1 把 AI 定位成 SOC Analyst Assistant,負責協助整理與解釋,而不是取代資安人員做最終判斷。既然最終判斷是人做的,系統就應該要有地方讓人把判斷記錄下來。今天要讓 Human Decision 變成一個真正可以操作的功能。

設計三種狀態

Status 意義
Open 尚未處理,所有事件的預設狀態
Confirmed 分析人員確認這是需要處理的資安威脅
False Positive 分析人員確認這是誤判,例如內部的弱點掃描或正常的管理行為

另外加上一個備註欄位,讓分析人員寫下判斷的依據。
False Positive 在 SOC 中很重要。IDS 的規則是依照特徵比對,同樣是 Nmap 掃描,可能是攻擊者,也可能是公司自己的資安人員在做檢測。如果誤判的告警沒有被標記,同樣的告警就會一再出現,消耗分析人員的時間。
這裡有一個設計上的決定:狀態只能由人來修改,AI 的分析結果不會自動改變狀態。這延續了 Day 19 的分工方式,也符合 Prompt 中「不得自動封鎖、停權或隔離」的規則。
修改 main.py
加入狀態的 Enum

class DecisionStatus(str, Enum):
    OPEN = "Open"
    CONFIRMED = "Confirmed"
    FALSE_POSITIVE = "False Positive"

使用 Enum 之後,如果送進來的狀態不是這三種,FastAPI 會直接回傳 422,不會把奇怪的內容寫進資料庫。
在 Day 27 加入 timestamp 欄位的地方,繼續補上兩個欄位

    if "status" not in columns:
        conn.execute(
            "ALTER TABLE events ADD COLUMN status TEXT NOT NULL DEFAULT 'Open'"
        )
    if "analyst_note" not in columns:
        conn.execute(
            "ALTER TABLE events ADD COLUMN analyst_note TEXT NOT NULL DEFAULT ''"
        )

因為設定了 DEFAULT,原本已經存在的事件會自動變成 Open。
新增 Data Model

class Decision(BaseModel):
    status: DecisionStatus
    analyst_note: str = ""

新增 PATCH /events/{event_id}/decision

@app.patch("/events/{event_id}/decision")
def update_decision(event_id: int, decision: Decision):
    conn = get_db_connection()
    cursor = conn.execute("""
        UPDATE events
        SET status = ?, analyst_note = ?
        WHERE id = ?
    """, (
        decision.status.value,
        decision.analyst_note,
        event_id
    ))
    conn.commit()
    conn.close()
 
    if cursor.rowcount == 0:
        return {
            "error": "Event not found"
        }
 
    return {
        "message": "Decision saved",
        "event_id": event_id,
        "status": decision.status.value,
        "analyst_note": decision.analyst_note
    }

這裡使用 PATCH 而不是 POST,是因為這個操作是修改既有事件的部分欄位,而不是建立新的資料。
在 build_analysis_response() 的 event 中加入兩個欄位,讓 Panel 可以顯示目前的狀態

            "severity": severity_level.value,
            "status": event["status"],
            "analyst_note": event["analyst_note"]

最後在 GET /events/stats 加入狀態統計

    # Human Decision 統計
    status_rows = conn.execute("""
        SELECT status, COUNT(*) AS count
        FROM events
        GROUP BY status
    """).fetchall()
    status_counts = {
        row["status"]: row["count"]
        for row in status_rows
    }

回傳內容加入

        "status": {
            "open": status_counts.get("Open", 0),
            "confirmed": status_counts.get("Confirmed", 0),
            "false_positive": status_counts.get("False Positive", 0)
        },

修改 React App.jsx

新增 State 保存備註內容

const [decisionNote, setDecisionNote] = useState("");

stats 的初始值加入 status

    status: {
      open: 0,
      confirmed: 0,
      false_positive: 0,
    },

在 analyzeEvent() 取得分析結果時,一起帶入已經儲存的備註

    setAiAnalysis(data);
    setDecisionNote(data.event.analyst_note);

建立 saveDecision()

  // 儲存分析人員的最終判斷
  const saveDecision = async (status) => {
    try {
      const response = await fetch(
        `http://127.0.0.1:8000/events/${aiAnalysis.event.id}/decision`,
        {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            status: status,
            analyst_note: decisionNote,
          }),
        }
      );
 
      const data = await response.json();
 
      setAiAnalysis({
        ...aiAnalysis,
        event: {
          ...aiAnalysis.event,
          status: data.status,
          analyst_note: data.analyst_note,
        },
      });
      searchEvents();
      fetchStats();
    } catch (error) {
      console.error("Save decision failed:", error);
    }
  };
 
  const statusClass = (status) =>
    status === "Confirmed"
      ? "status-confirmed"
      : status === "False Positive"
      ? "status-false-positive"
      : "status-open";

儲存成功之後更新 Panel 上顯示的狀態、重新取得事件列表與重新取得統計數字。
在原本四張統計卡片的下方,加入第二排卡片

      <div className="cards">
        <div className="card">
          <h3>Pending Review</h3>
          <p>{stats.status.open}</p>
        </div>
 
        <div className="card">
          <h3>Confirmed</h3>
          <p>{stats.status.confirmed}</p>
        </div>
 
        <div className="card">
          <h3>False Positive</h3>
          <p>{stats.status.false_positive}</p>
        </div>
      </div>

Event Table 在 Severity 後面加入 Status 欄位

<th>Severity</th>
<th>Status</th>
              <td>
                <span
                  className={`status-badge ${statusClass(event.status)}`}
                >
                  {event.status}
                </span>
              </td>

最後修改 Panel 最下方的 Human Decision 區塊

    <div className="human-decision">
      <div className="decision-header">
        <strong>Human Decision</strong>
        <span
          className={`status-badge ${statusClass(
            aiAnalysis.event.status
          )}`}
        >
          {aiAnalysis.event.status}
        </span>
      </div>
      <p>
        AI 分析結果僅供參考,
        最終風險判斷與處置仍由 SOC 分析人員確認。
      </p>
 
      <textarea
        placeholder="分析人員備註:判斷依據、已採取的調查或處置..."
        value={decisionNote}
        onChange={(e) => setDecisionNote(e.target.value)}
      />
 
      <div className="decision-buttons">
        <button onClick={() => saveDecision("Confirmed")}>
          Confirm Threat
        </button>
        <button onClick={() => saveDecision("False Positive")}>
          False Positive
        </button>
        <button onClick={() => saveDecision("Open")}>
          Keep Open
        </button>
      </div>
    </div>

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

.status-badge {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 20px;
  font-size: 12px;
  font-weight: bold;
  white-space: nowrap;
}
 
.status-open {
  background: #e5e7eb;
  color: #374151;
}
 
.status-confirmed {
  background: #fee2e2;
  color: #991b1b;
}
 
.status-false-positive {
  background: #dcfce7;
  color: #166534;
}
 
.decision-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
 
.human-decision textarea {
  box-sizing: border-box;
  width: 100%;
  min-height: 80px;
  margin-top: 14px;
  padding: 10px;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  font-family: inherit;
  font-size: 14px;
}
 
.decision-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 12px;
}

測試
重新啟動 FastAPI 並重新整理 Dashboard。上方多了一排卡片,目前 36 筆事件全部都是 Pending Review,事件列表也多了 Status 欄位。
https://ithelp.ithome.com.tw/upload/images/20261012/20177754mcWL21QR3U.png
https://ithelp.ithome.com.tw/upload/images/20261012/20177754ReE4OHEdNn.png
選擇 Day 26 分析過的 SQL Injection 事件,查看 AI 分析之後,在備註輸入判斷依據並按下 Confirm Threat。
https://ithelp.ithome.com.tw/upload/images/20261012/201777546QOnTOb0eC.png
接著選擇來自內部主機 192.168.3.100 的 Nmap 掃描,備註寫上「內部弱點掃描主機的例行檢測」,按下 False Positive。
https://ithelp.ithome.com.tw/upload/images/20261012/20177754xsv7z4jmyq.png
回到上方,Pending Review 變成 34,Confirmed 與 False Positive 各為 1,事件列表中的 Status 也同步更新。重新整理頁面之後,狀態與備註仍然保留。
https://ithelp.ithome.com.tw/upload/images/20261012/20177754qEbH8cQzQl.png
https://ithelp.ithome.com.tw/upload/images/20261012/201777547DEryVn7Ae.png
小結
今天讓 Human Decision 從一段提醒文字,變成可以實際操作並保存的功能。
到這裡,一筆事件在系統中有了三層資訊:

  • 原始事件:Suricata 或測試資料提供的事實,存在 events 資料表。
  • AI 分析:Gemini 產生的摘要、風險、建議與 MITRE ATT&CK 對應,存在 ai_analyses 資料表。
  • 人工判斷:分析人員決定的狀態與備註。
    這三層剛好對應了這個系列一直在討論的分工:確定的資料由 Backend 負責,AI 負責協助理解,最後的決定由人來做。
    實作之後我也發現,加入狀態之後 Dashboard 的意義不太一樣了。原本的統計只能告訴我「有多少事件」,現在可以知道「還有多少事件沒有人看過」,這比較接近 SOC 人員實際在意的事情。

上一篇
Day 27|把 Suricata 接回來:從 EVE JSON 匯入事件到資料庫
下一篇
Day 29|案例實測:用三個攻擊情境走完一次完整的分析流程
系列文
30 天打造 AI 輔助 SOC 資安事件分析平台 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言