---
title: D2-1b 檢查結果：壓縮檔封存驗證（jedi-detection）
---

# D2-1b 檢查結果：壓縮檔封存驗證（jedi-detection）

> 檢查日期 2026-09-19｜對應卡片 CM-1874（D2 第 1 小棒的後半）｜檢查範圍 2 個檔案 422 行

## 🔴 一句話結論

**找到 1 條中風險：防「壓縮檔炸彈」的那道「最多一萬個檔」上限，對 .zip 格式形同虛設——程式要等整份清單都讀進記憶體了才開始數，數到第一萬零一個喊停時記憶體早就吃掉了。** 我實測過：一個 49.7MB、裝 59.5 萬個空檔的 zip，光「打開」這個動作就讓記憶體多吃 **360MB**，連送幾次就能把後端工人打掛。要先是租戶管理員才打得到，所以不急。修法很小——打開之前先讀 zip 檔尾的目錄筆數欄位，超量直接擋。**同時要改掉那段寫反的註解**，它正是這個洞活下來的原因。

另外**推翻一項卡片上的錯誤前提**：卡片說「15 條基準 route 一顆能力點都沒掛」，實查**不成立**——18 支方法裡 10 支掛了能力點，而且掛的正好是全部的寫入類，讀取類不掛是合理設計。這個錯誤前提在開卡當下（`a6475b4`）就已經是錯的。

## 這一棒在檢查什麼

客戶要做弱點掃描，得先給系統一包「掃描規則」——實務上就是上傳一個壓縮檔（zip 或 tar）。系統收到之後不會馬上解開，而是先**隔著檔案檢查一遍**：這是不是真的壓縮檔、有沒有大到離譜、裡面的檔案會不會解出去寫到不該寫的地方。

這一棒只看這道關卡本身（兩個檔）：

