W2 掃描報告 — 任務使用問卷與檢測(CM-2091)

範圍:6 檔/1,522 行(app/flow_control/service/job_handlers/survey_handler.py、job_handlers/__init__.py、app/flow_control/service/job_service.py、app/flow_control/dto/job_dto.py、app/flow_engine/service/job_evidence_service.py、infra/readmodel/tasks/job_batch_complete_query.py)。 掃描工具:Claude Code 官方 claude-security plugin,effort low,focus 生產程式碼。 掃描基準 commit:081b61cc812a(工作區有其他 session 的未提交改動)。 驗證章:verified(5 條候選,面板 15 票全數投出,5 條全部 3:0 成立)。只掃不修。 與 W1(CM-2090,scan-W1.md)同一個 runner 連續跑;第 6 節回答「W1 看到的守門零件,在這條路上有沒有被用到」。


1. 一句話結論

規劃頁的「任務」那組網址,在目前分支上只問「你是不是『網址上那個專案』的管理者」,從來不問「這張任務是不是那個專案的」。 所以甲專案的管理者,把網址裡的任務編號換成乙專案的,就能改掉或刪掉乙專案的任務——連同乙專案問卷的填答資料一起刪(W2-1、W2-2)。讀的那半更鬆:看任務詳情、看任務證明清單,連「是不是專案成員」都不問(W2-3、W2-5)。

這五條裡四條是總表已登記的舊案,而且修正分支 fix/security-b1 已修好(CM-2039、CM-2035),只是還沒合進目前分支。真正新的是「任務清單」那一支(W2-4):修正分支上也沒補,它是另外幾條的「入場券」——拿到清單,就拿到別專案所有任務編號。

跟 W1 接起來看,最要緊的是這件事:W1 查到問卷套件判斷「你能不能看/填這份問卷」,靠的是 task_assignees(任務指派表)裡記的專案編號。而 W2-2 這條路可以替別專案的任務寫一列指派、專案編號填自己的——寫完之後,問卷套件就會把那份問卷當成攻擊者自己專案的,攻擊者同時變成「被指派人」與「專案管理者」,可以讀寫別專案的問卷答案(第 6 節)。修正分支的 CM-2039 已經把這條封住。

卡片點名的其餘幾點都查過:問卷掛上任務時的客戶歸屬由資料庫隔離規則擋住(跨客戶打不到)、檢測工具帳密兩個出口都有遮、批次完成查詢有綁任務。理由在第 5 節。


2. 這一棒在檢查什麼

W1 看的是「主專案把問卷套件接上來的那一層」。W2 看的是另一個方向:主專案自己的「任務」模組,拿套件的東西來用——

  • 規劃頁新增/修改任務時,把問卷掛上去(對每份問卷複製一份「快照」,再依「問卷 × 受檢設備」建立 task_survey 關聯),也把檢測工具綁上去。
  • 任務回給前端時,把檢測工具的帳號密碼遮掉。
  • 任務的證明清單(上傳的檔案、連結、問卷)怎麼讀。
  • 批次完成任務時,檢查每張任務的問卷是不是都填完了。

