306ef4453df4e9aeeef91427183d806e6a72e5dd(branch feature/FR-075)claude-security plugin v0.11.0,effort low、focus attack-surfaceverified(三個獨立檢查員對 11 條候選各投一票,33 票全數投出,沒有漏投)掃完了,範圍內找到 4 個真問題,最嚴重的是「任何登入者只要知道一個輪次編號,就能讀走別家客戶的稽核階段歷程——包括每次退回是誰做的、退回理由寫了什麼」。 但派工卡上懷疑的「能不能跨專案把別人的稽核推進/退回(改資料)」,實測不成立——被下游套件的第二道檢查擋住了,詳見 §5。另外工具在範圍外順手撈到 4 條密碼/金鑰被寫進版控的問題,全部都是已經開過卡的舊案。
現況(以 docs/security-report/M06*.md 為準):F5+F6(階段歷程,M06-5)→ ✅ 已修(CM-2035);F4(階段資訊,M06-6)→ ✅ 已修(CM-2035);F8(署名可自填,M06-12)→ ✅ 已修(CM-2059);force 兩來源(M06-13)→ 🚫 裁定不修(非資安,決策者 10-01)。範圍外的憑證項(CM-1607/1608/1629/1631 系列):檔案殘留已清(CM-2048/2049/2051),密碼換發排在正式環境上版前。
每個稽核專案的每一輪,畫面上方都有一條橫幅顯示「現在走到哪個階段」(規劃 → 稽核計畫 → 稽核 → 改善 → 結案)。橫幅上有兩個按鈕:「推進」(進下一階段,會順帶觸發「啟動稽核」「結案」這類真的會改資料的動作)與**「返回上一階段」(管理者專用)。旁邊還有一個「階段歷程」**時間軸,記錄每次推進與退回。
這一棒檢查的是:當這三個功能從網址送進後端時,後端有沒有問一句「你是不是這個專案的人」。
關鍵在於這些網址同時帶了兩個編號:
/api/1.0/project/<專案編號>/audit-round/<輪次編號>/stage/info
↑ 拿這個查「你是誰」 ↑ 拿這個做事
程式用左邊的編號查你的角色,卻用右邊的編號去撈資料,而且從頭到尾沒有比對過「右邊那個輪次,是不是真的屬於左邊那個專案」。 兩個編號都是呼叫者自己填的,所以理論上可以左邊填自己的專案(讓角色檢查通過)、右邊填別人的輪次(去撈別人的資料)。本棒要驗的就是這條路走不走得通。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| F5+F6 🟡 MEDIUM (兩位研究員各報一次,同一個洞) |
「階段歷程」這支 API 完全沒有檢查你是誰,只檢查你有沒有登入。網址上的專案編號甚至從頭到尾沒被程式用過 | 讀走別家客戶的稽核流程時間軸:每一次推進/退回的操作者帳號、暱稱、從哪階段退到哪階段、退回理由全文、時間。退回理由是管理者自由填寫的文字(資料庫規定退回時必填),內容通常直接寫明「這次稽核哪裡出了問題」 | ① 任一個有效登入帳號(任何角色,連專案參與者都不必是) ② 知道一個輪次編號(是亂數 UUID,猜不出來,要從轉寄的連結、截圖、log 或匯出檔取得) |
api/flow_engine/routes/stage_rollback_route.py:64(整支 get 從 :52 起)下游: jedi-compliance-audit 的 audit_round_app_service.py:867,只查「輪次存不存在」 |
在 app service 層(不是 route 層)用 round_uid 解出 round.project_id,比對它是否等於 project_uid 解出的專案;再用 common.authz 的 assert_project_participant 確認呼叫者是該專案參與者。做法可直接抄同檔 StageRollbackResource(:24)走的 _check_role 路徑 |
| F4 🟡 MEDIUM |
「查目前在哪個階段」這支 API 用你填的專案編號查你的角色,卻用你填的輪次編號撈資料,兩者不核對;角色不符時只是把「可否推進」標成 false,照樣把資料回給你 | 讀走別家客戶的稽核進度:目前階段代碼與名稱、整條流程的進度清單(哪些做完、哪些還沒)、流程執行編號、流程狀態、內部 job 編號、前置條件內容(例如「已審查控制項數量」這種來自對方稽核計畫的統計) | ① 任一個有效登入帳號 ② 自己名下有一個專案編號可填(不必是該專案的參與者——查不到角色時程式回 None 不擋)③ 知道一個輪次編號 |
app/flow_engine/service/stage_advance_service.py:84(get_current_stage_info,整支從 :81 起)角色查詢在 :578-587 的 _get_user_role |
在 get_current_stage_info 開頭、第 84 行 _resolve_workflow_context() 之後,比對 ctx["round"].project_id 與 project_uid 解出的 project.id,不符就 raise ForbiddenError;並把「查不到角色」從「回 None 繼續」改成擋下來 |
| F8 ⚪ LOW |
推進/退回時,操作者的顯示名稱可以由呼叫端自己指定——程式用的是「呼叫端沒填才補上真名」的寫法 | 稽核歷程時間軸與流程留言上,會顯示攻擊者自選的名字(例如同事的名字)作為「這個階段是誰推進的」。真正的帳號(login_name)仍然是伺服器自己填的,所以只影響畫面顯示、不影響稽核軌跡的權威欄位 | 攻擊者本來就有權推進或退回該階段(也就是已經通過角色檢查了)——所以這是「自己人竄改署名」,不是外人打得到的洞 | api/flow_engine/routes/stage_advance_route.py:59(ctx.setdefault("user_nickname", ...));同樣寫法也在 stage_rollback_route.py:40 |
把 setdefault 改成直接覆寫:ctx["user_nickname"] = user_ctx.nickname(身分欄位一律以登入身分為準,不接受呼叫端指定);並在 StageAdvanceRequestSchema(api/flow_engine/serializers/stage_advance.py:11-12)給 ctx 加白名單,丟掉不認識的 key |
工具只要設了 focus 就會額外跑一次「找密碼金鑰」的專項掃描,它掃全 repo、不受本棒 21 檔範圍限制。以下四條都不在本棒範圍內,且經比對全部落在既有卡片上:
| # | 內容 | 對應既有卡 |
|---|---|---|
| F1(HIGH) | POC 資料庫密碼寫死在文件產生腳本裡 docs/system-design/scripts/generate_db_schema_docx.py:34 |
CM-1608/CM-1629 |
| F2(HIGH) | OpenAI/Anthropic/Google 三家 API 金鑰被寫進對話紀錄檔 | CM-1607(決策者 2026-09-08 已撤銷重發,剩字串清理) |
| F3(MEDIUM) | JWT 簽章金鑰被寫進對話紀錄檔,與目前 .env 使用中的值相同 |
CM-1607(FR-079 F12 已裁定:安裝時每套各自產生,外流的只是開發機那把) |
| F7(MEDIUM) | Google 雲端硬碟 OAuth 應用程式密鑰被寫進對話紀錄檔 | CM-1631 |
結論:範圍外這 4 條不需要開新卡。 與上一棒(H2)撈到的 6 條高度重疊,再次佐證同一件事——「把 .env 原樣貼進對話紀錄」這個習慣,讓同一批金鑰散進了幾十到幾百個檔案。
這是什麼問題(白話)
畫面上的「階段歷程」時間軸,會列出這一輪稽核從頭到尾每一次推進與退回:誰做的、什麼時候、從哪個階段到哪個階段、退回的理由是什麼。後端提供這份清單的 API,只確認「你有登入」,其他什麼都不查。
網址長這樣:GET /api/1.0/project/<專案編號>/audit-round/<輪次編號>/stage/transitions。看起來有帶專案編號,但那個參數程式從頭到尾沒有用過——它被解析進函式,然後就被丟在一旁。真正決定回傳什麼的只有輪次編號。
在哪裡
api/flow_engine/routes/stage_rollback_route.py:52-67,整支 get 只掛了 jwt_required()(=只驗有沒有登入),第 64 行直接把 round_uid 丟下去jedi-compliance-audit 套件的 jedi_compliance_audit/app/service/audit_round_app_service.py:867-887,list_stage_transitions() 只做一件事——查輪次存不存在,不存在就回 404。沒有任何角色或參與者檢查首腦人工核對(2026-09-12)
逐項開檔核對,全部屬實:
api/flow_engine/__init__.py:92,路徑與報告一致list_stage_transitions 全文讀過,確認只有 get_by_uid + 存在性檢查,回傳的 7 個欄位含 operator/operator_nickname/reasonSELECT):
compliance.project_audit_rounds → RLS 未啟用
compliance.round_stage_transitions → RLS 未啟用
全庫只有 33 張表開了 RLS(就是「每個客戶只能看自己資料」的資料庫隔離機制),這兩張都不在其中。連 compliance.projects 與 compliance.project_participants 也沒開——這點與工具報告裡「RLS 會把專案限縮在自己租戶」的說法不符,工具這句是錯的(見下方「工具說錯的地方」)operator 欄位實際存著 blsadmin、blspan 這類帳號與 Billows Admin、Pan 這類暱稱⚠️ 工具說錯的地方(需要修正報告原文)
工具在 F4 的前提條件裡寫「compliance.projects 上的 RLS 會強制專案必須是自己租戶的」。這句不成立——compliance.projects 根本沒開 RLS。這不會讓 F4 消失,反而讓門檻更低:攻擊者連「專案得是自己租戶的」這個限制都沒有。租戶隔離目前靠的是程式層(session_scope() 注入的 session 變數 + 各查詢的過濾),不是資料庫層。
怎麼修
在 app service 層(依 CLAUDE.md 規範,route 層不做 DB 查詢):
# 在 list_stage_transitions 的呼叫端(或套件內該方法開頭)
rnd = self._rounds.get_by_uid(round_uid)
if rnd is None:
raise NotFound(...)
project = self._project_domain_service.get_one(ProjectQueryEntity(uid=project_uid))
if project is None or rnd.project_id != project.id:
raise ForbiddenError(...) # 輪次不屬於這個專案
assert_project_participant(...) # common.authz 資源域守門同一支檔案裡的「返回上一階段」(StageRollbackResource,:24)已經有完整的 manager 檢查了(走 stage_rollback_service.py:95 的 _get_user_role),只有「讀歷程」這支漏掉。
為什麼是 MEDIUM 不是 HIGH
因為打到之前要先拿到輪次編號,而它是亂數 UUID、猜不出來,得從外部管道取得(轉寄的連結、截圖、log、匯出檔)。不過一旦拿到一個,就完全沒有第二道關卡——程式層沒有、資料庫層也沒有。
這是什麼問題(白話)
這支 API 負責畫面上方那條橫幅。它同時收兩個編號,用你填的專案編號去查「你在這個專案是什麼角色」,卻用你填的輪次編號去撈資料——而且不比對這兩者是否相配。
更關鍵的是:角色查出來不符,程式也不擋。它只是把回傳結果裡的「可否推進」標成 false,然後照樣把整包階段資訊回給你。
在哪裡
app/flow_engine/service/stage_advance_service.py:81-188(get_current_stage_info)_resolve_workflow_context(round_uid) — 只用輪次編號撈輪次與流程(該函式在 :529-549)_get_user_role(project_uid, user_id) — 只用專案編號查角色(該函式在 :578-587)user_can_advance 布林值,全檔沒有任何一處 raise ForbiddenError會流出什麼(依 :169-187 的回傳內容逐項對照)
目前階段代碼與名稱、階段種類、推進/退回按鈕文字、處理器代碼、該階段的主要角色清單、是否為最後階段、整條流程的進度清單(每一步是 done/current/pending)、流程執行編號 workflow_execution_uid、流程狀態、內部 job_id,以及前置條件的檢查結果與 context。
其中 workflow_execution_uid 值得單獨標記——它本身就是另一批 API 的鑰匙,拿到之後可以再去打其他吃這個編號的端點。
首腦人工核對(2026-09-12)
開檔逐行確認:_resolve_workflow_context(:529-549)確實只吃 round_uid,回傳的 dict 裡雖然有 round 物件(含 project_id 欄位),但呼叫端從沒拿它來比對。_get_user_role(:578-587)在查不到參與者紀錄時 return None——不 raise,所以「我不是這個專案的人」也能順利走完。輪次表確實有 project_id 欄位(jedi-compliance-audit 的 infra/model/project_audit_round.py:28),該比對的資料就在手上,只是沒比。
怎麼修
在 get_current_stage_info 第 84 行拿到 ctx 之後立刻插入歸屬檢查,與 F5/F6 同一套做法(比對 ctx["round"].project_id 與 project_uid 解出的 project.id,不符就擋)。advance_stage(:191)雖然後面有守門擋住寫入,但它第 224 行也是同樣的寫法,建議一併補上——現在擋得住是因為下游套件有第二道檢查,這屬於「別人替你擋」,不是自己的設計保證(見 §5)。
這是什麼問題(白話)
推進與退回的請求裡有一個叫 ctx 的欄位,是完全開放的自由字典(api/flow_engine/serializers/stage_advance.py:12:ctx = fields.Dict,沒有任何 key 限制)。後端用的是「呼叫端沒填才補上真名」的寫法:
ctx.setdefault("user_nickname", user_ctx.nickname) # stage_advance_route.py:59setdefault 的意思是**「只有在這個 key 不存在時才填」**。所以呼叫端只要自己塞一個 user_nickname,就會原封不動地傳下去,最後寫進三個地方:稽核歷程表的 operator_nickname 欄位(stage_advance_service.py:450)、BPMN 流程的留言(:387)、以及流程討論串的作者欄(:412)。
為什麼是 LOW
兩個原因把傷害限制住了:① 攻擊者必須本來就有權推進/退回這個階段(已通過角色檢查),所以是自己人竄改署名、不是外人打得到;② 權威欄位 operator(login_name)仍然是伺服器從登入身分取的(curr_user=user_ctx.login_name),沒被污染——查稽核軌跡時真相仍在,只是畫面上會顯示錯的名字。
在哪裡
api/flow_engine/routes/stage_advance_route.py:59api/flow_engine/routes/stage_rollback_route.py:40(同樣的 setdefault 寫法)app/flow_engine/service/stage_advance_service.py:450(歷程表)、:387(BPMN 留言)、:412(討論串作者)怎麼修
setdefault 改直接覆寫(身分類欄位一律以登入身分為準);並在 marshmallow schema 給 ctx 加白名單,丟掉不認識的 key。
卡片「重點看什麼」列了 7 項,逐項交代(證偽與證實同樣重要):
| 卡片上的疑點 | 結果 | 說明 |
|---|---|---|
| 🔴 ① 專案編號與輪次編號從不核對 | ✅ 部分成立(讀成立、寫不成立) | 見下方詳述 |
🔴 ② force 旗標有兩個版本 |
❌ 不成立(三個檢查員 0:3 否決,首腦複核同意) | 見下方詳述 |
| ③ 階段歷程 GET 零守門 | ✅ 完全成立 | 即 F5/F6,是本棒最嚴重的一條 |
| ④ 階段資訊 GET 對非參與者也回資料 | ✅ 成立,且不像是刻意的 | 即 F4。程式碼沒有任何註解說明這是刻意設計;user_can_advance 的用途是控制按鈕能不能按,不是「授權給非參與者看」。外洩範圍已逐項列在 F4 |
⑤ ctx 整包往下傳給 handler |
✅ 讀過,找到 1 個問題(即 F8) | 六支 handler(oscal_stage_handlers.py:40-140)與六支前置條件(oscal_stage_preconditions.py:35-128)全部讀過。handler 從 ctx 只拿兩個值:force(僅 LaunchAuditOnCompleteHandler:41)與 decision(僅 ReviewDecisionHandler:140);六支前置條件完全沒有讀 ctx(簽章有 ctx 參數但函式內沒用)。唯一被使用者控制值污染的是 user_nickname(F8)。另外 _next_stage_code 是程式自己塞的,不是使用者給的 |
⑥ decision 與 comment 落到哪 |
✅ 讀過,沒有發現可報的問題 | decision 只接受 approve/reject(schema 層 validate.OneOf),reject 時 comment 必填(schema 層 + service 層雙重檢查)。comment 寫進 compliance.job_execution_comments.content(Text 型別,沒有長度上限,API 層也沒限長)。留言的守門是好的——JobCommentService.add_comment(jedi-task-platform)會自己反查 workflow_execution_id 並呼叫 assert_project_participant。唯一可記的小事:reason 與 content 都是無上限的 Text,理論上可灌大量文字,但這屬於容量問題不是權限問題,不足以單獨開卡 |
| ⑦ StageRegistry 後註冊者覆蓋 | ✅ 確認只在啟動時註冊,只記錄不報問題 | 全 codebase 搜過 register_handler( / register_precondition( / register_rollback_handler(,呼叫點只有一處:di_containers/flow_control/flow_control_containers.py:701-705,由 register_stage_hooks_to_registry() 呼叫;而該函式只在 core/app_factory.py:308 被呼叫一次,位置在應用程式啟動流程內。沒有任何 request 期路徑能觸發註冊,覆蓋風險不存在 |
另有第 8 項「跨層檢查:三張表隔離全關」——已實測確認(見 F5 核對段),stage_objects、round_stage_transitions、project_audit_rounds 三張表的 RLS 全部未啟用。且比卡片說的更廣:compliance.projects 與 compliance.project_participants 也沒開。
成立的部分(讀):F4 與 F5/F6 就是這條,兩支 GET 端點都中。
不成立的部分(寫):卡片問「我是 A 專案的 manager,打 POST .../project/<A>/audit-round/<B 的輪次>/stage/advance,能不能推進 B 的稽核?」——不能。
原因是下游套件有第二道檢查。advance_stage(stage_advance_service.py:191)雖然自己的角色檢查(:289)確實查錯了專案,但它在第 356 行呼叫 handler 執行業務動作時,每一支 handler 都會轉呼叫 jedi-compliance-audit 的 AuditRoundAppService,而那裡會拿輪次自己的 project_id 再查一次角色:
# jedi_compliance_audit/app/service/audit_round_app_service.py
def launch_audit(self, round_uid, ...):
e = self._rounds.get_by_uid(round_uid)
self._check_role(e.project_id, curr_user_id, "manager") # ← :385,用輪次自己的專案首腦逐一開檔核對了全部 6 條寫入路徑,每一條都有這道檢查:
| 推進動作 | 套件方法 | 第二道檢查在 | 要求角色 |
|---|---|---|---|
| 啟動稽核 | launch_audit |
:385 |
manager |
| 送出稽核計畫 | start_auditing |
:445 |
auditor(manager 可代行) |
| 稽核定版 | finalize_audit |
:476 |
auditor(manager 可代行) |
| 結案 | close_round |
:538 |
manager |
| 退回到規劃 | rollback_to_planning |
:792 |
manager |
| 退回到稽核計畫 | rollback_to_audit_planning |
:823 |
manager |
| 退回到稽核 | rollback_to_auditing |
:847 |
manager |
而且 handler 是在第 356 行執行、BPMN 推進在第 382 行——順序上先檢查後動作,且整支包在 @transaction 內,handler 拋錯會整筆 rollback。所以跨專案推進會在第一步就被 403 擋下,BPMN 也不會被動到。
但這件事值得記一筆:擋住它的是下游套件的自我保護,不是本層的設計。stage_advance_service.py 這一層目前的狀態是「自己沒守,靠別人守」。哪天某支 handler 忘了檢查、或新增一支不走 AuditRoundAppService 的 handler,這個洞就會立刻變成可寫入。建議把 F4 的歸屬檢查同時補在 advance_stage 上(不只補 get_current_stage_info),讓這層自己站得住。
回退同理:stage_rollback_service.py:95 也是拿 project_uid 查角色,但第 137 行的 handler 一樣會走到套件的 rollback_to_*,在那裡用輪次自己的專案再檢一次(:792/:823/:847)。
force 兩個版本:確實有兩個版本,但吃不到東西卡片的懷疑是:外層 force=false、ctx={"force": true},非 manager 的階段負責角色能不能藉此跳過「規劃就緒檢查」啟動稽核?
兩個版本是真的存在:
stage_advance_service.py:209:ctx.setdefault("force", force) — 只在 ctx 裡沒有 force 時才填,呼叫端塞的值會活下來:303 的「非 manager 不可 force」檢查,看的是外層 force 變數oscal_stage_handlers.py:41:force = bool((ctx or {}).get("force")) — 讀的是 ctx 裡的所以「檢查看外層、使用看內層」這個錯位確實成立。但跳過的東西沒有價值:
ctx["force"] 唯一的用途,是讓 LaunchAuditOnCompleteHandler 跳過 PlanningReadinessChecker 的兩項軟提醒(證據蒐集任務還沒做完、流程模板要的角色還沒指派)。首腦開檔確認(jedi-compliance-audit/app/service/planning_readiness_checker.py),這個檢查器的設計就是提醒而非守門——
force=true——本來就是給使用者放行的機制而真正的守門一個都跳不掉:第 292 行的角色檢查(非 main_role 且非 manager 直接 403)在 handler 之前執行、:303 的「force 需 manager」看的是外層變數(塞 ctx 繞不過它)、第 314 行真正的前置條件檢查(_run_precondition)看的也是外層 force,不是 ctx。
檢查其他 handler:沒有第二支讀 ctx 裡的 force(全 codebase 只有 oscal_stage_handlers.py:41 這一處),六支前置條件也都不讀 ctx。
結論:這是程式碼異味(同一個意思有兩個來源、寫法不一致),不是漏洞。 三個檢查員各自獨立判為 0:3 否決,首腦複核同意。建議順手清理(把 setdefault 改成直接覆寫 ctx["force"] = force),但不需要開修正卡。
分兩層講,這兩件事不一樣:
verifiedcompliance.projects 有 RLS 保護,實查沒有。已在報告中更正low effort,一位研究員掃全範圍(工具實際派了 2 位、2 位都回報),不是逐檔逐類的窮舉stage_object_* 那一串檔案(8 支,屬階段定義的讀取鏈),工具與首腦都沒在上面發現問題,但沒有做到逐行確認| 項目 | 數值 |
|---|---|
verification.status |
verified |
候選發現數(candidates) |
12 |
去重後(candidates_deduped) |
11 |
面板票數(panel_votes) |
33(11 條 × 3 個檢查員) |
達到共識的發現(panel_quorum_findings) |
8 |
沒被檢查到的候選點(unreviewed_candidate_sites) |
0 |
研究員派出/回報(researchers_dispatched/returned) |
2/2(全數回報) |
| 嚴重度分佈 | HIGH 2(皆範圍外)/MEDIUM 5/LOW 1 |
| 面板調整 | F3 由 HIGH 降為 MEDIUM(三票一致) |
| 掃描耗時 | 14,359 秒(約 4 小時) |
| 執行代理總數 | 35 個(全數完成,0 錯誤、0 略過、0 空回報) |
wf_1bb7fb14-b17CLAUDE-SECURITY-20260912-073625/(含 .jsonl/.sarif/stamp)| 建議 | 內容 | 優先序 |
|---|---|---|
| 開修正卡 A | 階段歷程 API 補歸屬檢查(F5/F6)+ 階段資訊 API 補歸屬檢查(F4)。兩條同根同源、修法相同(比對 round.project_id 與 project_uid,再走 assert_project_participant),建議合成一張卡 |
本棒最高 |
| 併入修正卡 A(或另開 LOW 卡) | 署名可自填(F8)+ force 兩版本清理。都在同一批檔案、同一種寫法(setdefault 用在不該用的地方),順手一起改成本最低 |
中 |
一併補上 advance_stage 的歸屬檢查 |
目前靠下游套件擋住,屬「別人替你擋」。修 F4 時建議把 advance_stage(:224)與 rollback_stage(:95)一起補,讓這層自己站得住 |
中(防未來退化) |
| 範圍外 4 條 | 不開新卡,已在 CM-1607/1608/1629/1631 | — |
| 值得上報的跨棒觀察 | 這兩張表(project_audit_rounds/round_stage_transitions)沒有租戶欄位也沒開 RLS,屬 FR-087 T1 盤點(13 張有租戶欄的表)之外的死角。同類型的表可能還有,建議母卡收口時單獨盤一次 |
提給首腦判斷 |