# FR-177.A5 資安掃描：簽章驗證核心、啟動完整性閘、漏洞庫同步

> ⚠️ **歷史紀錄**：本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線（修正卡 CM-3559～3564、3573～3576、3584，驗收紀錄見母卡 CM-3507）。文中的檔名與行號是掃描當下（commit `bab2aa3`）的版本，修正後已變動，請勿拿來定位現行程式。

> 卡片：CM-3512（母卡 CM-3507）｜掃描 run ID：`wf_4fa8f010-296`｜程式版本：`bab2aa31f00d`（branch `ws-CM-3507-CM-3512`）｜2026-11-05

## 1. 🔴 一句話結論

**沒有找到需要馬上修的問題。** 工具只提出 1 條疑點，三個獨立檢查員一致投「不成立」；人工照「重點看什麼」逐條核對，7 項都守住了，另外記下 2 個很小的觀察（不急，要不要開卡由決策者決定）。

## 2. 這一棒在檢查什麼

Agent 裝在客戶機器上，它怎麼確認「這份東西是原廠出的、沒被改過」：

- **信封驗章**（`common/integrity/verify.py`）：原廠用私鑰簽名，Agent 用寫死在程式裡的公鑰核對。更新清單、漏洞庫清單、啟動檢查全靠它。
- **啟動檢查**（`common/integrity/startup_gate.py`）：Agent 每次開機前先核對自己的程式檔有沒有被改過，被改過就拒絕啟動。
- **漏洞庫下載與解壓**（`guidant_agent/sync/app/vulndb_sync.py`、`vulndb_bundle.py`）：從主系統拉新版漏洞資料包、解開、驗章、切換。

## 3. 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 結論 |
|---|---|---|---|---|---|---|
| 工具 C1 | 驗章前先把信封內容解壓，解壓沒設上限（「壓縮炸彈」：小檔解開變超大） | Agent 記憶體被吃光、當掉 | 要先**控制主系統本身**（資料包走驗證過對方身分的加密連線，且指紋要符合主系統公布的值） | `common/integrity/verify.py:102` | 可選：解壓改用 `zlib.decompressobj().decompress(data, max_length)` 設上限 | **不成立**（三票全否：能打的人本來就能做更糟的事） |
| 人工 O1 | 啟動檢查只核對「清單上列的檔」，清單外多出的檔不會被發現 | 有人在程式目錄放額外檔案，開機檢查不會發現 | 要先**能寫入 Agent 安裝目錄**（通常只有 root） | `common/integrity/startup_gate.py:146-161` | 比照漏洞庫 `check_files` 的做法：掃目錄、清單外有多的檔就拒啟（要排除 log／暫存類） | 觀察，低；是否成立要看 Nuitka 產物會不會載入多出的檔，未驗證 |
| 人工 O2 | 判斷「新版不能比舊版舊」時，舊版日期讀的是本機那份沒重新驗章的 manifest；它壞掉時當成「沒裝過」 | 可以塞回一包**原廠簽過的舊版**漏洞庫（降版） | 要先**能寫入漏洞庫目錄**，且主系統要真的送出舊包 | `guidant_agent/sync/app/vulndb_sync.py:195-199`、`:254` | 本機 manifest 讀不到但 `current` 存在時，當錯誤停下而不是當首次安裝 | 觀察，低 |

## 4. 每條詳述

### 工具 C1：驗章前解壓沒有上限（不成立）

- **白話**：信封裡的內容是壓縮過的。Agent 先解壓、才驗簽名，解壓這一步沒有限制大小。
- **出事會怎樣**：一個 8MB 的簽章檔可能解成好幾 GB，把記憶體吃光。
- **要先有什麼**：漏洞庫的 `manifest.sig` 由主系統送來，走驗證過主系統身分的加密連線（`cloud_trust.py:57-66`），整包指紋要等於主系統自己公布的值（`vulndb_sync.py:223-225`）；簽章檔上限 8MB（`vulndb_bundle.py:130`）。啟動檢查的 `manifest.sig` 在安裝目錄裡，能改它的人本來就能直接換掉程式。
- **為什麼不成立**：能送惡意檔的人只有「已經被入侵的主系統」或「本機 root」，這兩種人不必靠這招，就能讓 Agent 停擺。
- **怎麼修（選做、加固）**：`verify.py:102` 改成 `zlib.decompressobj().decompress(raw, 16*1024*1024)`，解完 `unconsumed_tail` 非空就拋 `VerificationError`。

### 人工 O1：啟動檢查不擋清單外的多餘檔案

- **在哪裡**：`common/integrity/startup_gate.py:146-161` `_compare_files` 只跑 `manifest["files"]` 列出的檔。
- **出事會怎樣**：理論上，有人在 binary 旁邊多放一個會被載入的模組，開機檢查不會發現。
- **要先有什麼**：本機 root 等級的寫入權。這個人也能改 systemd 設定、直接換掉程式，所以多出的風險很有限。
- **怎麼修**：參考 `vulndb_bundle.py:158-173` `check_files`，加一步「目錄實際檔案集合 ⊆ 清單」。
- **不確定**：Nuitka standalone 產物會不會載入清單外的檔，這次沒驗證。

### 人工 O2：本機 manifest 壞掉時，降版檢查會放行

