範圍: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-securityplugin,effort low,focus 生產程式碼。 掃描基準 commit:081b61cc812a(工作區有其他 session 的未提交改動)。 驗證章:verified(5 條候選,面板 15 票全數投出,5 條全部 3:0 成立)。只掃不修。 與 W1(CM-2090,scan-W1.md)同一個 runner 連續跑;第 6 節回答「W1 看到的守門零件,在這條路上有沒有被用到」。
規劃頁的「任務」那組網址,在目前分支上只問「你是不是『網址上那個專案』的管理者」,從來不問「這張任務是不是那個專案的」。 所以甲專案的管理者,把網址裡的任務編號換成乙專案的,就能改掉或刪掉乙專案的任務——連同乙專案問卷的填答資料一起刪(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 節。
W1 看的是「主專案把問卷套件接上來的那一層」。W2 看的是另一個方向:主專案自己的「任務」模組,拿套件的東西來用——
task_survey 關聯),也把檢測工具綁上去。這些是主專案自己的路,套件的守門管不到這裡。問卷套件擋的是「填答」那幾支網址;規劃頁建立、刪除 task_survey 走的是主專案直接呼叫套件的 domain service,套件那邊的守門根本不會經過。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才會發生 | 在哪裡(該補檢查的位置) | 嚴重度 | 總表對照 | 來源 |
|---|---|---|---|---|---|---|---|
| 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 的接縫,也就是卡片擔心的「責任真空」。
白話說明
規劃頁上每張任務的網址長這樣:/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 張沒有任何指派列(大多是流程自動建的)。這些任務在修正分支上會一律回「查無」——安全方向是對的(擋下而非放行),但規劃頁若要看或改一張還沒指派人的任務,會被誤擋。這不是漏洞,是修正卡驗收時要測的情境。
白話說明
每張任務底下可以放「證明」:上傳的檔案、貼的連結、掛的問卷。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 項)已補,兩支讀取都接上了。 本棒不另計。
白話說明
規劃頁點開一個查核項目,會列出底下所有任務。這支網址是 /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 核對過)。建議登記為新項。
① 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 的問卷編號,來自上一步剛建好的快照,不是前端原始輸入。前端的問卷清單在這支只用來對應「要不要審核」這個開關。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 那條時要記得批次這條入口一起改。
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 反查專案編號)。
串起來:
任務=乙專案那張、專案=甲、使用者=小王。workflow_execution_service.py:149 同樣挑一列指派),小王因此也能在乙專案那條流程上新增/修改證明、完成任務。這條路只有在「任務還沒有指派列」時才完整;已有指派列的任務,挑到哪一列要看資料庫回傳順序(DEV 目前沒有同一任務混兩個專案的資料,查過 0 筆)。
這就是卡片擔心的責任真空:W1 那邊的守門寫得沒錯、W2 這邊的寫入也各自看起來合理,但 W2 可以寫出一份讓 W1 判錯的資料。
修正分支的狀態:CM-2039 在 update_job 最前面先核對「任務屬於網址專案」,對不上就回「查無」,第 2 步就擋掉了;沒有指派列的任務在修正分支上一律回「查無」,所以也寫不進去。這條接縫在修正分支上已封住,前提是 CM-2039 要合。
但體質上還留一個坑:task_assignees 同時當兩種用途——「誰被指派」和「任務屬於哪個專案」。後者是守門依據,卻存在一張會被前端操作寫入的表裡,而且讀的時候只挑一列。建議首腦考慮讓「任務屬於哪個專案」改成從流程關係推(任務 → 流程 → 專案),不要從指派表推(第 9 節)。
「這五條存在嗎」——高。三個獨立檢查員對每條各投一票,15 票全數投出、全部 3:0 成立;W2-5 從中調降為低(三票裡兩票評低)。關鍵事實我另外開檔核對過:
git diff feature/review fix/security-b1)逐行比對。pg_policies,2026-09-23 22:49):surveys、job_executions 只分客戶;task_assignees 的寫入規則只驗「專案存在」。cm_app(受隔離規則約束)。「第 6 節的接縫」——未經三人面板投票、runner 自行開檔核對。每一步都有對應程式碼,但沒有實際打網址重現。第 4 步(流程引擎也被騙)是依程式碼推的。
「只有這幾條嗎」——不保證。
jedi-wt-fix-security),主專案則是 feature/review。| 項目 | 數值 |
|---|---|
| 掃描範圍 | 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/(未入版控) |
| 密鑰專項 | 無新發現 |
list_jobs 開頭補 assert_project_participant。task_assignees 判歸屬,DEV 上 89% 的任務沒有指派列,會被誤擋成「查無」。project 模組、不驗 survey 模組(第 6 節表格):併進 W1-3 一起裁。沿革見 FR-115 LOG 與 git log。