---
title: V2 檢查結果：任務問卷填答全鏈（jedi-survey）
---

# V2 檢查結果：任務問卷填答全鏈（jedi-survey）

> 檢查日期 2026-09-17｜對應卡片 CM-1881｜檢查範圍 31 個檔案

## 🔴 一句話結論

**七條發現全部通過三位檢查員投票——兩條嚴重、五條中等，而且全部是同一個病根：2026-07 補的那道門只裝在「寫」的路上，「讀」的路一扇門都沒裝。**

白話講整件事：稽核任務指派某人填問卷，2026-07 那次補洞（FR-048）定下的規則是「只有被指派的人本人、或專案管理者，才能動這份填答」。這道檢查**確實裝上去了，而且裝得正確**——存答案、暫存檢查點、單題修改、歷史還原，四條寫入路徑逐條都有。**但同一批端點裡的「讀」——看別人填了什麼、看填答歷史、列出所有任務問卷——一個檢查都沒有。** 任何一個登入帳號（不必是被指派的人、不必參與那個專案、不必有任何角色）都能把整個客戶範圍內所有問卷的答案讀回來，連空白請求都行。

另外開卡時懷疑的**資料庫租戶隔離可能失效，實測證明是虛驚**——我直接連開發資料庫用唯讀查詢驗過，隔離正常運作（詳見下方第 ⑦ 項）。所以這七條的影響範圍是**「同一個客戶內部跨專案、跨部門」**，不是跨客戶。這個界線很重要：對 SaaS 來說是內部越權，對落地版單一客戶部署來說是「員工可以看到不該看的別部門稽核答案」。

## 這一棒在檢查什麼

稽核任務把問卷指派給某人之後，會發生的那一整串事情，就是本棒的主題：

1. **他填答案**（存檔、暫存檢查點、單題即時修改）
2. **看歷史版本並還原**（存過的每一版都留著，可以挑一版還原回去）
3. **從 Excel 匯入答案**（管理者批次灌）
4. **多人同時填時畫面即時同步**（socket 連線，同一份問卷開一個「房間」互相廣播）
5. **管理者設定這份問卷要不要送審**

要驗的核心只有一件事：**2026-07 補上去的那道「只有被指派者或專案管理者能動」的檢查，是不是每一條路都走過它。**

掃描目標 `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey`，revision `90f0ce3f`（branch `feature/review`），mode `scan`，effort `low`，focus 未設（範圍已經夠小，不必再篩）。範圍是 31 個受版控檔案，啟動前已核對數量相符。

⚠️ **開卡時記的 revision 是 `6119da5`，實際掃的是 `90f0ce3`**——這個 monorepo 是多個套件共用一份 git，中間那幾筆提交都在別的套件（證據分類）推進，`jedi-survey/` 這 31 個檔案本身沒有被動過。工作區當下有兩個未提交的檔，也都在別的套件，不在本棒範圍內。

刻意排除在本棒之外的是**建題那半邊**（建問卷、資料夾、分頁、題目、討論五條 CRUD，加上守門殼、插件組裝、資料庫遷移腳本）——那是下一棒 CM-1882 的範圍。

## Coverage

31 個檔以單一元件讀完。低強度跑法不做元件盤點、不做威脅建模、不跑額外的廣度掃描，`completenessCheckOutcome` 為 `not-applicable`（指定範圍的掃描本就不適用）。**這份報告完全不能拿來說 `jedi-survey` 其他地方沒事**，建題那半邊要等 V1。

派出一位研究員、一位回報。中途研究員曾被判定停滯（1641 秒沒有進展）而重派一次，重派後完成。8 個原始候選去重後仍是 8 個，每一條都拿到完整的三票，**24 票全投出，沒有候選遺失、沒有候選未被審、沒有候選被容量上限砍掉**。其中 1 條被三位檢查員一致駁回（0:3），不列入本報告。沒有元件被跳過、沒有桶被裁剪、沒有對抗階段的傷亡。

**有幾條發現的證據鏈延伸到範圍外的檔**：守門 helper 本身（`app/common/task_survey_guard.py`）與 socket 的身分驗證基底類別（`jedi_iam/middleware/socketio_auth.py`）。這兩個是當作背景讀的，不是本棒的稽核對象。

工具沒有實際執行任何程式碼：沒跑測試、沒發請求、沒示範攻擊。七條發現全部是讀原始碼推出來的。**唯一的例外是第 ⑦ 項的資料庫隔離驗證**，那是我用唯讀 `SELECT` 直接對開發資料庫查證的，下方會標明。

