---
title: D2-3 檢查結果：解析與外抓（jedi-detection）
---

# D2-3 檢查結果：解析與外抓（jedi-detection）

> 檢查日期 2026-09-19｜對應卡片 CM-1874（D2 第 3 小棒）｜檢查範圍 4 個檔案 1,677 行

## 🔴 一句話結論

**找到 2 條，最嚴重是高風險：客戶上傳的掃描規則包會被當成程式碼執行，整台後端主機等於交出去。**

系統把使用者上傳的壓縮檔原封不動交給外部程式 `cinc-auditor` 解析，而那支程式讀設定檔 `inspec.yml` 時，**會先把檔案內容當 Ruby 樣板跑一遍再解析**。上傳驗證器只檢查壓縮檔的「形狀」不看「內容」，所以一份檔名正常、結構正常、放了 `inspec.yml` 的惡意包**一道防線都不會碰到**。得手之後拿到的是後端行程的全部權限：`.env` 裡的資料庫／JWT／Redis 三把密鑰、以繞過租戶隔離的管理員身分讀寫**全部客戶**的資料、代理程式與檔案儲存的憑證，以及往內網橫向移動的跳板。**一個租戶管理員就能打下整台主機與全部客戶資料。**

第二條是中風險：**網址型來源整個跳過壓縮檔驗證器**。防炸彈的三道上限只裝在「上傳檔案」那條路，「填網址讓平台去抓」那條路只檢查開頭是不是 `https://` 就放行，下載回來直接解開，可以把後端記憶體吃光。這與 D2-1b 那條是同一個病的兩種長法——**同一套防護，一條路有、另一條沒有**。

卡片指定的重點②（外抓呼叫端有沒有用對）與③（外部程式子程序）工具都沒有單獨報，我逐項開檔查證：**②呼叫端全部用對，五道防線一道沒繞**；**③子程序的參數組法、逾時、暫存檔清理、執行身分全部正確**——唯一的問題不在「怎麼啟動這支程式」，而在「這支程式拿到檔案之後做了什麼」，也就是 F1。

## 這一棒在檢查什麼

客戶要做弱點掃描，得先給系統一包「掃描規則」。給法有兩種：**上傳一個壓縮檔**，或**填一個網址讓平台自己去抓**。系統拿到之後要把裡面的規則一條一條讀出來存進資料庫，這個動作叫「抽取」，實際執行的是一支叫 `cinc-auditor` 的外部程式（業界標準的合規檢測工具）。

這一棒看的就是「拿到規則包之後到存進資料庫之前」這一整段，四個檔案：

1. **`detection_profile_extraction_service.py`（692 行）** — 抽取管線的主幹。決定什麼時候開始抽、開背景執行緒、網址型的話負責下載、抽完落庫、失敗怎麼收。
2. **`profile_extractor/inspec.py`（433 行）** — 真正跟 `cinc-auditor` 打交道的那一支：把壓縮檔重新打包成工具吃得下的格式、落成暫存檔、啟動子程序、讀回 JSON、轉成控制項清單。
3. **`profile_extractor/base.py`（95 行）** — 抽取器的介面約定（「吃 bytes、吐結果、不准拋例外」）與結果的資料形狀，不含任何格式細節。
4. **`safe_http_fetch.py`（457 行）** — 網址型來源的下載器，含五道防「被拿去打內網」的防線（SSRF 防護）。**這支的五道防線 D3-1 那棒已經核過了**，所以這一棒不重驗它本身，改看**呼叫端有沒有用對**。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，revision `955e40964baa`（branch `feature/review`，工作區有未追蹤的 `.claude/`、程式碼乾淨），mode `scan`，effort `low`，範圍 4 檔 1,677 行。

## Coverage

`low` 強度：一位研究員把這 4 個檔案讀完就提報候選，未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描，`completenessCheckOutcome` 為 `not-applicable`（低強度本來就不跑盤點，而且範圍是指定檔案不是整棵樹）。驗證跑了 **1 輪**，2 個候選去重後仍是 2 個，**6 票全數投出**，沒有候選遺失、沒有被略過、沒有嚴重度被降低、沒有候選被交到下一輪。

**研究員零重試**——派 1 位、回 1 位，沒有任何一位被工具判定停滯而砍掉。這是 D2 四小棒裡跑得最順的一次（對照：D2-1 六位全滅、D2-1a 四位陣亡三位）。

研究員為了證明可達性另外讀了範圍外的幾處（基準 route、基準服務、壓縮檔驗證器），以及**本機安裝的 `cinc-auditor` 套件原始碼**，那些只當佐證、沒有納入稽核範圍。

研究員沒有回報「哪些檔案沒讀完」的自述（`coverage.research` 為 null），所以工具端沒有做讀取完整度的交叉檢查——4 個檔案不多，這個缺口不大，但它不是量測過的保證。