1. **`detection_profile_archive.py`（347 行）** — 那道關卡的全部實作。它自己在檔頭寫明了做哪些檢查、也自己承認了界線（「炸彈檢查靠檔案自己宣告的大小，是宣告值不是實讀值，理論上可被偽造」）。**這一棒要驗的就是：自陳的防護真的成立嗎？自陳的界線之外有沒有真的路？**
2. **`detection_profile_ref.py`（75 行）** — 一支 75 行的格式約定：掃描規則下發給代理程式時，怎麼表示「這是規則庫裡的第幾號」而不是「使用者自己填的網址」。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection`，revision `955e40964baa`（branch `feature/review`，工作區乾淨），mode `scan`，effort `low`，範圍 2 檔 422 行。

## Coverage

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

研究員為了追資料流另外讀了範圍外的幾處（建構驗證器的基準服務、`safe_http_fetch`、`profile_extractor/inspec.py`，以及為了那條被駁回的候選讀了主專案的任務綁定處理器、編排服務、代理程式取檔服務），那些只當佐證、沒有納入稽核。

研究員沒有回報「哪些檔案沒讀完」的自述（`coverage.research` 為 null），所以工具端沒有做讀取完整度的交叉檢查。

**工具沒有實際執行任何專案程式碼**：沒跑測試、沒發請求、沒示範攻擊，所有判斷都是讀原始碼推出來的。唯一的例外是首腦核對時**在一個拋棄式的 Python 直譯器裡實測 CPython 的 zip 套件行為**（自己造一個合成壓縮檔、量打開它要吃多少記憶體）——那沒碰到專案的任何程式碼、也沒改任何狀態，純粹是為了把「理論上會」變成「實際數字」。

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - F1（zip 一萬個檔上限形同虛設）＝M03 第 4 條，✅ 已修（CM-2057）。

## 掃到什麼：總覽

| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| F1 | zip 的「最多一萬個檔」上限在記憶體吃完之後才生效 | 一次合法上傳就讓後端多吃 360MB，連送幾次把工人打掛、API 服務中斷 | 租戶管理員身分 ＋ 自製 zip | `detection_profile_archive.py:249` | MEDIUM |

被駁回 1 條（內部欄位沒過濾就寫進派工參數），**而且它整條都落在本棒範圍外**（任務綁定與編排服務），理由見下方。

## Findings

### F1 — zip 的 entry 數上限在記憶體已經配置完之後才生效（MEDIUM，confidence medium）

**這是什麼問題。** 程式想防的是「壓縮檔炸彈」——檔案本身很小，解開卻爆出天量內容。防線之一是「最多一萬個檔」，寫法是**一邊逐個掃、掃到第一萬零一個就喊停**（`:157-160`）。這個寫法對 tar 系是對的，因為 tar 天生就是一筆一筆往下讀。

**但 zip 不是。** zip 的檔案清單集中放在檔案尾端（叫 central directory）。Python 的 `zipfile` 套件在執行 `ZipFile(buffer)` 這一行——也就是「打開」這個動作——的建構子裡面，就會把**整份清單全部展開成記憶體物件**（CPython 的 `_RealGetContents()`）。等程式碼回到迴圈開始數第 1 個、第 2 個……數到第 10001 個喊停時，六十萬筆物件**早就全部躺在記憶體裡了**。

這道上限的設計目的是「別讓記憶體被吃爆」，結果它擋的只是「別讓後面的檢查跑太久」——**真正要防的那件事，在上限生效之前就已經發生**。

**出事會怎樣。** 我實測了（CPython 3.11.9，就是專案用的版本）：

| 檔案 | 裡面裝什麼 | 打開它要多吃多少記憶體 |
|---|---|---|
| 49.7 MB 的 zip | 59.5 萬個空檔案 | **+360 MB**（實測常駐記憶體增量） |

再加上讀檔那一段本來就會有兩份副本的峰值（`_read_all_with_limit()` 先把碎片收進 list、最後再 `b"".join()` 合成一份，`:199-215`），一次請求的記憶體高峰遠不只 410MB。預設是 4 個 gunicorn 工人（`main.py:265`），**幾個併發請求就足以把工人吃到被系統砍掉**，API 在那段期間對所有使用者都是慢或不通。

打完之後系統會回一個 HTTP 400「壓縮檔不安全」——**看起來像是被擋下來了，實際上錢已經付掉了**。這是這個洞最難察覺的地方：從日誌看起來防線運作正常。

**要先有什麼才打得到。**
- 攻擊者得是**已登入、且持有「新增檢測基準」能力點的使用者**（`detection-profile.create`，這顆是客戶層能力點 `is_platform=false`，租戶管理員就有）
- 該客戶的授權要包含弱點掃描模組（`@require_license("detection-profile")`）
- 上傳走 **.zip**——tar 系（`.tar` / `.tar.gz` / `.tgz`）不受影響，因為 `tarfile` 是逐筆迭代的

**檔案本身完全合法**：每個 entry 的檔名都是正常字元、沒有 `..`、沒有絕對路徑、沒有 NUL、沒有 symlink，所以四道 entry 安全檢查全部會放行；再放一個頂層 `inspec.yml` 連結構檢查都過得了。**沒有一道現有防線攔得住它**。

**在哪裡。**
- 缺口本體：`jedi-detection/jedi_detection/common/detection_profile_archive.py:249` —— `with zipfile.ZipFile(buffer) as zf:`，建構子當場展開整份清單
- 上限生效點（太晚）：同檔 `:157-160` —— `if entry_count > self._max_entries: raise`，迴圈跑到第 10001 圈才會到
- **寫反的註解**：同檔 `:145-148` —— docstring 明寫「**不先 materialize 成 list**：50MB 的壓縮檔可以在 central directory 塞進上百萬個 entry，先全部收進記憶體再檢查，等於讓『entry 數上限』這道防線在生效之前就先被繞過」。**這段話把問題描述得完全正確，但它描述的正是現行 zip 路徑真實發生的事**——作者想防的就是這個，寫出來的程式碼對 tar 做到了、對 zip 沒有
- 讀檔的兩份副本峰值：同檔 `:199-215`
- 誰能觸發：`jedi_detection/api/routes/detection_profile_route.py:184`（新增基準）、`:359`（改某一版的來源）、`:410`（版更）、`:542`（重新抽取）——四支都吃使用者上傳的檔案

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

1. **把上限搬到打開之前。** zip 的檔尾有一段 EOCD（End of Central Directory）記錄，裡面直接寫著「這包有幾筆 entry」和「清單有多大」。在 `_iter_zip_entries()` 進到 `ZipFile()` 之前先讀這段（可用標準庫的 `zipfile._EndRecData`，或自己解析尾端那幾十個 bytes——它是固定格式），筆數或清單大小超標就直接丟 `DETECTION_PROFILE_ARCHIVE_UNSAFE`，**不要開 `ZipFile`**。這樣上限才真的擋在記憶體配置之前。
2. **改掉 `:145-148` 那段 docstring。** 現在它告訴下一個維護者「這道防線已經做好了」，而那對 zip 不成立。**這段註解本身就是這個洞能活到今天的原因**——任何人讀了都會以為這裡已經處理過。改成寫明「tar 逐筆迭代天然滿足；zip 的清單在 `ZipFile()` 建構時就整份展開，所以上限必須在開檔前用 EOCD 先判」。

⚠️ 修的時候注意：**不要改成「先把 entry 收進 list 再數」**，那會讓 tar 也一起中招。兩個格式共用同一套 entry 檢查是這個模組刻意的設計（檔頭 D4 段寫明理由：「按格式分支處理正是 zip 路徑漏防的典型成因」），要保留；要加的是 zip **專屬的開檔前預檢**，不是改共用的那段。

**驗證。** 3/3 三位檢查員確認成立（可達性、影響、既有防護三個角度），嚴重度維持 MEDIUM。三位各自獨立去翻了 CPython 的 `zipfile` 原始碼，都定位到 `_RealGetContents()` 在建構子裡被呼叫、清單在 `infolist()` 之前就已經建好。

**為什麼是 MEDIUM 不是 HIGH：** 要先是租戶管理員（不是外面的人打一個網址就中），而且打不到任何資料——不會外洩、不會竄改，只會讓服務變慢或倒掉。

**首腦核對註記。** 屬實，而且**比工具報的更具體**。工具用的是推估（「粗估數百 MB」），我實際造了合成檔案量測：49.7MB／59.5 萬 entry → 常駐記憶體 +360MB，`ZipFile()` 打開耗時 1.7 秒。另外確認了三件工具沒講的事：① **tar 路徑確實不受影響**（`tarfile` 的 `for member in tf` 是逐筆讀 header，不預先展開）；② **四支上傳端點全部吃同一支驗證器**（`detection_profile_service.py:115` 建的那一個），所以修一處四支都好；③ **那段寫反的註解是關鍵**——它不只是文件錯誤，是讓這個洞通過所有 code review 的原因，修法必須含它。**與前面棒次不重複**：D1 查的是工具憑證、D3 五棒查的是執行編排，都沒碰到上傳驗證這一段。淨新增 1 條，登記跨 arc 總表。

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

卡片重點①（壓縮檔驗證界線）、④的一部分（route 能力點）、⑦（離線工具）落在本棒；其餘落在 D2 的其他小棒。

### ① 壓縮檔驗證的界線：自陳成不成立 — ⚠️ 大部分成立，一處破口（就是 F1）

卡片指名要驗檔頭第 20 行的自陳：「bomb 檢查靠 entry 自報的 size 累加，是 header 值不是實讀值，理論上可被偽造」。**逐項查證結果**：

**自陳的那個弱點本身，實際影響比自陳講的還小——方向是對的。** 偽造 header 大小確實做得到（宣告 1KB 實際 1GB），但那樣做**繞不過任何東西**：這一層只看宣告值，偽造小了就是讓它通過這一層，而真正解壓在代理程式那端，那邊拿到的實際內容與宣告不符會失敗。檔頭自己也寫了「agent 端另有一套同等防護，本層是第一道而非唯一一道」。**判定：自陳成立，這個界線是有意識的取捨、不是缺陷。**

**但自陳漏講了另一個弱點，而且那個是真的** —— 就是 F1。檔頭花了整段講「bomb 檢查的數值可能被偽造」，卻沒察覺**連「數」這個動作本身都太晚**。自陳的界線畫在「數值可不可信」，真正的破口在「什麼時候開始數」。

**其他四道防護逐一查證，都成立：**

- **副檔名白名單用「最長後綴優先」** — `match_extension()` 的 `sorted(ALLOWED_EXTENSIONS, key=len, reverse=True)`（`:295`）。這是必要的：`os.path.splitext("a.tar.gz")` 只會切出 `.gz`，而裸 `.gz` 不在白名單，用 splitext 會把合法的 tar.gz 一起擋掉。比對前先取 basename（防有人把路徑當檔名送）並轉小寫。**判定：正確。**
- **magic number 第二重確認** — `_assert_magic()`（`:218-226`）。zip 認三種 signature（含空 zip 的 `PK\x05\x06` 與 spanned 的 `PK\x07\x08`，否則合法空包會被誤判成假檔）；tar 沒有檔頭 magic，看 offset 257 的 `ustar`。**判定：三種 signature 都認得，方向正確。**
- **zip-slip 防護拒絕四種路徑** — `_is_safe_entry_path()`（`:325-347`）：空名、絕對路徑（含 Windows `C:/` 與 UNC `//`）、正規化後仍以 `..` 開頭、帶 NUL 的路徑。**NUL 那一項特別值得記**：某些解壓器會在 NUL 處截斷，造成「檢查的字串」與「實際寫出的路徑」不一致——這是少見但真實的繞過手法，這裡有擋。正規化前先把反斜線折成斜線（`a\..\..\etc` 這種寫法擋得住）。**判定：四種都對，沒有漏。**
- **symlink / hardlink 一律拒收** — `_assert_entry_safe()`（`:274-277`）。這是 path traversal 的第二條路：路徑本身乾淨，但解壓後跟著連結寫出去就能逸出。`_EntryInfo.is_link` 由 `member.issym() or member.islnk()` 填（`:266`）。**zip 這邊沒有對應檢查**，檔頭註解（`:86-88`）自己說明了：zip 理論上能存 symlink（靠外部屬性），但標準庫的 `ZipInfo` 不直接暴露，且代理程式端解壓器另有防護。**判定：tar 擋滿；zip 是已知且有意識的缺口，已自陳、且有第二道防線，不另計為發現**——但若日後代理程式端防護有變動，這裡要重新評估。

