檢查日期 2026-09-17|對應卡片 CM-1881|檢查範圍 31 個檔案
七條發現全部通過三位檢查員投票——兩條嚴重、五條中等,而且全部是同一個病根:2026-07 補的那道門只裝在「寫」的路上,「讀」的路一扇門都沒裝。
白話講整件事:稽核任務指派某人填問卷,2026-07 那次補洞(FR-048)定下的規則是「只有被指派的人本人、或專案管理者,才能動這份填答」。這道檢查確實裝上去了,而且裝得正確——存答案、暫存檢查點、單題修改、歷史還原,四條寫入路徑逐條都有。但同一批端點裡的「讀」——看別人填了什麼、看填答歷史、列出所有任務問卷——一個檢查都沒有。 任何一個登入帳號(不必是被指派的人、不必參與那個專案、不必有任何角色)都能把整個客戶範圍內所有問卷的答案讀回來,連空白請求都行。
另外開卡時懷疑的資料庫租戶隔離可能失效,實測證明是虛驚——我直接連開發資料庫用唯讀查詢驗過,隔離正常運作(詳見下方第 ⑦ 項)。所以這七條的影響範圍是**「同一個客戶內部跨專案、跨部門」**,不是跨客戶。這個界線很重要:對 SaaS 來說是內部越權,對落地版單一客戶部署來說是「員工可以看到不該看的別部門稽核答案」。
稽核任務把問卷指派給某人之後,會發生的那一整串事情,就是本棒的主題:
要驗的核心只有一件事: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 的範圍。
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 | 🔴 嚴重 | 查「填答歷史明細」的端點完全不檢查這筆資料是不是你的,而且送空白請求就回全部 | 任何登入帳號可以把整個客戶範圍內所有問卷的每一版答案、補充說明、審核意見整包讀走 | 只要有任何一個能登入的帳號。不必被指派、不必參與專案、不必有任何角色 | api/routes/question_answer_history_detail_route.py:21 |
| F2 | 🔴 嚴重 | 「讀某份任務問卷的答案」這條路,同一支 service 裡只有它沒有檢查,其他三支都有 | 讀走別部門、別專案的問卷答案、分數與審核意見 | 有登入帳號 + 知道一組任務問卷編號(F3 那條路一次給你全部) | api/routes/question_answer_route.py:30 |
| F3 | 🟡 中等 | 「列出任務問卷」不限制範圍,送空白請求回整個客戶的全部 | 拿到所有問卷的編號、所屬專案、狀態、設備與部門名稱、經手人名字——同時也是 F2 的入場券 | 有登入帳號 | api/routes/task_survey_route.py:33 |
| F4 | 🟡 中等 | 「列出填答歷史」同樣不限制範圍 | 看出誰在什麼時候改了哪份問卷(跨全部專案),並拿到 F1 與 F6 需要的歷史編號 | 有登入帳號 | api/routes/question_answer_history_route.py:35 |
| F5 | 🟡 中等 | 即時同步那條路,「這筆是誰填的」直接採用前端自己送上來的名字 | 填答者可以把自己的修改掛在別人名下,稽核紀錄上的「經手人」不再可信 | 要是合法的填答者(被指派者或專案管理者)才打得到 | app/handler/fill_survey_socketio_handler.py:165 |
| F6 | 🟡 中等 | 「還原歷史版本」有檢查你能不能動這份問卷,但沒檢查你指定的那個歷史版本是不是這份問卷的 | 把別部門問卷的答案複製進自己的問卷裡,然後正常打開就看得到;自己的歷史紀錄也被無聲竄改 | 要是某份問卷的合法填答者 + 兩份問卷用同一套題目 + 知道對方的歷史編號(F4 給) | app/service/question_answer_history_service.py:56 |
| 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 出貨)。
現況:已修(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-detailsjedi_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 三位檢查員一致確認成立。
現況:已修(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 那條路送空白請求就會把全部編號列給你。
要先有什麼才打得到。
在哪裡。 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 三位檢查員一致確認成立。
現況:已修(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 三位檢查員一致確認成立。
現況:已修(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 三位檢查員一致確認成立。
現況:已修(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 兩位檢查員確認成立,一位認為不算(因為它不造成越權)。依三票規則,這一條的可信度只能標「中」不能標「高」。
現況:已修(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 之後,加一行比對:
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 三位檢查員一致確認成立。
現況:已修(M05-7,CM-2034)
這是什麼問題。 多人同時填問卷時,同一份問卷的人會進同一個「房間」,房間名稱就是那份問卷的編號。前端說要進哪間就進哪間——room 直接從事件內容取出,拿去呼叫 join_room,中間沒有任何檢查。
連線本身是有驗身分的(基底類別 AuthenticatedNamespace 在連線時驗過登入憑證,每個事件也會重新注入身分),但沒有任何一行檢查「這個身分可以進哪些房間」。
出事會怎樣。 兩種傷害。第一,旁聽:進了房間之後,那份問卷每一筆修改都會即時廣播給你,包含答案內容。第二,一次性快照:on_join 裡有一段邏輯是「房間裡如果超過一個人,就從資料庫撈一份當下的完整答案回傳給新加入的人」——所以只要讓房間人數變成兩個,就能把整份答案拿到手。
怎麼讓人數變成兩個? 房間的「在場名單」也是用前端送來的 user 欄位記的(沒有用真實身分),所以同一個人用兩個不同名字各進一次,名單上就有兩個人了。
要先有什麼才打得到。
在哪裡。 jedi_survey/app/handler/fill_survey_socketio_handler.py:102(join_room(room));room 在 :65 取出;快照讀取在 :86。
怎麼修。 兩件事分開做:
get_answer_list_by_task_survey_uid 加守門)就自動解決,因為快照呼叫的就是那支方法。join_room(room) 之前,把 room 解析成對應的任務問卷,套用與 HTTP 端點相同的守門。同時在場名單的身分也改用 get_user_context() 取,不要用前端送的。驗證。 3/3 三位檢查員一致確認成立。
工具不照卡片清單走,以下是首腦逐項回頭核對的結果。工具沒碰的部分自行開檔或查資料庫查證,並標明。
(部分工具已報,缺口由人工補齊完整表格)
填答側 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 補洞時整個「讀」的類別沒被納入範圍。
工具漏報的兩條,人工補記:
TaskSurveyRoute.get(工具未報,人工查證):讀單一任務問卷的中繼資料。洩漏程度比 F2 輕(沒有答案內容),但同屬缺口,修 F3 時應一併處理。TaskSurveyImportTemplateRoute.get(工具未報,人工查證):下載 Excel 匯入範本。卡片特別問「產範本時會不會把現有答案帶進去」——開檔核對後答案是:不會。 generate_task_survey_question_import_excel_template_data 組出來的欄位是 No/Question/Answer_N,其中 Answer_N 填的是題目的選項文字(從 survey_question 的 options 取),不是任何人填過的答案。所以它洩漏的是「這份問卷有哪些題目與選項」,不是填答內容。卡片這一項的懷疑不成立。QuestionAnswerHistoryMenuRoute.get(工具未報,人工查證):歷史版本清單(選單用)。與 F4 同型但走不同方法,同樣無守門,修 F4 時應一併處理。(工具未報,人工查證)
卡片問了三個具體問題,逐一回答:
問題一:task 沒有任何指派列時會怎樣? _resolve_project_id_by_task 查不到會回 None → manager 那個分支因為 project_id is None 直接跳過 → 只剩「assignee 本人」一條路,若那條也查不到就走到最後 raise ForbiddenError。這是正確的「出錯時預設擋下來」,不是放行。
問題二:task_assignee_domain_service 沒被注入(是 None)時會怎樣? 逐行追:
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),語意一致。
(工具已報 F5、F7,以下補工具沒講的部分)
on_connect 本身只回一句歡迎訊息不做驗證,真正的驗證在基底類別 jedi_iam.middleware.socketio_auth.AuthenticatedNamespace ——它在連線階段用與 HTTP 同一把 decode_token 驗簽與驗期,驗不過丟 ConnectionRefusedError,並在每個事件重新注入登入身分。所以「連線要不要驗」這件事是有做的,缺的是「進哪個房間」的檢查(F7)。fill-survey:<room>:users 的成員名字來自前端 user 欄位(:65、:112),沒有用真實身分。這正是 F7 能靠「一人兩名」湊到兩人的原因。emit('reload', ...) 由 HTTP 端點觸發:question_answer_history_route.py:57 還原成功後對房間廣播。這一條本身有守門(:53),但廣播對象是整個房間——若房間裡有 F7 混進來的旁聽者,他也會收到通知。這是 F7 的延伸影響,不另計一條。(工具未報,人工查證)
卡片問了四件事:
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 級),比填答守門更嚴格。✅ 沒問題。(工具已報 F6) 卡片的懷疑完全命中——history_uid 與 task_survey_uid 確實是分開傳的,而且確實沒有比對歸屬。詳見 F6。
(部分工具未報,人工查證)
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,屬主專案接線那一棒的範圍(卡片也標為越界可讀不可深追),本棒不展開。(工具未報,人工以唯讀查詢實測)
這是卡片標的「第二大嫌疑」,也是本棒唯一一個被實測推翻的假設。值得完整記錄,因為推理方向是對的、結論卻是反的。
懷疑是什麼。 問卷相關的 15 張表裡,只有 surveys 與 survey_folders 有「這是哪個客戶的」欄位,其餘 13 張靠隔離規則裡的 EXISTS(... JOIN ...) 一路往上反查。開卡時截到的 question_answers 規則長這樣:
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 範圍,本棒只讀呼叫端,未深入。)
分兩層講,這兩層的可信度差很多。
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,不入版控) |