---
title: V1 檢查結果：建題全鏈——問卷／資料夾／分頁／題目／討論＋Excel 匯入＋守門殼與插件（jedi-survey）
---

# V1 檢查結果：建題全鏈——問卷／資料夾／分頁／題目／討論＋Excel 匯入＋守門殼與插件（jedi-survey）

> 檢查日期 2026-09-17｜對應卡片 CM-1882｜檢查範圍 50 個檔案（分兩輪各 25 檔）

## 🔴 一句話結論

**七條發現全部通過三位檢查員的審查，淨新增五條——其中最該先修的是「任何人登入後送一個空的查詢，就能把全公司的問卷討論一次撈光」。** 另外兩條跑到了填答側（V2 的地盤），與 V2 那棒重複，本棒只標記不重算。

白話講整件事：**這個套件把「你有沒有權限做這件事」跟「你能不能碰這一筆資料」搞混了。** 寫入端（新增／修改／刪除）都掛了能力點，看起來很嚴謹；但能力點只管「你是不是有改問卷的權限」，不管「你改的是不是你的東西」。於是有改問卷權限的人，可以改寫任何人的留言；而讀取端更鬆——大部分只要求「你有登入」，連能力點都沒有。**這正是 V2 填答側（CM-1881）驗出的同一個形狀，在建題側完整複製了一份。**

還有一個獨立的成因，造成三條發現：**四個路由上的資料驗證其實是關掉的**。程式碼裡寫了 `@use_kwargs(SurveyFolderRequest, ..., apply=False)`，看起來有驗證，但 `apply=False` 這個參數會讓框架**完全跳過驗證**——那行等於裝飾品。於是前端送什麼，後端就原封不動照單全收，包括那些本來只該由系統決定的欄位。

## 這一棒在檢查什麼

管理者建問卷、分頁、題目、放進資料夾、從 Excel 匯入、複製一份、搬到別的資料夾、發佈給任務用；填答者可以在題目底下留言討論。這一整條「建題」的鏈子就是本棒的主題。具體七件事：

1. **四顆能力點有沒有每條寫入路由都掛上**，有沒有掛錯（例如刪除掛成修改）。
2. **討論的改與刪有沒有驗本人**。
3. **Excel 匯入**會不會炸、暫存檔會不會留、匯進去的目標資料夾能不能指到別家客戶的。
4. **範本下載的簽章憑證**有效期多久、能不能拿去打別的用途。
5. **複製／搬移／發佈／快照**時，客戶歸屬會不會跑掉。
6. **守門殼與插件**：宿主忘了接線時，會靜靜地變成沒人守門嗎。
7. **資料夾與翻譯表**的客戶隔離規則鏈條完不完整。

掃描目標 `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey`，branch `feature/review`，mode `scan`，effort `low`。**因為單輪 50 檔會撞到一個約三十分鐘的中斷門檻（詳見文末「執行概況」），本棒拆成兩輪各 25 檔跑**，兩輪的版本戳記不同：

| 輪次 | 範圍 | 版本 | 驗證章 | 發現 |
|------|------|------|--------|------|
| 第一輪 | 資料夾／分頁／題目／討論四條 CRUD（路由、序列化、服務、查詢實體、儲存庫） | `e55a53cc`（工作區有未提交改動） | `verified` | 5 條 |
| 第二輪 | 問卷主體＋Excel 匯入＋複製搬移快照＋守門殼＋插件＋路由表＋migration | `82fda05d`（工作區乾淨） | `verified` | 2 條（皆越界至填答側） |

刻意排除在本棒之外的是填答側整條鏈（`task_survey*`、`question_answer*`、`app/handler/`、`task_type_declaration.py`、`common/task_asset_directory.py`）——那是 V2（CM-1881）的範圍。

## Coverage

兩輪各以單一元件讀完 25 檔。低強度跑法不做元件盤點、不做威脅建模、不跑額外的廣度掃描，`completenessCheckOutcome` 兩輪都是 `not-applicable`（指定範圍的掃描本就不適用）。**這份報告完全不能拿來說 `jedi-survey` 其他地方沒事**，填答側要看 V2。