**`_MAX_MANIFEST_DEPTH = 1` 的比對方式也對** — `_is_manifest_path()`（`:308-318`）用的是**整段檔名相等**而不是 `endswith`，所以 `inspec.yml.bak`、`my-inspec.yml` 這種騙不過去。深度上限 1 的理由（打包時多包一層目錄是常態，埋更深 cinc-auditor 找不到）站得住。

**檢查順序刻意由便宜到昂貴** — 副檔名 → 大小 → magic → 開檔 → 逐 entry → 結構（`validate()` 的 docstring `:123-124`）。**結構驗證刻意放最後**（`:167-170`），理由寫明：安全性優先於「是不是一份 profile」，惡意檔要先被擋掉，不該因為「剛好也沒有 inspec.yml」而回一個誤導的結構錯誤。**判定：順序正確，理由站得住。**

**大小上限是邊讀邊斷、不是先讀完再看** — `_read_all_with_limit()`（`:194-215`）：`total > self._max_bytes` 在迴圈裡判，超限立刻丟。**判定：正確**（先整包讀進來再看大小等於沒有上限，50MB 的宣告擋不住 5GB 的實際上傳）。另外 Flask 層還有一道 `MAX_CONTENT_LENGTH`（主專案 `core/app_factory.py:104`，預設 50MB），在 routing 之前就用 Content-Length 擋掉，是第二道。