**工具沒有實際執行任何專案程式碼**：沒跑測試、沒發請求、沒造惡意壓縮檔、沒示範攻擊，所有判斷都是讀原始碼推出來的。唯一的例外是首腦核對 F1 時**開了本機安裝的 `cinc-auditor` 原始碼**（`/opt/cinc-auditor/embedded/lib/ruby/gems/3.4.0/gems/inspec-core-7.1.7/lib/inspec/metadata.rb`）確認那一行確實存在——那是讀檔，沒有執行任何東西。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - F1（規則包被當 Ruby 樣板執行）＝M03 第 1 條，✅ 已修（CM-2057）。
> - F2（網址型來源跳過壓縮檔驗證）＝M03 第 3 條，✅ 已修（CM-2057）。

## 掃到什麼：總覽

| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| F1 | 上傳的規則包會被外部程式當 Ruby 程式碼執行 | 後端主機被完全控制：三把密鑰外洩、繞過租戶隔離讀寫全客戶資料、內網跳板 | 租戶管理員身分 ＋ 自製壓縮檔（或一個自己的網址） | `inspec.py:167` | **HIGH** |
| F2 | 網址型來源完全跳過壓縮檔驗證器，可用解壓炸彈打爆記憶體 | 後端行程被系統砍掉或長時間無回應，全平台所有客戶一起中斷 | 租戶管理員身分 ＋ 一個自己的 https 網址 | `inspec.py:399` | MEDIUM |

零駁回——兩條候選全部三票通過，沒有越界候選。

## Findings

### F1 — 上傳的規則包會被 `cinc-auditor` 當成 Ruby 樣板執行（HIGH，confidence medium）

**這是什麼問題。** 系統把使用者給的壓縮檔重新打包之後，交給外部程式處理：`cinc-auditor json <檔案路徑>`。問題不在這個啟動動作——它寫得很乾淨（參數用陣列不是字串、沒有 `shell=True`、有逾時上限）——**問題在被啟動的那支程式拿到檔案之後做了什麼**。

`cinc-auditor` 讀一份規則包時，第一件事是讀裡面的 `inspec.yml`（規則包的身分證，寫著名稱、版本、支援平台）。它的讀法是：

```ruby
res.params = YAML.load(ERB.new(content).result)
```

`ERB` 是 Ruby 的樣板引擎。**`ERB.new(content).result` 的意思是「把這個檔案的內容當程式碼跑一遍，把跑出來的結果當成 YAML」**。所以只要 `inspec.yml` 裡面寫了樣板標記包起來的指令，那個指令就會在讀設定檔的當下被執行。

這行程式碼我開本機安裝的套件原始碼核對過，確實存在：`inspec-core-7.1.7/lib/inspec/metadata.rb:259`。

**而系統這一側完全沒有任何一道防線在看檔案內容。** 壓縮檔驗證器自己在檔頭就寫明「只驗結構不驗語意」——它檢查的是：副檔名對不對、有沒有超過 50MB、magic number 像不像壓縮檔、裡面的檔名會不會解出去寫到別的目錄、有沒有 symlink、entry 數量與解壓比例有沒有超標、有沒有一個叫 `inspec.yml` 的檔。**沒有一項在看 `inspec.yml` 裡面寫了什麼。**

**出事會怎樣。** 指令會以後端應用程式行程的身分執行。那個身分拿得到：

- **`.env` 檔**：`DB_SECRET`（資料庫帳密）、`JWT_SECRET`（簽發登入憑證用的密鑰，拿到就能偽造任何人的登入）、`REDIS_SECRET`
- **資料庫的 `cmmgr` 帳號**：這個帳號是設計來繞過租戶隔離的系統管理員，拿到它等於**讀寫全部客戶的全部資料**
- **檔案儲存（MinIO）與代理程式的憑證**
- **內網位置**：後端主機能連到的每一台機器，都成了下一步的目標

也就是說，**一個租戶管理員可以取得整台後端主機，以及平台上全部客戶的資料**。這是這個 arc 到目前為止影響面最大的一條。

**怎麼打（具體走一遍）。** 攻擊者是某個客戶的管理員，在畫面上做的事完全正常：

1. 造一個 tar.gz，裡面放一個 `inspec.yml`，內容除了正常的 `name:` 之外，多一行**樣板標記包起來的 shell 指令**——例如把 `.env` 編碼後送到自己的伺服器。
2. 其餘檔案照常放（要不要放真的規則都行）。
3. 到「檢測基準」頁面建一支新基準，上傳這個檔。
4. **上傳驗證全部通過**：它是合法的 tar.gz、沒超過 50MB、magic 對、檔名都正常沒有 `..`、沒有 symlink、entry 數與壓縮比正常、頂層有 `inspec.yml`。
5. 建立成功後**系統自動排一次抽取**（不需要任何人再按什麼）。背景執行緒把檔案重新打包成暫存檔，啟動 `cinc-auditor json /tmp/xxx.tar.gz`。
6. `cinc-auditor` 讀 `inspec.yml` 時，樣板引擎執行了那行指令。