兩輪各派一位研究員、各回報一位。驗證各跑一輪：第一輪 5 個候選拿到 15 票全數通過；第二輪 3 個候選拿到 9 票，其中 2 條通過、1 條被三票一致駁回。**沒有候選遺失、沒有候選未被審、沒有候選被交到下一輪，沒有任何一條的嚴重度被面板調降。**

**第二輪有一條被駁回，這裡明講，因為讀者有權知道什麼被看過而後被排除。** 研究員提出 `SurveyRoute.put`（`survey_route.py:139`）會在解析路徑上的問卷之前，就先照請求內容裡的編號刪掉題目與分頁，所以可以刪掉屬於另一份問卷的題目。三位檢查員都確認**沾染點是真的**——請求內容沒驗證（`apply=False`），刪除確實只照編號刪、沒綁父問卷——但三位都以「後果不成立」駁回：這條路由掛的 `survey.update` 能力點是**全公司範圍、不吃資源參數**的，打得到這個端點的人本來就能改公司內任何一份問卷，所以「刪到 B 問卷而不是 A 問卷」不構成權限提升；跨客戶則另有資料庫層隔離擋住（預讀是用受隔離的帳號跑的）。**這是程式碼品質缺陷——刪除應該綁父問卷——不是資安漏洞**，記在這裡而不進發現清單。

**第二輪的兩條發現位置都在範圍外**（填答側的 `question_answer_history_detail_route.py` 與 `question_answer_route.py`）。研究員是從範圍內的路由表（`api/routing.py`）與守門模組（`app/common/task_survey_guard.py`，其檔頭自陳守門只掛在寫入端）追進去的，所以報出來但標為越界。

**這份報告沒有逐檔紀錄**：兩輪的 `coverage.research` 都是 `null`，工具沒留下「研究員把哪個檔讀到什麼程度」的帳。下方「卡片重點逐項查證」段由首腦逐項開檔補查，並標明哪些是工具報的、哪些是人工查的。

**工具偏食得很明顯，這點必須講白。** 第二輪拿到 25 個檔——含 Excel 匯入、簽章憑證、複製搬移快照、守門殼、插件契約——**一條都沒報**，兩條發現全跑去填答側。卡片預警的「工具會被鄰居吸走」完全命中。所以卡片重點③④⑤⑥⑦**全部由人工查證**，那才是這一棒的主體。

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊。七條發現全部是讀原始碼推出來的；人工查證段落另有實際連線資料庫查規則（唯讀）。

