FR-088.2 H1:稽核階段推進與回退——資安掃描報告

  • 卡片:CM-1671(FR-088 第 2 棒)
  • 範圍:BE repo 21 支檔(稽核階段「推進」與「返回上一階段」兩個按鈕背後的網址入口到守門)
  • 掃描時間:2026-09-12(UTC 07:36 起,跑了約 4 小時)
  • 掃描版本:306ef4453df4e9aeeef91427183d806e6a72e5dd(branch feature/FR-075)
  • 工具:Claude Code claude-security plugin v0.11.0,effort low、focus attack-surface
  • 驗證章:verified(三個獨立檢查員對 11 條候選各投一票,33 票全數投出,沒有漏投)
  • 只掃不修:本棒沒有改任何程式碼,也沒有動任何環境(只對 DEV 資料庫做了唯讀查詢)

1. 🔴 一句話結論

掃完了,範圍內找到 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),密碼換發排在正式環境上版前。

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

每個稽核專案的每一輪,畫面上方都有一條橫幅顯示「現在走到哪個階段」(規劃 → 稽核計畫 → 稽核 → 改善 → 結案)。橫幅上有兩個按鈕:「推進」(進下一階段,會順帶觸發「啟動稽核」「結案」這類真的會改資料的動作)與**「返回上一階段」(管理者專用)。旁邊還有一個「階段歷程」**時間軸,記錄每次推進與退回。

這一棒檢查的是:當這三個功能從網址送進後端時,後端有沒有問一句「你是不是這個專案的人」。

關鍵在於這些網址同時帶了兩個編號:

/api/1.0/project/<專案編號>/audit-round/<輪次編號>/stage/info
                 ↑ 拿這個查「你是誰」      ↑ 拿這個做事

程式用左邊的編號查你的角色,卻用右邊的編號去撈資料,而且從頭到尾沒有比對過「右邊那個輪次,是不是真的屬於左邊那個專案」。 兩個編號都是呼叫者自己填的,所以理論上可以左邊填自己的專案(讓角色檢查通過)、右邊填別人的輪次(去撈別人的資料)。本棒要驗的就是這條路走不走得通。


3. 掃到什麼:總覽表

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

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修
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

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

工具只要設了 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 原樣貼進對話紀錄」這個習慣,讓同一批金鑰散進了幾十到幾百個檔案。


4. 每條發現的詳述

F5+F6 — 階段歷程 API 完全沒有守門(MEDIUM,可信度 高)

這是什麼問題(白話)

畫面上的「階段歷程」時間軸,會列出這一輪稽核從頭到尾每一次推進與退回:誰做的、什麼時候、從哪個階段到哪個階段、退回的理由是什麼。後端提供這份清單的 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)

逐項開檔核對,全部屬實:

  1. 路由確實註冊在 api/flow_engine/__init__.py:92,路徑與報告一致
  2. list_stage_transitions 全文讀過,確認只有 get_by_uid + 存在性檢查,回傳的 7 個欄位含 operator/operator_nickname/reason
  3. 資料庫層確實沒有第二道防線——實際連 DEV 資料庫查過(唯讀 SELECT):
    compliance.project_audit_rounds      → RLS 未啟用
    compliance.round_stage_transitions   → RLS 未啟用
    全庫只有 33 張表開了 RLS(就是「每個客戶只能看自己資料」的資料庫隔離機制),這兩張都不在其中。連 compliance.projects 與 compliance.project_participants 也沒開——這點與工具報告裡「RLS 會把專案限縮在自己租戶」的說法不符,工具這句是錯的(見下方「工具說錯的地方」)
  4. 外洩的資料是真的存在的——DEV 庫目前有 21 筆歷程紀錄、涵蓋 6 個輪次,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、匯出檔)。不過一旦拿到一個,就完全沒有第二道關卡——程式層沒有、資料庫層也沒有。


F4 — 階段資訊 API 用 A 專案的身分讀 B 專案的資料(MEDIUM,可信度 高)