**網址型更省事**：把同一份檔案放在自己的 https 伺服器上，建基準時選「網址」填進去即可——連上傳都不用。

**要先有什麼才打得到。**
- 攻擊者得是**已登入、且持有「新增檢測基準」能力點的使用者**（`detection-profile.create`）。這顆是客戶層能力點（`is_platform=false`），**租戶管理員就有**——這是刻意的設計（客戶要能管自己的基準庫），不是設定錯誤。
- 該客戶的授權要包含弱點掃描模組。
- 後端主機裝了 `cinc-auditor`。專案文件記載四台機器都裝了；本機也裝了（這次核對就是開它的原始碼）。沒裝的話抽取只會落 `failed`，打不到。
- **不需要任何人配合**。抽取在建基準、版更、改來源、手動重抽之後都會自動觸發。

**在哪裡。**
- 缺口本體：`jedi-detection/jedi_detection/common/profile_extractor/inspec.py:167` —— `subprocess.run([cmd, "json", path], ...)`，把使用者內容交給會求值的工具
- 外部工具求值那一行：`/opt/cinc-auditor/embedded/lib/ruby/gems/3.4.0/gems/inspec-core-7.1.7/lib/inspec/metadata.rb:259` —— `YAML.load(ERB.new(content).result)`
- 內容原封不動的證據：同檔 `:330-358` `_repack_flat()` —— 只改路徑前綴、時間戳、權限位元，**每個 entry 的 bytes 原樣寫回**（`tf.addfile(info, io.BytesIO(data))`）
- 上傳這條路的入口：`detection_profile_route.py:184`（新增基準）、`:359`（改某版來源）、`:410`（版更）、`:542`（重新抽取）
- 網址那條路的入口：`detection_profile_service.py:946-956` —— 只檢查 `http://` / `https://` 前綴
- 驗證器自陳「不驗語意」：`detection_profile_archive.py` 檔頭

**怎麼修。** 兩件事，都建議做：

1. **在交給工具之前，先自己看一眼 `inspec.yml` 的內容。** 抽取管線本來就已經把每個 entry 的 bytes 拿在手上了（`_read_entries()` 回的就是 `[(檔名, bytes), ...]`），在重新打包之前多一步：找出 `inspec.yml`，**若內容含 ERB 樣板標記（`<%`、`<%=`、`<%#`）就直接拒收**，回一個明確的錯誤訊息。更嚴格的做法是自己用安全的 YAML 解析器（`yaml.safe_load`）讀出 metadata，只把規則檔交給工具——但那要確認 `cinc-auditor` 吃不吃「沒有 `inspec.yml` 的規則包」，成本較高。**先擋樣板標記是最小可行的修法**，落點就在 `_repack_flat()` 裡面，一處改完兩條路（上傳與網址）一起好。
2. **把 `cinc-auditor` 關進沙箱跑。** 獨立容器或低權限使用者、唯讀檔案系統、不給網路、限制記憶體與 CPU。這件事的價值不只擋這一次——它讓「這支外部工具未來又冒出什麼求值行為」從致命變成有界。第 1 點擋的是已知的這一條，第 2 點擋的是還不知道的下一條。

⚠️ **修的時候注意**：兩條來源（上傳、網址）**必須套同一道檢查**。只在上傳那條路加，網址那條路照樣打得進來——這正是 F2 的病因，不要重蹈。落在 `_repack_flat()` 或 `extract()` 裡面就自然涵蓋兩條，落在 `detection_profile_service.py` 的上傳分支就會漏。

**驗證。** 3/3 三位檢查員確認成立（可達性、影響、既有防護三個角度），嚴重度 HIGH 未被調整。三位各自獨立追了完整路徑：可達性那位從 route 的 multipart 參數一路追到暫存檔、確認內容全程未被改寫；影響那位確認能力點是 `is_platform=false`（租戶管理員即可）且抽取自動觸發；防護那位逐一檢查了路徑上每一道關卡，確認**沒有一道在看檔案內容**。

**為什麼是 HIGH 不是 CRITICAL：** 要先是登入的租戶管理員，不是外面的人打一個網址就中——那一道是真實的門檻。但門檻只有這一道，過了之後沒有第二道，且拿到的是整台主機，所以是 HIGH 不是 MEDIUM。

**為什麼可信度是 medium 不是 high：** 沒有實際造檔驗證過。結論建立在兩件讀出來的事實上——系統把內容原樣交出去（讀專案程式碼）、工具會把內容當樣板跑（讀工具原始碼）。兩件都核對過，但**沒有人真的送一份惡意檔進去看它會不會執行**。要升到 high 就得實測，而實測要在隔離環境做（見下方手測清單）。

