FR-088.1 H2:任務完成/回退/留言/證明——資安掃描報告

  • 卡片:CM-1670(FR-088 第 1 棒)
  • 範圍:BE repo 23 支檔(任務證明與流程留言的網址入口到守門)
  • 掃描時間:2026-09-12(UTC 03:43 起,跑了約 3 小時 10 分)
  • 掃描版本:84ea07ef9fe286d6b3bc33569539cdc38dd2ccde(branch feature/FR-075)
  • 工具:Claude Code claude-security plugin v0.11.0,effort low、focus attack-surface
  • 驗證章:verified(三個獨立檢查員對每條發現各投一票,30 票全數投出,沒有漏投)
  • 只掃不修:本棒沒有改任何程式碼,也沒有動任何環境(只對 DEV 資料庫做了唯讀查詢)

1. 🔴 一句話結論

掃完了,範圍內找到 3 個真問題,最嚴重的是「任何登入者只要知道一個任務編號,就能讀走別家客戶的稽核證明清單」——而資料庫層完全沒有第二道防線。 另外工具在範圍外順手撈到 6 條密碼/金鑰被寫進版控的問題,全部都是已經開過卡的舊案,不算本棒的新發現。


現況(以 docs/security-report/M06*.md 為準):F1/F8(證明清單讀取,M06-1)→ ✅ 已修(CM-2059);F9(流程留言讀寫,M06-2)→ ✅ 已修(CM-2035);建議後續第 3 點的流程定義外洩併 P1(M06-8)已修。範圍外的憑證項(CM-1607/1608/1629/1631 系列):檔案殘留已清(CM-2048/2049/2051),密碼換發排在正式環境上版前。

2. 這一棒在檢查什麼(白話)

稽核任務跑起來之後,使用者會在畫面上做四件事:把任務標成「完成」、把完成的任務「退回」、在流程上「留言」、替任務上傳「證明文件」。

這一棒檢查的就是:當這四個動作從網址送進後端時,後端有沒有問一句「你是不是這個專案的人」。 因為這些網址只帶一個編號(例如某個任務的編號),沒有帶「哪個專案」,所以如果後端不主動反查並比對,任何一個登入者都能把編號換成別人的,去動別人的資料。


3. 掃到什麼:總覽表

3.1 範圍內(本棒的發現,共 3 條)

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修
F1
🔴 HIGH
「查某個任務的證明清單」這支 API 沒有檢查你是不是這個專案的人,只檢查你有沒有登入 讀走別家客戶的稽核證明清單:檔名、描述、參考網址、Google 雲端硬碟連結、上傳者帳號、檔案指紋。拿到檔案編號後還能再去下載檔案本體 ① 任一個有效登入帳號(不需任何權限)
② 知道一個任務編號(是亂數 UUID,通常要從被轉寄的連結、截圖、log 或匯出檔取得,猜不出來)
app/flow_engine/service/job_evidence_service.py:48 在第 48 行取到 job_execution 之後、第 55 行查證明之前,補一行 self._workflow_execution_service.assert_project_participant(job_execution.workflow_execution_id)——同一個檔案的新增(:81)、更新(:145)、刪除(:168)都已經有這行了,只有「讀」漏掉
F8
🟡 MEDIUM
同一支服務裡「查單一筆證明」(get_job_evidences_uid)也沒有檢查歸屬 同 F1,但目前打不到——回傳前程式會先自己壞掉(見下方詳述),所以是「守門缺口」不是「資料外洩」 同 F1 app/flow_engine/service/job_evidence_service.py:56(缺口本體在 :67 的 get_job_evidences_uid) 在 get_job_evidences_uid 取到 job_evidence 之後補 assert_project_participant(job_evidence.workflow_execution_id)
F9
🟡 MEDIUM
「流程留言」的讀與寫都沒有檢查你是不是這個專案的人 任何登入者可以對別家客戶的稽核流程塞留言、也能讀走整串既有討論。留言還會透過即時推播丟給該專案的正當成員,可以拿來假冒同事做釣魚(例如「請把稽核報告寄到 xxx@evil.com」)。留言存成 JSON 陣列且沒有筆數上限,也能被灌爆 ① 任一個有效登入帳號
② 知道一個流程編號
寫:api/flow_engine/routes/flow_engine_route.py:105(整支 put 從 :66 起)
讀:同檔 :44-64 的 get
在 app service 層(不是在 route)解出 workflow_execution 之後呼叫 assert_project_participant(workflow_execution.id),和同模組的「完成任務」(workflow_execution_service.py:655)、「退回任務」(:833)同一套做法。讀寫都要加