### ④（本棒範圍內的那部分）route 能力點 — 🔴 **推翻卡片前提：卡片說錯了**

**卡片寫的是**：「15 條 profile route 只掛 `require_license`（**沒有一條掛 `detection-profile.*` 四顆能力點**——與 FR-098 第 80 項／FR-101 同款『宣告了不守』，確認後標同一產品決策題）」。

**實查不成立。** 逐支列出 `detection_profile_route.py` 的全部 18 支方法與它們的守門：

| 行 | 方法 | 能力點 |
|---|---|---|
| 135 | POST 列表查詢 | （無，讀取類） |
| 163 | GET 下拉選單 | （無，讀取類） |
| **184** | **POST 新增基準** | **`_profile_create`** |
| 211 | GET 單筆詳情 | （無，讀取類） |
| **230** | **PUT 編輯基本資料** | **`_profile_update`** |
| **258** | **DELETE 刪除基準** | **`_profile_delete`** |
| 274 | GET 版本列表 | （無，讀取類） |
| 303 | GET 使用狀況 | （無，讀取類） |
| 328 | GET 版本使用狀況 | （無，讀取類） |
| **359** | **PATCH 就地換來源** | **`_profile_create`** |
| **392** | **DELETE 刪除某一版** | **`_profile_delete`** |
| **410** | **POST 版更** | **`_profile_create`** |
| **439** | **POST fork** | **`_profile_create`** |
| **460** | **POST 複製到另一工具** | **`_profile_create`** |
| **484** | **PUT 停用** | **`_profile_delete`** |
| 507 | GET 控制項列表 | （無，讀取類） |
| 527 | GET 抽取狀態 | （無，讀取類） |
| **542** | **POST 重新抽取** | **`_profile_create`** |