**首腦核對註記。** 屬實。我開了本機 `/opt/cinc-auditor/.../metadata.rb:259`，`YAML.load(ERB.new(content).result)` 一字不差。也核對了 `_repack_flat()` 確實原樣搬 bytes（只動 `mtime`／`mode`／路徑前綴），以及驗證器檔頭的「只驗結構不驗語意」自陳。**與前面棒次不重複**：D2-1b 查的是同一支驗證器的**數量上限**問題，這條是**內容**問題，兩條在同一個模組但不是同一件事。淨新增 1 條，登記跨 arc 總表。

---

### F2 — 網址型來源完全跳過壓縮檔驗證器，解壓炸彈可打爆記憶體（MEDIUM，confidence medium）

**這是什麼問題。** 壓縮檔驗證器有三道防「解壓炸彈」的上限：最多一萬個檔、解開後總量最多 500MB、壓縮比最多 200 倍。**但這支驗證器全專案只有一個呼叫點**，而且在「上傳檔案」那個分支裡面。

「填網址」那個分支長這樣：檢查字串開頭是不是 `http://` 或 `https://`，是就存起來，回傳。**沒有別的了。** 之後背景執行緒把網址交給下載器抓回來，下載回來的 bytes **直接進解析器**，三道上限一道都沒經過。

下載器本身有一道 50MB 上限，但那管的是**壓縮之後的傳輸量**——一個 45MB 的壓縮檔解開變成幾十 GB 是完全做得到的（高度重複的內容壓縮比可以上千倍）。而解析器讀 tar 的寫法是逐筆 `fh.read()` 把內容累積進一個 list，**沒有任何累計上限**。

**出事會怎樣。** 後端行程的記憶體被吃光，被系統的 OOM killer 砍掉，或長時間卡住沒有回應。後端是所有客戶共用的，所以**一個客戶的管理員可以讓全平台一起中斷**。

更糟的是可以疊加：排抽取的那支方法每收一次請求就無條件開一條新的背景執行緒（`schedule()`，`extraction_service.py:167-173`），**沒有併發上限、也沒有「這一版已經在跑就別再排」的判斷**——連續建幾個版本就有幾條執行緒同時在吃記憶體。（⚠️ 這個併發問題 **D2-1a 那棒已經報過**，是那棒兩條發現裡的第二條；這裡只是指出它會放大 F2 的後果，**不另計為新發現**。）

**怎麼打（具體走一遍）。**
1. 攻擊者在自己的 https 主機上放一份約 45MB 的 tar.gz，裡面裝高度重複的填充內容，解開後總量數十 GB，另外放一個頂層 `inspec.yml`（讓後續的結構檢查過得了）。
2. 到「檢測基準」建一支新基準，來源選「網址」，填自己那個網址。
3. 後端背景執行緒去抓：**通過 50MB 傳輸上限**（壓縮後只有 45MB）。
4. 解析器逐筆把成員讀進記憶體，數十 GB 灌進去，行程被砍。

**要先有什麼才打得到。**
- 攻擊者得是**已登入、且持有「新增檢測基準」能力點的租戶管理員**（同 F1）。
- 需要一個**能公開連到的 https 網址**。SSRF 防線擋的是私網／本機／保留位址，**公網位址一律放行**——這是決策者裁定的設計（不做網域白名單），不是缺陷。
- 不需要任何人配合，建版之後自動觸發。

**在哪裡。**
- 缺口本體：`inspec.py:399` —— `out.append((_normalize_name(member.name), fh.read()))`，逐筆讀進 list 無累計上限（zip 那條路 `:384` 同理）
- 驗證器唯一的呼叫點：`detection_profile_service.py:941` —— 在 `normalized == SOURCE_FILE` 分支裡面
- 網址分支放行的地方：同檔 `:946-956` —— 只驗 `http(s)://` 前綴就 return
- 下載回來直接進解析器：`extraction_service.py:413-419` —— `_download()` 拿到 raw 就 `extractor.extract(raw, file_name)`
- 只管壓縮後大小的那道上限：`safe_http_fetch.py:73` `DEFAULT_MAX_MB = 50`
- 併發無上限（放大後果，D2-1a 已報）：`extraction_service.py:167-173`

**怎麼修。** 建議**改在解析器裡面**而不是補在服務層：

- 在 `_read_entries()`（`inspec.py:361`）加上**累計解壓量上限**與 **entry 數上限**，超限就中止、回 `ExtractionResult.failed`。
- **為什麼選這裡而不是「網址分支也呼叫一次驗證器」**：驗證器吃的是 Flask 的 `FileStorage` 物件，網址這條路拿到的是 bytes，要重用得先包一層轉接，而且**未來任何繞過服務層直接呼叫解析器的新程式碼還是會漏**。防護長在解析器裡面，兩條來源、以及未來的第三條，天然都涵蓋。
- 上限值直接沿用驗證器那三個常數即可（一萬個檔／500MB／200 倍），維持兩邊語意一致。

⚠️ 這條與 D2-1b 那條**是同一個模組的兩種病**，但修法不同、不要混為一談：D2-1b 是「上限存在但太晚生效」（zip 開檔就展開清單），這條是「上限根本沒裝在這條路上」。兩條都修完，才算兩條來源都有完整的炸彈防護。