這些是主專案自己的路,套件的守門管不到這裡。問卷套件擋的是「填答」那幾支網址;規劃頁建立、刪除 task_survey 走的是主專案直接呼叫套件的 domain service,套件那邊的守門根本不會經過。


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才會發生 在哪裡(該補檢查的位置) 嚴重度 總表對照 來源
W2-1 **刪任務時不核對「這張任務是不是網址上那個專案的」 甲專案管理者可以把乙專案的任務整張硬刪,連問卷快照、已填的答案、指派人一起刪,乙專案的「規劃期凍結」也擋不住 ① 是同一客戶內任何一個**專案的管理者 ② 知道乙專案某張任務的編號 app/flow_control/service/job_service.py:211(delete_job 起點) 中 總表第 63 項(部分修);修正分支 CM-2039 已修 工具 3:0
W2-2 改任務時不核對任務歸屬 甲專案管理者可以改乙專案任務的名稱、類型、問卷、設備、檢測工具設定,還能把自己寫進乙專案任務的指派人——接著就能讀寫乙專案那份問卷的答案(第 6 節) 同上 app/flow_control/service/job_service.py:266(update_job 起點) 中 總表第 63 項;CM-2039 已修 工具 3:0
W2-3 任務證明清單不問你是不是專案成員 同客戶內任何人,拿到別專案的任務編號,就看得到那張任務上傳的檔名、檔案編號、連結、問卷快照編號 ① 同客戶內任何登入帳號 ② 知道任務編號 app/flow_engine/service/job_evidence_service.py:46(get_job_evidences_by_job_execution_uid 起點) 中 總表第 7 項(部分修);修正分支 CM-2035 已修 工具 3:0
W2-4 任務清單不問你是不是專案成員(修正分支上也還沒補) 同客戶內任何人,只要知道一個專案編號,就能把那個專案每個查核項目底下的任務全列出來:任務編號、指派人、問卷、檢測工具設定——正是 W2-1~3 要用的「任務編號」的來源 ① 同客戶內任何有 project 模組的登入帳號 ② 知道專案編號(查核項目編號是公開的標準編號,猜得到) app/flow_control/service/job_service.py:244(list_jobs 起點) 中 總表未登記(第 63 項只講詳情,不含清單) 工具 3:0
W2-5 看任務詳情不問你是誰 同客戶內任何人拿任務編號就看得到那張任務的完整設定 ① 同客戶內任何登入帳號 ② 知道任務編號 app/flow_control/service/job_service.py:140(get_job 起點) 低(面板從中調降:只讀一張任務、要先知道編號) 總表第 63 項本身;CM-2039 已修 工具 3:0

另有一條是我讀程式補的、沒經過投票:W2-2 寫進去的那一列指派,會讓 W1 看過的問卷守門判錯專案(第 6 節)。這是 W1 與 W2 的接縫,也就是卡片擔心的「責任真空」。


4. 每條發現的詳述

4.1 W2-1、W2-2、W2-5 規劃頁的「任務」網址只認網址上的專案、不認任務本身屬於誰

白話說明

規劃頁上每張任務的網址長這樣:/grc/project/<專案編號>/job/<任務編號>。看、改、刪都走這個網址。

在目前分支 feature/review 上:

動作 檢查了什麼 沒檢查什麼
看(get_job) 只有登入+買了 project 模組 連「你是不是這個專案的人」都沒問,網址上的專案編號完全沒用到
改(update_job) 你是不是網址上那個專案的管理者 這張任務是不是那個專案的
刪(delete_job) 同上,外加網址上那個專案是否還在規劃期 同上

資料庫的隔離規則(RLS)只擋「不同客戶」,同一個客戶底下的不同專案之間不擋。所以「甲專案管理者+乙專案任務編號」這個組合,在每一層都會被放行。

攻擊情境(刪)

小王是甲專案的管理者,在同一個客戶裡另有乙專案他不在裡面。他從任務清單(W2-4)拿到乙專案某張問卷任務的編號,送出「刪除」,網址填甲專案的編號+乙專案的任務編號。系統確認「小王是甲專案管理者、甲專案在規劃期」,放行;然後照任務編號把乙專案那張任務硬刪。刪除時會連帶清掉 task_survey、問卷快照與已填的答案——這條刪除路徑不會檢查快照有沒有人填過(「改任務時移除問卷」那條路會檢查,有答案就拒絕;刪除這條沒有)。乙專案若已離開規劃期(稽核進行中),這道檢查也擋不住,因為它查的是甲專案的輪次。

攻擊情境(改):見第 6 節,後果比刪更隱蔽。