**18 支裡 10 支掛了能力點，而且掛的正好是全部 10 支寫入類；8 支不掛的全是讀取類（GET，加上那支查詢用的 POST）。** 這是合理設計，不是缺口。

**而且不是最近才補的。** `git show 8832c14`（2026-08-31 套件抽出當下）與 `git show a6475b4`（**開卡那天**）兩個版本都各有 10 處 `@_profile_`——**開卡時就已經掛好了，卡片的前提從寫下的那一刻就是錯的**。中間 `1461aea`（09-13）只是把能力點名字從寫死字串改成從 config 取，掛載位置一支沒動。

**讀取類不掛能力點是不是問題？** 不是，理由有二：① 讀取本來就受 `require_license` 管（沒買這個模組的租戶進不來）；② 能看到什麼由資料庫的租戶隔離與服務層的 `or_(scope=='SYSTEM', tenant_id==ctx.tenant_id)` 決定（`detection_profile_service.py:26` 註解寫明「RLS 只是兜底」），不是靠能力點。**判定：④ 的這一半沒有問題，卡片標的「宣告了不守」與 FR-098 第 80 項的類比不成立，不應登記為同題。**

⚠️ **④ 的另一半（公版 vs 租戶 scope、fork／copy_to_tool 的 tenant_id 來源）落在 D2-2 範圍**（`detection_profile_service.py` 1,133 行），本棒沒掃。只順手記下一個錨點供 D2-2 用：新增基準的 serializer（`api/serializers/detection_profile.py:216`）`scope` 預設 `TENANT`、註解寫「只有平台管理員送得動 SYSTEM（service guard 擋，非此處）」，而分享給子樹那支（`:250`）用 `validate.OneOf(["TENANT", "SHARED"])` 把 SYSTEM 排除在值域外——**兩層寫法不一致**（一個靠 service 擋、一個靠 schema 擋），D2-2 要驗第一層真的擋得住。