**驗證。** 3/3 三位檢查員確認成立，嚴重度 MEDIUM 未被調整。三位都用同一個方式確認：全專案搜尋驗證器的呼叫點，**確認只有一處、且在 file 分支裡**——這不是「推測沒有防護」，是「查過了確實只有一個呼叫點」。

**為什麼是 MEDIUM 不是 HIGH：** 打不到任何資料（不外洩、不竄改），只會讓服務倒掉；而且要先是租戶管理員。

**首腦核對註記。** 屬實。我自己跑了一次全套件搜尋 `ProfileArchiveValidator`，命中只有三類：服務層的 import／建構／那一個呼叫點（`:59`／`:115`／`:941`），以及三處註解引用。**確實只有一個呼叫點，確實在 file 分支裡**。也開檔確認了網址分支（`:946-956`）從頭到尾只有 `startswith(("http://", "https://"))` 這一個檢查。淨新增 1 條，登記跨 arc 總表。

## 卡片重點逐項人工查證

卡片指定本棒要看重點②（SSRF 受限下載的呼叫端）與③（外部程式子程序）。工具兩條都沒有單獨提報，以下全部是**人工開檔查證**（標「（工具未報，人工查證）」者即此）。

### ② 外抓：呼叫端有沒有用對 —（工具未報，人工查證）✅ 全部用對

卡片寫明「`safe_http_fetch` 五道防線 D3-1 已核過，這棒看**呼叫端有沒有用對**」，所以我不重驗防線本身，只查呼叫關係。

**全套件只有兩個呼叫點，都在本棒範圍內的抽取服務裡：**

| 呼叫點 | 用哪支 | 做什麼 |
|---|---|---|
| `extraction_service.py:493` | `fetch_url_bytes(url)` | 背景抽取時真正下載內容 |
| `extraction_service.py:337` | `probe_url_validators(url)` | 開明細頁時輕量探測「來源變了沒」 |

**逐項查證結果：**

- **沒有任何地方繞過這支去自己發請求。** 全套件搜尋 `httpx` / `requests` / `urlopen`，**除了 `safe_http_fetch.py` 自己之外零命中**。也就是說「讓後端去打一個外部網址」這件事，全套件只有這一個入口。**這是最重要的一項**——防線再好，只要旁邊有第二條沒防護的路就全白做。
- **兩支都走同一套防線。** 探測那支（HEAD）在自己的 docstring 寫明理由：「若這裡只做個簡化版檢查，攻擊者只要挑會用 HEAD 的那條路徑就繞過了全部防護」。實查程式碼屬實——`probe_url_validators()` 的迴圈與 `fetch_url_bytes()` 結構完全一致，每一跳都呼叫同一支 `_validate_and_pin()`、同一支 `_open()`，**唯一的差別是動詞 GET／HEAD 與「沒有大小上限」（HEAD 本來就沒有 body）**。
- **兩個呼叫點都用預設參數，沒有一個放寬上限。** `fetch_url_bytes(url)` 沒傳 `max_mb`／`timeout_sec`／`max_redirects`，吃的就是 50MB／60 秒／3 跳；`probe_url_validators(url)` 同理，吃 5 秒／3 跳。**沒有呼叫端偷偷把上限調大。**
- **兩支的「不可在 DB session 內呼叫」紅字，呼叫端都遵守了。** 下載那支跑在 `_run_worker()` 的中段，程式碼註解明寫「全部在 session 之外」，前後各自包 `session_scope()`（`:404-419`）；探測那支的呼叫點在 `_check_source_freshness()`，它是 `@staticmethod` 且不碰 DB，caller 側也標了紅字。**這不是資安問題是可用性問題**（握著連線等外部網站 60 秒等於把連線池的命交給別人），但既然自陳了就一併核，**結論是做到了**。
- **錯誤訊息沒有把內網資訊漏給前端。** `_download()`（`:492-504`）只把 `SafeFetchError.message`（我們自己寫的中文）落進 `extraction_error`，httpx 的原始例外與目標 IP 留在 log 裡；下載器那一側也明寫「不把 httpx 的訊息帶進使用者看得到的字串——它含內部位址與連線細節」。**探測那支更保守**：失敗一律吞掉回 `False`（判「無法判定」而不是「有變更」），連錯誤都不回顯。**判定：沒有透過錯誤訊息做內網探測的縫隙。**
- **探測回給前端的是什麼**（卡片特別問「`probe_url_validators` 回給前端的內容會不會把內網回應帶出來」）：**只回兩個 HTTP header**（`ETag`、`Last-Modified`），`_read_validators()`（`:217-233`）只取這兩個鍵，**不碰 body**（HEAD 本來就沒 body）。而且這兩個值也不是直接回給前端——呼叫端拿它們跟資料庫記的舊值**比對，只回一個布林**「變了沒」。**判定：沒有內容外洩的路徑。**