為什麼是中不是高:要先是同一客戶內某個專案的管理者(不是隨便一個帳號),而且要拿到別專案的任務編號。不跨客戶。

在哪裡

位置 是什麼
app/flow_control/service/job_service.py:140 get_job:該補「是不是網址專案的參與者」+「任務是否屬於網址專案」
app/flow_control/service/job_service.py:211 delete_job:該補「任務是否屬於網址專案」;project_uid 目前還是選填(=None),不傳就連管理者檢查都跳過
app/flow_control/service/job_service.py:266 update_job:該補「任務是否屬於網址專案」
api/flow_control/routes/job_route.py:88 詳情網址收了 project_uid 卻沒傳給 service

修正分支的狀態:fix/security-b1 的 commit 8e33568d2(CM-2039,總表第 63 項)三支都補了——新增 _assert_job_belongs_to_project(),反查任務實際所屬專案與網址比對,對不上一律回「查無」;delete_job 的 project_uid 改必填;get_job 補參與者檢查。我開修正分支的程式碼逐行核對過,修法正確。 本棒不另計,只列出來讓首腦知道目前分支上仍然開著。

要注意的一點:修正分支判斷「任務屬於哪個專案」是看 task_assignees 表的第一列。我在 DEV 唯讀查過(2026-09-23 22:49):41,519 張使用者任務裡,36,851 張沒有任何指派列(大多是流程自動建的)。這些任務在修正分支上會一律回「查無」——安全方向是對的(擋下而非放行),但規劃頁若要看或改一張還沒指派人的任務,會被誤擋。這不是漏洞,是修正卡驗收時要測的情境。


4.2 W2-3 任務證明清單:寫的那半有守、讀的那半沒守

白話說明

每張任務底下可以放「證明」:上傳的檔案、貼的連結、掛的問卷。job_evidence_service.py 裡五支方法,新增、修改、刪除三支都會先問「你是不是這個任務所屬專案的成員」,讀清單和讀單筆兩支不問——典型的「讀的那半沒守」。

會怎樣:同客戶內不在乙專案的人,拿到乙專案任務編號後,可以讀到乙專案那張任務的證明清單:檔名、儲存位置、檔案編號(可接著下載)、校驗碼、連結、問卷快照編號。

為什麼是中:外洩的是稽核證據的中繼資料與下載用的編號;要先拿到任務編號、不跨客戶。

在哪裡:app/flow_engine/service/job_evidence_service.py:46(get_job_evidences_by_job_execution_uid)、:67(get_job_evidences_uid)。該補的就是新增那支在 :87 呼叫的同一道 assert_project_participant。

卡片問的「第 48 行那條還在不在」:還在,就是這一條。FR-088 H2 報的位置是同一支方法,掃描後的 +13/-3 改動沒有碰到它。修正分支 CM-2035(commit e2c32258d,總表第 7 項)已補,兩支讀取都接上了。 本棒不另計。


4.3 W2-4 任務清單不問你是不是專案成員(總表未登記、修正分支也沒補)

白話說明

規劃頁點開一個查核項目,會列出底下所有任務。這支網址是 /grc/project/<專案編號>/.../assessment-object/<查核目標編號>/jobs/list。它只檢查登入+買了 project 模組,list_jobs 本身不問你是不是這個專案的人。

查核目標編號不是隨機產生的,是標準條文的固定編號(例如 AC.L1-3.1.2_obj.1),所以只要知道一個專案編號,就能一個一個條文列下去。

會怎樣:同客戶內任何有 project 模組的人,列出別專案所有任務的編號、名稱、指派人、掛的問卷、檢測工具設定(帳密有遮,見第 5 節)。拿到的任務編號可以直接拿去打 W2-1~3。

為什麼是中不是低:它是其他幾條的入場券,一次就能拿到整個專案的任務。

在哪裡:app/flow_control/service/job_service.py:244(list_jobs 起點)。修法是開頭補一道 assert_project_participant(同一檔案已經 import 的那支),專案編號改必填。