3.2 範圍外(工具的密鑰專項順手撈到,共 6 條,全部是舊案不是新發現)

工具設了 focus 就會額外跑一次「找密碼金鑰」的專項掃描,它會掃全 repo、不受本棒 23 檔範圍限制。以下六條都不在本棒範圍內,且經比對全部落在既有卡片上:

# 內容 對應既有卡
F2(HIGH) GitLab 個人存取權杖被寫進對話紀錄檔(9 個檔) CM-1607(清版控殘留金鑰)
F3(MEDIUM) POC 資料庫密碼寫死在文件產生腳本裡 docs/system-design/scripts/generate_db_schema_docx.py:34 CM-1608/CM-1629
F4(MEDIUM) JWT 簽章金鑰被寫進對話紀錄檔(78 個檔含此字串) CM-1607(FR-079 F12 已併入)
F5(MEDIUM) OpenAI/Anthropic/Google 三家 API 金鑰被寫進對話紀錄檔 CM-1607 系列(FR-079 B2 F2 已記)
F6(MEDIUM) Google 雲端硬碟應用程式密鑰 + 保護權杖用的加密金鑰一起外洩 CM-1631
F7(MEDIUM) Nexus 私有套件庫的 admin 帳密用 base64 寫死在前端打包腳本 scripts/build/build_fe_image.sh:51 CM-1634 系列(FR-083 D2 F2 已記同一組帳密)

結論:範圍外這 6 條不需要開新卡。 但它們再次佐證同一件事——「把 .env 原樣貼進對話紀錄」這個習慣,讓同一批金鑰散進了幾十到幾百個檔案。


4. 每條發現的詳述

F1 — 查任務證明清單沒有檢查歸屬(HIGH,可信度 高)

這是什麼問題(白話) 畫面上點開一個稽核任務,會看到「這個任務附了哪些證明文件」。後端提供這個清單的 API,只確認「你有登入」,沒有確認「這個任務是不是你們專案的」。

出事會怎樣 換一個任務編號就能看到別家客戶的證明清單,內容包含:檔名、描述、參考網址、Google 雲端硬碟的檢視連結、上傳者的帳號、檔案的 MD5 指紋、檔案編號。拿到檔案編號之後,還可以再去換簽章憑證把檔案本體下載下來。稽核證明常常裝的是內部組態、掃描報告、人員名單。

要先有什麼才打得到

  1. 任一個有效的登入帳號(不需要是管理員,不需要任何角色)
  2. 知道一個任務編號。編號是亂數 UUID(像 3f2a…),猜不出來,實務上要從被轉寄的畫面連結、截圖、log 摘錄、匯出報表,或「曾經是這個專案成員但被移除」取得
  3. 資料庫層不會擋 —— 見下方首腦核對

在哪裡

  • 入口:api/flow_engine/routes/job_evidence_route.py:25-30(GET /api/1.0/job-evidences?job_execution_uid=<編號>,只掛了 @jwt_required())
  • 缺口本體:app/flow_engine/service/job_evidence_service.py:42-59,第 48 行只用編號查出任務就往下走
  • 對照組(同一個檔案裡有做的)::81(新增)、:145(更新)、:168(刪除)都呼叫了 assert_project_participant

怎麼修 在 get_job_evidences_by_job_execution_uid 第 48 行取到 job_execution、第 55 行查清單之前,插一行:

self._workflow_execution_service.assert_project_participant(job_execution.workflow_execution_id)

守門政策在 common/authz/workflow.py,任何角色的專案參與者都放行、只擋非參與者,查不到專案就預設擋下來。