**順帶核了一項 D3-1 沒特別提的：下載完成的 log 會印 `url` 與 `etag`**（`:499-503`）。網址是使用者自己填的、ETag 是對方伺服器給的，**都不含憑證**（這條路本來就不代管憑證，服務層註解寫明「平台不驗證可達性、不代管憑證」）。**判定：不構成第 22 項（密碼原文進 log）的同題。**

**② 的總結論：呼叫端全部用對，五道防線一道沒被繞過，也沒有第二條沒防護的外連路徑。**

### ③ 外部程式子程序 —（工具未報，人工查證）✅ 啟動方式全部正確，問題在被啟動的程式（＝F1）

卡片列了六個小問題，逐一回答：

**a) 參數怎麼組，有沒有 `shell=True`、路徑可控？** —— **組法正確。**
`subprocess.run([cmd, "json", path], capture_output=True, timeout=self._timeout)`（`:167-171`）。用**參數陣列**不是字串，**沒有 `shell=True`**，所以就算檔名含分號、反引號、`$()` 都不會被殼層解讀。

`path` 完全不受使用者控制——它是 `tempfile.NamedTemporaryFile(suffix=".tar.gz")` 產生的隨機路徑（`:152-154`），使用者給的原始檔名只用在 log 與錯誤訊息裡。

`cmd` 也不受使用者控制：`resolve_cinc_cmd()`（`:113-126`）在一份**寫死的候選清單**裡挑——絕對路徑要檔案存在、相對名稱要 `shutil.which()` 找得到，都找不到就回清單第一個（讓它以 `FileNotFoundError` 明確失敗）。清單來源是常數不是設定值。⚠️ 唯一的理論前提是 `PATH` 乾淨——但能改後端行程 `PATH` 的人已經在主機上了，不構成新的攻擊面。

**b) 逾時。** —— **有，900 秒**（`_DEFAULT_TIMEOUT_SEC = 900`，`:85`）。`subprocess.TimeoutExpired` 有接、轉成給使用者看的中文 failed（`:175-179`）。實例是模組載入時建的單例（`profile_extractor/__init__.py:38`），**沒有任何呼叫端傳自訂 timeout**，所以實際值就是 900 秒。⚠️ 900 秒偏長（文件記載正常抽取 28～117 秒），**它本身不是漏洞**，但它是 F2 那類資源耗盡的放大器——一條卡住的抽取會佔住執行緒 15 分鐘。建議修 F2 時一併考慮調短。

**c) stdout 上限。** —— **沒有上限**（`capture_output=True` 會把子程序的 stdout 全部收進記憶體）。但**這不構成獨立問題**：輸出是 `cinc-auditor` 自己產生的 JSON，大小由規則包的規則數決定，而規則包本身已經被上限管住了（F2 修好之後更是）。錯誤訊息那一側**有**截斷（`_MAX_ERROR_CHARS = 2000`，`:189`），避免把工具的長篇 stderr 整包落進資料庫欄位。**判定：不另計為發現**，但修 F2 時可順手加一個保守的 stdout 上限。

**d) 暫存目錄怎麼清。** —— **清得正確。**
`try/finally` 保證刪除（`:156-161`），**`finally` 在 `_run()` 之外層**，所以子程序逾時、崩潰、丟例外都照樣刪。刪不掉只寫 warning 不讓整次抽取失敗（合理：留一個暫存檔比讓使用者看到失敗好）。用 `NamedTemporaryFile(delete=False)` 建檔，**檔名隨機、權限預設 0600**（只有行程自己讀得到），沒有用可預測的固定路徑，**沒有 symlink 攻擊的縫**。
⚠️ 一個小觀察：`delete=False` 之後手動刪，若行程在寫檔與 `finally` 之間被 SIGKILL，暫存檔會留在 `/tmp`。這是**磁碟殘留不是安全問題**（內容就是使用者自己上傳的東西、權限 0600），不計。

**e) `_run_worker` 是背景執行緒還是排程？用什麼身分？** —— **背景執行緒，身分有正確傳遞。**
`schedule()`（`:154-175`）開的是 `threading.Thread(daemon=True)`，不是排程器。**身分的傳遞是對的**：呼叫端在 HTTP 脈絡裡 `get_user_context()` 捕捉（`:166`），worker 進去第一件事 `set_user_context(user_ctx)` 還原（`:399-400`）。這很重要——**沒傳的話背景執行緒就沒有租戶脈絡，資料庫的租戶隔離會拿不到 `app.user_id` 這些變數**，寫入會 silent fail 或寫錯租戶。`_current_login_name()`（`:665-667`）也是從還原後的脈絡取，用來填稽核欄位。
⚠️ 順帶確認：`schedule()` **刻意不加 `@transaction`**，docstring 寫明理由（worker 是另一條執行緒另一個 session，太早排會讀不到還沒 commit 的那一列，所以呼叫端必須在 commit 之後才排）。**這是對的**，而且這個「本方法不碰 DB」的前提我核過——`schedule()` 內確實只有捕捉脈絡與開執行緒。

