# FR-177.A4 資安掃描：檢測規則包下載與解壓（CM-3511）

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

## 1. 🔴 一句話結論

找到 **1 個中度問題**：一個很小、但刻意做壞的 tar.gz 壓縮檔，可以在 Agent「數檔案數」那一步就把記憶體吃光，讓 Agent 整支程式當掉（心跳、掃描、主控台全停）。三個獨立的檢查員全數認定成立。不會被偷資料、不會被執行程式，是「讓服務停擺」型的問題。

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

主系統派掃描任務時會附一份「檢測規則包」（CINC Auditor profile，本質是會被執行的 Ruby 程式）。Agent 要把它下載、核對指紋（sha256，就是檔案內容的「身分證號碼」，內容改一個字就對不上）、解壓到本機，再交給掃描工具。這一棒看這條鏈有沒有洞，以及全 Agent 唯一一份「安全解壓」共用程式（它同時服務源碼包、漏洞庫、升級包）。

範圍三個檔：`guidant_agent/sync/app/profile_cache.py`、`guidant_agent/sync/app/profile_sync.py`、`guidant_agent/shared/archive_safety.py`。

## 3. 總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 急不急 |
|---|---|---|---|---|---|---|
| F1 | 解壓前「先數有幾個檔」這一步本身沒有上限，做壞的小壓縮檔可以讓這一步吃光記憶體 | Agent 整支程式被系統強制結束；同一個檔再丟一次就再當一次 | 能讓 Agent 解一個 tar 類壓縮檔的人：主控台有掃描或更新權限的登入者、或能替這台 Agent 的掃描任務附源碼包的主系統使用者。**對規則包這條路**：要先能在主系統放一份 sha256 對得上的惡意規則包（等於主系統已被打穿或上傳者本身惡意） | `guidant_agent/shared/archive_safety.py:191` | 見下方 F1「怎麼修」 | 中：要先有登入權限，後果是停擺不是外洩；但共用層，一處修好四條路都受惠 |

## 4. 每條發現詳述

### F1 — 解壓前的「數檔案」步驟沒有上限，小檔可吃光記憶體（中度，可信度中）

**白話**：Agent 解壓前會先看「這包裡面有幾個檔、加起來多大」，超過上限就拒收——這是防「壓縮炸彈」（解開後暴增成幾百 GB 的檔）的設計。問題是 tar 格式要「整包讀過一遍」才知道有幾個檔，而 Python 的 `getmembers()` 讀的時候會把**每一個檔的檔頭資訊都存進記憶體**；另外 tar 有一種「長檔名」檔頭，檔頭本身宣告多大就讀多大進記憶體。這些都發生在「檢查上限」之前，所以上限擋不到。

**出事會怎樣**：攻擊者做一個不到 100 MB 的 tar.gz，裡面塞幾千萬個一模一樣的空檔頭（壓縮後每個只佔幾個 byte），或一個宣告好幾 GB 的長檔名檔頭。Agent 一讀就超過容器記憶體上限，被作業系統強制結束。心跳停、掃描停、主控台停，重丟一次再停一次。

**要先有什麼才打得到**：
- 能讓 Agent 解 tar 類壓縮檔：主控台（8444）有掃描或更新權限的登入者，或能替這台 Agent 的 SonarQube／SBOM 任務附源碼包的主系統使用者。
- 離線更新包這條路：更新包是在**驗簽章之前**就先解開的（呼叫端行為，引自檢查員，本棒未讀該檔），所以不需要合法簽章。
- 規則包這條路：sha256 是在解壓**之前**驗的（`profile_cache.py:339-342`），所以要打到這裡，惡意檔必須就是主系統記錄的那一份——門檻最高。
- 本結論是讀程式碼與 CPython `tarfile` 原始碼得出，**沒有實際跑**。

