範圍:3 檔,共 1,629 行,分別是
infra/flow_control/repository/flow_control_job_repo_impl.py(1,216 行)、infra/flow_control/repository/job_import_lookup_query.py、infra/readmodel/tasks/job_export_query.py。 掃描工具:Claude Code 官方claude-securityplugin,effort low,只看正式程式碼。 掃描基準 commit:9abe5e64e895。掃描時工作區有其他 session 還沒 commit 的改動。 驗證章:verified。6 條候選全部經過三人面板投票,18 票都投了,6 條都成立,其中 1 條被面板降級。只掃不修。 資料庫隔離現況:2026-09-23 22:34~22:41(台北時間)在 DEV 用唯讀查詢確認,見第 5 節。
卡片擔心的「任務把別家客戶的問卷或設備拼進來」不成立:這批查詢碰到的問卷表、設備表、填答表、問卷指派表都有開資料庫隔離,別家客戶的資料進不來(詳見第 5、6 節)。
真正的問題出在同一家公司內部、不同專案之間。 任務相關的網址都帶「專案編號」和「任務編號」,系統只檢查「你在這個專案裡的身分」,從來沒有核對這筆任務到底屬不屬於這個專案。資料庫隔離只分客戶,不分專案,所以兩層都擋不住。
工具提報 6 條,三人面板全部判定成立。runner 自己開檔又追出 2 條。依總表現況分成三類:
「規劃頁」上每個查核項目底下都有若干任務。專案管理者可以在頁面上新增、修改、刪除任務,也可以把任務匯出成 Excel、改好再匯回來。
這三支檔案是這些功能的資料存取層:
flow_control_job_repo_impl.py:任務的讀取、新增、修改、刪除。job_import_lookup_query.py:Excel 匯入時,查人員、部門、設備的名稱。job_export_query.py:Excel 匯出時,把任務與指派資料組成一張表。這三支都不透過問卷套件、設備套件的服務,而是直接查它們的資料表。所以套件自己的權限檢查在這條路上不會生效,能擋的只有兩層:
job_service.py、job_import_service.py)裡的權限檢查。資料存取層本身本來就不該做權限檢查,這一層 19+7+2 個方法都沒有權限檢查是正常的。這一棒要回答的是:守門的那一層有沒有守對東西。
| # | 發生在哪個畫面、誰做什麼 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 與總表的關係 |
|---|---|---|---|---|---|---|
| W4-1 | 規劃頁「編輯任務」。某個專案的管理者送出修改時,網址填自己的專案,任務編號填別的專案的 | 別的專案的任務被改名、改說明、改類型、換問卷、換部門與設備,還可以把自己加成指派人,然後就能去執行那個任務 | 同公司內任一專案的管理者(自己開一個專案就是),並且知道目標任務的編號 | app/flow_control/service/job_service.py:266(update_job 開頭,第 287 行查完身分後要補「任務屬於此專案」)api/flow_control/routes/job_route.py:104 |
中 | 總表第 63 項後半(已登記「改與刪只驗人、沒驗物」),不另計 |
| W4-2 | 規劃頁「刪除任務」。同上,網址填自己的專案,任務填別人的 | 別的專案的任務永久刪除,連同指派、部門、設備、問卷、留言。🔴 新細節:「輪次凍結後不能刪任務」這道保護查的也是網址上的專案,所以對方已經開跑、已凍結的稽核輪次裡的任務也刪得掉 | 同公司內某個還在規劃期專案的管理者,並且知道目標任務的編號 | app/flow_control/service/job_service.py:211(delete_job,凍結檢查要改成查任務真正所屬的專案;project_uid 預設 None 要改成必填)api/flow_control/routes/job_route.py:147 |
中 | 總表第 63 項後半,但凍結被繞過是新的後果,要加進修正卡的驗收項 |
| W4-3 | 規劃頁「匯入任務 Excel」的確認步驟。管理者上傳的 Excel 裡,任務編號填別的專案的 | 一次批次改掉別的專案的任務:換指派人、換類型、換部門與設備。程式註解寫著「防止未經驗證的確認」,但那份白名單只確認「任務存在」,不確認「在這個專案裡」 | 同公司內任一專案的管理者,並且知道目標任務的編號 | app/flow_control/service/job_import_service.py:397(confirm_import,第 413 行的白名單要改用這個計畫底下的任務)infra/flow_control/repository/job_import_lookup_query.py:86 |
中 | 新 |
| W4-4 | 任務詳細頁。任何登入的人打開一筆任務 | 看得到任何一個專案的任務完整內容:指派人、部門、設備、問卷、檢測工具設定 | 同一家公司的登入帳號,並且知道任務編號 | app/flow_control/service/job_service.py:139-140(get_job,連專案編號這個參數都沒有)api/flow_control/routes/job_route.py:82 |
中 | 總表第 63 項前半,不另計 |
| W4-5 | 規劃頁的任務清單。任何登入的人帶別的專案的編號來查 | 列出別的專案每個查核項目底下的任務,包含任務編號。W4-1~W4-3 需要的任務編號就是從這裡來的 | 同一家公司的登入帳號,並且知道目標專案的編號。查核項目的編號是公開標準的條號(例如 AC.L1-3.1.1_obj.1),可以用猜的 |
app/flow_control/service/job_service.py:243-244(list_jobs 開頭補成員檢查)api/flow_control/routes/job_route.py:52 |
中 | 新(總表第 63 項只把清單當成「看到編號的地方」,沒有把清單本身列成缺口) |
| W4-6 | 規劃頁「匯出任務 Excel」。網址上有兩個編號:專案編號和計畫編號。系統檢查你是不是前者的成員,匯出的卻是後者指向的專案 | 某個專案的一般成員(連管理者都不必是)可以把別的專案的整張任務表匯出來,內容包括任務編號、指派人的登入帳號、部門、設備 | 同公司內任一專案的成員,並且知道目標專案的編號 | app/flow_control/service/job_import_service.py:103-104(export_excel,要核對計畫編號解析出來的專案等於網址上的專案)infra/readmodel/tasks/job_export_query.py:79 |
低(面板 2:1 從中降為低,理由見 4.6) | 新 |
| W4-R1 | W4-1 的連鎖後果:跨專案加指派人時,系統會寫一筆「這個任務屬於你的專案」的指派紀錄 | 有好幾個地方用「指派紀錄」反推任務屬於哪個專案,包括凍結判斷、存流程圖時查輪次狀態、「我的任務」清單,都可能被這筆假紀錄帶偏 | 同 W4-1 | 修 W4-1 就一起消失。受影響的讀取點:flow_control_job_repo_impl.py:327(_is_round_frozen_for_job,第 339 行取第一筆、沒有排序)、:970(current_round_status_by_workflow_execution 第二條路) |
中(與 W4-1 一起修) | 與總表 M10 第 6 條是同一種病(「塞一筆假指派,騙過歸屬判斷」),這次在另一個入口再出現一次 |
| W4-R2 | 匯入任務 Excel 的「驗證」與「重新驗證」兩步。任何登入的人都可以呼叫 | 可以拿來試探「某個任務編號屬不屬於某個計畫」,也可以試探公司內有哪些帳號、部門、設備名稱。不會改到資料 | 同一家公司的登入帳號 | app/flow_control/service/job_import_service.py:197(validate_import)、:314(revalidate_items)開頭補成員檢查 |
低 | 新。與 W4-6 是同一組入口,建議併同一張卡 |
W4-1~W4-6 經過三人面板投票;W4-R1、W4-R2 是 runner 自己開檔核對的,沒有經過三人面板投票。
場景:小明在公司裡自己開了一個專案 A,所以他是 A 的管理者。他從任務清單(W4-5)或匯出表(W4-6)拿到專案 B 的某個任務編號。在規劃頁按「編輯任務」送出時,他把網址改成 /grc/project/<A>/job/<B 的任務>,指派人填自己。
會發生什麼:系統先確認「小明是不是 A 的管理者」,是,就放行。接著用任務編號把那筆任務撈出來直接改,從頭到尾沒有比對這筆任務是不是 A 的。專案 B 的任務就被改名、換問卷、換設備,小明也變成指派人。
為什麼擋不住:
job_service.py:287 只呼叫 _require_manager(project_uid),查的是網址上的專案。app_tenant_allowed_for_session(tenant_id),只分客戶,同一家客戶的任務全部看得到(第 5 節)。與總表的關係:docs/security-report/M20-tenant-isolation.md 第 3 條已寫明「補上的守門仍不核對這筆任務到底屬不屬於那個專案」,也就是總表第 63 項的後半。這條是既有案,不另計。 修正卡是 FR-114 卡 2-7。
場景:同上,小明按「刪除任務」,網址專案填 A,任務填 B 的。
會發生什麼:B 的任務被永久刪除:指派、部門、設備、問卷連結、留言都一起清掉,流程圖上的方塊也被移除(flow_control_job_repo_impl.py:777 起)。
新細節(總表還沒寫到):規劃頁「稽核輪次開跑後不能新增或刪除任務」這道保護(job_service.py:100 _assert_round_in_planning),吃的也是網址上的專案。所以只要小明自己的 A 還在規劃期,就算 B 的稽核已經開跑、任務清單已經凍結,B 的任務還是刪得掉。凍結保護的就是「稽核範圍不能中途改變」,這等於它對跨專案攻擊完全無效。
建議:併入總表第 63 項的修正卡(FR-114 卡 2-7),並在那張卡的驗收項加一條:「凍結檢查要查任務真正所屬的專案」。
場景:小明是 A 的管理者。他在規劃頁用「匯入任務 Excel」,上傳前把 Excel 的「任務識別碼」欄換成 B 的任務編號,指派人填自己。
會發生什麼:
get_job_control_uid_map(all_job_uids)(job_import_lookup_query.py:86),它只查「這些任務存不存在、有沒有對應的控制項」,不限定專案,也不限定計畫(第 115 行只有 JobExecution.uid.in_(job_uids))。update_job(project_uid=A),一次批次改掉 B 的任務。為什麼是新的:總表第 63 項講的是單筆編輯;批次匯入是另一個入口,而且那段「防止未經驗證的確認」的註解讓人以為已經守住了。這正是「同一件事兩個入口兩套門」的形狀。
建議:白名單改用「這個計畫底下的任務」,也就是驗證步驟用的 get_export_data(ap_uid),並且核對計畫解析出來的專案等於網址上的專案。要和 W4-1 放在同一張卡,否則修好一個入口,另一個還開著(見 memory「平行 runner 兩入口只改一邊」)。
即總表第 63 項前半,不另計。本棒只再確認一次現況:job_service.py:140 get_job(self, uid) 的簽名裡仍然沒有專案編號,整支沒有任何權限檢查。
場景:同一家公司的任何登入的人,在規劃頁的任務清單 API 帶上別的專案的編號,再把查核項目的編號換成標準條號(例如 AC.L1-3.1.1_obj.1),一條一條查。
會發生什麼:每一條查核項目底下的任務都列出來,包含任務編號、指派人、部門、設備、問卷。W4-1~W4-3 都需要任務編號,這裡就是它們的來源。
為什麼擋不住:job_service.py:244 list_jobs 沒有任何權限檢查。資料存取層(flow_control_job_repo_impl.py:90 那段 SQL)確實用網址上的專案縮小了範圍,所以不會看到別家客戶的,但沒人問「你是不是這個專案的人」。
為什麼是中不是高:要先知道目標專案的編號。專案編號是隨機產生的長串,不能用猜的;不過同一家公司內的人,從其他畫面拿得到的機會不低。
場景:小華只是專案 A 的一般成員。她在「匯出任務 Excel」的網址 /grc/project/<A>/ap/<X>/jobs/export 裡,把 X 換成專案 B 的編號。
會發生什麼:
get_export_data(ap_uid) 會依序把 X 當成專案編號、輪次編號、計畫編號去解析(job_export_query.py:109,實際在 jedi-compliance-audit 套件的 _resolve_ssp),解析到 B 就匯出 B。面板為什麼降成低:三票裡兩票給低,理由是:匯出內容大多是規劃資訊,不含證據本體或稽核結論;而且只限同一家公司。我同意降級,但要提醒一點:它吐出的任務編號正是 W4-1~W4-3 的攻擊材料,所以修正時要跟那幾條一起排。
附帶:jobs/export 與 jobs/import 的網址是 jedi-task-platform 套件宣告的,套件那邊只掛了「登入+授權模組」,真正的權限檢查交給宿主的服務。宿主的服務只守了四個入口中的兩個(匯出、確認),另外兩個見 W4-R2。這就是卡片說的「套件以為宿主守,宿主只守一半」。
場景:接續 W4-1,小明把自己加成 B 的任務的指派人。
會發生什麼:update_job 寫進指派表的那一筆,project_id 用的是網址上的 A(flow_control_job_repo_impl.py:589 用網址專案查 project_id,第 614 行寫入)。於是 B 的任務在指派表裡多了一筆「屬於 A」的紀錄。
為什麼要緊:系統有好幾個地方是用「指派表」反推「這個任務屬於哪個專案」:
_is_round_frozen_for_job(:339)取第一筆指派紀錄、沒有排序。拿到的若是 A 那筆,B 的任務能不能「退回」就改看 A 的輪次狀態。current_round_status_by_workflow_execution(:995 起的第二條路)一樣是 JOIN 指派表去找輪次。vw_user_job_queue)顯示的專案欄位取自指派表,這個任務會以「A 專案的任務」出現在小明的清單上。結論:這不是獨立的洞,而是 W4-1 讓傷害擴散的方式。修 W4-1 就會一起消失,不另開卡;但修正卡的驗收要檢查 DEV 上有沒有已經存在的這種錯位紀錄。DEV 實查時間 22:38:165 個專案的指派紀錄中,沒有任何一個任務同時掛在兩個專案底下,目前乾淨。
場景:同一家公司的任何登入的人,直接呼叫匯入的「驗證」或「重新驗證」。
會發生什麼:
validate_import(job_import_service.py:197)和 revalidate_items(:314)開頭都沒有成員檢查。為什麼是低:只能確認「某個編號或名稱存不存在」,同公司的人本來就從其他畫面看得到大部分這類名稱。建議和 W4-6 併在同一張卡,四個入口一起補成「計畫要屬於網址上的專案+你要是成員」。
查詢方式:2026-09-23 22:34,用 DEV 的 cm_app 帳號(受隔離規則管)唯讀查 pg_class.relrowsecurity 與 pg_policy。22:38 另外開一個「唯讀交易+最高權限變數」查資料分布,結束就回滾,沒有改任何東西。
「有隔離」指資料庫開了隔離,而且有查詢規則;「只分客戶」指規則只比對客戶,不比對專案。
| 表 | 有沒有隔離 | 隔離依據 | 這批哪裡碰到 |
|---|---|---|---|
survey.surveys(問卷) |
✅ 有 | 客戶 | repo :190、:193、:269、:869、:916 |
survey.task_surveys(問卷指派) |
✅ 有 | 跟隨流程實例(流程實例再依客戶) | repo :1094 |
survey.question_answers(填答) |
✅ 有 | 跟隨題目(題目再跟隨問卷頁) | repo :920 |
public.devices(設備) |
✅ 有 | 客戶 | repo :166、:289、:639、:770;匯入 :60;匯出 |
public.org_units(部門) |
✅ 有 | 客戶 | repo :154、:281、:630;匯入;匯出 |
public.users(使用者) |
✅ 有 | 客戶+本人 | repo :595;匯入 :21、:120 |
compliance.job_executions(任務) |
✅ 有 | 只分客戶 | 幾乎每個方法 |
compliance.workflow_executions(流程實例) |
✅ 有 | 只分客戶 | repo :210、:251、:373 |
compliance.workflow_templates(流程範本) |
✅ 有 | 客戶;讀取另外放行系統範本與上層共享範本 | repo :379、:436、:962 |
compliance.projects(專案) |
✅ 有 | 客戶 | repo :90、:589、:677 |
compliance.task_assignees(任務指派) |
✅ 有 | 跟隨專案(專案再依客戶) | repo :137、:301、:339、:600 |
compliance.project_audit_rounds(稽核輪次) |
✅ 有 | 跟隨專案 | repo :346、:986 |
config.job_execution_detection_tools/_agents(任務綁的檢測工具) |
✅ 有 | 客戶 | repo :1159、:1174 |
compliance.job_evidences(任務佐證連結) |
❌ 沒有 | — | repo :181、:259、:857、:885、:900 |
compliance.job_execution_devices/_org_units/_surveys/_comments(任務關聯表) |
❌ 沒有 | — | repo :154、:166、:626~:836 |
compliance.workflow_templates_trans(範本翻譯) |
❌ 沒有 | — | repo :524、:532(寫入) |
public.workflow_execution_control_mapping(流程↔︎控制項對位) |
❌ 沒有 | — | repo :986;匯入 :111;匯出 |
oscal.catalog_control_parts 等 oscal 目錄表 |
❌ 沒有 | — | repo :90、:106、:677 |
config.detection_tools(工具清單,全系統共用) |
❌ 沒有 | — | repo :1159 |
讀這張表的方法:沒有隔離的表,只要每一條查詢都 JOIN 到一張有隔離的表,或它的鍵值是從有隔離的查詢拿來的,就不會漏。第 6 節逐條確認過這個前提。
session.query(Survey).filter(Survey.uid.in_(ref_ids)):ref_ids 從哪來?:排除ref_ids 取自任務佐證連結表的 ref_id。add_survey_evidence(:890),寫入的值是 create_snapshot() 在伺服器端新產生的問卷快照編號(survey_handler.py:191-195)。get_by_uid 讀一次(受問卷表的客戶隔離),再複製一份快照。ref_id,「把別家問卷拼進自己的任務」這個形狀不成立。同一家公司內,拿別的專案的問卷來建快照是可以的,但問卷本來就是客戶層級的共用題庫,這是設計,不是漏洞。
_batch_fetch_task_surveys(:1072)用 TaskSurvey.task_id.in_(job_ids) 過濾,有綁定任務。has_survey_answers(:911)用快照查所有填答。快照是每個任務各自複製一份,而且傳進來的快照編號是從 get_job_survey_evidences(job_uid) 拿到的(survey_handler.py:167),也綁定在這個任務上。逐條看過,每一條都 JOIN 到有隔離的表,或者鍵值是從有隔離的查詢拿來的:
list_jobs 在沒帶專案編號時,會退回從 oscal 目錄表開始查(:106),但最後一定 JOIN 流程實例(有隔離)。網址一定帶專案編號,所以這條舊路其實走不到。_write_template_xml(:502)會直接寫入它,只靠 WHERE workflow_template_id = :tid。目前所有呼叫端的 tid 都來自有隔離的查詢(任務本身的 template_id,或 get_template_context_by_uid 的結果)。DEV 22:38 實查:41,519 筆任務的範本編號與流程實例的範本全部一致;範本全部是「客戶自有」,而且與流程實例屬於同一客戶。所以現在安全。但哪天有人新增一個「前端直接帶範本編號」的入口,翻譯表就擋不住了。這一條不開卡,只記在這裡給之後改這支的人。| 項目 | 結果 |
|---|---|
| ① 權限檢查次數 vs 對外方法數 | 三支檔的權限檢查次數都是 0,方法數分別是 19/7/2。這是資料存取層,本來就該是 0。真正的檢查在 job_service.py(改、刪、新增有;看詳細、看清單沒有)與 job_import_service.py(匯出、確認有;驗證、重新驗證沒有) |
| ② 同一套接線複製多份、某一份漏了 | 同一個「任務」有兩組入口:規劃頁單筆(job_route.py)和 Excel 批次(套件的 job_import_route.py)。兩組都漏了同一件事:沒核對任務或計畫屬於網址上的專案。另外,兩組裡的「讀」都沒守 |
| ③ 套件以為宿主守、宿主以為套件守 | 命中一處:jedi-task-platform 的匯入匯出網址只掛「登入+授權模組」,把權限交給宿主;宿主只守了四個入口中的兩個(W4-R2)。另外,DI 有把守門零件注入進去(flow_control_containers.py:422、:439),所以 _require_manager 開頭那條「零件沒給就放行」在正式環境走不到,排除 |
| ④ AI 儀表板那條路 | di_containers/dashboard_apis/ 沒有申報這三支或任務服務的任何查詢,不適用 |
「這幾條存在嗎」:W4-1~W4-6 很可信。
但全部是讀程式推論,沒有實際打 API 驗證。 第 5 節的資料庫隔離狀態,以及 4.7、6.3 的資料分布,是在 DEV 唯讀實查的(附時間),沒有寫入任何資料。
「只有這幾條嗎」:不保證。 這次用的是最快的檔位(effort low)。W4-R1、W4-R2 是工具沒報、runner 自己追出來的。任務平台其他還沒掃的檔(job_batch_complete_service.py 等,見盤點檔第 131 行)不在本棒範圍內。
| 項目 | 數值 |
|---|---|
| 掃描範圍 | 3 檔/1,629 行 |
| 基準 commit | 9abe5e64e895(工作區有平行 session 的改動) |
| 檔位 | effort low,只看正式程式碼(因此多跑一輪密鑰專項) |
| 研究員 | 派 2 支,回 2 支 |
| 原始候選 | 6 條,去重後 6 條 |
| 投票 | 三位檢查員 × 6 條 = 18 票,全部投出,沒有漏投 |
| 票型 | 6 條都是 3:0 成立;W4-6 的嚴重度由面板從中降為低(低、低、中) |
| 密鑰專項 | 零發現 |
| 驗證章 | verified(CLAUDE-SECURITY-REVISION-9abe5e64e895-dirty.json) |
| 工具 run ID | wf_cc9b4a46-2c3 |
| 耗時 | 約 22 分鐘(20 個 agent,零失敗;其中 1 個回傳空結果,不影響投票) |
| 工具產出的原始報告 | CLAUDE-SECURITY-20260923-143314/(沒有進版控) |
| 報告編號對照 | 工具 F1~F6 對應本報告 W4-1~W4-6 |