## 掃到什麼（總覽）

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 |
|---|--------|---------------------|-----------|------------------|------|
| [F1](#f1) | 🔴 嚴重 | 查「填答歷史明細」的端點完全不檢查這筆資料是不是你的，而且**送空白請求就回全部** | 任何登入帳號可以把整個客戶範圍內**所有問卷的每一版答案、補充說明、審核意見**整包讀走 | 只要有任何一個能登入的帳號。不必被指派、不必參與專案、不必有任何角色 | `api/routes/question_answer_history_detail_route.py:21` |
| [F2](#f2) | 🔴 嚴重 | 「讀某份任務問卷的答案」這條路，同一支 service 裡**只有它沒有檢查**，其他三支都有 | 讀走別部門、別專案的問卷答案、分數與審核意見 | 有登入帳號 ＋ 知道一組任務問卷編號（F3 那條路一次給你全部） | `api/routes/question_answer_route.py:30` |
| [F3](#f3) | 🟡 中等 | 「列出任務問卷」不限制範圍，送空白請求回整個客戶的全部 | 拿到所有問卷的編號、所屬專案、狀態、設備與部門名稱、經手人名字——同時也是 F2 的入場券 | 有登入帳號 | `api/routes/task_survey_route.py:33` |
| [F4](#f4) | 🟡 中等 | 「列出填答歷史」同樣不限制範圍 | 看出誰在什麼時候改了哪份問卷（跨全部專案），並拿到 F1 與 F6 需要的歷史編號 | 有登入帳號 | `api/routes/question_answer_history_route.py:35` |
| [F5](#f5) | 🟡 中等 | 即時同步那條路，**「這筆是誰填的」直接採用前端自己送上來的名字** | 填答者可以把自己的修改掛在別人名下，稽核紀錄上的「經手人」不再可信 | 要是合法的填答者（被指派者或專案管理者）才打得到 | `app/handler/fill_survey_socketio_handler.py:165` |
| [F6](#f6) | 🟡 中等 | 「還原歷史版本」有檢查你能不能動這份問卷，但**沒檢查你指定的那個歷史版本是不是這份問卷的** | 把別部門問卷的答案複製進自己的問卷裡，然後正常打開就看得到；自己的歷史紀錄也被無聲竄改 | 要是某份問卷的合法填答者 ＋ 兩份問卷用同一套題目 ＋ 知道對方的歷史編號（F4 給） | `app/service/question_answer_history_service.py:56` |
| [F7](#f7) | 🟡 中等 | 即時同步的「房間」想進哪間就進哪間，不檢查你有沒有份 | 旁聽別專案的問卷編輯過程，即時看到他們每一筆修改；還能抓一份當下答案快照 | 有登入帳號 ＋ 知道一組任務問卷編號（F3 給）＋ 用兩個名字各進一次房間 | `app/handler/fill_survey_socketio_handler.py:102` |

**七條全部是新發現，淨新增 7。** 其中 F1／F2／F3／F4 是同一個結構性缺口的四個入口，建議合併成一張修正卡處理；F5 與 F7 同屬 socket 面；F6 獨立。

⚠️ **修正卡本輪不開**（決策者 2026-09-17 裁：等全部掃完分類後統一開）；後續已統一派工並修完（1.21.0 出貨）。

## Findings

<a id="f1"></a>
### F1 — 填答歷史明細端點：不檢查歸屬，空白請求回全部（嚴重，3:0）

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

**這是什麼問題。** 有一支端點叫「查填答歷史明細」，功能是「給我某一版歷史裡每一題的答案」。它把前端送來的 JSON 原封不動當成查詢條件，中間沒有任何一行檢查「這筆資料是不是你的」。更糟的是，**送一個空的 `{}` 過去時，查詢條件會變成空的，資料庫就把整張表回給你**。

為什麼會變成「整張表」：底層那支共用的查詢函式（`get_all_by_fields`）在組條件時，會**跳過所有沒填值的欄位**。所有欄位都沒填 → 一個條件都不產生 → 等於沒有 `WHERE`。

**出事會怎樣。** 客戶內部所有部門、所有專案的稽核問卷答案，包含每一版的歷史、填答者寫的補充說明、以及審核者留下的意見，全部可以被任何一個登入帳號整包讀走。對一個「合規稽核」產品來說，這些正是最敏感的內容——它們記錄著這家公司在各項控制措施上實際做到什麼程度。

**要先有什麼才打得到。**
- **只要有任何一個能登入的帳號。** 不必被指派任何問卷、不必參與任何專案、不必有任何角色。
- 資料庫的租戶隔離會把範圍限制在**同一個客戶內**（實測確認，見第 ⑦ 項）——所以這是「客戶內部跨部門越權」，不是「跨客戶外洩」。

**在哪裡。**
- 端點：`jedi_survey/api/routes/question_answer_history_detail_route.py:21`（`QuestionAnswerHistoryDetailsRoute.post`），對外路徑 `/api/1.0/project-survey/history-details`
- 服務層：`jedi_survey/app/service/question_answer_history_detail_service.py:19`（`get_all_question_answer_history_detail`）——整支只有三行，沒有任何檢查
- 空條件變全表的機制：`jedi-common` 的 `base_repository_impl.py:459`（`_gen_filters`）

**怎麼修。** 在 `QuestionAnswerHistoryDetailService.get_all_question_answer_history_detail()` 開頭要求呼叫者必須指名一份他有權看的任務問卷（或歷史），解析出 `task_id` 之後呼叫既有的 `assert_task_survey_writer`（或新增一支讀取用的參與者守門）。同時**拒絕沒有縮小到該資源的請求**，不要讓它退化成無條件查詢。

**驗證。** 3/3 三位檢查員一致確認成立。

<a id="f2"></a>
### F2 — 讀某份問卷的答案：同一支 service 裡唯一沒守門的方法（嚴重，3:0）

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

**這是什麼問題。** 這條特別值得看，因為它是**對照組最清楚的一條**。`QuestionAnswerService` 這支服務裡有四個處理答案的方法：

| 方法 | 做什麼 | 有沒有檢查身分 |
|---|---|---|
| `update_task_survey_answer` (`:110`) | 存整份答案 | ✅ 有 |
| `checkpoint_task_survey_answer` (`:244`) | 暫存檢查點 | ✅ 有 |
| `patch_task_survey_answer` (`:395`) | 改單一題 | ✅ 有 |
| **`get_answer_list_by_task_survey_uid` (`:90`)** | **讀答案** | ❌ **沒有** |

三個「寫」的都有，唯一一個「讀」的沒有。這不是遺漏了某個角落，而是 2026-07 補洞時**整個「讀」的類別沒被納入考慮**。

**出事會怎樣。** 拿到一組任務問卷編號，就能讀走那份問卷的完整答案、補充答案、分數與審核意見——不管那是哪個部門、哪個專案的。編號怎麼拿？F3 那條路送空白請求就會把全部編號列給你。

**要先有什麼才打得到。**
- 有登入帳號。
- 知道一組任務問卷編號——透過 F3 可以一次拿到全部。

**在哪裡。** `jedi_survey/api/routes/question_answer_route.py:30`（`TaskSurveysAnswersRoute.get`），對外路徑 `/api/1.0/project-survey/answers/<uid>`；服務層在 `jedi_survey/app/service/question_answer_service.py:90`。

**怎麼修。** 在 `get_answer_list_by_task_survey_uid()` 取出 `task_survey` 之後、回傳答案之前，比照同檔 `:110` 的寫法呼叫 `assert_task_survey_writer(self.task_assignee_domain_service, self.participant_role_service, task_survey.task_id)`。

**這一改順便解決 F7 的一半**——socket 的快照讀取呼叫的就是這支方法。

**驗證。** 3/3 三位檢查員一致確認成立。

<a id="f3"></a>
### F3 — 列出任務問卷：空白請求回整個客戶的全部（中等，3:0）

**現況**：已修（M05-3，CM-2034）

**這是什麼問題。** 「列出任務問卷」這支端點把前端送來的 JSON 當查詢條件，服務層沒有任何權限檢查。端點只額外加了一個條件「沒有被刪除」，所以送空白請求得到的就是**整個客戶範圍內所有還在的任務問卷**。

**出事會怎樣。** 兩層傷害。第一層是資訊本身——每一筆包含問卷編號、所屬任務與專案編號、問卷名稱、目前狀態、設備名稱、部門名稱，以及建立者與最後修改者的帳號，等於把整個組織的稽核工作分佈圖攤開。第二層更關鍵：**它是 F2 與 F7 的入場券**，那兩條都需要「知道一組任務問卷編號」，而這條一次把全部給你。

**為什麼只算中等不算嚴重。** 它洩漏的是「有哪些問卷、歸誰」這種結構資訊，不是答案內容本身。真正把答案讀出來要靠 F2。

**在哪裡。** `jedi_survey/api/routes/task_survey_route.py:33`（`TaskSurveysRoute.post`），對外路徑 `/api/1.0/task-surveys`；服務層 `jedi_survey/app/service/task_survey_service.py:59`（`get_task_surveys`）。

**怎麼修。** 要求請求必須帶專案或任務範圍，並用既有的專案角色守門確認呼叫者有參與；或者在服務層把結果過濾成「呼叫者有參與的專案底下的任務問卷」。

**驗證。** 3/3 三位檢查員一致確認成立。

<a id="f4"></a>
### F4 — 列出填答歷史：同樣不限制範圍（中等，3:0）

**現況**：已修（M05-4，CM-2034）

**這是什麼問題。** 與 F3 同一個模式，只是換一張表。「列出填答歷史」把前端 JSON 當查詢條件，服務層 `get_all_question_answer_history` 沒有任何檢查，空白請求回整個客戶的全部歷史紀錄。

**值得注意的對照**：同一個 class 裡的 `revert_question_answer_from_history`（還原歷史）**是有守門的**（`:53`）。所以這又是一次「同一支服務裡，寫的有守、讀的沒守」。

**出事會怎樣。** 看得出誰在什麼時候改了哪一份問卷，跨全部專案——這本身就是組織行為資訊。更實際的傷害是它提供了 F1 與 F6 需要的「歷史編號」。

**在哪裡。** `jedi_survey/api/routes/question_answer_history_route.py:35`（`QuestionAnswerHistoriesRoute.post`），對外路徑 `/api/1.0/project-survey/histories`；服務層 `jedi_survey/app/service/question_answer_history_service.py:35`。

**怎麼修。** 要求請求必須帶 `task_survey_uid`（或 `task_survey_id`），解析出來之後套用同 class 的 `revert_question_answer_from_history` 已經在用的那道守門，再去查。

**驗證。** 3/3 三位檢查員一致確認成立。

<a id="f5"></a>
### F5 — 即時同步：「這筆是誰填的」直接採用前端送來的名字（中等，2:1）

**現況**：已修（M05-9，CM-2060）

**這是什麼問題。** 多人同時填問卷時走 socket 連線。每次改一題，前端會送一包資料過來，裡面除了「改了哪一題、改成什麼」之外，還有一個 `user` 欄位。後端**直接拿這個 `user` 當作「這筆答案是誰填的」寫進資料庫**（`created_user` / `updated_user` 兩個稽核欄位）。

對照組很明確：**所有走 HTTP 的端點都不是這樣做的**——它們用 `get_user_context().login_name`，也就是從已驗證的登入憑證裡取出來的真實身分（見 `question_answer_route.py:39` 與 `:59`）。

**這不是權限漏洞，是紀錄可信度問題。** 權限檢查本身還是照真實身分跑的（`patch_task_survey_answer` 裡面那道守門擋得住不相干的人），所以攻擊者沒辦法藉此改別人的問卷。他能做的是**改自己有權改的東西，但署別人的名**。

**出事會怎樣。** 稽核紀錄上的「經手人」不再可信。對合規產品而言這是直接的完整性問題——出了事要追責時，資料庫裡寫的名字可能是被造假的。介面上顯示的暱稱也是從這個帳號查出來的，所以整條顯示鏈都會一起錯。

**為什麼只算中等。** 要先是合法的填答者才打得到（被指派者本人或專案管理者），而且只能影響自己有權動的那份問卷。

**在哪裡。** `jedi_survey/app/handler/fill_survey_socketio_handler.py:165`（`on_update` 呼叫 `patch_task_survey_answer` 那一行），`user` 是在 `:151` 從事件內容取出來的。

**怎麼修。** 寫入時不要用 `data['user']`，改用 `get_user_context().login_name`——基底類別 `AuthenticatedNamespace` 每個事件都會重新注入登入身分，所以這個值在 socket handler 裡是拿得到的。前端送來的那個欄位如果廣播時還要用（顯示「某某正在編輯」），保留當顯示用即可，但不要進資料庫。

**驗證。** 2/3 兩位檢查員確認成立，一位認為不算（因為它不造成越權）。**依三票規則，這一條的可信度只能標「中」不能標「高」。**

<a id="f6"></a>
### F6 — 還原歷史版本：不檢查那個版本是不是這份問卷的（中等，3:0）

**現況**：已修（M05-6，FR-114.1-4a）

**這是什麼問題。** 「還原歷史版本」這個動作要兩個參數：**哪份問卷**（`task_survey_uid`）與**還原到哪一版**（`history_uid`）。程式碼確實檢查了第一個——`:53` 有守門，確認你有權動這份問卷。**但第二個參數完全沒驗**：拿到 `history_uid` 之後直接把那一版的每一題答案複製進你的問卷，從頭到尾沒有比對「這個歷史版本本來是不是屬於這份問卷的」。

**出事會怎樣。** A 部門的填答者可以指定 B 部門問卷的某個歷史版本，把 B 的答案複製進自己的問卷裡，然後用正常的方式打開自己的問卷就看到 B 的內容。同時他自己的歷史紀錄也被寫進一筆造假的資料（記成是他填的）。另外，如果給一個不存在的歷史編號，程式會對 `None` 取屬性而回 500 錯誤。

**為什麼只算中等。** 需要同時滿足三個條件：要是某份問卷的合法填答者、兩份問卷要用同一套題目（答案是按題目編號對應複製的，題目對不上就不會複製）、還要知道對方的歷史編號（F4 那條路會給）。

**在哪裡。** `jedi_survey/app/service/question_answer_history_service.py:56`（`revert_question_answer_from_history` 取歷史那一行）。守門在 `:53`，複製迴圈在 `:78-90`。

**怎麼修。** 在 `:56` 取出 `answer_history` 之後，加一行比對：

```python
if answer_history is None or answer_history.task_survey_id != task_survey.id:
    raise NotFound(ErrorCode.TASK_SURVEY_QUESTION_ANSWER_HISTORY_NOT_FOUND)
```

這同時解決「不存在的編號會 500」那個附帶問題。

**驗證。** 3/3 三位檢查員一致確認成立。

<a id="f7"></a>
### F7 — 即時同步的房間：想進哪間就進哪間（中等，3:0）

**現況**：已修（M05-7，CM-2034）

**這是什麼問題。** 多人同時填問卷時，同一份問卷的人會進同一個「房間」，房間名稱就是那份問卷的編號。**前端說要進哪間就進哪間**——`room` 直接從事件內容取出，拿去呼叫 `join_room`，中間沒有任何檢查。

連線本身是有驗身分的（基底類別 `AuthenticatedNamespace` 在連線時驗過登入憑證，每個事件也會重新注入身分），**但沒有任何一行檢查「這個身分可以進哪些房間」**。

**出事會怎樣。** 兩種傷害。第一，**旁聽**：進了房間之後，那份問卷每一筆修改都會即時廣播給你，包含答案內容。第二，**一次性快照**：`on_join` 裡有一段邏輯是「房間裡如果超過一個人，就從資料庫撈一份當下的完整答案回傳給新加入的人」——所以只要讓房間人數變成兩個，就能把整份答案拿到手。

**怎麼讓人數變成兩個？** 房間的「在場名單」也是用前端送來的 `user` 欄位記的（沒有用真實身分），所以**同一個人用兩個不同名字各進一次**，名單上就有兩個人了。

**要先有什麼才打得到。**
- 有登入帳號（能建立 socket 連線）。
- 知道一組任務問卷編號當房間名——F3 那條路給。
- 快照那一半需要製造「兩個人在場」，用兩個名字各 join 一次即可。

**在哪裡。** `jedi_survey/app/handler/fill_survey_socketio_handler.py:102`（`join_room(room)`）；`room` 在 `:65` 取出；快照讀取在 `:86`。

**怎麼修。** 兩件事分開做：
1. **快照那一半**：修好 F2（在 `get_answer_list_by_task_survey_uid` 加守門）就自動解決，因為快照呼叫的就是那支方法。
2. **房間成員那一半**：在 `join_room(room)` 之前，把 `room` 解析成對應的任務問卷，套用與 HTTP 端點相同的守門。同時在場名單的身分也改用 `get_user_context()` 取，不要用前端送的。

**驗證。** 3/3 三位檢查員一致確認成立。

---

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

工具不照卡片清單走，以下是首腦逐項回頭核對的結果。**工具沒碰的部分自行開檔或查資料庫查證，並標明。**

### ① 守門 helper 的呼叫點對照 route 表

**（部分工具已報，缺口由人工補齊完整表格）**

填答側 route 逐條追到 service 的結果：

| # | Route | 對外路徑 | 走到哪支 service | 有沒有守門 |
|---|-------|---------|-----------------|-----------|
| 1 | `TaskSurveysRoute.post` | `POST /task-surveys` | `get_task_surveys` | ❌ **無**（F3）|
| 2 | `TaskSurveyRoute.get` | `GET /task-survey/<uid>` | `get_task_survey_by_uid` (`:71`) | ❌ **無**（工具未報，人工查證）|
| 3 | `TaskSurveyRoute.put` | `PUT /task-survey/<uid>` | `update_task_survey` (`:83`) | ✅ manager (`:89`) |
| 4 | `TaskSurveyImportTemplateRoute.get` | `GET /task-survey/import-template/<task_uid>` | `generate_task_survey_question_import_excel_template_data` (`:183`) | ❌ **無**（工具未報，人工查證；影響見下）|
| 5 | `ProjectTaskSurveyAnswerUploadRoute.post` | `POST /task-survey/import-answers` | `batch_import_task_survey_questions` (`:262`) | ✅ manager (`:273`) |
| 6 | `TaskSurveyConfiguredRoute.put` | `PUT /task-survey/configured/<uid>` | `update_task_survey_configure` (`:551`) | ✅ manager (`:569`) |
| 7 | `TaskSurveysAnswersRoute.get` | `GET /project-survey/answers/<uid>` | `get_answer_list_by_task_survey_uid` (`:90`) | ❌ **無**（F2）|
| 8 | `TaskSurveysAnswersRoute.put` | `PUT /project-survey/answers/<uid>` | `update_task_survey_answer` (`:104`) | ✅ writer (`:110`) |
| 9 | `TaskSurveyAnswerPatchRoute.post` | `POST /project-survey/answers/<uid>/patch` | `patch_task_survey_answer` (`:376`) | ✅ writer (`:395`) |
| 10 | `TaskSurveyAnswerCheckpointRoute.post` | `POST /project-survey/answers/<uid>/checkpoint` | `checkpoint_task_survey_answer` (`:217`) | ✅ writer (`:244`) |
| 11 | `QuestionAnswerHistoriesRoute.post` | `POST /project-survey/histories` | `get_all_question_answer_history` (`:35`) | ❌ **無**（F4）|
| 12 | `QuestionAnswerHistoryMenuRoute.get` | `GET /project-survey/histories/menu/<uid>` | `get_question_answer_history_menu` (`:42`) | ❌ **無**（工具未報，人工查證）|
| 13 | `QuestionAnswerHistoryDetailsRoute.post` | `POST /project-survey/history-details` | `get_all_question_answer_history_detail` (`:19`) | ❌ **無**（F1）|
| 14 | `RevertQuestionAnswerHistoryRoute.post` | `POST /project-survey/history/revert` | `revert_question_answer_from_history` (`:49`) | ✅ writer (`:53`)，但歸屬不驗（F6）|

**結論一句話：14 條路裡，6 條寫入路徑全部有守門（6/6），8 條讀取路徑一條都沒有（0/8）。** 分界線乾淨到不可能是巧合——這是 FR-048 補洞時整個「讀」的類別沒被納入範圍。

**工具漏報的兩條，人工補記：**

- **第 2 條 `TaskSurveyRoute.get`**（工具未報，人工查證）：讀單一任務問卷的中繼資料。洩漏程度比 F2 輕（沒有答案內容），但同屬缺口，修 F3 時應一併處理。
- **第 4 條 `TaskSurveyImportTemplateRoute.get`**（工具未報，人工查證）：下載 Excel 匯入範本。**卡片特別問「產範本時會不會把現有答案帶進去」——開檔核對後答案是：不會。** `generate_task_survey_question_import_excel_template_data` 組出來的欄位是 `No`／`Question`／`Answer_N`，其中 `Answer_N` 填的是**題目的選項文字**（從 `survey_question` 的 options 取），不是任何人填過的答案。所以它洩漏的是「這份問卷有哪些題目與選項」，不是填答內容。**卡片這一項的懷疑不成立。**
- **第 12 條 `QuestionAnswerHistoryMenuRoute.get`**（工具未報，人工查證）：歷史版本清單（選單用）。與 F4 同型但走不同方法，同樣無守門，修 F4 時應一併處理。

### ② 守門 helper 本身的邏輯

**（工具未報，人工查證）**

卡片問了三個具體問題，逐一回答：

**問題一：task 沒有任何指派列時會怎樣？**
`_resolve_project_id_by_task` 查不到會回 `None` → manager 那個分支因為 `project_id is None` 直接跳過 → 只剩「assignee 本人」一條路，若那條也查不到就走到最後 `raise ForbiddenError`。**這是正確的「出錯時預設擋下來」，不是放行。**

**問題二：`task_assignee_domain_service` 沒被注入（是 `None`）時會怎樣？**
逐行追：
- assignee 分支的條件是 `task_id is not None and task_assignee_domain_service is not None` → 因為是 `None`，**跳過**
- `_resolve_project_id_by_task` 開頭就是 `if task_assignee_domain_service is None ... return None` → `project_id` 得到 `None` → manager 分支的條件 `project_id is not None` 不成立，**跳過**
- 兩個分支都跳過 → 落到函式最後一行

**確認：最後一行是 `raise ForbiddenError(ErrorCode.TASK_SURVEY_NOT_ASSIGNEE)`，是 raise 不是 return。** 卡片這一項要確認的事確認了——**宿主忘了注入的話，結果是全部擋下（大家都用不了），不是全部放行。** 這是安全的失敗方向，壞掉會立刻被發現。

**問題三：`ParticipantRole.MANAGER` 的比對語意。**
`assert_task_survey_writer` 用 `role == ParticipantRole.MANAGER` 精確比對，所以 viewer／member 都不算 manager，不會誤放。`assert_task_survey_manager` 則改走套件版的 `assert_project_manager`（canonical），語意一致。

### ③ socketio 房間

**（工具已報 F5、F7，以下補工具沒講的部分）**

- **連線時怎麼驗身分**：`on_connect` 本身只回一句歡迎訊息不做驗證，**真正的驗證在基底類別** `jedi_iam.middleware.socketio_auth.AuthenticatedNamespace` ——它在連線階段用與 HTTP 同一把 `decode_token` 驗簽與驗期，驗不過丟 `ConnectionRefusedError`，並在每個事件重新注入登入身分。**所以「連線要不要驗」這件事是有做的**，缺的是「進哪個房間」的檢查（F7）。
- **Redis 在場名單誰都能寫**：`fill-survey:<room>:users` 的成員名字來自前端 `user` 欄位（`:65`、`:112`），沒有用真實身分。這正是 F7 能靠「一人兩名」湊到兩人的原因。
- **`emit('reload', ...)` 由 HTTP 端點觸發**：`question_answer_history_route.py:57` 還原成功後對房間廣播。這一條本身有守門（`:53`），但**廣播對象是整個房間**——若房間裡有 F7 混進來的旁聽者，他也會收到通知。這是 F7 的延伸影響，不另計一條。

### ④ 答案 Excel 匯入

**（工具未報，人工查證）**

卡片問了四件事：

- **`secure_filename` 有沒有做** → ✅ 有（`task_survey_route.py:143`），檔名先過濾再取副檔名。
- **`load_workbook` 有沒有 `read_only` 或大小上限** → **這裡要澄清一個誤會**：`load_workbook` 在這支檔案裡（`:96`）是用在**下載範本**的路徑，讀的是程式自己剛產生的檔，不是使用者上傳的檔。**使用者上傳的檔走的是 `pd.read_excel`（`:153`）**。這條路徑**沒有檔案大小上限、沒有列數上限**，`pandas` 會把整份試算表載進記憶體。上傳一個超大的 Excel 可以造成記憶體壓力。**但打得到這條路的人必須是專案管理者**（`batch_import_task_survey_questions` 有 manager 守門，`:273`），而且它只影響服務可用性不涉及資料外洩，所以我判定**不足以單獨開一條發現**，記在這裡備查。
- **匯入後寫答案走哪條路** → 走 `batch_import_task_survey_questions` 內部的寫入，**不是** `update_task_survey_answer`。守門在該方法開頭（`:273`，manager 級），比填答守門更嚴格。✅ 沒問題。
- **範本會不會把現有答案帶進去** → ❌ 不會，見上方 ① 第 4 條的說明。**卡片這一項的懷疑不成立。**

### ⑤ 歷史還原的歸屬比對

**（工具已報 F6）** 卡片的懷疑**完全命中**——`history_uid` 與 `task_survey_uid` 確實是分開傳的，而且確實沒有比對歸屬。詳見 F6。

### ⑥ 管理端點

**（部分工具未報，人工查證）**

- **三支 manager 守門都在**：`update_task_survey`（`:89`）、`batch_import_task_survey_questions`（`:273`）、`update_task_survey_configure`（`:569`）。✅
- **卡片問「`survey_uid` 可換，能不能把別租戶的問卷換進來」** → **不能。** `update_task_survey_configure` 換問卷時走 `verify_survey_exist_by_uid(survey_uid)`（`:580`），這支查的是 `surveys` 表，而**`surveys` 是少數直接有租戶欄位的表之一**，它的隔離規則是直接比對租戶（`app_tenant_allowed_for_session(tenant_id)`），不靠 join 反查。所以別租戶的問卷在查詢階段就看不到，會直接 `NotFound`。**卡片這一項的懷疑不成立。**
- `_reconcile_task_surveys` / `_replace_survey_snapshots` 在宿主 `survey_handler.py`，屬主專案接線那一棒的範圍（卡片也標為越界可讀不可深追），本棒不展開。

### ⑦ 資料庫租戶隔離的 join 鏈 —— 🔴 **懷疑不成立，實測推翻**

**（工具未報，人工以唯讀查詢實測）**

這是卡片標的「第二大嫌疑」，也是本棒**唯一一個被實測推翻**的假設。值得完整記錄，因為推理方向是對的、結論卻是反的。

**懷疑是什麼。** 問卷相關的 15 張表裡，只有 `surveys` 與 `survey_folders` 有「這是哪個客戶的」欄位，其餘 13 張靠隔離規則裡的 `EXISTS(... JOIN ...)` 一路往上反查。開卡時截到的 `question_answers` 規則長這樣：

```sql
EXISTS (SELECT 1 FROM survey_questions q JOIN survey_pages p ON p.id = q.page_id
        WHERE q.id = question_answers.question_id)
```

**表面看只驗了「這個題目存在，而且掛在某一頁上」——完全沒有提到客戶。** 如果字面理解，這等於「只要有這筆資料就放行」，隔離形同虛設。

**實測方法。** 連 DEV 資料庫（`localhost:5432` / `guidant_ai_dev`），用受隔離限制的帳號 `cm_app`，把「你屬於哪個客戶」的 session 變數設成不同值，數各表看得到幾筆。**全程唯讀，只有 `SELECT` 與 `SET`。**

| 設定 | `surveys` | `survey_questions` | `question_answers` | `qa_history_details` | `task_surveys` |
|---|---|---|---|---|---|
| 超級管理員（看全部） | 52 | 1848 | 357 | 4059 | 23 |
| 租戶 `/1/102/`（Billows，擁有 44 份問卷） | 44 | 1263 | **357** | **4059** | 22 |
| 租戶 `/1/158/`（JEDI，沒有自己的問卷） | 0 | 0 | **0** | **0** | 0 |
| 不存在的路徑 | 0 | 0 | **0** | **0** | 0 |

**關鍵在最後兩列。** 如果 join 鏈真的斷了，沒有任何問卷的租戶 158 應該還是看得到全部 357 筆答案（因為規則只驗「題目存在」）。**實際上它看到 0 筆。**

**為什麼？** 因為 PostgreSQL 在執行隔離規則裡的子查詢時，**會對子查詢引用的每一張表再套用那張表自己的規則**。所以這條鏈實際上是層層收斂的：

```
question_answers → survey_questions（也有規則）→ survey_pages（也有規則）→ surveys（有租戶欄，直接比對）
```

鏈的終點確實走到了 `surveys.tenant_id`，只是**不寫在同一條規則的文字裡**，而是由資料庫在執行時自動串起來。同理 `task_surveys` 的規則反查 `compliance.workflow_executions`，那張表也有直接比對租戶的規則。

**至於租戶 102 為什麼看到的數字等於全庫**——那是因為這個開發資料庫裡的答案資料**本來就全部屬於租戶 102**（用超級管理員視角 join 上去確認過：357 筆答案與 4059 筆歷史明細，100% 歸 102）。不是隔離失效，是資料剛好只有一家。

**結論：卡片第 ⑦ 項的懷疑不成立，隔離機制運作正常。** 這也是為什麼本報告七條發現全部限定在「同一個客戶內部」——跨客戶那條路被資料庫擋住了。

⚠️ **一個必須講明的限制**：這是在 DEV 測的，而且測的是 `cm_app` 這個受隔離的帳號。如果正式環境的服務是用繞過隔離的帳號（`cmmgr`）連線，上面這整套保護就不存在。**本棒沒有查證正式環境用哪個帳號**，那超出範圍。

**首腦補查（2026-09-17）**：落地版安裝程式產生的應用設定寫的是 `DB_USER=cm_app`（`scripts/installer/install.sh:1386`），也就是**受隔離的那個帳號**；繞過隔離的 `cmmgr` 只在裝機與備份還原時使用，服務日常運轉不會用到它。**所以上面這套隔離保護在正式環境同樣成立**，本報告七條發現的影響範圍確定是「同一個客戶內部跨專案、跨部門」，不會跨客戶。

### ⑧ 任務型別宣告與狀態機

**（工具未報，人工查證）**

卡片問「checkpoint 的 status 由前端送來，能不能跳狀態、能不能把已審核的打回」。

**答案：不能，這一項有守。** `checkpoint_task_survey_answer` 在更新狀態前會呼叫 `assert_legal_survey_status_transition(task_survey.status, status)`（`question_answer_service.py:329`；`update_task_survey_answer` 在 `:174` 也有）。那支函式維護一張「目前狀態 → 允許轉到哪些狀態」的對照表（`app/common/task_survey_status_transition.py:29`），不在表上的組合直接拒絕。

**卡片這一項的懷疑不成立。**

（`task_type_declaration.py` 屬 V1 範圍，本棒只讀呼叫端，未深入。）

---

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

**分兩層講，這兩層的可信度差很多。**

### 第一層：「這七條是真的嗎」→ 可信度高

- 七條全部通過三位獨立檢查員投票，其中六條是 3:0 一致通過，一條（F5）是 2:1。
- **24 票全數投出，沒有一票遺失、沒有候選未被審。** 這在這個掃描系列裡是難得的完整——前幾個 arc 常因為範圍太大導致檢查員大量陣亡。31 檔這個規模是跑得動的。
- F1～F4 這四條我**逐條開檔核對過**，程式碼與工具描述相符；空條件變全表的機制也追到共用函式的原始碼確認。
- F5 的可信度只能標「中」——一位檢查員認為它不算漏洞（理由是它不造成越權）。**這個異議有道理**：它確實不是權限問題，是紀錄可信度問題。是否要修屬產品決策。

### 第二層：「只有這七條嗎」→ **不能這樣說**

- **低強度跑法只派一位研究員讀完 31 檔**，沒有元件盤點、沒有威脅建模、沒有第二輪廣度掃描。找到的是「一位仔細的人讀一遍會看到的東西」。
- **研究員中途停滯過一次被重派**。重派後完成，但無法排除第一次已經讀過的部分在重派後讀得比較快。
- **工具漏了三條 route**（上方 ① 表格第 2、4、12 條），是我人工補的。這說明工具的覆蓋不是窮盡的——**快篩會被最顯眼的檔吸走**，本棒最顯眼的是 612 行的 `task_survey_service.py`。
- **卡片八個重點裡，工具只主動碰了三個**（①的一部分、③、⑤）。②④⑥⑦⑧ 全部是人工補查的，其中 ④⑥⑦⑧ 四項的懷疑經查證**不成立**——這是好消息，但也說明如果只看工具報告，會不知道這四項已經被排除。
- **建題那半邊完全沒看**，不能說 `jedi-survey` 整體如何。

## 執行概況

| 項目 | 值 |
|---|---|
| 掃描目標 | `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey` |
| revision | `90f0ce3fd152`（branch `feature/review`，工作區有別套件的未提交改動故標 `-dirty`）|
| mode / effort / focus | `scan` / `low` / 未設 |
| 範圍 | 31 個受版控檔案（啟動前核對相符）|
| run ID | `wf_11595d3b-2f8` |
| 研究員 | 派 1 位、回 1 位（中途停滯 1641 秒重派一次，重派後完成）|
| 候選 | 原始 8 → 去重後 8 |
| 面板票數 | 8 條 × 3 位檢查員 = **24 票全數投出**，0 條未審、0 條被容量上限砍掉 |
| 通過 / 駁回 | 通過 7（3:0 六條、2:1 一條）／駁回 1（0:3）|
| 驗證章 | `verified` |
| 總執行時間 | 約 124 分鐘（7,433 秒）|
| 子 agent 用量 | 25 個 agent、3,322,974 tokens、1,548 次工具呼叫 |
| 工具產物 | `jedi-survey/CLAUDE-SECURITY-20260917-045317/`（含 `.gitignore`，不入版控）|