## 掃到什麼（總覽）

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 |
|---|--------|---------------------|-----------|------------------|------|
| [V1-1](#v1-1) | 🟡 中等 | 討論列表送一個空的 `{}`，就把全公司的討論撈回來 | 稽核討論串裡的缺失細節、審查意見全部外流，包含你沒參與的專案 | **只要登入**——不需能力點、不需是專案成員 | `api/routes/survey_discussion_route.py:29` |
| [V1-2](#v1-2) | 🟡 中等 | 資料夾更新 API 前端送什麼收什麼，塞 `is_delete` 就能繞過刪除守門 | 造出「資料夾被刪但裡面問卷還在」的孤兒資料——正是那兩道守門要防的事 | 有「修改問卷」能力點 | `app/service/survey_folder.py:81` |
| [V1-3](#v1-3) | 🟡 中等 | 改／刪討論不驗是不是本人，而且改完還掛原作者的名字 | 可以在稽核紀錄裡替別人說話；刪除是硬刪、不留痕跡 | 有「修改問卷」或「刪除問卷」能力點 | `api/routes/survey_discussion_route.py:73`、`:87` |
| [V1-4](#v1-4) | ⚪ 輕微 | 資料夾列表可以叫出系統刻意隱藏的資料夾 | 看得到流程節點產生的快照資料夾（`__` 開頭），以及已刪除的資料夾 | 只要登入 | `api/routes/survey_folder_route.py:46` |
| [V1-5](#v1-5) | ⚪ 輕微 | 資料庫的錯誤訊息原文直接吐回前端 | 拿到資料表名稱、欄位名稱、SQL 片段 | 只要登入 | `api/routes/survey_folder_route.py:51` |
| [V1-6](#v1-6) | 🟡 中等 | ⚠️ **越界（屬 V2）** 填答歷史明細送空的 `{}` 撈光全公司填答紀錄 | 所有問卷的作答內容與審查意見外流 | 只要登入 | `api/routes/question_answer_history_detail_route.py:21` |
| [V1-7](#v1-7) | 🟡 中等 | ⚠️ **越界（屬 V2）** 讀別人的填答不守門，但寫同一筆有守門 | 讀得到別人的作答與審查意見 | 只要登入＋知道一個任務問卷編號 | `api/routes/question_answer_route.py:30` |

**淨新增五條（V1-1～V1-5），另兩條越界至填答側與 V2 重疊。** V1-6／V1-7 與 V2 那棒（CM-1881，總表第 89～95 項）是同一批「填答側讀取零守門」的問題，**由 V2 收口，本棒不重複計入總表**。

## Findings

<a id="v1-1"></a>
### V1-1 — 討論列表沒有資源層守門，送空的查詢就回全部（中等，3:0，把握高）

**現況**：已修（M05-5，CM-2034，1.21.0 出貨）

**這是什麼問題。** 查討論的時候可以指定「我要看哪一份問卷的」或「哪一個任務問卷的」。問題是這兩個欄位**都是選填**，兩個都不填時，程式不會拒絕，而是一路往下走：查詢條件產生器看到欄位是空的就把它丟掉，最後送到資料庫的是一句**完全沒有條件的查詢**，整張表回給你。

同一個檔案裡的修改與刪除都掛了能力點，只有這支列表沒有——**不是刻意放寬，是漏掉了**。

**出事會怎樣。** 全公司所有問卷與所有稽核任務問卷的討論訊息一次外流：留言內容、留言者的帳號、問卷名稱、時間。合規稽核的討論串裡經常有缺失細節與審查意見。資料庫層的隔離只擋得住「別家客戶」，擋不住「同一家公司但不同專案」——因為那條規則只確認問卷存在且屬於這家公司，不確認你是不是這個專案的成員。

**要先有什麼才打得到。** 一組能登入的帳號，就這樣。不需要任何能力點，不需要是專案成員。

**在哪裡。** `jedi_survey/api/routes/survey_discussion_route.py:29`，`SurveyDiscussionsRoute.post`。

**怎麼修。** 兩件事：① 把 `SurveyDiscussionQueryRequest` 的 `survey_uid`／`task_survey_uid` 改成必填；② 在查之前先解析出那份問卷／任務問卷，確認呼叫者是該專案的成員才回資料——套件裡已經有 `assert_task_survey_writer` 與 `IProjectRoleGuard` 就是幹這個的。另外建議在儲存庫層把「查詢條件全空」改成報錯，而不是退化成撈全表，這樣下一個忘記加條件的人會當場失敗，而不是靜靜地把整張表送出去。

<a id="v1-2"></a>
### V1-2 — 資料夾更新照單全收，可塞 `is_delete` 繞過刪除守門（中等，3:0，把握高）

**現況**：已修（M05-10，CM-2060，1.21.0 出貨）

**這是什麼問題。** 更新資料夾的路由直接把前端送來的整包內容原封不動展開成資料夾物件，再寫進資料庫。路由上那行 `@use_kwargs(SurveyFolderRequest, ..., apply=False)` 看起來像在驗證，但 `apply=False` 會讓框架**整段跳過**——三位檢查員都去翻了框架原始碼確認這一點。於是 `is_delete`、`pid`（父資料夾）這些本該由系統決定的欄位，前端都能塞。

**出事會怎樣。** 刪除資料夾的那支 API 有兩道守門：裡面還有問卷就擋（回 409）、還有子資料夾也擋。**用更新 API 塞 `is_delete:1` 可以完全繞過這兩道**——資料夾從所有清單消失（清單都只撈 `is_delete=0`），但裡面的問卷還在，變成指向一個已刪除父節點的孤兒，從樹狀結構再也找不到。反過來塞 `is_delete:0` 則可以把已刪除的資料夾救回來。`pid` 可以指到任何資料夾且沒有迴圈檢查。新增時還能自己指定主鍵與 uid，會把資料庫的流水號打亂，導致之後的新增失敗。

**要先有什麼才打得到。** 有「修改問卷」能力點的帳號（新增路徑則是「新增問卷」能力點）。

**在哪裡。** `jedi_survey/app/service/survey_folder.py:81`，`SurveyFolderService.update_folder`。源頭在 `jedi_survey/api/routes/survey_folder_route.py:92`。

**怎麼修。** 不要再整包展開。拿掉 `apply=False`，用一個只宣告 `name` 與 `description` 的嚴格結構去解析，然後一個一個具名帶進去建立物件。`is_delete`、`id`、`uid`、`pid` 與稽核欄位都不該能從請求內容設定；刪除必須走那支有守門的刪除 API。

<a id="v1-3"></a>
### V1-3 — 改／刪討論不驗本人，改完仍掛原作者的名字（中等，3:0，把握中）

**現況**：已修（M05-8，FR-114.1-4a，1.21.0 出貨）

**這是什麼問題。** 修改與刪除討論時，程式只確認「這個編號的討論存在」，完全不確認「這則留言是不是你寫的」，也不確認「你有沒有參與這份問卷所屬的專案」。路由上掛的能力點管的是**「你能不能做修改這個動作」**，不是**「你能不能改這一筆」**——這個差別在這裡很要命，因為這一筆是別人的。

**出事會怎樣。** 有「修改問卷」能力點的人，可以改寫或刪掉公司內**任何人**的留言。更糟的是作者欄位在儲存庫的「更新時不覆寫」名單裡，所以**改完之後還是掛原作者的帳號與暱稱**——等於可以在稽核討論紀錄裡替別人說話，而讀的人看不出來。刪除是硬刪除（這張表沒有軟刪除欄位），刪完不留任何痕跡。配合 V1-1，攻擊者可以先把全公司留言撈出來再挑對象。

**要先有什麼才打得到。** 有「修改問卷」能力點（要改）或「刪除問卷」能力點（要刪），加上知道一個目標留言的編號——而 V1-1 免費提供。

**在哪裡。** `jedi_survey/api/routes/survey_discussion_route.py:73`（修改）與 `:87`（刪除）。

**怎麼修。** 動手之前先把那則留言載出來，確認 `created_user` 就是呼叫者本人。如果產品確實需要「管理員可以刪別人的留言」，那要走一條明確的管理路徑，並且同時確認呼叫者參與了這則留言所屬的問卷或任務問卷（用 `IProjectRoleGuard` / `assert_task_survey_manager`）。

<a id="v1-4"></a>
### V1-4 — 資料夾列表可翻出系統隱藏資料夾（輕微，3:0，把握高）

**現況**：已修（M05-11，CM-2060，1.21.0 出貨）

**這是什麼問題。** 跟 V1-2 同一個成因（`apply=False` 讓驗證失效），但這裡被收進去的是一個**內部開關**。程式把前端送來的內容整包展開，一路傳到 `get_folders(locale, exclude_system=True, **kwargs)`——於是前端送一個叫 `exclude_system` 的欄位，就直接蓋掉那個內部參數。全專案 grep 過，**沒有任何一個合法呼叫端會傳這個參數**，它本來就是純內部開關。

**出事會怎樣。** 送 `{"exclude_system": false}` 就能看到產品刻意藏起來的資料夾——名稱以 `__` 開頭的那些，例如流程節點自動產生的問卷快照資料夾。再送 `{"is_delete": 1}` 可以看到已刪除的資料夾。範圍限於自己公司，因為資料夾表有客戶隔離規則。

**要先有什麼才打得到。** 只要登入。這支端點連能力點都沒掛。

**在哪裡。** `jedi_survey/api/routes/survey_folder_route.py:46`，`SurveyFoldersRoute.post`。

**怎麼修。** 用嚴格結構解析，只列出真正能當查詢條件的欄位，然後具名傳進去。**永遠不要把前端送來的字典整包展開，丟進一個參數列裡有行為開關的函式。** `exclude_system` 必須留在後端決定。

<a id="v1-5"></a>
### V1-5 — 資料庫錯誤原文吐回前端（輕微，3:0，把握中）

**現況**：已修（M05-12，CM-2060，1.21.0 出貨）

**這是什麼問題。** 這支路由把 `get_folders(...)` 整個包在一個「抓所有例外」裡，然後把例外訊息的原文直接當回應內容送出去。而那個呼叫的參數是前端控制的（見 V1-4），所以前端可以刻意觸發例外。

**出事會怎樣。** 送 `{"id": "abc"}`，這個值會被拿去跟一個整數欄位比對，PostgreSQL 丟出 `invalid input syntax for type integer`，SQLAlchemy 包一層，然後整段原文回到前端——裡面帶著 schema 名稱、資料表名稱、欄位名稱與出錯的 SQL 片段。標準的錯誤信封設計就是為了不讓這些出去。

**要先有什麼才打得到。** 只要登入，送一個型別不對的查詢條件即可。

**在哪裡。** `jedi_survey/api/routes/survey_folder_route.py:51`。

**怎麼修。** 把那段「抓所有例外」拿掉，讓框架已經註冊好的錯誤處理器產生標準錯誤碼。真的需要在地處理的話，就記錄到 log、回一個固定的錯誤碼，**絕不回 `e.args[0]`**。

<a id="v1-6"></a>
### V1-6 — ⚠️ 越界（屬 V2）填答歷史明細送空的查詢就回全部（中等，3:0，把握高）

**現況**：已修（M05-1，CM-2034，1.21.0 出貨）

**這是什麼問題。** 與 V1-1 完全同型，發生在填答歷史明細的列表端點：前端送來的內容整包展開成查詢條件，端點只要求登入、沒有任何指派或專案角色檢查，查詢條件全空時退化成撈全表。

**出事會怎樣。** 全公司所有問卷的作答內容、補充說明、審查意見（四個 JSON 欄位原封不動吐出）一次外流。**這正是寫入端刻意用 `assert_task_survey_writer` 限制只有被指派者或專案經理能碰的資料。**

**要先有什麼才打得到。** 只要登入。

**在哪裡。** `jedi_survey/api/routes/question_answer_history_detail_route.py:21`。

**怎麼修。** 要求一個必填的範圍鍵（任務問卷編號或歷史編號），解析成任務後呼叫 `assert_task_survey_writer` 或對應的讀取守門再查——同區的還原功能已經這樣做了。

> **與 V2 的關係**：屬填答側，由 CM-1881 收口。本棒只標記。

<a id="v1-7"></a>
### V1-7 — ⚠️ 越界（屬 V2）讀別人的填答不守門，寫同一筆卻有守門（中等，3:0，把握高）

**現況**：已修（M05-2，CM-2034，1.21.0 出貨）

**這是什麼問題。** 同一個網址，`PUT`（寫）有呼叫 `assert_task_survey_writer`，`GET`（讀）沒有。**這種一邊有一邊沒有的不對稱，說明是漏掉了守門，不是刻意放寬讀取。**

**出事會怎樣。** 任何登入者只要知道或猜到一個任務問卷編號，就能讀到別人的作答、補充說明與審查意見。

**要先有什麼才打得到。** 登入，加上一個別人的任務問卷編號——而任務問卷列表端點同樣只要求登入，編號在那裡拿得到。

**在哪裡。** `jedi_survey/api/routes/question_answer_route.py:30`。

**怎麼修。** 補上讀取端的守門：解析出任務問卷之後，拿 `task_id` 呼叫 `assert_task_survey_writer`（或一個允許被指派者加專案成員的唯讀版本），比照更新路徑。

> **與 V2 的關係**：屬填答側，由 CM-1881 收口。本棒只標記。

## 卡片重點逐項查證

工具不照卡片清單走，而且這一棒偏食得很嚴重（第二輪 25 檔零發現）。以下七項全部由首腦逐項開檔查證，**標明哪些是工具報的、哪些是工具完全沒碰、由人工補查的**。

### ① 四顆能力點逐支對路由（工具未報，人工查證）

實際 grep 四個路由檔的守門裝飾器，逐支對照：

| 路由檔 | 端點 | 掛的守門 | 判定 |
|--------|------|---------|------|
| `survey_folder_route.py` | 選單 GET、列表 POST、單筆 GET | 只有登入＋授權 | 讀取無能力點 |
| | 新增 POST | `survey_create_capability` | ✅ |
| | 修改 PUT | `survey_update_capability` | ✅ |
| | 刪除 DELETE | `survey_delete_capability` | ✅ |
| `survey_page_route.py` | 新增／修改／刪除 | create／update／delete | ✅ 三支對應正確 |
| | 重新排序 POST、搬移 POST | 都掛 `survey_create_capability` | ⚠️ 見下方說明 |
| `survey_question_route.py` | 新增／修改／刪除 | create／update／delete | ✅ 三支對應正確 |
| | 重新排序 POST、搬移 POST | 都掛 `survey_create_capability` | ⚠️ 見下方說明 |
| `survey_discussion_route.py` | 列表 POST | **只有登入＋授權** | 🔴 **缺口 → V1-1** |
| | 新增 POST | **只有登入＋授權** | ⚠️ 見下方說明 |
| | 修改 PUT | `survey_update_capability` | ✅ 動作層對，但物件層缺 → V1-3 |
| | 刪除 DELETE | `survey_delete_capability` | ✅ 動作層對，但物件層缺 → V1-3 |

**沒有掛錯的**（沒有「刪除掛成 update」這種）。四支路由共 21 個端點，寫入端一支不漏。

**兩點觀察，都不是漏洞、但值得記錄：**
- **「改結構」的動作（重新排序、搬移）掛的是 `create` 而不是 `update`**。四支路由一致這樣寫，是刻意的選擇而非疏漏。語意上 reorder／move 比較接近「修改」，但因為 create 與 update 在本產品的角色矩陣裡幾乎總是綁在一起授予，實務影響是零。**記錄，不建議改**（改了要同步調角色矩陣，風險大於收益）。
- **新增討論（留言）只要求登入、沒掛能力點**。這是合理的產品決策——填答者要能留言，而填答者不會有 `survey.create` 這種建題能力點。**不是漏洞。**

### ② 討論的改刪歸屬（工具已報 → V1-3）

卡片指出 `update_discussion(uid, message, user_name)` 與 `delete_discussion(uid)` 都不比對 `created_user`，且 `delete_discussion` 連 user 都不收。**工具獨立發現了同一件事**，三位檢查員一致確認。與 CM-1630 同型（🅰 組）。

人工補一點工具沒說的：**改完之後作者欄位不會變**（`created_user` 在儲存庫的更新排除名單裡），所以竄改後在列表上看不出來，這比單純「能改別人的」更嚴重。刪除是硬刪除、這張表沒有軟刪除欄位。

### ③ Excel 匯入（工具未報，人工查證）

逐項對照卡片的四個疑問：

**暫存檔會不會留** — 🔴 **會**。`survey_route.py:186-201` 用 `tempfile.NamedTemporaryFile(delete=False)` 存檔，讀完三個工作表後才 `os.remove(temp_path)`。中間三行 `pd.read_excel` 任何一支丟出例外（工作表不存在、檔案毀損、非 xlsx），**`os.remove` 那行就跑不到，暫存檔永久留在磁碟上**。沒有 `try/finally`。實務衝擊是磁碟慢慢被吃掉（每次失敗留一個檔），不是資安漏洞——但這是明確的資源洩漏，值得修。修法：把 `os.remove` 放進 `finally`。

**有沒有大小上限／`read_only`** — ⚠️ **都沒有**。`pd.read_excel` 沒有任何大小限制，路由層也沒看到檔案大小檢查。一個刻意做大的 xlsx（或 zip 炸彈式的檔案）會把記憶體吃光。**但要先有「新增問卷」能力點才打得到**，所以是內部人風險而非外部攻擊面，嚴重度低。建議補一個檔案大小上限。

**選項字串的切法** — `process_excel_options` 用 `||` 與 `::` 切字串。內容沒有做任何清洗就進資料庫。**跨站腳本的風險面在前端渲染**，這裡只標記「資料未清洗」，不算本棒的發現（前端如何渲染不在範圍內）。

**`folder_uid` 能不能指到別家客戶的資料夾** — ✅ **不能**。`import_survey` 的 `folder_uid` 一路走到 `verify_folder_exist_by_uid`，那支是用受隔離的資料庫帳號（`cm_app`）查的，資料夾表有客戶隔離規則，別家客戶的資料夾查不到、直接 404。**這條卡片的疑慮不成立。**

### ④ 範本下載的簽章憑證（工具未報，人工查證）

**沒有 FR-101 那款假修**。卡片擔心的是「沒帶 `?st=` 就退回只驗登入」——那個問題出在 `signed_token_or_jwt`（雙軌版）。本棒用的是 `signed_token_required`（`survey_route.py:234`），**單軌、沒有 fallback**：沒有合法的 `st` 就直接擋，不會退回驗 bearer。查過 `jedi-iam/jedi_iam/authz/signed_token.py:153-172` 確認。

`issue_signed_token(scope)` 只綁用途不綁資源，這是**正確的**——下載的是隨版附帶的靜態範本檔，本來就沒有「哪一筆資源」可綁。發放端點本身要求登入＋授權。

### ⑤ 複製／搬移／發佈／快照（工具未報，人工查證）

**`clone_survey` 的客戶歸屬正確**。`survey_clone_service.py:46-55` 建立新問卷時**沒有顯式帶來源的客戶欄位**——只帶 name／description／folder_id／is_sample 這些業務欄位，客戶欄位交給 `before_flush` 自動補成「呼叫者的客戶」。這正是卡片說的正確做法。

**`create_snapshot` 同樣正確**。`survey_snapshot_domain_service.py:45-57` 也沒有顯式帶客戶欄位。至於卡片問的「跨客戶的來源能不能被快照」——`get_by_uid` 是用受隔離的帳號查的，別家客戶的問卷根本查不到，`source is None` 就 404 了。**不成立。**

**`move_survey` 的目標資料夾歸屬** — 同③的結論，走 `verify_folder_exist_by_uid`，受隔離保護。

**`as_new_version=True` 時舊版本誰能改** — 舊版本仍是一筆普通問卷列，權限判定與其他問卷相同（`survey.update` 能力點，全公司範圍）。**沒有額外的鎖定機制**，但這與 V1-3 同源（能力點只管動作不管物件），不另計一條。

### ⑥ 守門殼與插件（工具未報，人工查證）

**卡片的疑問「`auth_required` 缺了呢」——已經守住了。** `plugin/contract.py:178-183` 的 `REQUIRED_WIRING` 是四項：`auth_required`、`license_guard`、`capability_required`、`project_role_guard`。`_assert_wiring`（`plugin/assembly.py:19-33`）在掛載 blueprint 之前先跑，**四項缺任何一項都當場拒絕掛載**，不是靜默變成 no-op。雖然這四個欄位在資料結構上都宣告成 `Optional`，但那只是型別標註（表示「可以是 None」），實際的必填檢查在 `_assert_wiring`。**這是正面案例。**

**`mount_api=False`（socketio 模式）時 runtime 還寫不寫** — 會寫。`register()` 仍會把 runtime context 放進 `app.extensions`，只是不掛 REST 路由。這是刻意設計（把套件當函式庫用），**不是漏洞**。

**能力點宣告 4 顆 vs 實掛** — 契約宣告三顆有預設值的能力點（create／update／delete，`contract.py:89-91`）。①的實查結果是這三顆全部有被掛上，沒有宣告了卻沒人用的。**對得起來。**

**模組級全域接線的已知取捨** — `api/guards.py` 檔頭自陳它是 21 支套件裡唯一用模組級全域存接線的，同行程起兩個 app 時第二次 `configure()` 會覆寫第一次。照卡片指示**確認即可，不當漏洞報**。

### ⑦ 資料夾與翻譯表的客戶隔離（工具未報，人工實際連線查證）

2026-09-17 對 DEV（`localhost:5432` / `guidant_ai_dev`）唯讀查詢 `pg_policies`：

四張翻譯表（`surveys_trans`、`survey_pages_trans`、`survey_questions_trans`、`survey_folders_trans`）**各有完整四條規則**（select／insert／update／delete），沒有漏表。

**鏈條是接得上的，但只有一跳。** 以 `survey_questions_trans_select` 為例，規則本體是：

```sql
(app.is_super_admin = 't') OR EXISTS (
    SELECT 1 FROM survey.survey_questions q
    WHERE q.id = survey_questions_trans.survey_question_id
)
```

它只確認「父題目這一列存在」，**沒有再往上追到有客戶欄位的表**。乍看是個缺口——但實際上不是：`survey_questions` 自己也受規則保護，所以這個 `EXISTS` 子查詢**看不到別家客戶的題目列**，查不到就等於不存在，翻譯列也就被擋下來。**隔離是靠父表的規則傳遞下來的，鏈條完整。**

⚠️ **但這個設計很脆弱，值得記一筆**：它依賴「父表的規則有正確設定」這個前提。如果哪天有人把 `survey_questions` 的規則關掉或改寬，**四張翻譯表會跟著一起破，而且完全沒有跡象**——因為翻譯表自己的規則長得「看起來很正常」。這不是現在的漏洞，是未來的陷阱，建議在資料庫文件裡標明這層依賴。

## 可信度：分兩層看

**工具報的五條（V1-1～V1-5）＋兩條越界（V1-6／V1-7）：可信度高。** 兩輪驗證章都蓋 `verified`，七條全部 3:0 全票通過、零遺失、零降級。檢查員在需要時讀到了範圍外——框架原始碼（確認 `apply=False` 真的跳過驗證）、`BaseRepositoryImpl` 的欄位排除名單、資料庫隔離規則、宿主的能力點實作——不是只看表面。被駁回的那一條也記在 Coverage 裡，沒有藏起來。

**人工查證的部分（①～⑦）：可信度中高，但性質不同。** 這些是首腦單人開檔查的，**沒有經過三人投票**。查法是實際 grep 原始碼、開檔讀、連資料庫查規則，不是憑印象；但單人判斷沒有對抗性審查，可能漏看。特別是③的「選項字串未清洗」我只標記不下結論（前端渲染不在範圍內），⑤的「舊版本誰能改」我判定與 V1-3 同源不另計，這兩個判斷值得複核。

**這份報告不能拿來說什麼**：不能說 `jedi-survey` 其他地方沒事（填答側看 V2），不能說這七條就是全部（低強度單研究員跑法，且第二輪 25 檔工具零產出，人工補查只涵蓋卡片列出的重點），不能說已經驗證過攻擊可行（沒有實際打過任何端點）。

## 執行概況

**這一棒跑得很不順，總共燒掉約七小時，其中四小時半是白費的。** 記錄下來給後面的人參考。

**第一次嘗試：單輪 50 檔，七次全滅。** 16:01 起跑，研究員起了七隻，**每一隻都在跑到 30～46 分鐘時被中斷**，訊息全是 `[Request interrupted by user]`。七次沒有一次跑完，一個產物都沒生出來。20:20 決策者停掉另一個一直掛掉的 session 後，判定那就是中斷源。

**關鍵推論**：七次中斷**最短的一次是 30 分鐘**，而 50 檔配高強度研究員一輪必然超過 40 分鐘——等於每一輪都注定踩線。所以拆成兩批各 25 檔，把單輪壓到門檻底下。

**第二次嘗試：拆兩批，兩批都跑完。**

| 輪次 | 起訖 | 時長 | 代理程式數 | 工具呼叫 | 結果 |
|------|------|------|-----------|---------|------|
| 第一輪 | 20:28 → 21:41 | 73 分 | 16 | 822 | 5 條，`verified` |
| 第二輪 | 21:44 → 01:04 | 199 分 | 10 | 875 | 2 條，`verified` |

**兩個教訓：**

1. **「25 檔約 20 分鐘」這個估計錯得離譜**，實際第一輪 73 分、第二輪 199 分。低強度（`low`）只決定「一名研究員、不做盤點與威脅建模」，**不決定研究員自己想多深**——實際跑在 `xhigh`。往後估時間要以「單檔 3～8 分鐘」為基準，不是以強度檔位。
2. **拆棒的判準是「單輪時長 vs 中斷門檻」，不是檔案數。** 這次能收斂是因為把單輪壓到門檻下，不是因為 25 比 50 小。

**兩輪的版本不同**（`e55a53cc` → `82fda05d`），因為中間有別線的工作進來推了 commit。第一輪掃描當下工作區有未提交改動（戳記帶 `-dirty`），第二輪乾淨。兩輪範圍沒有重疊，也沒有遺漏。

報告產物落在套件 repo 的 `CLAUDE-SECURITY-20260917-122832/`（第一輪）與 `CLAUDE-SECURITY-20260917-134423/`（第二輪），各含機器可讀的 JSONL、SARIF 與版本戳記。兩個目錄都有自己的 `.gitignore`，不會進版控。

**修正卡當時未開**——決策者 2026-09-17 裁示：全部掃完分類後統一開；後續已統一派工並修完（1.21.0 出貨）。