這是什麼問題(白話)

這支 API 負責畫面上方那條橫幅。它同時收兩個編號,用你填的專案編號去查「你在這個專案是什麼角色」,卻用你填的輪次編號去撈資料——而且不比對這兩者是否相配。

更關鍵的是:角色查出來不符,程式也不擋。它只是把回傳結果裡的「可否推進」標成 false,然後照樣把整包階段資訊回給你。

在哪裡

  • app/flow_engine/service/stage_advance_service.py:81-188(get_current_stage_info)
  • 第 84 行 _resolve_workflow_context(round_uid) — 只用輪次編號撈輪次與流程(該函式在 :529-549)
  • 第 118 行 _get_user_role(project_uid, user_id) — 只用專案編號查角色(該函式在 :578-587)
  • 第 120 行把兩者的結果組成 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)。


F8 — 推進/退回的操作者署名可以自己填(LOW,可信度 中)

這是什麼問題(白話)

推進與退回的請求裡有一個叫 ctx 的欄位,是完全開放的自由字典(api/flow_engine/serializers/stage_advance.py:12:ctx = fields.Dict,沒有任何 key 限制)。後端用的是「呼叫端沒填才補上真名」的寫法:

ctx.setdefault("user_nickname", user_ctx.nickname)   # stage_advance_route.py:59

setdefault 的意思是**「只有在這個 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:59
  • api/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。


5. 派工卡上的疑點:逐項回應

卡片「重點看什麼」列了 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 也沒開。


① 專案編號 vs 輪次編號:讀得到,但改不了

成立的部分(讀):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),這個檢查器的設計就是提醒而非守門——

  • 它的 docstring 明寫「軟提醒非硬擋」
  • 它本身就是 fail-open(拿不到資料就跳過該項,不擋推進)
  • 它回傳 warning 時,正常流程是讓使用者在畫面上按「確認」再送一次 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),但不需要開修正卡。


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

分兩層講,這兩件事不一樣:

第一層:「報出來的這些,是真的嗎?」——可信度高

  • 三個獨立檢查員對 11 條候選各投一票,33 票全數投出、沒有漏投,驗證章 verified
  • 範圍內的 4 條發現,首腦全部自己開檔核對過,並實際連 DEV 資料庫做唯讀查詢驗證(RLS 狀態、實際外洩的資料內容)
  • 而且反向也驗了:三條被否決的候選(都主張「可以跨專案改資料」),首腦獨立追了一次完整攻擊路徑,逐一開檔核對 7 條寫入路徑的第二道檢查,結論與檢查員一致
  • 核對過程中抓到工具一個錯誤(見 F5 的「工具說錯的地方」):它宣稱 compliance.projects 有 RLS 保護,實查沒有。已在報告中更正

第二層:「是不是只有這些?」——不保證

  • 本次是 low effort,一位研究員掃全範圍(工具實際派了 2 位、2 位都回報),不是逐檔逐類的窮舉
  • 零發現不等於乾淨。本棒有明確「讀過但判定沒問題」的項目(卡片疑點 ⑤⑥⑦),也有「工具沒特別報、首腦也沒逐行看」的部分——例如 stage_object_* 那一串檔案(8 支,屬階段定義的讀取鏈),工具與首腦都沒在上面發現問題,但沒有做到逐行確認
  • 本範圍是第一次掃,沒有歷史基準可以對照

7. 執行概況(工程師看的,數字照 stamp 實抄)

項目 數值
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 空回報)
  • Run ID:wf_1bb7fb14-b17
  • 工具產出:CLAUDE-SECURITY-20260912-073625/(含 .jsonl/.sarif/stamp)
  • 被否決的候選:3 條(F9/F10/F11),全部主張「可跨專案改資料」,三票全否,首腦複核同意——理由見 §5①

8. 建議的後續處理

建議 內容 優先序
開修正卡 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 張有租戶欄的表)之外的死角。同類型的表可能還有,建議母卡收口時單獨盤一次 提給首腦判斷