總表對照:第 63 項(M20-3)講的是「任務詳情收了專案編號卻沒用」,範圍是 GET/PUT/DELETE 三支;CM-2039 也只修了那三支。清單這支兩邊都沒涵蓋,修正分支上 list_jobs 跟目前分支一字不差(我用 git show 核對過)。建議登記為新項。


5. 卡片點名要追的問題:逐條結論

① survey_handler.py 的 _reconcile_task_surveys:前端送來的問卷編號,有沒有檢查是同一家客戶的?

跨客戶打不到,由資料庫隔離規則擋住;同客戶內不需要擋。

  • 前端送來的問卷編號不會直接寫進關聯表。它先進 _replace_survey_snapshots(survey_handler.py:159),由套件的 create_snapshot 用這個編號去讀原問卷。讀的時候走的是 cm_app 這個資料庫帳號,surveys 表的讀取規則是「只能讀自己客戶的」(DEV 唯讀查過 surveys_select 規則)。別家客戶的問卷編號讀不到,會拋「問卷不存在」。
  • _reconcile_task_surveys(:39)寫進 task_survey 的問卷編號,來自上一步剛建好的快照,不是前端原始輸入。前端的問卷清單在這支只用來對應「要不要審核」這個開關。
  • 同一客戶內,問卷設計庫本來就是全公司共用(不屬於哪個專案),任何專案都可以引用,所以不需要專案層的檢查。
  • FR-088 H4 當時的疑慮「只驗證存在、沒看到客戶歸屬檢查」:程式碼裡確實沒有寫檢查,但擋在資料庫那一層。不成立,下次不用重查。
  • 這整段的守門在呼叫它的 create_job / update_job(_require_manager)。問題不在這支,在呼叫端沒核對任務歸屬(W2-1、W2-2)。

② job_dto.py 的敏感參數遮罩:每一條把任務回給前端的路,都有經過嗎?

有。檢測工具的參數會從兩條路出去,兩條都有遮:

出口 遮罩位置
規劃頁任務(FlowControlJobDto) job_dto.py:209(工具層 params)、:155、:171(每台分派列)
SSP 控制項實作頁 app/oscal/service/ssp_control_implementation_service.py:588、:626、:657;未遮的暫存欄位 _raw_params 在 :664 輸出前移除

我另外 grep 過 tool_params / params 在 app/ 與 api/ 的所有出口,沒有第三條。不成立。(卡片提到「總表第 85 項:檢測工具帳密可被解密外送」——對照總表,第 85 項其實是問卷署名那條;帳密解密外送是第 4 項(測試連線把帳密送到指定主機)與第 70 項(派工時多存一份明文)。那兩條都是解密那端的問題,不是這裡的遮罩,本棒不重報。)

③ job_evidence_service.py:48(FR-088 H2)那條還在不在?

還在,見 W2-3。修正分支 CM-2035 已修。不重報成新發現。

④ job_service.py 的讀取入口 get_job、list_jobs 有沒有同等檢查?

都沒有。get_job 見 W2-5(修正分支已修),list_jobs 見 W2-4(修正分支也沒修)。「讀的那半沒守」在這支檔案裡完全成立。

⑤ job_batch_complete_query.py:批次完成時查問卷狀態,有沒有綁定「這個任務」?

有。get_job_completion_readiness(:108)依呼叫端給的任務編號清單查 task_surveys.task_id,不會查到清單外的任務。這支查詢本身不做權限判斷,也不該做——守門在呼叫它的 JobBatchCompleteService.batch_complete(範圍外):入口先驗「是不是網址專案的參與者」,每張任務最後再經過 complete_job 裡的「是不是那張任務所屬專案的參與者」(workflow_execution_service.py:660)。不成立。