- **在哪裡**：`vulndb_sync.py:195-199` `installed_date()` 讀不到日期就回 `None`；`vulndb_bundle.py:154` 遇到 `previous is None` 就放行。
- **出事會怎樣**：漏洞庫退回舊版，掃描漏掉新公布的漏洞。
- **要先有什麼**：能寫本機漏洞庫目錄，而且主系統真的送了舊包（主系統送舊包時，`vulndb_sync.py:109` 已經先比過一次）。
- **怎麼修**：`current` 連結存在但 manifest 讀不到時，回報錯誤、不同步，不要當成首次安裝。

### 「重點看什麼」逐條人工查證

| 項目 | 結果 | 為什麼 |
|---|---|---|
| `verify.py:61-81` 從檔案讀公鑰 | **不成立** | 預設只查寫死在程式裡的 `PUBLIC_KEYS`（`:61-65`）。從檔案讀公鑰的 `release_key_lookup` 只用在 Agent 更新清單（`update_console.py:178`），目錄寫死為 `/opt/guidant-agent/current/release-keys`（`update_console.py:34`）。上傳包裡的鑰一律不看；kid 只收 16 位十六進位（`verify.py:47`），擋 `../`；捷徑檔（symlink）、超過 4KB、非 Ed25519 的公鑰都拒收（`:78`、`:84`）。能寫 `/opt/guidant-agent` 的人等同 root。**附帶一點**：`release-keys/` 有沒有列在啟動 manifest 裡受保護，不在本棒範圍，沒查。 |
| `verify.py:114-152` `alg`／序列化／類別不符 | **不成立** | `alg` 不是 `Ed25519` 一律拒收（`:128`），而且公鑰物件本身就是 Ed25519，不存在換演算法的問題。簽名驗的是「解壓後的原始位元組」（`:152`），不會在 Agent 端重新排序 JSON，所以兩邊序列化不一致的問題不存在。類別不符直接拋錯拒收（`:157-158`），不是只發警告。 |
| `startup_gate.py:105` 測試開關、`:55-58` 覆寫路徑 | **不成立** | 打包模式下（由 `__compiled__`／`frozen` 判斷，`resource_path.py:27-29`），驗證一律開啟（`:103-104`），環境變數只能讓驗證更嚴、不能關掉。驗證目錄在打包模式固定在 binary 所在目錄（`:60-61`），不吃環境變數覆寫。 |
| `startup_gate.py:24` 刪 manifest 能不能繞過 | **不成立** | 簽章檔不存在就拋錯（`:118-121`），清單空的也拒啟（`:138-142`），失敗一律 `SystemExit(1)` 拒絕啟動（`:181`），不是記 log 後繼續跑。 |
| `vulndb_bundle.py:79-94` 自寫的解壓 | **不成立** | 絕對路徑、`..`、空字元一律拒收（`:103`）；`realpath` 確認目標還在資料目錄內（`:105-107`）；連結與特殊檔整包拒收（`:95-97`）；實際寫入量超過 tar 標頭宣告值就拒收（`:118-119`），標頭說謊沒用；檔數、總量都有上限；`O_EXCL|O_NOFOLLOW` 防止覆寫或跟著捷徑寫到別處（`:113`）。 |
| `vulndb_bundle.py:135-172` 清單外的檔、切換是否原子 | **不成立** | 清單外多出的檔整包拒收（`:168-170`）。全部檢查做完才切換。`os.rename` 搬進正式目錄名，再用 `os.replace` 原子地換掉 `current` 連結（`vulndb_sync.py:264-272`），掃描任何時刻看到的不是舊版就是新版。 |
| `vulndb_sync.py` 來源／頻率／降版／失敗是否刪舊庫 | **不成立**（見 O2） | 來源是主系統 `/api/1.0/agents/vulndb/bundle`（`:32`），走同一條驗證過身分的控制通道（`:247`），由心跳設定通道觸發；同一包失敗後冷卻 30 分鐘（`:44`）。降版有兩道擋：主系統宣告的日期（`:109`）、簽章內的日期（`:254`）。失敗時只清暫存，`current` 不動（`:239-241`），不會刪掉舊庫。 |
| `public_keys.py` 幾把鑰、dev 鑰 | **不成立** | 只有一把 PROD 鑰（`public_keys.py:31-38`），沒有 dev／stg 鑰跟著出貨。 |

## 5. 這份結果可信到什麼程度

- **「找到的這些存在嗎」**：工具的 C1 確實存在，但三個檢查員都判定沒有實際攻擊價值。O1、O2 是人工開檔看到的，**沒有經過三人投票**，嚴重度是 runner 自己判的。
- **「只有這些嗎」**：只能說「這 6 個檔裡，一個研究員讀過一輪、人工對過重點清單，沒看到更多」。effort low 只派一個研究員，不是窮盡式審查。範圍外的檔（`update_console.py`、`cloud_trust.py`、`resource_path.py`）只為了確認呼叫端而翻過幾行，沒有審。沒有執行任何程式或測試，所有結論都來自讀程式碼。

## 6. 執行概況（照 stamp `CLAUDE-SECURITY-REVISION-bab2aa31f00d.json`）

| 欄位 | 值 |
|---|---|
| status | `verified` |
| reason_kind | `null` |
| candidates | 1（工具提出 1 條疑點） |
| panel_votes | 3（三個檢查員對 1 條各投一票，3 票全投出，沒有漏投；結果 0 票成立） |
| researchers_dispatched / returned | 1 / 1 |
| 範圍檔數 | 6 |
| 耗時 | 1042 秒（約 17 分鐘） |
| 歸檔 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-152756/` |

`verified` 在這裡的意思是「唯一的那條疑點，三人投票完整跑完」。0 條發現是「投票全數否決」，不是「沒東西可投」。