**在哪裡**：`guidant_agent/shared/archive_safety.py:190-195`（`extract_archive` 的 tar 分支：`getmembers()` 在第 191 行，`check_archive_quota` 在第 192 行之後）。zip 分支（`:177-182`）用 `infolist()` 讀的是檔尾的目錄區，成本跟檔案數成正比但不受「長檔名」放大，影響較小。

**怎麼修**（修另開卡）：在 `extract_archive` 的 tar 分支，不要呼叫 `archive.getmembers()`；改成 `for member in archive:` 逐一讀，邊讀邊累加檔數與宣告大小，一超過 `max_files`／`max_bytes` 就立刻 raise；並拒收 `member.name` 長度超過例如 4 KB 的項目。另把傳給 `tarfile.open` 的檔案物件包一層「讀超過 N bytes 就中止」的計數器，限制列舉階段解壓出來的總量。離線更新包的呼叫端（不在本棒範圍）應先驗簽章再解壓。

**runner 開檔核對**：已開 `archive_safety.py:165-209` 確認：tar 分支 `getmembers()` 確實在 `check_archive_quota` 之前，中間沒有任何計數或上限。成立。

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

| 卡片要點 | 人工結果 | 為什麼 |
|---|---|---|
| `archive_safety.py:130-146` 缺 sha256 的兩種處置；profile 有沒有走「略過」；網址型來源會不會正好走進略過 | **不成立（對 profile）** | `verify_checksum` 的 `required=True` 會中止（`:139-144`）。profile 兩條入口都在下載前先擋缺 sha：派工路徑 `profile_cache.py:159-164`、心跳預拉路徑 `profile_cache.py:285-287`，下載後再以 `required=True` 驗一次（`:341`）。網址型 profile 在 `resolve_profile_source` 直接被當資料錯誤拒收（`profile_cache.py:119-131` docstring 與型別檢查），所以不存在「網址型走進略過」。**旁註**：「略過只記 log」那條路仍存在，是給源碼包用（`required=False`），不在本棒範圍，建議源碼包那棒確認主系統是否一定帶 sha。 |
| `:185-198` 解壓的路徑逃逸怎麼擋；tar 與 zip 是否同一套；總大小／檔數上限怎麼算 | **路徑逃逸：不成立；上限：部分成立（即 F1）** | zip 用 `ZipFile.extract`（標準庫會去掉 `..` 與開頭的 `/`）；tar 用 `extract(..., filter="data")`（Python 3.11.4 以上的官方安全過濾，擋 `..`、絕對路徑、逃逸 symlink、裝置檔）。兩種格式不是同一套程式碼，但各自走標準庫的安全機制。上限：解壓前加總「宣告」大小與檔數，解壓後 `verify_extracted_size`（`:232-253`）再量實際用量——但實際用量是**全部解完才量**，所以「宣告說謊」的檔會先把磁碟寫到超量才被發現（上限是 `_EXTRACTED_MAX_MB`，有上界，不至於無限）。列舉階段沒上限就是 F1。 |
| `profile_cache.py:29-30` 完成標記；cache 路徑由誰決定、uid 有沒有過濾；同一 uid 不同租戶會不會互相命中 | **不成立** | 路徑是 `build_cache_dir`（`profile_cache.py:389-395`）組的，uid 與 version 都經 `_safe_path_segment`（`:497-505`）：只留英數與 `-_.`、去頭尾的 `.`，帶 `../` 的 uid 會被換成底線，逃不出 cache 根。Agent 是單租戶（一台 Agent 固定一個客戶，見 CLAUDE.md），沒有「不同租戶共用 cache」的情境。另外命中判斷要求完成標記裡的 sha256 跟這次派工給的一致（`:463-477`），就算 uid 撞到也拿不到不同內容。 |
| `profile_sync.py:162-168` 回報的 sha 是自己算的還是抄主系統的；錯誤訊息會不會帶出本機路徑 | **sha：抄主系統的（低風險、非漏洞）；路徑外洩：部分成立（輕微）** | `_sha(profile)`（`profile_sync.py:202`）是讀心跳給的值，不是本機重算。但本機在寫完成標記前已經用 `required=True` 對帳過（`profile_cache.py:339-342`），對不上就失敗、不會回報成功，所以回報「OK＋sha X」時本機內容確實等於 X，回報的值並不失真。錯誤訊息：`error = f"{exc}（...）"`（`:153`）會把例外原文（截 500 字）寫進稽核 log 與回報列，例如 `OSError` 原文會帶本機 cache 路徑。這是送回自家主系統、不是給外人，影響很小；若要收緊，可對照規則「錯誤只回代碼」改成只回錯誤種類。**本棒工具沒報，列為人工觀察，不算正式發現。** |
| 下載 `GET /api/1.0/agents/files/<uid>`：Agent 有沒有驗 uid 屬於這次任務，還是任何 uid 都拉 | **不成立（在 Agent 這側）** | uid 只來自主系統下發的派工 `_profile` 或心跳清單，Agent 不接受本機使用者輸入的 uid，主控台沒有「任意 uid 下載」入口（本棒範圍內的呼叫者只有 `profile_sync._sync_one` 與 `resolve_profile_source`）。Agent 是拿主系統的檔回自己用，不是替別人轉檔，所以不構成「讀檔代理」。「主系統是否只允許這台 Agent 拉屬於它的檔」是主系統 `agents/files` 端點的權限檢查，不在本 repo。 |
| 解壓後交給 cinc-auditor 的目錄內容是否與驗過的那份一致 | **成立（一致）** | 順序是下載 → sha256 對帳 → 解壓到暫存目錄 → 寫完成標記 → `os.replace` 原子換上（`profile_cache.py:322-372`），失敗就整包清掉；命中時再比一次標記 sha。交出去的是 `resolve_profile_root(cache_dir)`，即同一個目錄。殘留風險：cache 目錄本身若被本機有寫入權限的人改，命中判斷只看標記不重算內容——這需要已能寫 Agent 資料目錄，屬本機權限問題，不另列。 |
| （附帶）規則寫「cinc 規則包設定含樣板記號 `<%` 即拒，擋在 `archive_safety.py` 共用層」 | **規則與程式不符（建議另查）** | 在 `guidant_agent/shared/` 與 `profile_cache.py` 都找不到 `<%` 的檢查。可能實作在別處（例如 scan 模組），也可能沒做。本棒範圍外，不能下結論，建議首腦另開卡確認，或更正規則文字。 |

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

