檢查日期 2026-09-25|對應卡片 CM-2160(母卡 CM-2152)|檢查範圍 8 個檔案 1,716 行|工具 run ID
wf_fc0b9dcb-c09|驗證章verified
這棒沒有找到跨公司的洞。有兩個「同公司內權限太鬆」的問題:一條工具報、三人面板 3 票通過;一條是我照卡片追出來的。另外有一個「將來會靜默失效」的陷阱,我在 DEV 實測重現了。
卡片問的核心問題「這八支用原生 SQL 直寫沒隔離的表,資料是不是從同一個已驗證的物件來」,答案是是:來源與目的地的編號都是程式自己從同一筆已驗證的輪次或流程實例推出來的,沒有任何一個是使用者各自傳進來再拼起來的。詳見「卡片點名的疑點逐條回答」。
這 8 支是「背景作業」:大多不是使用者直接按按鈕觸發,而是排程定時跑,或別的功能跑到一半順帶呼叫。
| 檔案 | 行數 | 做什麼 | 誰會觸發 |
|---|---|---|---|
core/scheduler.py |
550 | 所有背景排程的總表:9 支排程,各自幾點跑、用什麼身分跑 | 服務啟動時掛上,之後按時間自動跑 |
app/flow_control/service/workflow_xml_sync_service.py |
246 | 流程圖編輯器存檔時,比對新圖與資料庫任務,決定要新建、保留還是刪除哪些任務 | 使用者在編輯器按存檔(PUT /module-frame/item/xml/<uid>) |
app/flow_control/service/job_binding_orphan_cleanup_service.py |
174 | 每天清一次「指向已刪任務的綁定資料」 | 排程,每天 03:10 UTC |
infra/flow_control/repository/job_binding_orphan_query.py |
129 | 上面那支的 SQL(數、取樣、刪) | 同上 |
app/flow_control/service/reverify_inheritance_service.py |
73 | 開「覆核輪」時,把上一輪的指派、部門/設備綁定、證據、問卷複製到新一輪 | 稽核員按「發起覆核」 |
infra/flow_control/repository/reverify_clone_query.py |
176 | 上面那支的 SQL(對五張表做 INSERT … SELECT) |
同上 |
infra/flow_control/repository/task_execution_query.py |
292 | 「開始執行任務」與「執行進度」的查詢與批次改狀態 | 專案管理人按「開始執行任務」/打開規劃頁 |
infra/flow_control/task_existence_query.py |
76 | 回答「這個任務存不存在、屬於哪條流程」的轉接器,給留言、人員指派這些功能用 | 被別的功能呼叫 |
先釐清三件事實(2026-09-25 15:13~15:31 +08,在 DEV 唯讀查證;刪除測試全部整段回滾):
job_evidences、job_execution_devices、job_execution_org_units、workflow_templates_trans 都沒開資料庫隔離(relrowsecurity = f)。task_assignees 已經開了(FR-094 CM-1800,scripts/sql/packages/jedi_task_platform/003-participant-rls.sql,規則是「看得到專案才看得到指派」)。所以盤點表第 6 節寫「task_assignees 無 RLS」已經過期。system_context("<名字>") 包住,也就是**「一個具名系統身分,掃全部客戶」**,沒有任何一支是逐家客戶切換身分的。對這些排程來說,資料庫隔離等於沒開(系統身分會直接繞過)。這跟 U5/U6 看到的一樣。job_evidences 對任務表的外鍵是 ON DELETE CASCADE:任務被刪,證據列會被資料庫連帶刪掉。U8-1 的影響就是從這裡來的。| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U8-1 | 在流程圖編輯器存檔刪方塊,會硬刪對應任務,這條路不問「你是不是這個專案的管理人」 | 同公司裡有「改範本」權限但不是該專案管理人的人,可以刪掉別的專案的稽核任務,連同指派、綁定與證據列(資料庫連帶刪),也可以替它新增任務 | ① 有 module-frame.update 權限 ② 知道目標專案某個查核項目背後的範本編號 ③ 那個專案的當前輪次還在「規劃中」,或系統查不到它屬於哪一輪(舊資料約一半如此)。覆核輪開出來時就是「規劃中」而且已經複製了證據,也符合 |
app/flow_control/service/workflow_xml_sync_service.py:91(只要有要增減的任務,就先從流程實例反查專案、要求專案管理人;查不到專案就擋,不要放行) |
🟡 低:只在同公司內;要有範本修改權限;而且這是決策者 2026-09-20 裁過的設計(FR-112 design.md 第 307 行) | 工具 F1,面板 3:0;runner 開檔核對 |
| U8-2 | 「證據蒐集執行進度」只驗登入,不驗是不是專案成員 | 同公司任何登入帳號把網址換成別的專案編號,就能看到那個專案這一輪的任務總數、完成數、進行中數、未開始數 | 同公司任一登入帳號+目標專案編號(網址上看得到的長碼) | 套件 jedi_task_platform/task/app/service/task_execution_service.py:139(比照同檔 :69 的 _check_manager,至少呼叫 assert_project_participant) |
🟡 低:外洩的只是四個數字;跨公司被 projects 表的隔離擋住 |
runner 開檔追出(未經三人面板) |
| U8-3 | 孤兒清理的判定與「排程有沒有帶系統身分」綁死;沒帶身分時,全部綁定都會被判成孤兒並刪除 | 如果哪天有人把 system_context 拿掉(例如重構、或改成逐家客戶跑),這支排程每天會把四張綁定表全部清空(留言、設備、部門、問卷綁定),只留一行 WARNING |
不是攻擊,是將來的程式修改。現在排程有帶身分,今天不會發生 | infra/flow_control/repository/job_binding_orphan_query.py:121(刪除前先確認這個 session 真的看得到全部任務,否則整輪停手、記 ERROR) |
🟡 低/陷阱:今天不會觸發,但一觸發就大量刪資料,而且沒有錯誤訊息 | runner DEV 實測(未經三人面板) |
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- U8-1(流程圖刪方塊硬刪任務)=總表第 201 項,✅ 已修(CM-2208,commit
1516d90bc,1.21.0 出貨)。- U8-2(執行進度只驗登入)=總表第 202 項,✅ 已修(CM-2211,套件 commit
cdb83526,1.21.0 出貨)。- U8-3(孤兒清理沒帶身分的陷阱)=總表第 203 項,✅ 已修(CM-2211,commit
fe928859f,1.21.0 出貨)。
工具報的:一條(U8-1),三人面板 3 票全數確認。 U8-2、U8-3 是我照卡片逐支追、再到 DEV 實測出來的,沒有經過三人面板。
app/flow_control/service/workflow_xml_sync_service.py:124(逐筆呼叫 delete_job_from_diagram)。刪除本體在 app/flow_control/service/job_service.py:217-228,那支方法刻意不做專案管理人檢查,註解寫明理由。api/module_frame/routes/module_frame_item_route.py:95-112,只掛了 @require_capability("module-frame.update"),也就是「你這個角色能不能改範本」。接著 app/module_frame/service/module_frame_item_service.py:69-70 把整份 XML 交給 sync()。sync() 會把資料庫裡有、新圖上找不到編號的任務全部列成「要刪」(:85-86),然後逐筆硬刪(:121-124)。job_service.py:212 的 _require_manager(project_uid)(FR-112 design.md 寫的 :179 是當時行號),要是專案管理人。編輯器這條只有範本權限+:91-92 的「輪次凍結」檢查。那道檢查在查不到輪次時直接放行(:141-143,倉儲那支註解說舊資料約一半查不到)。job_evidences 對任務是 ON DELETE CASCADE(DEV pg_constraint 15:31 實查),所以證據列會被資料庫連帶刪掉。workflow_templates、workflow_executions、job_executions),呼叫者看不到別家客戶的範本就拿不到它的流程實例。存檔端點在使用者的請求裡跑,受隔離約束。docs/features/FR-112-2609-planning-task-parallel-flow/design.md:307 寫「編輯器刪任務走範本能力守門(module-frame.update),不要求專案管理人身分(決策者 2026-09-20 裁)」。理由是存檔端點只拿得到範本編號,多數範本反查不到專案。所以這條要決策者決定要不要重新裁,不是單純的 bug。 工具提出重新評估的理由是:當初的考量是「刪任務」,但實際上連證據也會一起消失。sync() 算出有要增減的任務時,先從流程實例反查專案。反查順序照倉儲已有的兩條路:workflow_execution_control_mapping.round_id → project_audit_rounds.project_id,或 task_assignees.project_id。查到就要求專案管理人;查不到就擋下增減(只准改走向)。不要再像現在這樣放行。update_module_frame_item_xml,但問的是「範本是不是你家的」。本項問的是「你是不是這個專案的管理人」,是不同的缺口,建議另登一項。修第 137 項時可以一起看。infra/flow_control/repository/task_execution_query.py:214(get_prep_job_progress)。守門該在的地方在套件:路由 jedi_task_platform/task/api/routes/task_execution_route.py:56-60 只掛 @jwt_required() 和 @require_license("project");服務 jedi_task_platform/task/app/service/task_execution_service.py:138-141 直接轉呼叫查詢,沒有任何角色檢查。start_task_execution(:69)有呼叫 _check_manager。同一個專案、同一個頁面,「按開始」要管理人,「看進度」連成員都不驗。這是「有人守了一半」的「只檢查了寫入卻沒檢查讀取」那一型。task_execution_query.py:61-66 join compliance.projects),projects 有開隔離。所以別家公司的專案編號查不到輪次,會走舊鏈 fallback,一樣 join projects,最後回 0。get_prep_job_progress 開頭照 _check_manager 的寫法先解析專案,再呼叫 assert_project_participant(common.authz 已有,不要另寫)。task-execution/progress、get_prep_job_progress、TaskExecutionProgressRoute 都零命中。FR-048 的端點盤點只列了 start-task-execution。判斷是淨新增。在哪:判定條件是 infra/flow_control/repository/job_binding_orphan_query.py:121-128 的 DELETE … WHERE NOT EXISTS (SELECT 1 FROM compliance.job_executions …)。
為什麼是陷阱:四張綁定表(留言、設備、部門、問卷)沒開隔離,所以不管誰來查都看得到每一列。但 job_executions 有開隔離,沒有身分時一列都看不到。「任務查不到」在沒身分的情況下對每一列都成立,於是每一列都被判成孤兒。
DEV 實測(15:27~15:29 +08,job_execution_devices 共 5 列,全屬客戶 102,都指向真實存在的任務)。原封不動跑這支檔案的 DELETE 句,三種身分各一次,每次整段 ROLLBACK:
| 身分 | 刪掉幾列 | 應該刪幾列 |
|---|---|---|
系統身分(app.is_super_admin = t,排程現況) |
0 | 0 |
沒有身分(session_scope 沒身分時的樣子) |
5 | 0 |
客戶 131 的一般使用者(allowed_tenant_paths = /1/131/) |
5 | 0 |
跑完再數一次,還是 5 列,沒有殘留。同一時間用 count 查另外三張表:沒身分時,部門綁定 5 列全判孤兒、留言 6 列全判孤兒;問卷綁定表本來就 0 列。
今天會不會發生:不會。core/scheduler.py:447 有 with system_context("job_binding_orphan_cleanup"):,service 的 docstring(job_binding_orphan_cleanup_service.py:33-40)也寫了「必須由呼叫端設好身分」。總表第 46 項的修正正是補上這個身分。
為什麼還要登:這條規則目前只靠註解和呼叫端記得。第 46 項講的是「沒身分會掃到 0 筆、靜默不做事」,剛好反過來:對「NOT EXISTS 判孤兒」這種寫法,沒身分不是掃到 0 筆,而是全部判成孤兒。也就是第 46 項那句「省略身分就會靜默掃到 0 筆」對這支檔案不成立,漏帶身分的後果是大量刪資料。將來若有人照 U5/U6 的建議把排程改成「逐家客戶切換身分」,這支就會把每家客戶看不到的別家任務的綁定全刪掉。
建議修法:delete_orphans 刪之前先做一次自我檢查。例如 SELECT current_setting('app.is_super_admin', true) = 't',不是就整輪停手並記 ERROR。或者把判定改成「帶著任務所屬客戶一起比」,但綁定表沒有客戶欄位,這條比較難。另外 JOB_BINDING_ORPHAN_CLEANUP_MODE=report 這個逃生門已經存在,可以在修之前先在客戶環境設成 report。
我沒能做的實測:本想直接把 service 以 report 模式(只數不刪)在本機起一次,比較有沒有身分的差別。但本機起 app 時撞到 FlowControlJobRepoImpl 少實作 resolve_project_id_for_job 的 TypeError(應是平行修正線的套件介面先行、主專案還沒跟上),起不來。所以實測改在資料庫層直接跑同一句 SQL,沒有經過 Python 那層。 這是打折處。
| 卡片問的 | 答案 | 依據 |
|---|---|---|
🔴 reverify_clone_query.py 對四張表 INSERT … SELECT:來源與目的的編號是不是從同一個已驗證物件來? |
不成立(安全)。唯一入口是套件 launch_reverify(jedi_compliance_audit/app/service/audit_round_app_service.py:569):先用網址的輪次編號撈母輪(:583),用母輪自己的 project_id 驗稽核員角色(:586),新輪由程式自己建、project_id 直接抄母輪(:623-627)。clone 時的配對 get_job_pairs(reverify_clone_query.py:31-53)只吃「母輪 id」與「新輪 id」兩個數字,而且兩個都是程式從同一個母輪推出來的,沒有任何編號是使用者另外傳的。每支 clone 只用 pair 裡的舊/新任務編號做 WHERE,寫入的專案、控制項欄位是從來源列原樣複製 |
開檔 |
同上,workflow_xml_sync_service.py 更新 workflow_templates_trans |
部分成立,已是舊帳。實際寫入在 flow_control_job_repo_impl.py:524-534,WHERE workflow_template_id = :tid。tid 來自 get_template_context_by_uid(:955-968),而那個查詢 join 了有隔離的 workflow_templates 與 workflow_executions,所以跨公司的範本在使用者請求裡查不到、tid 拿不到。但範本歸屬本身沒檢查=總表第 137 項(已登),不另計 |
開檔+02-schema.sql 查 policy |
core/scheduler.py:每個排程用誰的身分跑?有沒有逐家客戶切換? |
9 支全部是具名系統身分掃全庫,沒有任何一支逐家客戶切換:drive_sync_worker(:177)、webhook_channel_renewer(:211)、framework_parse_job_cleanup(:248)、license_expiry_state_machine(:305)、tamper_fs_db_sync(:353)、detection_execution_timeout(:399)、job_binding_orphan_cleanup(:447)、log_partition_maintenance(:497)、integrity_spot_check(:68)。其中「需要跨客戶才做得完」的(清孤兒、續 webhook、到期狀態機、分區維護)用系統身分是合理的;drive_sync_worker 處理的每一筆工作其實都有單一客戶,用系統身分是過度授權,這一點 U5/U6 已登,不另計 |
開檔 |
job_binding_orphan_cleanup_service.py:「孤兒」判定會不會誤判活資料? |
在現行呼叫方式下不會;少帶身分就會全誤判。見 U8-3(DEV 實測)。另外兩點不成立:①任務是硬刪、沒有「軟刪後復活」的歧義(delete_job 走 session.delete);②判定與刪除在同一句 SQL,沒有先查後刪的時間差 |
開檔+DEV 回滾實測 |
| 有人守了一半:只驗「你是誰」沒驗「是不是你的」 | 成立一條:U8-2(進度查詢只驗登入)。U8-1 屬「同一件事兩個入口兩套門」 | 開檔 |
| 有人守了一半:背景排程假設呼叫者一定是自己人 | 不成立。排程沒有外部呼叫者;drive_sync_worker 處理的工作列是使用者請求排進去的,那是 U5/U6 的範圍 |
開檔 |
| 有人守了一半:防重放/計次只在單一程序內生效 | 有一處,但不構成問題:scheduler.py:189 的 max_instances=1 只防同一程序內重疊。註解(:483)寫明排程只在 gunicorn master 跑一份(CM-1939 實測),多台機器才會重複。到期狀態機靠資料庫的 CAS 保證冪等(:274),孤兒清理本身冪等。不另計 |
開檔 |
| 有人守了一半:「查不到」與「沒權限」混成同一個回應 | 不成立。task_existence_query.py 查不到回 None,由呼叫端決定 404;本棒範圍內沒有把權限錯誤吞掉的地方 |
開檔 |
| 註解寫「這裡刻意不檢查」但上一層沒人真的檢查 | 成立一條:job_service.py:223 註解寫「該端點自己有 module-frame.update 這道能力守門,租戶隔離由 RLS 負責」。上一層確實有這兩道,但兩道都不回答「你是不是這個專案的管理人」=U8-1 |
開檔 |
範圍內沒問題的:
task_existence_query.py:只是轉呼叫,建構時就強制注入、不會降級成「查不到就跳過守門」(:45-57,arc-review I-6 已修掉的舊病),沒問題。task_execution_query.py 的 activate_jobs:用 job_ids 批次改狀態,沒帶專案條件,但 job_ids 是同一個 service 從「已驗管理人的專案」查出來的(task_execution_service.py:69-76),不是使用者傳的。get_detection_jobs_without_execution 同理。job_binding_orphan_query.py:表名用字串拼接,但有白名單(:62-70),沒有 SQL 注入面。verified。U8-2、U8-3 是我開檔追的;U8-3 在 DEV 用資料庫層實測重現,未經三人面板投票。job_service.py 刪除段、module_frame_item_service.py、module_frame_item_route.py、flow_control_job_repo_impl.py 存檔相關五支方法、套件 jedi_compliance_audit 的 launch_reverify 與 _check_role、套件 jedi_task_platform 的 task_execution_service.py 與 task_execution_route.py、jedi_common 的 session_scope 與 system_context。打折處:
SET LOCAL 模擬三種身分),沒有經過 Python 的 session_scope。本機起 app 撞到平行線造成的介面缺實作,起不來。scan-inventory.md 第 6 節「compliance.task_assignees 無 RLS」已過期:DEV 已開(CM-1800)。U6 報告若沿用這個前提,那一張表的結論要重看。| 項目 | 數值 |
|---|---|
| run ID | wf_fc0b9dcb-c09 |
| 報告目錄 | CLAUDE-SECURITY-20260925-070348/(不入版控) |
| 驗證章 | verified(stamp CLAUDE-SECURITY-REVISION-e1c923033405-dirty.json;dirty 是平行線未 commit 的改動,不在範圍內) |
| 掃描參數 | --effort low、focus attack-surface、範圍 8 檔 |
| 研究員 | 2 派 2 回(主掃 1+密鑰掃 1,密鑰零發現),零失敗、零重試 |
| 候選/投票 | 1 候選、3 票全投,3:0 |
| agent 總數 | 5 個,0 錯誤 |
| 耗時 | 約 24 分鐘 |
| 淨新增 | 低 3(U8-1 面板;U8-2、U8-3 runner) |