**f) `_repack_flat` 重打包時檔名 `_normalize_name` 有沒有洗乾淨？** —— **有，而且重打包這個設計本身就是一道額外防護。**
`_normalize_name()`（`:424-426`）把反斜線折成斜線、去掉 `./` 前綴。更關鍵的是**重打包的方式**：`_repack_flat()`（`:339-358`）**不是解壓到磁碟再重壓**，而是在記憶體裡建全新的 `TarInfo`，只填 `rel`（去掉共同前綴後的相對路徑）、`size`、`mtime=0`、`mode=0o644`。
這意味著：**原始壓縮檔裡的 symlink、hardlink、device node、詭異權限位元、絕對路徑，全部不可能進到重打包後的檔案裡**——因為新的 entry 是程式自己造的，只有「一般檔案」這一種型別。讀 tar 那一側也明寫只收 `member.isfile()`（`:392-394`）。**判定：這一段是這個模組做得最紮實的地方**，路徑類攻擊在這裡天然斷掉。
⚠️ 但也正因為它**只重建「容器」不碰「內容」**（`tf.addfile(info, io.BytesIO(data))` 原樣搬 bytes），F1 那條才得以穿過——**路徑安全與內容安全是兩件事，這裡做到了前者、沒有後者。**

**③ 的總結論：子程序的啟動方式（參數、逾時、暫存檔、身分、路徑清洗）逐項查證全部正確，寫得相當謹慎。唯一的問題不在「怎麼啟動這支程式」，而在「這支程式拿到檔案之後做了什麼」——那就是 F1。**

### 附帶：`base.py`（95 行）查證 —（工具未報，人工查證）✅ 沒問題

這支是純介面與資料形狀，沒有可執行邏輯，查三點：

- **「實作不可 raise」的約定**（檔尾紅字）：理由寫明「呼叫端是背景執行緒，未捕捉的例外會讓那一版永遠卡在 `running`」。**實查 `inspec.py` 有遵守**——`extract()` 把重打包的例外收成 `failed`（`:145-148`），`_run()` 把三種子程序例外全部收成 `failed`，worker 最外層再包一層全捕（`:414-421`）。**三層都在，卡在 running 的路徑不存在。**
- **「靜默失敗」那一分支**：`cinc-auditor` 遇到規則檔語法錯誤時會 **exit 0、stderr 全空、輸出合法 JSON、但 controls 是空陣列**，整包規則被默默丟掉。這個模組把它當成第三種失敗明確處理。**這不是資安問題，是正確的可靠性設計**，值得記一筆。
- **`source_sha256` 與從檔 `sha256` 刻意不同名**（檔尾紅字）：前者是抽取器自算的內容雜湊，後者是原始壓縮檔 bytes 的雜湊、是代理程式對帳的信任根。**刻意取不同 key 名避免落庫時覆蓋**。**判定：正確，這種「兩個都叫 sha256 遲早有人寫錯」的預防值得肯定。**

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

分兩層講：

**「報出來的這兩條存在嗎」——F1 可信度中高、F2 可信度高。**

F2 我自己跑過全套件搜尋，**驗證器只有一個呼叫點、就在 file 分支裡**，這是可以直接查證的事實，不是推論；三位檢查員也是用同一個方式確認的。

F1 的兩個環節我都核對過原始碼——系統原樣交出內容（讀專案程式碼）、工具會把內容當樣板跑（讀本機安裝的 `cinc-auditor` 原始碼，行號一字不差）。**但沒有實際造一份惡意檔送進去看它會不會執行。** 理論上的殘餘不確定性是：`cinc-auditor json <tar.gz>` 這條路徑是否一定會走到 `from_yaml()` 那一支（該檔另有 `from_ruby()` 等分支）。三位檢查員都判定會，但**這一點只有實測能定論**。這就是為什麼它掛 `medium` 而不是 `high`。

**「只有這兩條嗎」——可信度中等，三個已知限制。**

1. **強度是 `low`**，一位研究員讀一遍，不是多位分工交叉讀。低強度設計目的是快速篩不是窮盡。
2. **重點②③的結論幾乎全是人工查的，沒有三票背書。** 工具只提了 2 條候選（都成立、零駁回），但它**一條都沒碰**卡片指定的兩個重點——②的呼叫端關係、③的六個子問題，全部是我開檔查的。這些「沒問題」的結論可信度低於 F1／F2 那兩條。
3. **這一棒跑得順，反而暴露另一件事**：零重試、6 票全投、1 小時 22 分跑完——但產出仍然只有 2 條，而報告裡大部分篇幅是人工查證。**這與 D2-1b、D3-4b 的觀察一致**：切棒保證的是工具跑得完，不保證它找得更多。

`verification.status` 為 **`verified`**：三位檢查員對 2 條候選各投一票，**6 票全數投出，沒有漏投**，票數由工具自己的程式碼統計、不是任何 agent 自報。