- **「這些存在嗎」**：F1 存在。三個獨立的檢查員分別從「打不打得到」「後果多大」「有沒有別的防線擋住」三個角度看，3 票全數認定成立，runner 也開檔確認了行號與順序。
- **「只有這些嗎」**：不能保證。這是 `low` 強度掃描：一位研究員讀完三個檔，沒有做威脅建模或廣度補掃。上面第 5 節由 runner 補了卡片列的六個重點，但工具本身有隨機性，沒報不等於沒有。另外 `candidates: 1` 代表只有一條候選送去投票，不是「三個檔只被看出一條就乾淨」，而是研究員只提出了這一條。

## 7. 執行概況（照 stamp 抄）

| 項目 | 值 |
|---|---|
| run ID | `wf_01029f1f-285` |
| 掃描版本 | `3813abd05232860faa658d681b37cb85a20168b8`（branch `ws-CM-3507-CM-3511`，工作目錄乾淨） |
| 強度 | low（一位研究員＋三位檢查員） |
| status | `verified` |
| reason_kind | `null` |
| candidates | 1 |
| panel_votes | 3（三個檢查員對 1 條發現各投一票，3 票全數投出，沒有漏投） |
| researchers_dispatched／returned | 1／1 |
| 耗時 | 約 36 分鐘（stamp `duration_s: 2139`） |
| 歸檔 | `docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-155939-A4/`（原目錄名與 A2 同秒撞名，加 `-A4` 後綴區分） |