首腦核對註記(我自己開檔+查 DEV 資料庫確認)

  • ✅ 開檔核對成立。job_evidence_route.py:28-29 確實直接用 request.args.get('job_execution_uid');job_evidence_service.py:42-59 確實沒有任何授權檢查,而 :81/:145/:168 三處確實都有,註解還寫著「擋跨專案 / 跨租戶猜 uid」——是漏掉不是刻意。
  • ✅ 資料庫層真的沒有第二道防線。我用唯讀 SQL 查 DEV 資料庫,compliance.job_evidences(848 筆)與 compliance.job_executions(11,065 筆)的「每個客戶只能看自己資料」隔離機制(RLS)開關是關的、規則 0 條。出貨基線 schema(scripts/init/02-schema.sql)全庫只有 31 張表開了這個機制,這兩張都不在裡面。
  • ⚠️ 一個細節要修正工具的說法:工具說「證據檔案本體可以再下載」,這一步我沒有進到檔案模組去核對(不在本棒範圍),所以這一環標記為未經核對的推論。前面的清單外洩本身已經確認成立。

F8 — 查單一筆證明也沒有檢查歸屬(MEDIUM,可信度 高)

這是什麼問題(白話) 和 F1 同一支服務裡,還有另一支「用證明編號查單一筆」的方法,一樣沒有檢查歸屬。

為什麼是 MEDIUM 不是 HIGH 因為目前打不到。三個檢查員都指出:api/flow_engine/routes/job_evidence_route.py:71 把「一筆」資料餵進了一個宣告成「多筆」的序列化器,而那個資料物件是純 dataclass、不能被逐項迭代,所以在資料送出去之前程式就會自己壞掉(工具原本另外報的 F10 就是因此被 3 票全數否決)。守門缺口是真的,資料外洩目前不成立。 但這是「靠一個 bug 擋住」,那個 bug 一被修好,缺口就變成真的外洩。

在哪裡 app/flow_engine/service/job_evidence_service.py:61-70(get_job_evidences_uid),route 在同檔 api/flow_engine/routes/job_evidence_route.py:59-72

怎麼修 和 F1 一起補:

self._workflow_execution_service.assert_project_participant(job_evidence.workflow_execution_id)

首腦核對註記:✅ 開檔核對成立,get_job_evidences_uid 確實只查 uid 就回傳。序列化器不匹配這件事我讀了 route 第 70-71 行確認形狀對不上(單筆丟進 many=True),與檢查員說法一致。


F9 — 流程留言的讀與寫都沒有檢查歸屬(MEDIUM,可信度 中)

這是什麼問題(白話) 稽核流程頁面上有一個討論區可以留言。留言的「寫」和「讀」兩支 API,都只確認你有登入,沒有確認這個流程是不是你們專案的。

出事會怎樣

  1. 竄改稽核紀錄的脈絡:可以在別人的稽核流程裡塞留言,而留言會經即時推播丟進該專案成員的畫面,成員重新載入就看到一則「同事的留言」——可以拿來做釣魚(「請把報告寄到某某信箱」)。
  2. 讀走別人的討論內容。
  3. 留言存進一個 JSON 陣列,沒有筆數與長度上限,可以一直塞把那一列灌爆。

要先有什麼才打得到 ① 任一個有效登入帳號 ② 知道一個流程編號(同樣是 UUID,要取得而非猜出)

為什麼是 MEDIUM 不是 HIGH 影響的是討論留言而不是證明文件本體,且同樣需要先取得編號。但如果把「假冒同事推播釣魚」算進來,實務衝擊不見得比 F1 低——這點我保留意見,交決策者判斷。

在哪裡

  • 寫:api/flow_engine/routes/flow_engine_route.py:66-121(PUT /api/1.0/flow-engine/process/comments/<id>),守門缺口在 :105 附近實際寫入處
  • 讀:同檔 :44-64(GET 同一條路徑)
  • 對照組:同模組「完成任務」app/flow_engine/service/workflow_execution_service.py:655、「退回任務」:833,兩支都有 assert_project_participant

怎麼修 把守門加在 app service 層(不是 route,route 只負責 HTTP 邊界):解出 workflow_execution 之後呼叫 assert_project_participant(workflow_execution.id),讀寫兩條路徑都要。另建議替留言內容加長度與筆數上限。