## 被駁回的候選

**這一棒零駁回**——兩條候選全部三票一致通過，也沒有出現越界候選（範圍外的檔案被當成本棒發現報上來）。

這值得記一筆：**D2-1b 與 D3-4b 都出現過研究員被鄰近大檔吸走的情形**（D2-1b 那棒範圍只有 2 檔，研究員仍跑去讀了三個套件外的檔案並報了一條越界候選）。這一棒沒有。可能的原因是本棒四個檔自成一條完整的鏈（下載 → 解析 → 結果形狀），研究員不必往外追就能下判斷——**與 D2-1a 那棒的觀察互相印證：含 route 的範圍會把研究員引向宿主層，自包含的模組不會。**

## 手測清單（給驗收用）

⚠️ **F1 的驗證必須在隔離環境做**（獨立的測試機或容器，不要在 DEV 以外的任何環境，更不要在 STG／POC）。這是會真的執行指令的測試。

**F1（規則包被當程式碼執行）：**
1. 在隔離環境造一個 tar.gz，裡面放一個 `inspec.yml`，除正常的 `name:` 之外加一行**無害的**樣板指令（例如寫一個檔到 `/tmp`，**不要**做任何對外連線或讀密鑰的事）。
2. 以租戶管理員身分建一支新檢測基準，上傳這個檔。
3. **預期現況**：上傳成功、抽取自動觸發，然後**去看 `/tmp` 那個檔案出現了**——出現就代表指令真的被執行了，F1 成立。
4. 修好之後重跑第 2 步，應該在抽取階段就回明確的失敗訊息（「規則包內含不允許的樣板語法」之類），且 `/tmp` 不會出現那個檔。
5. **對照組**：同一份檔案改成走「網址」型（放自己的 https 伺服器），確認修法兩條路都擋得住——**只擋上傳那條就是沒修好**。

**F2（網址型跳過炸彈防護）：**
1. 在隔離環境造一個約 45MB 的 tar.gz，內容用高度重複的填充（解開後數 GB 即可，不必真的做到數十 GB），另放一個頂層 `inspec.yml`。
2. 放到一個可連的 https 位置，以租戶管理員身分建基準、來源選「網址」填進去。
3. **預期現況**：下載通過（壓縮後 45MB < 50MB），接著**看後端行程的記憶體跳升**（`docker stats` 或 `ps`）——跳升就代表三道上限確實沒套到這條路。
4. **對照組**：把同一份檔案改成**上傳**，應該在上傳驗證階段就被「解壓總量超標」擋下，記憶體不會跳。**兩邊行為不一致，就是這條的本質。**
5. 修好之後重跑第 2 步，應該在解析階段中止並落 `failed`，記憶體不會失控。

## 執行概況

| 項目 | 值 |
|---|---|
| run ID | `wf_1ce16de4-05f` |
| 報告 | `docs/features/FR-108-2609-detection-security-scan/scan-D2-3-extraction-fetch.md` |
| 掃描 commit | `955e40964baa3cfc0e8766078db7d65075d3c50b`（jedi-detection，branch `feature/review`） |
| 範圍 | 4 檔 1,677 行 |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 1 位、回 1 位、**零重試** |
| 面板 | 2 條候選 × 3 位檢查員 ＝ 6 票，全數投出 |
| 耗時 | 約 82 分鐘 |
| 候選 → 成立 | 2 → 2（**零駁回**） |
| 驗證章 | `verified` |

## 這一棒的觀察（給後續小棒）

**① 「不含 route 就跑得動」這個假設再得一個樣本。** D2-1a 那棒提出的判讀是「檔案性質比行數關鍵，含 route 的範圍會把研究員引向宿主層」。本棒 1,677 行、四個檔、**不含 route**，結果零重試、82 分鐘跑完、零越界候選——**與該判讀一致**。D2-4（16 檔 1,609 行，同樣不含 route）可照原切法派。

**② 工具找到的與卡片要查的，是兩組不同的東西。** 卡片點名重點②③，工具一條都沒碰；工具報的兩條卻是卡片沒預期到的（F1 根本不在卡片的六個重點裡——卡片重點③問的是「參數怎麼組、timeout、暫存目錄」這些**啟動面**的事，而真正的洞在**被啟動的程式的行為**）。**這不是工具失職**，是「列清單」與「找洞」本來就是兩種活動。給後續：卡片重點仍要逐項人工查（它保證覆蓋），但**不要期待工具照清單走**。

**③ 同一個模組的第二條、第三條病，形狀會不一樣。** `detection_profile_archive.py` 這支驗證器現在身上有三條記錄：D2-1b 的「上限太晚生效」、本棒 F2 的「上限沒裝在這條路」、本棒 F1 的「上限管不到的維度（內容）」。**三條都是同一個防護模組，但沒有一條是同一件事。** 收口修正卡時要分開開，不要合併成「修一下驗證器」。