### ⑤ 版本檔案的取回 — ⚠️ 部分答覆，主體在 D2-2

卡片問「`DetectionProfileVersionSourceRoute`（`:337-377`）是 `patch` 不是下載？——確認這支到底做什麼」。

**確認了：這支不是下載。** 它是「就地修正某一版的來源」——把某一版的內容換成新上傳的檔案或新網址，**版號不變**，抽取狀態回落 pending 並自動排重抽（docstring `:339-345`）。掛 `_profile_create` 而非 `_profile_update`，docstring 解釋了理由：它產生的是「這一版的內容」，與版更、重抽同一組權限語意；`update` 對的是主檔的名稱／分類編輯。**判定：權限語意的選擇有明確理由，站得住。**

⚠️ **「真正的下載在哪、`_read_source_bytes(file_id)` 用數字 id 查 `upload_files` 有沒有驗歸屬」這一半在 `detection_profile_service.py`，屬 D2-2 範圍，本棒沒掃。** 留給 D2-2。

### ⑦ 七支隨包轉換腳本 — ✅ 確認不構成攻擊面

卡片要 D2-1 的 runner 順手答一句「有沒有被 runtime import」。

**實查：零命中。** 在套件內全域 grep `profiles.tools` / `profiles/tools` / `from jedi_detection.profiles`，扣掉那七支自己之間的相互 import，**沒有任何 runtime 程式碼 import 它們**。它們是把政府基準文件轉成 InSpec profile 的離線開發工具，跑在開發者自己機器上、不隨服務執行。**判定：不構成攻擊面**，與首腦 09-16 剔除它們的判斷一致。

### 附帶：`detection_profile_ref.py`（75 行）查證 — ✅ 沒問題

這支只有兩個函式加一個 payload 組裝。查證三點：

- **用顯式前綴 `profile:` 區分「庫內參照」與「使用者手填」是對的**（`:19`）。檔頭的理由站得住：手填值理論上可以長得像 UUID（低機率但非零），靠格式猜測是隱性契約；前綴則零歧義。**判定：正確。**
- **`extract_profile_uid()` 對非字串回空字串而不是拋例外**（`:36-37` 的 `isinstance` 檢查）。前端送來的值型別不可控，這個寫法讓呼叫端可以直接 `if uid:`。**判定：容錯方向正確。**
- **`PROFILE_PARAMS_KEY = "_profile"` 用 `_` 前綴**（`:23`），沿用 `_credentials` / `_source_file` 慣例，讓既有剝除機制把它擋在畫面與稽核記錄之外。**判定：與既有慣例一致。** ⚠️ 不過 D3-2 已經查出**綁定寫入那條路沒有做剝除**（`detection_job_binding_handler.py:213`，當時判定「不是漏洞但建議補」），所以這個 `_` 前綴的保護在那條路上是打折的——**兩件事是同一個題目**，修 D3-2 那條時一併處理即可，本棒不另計。

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

分兩層講：

**「報出來的這條存在嗎」——可信度高。** F1 我不只開檔核對，還**實測量出了數字**（49.7MB／59.5 萬 entry → +360MB，CPython 3.11.9）。三位檢查員從三個不同角度獨立去翻 CPython 原始碼，都定位到同一個函式，三票全過。攻擊路徑的每一環我都確認過：四道 entry 檢查會放行合法檔名、結構檢查放一個 `inspec.yml` 就過、四支上傳端點吃同一支驗證器。

