範圍:10 檔/1,939 行,跨兩個 repo。套件側
jedi-compliance-audit8 檔(服務本體audit_round_app_service.py,加上輪次表的 entity/查詢 entity/repo 介面與實作/domain service/mapper/model);主專案側 2 檔(api/project/routes/audit_round_route.py、api/project/serializers/audit_round.py)。 掃描工具:Claude Code 官方claude-securityplugin,effort low,只看正式程式碼。兩側各掃一次,因為工具只認它所在那個 repo 的檔案。 掃描基準:套件側eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側d0b69c115970(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。 驗證章:兩次都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。
卡片點名的「兩支讀取沒守門」是真的,而且範圍比卡片寫的更大:主專案這支路由檔裡 8 個讀取網址全部只驗「有沒有登入」和「客戶有沒有買稽核模組」,不驗「你是不是這個專案的人」。 同一家客戶的任何員工,只要拿到一個專案編號,就能順著「輪次清單 → 稽核發現矩陣 → 風險 → 改善計畫 → 稽核團隊聯絡方式」一路讀完別人專案的全部稽核結果。
這幾條大多已經登記在總表上(第 52、57 項),修法也寫好了(CM-2037),但兩個 repo 的修法都還在 fix/security-b1 分支,沒有合回 feature/review。第 7 節有一個兩側接起來才看得到的狀況:開發機現在跑的是「套件已修、主專案沒修」的混搭,守門因此形同虛設。
本棒新增一條總表沒有的:專案經理可以改寫、再刪掉稽核人員寫的「改善建議」(C2a-6),修正分支也沒有補。
卡片點名的三種回退查證後不是洞:兩道角色檢查疊在一起,拿 A 專案的經理身分退不了 B 專案的輪次(第 5 節)。
「稽核輪次」是整個稽核流程的骨架:一個專案可以跑好幾輪稽核,每一輪會經過「規劃 → 啟動稽核 → 稽核中 → 結案 → 覆核」這些階段。這一棒要回答:
背景先說明:這支套件把權限守在「服務層」(_check_role 這類函式),網址那層只掛登入檢查和商務授權,這是本專案允許的正規做法。所以這裡找的不是「哪個檔沒寫守門」,而是「哪一支對外的方法漏了檢查」。
開工前先算了一次:audit_round_app_service.py 有 12 支對外方法,守門關鍵字出現 12 次,但全部集中在 9 支寫入和一個 helper;三支讀取(list_rounds、get_round、list_stage_transitions)一次都沒有,和盤點檔 §3 的結果一致。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 哪一側發現 | 跟總表的關係 |
|---|---|---|---|---|---|---|
| C2a-1 | 輪次清單不問你是不是專案成員 | 同公司非成員看得到別人專案每一輪的名稱、狀態、建立者,還拿得到計畫、SSP、流程的內部編號,可以拿去打其他網址 | 同一客戶的登入帳號+知道專案編號 | 套件側 audit_round_app_service.py:211(list_rounds 起點);主專案側 audit_round_route.py:74(要把使用者編號傳下去) |
套件側掃描(面板 3:0,中) | =第 57 項,既有案、不另計 |
| C2a-2 | 單筆輪次只用輪次編號查,不驗專案歸屬 | 同上,看得到單一輪次的完整狀態與關聯編號 | 同上+知道輪次編號 | 套件側 :218(get_round 起點);主專案側 :85 |
套件側掃描(面板 3:0,降為輕) | 屬第 57 項同一批修法(CM-2037 已涵蓋),不另計 |
| C2a-3 | 階段歷程完全不守門,路由連商務授權都沒掛 | 看得到經理填的每一次「退回理由」原文與操作人 | 同一客戶的登入帳號+知道輪次編號 | 套件側 :870(list_stage_transitions 起點);主專案側 api/flow_engine/routes/stage_rollback_route.py:56(get 起點) |
套件側掃描(面板 3:0,降為輕) | =第 52 項,既有案、不另計。細節歸 C2b |
| C2a-4 | 稽核發現、風險、改善計畫、AP 內容這幾個讀取網址都不驗專案成員 | 看得到別人專案逐條控制項的「符合/不符合」、缺失描述、風險等級、整改負責人 | 同一客戶的登入帳號+知道輪次編號(C2a-1 就拿得到) | 主專案側 audit_round_route.py:163、:258、:333、:419、:429 各個 get 起點 |
主專案側掃描(面板 3:0,中) | 屬第 57 項同一批修法(CM-2037 已涵蓋),不另計。稽核結果/改善計畫的服務本體歸 C3 |
| C2a-5 | 稽核團隊名單只用 AP 編號查,不驗專案歸屬 | 看得到稽核人員的姓名、Email、電話 | 同一客戶的登入帳號+知道 AP 編號(C2a-1 就拿得到) | 主專案側 audit_round_route.py:234(ApPartiesRoute.get 起點);服務在 app/flow_control/service/assessment_plan_app_service.py 的 list_ap_parties |
主專案側掃描(面板 3:0,降為輕) | 屬第 57 項同一批修法(CM-2037 已涵蓋),不另計;「跨客戶也打得到」這一點見 4.5 |
| C2a-6 | 專案經理可以改寫、再刪掉稽核人員寫的「改善建議」 | 受稽核的一方能把稽核方的建議改掉或整筆刪掉,破壞稽核獨立性與佐證軌跡 | 必須是專案經理,且輪次在「整改中」階段 | 主專案側 audit_round_route.py:440(RiskRemediationsRoute.post 起點);套件側 poam_app_service.py:310(add_remediation 起點) |
主專案側掃描(面板 2:1,輕) | 新的,總表沒有;修正分支也沒補。服務本體屬 C3 範圍 |
第 3 節表格六條都經過三人面板投票。 第 5、6、7 節是我自己開檔追出來的,沒有經過投票。
現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)
場景:甲是公司裡的一般員工,沒有被加進 P 專案。他從別處拿到 P 的專案編號(例如之前待過這個專案後被移出,或 AI 儀表板的專案清單會列出全公司專案),直接呼叫「列出稽核輪次」的網址,就拿到 P 專案每一輪的名稱、狀態、類型、建立者,還有 AP、SSP、流程執行的內部編號。有了這些編號,他可以接著打 C2a-2、C2a-4、C2a-5 的網址。
為什麼會這樣:套件的 list_rounds 只把專案編號換成內部 id,就把所有輪次撈出來回傳,沒有呼叫 _check_role,也沒有其他任何成員檢查。主專案路由只掛 @jwt_required(有登入)和 @require_license("audit")(客戶有買稽核模組)。資料庫隔離在 DEV 有開,但規則只檢查「上層專案是不是你這家客戶的」(見第 6 節),同一家客戶跨專案擋不住。
該補的位置:套件側 audit_round_app_service.py:211 方法起點補成員檢查;主專案側 audit_round_route.py:74 要把 user.id 傳下去。
面板:3 票全數認定成立,嚴重度三票裡兩票評中、一票評輕,定案是中。
現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)
場景:同一個非成員拿到某個輪次編號(C2a-1 就拿得到;流程執行的名字 ROUND-<輪次編號>-main 也會露出來),呼叫「查看輪次」的網址,拿到這一輪的狀態、AP/SSP/稽核結果/改善計畫的關聯、母輪編號和建立者。
為什麼會這樣:get_round 用 get_by_uid(round_uid) 撈出輪次就直接轉成回應。repo 的 get_by_uid(套件側 project_audit_round_repo_impl.py:29)只用編號查,這是 repo 的正常寫法,問題在服務層沒補歸屬檢查。
該補的位置:套件側 :218 方法起點;主專案側 audit_round_route.py:85。
面板:3:0 成立,三票都評輕,嚴重度從中降為輕(只露出狀態與編號,看不到稽核內容本身)。
現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)
場景:非成員呼叫「階段歷程」網址,拿到這一輪每一次推進和退回的紀錄,包含經理親手寫的退回理由原文、操作人的帳號和暱稱。網址上的專案編號完全被忽略,隨便填都行。
為什麼會這樣:服務層 list_stage_transitions 沒有任何檢查。主專案路由 StageRollbackResource/RoundStageTransitionsResource 只掛 jwt_required,連 require_license("audit") 都沒掛。歷程表在資料庫完全沒有隔離(第 6 節)。
面板:3:0 成立,降為輕。總表第 52 項已登記,這裡不另計。「歷程表只用輪次 id 查」的 repo 細節由 C2b 接著看。
現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)
場景:甲接著 C2a-1 拿到的輪次編號,依序呼叫:
/audit-round/<輪次>/ar/findings:逐條控制項的符合/不符合矩陣/ar/risks:系統風險與嚴重度/poam-items、/poam-item/<項目>:改善計畫、整改負責人、里程碑/audit-round/<輪次>/ap:稽核計畫內容這幾份資料加起來,等於一張「這家公司哪裡資安做不好」的地圖。
為什麼會這樣:主專案路由檔裡這 5 個 GET 都不傳使用者編號,底下的服務方法也不驗成員;同一批服務的寫入方法都有驗。
該補的位置:主專案側 audit_round_route.py 以下各 get 方法起點::163(AP 內容)、:258(發現)、:333(風險)、:419(改善計畫列表)、:429(改善計畫單筆)。服務本體的修法歸 C3 那一棒。
面板:3:0 成立,三票都評中。
現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)
場景:非成員從 C2a-1 拿到 ap_uid,呼叫 GET /ap/<ap_uid>/parties,拿到稽核團隊每個人的姓名、Email、電話。
為什麼會這樣:list_ap_parties 用 AP 編號直接查 OSCAL 的 AP 表和人員表,不驗任何角色或成員。這條路完全沒有經過有隔離的資料表:出貨版 scripts/init/02-schema.sql 在 OSCAL 這一區只對幾張「解析工作」表開了隔離,AP 表和人員表都沒有。所以理論上,別家客戶的 AP 編號一旦外流(日誌、匯出的 OSCAL 檔、分享連結),跨客戶也打得到。AP 編號是隨機 UUID 猜不到,這是面板把它降為輕的主因。
該補的位置:主專案側 audit_round_route.py:234(ApPartiesRoute.get 起點)。
面板:3:0 成立,降為輕。
現況:已修(M12-6,FR-114 CM-2174,commit BE a8f1a24b7/套件 b7022b8b,1.21.0 出貨)
場景:輪次進入「整改中」後,稽核人員會在每個風險底下留一筆「改善建議」(系統裡標成 lifecycle = recommendation),系統規定這筆不能刪。專案經理(也就是受稽核的一方)先從改善計畫明細拿到這筆建議的編號,再呼叫「新增整改計畫」網址,送出 {uuid: <那筆建議的編號>, lifecycle: "planned", title: "x", description: "x"}。系統把它當成「更新既有那筆」,稽核人員寫的建議內容就被改掉了,標記也變成一般整改計畫。接著經理呼叫刪除,這時「建議不可刪」的檢查已經不成立,整筆刪掉。
為什麼會這樣:主專案 serializer(audit_round.py:211-212)的 uuid 和 lifecycle 都是自由字串、沒有限制值域;路由原樣傳給套件 add_remediation,底下 upsert_remediation 只要編號對得上就覆蓋。刪除時的保護只看「現在的 lifecycle 是不是 recommendation」。
為什麼是輕、又為什麼有一票反對:攻擊者必須本來就是這個專案的經理,不是外人。反對那一票(影響面)的理由是「經理本來就有權改整改計畫」。另外兩票認為,稽核建議屬於稽核方的紀錄,受稽核方不該能改。這一點牽涉產品規則,放進第 10 節請首腦裁決。
該補的位置:主專案側 audit_round_route.py:440(RiskRemediationsRoute.post 起點);套件側 poam_app_service.py:310(add_remediation 起點)。修正分支 fix/security-b1 對這一段沒有任何改動(兩個 repo 我都開檔對照過)。
面板:2:1 成立,評輕,信心中等。
同一支服務的 9 支寫入都有 _check_role,三支讀取都沒有。修正分支 fix/security-b1 的套件版本已經新增 _require_participant(「任一專案成員都可讀,不限角色」),補在 list_rounds/get_round/list_stage_transitions,證明這是漏掉的,不是刻意開放。
回退的網址是 POST /project/<專案>/audit-round/<輪次>/stage/rollback,路徑上有兩道關:
app/flow_engine/service/stage_rollback_service.py 的 rollback_stage,先用網址上的專案編號查「你在這個專案是不是經理」,不是就擋。rollback_to_*,套件再用輪次自己的專案 id(e.project_id)做一次 _check_role(..., "manager")。所以「我在 A 專案是經理,網址填 A 專案、輪次填 B 專案的」會在第二道被擋下。「檢查甲、動手改乙」在這裡不成立。
小瑕疵(不是資安問題):網址上的專案和輪次對不上時,第一道不會發現,要到第二道才擋;另外回退路由也沒掛 require_license("audit")。
逐支開檔核對:
| 方法 | 用哪個專案 id 檢查 | 要什麼角色 | 結果 |
|---|---|---|---|
create_round |
網址專案編號換出來的 id | 經理 | ✅ |
launch_audit |
輪次自己的 project_id |
經理 | ✅ |
start_auditing |
輪次自己的 project_id |
稽核員或經理 | ✅ |
finalize_audit |
輪次自己的 project_id |
稽核員或經理 | ✅ |
close_round |
輪次自己的 project_id |
經理 | ✅ |
launch_reverify |
母輪自己的 project_id |
稽核員或經理 | ✅ |
每一支都是「先拿輪次,再用輪次本身的專案檢查,然後才動這一輪」,檢查的和改的是同一筆,沒有「檢查甲、改乙」。
套件 create_round 接受 flow_template_uid 參數,看起來可以指定任意範本。但主專案路由(audit_round_route.py:61-65)根本沒有把這個參數傳下去,serializer 也沒有這個欄位,外部送不進來。另外範本表在 DEV 有隔離,只看得到系統範本和自家範本。
launch_reverify → _generate_reverify_prep_jobs 用的專案 id、流程範本全部取自母輪與伺服器端查詢,請求內容只帶得進輪次名稱。任務產生器 prep_job_generation_service.py 本身歸 C2b 那一棒。
| 表 | DEV 隔離開了嗎 | 規則在擋什麼 | 出貨版 02-schema.sql |
|---|---|---|---|
compliance.project_audit_rounds(輪次) |
開 | 「上層專案這家客戶看得到」,不看專案成員 | 沒開,一條規則都沒有 |
compliance.round_stage_transitions(歷程) |
關,0 條規則 | 無 | 沒開 |
compliance.round_rollback_supersessions(回退標記) |
關,0 條規則 | 無 | 沒開 |
compliance.projects(專案) |
開 | 客戶範圍 | 開 |
白話說明:
get_round/list_stage_transitions 是直接用輪次編號查、不先查專案的,所以在新客戶那邊,這兩支靠輪次編號就能跨客戶讀。唯一的門檻是輪次編號是隨機 UUID,得先外流才打得到。jedi_compliance_audit.pth,把套件指到 jedi-python-package/.claude/worktrees/jedi-wt-fix-security/,也就是修正分支 fix/security-b1 的套件(版號 1.2.0)。pyproject.toml 鎖的還是 ==1.1.3。list_rounds(project_uid, curr_user_id=None),裡面是 if curr_user_id is not None: 才檢查。feature/review 的路由沒有傳 curr_user_id(修正版的路由在 fix/security-b1,還沒合回來)。結果是:開發機上套件「看起來有守門」,但因為主專案沒傳使用者編號,守門一次都不會觸發。手測時容易以為修好了。
這個「選填參數沒傳就不檢查」的寫法本身是陷阱:以後任何一個新呼叫端忘記傳,就會安靜地放行。主專案側掃描的面板理由也提了同一點,建議改成必填。
現況:已合回並隨 1.21.0 出貨(M12-1);以下為掃描當時的狀態。
cd9223592(CM-2037)→ git merge-base --is-ancestor 確認不在 feature/review。f513ae88(CM-2037)→ 在 fix/security-b1,也不在主 checkout 的 feature/review。「工具報的六條存在嗎」:可信度高。兩次掃描共 18 票全數投出。六條裡五條是 3:0 一致成立,一條(C2a-6)2:1 成立。三條被面板從中降為輕。
「第 5、6、7 節」:runner 自行開檔核對,未經三人面板投票。關鍵事實都實查過:
stage_rollback_service.py 與套件 rollback_to_*。pg_class/pg_policies(12:49,唯讀交易、ROLLBACK);出貨版 grep 02-schema.sql。.venv 裡的 .pth 檔。git merge-base --is-ancestor。「只有這些嗎」:不保證。
| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 8 檔/1,192 行 | 2 檔/747 行 |
| 基準 commit | eafc7ae511d5(dirty) |
d0b69c115970(dirty) |
| 檔位 | effort low,只看正式程式碼 | 同左 |
| 研究員 | 派 2 支,回 2 支 | 派 2 支,回 2 支 |
| 候選 | 3 條,去重後 3 條 | 3 條,去重後 3 條 |
| 投票 | 9 票全數投出 | 9 票全數投出 |
| 票型 | F1 3:0 中;F2 3:0 輕(原中);F3 3:0 輕(原中) | F1 3:0 中;F2 3:0 輕(原中);F3 2:1 輕 |
| 驗證章 | verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) |
verified(CLAUDE-SECURITY-REVISION-d0b69c115970-dirty.json) |
| 工具 run ID | wf_8a12239a-d77 |
wf_641b7e70-971 |
| 耗時 | 約 14 分鐘(11 個 agent,零失敗,1 個回傳空結果) | 約 19 分鐘(11 個 agent,零失敗,1 個回傳空結果) |
| 工具原始報告 | 套件 repo CLAUDE-SECURITY-20260924-044701/(未入版控) |
BE repo CLAUDE-SECURITY-20260924-050420/(未入版控) |
工具報告裡的 F 編號對到本報告:套件側 F1=C2a-1、F2=C2a-2、F3=C2a-3;主專案側 F1=C2a-4、F2=C2a-5、F3=C2a-6。
.pth 指向修正分支、主專案卻還在舊分支,要不要提醒手測人員(7.1)。在 fix/security-b1 合回之前,開發機手測「輪次讀取守門」會得到錯的結論。require_license("audit"),併進第 52 項的修正卡(5.2 小瑕疵)。