首腦核對註記

  • ✅ 開檔核對成立。flow_engine_route.py 的 WorkflowExecutionCommentRoute 的 get(:44)與 put(:66)都只有 @jwt_required(),route 內直接查 workflow_execution 再讀寫留言,全程沒有任何歸屬檢查。
  • ✅ 對照組確認:complete_job(:655)與 revert_job(:833)真的都有守門,所以「留言漏掉」是這個模組自己的不一致,不是設計上刻意不守。
  • ✅ 資料庫層同樣沒有兜底:DEV 實查 compliance.element_variables(3,236 筆)隔離開關關、規則 0 條;compliance.workflow_executions 有 3 條規則但開關是關的,等於規則寫好了沒生效。
  • ⚠️ 留言內容被前端怎麼渲染(會不會變成「攻擊者存進去的內容,別人打開頁面時被當程式執行」)本棒沒有追前端,只記錄不下結論。

5. 「重點看什麼」逐項回應

卡片列了 8 項思考起點,逐項交代(證偽和證實一樣重要):

卡片上的疑點 結果
① 流程留言 GET/PUT 零守門 ✅ 成立 → F9。讀寫都沒守門,與「完成/退回」不一致
② 任務證明「讀」零守門、「寫」有守門 ✅ 成立 → F1(清單)、F8(單筆)。寫的三處確實都有
③ job_evidences/element_variables 隔離關、零規則 ✅ 成立,我用唯讀 SQL 在 DEV 實查確認(見上表)。job_executions 也是關的、零規則
④ 守門政策三個「不守」分支 ⚠️ 部分推翻卡片的假設,需要決策者看一眼。common/authz/workflow.py:38 的註解寫「tenant admin 放行(RLS 仍限租戶)」,但我追到源頭 jedi-iam/jedi_iam/middleware/context.py:134 是 is_admin=user.is_super_admin——這個旗標實際上是「平台超級管理員」不是「租戶管理員」。所以卡片擔心的「租戶 A 的管理員能動租戶 B」不成立(因為根本不是租戶管理員)。但註解是錯的,而且它宣稱的兜底「RLS 仍限租戶」在這三張表上是空話(隔離根本沒開)。
另兩個分支:「沒有登入身分放行」屬背景任務路徑(同 CM-1559 同源,已有卡);「role_service 沒注入放行」我查了 DI 接線(di_containers/flow_engine/workflow_excution_containers.py:151),正式路徑有注入,不會走到這個放行分支
⑤ 反查鏈對「輪次主流程」恆回 None → 100% 誤擋 ⚠️ 狀況屬實但沒有引發繞過。workflow_execution_service.py:137-153 確實在查不到時回 None,而守門對 None 是預設擋下來。我 grep 過全部呼叫端:只有 FR-050 的階段回退傳了 known_project_id 繞過反查鏈,而且它是傳「已解析出的正確專案編號」、不是可被外部操控的值,其餘呼叫端都沒有為了繞過而拿掉守門。沒有發現「因為必被擋所以有人把守門刪掉」的情況
⑥ 完成/退回端點的 body 沒有 schema ✅ 讀過了,不構成安全問題。守門在 service 層(:655/:833)確實有;revert_to_job_id 指到別的流程的 job 時,workflow_execution_service.py:857 會因為在 BPMN 圖裡找不到節點而回 404(GRC_JOB_NOT_FOUND),不會亂跑;request.get_json() 遇到非 JSON 在 Flask 3.1 是回 400/415 而不是 500
⑦ 流程定義 GET 回整份範本 XML 給任何登入者 ⚠️ 人工核對成立,但工具沒有報,故未經三人投票。flow_engine_route.py:29-37 只有 @jwt_required();套件端 jedi-flow-engine 的 get_workflow_template_by_uid(workflow_template_service.py:46-56)確實只用 uid 查、沒有租戶檢查;而 compliance.workflow_templates 在 DEV 實查是規則 4 條但開關關的(=規則沒生效)。結論:知道範本編號就能讀到別家客戶的流程圖(含自訂階段、角色、任務名稱)。 這條的責任層在套件(屬 P1 範圍),本棒只記錄、建議併入 P1 那一棒處理
⑧ 我的任務清單 GET ✅ 這條沒問題。flow_engine_route.py:175-186 用的是登入者自己的 user.id,而 infra/readmodel/tasks/my_grc_jobs_query.py:74-77 的查詢確實是 VwUserJobQueue.user_id == user_id,使用者無法指定別人的 id。不是 FR-087 說的那 4 支「用擁有者身分繞過隔離」的 view 之一(那個問題的形狀是「view 沒有帶 security_invoker,導致查詢用 view 擁有者的身分跑」,這裡因為過濾條件綁死自己的 user_id,繞不過去)

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