**「只有這條嗎」——可信度中等，有三個已知限制。**
1. **強度是 `low`**，只有一位研究員讀一遍，不是多位分工交叉讀。低強度的設計目的是快速篩，不是窮盡。
2. **這是 D2-1 切開後的後半棒，只有 2 檔 422 行**。前半棒（route + serializer + dto，3 檔 1,058 行）**還沒跑**，所以「誰能打到這道關卡」的上游守門只查到能力點這一層（那部分我人工補了，見重點④），request 解析、multipart 處理、schema 驗證那一段沒有工具掃過。
3. **「沒問題」的那些結論多數是人工定向查證，不是面板投票的結果**——工具只提了 2 條候選（1 條成立、1 條越界），重點①的四道防護、④的能力點盤點、⑦的離線工具、ref 那支的三點，全部是我開檔查的。這些結論沒有三票背書，可信度低於 F1 那條。特別是**能力點那項是推翻卡片前提**，我用 `git show` 查了兩個歷史版本交叉確認，但仍屬單人判斷。

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

## 被駁回的那條：內部欄位沒過濾就寫進派工參數 — ❌ 越界，且面板 1:2 否決

研究員提報：前端可以塞 `_` 開頭的內部欄位進任務參數，一路流到代理程式讀取檔案時用的允許清單。

**這條整條都落在本棒範圍外**（`detection_job_binding_handler.py`、`detection_orchestration_service.py`、`agent_file_access_service.py`，分屬 D3-2／D3-4a 與 FR-077 R2b）。**⚠️ 越界（屬 D3-2 範圍）**——而且 **D3-2 那棒已經報過同一條並判定不成立**（見 `scan-D3-2-job-binding.md` 末段），這次是第二次撞到同一個題目。

三位檢查員 1:2 否決，理由與 D3-2 當時一致：注入機制屬實，但**攻擊者做完跟做之前的權限一模一樣**（操作者本來就是專案管理者，本來就有那些檔案的存取權），且取檔跑在該代理程式自己的租戶脈絡下，跨租戶拿不到東西。**判定：不計為發現，總表不加號。**

這也是「低強度工具會被鄰居吸走」的又一次實證——範圍只有 2 個檔，研究員仍跑去讀了三個套件外的檔案，並把那邊的問題當成本棒發現報上來。

## 執行概況

| 項目 | 數字 |
|---|---|
| run ID | `wf_2d7eaf32-309` |
| 掃描 commit | `955e40964baa3cfc0e8766078db7d65075d3c50b`（工作區乾淨） |
| 範圍 | 2 檔 422 行 |
| 強度 | `low`（一位研究員 ＋ 三票面板） |
| 研究員 | 派 1 位、回 1 位、**零重試** |
| 面板 | 2 條候選 × 3 位檢查員 ＝ 6 票，全數投出 |
| 總耗時 | 約 136 分鐘（14:43 啟動 → 16:59 報告產出） |
| 候選 → 成立 | 2 → 1（另 1 條越界且被否決） |
| 驗證輪數 | 1 |
| 驗證章 | `verified` |
| 工具原始產物 | `jedi-detection/CLAUDE-SECURITY-20260919-064334/`（不入版控） |

**這一棒是「先切小再跑」的第四次實證，也是最極端的一次。** D2-1 原本 5 檔 1,480 行，**連跑六位研究員全部被砍、耗掉 2 小時 53 分零產出**；切成 422 行之後，一位研究員零重試跑完。

**⚠️ 但要記一筆：切小不是免費的。** 422 行只跑出 1 條發現，而報告裡大部分的「沒問題」結論是人工查的——**工具在這個尺寸下的產出密度很低**。這與 D3-4b 的觀察（同檔第二次掃淨新增掉到 0）指向同一件事：低強度工具的邊際產出遞減得很快，**切棒真正在做的是保證它跑得完，不是提高它找得到的量**。人工查證那一端的比重會愈來愈高。

**另記一筆診斷更正。** D2-1 六次失敗，我第一次回報時把死因歸給「工具判定思考停滯」，**那個歸因不準確**。實查 transcript 後發現六支裡有五支的最長思考卡在**同一個數字 192 秒**（3 分 12 秒），同一秒數重複五次代表那是一道固定的切斷線，不是模型自己想太久。首腦已採納此觀察記入 STATE。