順帶記一筆:批次完成的「你是不是管理者」是用網址上的專案算的,但每張任務可以來自別的專案。如果某人是甲專案管理者、只是乙專案的檢視者,他送甲專案網址+乙專案任務,就能完成乙專案不是指派給他的任務(complete_job 只問「是不是成員」)。這跟總表 M10「單筆完成時任何參與者都能完成別人的任務」是同一個病,不另計,但修 M10 那條時要記得批次這條入口一起改。


6. W1 看到的守門零件,在 W2 這條路上有沒有被用到

W1 報告第 6 節列了主專案交給問卷套件的守門零件。逐樣對照 W2 這條路:

W1 的零件 在 W2 這條路上 說明
登入檢查 auth_required ✅ 有 任務網址都掛了 @jwt_required()
商務授權 license_guard ⚠️ 換了一個 任務網址掛的是 require_license("project"),不是 survey。沒買問卷模組的客戶,照樣能在規劃頁把問卷掛上任務、建出 task_survey。跟 W1-3 同一類問題
能力點 capability_required ❌ 沒用 任務建改刪用的是專案角色,不看能力點。這是設計,不算缺
專案角色守門 project_role_guard ⚠️ 沒經過零件,直接呼叫 job_service.py 直接 import common.authz.project.assert_project_manager,沒有走 W1 那個 adapter。實作相同所以結果一樣,但判的是網址上的專案,不是任務所屬的專案(W2-1、W2-2)
任務編號換算 task_uid_resolver ❌ 沒用 W2 直接用任務的內部編號,不經換算。換算只給套件查詢用
填答歸屬反查(task_assignees) 🔴 W2 會寫它,而 W1 的守門信任它 見下
即時共編連線身分 不相關 W2 沒有即時連線

最關鍵的接縫——W2 可以偽造 W1 守門依賴的那份資料

W1 查過:問卷套件判斷「你能不能讀/填/管理這份任務問卷」(task_survey_guard.py),是先從 task_assignees 表隨便挑一列,看它記的專案編號,再問你在那個專案是什麼角色。這張表記的專案編號,問卷套件完全相信。

而 W2-2 那條路(update_job 帶 assignees),寫進 task_assignees 的專案編號是網址上的專案,不是任務真正所屬的專案(infra/flow_control/repository/flow_control_job_repo_impl.py:589,指派那段用網址的 project_uid 反查專案編號)。

串起來:

  1. 小王是甲專案管理者。乙專案有一張問卷任務,還沒指派任何人(DEV 上大多數任務都沒有指派列,見 4.1)。
  2. 小王送「修改任務」,網址填甲專案+乙專案任務編號,指派人填自己。系統只驗「小王是甲專案管理者」→ 放行,寫進一列:任務=乙專案那張、專案=甲、使用者=小王。
  3. 現在問卷套件判斷這張任務的問卷時,查到的專案是甲。小王在甲是管理者,也是這張任務的被指派人 → 讀、填、改答案、匯入答案、還原歷史版本全部放行。
  4. 同一份資料也會讓流程引擎把乙專案那條流程判成屬於甲專案(workflow_execution_service.py:149 同樣挑一列指派),小王因此也能在乙專案那條流程上新增/修改證明、完成任務。

這條路只有在「任務還沒有指派列」時才完整;已有指派列的任務,挑到哪一列要看資料庫回傳順序(DEV 目前沒有同一任務混兩個專案的資料,查過 0 筆)。

這就是卡片擔心的責任真空:W1 那邊的守門寫得沒錯、W2 這邊的寫入也各自看起來合理,但 W2 可以寫出一份讓 W1 判錯的資料。

修正分支的狀態:CM-2039 在 update_job 最前面先核對「任務屬於網址專案」,對不上就回「查無」,第 2 步就擋掉了;沒有指派列的任務在修正分支上一律回「查無」,所以也寫不進去。這條接縫在修正分支上已封住,前提是 CM-2039 要合。