分兩層講,不要混在一起:

第一層:「報出來的這些,真的存在嗎」→ 可信度高

  • 三個獨立檢查員從零讀檔,對 10 條候選各投一票,30 票全數投出,沒有漏投。9 條通過、1 條(F10)被 3 票全數否決。
  • 通過的 9 條裡,8 條是 3:0 全數同意,1 條(F5,範圍外的 LLM API 金鑰)是 2:1。
  • 檢查員主動把 2 條的嚴重度往下調(F3、F4 從 HIGH 降 MEDIUM),代表這個投票不是橡皮圖章。
  • 範圍內 3 條我全部自己開檔核對過,另外用唯讀 SQL 在 DEV 查了資料庫隔離狀態。核對結果與工具一致,只有 F1 的「檔案本體可下載」那一環我標為未核對的推論。

第二層:「這 23 個檔只有這些問題嗎」→ 不可宣稱

  • effort 設 low,代表只派了一位研究員通讀全範圍(實際派 2、回 2,含密鑰專項那支),不是逐檔窮舉。
  • 工具沒有申報逐檔閱讀帳本(coverage.research 是空的),所以「哪幾個檔真的被讀完了」無從查證。
  • 注意力被稀釋:10 條候選裡有 6 條是範圍外的密鑰重複案,也就是說 30 票裡有 18 票投在範圍外,真正投在這 23 個檔上的只有 12 票。
  • 兩條有份量的補充(上表的 ④ 註解與事實不符、⑦ 流程定義外洩)是我人工追出來的,工具一條都沒碰。

白話總結:報出來的問題是真的;但「掃過了」不等於「這 23 個檔乾淨了」。


7. 執行概況(工程師看的數字)

照 stamp 的 verification 欄實抄:

項目 數值
status verified
candidates 11(去重後 10)
panel_votes 30
panel_quorum_findings 9
researchers_dispatched / returned 2 / 2
unreviewed_candidate_sites 0
verificationRun 1
lostCandidates 無
severityLowered F3(HIGH→MEDIUM)、F4(HIGH→MEDIUM),皆由檢查員投票結果決定
掃描範圍 23 支檔(scopeFileCount: 23,與 git ls-files 對上)
effort / focus low / attack-surface
完整度檢查 not-applicable(範圍掃描不做全樹盤點)
Run ID wf_bd76357a-f9d
耗時 約 3 小時 10 分(11,366 秒),32 個 agent 全數完成、0 失敗

沒有執行任何程式碼:所有發現都是讀程式碼推導出來的,沒有實際打過 API、沒有驗證過任何攻擊手法。對資料庫只做了唯讀查詢(確認隔離開關與筆數)。


8. 建議的後續(交決策者裁)

  1. F1 + F8 一起修(同一個檔案、同一種修法,補兩行)——建議開一張修正卡。
  2. F9 單獨修(改的是另一個模組的 app service 層,且讀寫兩條都要)——建議開一張修正卡。
  3. 上表 ⑦ 的流程定義外洩責任在 jedi-flow-engine 套件,建議併入 P1 那一棒,不在本棒開卡。
  4. 上表 ④ 的錯誤註解(common/authz/workflow.py:38 把平台超管寫成 tenant admin)——衛生類,建議併入前述任一張修正卡順手改掉,因為這行註解會誤導下一個讀它的人以為有租戶兜底。
  5. 這三張表要不要補資料庫層隔離(job_evidences / job_executions / element_variables)——這是治本方向,但屬跨模組決策,且 workflow_executions 已經出現「規則寫好卻沒開開關」的狀況(FR-087 已記),建議與 FR-087 一起收口,不在本棒決定。