但體質上還留一個坑:task_assignees 同時當兩種用途——「誰被指派」和「任務屬於哪個專案」。後者是守門依據,卻存在一張會被前端操作寫入的表裡,而且讀的時候只挑一列。建議首腦考慮讓「任務屬於哪個專案」改成從流程關係推(任務 → 流程 → 專案),不要從指派表推(第 9 節)。


7. 這份結果可信到什麼程度

「這五條存在嗎」——高。三個獨立檢查員對每條各投一票,15 票全數投出、全部 3:0 成立;W2-5 從中調降為低(三票裡兩票評低)。關鍵事實我另外開檔核對過:

  • 三支任務方法與網址層的程式碼、修正分支的 diff(git diff feature/review fix/security-b1)逐行比對。
  • 資料庫隔離規則是 DEV 唯讀查的(pg_policies,2026-09-23 22:49):surveys、job_executions 只分客戶;task_assignees 的寫入規則只驗「專案存在」。
  • 主專案連資料庫用的是 cm_app(受隔離規則約束)。

「第 6 節的接縫」——未經三人面板投票、runner 自行開檔核對。每一步都有對應程式碼,但沒有實際打網址重現。第 4 步(流程引擎也被騙)是依程式碼推的。

「只有這幾條嗎」——不保證。

  1. 最快的掃描檔位(effort low):一輪研究員加一輪投票,沒有威脅建模與廣度掃描。
  2. 研究員有追到範圍外的網址層、repo 層與套件,但範圍外的檔案不是逐支讀。
  3. 本機載入的是修正分支 worktree 的套件(jedi-wt-fix-security),主專案則是 feature/review。
  4. 全程沒有打任何網址、沒有寫資料庫。

8. 執行概況(數字,給工程師看)

項目 數值
掃描範圍 6 檔 / 1,522 行
基準 commit 081b61cc812a(工作區 dirty,有平行 session 改動)
檔位 effort low,focus 生產程式碼(含密鑰專項)
原始候選 5 條,去重後 5 條
投票 三個檢查員 × 5 條 = 15 票,全數投出
票型 W2-1(F1)、W2-2(F2)、W2-3(F3)、W2-4(F4)3:0 評中;W2-5(F5)3:0 成立,從中調降為低
驗證章 verified(CLAUDE-SECURITY-REVISION-081b61cc812a-dirty.json)
工具 run ID wf_3dc14845-314
耗時 約 27 分鐘(17 個 agent,零失敗)
工具產出原始報告 CLAUDE-SECURITY-20260923-142057/(未入版控)
密鑰專項 無新發現

9. 待首腦裁決

  1. W2-4(任務清單)要不要登記成新項(建議要:總表第 63 項與 CM-2039 都只涵蓋詳情三支;它是其他幾條的入場券,修正分支上也還開著)。修法一行:list_jobs 開頭補 assert_project_participant。
  2. W2-1、W2-2、W2-5 → 第 63 項;W2-3 → 第 7 項,都已在修正分支修好,不另計。要確認的是合併時程:目前分支上這四條全開著,而且 W2-2 會打穿 W1 的問卷守門(第 6 節)。
  3. CM-2039 的驗收要補一個情境:規劃頁看/改一張還沒有指派人的任務。修正分支用 task_assignees 判歸屬,DEV 上 89% 的任務沒有指派列,會被誤擋成「查無」。
  4. 「任務屬於哪個專案」的判斷依據要不要改成走流程關係、不走指派表(第 6 節末段)。這是體質問題,影響問卷守門、流程引擎守門、CM-2039 三處,建議開一張分析卡而不是直接修。
  5. 規劃頁掛問卷只驗 project 模組、不驗 survey 模組(第 6 節表格):併進 W1-3 一起裁。
  6. 批次完成的管理者判斷用網址專案(第 5 節⑤):修 M10 單筆完成那條時一起改,不另計。

沿革見 FR-115 LOG 與 git log。